chore: initial commit

This commit is contained in:
Anna 2020-10-07 06:44:26 -04:00
commit 7a58e54b19
106 changed files with 4614 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx

1
.idea/.name Normal file
View File

@ -0,0 +1 @@
XIVChat

View File

@ -0,0 +1,158 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="LINE_SEPARATOR" value="&#10;" />
<option name="RIGHT_MARGIN" value="0" />
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="java.util" alias="false" withSubpackages="false" />
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
</option>
<option name="PACKAGES_IMPORT_LAYOUT">
<value>
<package name="" alias="false" withSubpackages="true" />
<package name="java" alias="false" withSubpackages="true" />
<package name="javax" alias="false" withSubpackages="true" />
<package name="kotlin" alias="false" withSubpackages="true" />
<package name="" alias="true" withSubpackages="true" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="WRAP_EXPRESSION_BODY_FUNCTIONS" value="0" />
<option name="WRAP_ELVIS_EXPRESSIONS" value="0" />
<option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="JAVA">
<option name="KEEP_CONTROL_STATEMENT_IN_ONE_LINE" value="false" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
</codeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="CALL_PARAMETERS_WRAP" value="0" />
<option name="METHOD_PARAMETERS_WRAP" value="0" />
<option name="EXTENDS_LIST_WRAP" value="0" />
<option name="METHOD_CALL_CHAIN_WRAP" value="0" />
<option name="ASSIGNMENT_WRAP" value="0" />
<option name="WRAP_ON_TYPING" value="0" />
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

21
.idea/gradle.xml Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="PLATFORM" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="1.8" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -0,0 +1,8 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ControlFlowStatementWithoutBraces" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="ImplicitThis" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="RedundantCompanionReference" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

35
.idea/jarRepositories.xml Normal file
View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://dl.bintray.com/terl/lazysodium-maven" />
</remote-repository>
<remote-repository>
<option name="id" value="maven2" />
<option name="name" value="maven2" />
<option name="url" value="https://dl.bintray.com/korlibs/korlibs/" />
</remote-repository>
</component>
</project>

9
.idea/misc.xml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

86
app/build.gradle Normal file
View File

@ -0,0 +1,86 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 30
buildToolsVersion "30.0.2"
defaultConfig {
applicationId "io.annaclemens.xivchat"
minSdkVersion 26
targetSdkVersion 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
}
}
dependencies {
ext.commonmark_version = '0.15.2'
ext.markwon_version = '4.6.0'
implementation fileTree(dir: "libs", include: ["*.jar"])
// default kotlin android stuff
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.1'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'com.google.android.material:material:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
implementation 'androidx.navigation:navigation-fragment:2.3.0'
implementation 'androidx.navigation:navigation-ui:2.3.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.0'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.0'
// preference ui objects
implementation "androidx.preference:preference:1.1.1"
implementation "androidx.preference:preference-ktx:1.1.1"
// libsodium for android
implementation 'com.goterl.lazycode:lazysodium-android:4.2.0@aar'
implementation 'net.java.dev.jna:jna:5.6.0@aar'
// kotlin serialisation (for xivapi)
implementation "org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0-RC"
// msgpack for xivchat protocol
// implementation 'com.daveanthonythomas.moshipack:moshipack:1.0.1'
implementation 'org.msgpack:msgpack-core:0.8.21'
// ktor is coroutines stuff for connection thread
implementation "io.ktor:ktor-network:$ktor_version"
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-android:$ktor_version"
// for pull-out nav profile pic
implementation 'de.hdodenhof:circleimageview:3.1.0'
// for markdown mode
implementation "io.noties.markwon:core:$markwon_version"
implementation "io.noties.markwon:ext-strikethrough:$markwon_version"
implementation "com.atlassian.commonmark:commonmark-ext-autolink:$commonmark_version"
// database stuff
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
androidTestImplementation "androidx.room:room-testing:$room_version"
// tests
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

37
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,37 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# for libsodium
-dontwarn java.awt.*
-keep class com.sun.jna.* { *; }
-keepclassmembers class * extends com.sun.jna.* { public *; }
# for kotlin serialisation
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class io.annaclemens.xivchat.**$$serializer { *; } # <-- change package name to your app's
-keepclassmembers class io.annaclemens.xivchat.** { # <-- change package name to your app's
*** Companion;
}
-keepclasseswithmembers class io.annaclemens.xivchat.** { # <-- change package name to your app's
kotlinx.serialization.KSerializer serializer(...);
}

View File

@ -0,0 +1,22 @@
package io.annaclemens.xivchat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("io.annaclemens.xivchat", appContext.packageName)
}
}

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.annaclemens.xivchat">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ui.servers.NewServerActivity" />
<service
android:name=".ConnectionService"
android:exported="false">
<intent-filter>
<action android:name="io.annaclemens.xivchat.DISCONNECT"/>
</intent-filter>
</service>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,462 @@
package io.annaclemens.xivchat
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Binder
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.Process
import android.os.ResultReceiver
import androidx.activity.viewModels
import androidx.lifecycle.MutableLiveData
import androidx.navigation.findNavController
import com.goterl.lazycode.lazysodium.utils.Key
import io.annaclemens.xivchat.model.Availability
import io.annaclemens.xivchat.model.ClientMessage
import io.annaclemens.xivchat.model.Ping
import io.annaclemens.xivchat.model.PlayerData
import io.annaclemens.xivchat.model.ServerMessage
import io.annaclemens.xivchat.model.ServerOperation
import io.annaclemens.xivchat.ui.messages.MessagesViewModel
import io.annaclemens.xivchat.ui.servers.ServersViewModel
import io.annaclemens.xivchat.util.AlertDialogFragment
import io.annaclemens.xivchat.util.AppKeyExchange
import io.annaclemens.xivchat.util.CharacterSearchResults
import io.annaclemens.xivchat.util.SecretMessage
import io.annaclemens.xivchat.util.TrustDialog
import io.annaclemens.xivchat.util.toHexString
import io.ktor.client.*
import io.ktor.client.engine.android.*
import io.ktor.client.request.*
import io.ktor.network.selector.*
import io.ktor.network.sockets.*
import io.ktor.util.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.Runnable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.channels.produce
import kotlinx.coroutines.channels.sendBlocking
import kotlinx.coroutines.channels.ticker
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.nio.ByteBuffer
class ConnectionService : Service() {
private lateinit var notif: NotificationManager
private var runner: ConnectionRunner? = null
private val binder: IBinder = LocalBinder(this)
private val handler = Handler(Looper.getMainLooper())
private val receiver = ConnectionBroadcastReceiver(this)
lateinit var app: MainActivity
val messages = Channel<ClientMessage>(Channel.UNLIMITED)
var bound = false
val connecting: MutableLiveData<Boolean> = MutableLiveData(false)
class LocalBinder(private val _service: ConnectionService) : Binder() {
fun getService(): ConnectionService {
return this._service
}
}
class ConnectionBroadcastReceiver(private val service: ConnectionService) : BroadcastReceiver() {
companion object {
const val DISCONNECT = "io.annaclemens.xivchat.DISCONNECT"
}
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != Companion.DISCONNECT) {
return
}
this.service.disconnect()
}
}
override fun onCreate() {
super.onCreate()
this.notif = this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
this.registerReceiver(this.receiver, IntentFilter(ConnectionBroadcastReceiver.DISCONNECT))
}
override fun onDestroy() {
super.onDestroy()
this.runner?.job?.cancel()
this.stopForeground(true)
this.unregisterReceiver(this.receiver)
if (!this.bound) {
return
}
val serversVm: ServersViewModel by this.app.viewModels()
serversVm.connectionService.value = null
}
private fun makeNotification(): Notification {
val title = this.getText(R.string.service_notification)
val content = this.getText(R.string.service_message)
val openIntent = Intent(this, MainActivity::class.java)
.putExtra(MainActivity.EXTRA_DESTINATION, R.id.nav_messages)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val intent = PendingIntent.getActivity(this, 0, openIntent, PendingIntent.FLAG_UPDATE_CURRENT)
val disconnectIntent = Intent(ConnectionBroadcastReceiver.DISCONNECT)
val disconnectPending = PendingIntent.getBroadcast(this, 0, disconnectIntent, 0)
val disconnectIcon = Icon.createWithResource(this, R.drawable.cloud_off_black_24dp)
val disconnectAction = Notification.Action.Builder(disconnectIcon, this.getString(R.string.notif_disconnect), disconnectPending)
.build()
this.notif.createNotificationChannel(NotificationChannel("xivchat", "XIVChat", NotificationManager.IMPORTANCE_DEFAULT))
return Notification.Builder(this, "xivchat")
.setSmallIcon(R.drawable.logo_no_background)
.setTicker(title)
.setWhen(System.currentTimeMillis())
.setContentTitle(title)
.setContentText(content)
.setContentIntent(intent)
.setActions(disconnectAction)
.build()
}
override fun onBind(intent: Intent?): IBinder? {
return this.binder
}
fun connect(host: String, port: Int) {
this.runner = ConnectionRunner(this, host, port)
Thread(this.runner).start()
}
fun disconnect() {
this.runner?.disconnectChannel?.sendBlocking(Unit)
this.app.stopConnectionService()
}
class ConnectionRunner(private val service: ConnectionService, private val host: String, private val port: Int) : Runnable {
var job: Job? = null
private var lastPlayerPortrait: String? = null
val disconnectChannel = Channel<Unit>(1)
@SuppressLint("SetTextI18n")
@OptIn(ExperimentalCoroutinesApi::class, ObsoleteCoroutinesApi::class, KtorExperimentalAPI::class, InternalCoroutinesApi::class)
private suspend fun asyncRun() {
val runner = this
// let the ui know we're actively connecting
this.service.handler.post { this.service.connecting.value = true }
// connect with a timeout
val conn: Socket
try {
conn = withTimeout(10_000) {
aSocket(ActorSelectorManager(Dispatchers.IO))
.tcp()
.connect(runner.host, runner.port) {
this.keepAlive = true
}
}
} catch (ex: Exception) {
val dialog = AlertDialogFragment {
it
.setTitle(R.string.connect_failure)
.setMessage(ex.localizedMessage)
.setNeutralButton(android.R.string.ok, null)
}
runner.service.handler.post { this.service.app.queueDialog(dialog, "connection_error") }
// unbind the service
this.service.app.stopConnectionService()
return
} finally {
// let the ui know we're done connecting
this.service.handler.post { this.service.connecting.value = false }
}
val rxStream = conn.openReadChannel()
val txStream = conn.openWriteChannel(true)
// do the handshake with the server
val info = AppKeyExchange.clientHandshake(this.service.app.sodium, this.service.app.keyPair, rxStream, txStream)
val rx = Key.fromBytes(info.keys.rx)
val tx = Key.fromBytes(info.keys.tx)
// check if key is trusted for this host
val trusted: MutableSet<String> = HashSet(this.service.app.preferences.getStringSet("trusted:${this.host}", emptySet()) ?: emptySet())
if (!trusted.contains(info.remotePublicKey.toHexString())) {
// prompt user to verify trust in public key
val trustChannel = Channel<Boolean>()
val receiver = object : ResultReceiver(this.service.handler) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
when (resultCode) {
Activity.RESULT_OK -> trustChannel.sendBlocking(true)
Activity.RESULT_CANCELED -> trustChannel.sendBlocking(false)
else -> throw IllegalArgumentException("unexpected result code")
}
}
}
val dialog = TrustDialog.newInstance(
receiver,
info.remotePublicKey,
this.service.app.keyPair.publicKey.asBytes
)
this.service.handler.post {
this.service.app.queueDialog(dialog, TrustDialog.TAG)
}
// listen for rejection from user and stop if necessary
if (!trustChannel.receive()) {
conn.close()
this.service.app.stopConnectionService()
return
}
// add key to the trusted set
trusted.add(info.remotePublicKey.toHexString())
this.service.app.preferences
.edit()
.putStringSet("trusted:${this.host}", trusted)
.apply()
}
this.service.handler.post {
val vm: ServersViewModel by this.service.app.viewModels()
vm.connected.value = true
}
this.service.handler.post {
val vm: MessagesViewModel by this@ConnectionRunner.service.app.viewModels()
vm.addSystemMessage("Connected")
}
// conn was trusted, so navigate to messages page
this.service.handler.post {
this.service.app.findNavController(R.id.nav_host_fragment).navigate(R.id.nav_messages)
}
// start the foreground service
this.service.startForeground(1, this.service.makeNotification())
// create a producer for messages from the server
val messagesIn = CoroutineScope(Dispatchers.IO).produce {
while (!conn.isClosed) {
try {
this.send(SecretMessage.readSecretMessage(this@ConnectionRunner.service.app.sodium, rxStream, rx))
} catch (ex: ClosedReceiveChannelException) {
break
} catch (ex: Exception) {
ex.printStackTrace()
}
}
}
// create a ticker for pinging
val pingTicker = ticker(delayMillis = 30_000, initialDelayMillis = 30_000)
// start the event loop and continue until we're no longer connected or explicitly told
// to stop
var loop = true
while (!conn.isClosed && loop) {
select<Unit> {
// being told to disconnect
this@ConnectionRunner.disconnectChannel.onReceive {
loop = false
}
// receiving a message from the server
messagesIn.onReceiveOrClosed {
if (it.isClosed) {
loop = false
val dialog = AlertDialogFragment { dialog ->
dialog
.setTitle(R.string.unexpected_disconnect)
.setMessage(R.string.unexpected_disconnect_desc)
.setNeutralButton(android.R.string.ok, null)
}
this@ConnectionRunner.service.handler.post {
this@ConnectionRunner.service.app.queueDialog(dialog, "disconnected")
}
return@onReceiveOrClosed
}
@Suppress("NAME_SHADOWING")
val it = it.value
if (it.isEmpty()) {
return@onReceiveOrClosed
}
// get the payload of the message
val payload = ByteBuffer.wrap(it, 1, it.size - 1)
// check what operation this message is
when (ServerOperation.fromCode(it[0])) {
ServerOperation.Message -> {
// read the message payload and update the messages view model with it
// FIXME: handle parsing failure
val message = ServerMessage.read(payload)
runner.service.handler.post {
val vm: MessagesViewModel by this@ConnectionRunner.service.app.viewModels()
vm.addMessage(message)
}
}
ServerOperation.Pong -> {
// do nothing on pongs
}
ServerOperation.Shutdown -> loop = false
ServerOperation.Availability -> {
// parse the payload and tell the messages view model if the server is available
// FIXME: handle payload parsing failure
val availability = Availability.read(payload)
val vm: MessagesViewModel by this@ConnectionRunner.service.app.viewModels()
runner.service.handler.post { vm.available.value = availability.available }
}
ServerOperation.PlayerData -> {
// parse the payload and update the nav view with info and avatar
// FIXME: handle payload parsing failure
val playerData = PlayerData.read(payload)
val vm: ServersViewModel by this@ConnectionRunner.service.app.viewModels()
runner.service.handler.post {
vm.playerData.value = playerData
}
// only download pfp if it's different than the last successful download
val nameAndWorld = "${playerData.name}/${playerData.homeWorld}"
if (nameAndWorld != runner.lastPlayerPortrait) {
// this will run without blocking this coroutine
runner.downloadPlayerPortrait(playerData) { success ->
if (success) {
runner.lastPlayerPortrait = nameAndWorld
}
}
}
}
null -> {
// ignore invalid message types
}
}
}
// receiving a message going to the server
runner.service.messages.onReceive {
// encode the message and send it to the server
val bytes = it.encode()
// FIXME: handle write failure
SecretMessage.writeSecretMessage(this@ConnectionRunner.service.app.sodium, txStream, tx, bytes)
}
// receiving a ping tick
pingTicker.onReceive {
// send a ping message to the server
// FIXME: handle write failure
SecretMessage.writeSecretMessage(this@ConnectionRunner.service.app.sodium, txStream, tx, Ping.encode())
}
}
}
// close the connection after the event loop has ended
conn.close()
// let the ui know we're no longer connected
this.service.handler.post {
val vm: ServersViewModel by this@ConnectionRunner.service.app.viewModels()
vm.connected.value = false
vm.playerPortrait.value = null
vm.playerData.value = null
}
runner.lastPlayerPortrait = null
this.service.handler.post {
val vm: MessagesViewModel by this@ConnectionRunner.service.app.viewModels()
vm.addSystemMessage("Disconnected")
}
// stop the service, removing the notification
this.service.app.stopConnectionService()
}
private suspend fun downloadPlayerPortrait(playerData: PlayerData, after: suspend (Boolean) -> Unit) {
val runner = this
// create the http client and build the xivapi url for searching for this player
val client = HttpClient(Android)
val url = Uri.parse("https://xivapi.com/character/search")
.buildUpon()
.appendQueryParameter("name", playerData.name)
.appendQueryParameter("server", playerData.homeWorld)
.build()
// run this on the io dispatcher
CoroutineScope(Dispatchers.IO).launch {
val image: Bitmap?
try {
// download the search results and decode them into a utf-8 string
val jsonBytes: ByteArray = client.get(url.toString())
val json = jsonBytes.decodeToString()
// parse the search results into a class
val data: CharacterSearchResults = Json {
this.ignoreUnknownKeys = true
}.decodeFromString(json)
// if there were no results, return false (will try again on next zone change)
if (data.results.isEmpty()) {
after(false)
return@launch
}
// otherwise, get the avatar url from the first result and download the image bytes
val imageBytes: ByteArray = client.get(data.results[0].avatar)
// decode the image into a bitmap for android
image = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
} catch (ex: Exception) {
// if any error occurred, log it and try again on next zone change
ex.printStackTrace()
after(false)
return@launch
} finally {
// make sure we always close the client
client.close()
}
// if the image got downloaded and parsed and the app is set, tell the app to update the picture
if (image != null) {
runner.service.handler.post {
val vm: ServersViewModel by this@ConnectionRunner.service.app.viewModels()
vm.playerPortrait.value = image
}
// this was a success, so don't try again on next zone change
after(true)
return@launch
}
after(false)
}
}
override fun run() {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
this.job = CoroutineScope(Dispatchers.Default).launch {
this@ConnectionRunner.asyncRun()
}
}
}
}

View File

@ -0,0 +1,31 @@
package io.annaclemens.xivchat
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
import io.annaclemens.xivchat.model.SavedServer
import io.annaclemens.xivchat.model.SavedServerDao
@androidx.room.Database(entities = [SavedServer::class], version = 2, exportSchema = false)
abstract class Database : RoomDatabase() {
abstract fun serverDao(): SavedServerDao
companion object {
@Volatile
private var INSTANCE: Database? = null
fun getDatabase(context: Context): Database {
val tempInstance = this.INSTANCE
if (tempInstance != null) {
return tempInstance
}
synchronized(this) {
val instance = Room.databaseBuilder(context.applicationContext, Database::class.java, "xivchat")
.fallbackToDestructiveMigrationFrom(1)
.build()
this.INSTANCE = instance
return instance
}
}
}
}

View File

@ -0,0 +1,46 @@
package io.annaclemens.xivchat
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RectShape
import android.util.AttributeSet
import android.view.View
import io.annaclemens.xivchat.util.toColours
class KeyVisualisation(context: Context, attrs: AttributeSet) : View(context, attrs) {
private val drawables: MutableList<ShapeDrawable> = ArrayList()
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val heightPx = 16 * this.context.resources.displayMetrics.density
val height = MeasureSpec.makeMeasureSpec(heightPx.toInt(), MeasureSpec.EXACTLY)
super.onMeasure(widthMeasureSpec, height)
}
fun generateDrawables(bytes: ByteArray) {
this.drawables.clear()
val colours = bytes.toColours()
for (colour in colours) {
val drawable = ShapeDrawable(RectShape()).apply {
this.paint.color = colour.toInt()
}
this.drawables.add(drawable)
}
}
override fun onDraw(canvas: Canvas) {
val width = this.width
val height = this.height
if (this.drawables.isEmpty()) {
return
}
val chunkWidth = width / this.drawables.size
for (drawable in this.drawables.withIndex()) {
drawable.value.setBounds(drawable.index * chunkWidth, 0, (drawable.index + 1) * chunkWidth, height)
drawable.value.draw(canvas)
}
}
}

View File

@ -0,0 +1,217 @@
package io.annaclemens.xivchat
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.DialogFragment
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceManager
import com.google.android.material.navigation.NavigationView
import com.goterl.lazycode.lazysodium.LazySodiumAndroid
import com.goterl.lazycode.lazysodium.SodiumAndroid
import com.goterl.lazycode.lazysodium.interfaces.KeyExchange
import com.goterl.lazycode.lazysodium.utils.Key
import com.goterl.lazycode.lazysodium.utils.KeyPair
import io.annaclemens.xivchat.ui.servers.ServersViewModel
import io.annaclemens.xivchat.util.ConnectionServiceConnection
import io.annaclemens.xivchat.util.hexStringToByteArray
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.nav_header_main.view.*
// TODO: chat filters interface
class MainActivity : AppCompatActivity() {
companion object {
const val CONFIG_MARKDOWN_MODE = "markdownMode"
const val EXTRA_DESTINATION = "io.annaclemens.xivchat.EXTRA_DESTINATION"
}
val preferences: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(this)
private lateinit var appBarConfiguration: AppBarConfiguration
lateinit var sodium: LazySodiumAndroid
lateinit var keyPair: KeyPair
private var registered: Boolean = false
private val dialogs = mutableListOf<Pair<DialogFragment, String>>()
private var paused = false
var markdownMode: Boolean = false
get() = this.preferences.getBoolean(Companion.CONFIG_MARKDOWN_MODE, false)
set(value) {
this.preferences.edit().putBoolean(Companion.CONFIG_MARKDOWN_MODE, value).apply()
field = value
}
private val serviceConnection = ConnectionServiceConnection(this)
fun regenerateKeyPair() {
val keyPair = (this@MainActivity.sodium as KeyExchange.Lazy).cryptoKxKeypair()
this.preferences
.edit()
.putString("keyPairPublic", keyPair.publicKey.asHexString)
.putString("keyPairSecret", keyPair.secretKey.asHexString)
.apply()
this.keyPair = keyPair
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
val dest = intent?.getIntExtra(Companion.EXTRA_DESTINATION, 0) ?: 0
if (dest != 0) {
this.findNavController(R.id.nav_host_fragment).navigate(dest)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// set up libsodium first
this.sodium = LazySodiumAndroid(SodiumAndroid())
this.keyPair = this.preferences.run {
val public = this.getString("keyPairPublic", null)?.hexStringToByteArray()
val secret = this.getString("keyPairSecret", null)?.hexStringToByteArray()
val keyPair: KeyPair
if (public == null || secret == null) {
keyPair = (this@MainActivity.sodium as KeyExchange.Lazy).cryptoKxKeypair()
this.edit()
.putString("keyPairPublic", keyPair.publicKey.asHexString)
.putString("keyPairSecret", keyPair.secretKey.asHexString)
.apply()
} else {
keyPair = KeyPair(Key.fromBytes(public), Key.fromBytes(secret))
}
keyPair
}
// re-bind to service if necessary
this.bindConnectionService()
val serversVm: ServersViewModel by this.viewModels()
serversVm.connectionService.observe(this) {
if (it == null) {
this@MainActivity.bindConnectionService()
}
}
serversVm.playerData.observe(this) {
val header = this.nav_view.getHeaderView(0)
if (it == null) {
header.characterName.setText(R.string.nav_header_title)
header.characterLocation.text = ""
return@observe
}
@SuppressLint("SetTextI18n")
header.characterName.text = "${it.name} (${it.homeWorld})"
@SuppressLint("SetTextI18n")
header.characterLocation.text = "${it.location} (${it.currentWorld})"
}
serversVm.playerPortrait.observe(this) {
val header = this.nav_view.getHeaderView(0)
if (it == null) {
header.characterPortrait.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.account_circle_white_24dp))
} else {
header.characterPortrait.setImageBitmap(it)
}
}
// register receiver
this.setContentView(R.layout.activity_main)
val toolbar: Toolbar = this.findViewById(R.id.toolbar)
this.setSupportActionBar(toolbar)
val drawerLayout: DrawerLayout = this.findViewById(R.id.drawer_layout)
val navView: NavigationView = this.findViewById(R.id.nav_view)
val navController = this.findNavController(R.id.nav_host_fragment)
// Passing each menu ID as a set of Ids because each
// menu should be considered as top level destinations.
this.appBarConfiguration = AppBarConfiguration(
setOf(
R.id.nav_servers,
R.id.nav_messages,
R.id.nav_settings,
),
drawerLayout,
)
this.setupActionBarWithNavController(navController, this.appBarConfiguration)
navView.setupWithNavController(navController)
}
override fun onSupportNavigateUp(): Boolean {
val navController = this.findNavController(R.id.nav_host_fragment)
return navController.navigateUp(this.appBarConfiguration) || super.onSupportNavigateUp()
}
override fun onDestroy() {
super.onDestroy()
if (this.isFinishing) {
this.stopConnectionService()
} else {
this.unbindConnectionService()
}
}
private fun bindConnectionService(): Boolean {
if (this.registered) {
return false
}
this.registered = true
this.startService(Intent(this, ConnectionService::class.java))
return this.bindService(Intent(this, ConnectionService::class.java), this.serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun unbindConnectionService() {
if (!this.registered) {
return
}
this.unbindService(this.serviceConnection)
this.registered = false
}
fun stopConnectionService() {
// stop and unbind
this.stopService(Intent(this, ConnectionService::class.java))
this.unbindConnectionService()
}
fun queueDialog(dialogFragment: DialogFragment, tag: String) {
if (!this.paused) {
dialogFragment.show(this.supportFragmentManager, tag)
return
}
this.dialogs.add(dialogFragment to tag)
}
override fun onResume() {
super.onResume()
this.paused = false
for (entry in this.dialogs.reversed()) {
entry.first.show(this.supportFragmentManager, entry.second)
}
this.dialogs.clear()
}
override fun onPause() {
super.onPause()
this.paused = true
}
}

View File

@ -0,0 +1,150 @@
package io.annaclemens.xivchat.model
@OptIn(ExperimentalUnsignedTypes::class)
class ChatCode(val code: UInt) {
companion object {
private val CLEAR_7: UInt = (0u.inv() shl 7).inv()
}
fun source(): ChatSource? {
if (this.code <= Companion.CLEAR_7) {
return null
}
return this.sourceFrom(11)
}
fun target(): ChatSource? {
if (this.code <= Companion.CLEAR_7) {
return null
}
return this.sourceFrom(7)
}
private fun sourceFrom(shift: Int): ChatSource? {
val shifted = (this.code shr shift).toInt() and 0xF
val code = (1 shl shifted).toUShort()
return ChatSource.fromCode(code)
}
fun type(): ChatType? {
val type = this.code and Companion.CLEAR_7;
return ChatType.fromCode(type)
}
}
@OptIn(ExperimentalUnsignedTypes::class)
enum class ChatSource(val code: UShort) {
Self(2u),
PartyMember(4u),
AllianceMember(8u),
Other(16u),
EngagedEnemy(32u),
UnengagedEnemy(64u),
FriendlyNpc(128u),
SelfPet(256u),
PartyPet(512u),
AlliancePet(1024u),
OtherPet(2048u);
companion object {
private val map = values().associateBy(ChatSource::code)
fun fromCode(code: UShort) = this.map[code]
}
}
@OptIn(ExperimentalUnsignedTypes::class)
enum class ChatType(val code: UInt) {
Debug(1u),
Urgent(2u),
Notice(3u),
Say(10u),
Shout(11u),
TellOutgoing(12u),
TellIncoming(13u),
Party(14u),
Alliance(15u),
Linkshell1(16u),
Linkshell2(17u),
Linkshell3(18u),
Linkshell4(19u),
Linkshell5(20u),
Linkshell6(21u),
Linkshell7(22u),
Linkshell8(23u),
FreeCompany(24u),
NoviceNetwork(27u),
CustomEmote(28u),
StandardEmote(29u),
Yell(30u),
// 31 - also party?
CrossParty(32u),
PvpTeam(36u),
CrossLinkshell1(37u),
Damage(41u),
Miss(42u),
Action(43u),
Item(44u),
Healing(45u),
GainBuff(46u),
GainDebuff(47u),
LoseBuff(48u),
LoseDebuff(49u),
Alarm(55u),
Echo(56u),
System(57u),
BattleSystem(58u),
GatheringSystem(59u),
Error(60u),
NpcDialogue(61u),
LootNotice(62u),
Progress(64u),
LootRoll(65u),
Crafting(66u),
Gathering(67u),
NpcAnnouncement(68u),
FreeCompanyAnnouncement(69u),
FreeCompanyLoginLogout(70u),
RetainerSale(71u),
PeriodicRecruitmentNotification(72u),
Sign(73u),
RandomNumber(74u),
NoviceNetworkSystem(75u),
Orchestrion(76u),
PvpTeamAnnouncement(77u),
PvpTeamLoginLogout(78u),
MessageBook(79u),
GmTell(80u),
GmSay(81u),
GmShout(82u),
GmYell(83u),
GmParty(84u),
GmFreeCompany(85u),
GmLinkshell1(86u),
GmLinkshell2(87u),
GmLinkshell3(88u),
GmLinkshell4(89u),
GmLinkshell5(90u),
GmLinkshell6(91u),
GmLinkshell7(92u),
GmLinkshell8(93u),
GmNoviceNetwork(94u),
CrossLinkShell2(101u),
CrossLinkShell3(102u),
CrossLinkShell4(103u),
CrossLinkShell5(104u),
CrossLinkShell6(105u),
CrossLinkShell7(106u),
CrossLinkShell8(107u);
companion object {
private val map = values().associateBy(ChatType::code)
fun fromCode(code: UInt) = this.map[code]
}
fun isBattle() = when (this) {
Damage, Miss, Action, Item, Healing, GainBuff, LoseBuff, GainDebuff, LoseDebuff, BattleSystem -> true
else -> false
}
}

View File

@ -0,0 +1,29 @@
package io.annaclemens.xivchat.model
import org.msgpack.core.MessagePack
class ClientMessage(private val content: String) {
fun encode(): ByteArray {
val packer = MessagePack.newDefaultBufferPacker()
packer.packArrayHeader(1)
packer.packString(this.content)
packer.close()
val bytes = packer.toByteArray()
val buf = ByteArray(1 + bytes.size)
buf[0] = ClientOperation.Message.code
bytes.copyInto(buf, 1)
return buf
}
}
class Ping {
companion object {
fun encode(): ByteArray {
val bytes = ByteArray(1)
bytes[0] = ClientOperation.Ping.code
return bytes
}
}
}

View File

@ -0,0 +1,25 @@
package io.annaclemens.xivchat.model
enum class ServerOperation(val code: Byte) {
Pong(1),
Message(2),
Shutdown(3),
PlayerData(4),
Availability(5);
companion object {
private val map = values().associateBy(ServerOperation::code)
fun fromCode(code: Byte) = this.map[code]
}
}
enum class ClientOperation(val code: Byte) {
Ping(1),
Message(2),
Shutdown(3);
companion object {
private val map = values().associateBy(ClientOperation::code)
fun fromCode(code: Byte) = this.map[code]
}
}

View File

@ -0,0 +1,118 @@
package io.annaclemens.xivchat.model
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.RecyclerView
import androidx.room.*
import io.annaclemens.xivchat.R
import io.annaclemens.xivchat.ui.servers.ServersViewModel
import kotlinx.android.synthetic.main.saved_server.view.*
@Entity(tableName = "servers")
data class SavedServer(
@PrimaryKey(autoGenerate = true)
val id: Int,
val name: String,
val host: String,
val port: Int,
)
@Dao
interface SavedServerDao {
@Query("select * from servers order by `id` asc")
fun getAll(): LiveData<List<SavedServer>>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(server: SavedServer)
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun update(server: SavedServer)
@Delete
suspend fun delete(server: SavedServer)
}
class SavedServerRepository(private val dao: SavedServerDao) {
val allServers: LiveData<List<SavedServer>> = this.dao.getAll()
suspend fun insert(server: SavedServer) {
this.dao.insert(server)
}
suspend fun update(server: SavedServer) {
this.dao.update(server)
}
suspend fun delete(server: SavedServer) {
this.dao.delete(server)
}
}
class SavedServerAdapter internal constructor(context: Context, private val vm: ServersViewModel) : RecyclerView.Adapter<SavedServerAdapter.ViewHolder>() {
private val inflater = LayoutInflater.from(context)
private var servers = emptyList<SavedServer>()
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
lateinit var server: SavedServer
val name: TextView = this.itemView.name
val address: TextView = this.itemView.address
val indicator: ProgressBar = this.itemView.connectingIndicator
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val itemView = this.inflater.inflate(R.layout.saved_server, parent, false)
return this.ViewHolder(itemView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val current = this.servers[position]
holder.server = current
holder.name.text = current.name
@SuppressLint("SetTextI18n")
holder.address.text = "${current.host}:${current.port}"
holder.itemView.setOnClickListener {
val serv = this.vm.connectionService.value ?: return@setOnClickListener
if (serv.connecting.value == true || this.vm.connected.value == true) {
return@setOnClickListener
}
it.isClickable = false
holder.indicator.visibility = View.VISIBLE
val observer = object : Observer<Boolean> {
private var first = true
override fun onChanged(connecting: Boolean) {
if (this.first) {
this.first = false
return
}
if (!connecting) {
it.isClickable = true
holder.indicator.visibility = View.GONE
serv.connecting.removeObserver(this)
}
}
}
serv.connecting.observeForever(observer)
serv.connect(current.host, current.port)
}
}
internal fun setServers(servers: List<SavedServer>) {
this.servers = servers
this.notifyDataSetChanged()
}
override fun getItemCount() = this.servers.size
}

View File

@ -0,0 +1,125 @@
package io.annaclemens.xivchat.model
import android.content.Context
import io.annaclemens.xivchat.util.SeString
import io.annaclemens.xivchat.util.rgbaToArgb
import io.noties.markwon.Markwon
import org.msgpack.core.MessagePack
import org.msgpack.core.MessageUnpacker
import java.nio.ByteBuffer
import java.util.*
@OptIn(ExperimentalUnsignedTypes::class)
class ServerMessage(
val timestamp: Date,
val channel: UShort,
val sender: ByteArray,
val content: ByteArray,
val chunks: List<Chunk>,
) {
fun spans(markdown: Markwon?, context: Context?) = SeString.chunksToSpans(this.chunks, markdown, context)
fun code(): ChatCode = ChatCode(this.channel.toUInt())
companion object {
fun read(input: ByteBuffer): ServerMessage {
val unpacker = MessagePack.newDefaultUnpacker(input)
unpacker.unpackArrayHeader()
val rawTimestamp = unpacker.unpackLong()
val timestamp = Date(rawTimestamp * 1_000)
val channel = unpacker.unpackShort().toUShort()
val senderLen = unpacker.unpackBinaryHeader()
val sender = unpacker.readPayload(senderLen)
val contentLen = unpacker.unpackBinaryHeader()
val content = unpacker.readPayload(contentLen)
val chunksLen = unpacker.unpackArrayHeader()
val chunks = ArrayList<Chunk>(chunksLen)
for (i in 0 until chunksLen) {
chunks.add(Chunk.decode(unpacker))
}
return ServerMessage(timestamp, channel, sender, content, chunks)
}
}
}
abstract class Chunk {
companion object {
@OptIn(ExperimentalUnsignedTypes::class)
fun decode(unpacker: MessageUnpacker): Chunk {
unpacker.unpackArrayHeader()
val tag = unpacker.unpackByte()
unpacker.unpackArrayHeader()
return when (tag) {
ChunkType.Text.code -> TextChunk(
fallbackColour = if (unpacker.tryUnpackNil()) null else unpacker.unpackLong().toUInt(),
foreground = if (unpacker.tryUnpackNil()) null else unpacker.unpackLong().toUInt(),
glow = if (unpacker.tryUnpackNil()) null else unpacker.unpackLong().toUInt(),
italic = unpacker.unpackBoolean(),
content = unpacker.unpackString(),
)
ChunkType.Icon.code -> IconChunk(
index = unpacker.unpackByte(),
)
else -> throw IllegalArgumentException()
}
}
}
}
enum class ChunkType(val code: Byte) {
Text(1),
Icon(2),
}
@OptIn(ExperimentalUnsignedTypes::class)
data class TextChunk(
val fallbackColour: UInt?,
val foreground: UInt?,
val glow: UInt?,
val italic: Boolean,
val content: String,
) : Chunk() {
fun argb(): Long? {
val colour = this.foreground ?: this.fallbackColour ?: return null
return rgbaToArgb(colour.toLong())
}
}
data class IconChunk(val index: Byte) : Chunk()
class Availability(val available: Boolean) {
companion object {
fun read(input: ByteBuffer): Availability {
val unpacker = MessagePack.newDefaultUnpacker(input)
unpacker.unpackArrayHeader()
val available = unpacker.unpackBoolean()
return Availability(available)
}
}
}
data class PlayerData(val homeWorld: String, val currentWorld: String, val location: String, val name: String) {
companion object {
fun read(input: ByteBuffer): PlayerData {
val unpacker = MessagePack.newDefaultUnpacker(input)
unpacker.unpackArrayHeader()
val homeWorld = unpacker.unpackString()
val currentWorld = unpacker.unpackString()
val location = unpacker.unpackString()
val name = unpacker.unpackString()
return PlayerData(homeWorld, currentWorld, location, name)
}
}
}

View File

@ -0,0 +1,130 @@
package io.annaclemens.xivchat.ui.messages
import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod
import android.view.Gravity
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.annaclemens.xivchat.MainActivity
import io.annaclemens.xivchat.R
import io.annaclemens.xivchat.model.ServerMessage
import io.noties.markwon.Markwon
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
class MessagesAdapter(
private val app: MainActivity,
private val model: MessagesViewModel,
private val viewManager: RecyclerView.LayoutManager,
private val markdown: Markwon,
) : RecyclerView.Adapter<MessagesAdapter.ViewHolder>() {
class ViewHolder(val textView: TextView) : RecyclerView.ViewHolder(textView)
private val filteredMessages: MutableList<ServerMessage> = ArrayList()
init {
this.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
// FIXME: when scrolled up and a new message is added, it pushes messages
val vm = this@MessagesAdapter.viewManager
if (vm is LinearLayoutManager) {
if (vm.findFirstVisibleItemPosition() == 0) {
this@MessagesAdapter.viewManager.scrollToPosition(0)
}
}
}
})
// listen for new messages
var firstUse = true
this.model.messages.observe(this.app) {
if (firstUse) {
firstUse = false
return@observe
}
val added = it.lastOrNull() ?: return@observe
if (this.model.filter.allowed(added)) {
this.filteredMessages.add(added)
this.notifyItemInserted(0)
}
}
this.filterExisting()
}
fun filterExisting(clear: Boolean = false) {
if (clear) {
this.filteredMessages.clear()
}
// update filtered messages in batches on separate thread
val currentMessages = this.model.messages.value.orEmpty()
CoroutineScope(Dispatchers.Default).launch {
val chunks = currentMessages.chunked(100)
var idx = 0
for (chunk in chunks) {
val filtered = chunk.filter { this@MessagesAdapter.model.filter.allowed(it) }
if (filtered.isEmpty()) {
continue
}
this@MessagesAdapter.filteredMessages.addAll(idx, filtered)
this@MessagesAdapter.app.runOnUiThread {
this@MessagesAdapter.notifyItemRangeInserted(idx, filtered.size)
}
idx += filtered.size
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val textView = TextView(parent.context).apply {
// allow text to be selected
// FIXME: when this is set (specifically isFocusable), tapping on a textview will scroll the recyclerview
this.setTextIsSelectable(true)
// necessary to allow links to be clicked
this.movementMethod = LinkMovementMethod.getInstance()
// ffxiv special symbols
this.typeface = ResourcesCompat.getFont(parent.context, R.font.ffxiv)
// TODO: figure out why this has to be done and address its cause
this.setLineSpacing(0f, .75f)
// add some bottom padding because letters like "g" get cut off
// TODO: figure out a better solution
this.setPadding(0, 0, 0, this.lineHeight / 4)
this.gravity = Gravity.CENTER_VERTICAL
}
return ViewHolder(textView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
if (this.filteredMessages.isEmpty()) {
return
}
val message = this.filteredMessages.getOrNull(this.filteredMessages.size - position - 1) ?: return
holder.textView.text = this.processMessage(message)
}
private fun processMessage(msg: ServerMessage): CharSequence {
val dt = msg.timestamp.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()
val time = dt.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT))
// TODO: configurable colours
return SpannableStringBuilder()
.append('[')
.append(time)
.append(']')
.apply {
val markdownMode = this@MessagesAdapter.app.markdownMode
val content = msg.spans(if (markdownMode) this@MessagesAdapter.markdown else null, this@MessagesAdapter.app)
this.append(content)
}
}
override fun getItemCount() = this.filteredMessages.size
}

View File

@ -0,0 +1,176 @@
package io.annaclemens.xivchat.ui.messages
import android.content.SharedPreferences
import android.os.Bundle
import android.view.*
import android.view.inputmethod.EditorInfo
import android.widget.SearchView
import androidx.activity.viewModels
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.annaclemens.xivchat.MainActivity
import io.annaclemens.xivchat.R
import io.annaclemens.xivchat.model.ClientMessage
import io.annaclemens.xivchat.ui.servers.ServersViewModel
import io.annaclemens.xivchat.util.XivChatMarkdownPlugin
import io.annaclemens.xivchat.util.getApp
import io.noties.markwon.Markwon
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import kotlinx.android.synthetic.main.fragment_messages.*
import kotlinx.android.synthetic.main.fragment_messages.view.*
import kotlinx.coroutines.launch
class MessagesFragment : Fragment() {
private lateinit var messagesViewModel: MessagesViewModel
private lateinit var viewAdapter: MessagesAdapter
private lateinit var viewManager: RecyclerView.LayoutManager
private lateinit var markdownModeListener: SharedPreferences.OnSharedPreferenceChangeListener
private lateinit var connectedObserver: Observer<Boolean>
override fun onResume() {
super.onResume()
this.viewAdapter.filterExisting(clear = true)
this.viewAdapter.notifyDataSetChanged()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
// Inflate the menu; this adds items to the action bar if it is present.
inflater.inflate(R.menu.messages_menu, menu)
val search = menu.findItem(R.id.app_bar_search).actionView as SearchView
search.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return this.onQueryTextChange(query)
}
override fun onQueryTextChange(newText: String?): Boolean {
this@MessagesFragment.messagesViewModel.filter.filterString = newText
this@MessagesFragment.viewAdapter.filterExisting(clear = true)
this@MessagesFragment.viewAdapter.notifyDataSetChanged()
return true
}
})
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_disconnect -> {
val serversVm: ServersViewModel by this.getApp().viewModels()
serversVm.connectionService.value?.disconnect()
}
R.id.action_toggle_battle -> {
this.messagesViewModel.filter.filterBattle = !this.messagesViewModel.filter.filterBattle
this.viewAdapter.filterExisting(clear = true)
this.viewAdapter.notifyDataSetChanged()
}
else -> {
return false
}
}
return true
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
this.messagesViewModel = ViewModelProvider(this.requireActivity() as MainActivity).get(MessagesViewModel::class.java)
val root = inflater.inflate(R.layout.fragment_messages, container, false)
this.setHasOptionsMenu(true)
this.viewManager = LinearLayoutManager(this.context).apply {
// this.stackFromEnd = true
this.reverseLayout = true
}
val markdown = Markwon.builder(this.requireContext())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(XivChatMarkdownPlugin())
.build()
this.viewAdapter = MessagesAdapter(this.getApp(), this.messagesViewModel, this.viewManager, markdown)
val serversVm: ServersViewModel by this.getApp().viewModels()
// update the input whenever the connection changes
this.connectedObserver = Observer<Boolean> { this.setUpInput(root) }
serversVm.connected.observe(this.viewLifecycleOwner, this.connectedObserver)
// update the input whenever the availability changes
this.messagesViewModel.available.observe(this.viewLifecycleOwner) {
this.setUpInput(root)
}
// set up the input initially
this.setUpInput(root)
// handle markdown mode
this.markdownModeListener = object : SharedPreferences.OnSharedPreferenceChangeListener {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key != MainActivity.CONFIG_MARKDOWN_MODE) {
return
}
root.messages.invalidate()
}
}
this.getApp().preferences.registerOnSharedPreferenceChangeListener(this.markdownModeListener)
root.messages.apply {
this.setHasFixedSize(false)
this.layoutManager = this@MessagesFragment.viewManager
this.adapter = this@MessagesFragment.viewAdapter
}
root.messageInput.apply {
this.setOnEditorActionListener listener@ { v, actionId, _ ->
if (actionId != EditorInfo.IME_ACTION_SEND) {
return@listener false
}
val messageText = v.text.toString()
this@MessagesFragment.lifecycleScope.launch {
serversVm.connectionService.value?.messages?.send(ClientMessage(messageText))
}
// serversVm.connectionService.value?.messages?.sendBlocking(ClientMessage(v.text.toString()))
v.text = ""
return@listener true
}
}
return root
}
override fun onDestroyView() {
super.onDestroyView()
val vm: ServersViewModel by this.getApp().viewModels()
vm.connected.removeObserver(this.connectedObserver)
this.getApp().preferences.unregisterOnSharedPreferenceChangeListener(this.markdownModeListener)
}
private fun setUpInput(view: View) {
val enabled: Boolean
val hint: CharSequence
val vm: ServersViewModel by this.getApp().viewModels()
if (vm.connected.value == false) {
enabled = false
hint = this@MessagesFragment.getText(R.string.messages_not_connected)
} else {
this.messageInput.apply {
if (this@MessagesFragment.messagesViewModel.available.value == true) {
enabled = true
hint = this@MessagesFragment.getText(R.string.messages_placeholder)
} else {
enabled = false
hint = this@MessagesFragment.getText(R.string.messages_unavailable)
}
}
}
view.messageInput.apply {
this.isEnabled = enabled
this.hint = hint
}
}
}

View File

@ -0,0 +1,43 @@
package io.annaclemens.xivchat.ui.messages
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import io.annaclemens.xivchat.model.ChatType
import io.annaclemens.xivchat.model.ServerMessage
import io.annaclemens.xivchat.model.TextChunk
import io.annaclemens.xivchat.util.MessageFilter
import java.util.*
import kotlin.collections.ArrayList
class MessagesViewModel : ViewModel() {
private val _messages = ArrayList<ServerMessage>()
val messages = MutableLiveData<List<ServerMessage>>().apply {
this.value = this@MessagesViewModel._messages
}
fun addMessage(message: ServerMessage) {
this._messages.add(message)
this.messages.value = this.messages.value
}
@OptIn(ExperimentalUnsignedTypes::class)
fun addSystemMessage(content: String) {
val bytes = content.toByteArray()
val message = ServerMessage(
Date(),
ChatType.Urgent.code.toUShort(),
ByteArray(0),
bytes,
listOf(
TextChunk(0xb38cffff.toUInt(), null, null, false, content),
),
)
this.addMessage(message)
}
val available = MutableLiveData<Boolean>().apply {
this.value = false
}
val filter: MessageFilter = MessageFilter()
}

View File

@ -0,0 +1,95 @@
package io.annaclemens.xivchat.ui.servers
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.text.InputFilter
import android.text.Spanned
import androidx.appcompat.app.AppCompatActivity
import io.annaclemens.xivchat.R
import io.annaclemens.xivchat.util.AlertDialogFragment
import kotlinx.android.synthetic.main.activity_new_server.*
class NewServerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
this.setContentView(R.layout.activity_new_server)
val id = this.intent.getIntExtra(Companion.EXTRA_ID, 0)
this.setTitle(if (id == 0) R.string.add_server_title else R.string.add_server_title_edit)
val existingName = this.intent.getStringExtra(Companion.EXTRA_NAME)
val existingHost = this.intent.getStringExtra(Companion.EXTRA_HOST)
val existingPort = this.intent.getIntExtra(Companion.EXTRA_PORT, 0)
if (existingName != null) {
this.name.setText(existingName)
}
if (existingHost != null) {
this.host.setText(existingHost)
}
if (existingPort != 0) {
this.port.setText(existingPort.toString())
}
if (id != 0) {
this.addButton.setText(R.string.add_server_edit)
}
this.port.apply {
this.filters = arrayOf(object : InputFilter {
override fun filter(source: CharSequence?, start: Int, end: Int, dest: Spanned?, dstart: Int, dend: Int): CharSequence? {
try {
// Remove the string out of destination that is to be replaced
var newVal = dest.toString().substring(0, dstart) + dest.toString().substring(dend, dest.toString().length)
newVal = newVal.substring(0, dstart) + source.toString() + newVal.substring(dstart, newVal.length)
val input = newVal.toInt()
if (this.isInRange(input)) {
return null
}
} catch (e: NumberFormatException) {
}
return ""
}
private fun isInRange(c: Int): Boolean = c in 1..65535
})
}
this.addButton.setOnClickListener {
val replyIntent = Intent()
val name = this.name.text.toString()
val host = this.host.text.toString()
val portText = this.port.text
val port: Int = if (portText.isNullOrBlank()) {
14777
} else {
portText.toString().toIntOrNull() ?: 14777
}
if (name.isBlank() || host.isBlank()) {
AlertDialogFragment {
it
.setTitle(R.string.add_server_missing_fields)
.setMessage(R.string.add_server_missing_fields_desc)
.setNeutralButton(android.R.string.ok, null)
}.show(this.supportFragmentManager, "missing_fields")
return@setOnClickListener
} else {
replyIntent.putExtra(Companion.EXTRA_ID, id)
replyIntent.putExtra(Companion.EXTRA_NAME, name)
replyIntent.putExtra(Companion.EXTRA_HOST, host)
replyIntent.putExtra(Companion.EXTRA_PORT, port)
this.setResult(Activity.RESULT_OK, replyIntent)
}
this.finish()
}
}
companion object {
const val EXTRA_ID = "io.annaclemens.xivchat.SERVER_ID"
const val EXTRA_NAME = "io.annaclemens.xivchat.SERVER_NAME"
const val EXTRA_HOST = "io.annaclemens.xivchat.SERVER_HOST"
const val EXTRA_PORT = "io.annaclemens.xivchat.SERVER_PORT"
}
}

View File

@ -0,0 +1,136 @@
package io.annaclemens.xivchat.ui.servers
import android.app.Activity
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.annaclemens.xivchat.MainActivity
import io.annaclemens.xivchat.R
import io.annaclemens.xivchat.model.SavedServer
import io.annaclemens.xivchat.model.SavedServerAdapter
import io.annaclemens.xivchat.util.AlertDialogFragment
import kotlinx.android.synthetic.main.fragment_servers.*
import kotlinx.android.synthetic.main.fragment_servers.view.*
class ServersFragment : Fragment() {
companion object {
private const val newServerActivityRequestCode = 1
}
private lateinit var serversViewModel: ServersViewModel
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
this.serversViewModel = ViewModelProvider(this.requireActivity() as MainActivity).get(ServersViewModel::class.java)
val root = inflater.inflate(R.layout.fragment_servers, container, false)
root.addServer.setOnClickListener {
val intent = Intent(this.requireContext(), NewServerActivity::class.java)
this.startActivityForResult(intent, Companion.newServerActivityRequestCode)
}
val itemTouchHelperCallback = object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
return false
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
when (direction) {
ItemTouchHelper.LEFT -> {
val dialog = AlertDialogFragment {
it
.setTitle(R.string.server_delete)
.setMessage(R.string.server_delete_desc)
.setPositiveButton(R.string.yes) { _, _ ->
val holder = viewHolder as SavedServerAdapter.ViewHolder
this@ServersFragment.serversViewModel.delete(holder.server)
}
.setNegativeButton(R.string.no) { _, _ ->
this@ServersFragment.serverList.adapter?.notifyDataSetChanged()
}
}
dialog.show(this@ServersFragment.childFragmentManager, "server_delete_dialog")
}
ItemTouchHelper.RIGHT -> {
val holder = viewHolder as SavedServerAdapter.ViewHolder
val intent = Intent(this@ServersFragment.requireContext(), NewServerActivity::class.java)
.putExtra(NewServerActivity.EXTRA_ID, holder.server.id)
.putExtra(NewServerActivity.EXTRA_NAME, holder.server.name)
.putExtra(NewServerActivity.EXTRA_HOST, holder.server.host)
.putExtra(NewServerActivity.EXTRA_PORT, holder.server.port)
this@ServersFragment.startActivityForResult(intent, Companion.newServerActivityRequestCode)
}
}
}
}
root.serverList.apply {
this.adapter = SavedServerAdapter(this@ServersFragment.requireContext(), this@ServersFragment.serversViewModel)
this.layoutManager = LinearLayoutManager(this@ServersFragment.requireContext())
this.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
if (parent.getChildAdapterPosition(view) == 0) {
return
}
outRect.top = 16
}
})
ItemTouchHelper(itemTouchHelperCallback).attachToRecyclerView(this)
}
this.serversViewModel.allServers.observe(this.viewLifecycleOwner) {
if (it == null || it.isEmpty()) {
root.emptyMessage.visibility = View.VISIBLE
root.instructions.visibility = View.GONE
return@observe
}
root.emptyMessage.visibility = View.GONE
root.instructions.visibility = View.VISIBLE
(root.serverList.adapter as SavedServerAdapter).setServers(it)
}
return root
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != Companion.newServerActivityRequestCode) {
return
}
when (resultCode) {
Activity.RESULT_OK -> {
val id = data?.getIntExtra(NewServerActivity.EXTRA_ID, 0) ?: 0
val name = data?.getStringExtra(NewServerActivity.EXTRA_NAME)
val host = data?.getStringExtra(NewServerActivity.EXTRA_HOST)
val port = data?.getIntExtra(NewServerActivity.EXTRA_PORT, 14777)
?.coerceAtLeast(1)
?.coerceAtMost(65535)
if (name == null || host == null || port == null) {
return
}
val server = SavedServer(id, name, host, port)
if (id == 0) {
this.serversViewModel.insert(server)
} else {
this.serversViewModel.update(server)
}
}
Activity.RESULT_CANCELED -> {
this.view?.serverList?.adapter?.notifyDataSetChanged()
}
}
}
}

View File

@ -0,0 +1,46 @@
package io.annaclemens.xivchat.ui.servers
import android.app.Application
import android.graphics.Bitmap
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.annaclemens.xivchat.ConnectionService
import io.annaclemens.xivchat.Database
import io.annaclemens.xivchat.model.PlayerData
import io.annaclemens.xivchat.model.SavedServer
import io.annaclemens.xivchat.model.SavedServerRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ServersViewModel(application: Application) : AndroidViewModel(application) {
private val repository: SavedServerRepository
val allServers: LiveData<List<SavedServer>>
val connectionService: MutableLiveData<ConnectionService> = MutableLiveData<ConnectionService>()
val connected = MutableLiveData<Boolean>().apply {
this.value = false
}
val playerData: MutableLiveData<PlayerData> = MutableLiveData()
val playerPortrait: MutableLiveData<Bitmap> = MutableLiveData()
init {
val dao = Database.getDatabase(application).serverDao()
this.repository = SavedServerRepository(dao)
this.allServers = this.repository.allServers
}
fun insert(server: SavedServer) = this.viewModelScope.launch(Dispatchers.IO) {
this@ServersViewModel.repository.insert(server)
}
fun update(server: SavedServer) = this.viewModelScope.launch(Dispatchers.IO) {
this@ServersViewModel.repository.update(server)
}
fun delete(server: SavedServer) = this.viewModelScope.launch(Dispatchers.IO) {
this@ServersViewModel.repository.delete(server)
}
}

View File

@ -0,0 +1,45 @@
package io.annaclemens.xivchat.ui.settings
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import androidx.preference.PreferenceViewHolder
import io.annaclemens.xivchat.R
import io.annaclemens.xivchat.util.hexStringToByteArray
import kotlinx.android.synthetic.main.key_visualisation.view.*
class KeyPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs, android.R.attr.preferenceStyle) {
init {
this.layoutResource = R.layout.key_visualisation
}
private var itemView: View? = null;
override fun onBindViewHolder(holder: PreferenceViewHolder?) {
super.onBindViewHolder(holder)
if (holder == null) {
return
}
// TODO: figure out why publicKeyLayout is null here so we don't have to set this in the XML
// holder.itemView.publicKeyLayout.updatePaddingRelative(
// start = android.R.attr.listPreferredItemPaddingStart,
// end = android.R.attr.listPreferredItemPaddingEnd,
// )
this.itemView = holder.itemView
this.setUpKey()
}
fun setUpKey() {
val itemView = this.itemView ?: return
val key = PreferenceManager.getDefaultSharedPreferences(itemView.context).getString("keyPairPublic", "")!!
itemView.publicKeyName.text = itemView.context.getString(R.string.settings_current_key)
itemView.publicKey.text = key
itemView.publicKeyColours.generateDrawables(key.hexStringToByteArray() ?: ByteArray(0))
}
}

View File

@ -0,0 +1,14 @@
package io.annaclemens.xivchat.ui.settings
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import io.annaclemens.xivchat.R
class KeyVisualisationFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_settings, container, false)
}
}

View File

@ -0,0 +1,40 @@
package io.annaclemens.xivchat.ui.settings
import android.os.Bundle
import androidx.core.content.edit
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import io.annaclemens.xivchat.R
import io.annaclemens.xivchat.util.getApp
class PreferencesFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
this.setPreferencesFromResource(R.xml.preferences, rootKey)
this.findPreference<Preference>("keyPairPublic")!!.let {
it.onPreferenceClickListener = Preference.OnPreferenceClickListener {
this.getApp().regenerateKeyPair()
this.findPreference<KeyPreference>("keyPairPublicDisplay")!!.run {
this.setUpKey()
}
true
}
}
this.findPreference<Preference>("trusted")!!.let {
it.onPreferenceClickListener = Preference.OnPreferenceClickListener {
val toRemove = this.getApp().preferences
.all
.keys
.filter { key -> key.startsWith("trusted:") }
this.getApp().preferences.edit {
for (key in toRemove) {
this.remove(key)
}
}
true
}
}
}
}

View File

@ -0,0 +1,14 @@
package io.annaclemens.xivchat.util
import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
class AlertDialogFragment(val f: (AlertDialog.Builder) -> Unit) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(this.activity);
this.f(builder)
return builder.create()
}
}

View File

@ -0,0 +1,60 @@
package io.annaclemens.xivchat.util
import com.goterl.lazycode.lazysodium.LazySodiumAndroid
import com.goterl.lazycode.lazysodium.interfaces.KeyExchange
import com.goterl.lazycode.lazysodium.utils.KeyPair
import com.goterl.lazycode.lazysodium.utils.SessionPair
import io.ktor.utils.io.*
class AppKeyExchange {
companion object {
private fun clientSessionKeys(sodium: LazySodiumAndroid, client: KeyPair, serverPublic: ByteArray): SessionPair {
val kx = sodium as KeyExchange.Native
val rx = ByteArray(KeyExchange.SESSIONKEYBYTES)
val tx = ByteArray(KeyExchange.SESSIONKEYBYTES)
kx.cryptoKxClientSessionKeys(rx, tx, client.publicKey.asBytes, client.secretKey.asBytes, serverPublic)
return SessionPair(rx, tx)
}
suspend fun clientHandshake(sodium: LazySodiumAndroid, client: KeyPair, input: ByteReadChannel, output: ByteWriteChannel): HandshakeInfo {
// send our public key
output.writeFully(client.publicKey.asBytes)
// get the server's public key
val serverPublic = ByteArray(KeyExchange.PUBLICKEYBYTES)
input.readFully(serverPublic)
// compute shared keys
val pair = this.clientSessionKeys(sodium, client, serverPublic)
return HandshakeInfo(serverPublic, pair)
}
}
data class HandshakeInfo(val remotePublicKey: ByteArray, val keys: SessionPair) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@Suppress("ImplicitThis")
if (this.javaClass != other?.javaClass) {
return false
}
other as HandshakeInfo
if (!this.remotePublicKey.contentEquals(other.remotePublicKey) || this.keys != other.keys) {
return false
}
return true
}
override fun hashCode(): Int {
var result = this.remotePublicKey.contentHashCode()
result = 31 * result + this.keys.hashCode()
return result
}
}
}

View File

@ -0,0 +1,66 @@
package io.annaclemens.xivchat.util
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.drawable.Drawable
import android.text.style.ImageSpan
import java.lang.ref.WeakReference
class CentredImageSpan(drawable: Drawable) : ImageSpan(drawable) {
private var mDrawableRef: WeakReference<Drawable>? = null
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
val d = this.cachedDrawable
val rect = d!!.bounds
if (fm != null) {
val pfm: Paint.FontMetricsInt = paint.fontMetricsInt
// keep it the same as paint's fm
fm.ascent = pfm.ascent
fm.descent = pfm.descent
fm.top = pfm.top
fm.bottom = pfm.bottom
}
return rect.right
// val d: Drawable = this.cachedDrawable!!
// val rect = d.bounds
//
// if (fm != null) {
// fm.ascent = -rect.bottom
// fm.descent = 0
// fm.top = fm.ascent
// fm.bottom = 0
// }
//
// return rect.right
}
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
val b: Drawable = this.cachedDrawable!!
canvas.save()
var transY = bottom - b.bounds.bottom
// if (mVerticalAlignment == ALIGN_BASELINE) {
// transY -= paint.fontMetricsInt.descent
// } else if (mVerticalAlignment == ALIGN_CENTER) {
transY = top + (bottom - top) / 2 - b.bounds.height() / 2
// }
canvas.translate(x, transY.toFloat())
b.draw(canvas)
canvas.restore()
}
// Redefined locally because it is a private member from DynamicDrawableSpan
private val cachedDrawable: Drawable?
get() {
val wr: WeakReference<Drawable>? = this.mDrawableRef
var d: Drawable? = null
if (wr != null) d = wr.get()
if (d == null) {
d = this.drawable
this.mDrawableRef = WeakReference(d)
}
return d
}
}

View File

@ -0,0 +1,6 @@
package io.annaclemens.xivchat.util
fun rgbaToArgb(rgba: Long): Long {
val alpha = rgba and 0xFF
return (alpha shl 24) or (rgba shr 8)
}

View File

@ -0,0 +1,21 @@
package io.annaclemens.xivchat.util
import android.content.ComponentName
import android.content.ServiceConnection
import android.os.IBinder
import androidx.activity.viewModels
import io.annaclemens.xivchat.ConnectionService
import io.annaclemens.xivchat.MainActivity
import io.annaclemens.xivchat.ui.servers.ServersViewModel
class ConnectionServiceConnection(val app: MainActivity) : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val serv = (service as ConnectionService.LocalBinder).getService()
serv.app = this.app
serv.bound = true
val serversVm: ServersViewModel by this.app.viewModels()
serversVm.connectionService.value = serv
}
override fun onServiceDisconnected(name: ComponentName?) {}
}

View File

@ -0,0 +1,29 @@
package io.annaclemens.xivchat.util
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Drawable
import androidx.core.graphics.drawable.toBitmap
class CroppedDrawable(private val drawable: Drawable, private val crop: Rect) : Drawable() {
override fun getIntrinsicWidth() = this.crop.width()
override fun getIntrinsicHeight() = this.crop.height()
override fun draw(canvas: Canvas) {
val bitmap = this.drawable.toBitmap(this.drawable.intrinsicWidth, this.drawable.intrinsicHeight)
canvas.drawBitmap(bitmap, this.crop, this.bounds, null)
}
override fun setAlpha(alpha: Int) {
// Empty
}
override fun setColorFilter(cf: ColorFilter?) {
// Empty
}
override fun getOpacity() = PixelFormat.OPAQUE
}

View File

@ -0,0 +1,52 @@
package io.annaclemens.xivchat.util
import androidx.fragment.app.Fragment
import io.annaclemens.xivchat.MainActivity
fun ByteArray.toHexString(upper: Boolean = false, separator: String = ""): String {
val format = if (upper) "%02X" else "%02x"
return this
.map { it.toInt() and 0xFF }
.joinToString(separator) {
format.format(it)
}
}
fun ByteArray.toColours(): List<Long> {
return this
.asIterable()
.map { it.toLong() and 0xFF }
.chunked(3) {
val a = it[0]
val b = it.getOrElse(1) { 0 }
val c = it.getOrElse(2) { 0 }
0xFF000000 or (a shl 16) or (b shl 8) or c
}
.toList()
}
private const val UPPER_HEX_CHARS = "0123456789ABCDEF"
private const val LOWER_HEX_CHARS = "0123456789abcdef"
fun String.hexStringToByteArray() : ByteArray? {
val result = ByteArray(this.length / 2)
for (i in this.indices step 2) {
val firstIndex = UPPER_HEX_CHARS.indexOf(this[i]).takeIf { it >= 0 } ?: LOWER_HEX_CHARS.indexOf(this[i])
val secondIndex = UPPER_HEX_CHARS.indexOf(this[i + 1]).takeIf {it >= 0 } ?: LOWER_HEX_CHARS.indexOf(this[i + 1])
if (firstIndex == -1 || secondIndex == -1) {
return null
}
val octet = firstIndex.shl(4).or(secondIndex)
result[i.shr(1)] = octet.toByte()
}
return result
}
fun Fragment.getApp(): MainActivity {
return this.requireActivity() as MainActivity
}

View File

@ -0,0 +1,95 @@
package io.annaclemens.xivchat.util
import android.graphics.Rect
fun iconBounds(id: Int): Rect? {
return when (id) {
1 -> Rect(0, 0, 20, 20)
2 -> Rect(20, 0, 40, 20)
3 -> Rect(40, 0, 60, 20)
4 -> Rect(60, 0, 80, 20)
5 -> Rect(80, 0, 100, 20)
6 -> Rect(0, 20, 20, 40)
7 -> Rect(20, 20, 40, 40)
8 -> Rect(40, 20, 60, 40)
9 -> Rect(60, 20, 80, 40)
10 -> Rect(80, 20, 100, 40)
11 -> Rect(0, 40, 20, 60)
12 -> Rect(20, 40, 40, 60)
13 -> Rect(40, 40, 60, 60)
14 -> Rect(60, 40, 80, 60)
15 -> Rect(80, 40, 100, 60)
16 -> Rect(60, 100, 80, 120)
17 -> Rect(80, 100, 100, 120)
18 -> Rect(0, 60, 54, 80)
19 -> Rect(54, 60, 108, 80)
20 -> Rect(60, 80, 80, 100)
21 -> Rect(0, 80, 28, 100)
22 -> Rect(28, 80, 60, 100)
23 -> Rect(80, 80, 100, 100)
24 -> Rect(0, 100, 28, 120)
25 -> Rect(28, 100, 60, 120)
51 -> Rect(124, 0, 144, 20)
52 -> Rect(144, 0, 164, 20)
53 -> Rect(164, 0, 184, 20)
54 -> Rect(100, 0, 112, 20)
55 -> Rect(112, 0, 124, 20)
56 -> Rect(100, 20, 120, 40)
57 -> Rect(120, 20, 140, 40)
58 -> Rect(140, 20, 160, 40)
59 -> Rect(100, 40, 120, 60)
60 -> Rect(120, 40, 140, 60)
61 -> Rect(140, 40, 160, 60)
62 -> Rect(160, 20, 180, 40)
63 -> Rect(160, 40, 180, 60)
64 -> Rect(184, 0, 204, 20)
65 -> Rect(204, 0, 224, 20)
66 -> Rect(224, 0, 244, 20)
67 -> Rect(180, 20, 200, 40)
68 -> Rect(200, 20, 220, 40)
69 -> Rect(236, 236, 256, 256)
70 -> Rect(180, 40, 200, 60)
71 -> Rect(200, 40, 220, 60)
72 -> Rect(220, 40, 240, 60)
73 -> Rect(220, 20, 240, 40)
74 -> Rect(108, 60, 128, 80)
75 -> Rect(128, 60, 148, 80)
76 -> Rect(148, 60, 168, 80)
77 -> Rect(168, 60, 188, 80)
78 -> Rect(188, 60, 208, 80)
79 -> Rect(208, 60, 228, 80)
80 -> Rect(228, 60, 248, 80)
81 -> Rect(100, 80, 120, 100)
82 -> Rect(120, 80, 140, 100)
83 -> Rect(140, 80, 160, 100)
84 -> Rect(160, 80, 180, 100)
85 -> Rect(180, 80, 200, 100)
86 -> Rect(200, 80, 220, 100)
87 -> Rect(220, 80, 240, 100)
88 -> Rect(100, 100, 120, 120)
89 -> Rect(120, 100, 140, 120)
90 -> Rect(140, 100, 160, 120)
91 -> Rect(160, 100, 180, 120)
92 -> Rect(180, 100, 200, 120)
93 -> Rect(200, 100, 220, 120)
94 -> Rect(220, 100, 240, 120)
95 -> Rect(0, 120, 20, 140)
96 -> Rect(20, 120, 40, 140)
97 -> Rect(40, 120, 60, 140)
98 -> Rect(60, 120, 80, 140)
99 -> Rect(80, 120, 100, 140)
100 -> Rect(100, 120, 120, 140)
101 -> Rect(120, 120, 140, 140)
102 -> Rect(140, 120, 160, 140)
103 -> Rect(160, 120, 180, 140)
104 -> Rect(180, 120, 200, 140)
105 -> Rect(200, 120, 220, 140)
106 -> Rect(220, 120, 240, 140)
107 -> Rect(0, 140, 20, 160)
108 -> Rect(20, 140, 40, 160)
109 -> Rect(40, 140, 60, 160)
110 -> Rect(60, 140, 80, 160)
111 -> Rect(80, 140, 100, 160)
else -> null
}
}

View File

@ -0,0 +1,21 @@
package io.annaclemens.xivchat.util
import io.annaclemens.xivchat.model.ServerMessage
class MessageFilter {
var filterString: String? = null
var filterBattle: Boolean = true
fun allowed(message: ServerMessage): Boolean {
if (this.filterBattle && message.code().type()?.isBattle() == true) {
return false
}
val filterString = this.filterString
if (filterString != null && message.spans(null, null)?.contains(filterString, ignoreCase = true) != true) {
return false
}
return true
}
}

View File

@ -0,0 +1,93 @@
package io.annaclemens.xivchat.util
import android.content.Context
import android.graphics.Rect
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import androidx.core.content.ContextCompat
import androidx.core.text.inSpans
import io.annaclemens.xivchat.R
import io.annaclemens.xivchat.model.Chunk
import io.annaclemens.xivchat.model.IconChunk
import io.annaclemens.xivchat.model.TextChunk
import io.noties.markwon.Markwon
@ExperimentalUnsignedTypes
class SeString {
companion object {
fun chunksToSpans(chunks: List<Chunk>, markdown: Markwon?, context: Context?): Spanned? {
val builder = SpannableStringBuilder()
for (chunk in chunks) {
when (chunk) {
is TextChunk -> builder.inSpans(*this.calculateSpans(chunk.italic, chunk.argb())) {
this.append(chunk.content, markdown)
}
is IconChunk -> {
if (context == null) {
continue
}
val bounds = iconBounds(chunk.index.toInt())
if (bounds != null) {
val fontIcon = ContextCompat.getDrawable(context, R.drawable.fonticon_ps4)
if (fontIcon != null) {
val cropped = CroppedDrawable(fontIcon, bounds).apply {
val width = (bounds.width() * context.resources.displayMetrics.density).toInt()
val height = (bounds.height() * context.resources.displayMetrics.density).toInt()
this.bounds = Rect(0, 0, width, height)
}
// FIXME: this aligns poorly, especially with multiple lines
val imageSpan = CentredImageSpan(cropped)
builder.inSpans(imageSpan) {
this.append(' ')
}
}
}
}
}
}
return builder
}
private fun calculateSpans(italic: Boolean, colour: Long?): Array<out Any> {
val spans = ArrayList<Any>()
if (italic) {
spans.add(StyleSpan(Typeface.ITALIC))
}
if (colour != null) {
spans.add(ForegroundColorSpan(colour.toInt()))
}
return spans.toArray()
}
private fun SpannableStringBuilder.append(text: String, markdown: Markwon?) {
if (markdown == null) {
// just normal text
this.append(text)
} else {
val spacePrefixIdx = text.indexOfFirst { !it.isWhitespace() }
val spaceSuffixIdx = text.indexOfLast { !it.isWhitespace() }
val spacePrefix = if (spacePrefixIdx > -1) text.subSequence(0, spacePrefixIdx) else null
val spaceSuffix = if (spaceSuffixIdx > -1) text.subSequence(spaceSuffixIdx + 1, text.length) else null
// append the spaces at the start that will be trimmed
if (spacePrefix != null) {
this.append(spacePrefix)
}
// process as markdown
this.append(markdown.toMarkdown(text))
// append the spaces at the end that will be trimmed
if (spaceSuffix != null) {
this.append(spaceSuffix)
}
}
}
}
}

View File

@ -0,0 +1,54 @@
package io.annaclemens.xivchat.util
import com.goterl.lazycode.lazysodium.LazySodiumAndroid
import com.goterl.lazycode.lazysodium.interfaces.SecretBox
import com.goterl.lazycode.lazysodium.utils.Key
import io.ktor.utils.io.*
import java.nio.ByteBuffer
import java.nio.ByteOrder
class SecretMessage {
companion object {
suspend fun readSecretMessage(sodium: LazySodiumAndroid, stream: ByteReadChannel, key: Key): ByteArray {
val headerBytes = ByteArray(4 + 24)
stream.readFully(headerBytes)
val header = ByteBuffer.wrap(headerBytes).apply { this.order(ByteOrder.LITTLE_ENDIAN) }
val length = header.int
val nonce = ByteArray(header.remaining())
header.get(nonce)
val ciphertext = ByteArray(length)
stream.readFully(ciphertext)
val plaintext = ByteArray(ciphertext.size - SecretBox.MACBYTES)
val secretBox = sodium as SecretBox.Native
secretBox.cryptoSecretBoxOpenEasy(
plaintext,
ciphertext,
ciphertext.size.toLong(),
nonce,
key.asBytes
)
return plaintext
}
suspend fun writeSecretMessage(sodium: LazySodiumAndroid, stream: ByteWriteChannel, key: Key, msg: ByteArray) {
val secretBox = sodium as SecretBox.Native
val nonce = sodium.nonce(SecretBox.NONCEBYTES)
val ciphertext = ByteArray(SecretBox.MACBYTES + msg.size)
secretBox.cryptoSecretBoxEasy(ciphertext, msg, msg.size.toLong(), nonce, key.asBytes)
val len = ciphertext.size
val buffer = ByteBuffer.allocate(nonce.size + ciphertext.size + 4).apply { this.order(ByteOrder.LITTLE_ENDIAN) }
buffer.putInt(len)
buffer.put(nonce)
buffer.put(ciphertext)
buffer.rewind()
stream.writeFully(buffer.array())
}
}
}

View File

@ -0,0 +1,59 @@
package io.annaclemens.xivchat.util
import android.app.Activity
import android.os.Bundle
import android.os.ResultReceiver
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import io.annaclemens.xivchat.R
import kotlinx.android.synthetic.main.trust_dialog.view.*
class TrustDialog : DialogFragment() {
companion object {
const val TAG = "trustDialog"
private const val SERVER_PUBLIC = "serverPublic"
private const val CLIENT_PUBLIC = "clientPublic"
private const val RECEIVER = "receiver"
fun newInstance(receiver: ResultReceiver, serverPublic: ByteArray, clientPublic: ByteArray): TrustDialog {
val args = Bundle().apply {
this.putParcelable(this@Companion.RECEIVER, receiver)
this.putByteArray(this@Companion.SERVER_PUBLIC, serverPublic)
this.putByteArray(this@Companion.CLIENT_PUBLIC, clientPublic)
}
return TrustDialog().apply {
this.arguments = args
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.trust_dialog, container)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val receiver = this.arguments?.getParcelable<ResultReceiver>(TrustDialog.RECEIVER) ?: throw IllegalArgumentException("receiver was null or not set")
val serverPublic = this.arguments?.getByteArray(TrustDialog.SERVER_PUBLIC) ?: ByteArray(0)
val clientPublic = this.arguments?.getByteArray(TrustDialog.CLIENT_PUBLIC) ?: ByteArray(0)
view.trustServerPublicKey.text = this@TrustDialog.getString(R.string.connect_trust_server).format(serverPublic.toHexString(upper = true))
view.trustServerPublicKeyColours.generateDrawables(serverPublic)
view.trustClientPublicKey.text = this@TrustDialog.getString(R.string.connect_trust_client).format(clientPublic.toHexString(upper = true))
view.trustClientPublicKeyColours.generateDrawables(clientPublic)
view.trustButtonNo.setOnClickListener {
receiver.send(Activity.RESULT_CANCELED, null)
this.dismiss()
}
view.trustButtonYes.setOnClickListener {
receiver.send(Activity.RESULT_OK, null)
this.dismiss()
}
}
}

View File

@ -0,0 +1,16 @@
package io.annaclemens.xivchat.util
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
class CharacterSearchResults {
@SerialName("Results")
val results: List<CharacterSearchResult> = ArrayList()
}
@Serializable
class CharacterSearchResult {
@SerialName("Avatar")
val avatar: String = ""
}

View File

@ -0,0 +1,47 @@
package io.annaclemens.xivchat.util
import android.graphics.Typeface
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.MarkwonSpansFactory
import io.noties.markwon.core.spans.CustomTypefaceSpan
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.node.AbstractVisitor
import org.commonmark.node.Emphasis
import org.commonmark.node.Image
import org.commonmark.node.Text
import org.commonmark.parser.Parser
class XivChatMarkdownPlugin : AbstractMarkwonPlugin() {
override fun configureParser(builder: Parser.Builder) {
builder
// auto-link bare links
.extensions(listOf(
AutolinkExtension.create(),
))
// don't parse any block types
.enabledBlockTypes(emptySet())
// disable images
.postProcessor {
it.accept(object : AbstractVisitor() {
override fun visit(image: Image?) {
if (image == null) {
return
}
// FIXME: title is always null for some reason
val text = "![${image.title ?: ""}](${image.destination ?: ""})"
image.insertAfter(Text(text))
image.unlink()
}
})
it
}
}
override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {
// make italics match normal system italics
builder.setFactory(Emphasis::class.java) { _, _ ->
CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC))
}
}
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,5c1.66,0 3,1.34 3,3s-1.34,3 -3,3 -3,-1.34 -3,-3 1.34,-3 3,-3zM12,19.2c-2.5,0 -4.71,-1.28 -6,-3.22 0.03,-1.99 4,-3.08 6,-3.08 1.99,0 5.97,1.09 6,3.08 -1.29,1.94 -3.5,3.22 -6,3.22z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2L6,11L6,9zM14,14L6,14v-2h8v2zM18,8L6,8L6,6h12v2z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4c-1.48,0 -2.85,0.43 -4.01,1.17l1.46,1.46C10.21,6.23 11.08,6 12,6c3.04,0 5.5,2.46 5.5,5.5v0.5H19c1.66,0 3,1.34 3,3 0,1.13 -0.64,2.11 -1.56,2.62l1.45,1.45C23.16,18.16 24,16.68 24,15c0,-2.64 -2.05,-4.78 -4.65,-4.96zM3,5.27l2.75,2.74C2.56,8.15 0,10.77 0,14c0,3.31 2.69,6 6,6h11.73l2,2L21,20.73 4.27,4 3,5.27zM7.73,10l8,8H6c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4h1.73z"
android:fillColor="#000000"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,35 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="2.25"
android:scaleY="2.25"
android:translateX="27"
android:translateY="27">
<path
android:pathData="M20,2L4,2C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2L22,4C22,2.9 21.1,2 20,2ZM20,16L6,16L4,18L4,4h16z"
android:fillColor="#000000"/>
<path
android:pathData="M4,4h16v12h-16z"
android:fillColor="#ffffff"/>
<path
android:pathData="M4,15.6227H6L4,18Z"
android:strokeWidth="1.09024"
android:fillColor="#ffffff"/>
<path
android:pathData="M11.0901,14.2291H9.8789L7.6347,10.5481 5.3548,14.2291H4.2267L7.041,9.8118 4.4048,5.7508h1.1874l2.078,3.3248 2.0899,-3.3248H10.8883L8.264,9.7881Z"
android:strokeWidth="8.90583"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
<path
android:pathData="M19.7733,5.7508 L16.7453,14.2291H15.6766L12.6486,5.7508h1.1162l1.9118,5.4385q0.19,0.5225 0.3087,0.9856 0.1306,0.4512 0.2256,0.8668 0.0831,-0.4156 0.2137,-0.8787 0.1306,-0.4631 0.3206,-0.9974l1.8999,-5.4147z"
android:strokeWidth="8.90583"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
<path
android:pathData="M12.2776,15.7044H11.2581V4.2903h1.0195z"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
</group>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
</vector>

View File

@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20,2L4,2C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2L22,4C22,2.9 21.1,2 20,2ZM20,16L6,16L4,18L4,4h16z"
android:fillColor="#000000"/>
<path
android:pathData="M11.0901,14.2291H9.8789L7.6347,10.5481 5.3548,14.2291H4.2267L7.041,9.8118 4.4048,5.7508h1.1874l2.078,3.3248 2.0899,-3.3248H10.8883L8.264,9.7881Z"
android:strokeWidth="8.90583"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
<path
android:pathData="M19.7733,5.7508 L16.7453,14.2291H15.6766L12.6486,5.7508h1.1162l1.9118,5.4385q0.19,0.5225 0.3087,0.9856 0.1306,0.4512 0.2256,0.8668 0.0831,-0.4156 0.2137,-0.8787 0.1306,-0.4631 0.3206,-0.9974l1.8999,-5.4147z"
android:strokeWidth="8.90583"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
<path
android:pathData="M12.2776,15.7044H11.2581V4.2903h1.0195z"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20,2L4,2C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2L22,4C22,2.9 21.1,2 20,2ZM20,16L6,16L4,18L4,4h16z"
android:fillColor="#000000"/>
<path
android:pathData="M4,4h16v12h-16z"
android:fillColor="#ffffff"/>
<path
android:pathData="M4,15.6227H6L4,18Z"
android:strokeWidth="1.09024"
android:fillColor="#ffffff"/>
<path
android:pathData="M11.0901,14.2291H9.8789L7.6347,10.5481 5.3548,14.2291H4.2267L7.041,9.8118 4.4048,5.7508h1.1874l2.078,3.3248 2.0899,-3.3248H10.8883L8.264,9.7881Z"
android:strokeWidth="8.90583"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
<path
android:pathData="M19.7733,5.7508 L16.7453,14.2291H15.6766L12.6486,5.7508h1.1162l1.9118,5.4385q0.19,0.5225 0.3087,0.9856 0.1306,0.4512 0.2256,0.8668 0.0831,-0.4156 0.2137,-0.8787 0.1306,-0.4631 0.3206,-0.9974l1.8999,-5.4147z"
android:strokeWidth="8.90583"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
<path
android:pathData="M12.2776,15.7044H11.2581V4.2903h1.0195z"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
</vector>

View File

@ -0,0 +1,33 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M0,0h24v24h-24z"
android:fillColor="#02ccee"/>
<path
android:pathData="M4,4h16v12.2031h-16z"
android:strokeWidth="1.00843"
android:fillColor="#ffffff"/>
<path
android:pathData="M4,16H6L4,18Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M20,2L4,2C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2L22,4C22,2.9 21.1,2 20,2ZM20,16L6,16L4,18L4,4h16z"
android:fillColor="#000000"/>
<path
android:pathData="M11.0901,14.2291H9.8789L7.6347,10.5481 5.3548,14.2291H4.2267L7.041,9.8118 4.4048,5.7508h1.1874l2.078,3.3248 2.0899,-3.3248H10.8883L8.264,9.7881Z"
android:strokeWidth="8.90583"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
<path
android:pathData="M19.7733,5.7508 L16.7453,14.2291H15.6766L12.6486,5.7508h1.1162l1.9118,5.4385q0.19,0.5225 0.3087,0.9856 0.1306,0.4512 0.2256,0.8668 0.0831,-0.4156 0.2137,-0.8787 0.1306,-0.4631 0.3206,-0.9974l1.8999,-5.4147z"
android:strokeWidth="8.90583"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
<path
android:pathData="M12.2776,15.7044H11.2581V4.2903h1.0195z"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,9 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="135"
android:centerColor="#009688"
android:endColor="#00695C"
android:startColor="#4DB6AC"
android:type="linear" />
</shape>

Binary file not shown.

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:openDrawer="start">
<include
android:id="@+id/navHeader"
layout="@layout/app_bar_main"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header_main"
app:menu="@menu/activity_main_drawer" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.servers.NewServerActivity">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<EditText
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:hint="@string/add_server_name"
android:inputType="textNoSuggestions|text"
android:importantForAutofill="no" />
<EditText
android:id="@+id/host"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:hint="@string/add_server_host"
android:inputType="textNoSuggestions|text"
android:importantForAutofill="no" />
<EditText
android:id="@+id/port"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:hint="@string/add_server_port"
android:inputType="number"
android:importantForAutofill="no" />
<Button
android:id="@+id/addButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/add_server_button" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/AppTheme.AppBarOverlay">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:popupTheme="@style/AppTheme.PopupOverlay" />
</com.google.android.material.appbar.AppBarLayout>
<include layout="@layout/content_main" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:showIn="@layout/app_bar_main">
<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/mobile_navigation" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.connect.ConnectFragment">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/connectTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/connect_title"
android:textAppearance="@style/TextAppearance.AppCompat.Large" />
<EditText
android:id="@+id/connectHost"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:hint="@string/connect_host"
android:importantForAutofill="no"
android:inputType="textNoSuggestions|text" />
<EditText
android:id="@+id/connectPort"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:hint="@string/connect_port"
android:importantForAutofill="no"
android:inputType="number"
android:maxLength="5" />
<Button
android:id="@+id/connectButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/connect_button" />
<ProgressBar
android:id="@+id/connectIndicator"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="auto"
android:focusableInTouchMode="true"
android:scrollbars="vertical"
tools:context=".ui.messages.MessagesFragment">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/messages"
android:layout_width="match_parent"
android:layout_height="0dp"
android:scrollbars="vertical"
app:layout_constraintBottom_toTopOf="@+id/messageInput"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:reverseLayout="true" />
<EditText
android:id="@+id/messageInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ems="10"
android:focusable="auto"
android:hint="@string/messages_not_connected"
android:imeOptions="actionSend"
android:importantForAutofill="no"
android:inputType="textShortMessage|textAutoCorrect|textCapSentences|textAutoComplete"
android:singleLine="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messages" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.servers.ServersFragment">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addServer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
android:clickable="true"
android:src="@drawable/add_black_24dp" />
<TextView
android:id="@+id/emptyMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/servers_empty"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/instructions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:paddingVertical="8dp"
android:text="@string/servers_instructions"
android:textAlignment="center" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/serverList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingStart="?attr/listPreferredItemPaddingStart"
android:paddingTop="8dp"
android:paddingEnd="?attr/listPreferredItemPaddingEnd"
android:paddingBottom="8dp">
</androidx.recyclerview.widget.RecyclerView>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/publicKeyLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.settings.KeyVisualisationFragment">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment"
android:name="io.annaclemens.xivchat.ui.settings.PreferencesFragment"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeight"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingEnd="?android:attr/scrollbarSize"
android:background="?android:attr/selectableItemBackground" >
<LinearLayout
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginStart="15dip"
android:layout_marginEnd="6dip"
android:layout_marginTop="6dip"
android:layout_marginBottom="6dip"
android:layout_weight="1">
<TextView
android:id="@+id/publicKeyName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/key"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="16sp"
android:ellipsize="marquee"
android:fadingEdge="horizontal" />
<TextView
android:id="@+id/publicKey"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<io.annaclemens.xivchat.KeyVisualisation
android:id="@+id/publicKeyColours"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<!--<?xml version="1.0" encoding="utf-8"?>-->
<!--<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"-->
<!-- xmlns:app="http://schemas.android.com/apk/res-auto"-->
<!-- xmlns:tools="http://schemas.android.com/tools"-->
<!-- android:id="@+id/publicKeyLayout"-->
<!-- style="?android:preferenceStyle"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:orientation="vertical"-->
<!-- android:paddingStart="?attr/listPreferredItemPaddingStart"-->
<!-- android:paddingEnd="?attr/listPreferredItemPaddingEnd"-->
<!-- tools:context=".ui.settings.KeyVisualisationFragment">-->
<!-- <TextView-->
<!-- android:id="@+id/publicKeyName"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:text="@string/key"-->
<!-- android:textAppearance="?android:attr/textAppearanceMedium" />-->
<!-- <TextView-->
<!-- android:id="@+id/publicKey"-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content" />-->
<!-- <io.annaclemens.xivchat.KeyVisualisation-->
<!-- android:id="@+id/publicKeyColours"-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content" />-->
<!--</LinearLayout>-->

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_header_height"
android:background="@drawable/side_nav_bar"
android:gravity="bottom"
android:orientation="vertical"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
android:theme="@style/ThemeOverlay.AppCompat.Dark">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/characterPortrait"
android:layout_width="64dp"
android:layout_height="64dp"
android:contentDescription="@string/nav_header_desc"
android:scaleType="centerCrop"
android:src="@drawable/account_circle_white_24dp" />
<TextView
android:id="@+id/characterName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="@dimen/nav_header_vertical_spacing"
android:text="@string/nav_header_title"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" />
<TextView
android:id="@+id/characterLocation"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:contentPadding="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/linearLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/connectingIndicator"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textColor="#FFFFFF"
android:textSize="16sp" />
<TextView
android:id="@+id/address"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<ProgressBar
android:id="@+id/connectingIndicator"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View File

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/publicKeyLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center">
<ScrollView
android:id="@+id/scrollView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="?attr/dialogPreferredPadding"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/connect_trust"
android:textAppearance="@style/TextAppearance.AppCompat.Large" />
<Space
android:id="@+id/space"
android:layout_width="match_parent"
android:layout_height="12dp" />
<TextView
android:id="@+id/textView6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/connect_trust_desc" />
<Space
android:id="@+id/space2"
android:layout_width="match_parent"
android:layout_height="12dp" />
<TextView
android:id="@+id/trustServerPublicKey"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<io.annaclemens.xivchat.KeyVisualisation
android:id="@+id/trustServerPublicKeyColours"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Space
android:layout_width="match_parent"
android:layout_height="12dp" />
<TextView
android:id="@+id/trustClientPublicKey"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<io.annaclemens.xivchat.KeyVisualisation
android:id="@+id/trustClientPublicKeyColours"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Space
android:id="@+id/space3"
android:layout_width="match_parent"
android:layout_height="12dp" />
<TextView
android:id="@+id/textView7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/connect_trust_prompt" />
<LinearLayout
android:id="@+id/linearLayout2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/trustButtonNo"
style="@style/Widget.AppCompat.Button.ButtonBar.AlertDialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:minHeight="0dp"
android:text="@string/no" />
<Button
android:id="@+id/trustButtonYes"
style="@style/Widget.AppCompat.Button.ButtonBar.AlertDialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:minHeight="0dp"
android:text="@string/yes" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:showIn="navigation_view">
<group
android:id="@+id/menuMain"
android:checkableBehavior="single">
<item
android:id="@+id/nav_servers"
android:icon="@drawable/cloud_black_24dp"
android:title="@string/menu_servers" />
<item
android:id="@+id/nav_messages"
android:icon="@drawable/chat_black_24dp"
android:title="@string/menu_messages" />
</group>
<group android:id="@+id/menuSettings">
<item
android:id="@+id/nav_settings"
android:icon="@drawable/settings_black_24dp"
android:title="@string/nav_settings" />
</group>
</menu>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/app_bar_search"
android:icon="@drawable/ic_search_black_24dp"
android:title="Search"
app:actionViewClass="android.widget.SearchView"
app:showAsAction="always" />
<item
android:id="@+id/action_toggle_battle"
android:title="Toggle battle messages" />
<item
android:id="@+id/action_disconnect"
android:orderInCategory="100"
android:title="@string/notif_disconnect"
app:showAsAction="never" />
</menu>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/mobile_navigation"
app:startDestination="@id/nav_servers">
<fragment
android:id="@+id/nav_messages"
android:name="io.annaclemens.xivchat.ui.messages.MessagesFragment"
android:label="@string/menu_messages"
tools:layout="@layout/fragment_messages" />
<fragment
android:id="@+id/nav_settings"
android:name="io.annaclemens.xivchat.ui.settings.KeyVisualisationFragment"
android:label="@string/nav_settings"
tools:layout="@layout/fragment_settings" />
<fragment
android:id="@+id/nav_servers"
android:name="io.annaclemens.xivchat.ui.servers.ServersFragment"
android:label="@string/nav_servers">
<action
android:id="@+id/action_serversFragment_to_nav_messages"
app:destination="@id/nav_messages" />
</fragment>
</navigation>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#4DD0E1</color>
<color name="colorPrimaryDark">#00BCD4</color>
<color name="colorAccent">#4DD0E1</color>
</resources>

View File

@ -0,0 +1,8 @@
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="nav_header_vertical_spacing">8dp</dimen>
<dimen name="nav_header_height">176dp</dimen>
<dimen name="fab_margin">16dp</dimen>
</resources>

View File

@ -0,0 +1,2 @@
<resources>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#02CCEE</color>
</resources>

View File

@ -0,0 +1,59 @@
<resources>
<string name="app_name">XIVChat</string>
<string name="navigation_drawer_open">Open navigation drawer</string>
<string name="navigation_drawer_close">Close navigation drawer</string>
<string name="nav_header_title">Not logged in</string>
<string name="nav_header_desc">Navigation header</string>
<string name="action_settings">Settings</string>
<string name="menu_messages">Messages</string>
<string name="menu_connect">Connect</string>
<string name="connect_title">Connect to XIVChat</string>
<string name="connect_button">Connect</string>
<string name="connect_host">Hostname or IP address</string>
<string name="connect_port">Port (default: 14777)</string>
<string name="messages_placeholder">Send a message...</string>
<string name="connect_failure">Could not connect to XIVChat</string>
<string name="service_notification">Connected to XIVChat</string>
<string name="service_message">Tap to see messages.</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="connect_trust">Key verification</string>
<string name="public_key">Public key</string>
<string name="connect_trust_desc">You are attempting to connect to a server you have never connected to before. Please check the server and ensure the two keys below match.</string>
<string name="connect_trust_prompt">Do both keys match?</string>
<string name="connect_trust_server">Server: %1$s</string>
<string name="connect_trust_client">Client: %1$s</string>
<string name="messages_not_connected">Please connect to a server</string>
<string name="messages_unavailable">Chat is unavailable at this time</string>
<string name="nav_settings">Settings</string>
<string name="notif_disconnect">Disconnect</string>
<string name="settings_regenerate_desc">Regenerate your key pair. This will make all new connections to servers go through the trust process again.</string>
<string name="settings_regenerate_title">Client keys</string>
<string name="settings_regenerate_button">Regenerate</string>
<string name="settings_markdown">Treat messages as Markdown</string>
<string name="settings_client_keys_title">Client keys</string>
<string name="settings_client_keys_desc">View your public key or regenerate your key pair.</string>
<string name="settings_markdown_desc">When enabled, chat messages will be parsed for a subset of Markdown, enabling such features as italics, bold, links, etc.</string>
<string name="key">Key</string>
<string name="settings_current_key">Current key</string>
<string name="add_server_host">Hostname of IP address</string>
<string name="add_server_port">Port (default: 14777)</string>
<string name="add_server_name">Server name</string>
<string name="add_server_button">Add</string>
<string name="servers_empty">Add a server with the plus button below to get started.</string>
<string name="menu_servers">Servers</string>
<string name="nav_servers">Servers</string>
<string name="settings_clear_trusted_desc">Clears the list of trusted keys, making all new connections initially untrusted.</string>
<string name="settings_clear_trusted">Clear trusted keys</string>
<string name="server_delete">Delete server?</string>
<string name="server_delete_desc">Are you sure you want to delete this server?</string>
<string name="add_server_edit">Edit</string>
<string name="servers_instructions">Swipe left to delete, right to edit.</string>
<string name="add_server_missing_fields">Missing fields</string>
<string name="add_server_missing_fields_desc">Please fill in at least the name and host fields.</string>
<string name="add_server_title">Add server</string>
<string name="add_server_title_edit">Edit server</string>
<string name="unexpected_disconnect">Disconnected from server</string>
<string name="unexpected_disconnect_desc">Could not read from server.</string>
</resources>

View File

@ -0,0 +1,19 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
<style name="AppTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
</resources>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
app:allowDividerAbove="false"
app:allowDividerBelow="false"
app:iconSpaceReserved="false"
app:title="@string/settings_client_keys_title">
<io.annaclemens.xivchat.ui.settings.KeyPreference
app:allowDividerAbove="false"
app:allowDividerBelow="false"
app:iconSpaceReserved="false"
app:key="keyPairPublicDisplay"
app:persistent="false" />
<Preference
app:allowDividerAbove="false"
app:allowDividerBelow="true"
app:iconSpaceReserved="false"
app:key="keyPairPublic"
app:persistent="false"
app:summary="@string/settings_regenerate_desc"
app:title="@string/settings_regenerate_button" />
</PreferenceCategory>
<SwitchPreferenceCompat
app:allowDividerAbove="true"
app:allowDividerBelow="true"
app:defaultValue="false"
app:iconSpaceReserved="false"
app:key="markdownMode"
app:summary="@string/settings_markdown_desc"
app:title="@string/settings_markdown" />
<Preference
app:allowDividerAbove="true"
app:allowDividerBelow="false"
app:iconSpaceReserved="false"
app:key="trusted"
app:persistent="false"
app:summary="@string/settings_clear_trusted_desc"
app:title="@string/settings_clear_trusted" />
</PreferenceScreen>

View File

@ -0,0 +1,16 @@
package io.annaclemens.xivchat
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

33
build.gradle Normal file
View File

@ -0,0 +1,33 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = "1.4.10"
ext.ktor_version = '1.4.0'
ext.room_version = '2.2.5'
repositories {
google()
jcenter()
}
dependencies {
classpath "com.android.tools.build:gradle:4.0.1"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
google()
jcenter()
maven { url "https://dl.bintray.com/terl/lazysodium-maven" }
maven { url "https://dl.bintray.com/korlibs/korlibs/" }
}
apply plugin: 'kotlinx-serialization'
}
task clean(type: Delete) {
delete rootProject.buildDir
}

21
gradle.properties Normal file
View File

@ -0,0 +1,21 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official

Some files were not shown because too many files have changed in this diff Show More