chore: initial commit
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
XIVChat
|
|
@ -0,0 +1,158 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="LINE_SEPARATOR" value=" " />
|
||||
<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>
|
|
@ -0,0 +1,5 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
/build
|
|
@ -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'
|
||||
}
|
|
@ -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(...);
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>
|
After Width: | Height: | Size: 15 KiB |
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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?) {}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = ""
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
After Width: | Height: | Size: 42 KiB |
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>-->
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 5.1 KiB |
After Width: | Height: | Size: 3.6 KiB |
After Width: | Height: | Size: 8.1 KiB |
After Width: | Height: | Size: 5.0 KiB |
After Width: | Height: | Size: 11 KiB |
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,2 @@
|
|||
<resources>
|
||||
</resources>
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#02CCEE</color>
|
||||
</resources>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|