diff --git a/android/.build.yml b/android/.build.yml
new file mode 100644
index 0000000..a6c152c
--- /dev/null
+++ b/android/.build.yml
@@ -0,0 +1,32 @@
+image: ubuntu/jammy
+packages:
+ - openjdk-18-jdk
+ - gradle
+ - maven
+sources:
+ - https://git.sr.ht/~buzzert/KordophoneDroid
+secrets:
+ - a24d65d9-3e71-40e9-946d-0e9b73efacee # ~/.gradle/gradle.properties: contains keystore passwords
+ - 4fbe9d83-5f38-49c0-b93d-863d15e92a60 # ~/keystore.jks: Android keystore
+tasks:
+ - setup: |
+ wget https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
+ unzip commandlinetools-linux-11076708_latest.zip
+ mkdir android-sdk
+ yes | ./cmdline-tools/bin/sdkmanager --sdk_root=android-sdk --licenses
+ ./cmdline-tools/bin/sdkmanager --sdk_root=android-sdk "build-tools;34.0.0" "platforms;android-33"
+ - build: |
+ export ANDROID_HOME=~/android-sdk
+ cd KordophoneDroid/
+ ./gradlew assembleRelease
+ - prepare: |
+ cd KordophoneDroid/app/build/outputs/apk/release/
+ cp app-arm64-v8a-release.apk ~/kordophone-arm64-v8a-release.apk
+ cp app-armeabi-v7a-release.apk ~/kordophone-armeabi-v7a-release.apk
+ cp app-x86_64-release.apk ~/kordophone-x86_64-release.apk
+ cp app-x86-release.apk ~/kordophone-x86-release.apk
+artifacts:
+ - kordophone-arm64-v8a-release.apk
+ - kordophone-armeabi-v7a-release.apk
+ - kordophone-x86_64-release.apk
+ - kordophone-x86-release.apk
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..617b0ca
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,17 @@
+*.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
+local.properties
+
+.idea/
diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/android/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml
new file mode 100644
index 0000000..61a9130
--- /dev/null
+++ b/android/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml
new file mode 100644
index 0000000..dde22c6
--- /dev/null
+++ b/android/.idea/gradle.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/inspectionProfiles/Project_Default.xml b/android/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..44ca2d9
--- /dev/null
+++ b/android/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/kotlinc.xml b/android/.idea/kotlinc.xml
new file mode 100644
index 0000000..9a55c2d
--- /dev/null
+++ b/android/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml
new file mode 100644
index 0000000..2530821
--- /dev/null
+++ b/android/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/vcs.xml b/android/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/android/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/.gitignore b/android/app/.gitignore
new file mode 100644
index 0000000..67e07b8
--- /dev/null
+++ b/android/app/.gitignore
@@ -0,0 +1,2 @@
+/build
+/release
diff --git a/android/app/build.gradle b/android/app/build.gradle
new file mode 100644
index 0000000..f5f136c
--- /dev/null
+++ b/android/app/build.gradle
@@ -0,0 +1,155 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+ id 'kotlin-kapt'
+ id 'com.google.dagger.hilt.android'
+}
+
+android {
+ namespace 'net.buzzert.kordophonedroid'
+ compileSdk 33
+
+ defaultConfig {
+ applicationId "net.buzzert.kordophonedroid"
+ minSdk 30
+ targetSdk 33
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary true
+ }
+ }
+
+ signingConfigs {
+ if (project.hasProperty('RELEASE_STORE_FILE')) {
+ release {
+ storeFile file(RELEASE_STORE_FILE)
+ storePassword RELEASE_STORE_PASSWORD
+ keyAlias RELEASE_KEY_ALIAS
+ keyPassword RELEASE_KEY_PASSWORD
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+
+ if (project.hasProperty('RELEASE_STORE_FILE')) {
+ signingConfig signingConfigs.release
+ }
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8.toString()
+ }
+
+ buildFeatures {
+ compose true
+ }
+
+ composeOptions {
+ // Note: this is strictly tied to a kotlin version, but isn't the version of kotlin exactly.
+ // See: https://developer.android.com/jetpack/androidx/releases/compose-kotlin
+ kotlinCompilerExtensionVersion '1.4.8'
+ }
+
+ packagingOptions {
+ resources {
+ excludes += '/META-INF/{AL2.0,LGPL2.1}'
+ }
+ }
+
+ splits {
+ abi {
+ // Enable building for multiple ABIs
+ enable true
+
+ // Include x86/x86_64 APKs
+ include "x86", "x86_64"
+ universalApk false
+ }
+ }
+
+ buildToolsVersion '33.0.1'
+}
+
+kotlin {
+ jvmToolchain(8)
+}
+
+dependencies {
+ implementation "androidx.compose.material3:material3:1.1.1"
+ implementation "androidx.core:core-ktx:${kotlin_version}"
+
+ // Kordophone lib
+ implementation project(':backend')
+ implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
+
+ // Navigation
+ def nav_version = "2.6.0"
+
+ // Java language implementation
+ implementation "androidx.navigation:navigation-fragment:$nav_version"
+ implementation "androidx.navigation:navigation-ui:$nav_version"
+
+ // Kotlin
+ implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
+ implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
+
+ // Feature module Support
+ implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
+
+ // Testing Navigation
+ androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
+
+ // Jetpack Compose Integration
+ implementation "androidx.navigation:navigation-compose:$nav_version"
+
+ // Jetpack Compose
+ def compose_version = "1.4.3"
+ implementation "androidx.compose.ui:ui:$compose_version"
+ implementation "androidx.compose.material:material:$compose_version"
+ implementation "androidx.compose.foundation:foundation:$compose_version"
+
+
+ implementation "androidx.activity:activity-compose:$compose_version"
+
+ // Lifecycle
+ def lifecycle_version = "2.6.1"
+ implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
+
+
+ // Hilt (dependency injection)
+ implementation "com.google.dagger:hilt-android:${hilt_version}"
+ implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
+ kapt "com.google.dagger:hilt-compiler:${hilt_version}"
+
+ // Coil (image loading library)
+ implementation "io.coil-kt:coil:2.4.0"
+ implementation "io.coil-kt:coil-compose:2.4.0"
+
+ // Disk LRU Cache
+ implementation "com.jakewharton:disklrucache:2.0.2"
+
+ // Zooming in images
+ implementation "net.engawapg.lib:zoomable:$compose_version"
+
+ debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
+}
+
+// Allow references to generated code
+kapt {
+ correctErrorTypes true
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# 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
\ No newline at end of file
diff --git a/android/app/src/debug/kotlin/previews/Previews.kt b/android/app/src/debug/kotlin/previews/Previews.kt
new file mode 100644
index 0000000..e437353
--- /dev/null
+++ b/android/app/src/debug/kotlin/previews/Previews.kt
@@ -0,0 +1,137 @@
+package previews
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.tooling.preview.Preview
+import net.buzzert.kordophonedroid.R
+import net.buzzert.kordophonedroid.ui.conversationlist.ConversationListItem
+import net.buzzert.kordophonedroid.ui.conversationlist.ConversationListScreen
+import net.buzzert.kordophonedroid.ui.conversationlist.NoContentView
+import net.buzzert.kordophonedroid.ui.messagelist.AttachmentRowItem
+import net.buzzert.kordophonedroid.ui.messagelist.MessageEntry
+import net.buzzert.kordophonedroid.ui.messagelist.MessageListItem
+import net.buzzert.kordophonedroid.ui.messagelist.MessageMetadata
+import net.buzzert.kordophonedroid.ui.messagelist.MessageTranscript
+import net.buzzert.kordophonedroid.ui.settings.SettingsScreen
+import java.util.Date
+
+// - Conversation List
+
+@Preview
+@Composable
+fun ConversationListItemPreview() {
+ Column(modifier = Modifier.background(MaterialTheme.colors.background)) {
+ ConversationListItem(name = "James Magahern", id = "asdf", lastMessagePreview = "This is a test", date = Date(), isUnread = true) {}
+ }
+}
+
+@Preview
+@Composable
+fun ConversationListScreenPreview() {
+ ConversationListScreen()
+}
+
+// - Message List
+
+private fun testMessageMetadata(fromMe: Boolean, delivered: Boolean): MessageMetadata {
+ return MessageMetadata(
+ fromMe = fromMe,
+ fromAddress = if (fromMe) "" else "cool@cool.com",
+ date = Date(),
+ delivered = delivered,
+ )
+}
+
+private fun makeTestTextMessageItem(text: String, fromMe: Boolean, delivered: Boolean = true): MessageListItem {
+ return MessageListItem.TextMessage(
+ text = text,
+ metadata = testMessageMetadata(fromMe = fromMe, delivered = delivered)
+ )
+}
+
+private fun makeTestImageMessageItem(fromMe: Boolean, delivered: Boolean = true): MessageListItem {
+ return MessageListItem.ImageAttachmentMessage(
+ guid = "asdf",
+ metadata = testMessageMetadata(fromMe, delivered)
+ )
+}
+
+@Preview
+@Composable
+private fun MessageListScreenPreview() {
+ val messages = listOf(
+ makeTestImageMessageItem(false),
+
+ makeTestTextMessageItem("Hello", false),
+ makeTestTextMessageItem( "Hey there, this is a longer text message that might wrap to another line", true),
+ makeTestTextMessageItem("How's it going", fromMe = true, delivered = false)
+ ).reversed()
+
+ Scaffold() {
+ MessageTranscript(
+ messages = messages,
+ paddingValues = it,
+ showSenders = true,
+ attachmentUris = setOf(),
+ onAddAttachment = {},
+ onClearAttachments = {},
+ onSendMessage = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MessageEntryPreview() {
+ var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue("Hello this is some text that might wrap multiple lines to show that there must be some padding here. "))
+ }
+
+ MessageEntry(onSend = {}, onTextChanged = {}, textFieldValue = textState)
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MessageEntryWithAttachmentsPreview() {
+ var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue("Attachments"))
+ }
+
+ MessageEntry(onSend = {}, onTextChanged = {}, textFieldValue = textState, attachmentItems = listOf(
+ AttachmentRowItem(painterResource(id = R.drawable.sedona), "id")
+ ))
+}
+
+// - No content
+
+@Preview
+@Composable
+fun NoContentPreview() {
+ Scaffold {
+ NoContentView(
+ icon = R.drawable.storage,
+ text = "Server not configured",
+ onSettings = {},
+ modifier = Modifier.padding(it)
+ )
+ }
+}
+
+// - Settings
+
+@Preview
+@Composable
+fun SettingsPreview() {
+ SettingsScreen()
+}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..ceeff15
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..414eb0a
Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/AppModule.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/AppModule.kt
new file mode 100644
index 0000000..9bbc955
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/AppModule.kt
@@ -0,0 +1,41 @@
+package net.buzzert.kordophonedroid
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import net.buzzert.kordophone.backend.db.CachedChatDatabase
+import net.buzzert.kordophone.backend.server.APIClientFactory
+import net.buzzert.kordophone.backend.server.ChatRepository
+import net.buzzert.kordophonedroid.ui.attachments.AttachmentImageLoader
+import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+ @Singleton
+ @Provides
+ fun provideChatRepository(configRepository: ServerConfigRepository): ChatRepository {
+ val serverConfig = configRepository.serverConfig.value
+
+ val server = serverConfig.serverName
+ val authentication = serverConfig.authentication?.toBackendAuthentication()
+ val client = APIClientFactory.createClient(server, authentication)
+
+ val database = CachedChatDatabase.liveDatabase()
+ return ChatRepository(client, database)
+ }
+
+ @Singleton
+ @Provides
+ fun provideAttachmentFactory(
+ chatRepository: ChatRepository,
+ @ApplicationContext context: Context
+ ): AttachmentImageLoader
+ {
+ return AttachmentImageLoader(chatRepository, context)
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/KordophoneApplication.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/KordophoneApplication.kt
new file mode 100644
index 0000000..caa2729
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/KordophoneApplication.kt
@@ -0,0 +1,13 @@
+package net.buzzert.kordophonedroid
+
+import android.app.Application
+import androidx.hilt.navigation.compose.hiltViewModel
+import dagger.hilt.android.HiltAndroidApp
+import net.buzzert.kordophonedroid.data.AppContainer
+
+@HiltAndroidApp
+class KordophoneApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt
new file mode 100644
index 0000000..5473810
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt
@@ -0,0 +1,33 @@
+package net.buzzert.kordophonedroid
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.app.ActivityCompat
+import dagger.hilt.android.AndroidEntryPoint
+import net.buzzert.kordophonedroid.ui.KordophoneApp
+
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Ask for notifications
+ val hasPermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
+ if (hasPermission != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1234)
+ }
+
+ // Start update monitor service
+ val intent = Intent(this, UpdateMonitorService::class.java)
+ startService(intent)
+
+ setContent {
+ KordophoneApp()
+ }
+ }
+}
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/UpdateMonitorService.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/UpdateMonitorService.kt
new file mode 100644
index 0000000..32d32ab
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/UpdateMonitorService.kt
@@ -0,0 +1,161 @@
+package net.buzzert.kordophonedroid
+
+import android.Manifest
+import android.R
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationChannelGroup
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Color
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.net.toUri
+import androidx.navigation.NavDeepLinkBuilder
+import androidx.navigation.NavDeepLinkRequest
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import net.buzzert.kordophone.backend.model.Message
+import net.buzzert.kordophone.backend.server.ChatRepository
+import net.buzzert.kordophonedroid.ui.Destination
+import javax.inject.Inject
+
+const val PUSH_CHANNEL_ID = "net.buzzert.kordophone.persistentNotification"
+const val NEW_MESSAGE_CHANNEL_ID = "net.buzzert.kordophone.newMessage"
+
+const val UPDATER_LOG = "UpdateService"
+
+@AndroidEntryPoint
+class UpdateMonitorService: Service()
+{
+ @Inject lateinit var chatRepository: ChatRepository
+
+ private var newMessageID: Int = 0
+ private var watchJob: Job? = null
+ private val job = SupervisorJob()
+ private val scope = CoroutineScope(Dispatchers.IO + job)
+
+ private fun createNotificationChannel(channelId: String, channelName: String) {
+ val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT)
+ chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+ chan.lightColor = Color.BLUE
+
+ val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ service.createNotificationChannel(chan)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ Log.v(UPDATER_LOG, "UpdateMonitor onCreate: Begin watching for updates.")
+
+ createNotificationChannel(NEW_MESSAGE_CHANNEL_ID, "New Messages")
+
+ // Connect to monitor
+ chatRepository.beginWatchingForUpdates(scope)
+
+ // Connect to new message flow for notifications
+ watchJob?.cancel()
+ watchJob = scope.launch {
+ chatRepository.newMessages.collectLatest(::onReceiveNewMessage)
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ createNotificationChannel(PUSH_CHANNEL_ID, "Update Monitor Service")
+
+ val notificationIntent = Intent(this, MainActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, PUSH_CHANNEL_ID)
+ .setContentTitle("Kordophone Connected")
+ .setContentText("Kordophone is listening for new messages.")
+ .setSmallIcon(R.drawable.sym_action_chat)
+ .setContentIntent(pendingIntent)
+ .setShowWhen(false)
+ .setSilent(true)
+ .setOngoing(true)
+ .build()
+
+ startForeground(5738, notification)
+
+ // Restart if we get killed
+ return START_STICKY
+ }
+
+ private fun onReceiveNewMessage(message: Message) {
+ val hasPermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
+ if (hasPermission != PackageManager.PERMISSION_GRANTED) {
+ Log.e(UPDATER_LOG, "No permissions to post notifications.")
+ return
+ }
+
+ if (message.conversation.unreadCount == 0) {
+ // Not unread.
+ Log.v(UPDATER_LOG, "Ignoring read message.")
+ return
+ }
+
+ if (message.sender == null) {
+ // From me.
+ Log.v(UPDATER_LOG, "Ignoring message from me.")
+ return
+ }
+
+ if (message.conversation.isGroupChat) {
+ // For now, since these can be noisy and there's no UI for changing it, ignore group chats.
+ Log.v(UPDATER_LOG, "Ignoring group chat message.")
+ return
+ }
+
+ val guid = message.conversation.guid
+ val deepLinkIntent = Intent(
+ Intent.ACTION_VIEW,
+ "kordophone://messages/$guid".toUri(),
+ this,
+ MainActivity::class.java
+ )
+
+ val pendingIntent = PendingIntent.getActivity(this, 0, deepLinkIntent, PendingIntent.FLAG_IMMUTABLE)
+
+ val groupId = message.conversation.guid
+ val notification = NotificationCompat.Builder(this, NEW_MESSAGE_CHANNEL_ID)
+ .setContentTitle(message.sender)
+ .setContentText(message.text)
+ .setSmallIcon(R.drawable.stat_notify_chat)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(pendingIntent)
+ .setGroup(groupId)
+ .build()
+
+ val manager = NotificationManagerCompat.from(this)
+ manager.notify(newMessageID++, notification)
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ // no binding
+ return null
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ chatRepository.stopWatchingForUpdates()
+ job.cancel()
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/data/AppContainerImpl.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/data/AppContainerImpl.kt
new file mode 100644
index 0000000..a95e826
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/data/AppContainerImpl.kt
@@ -0,0 +1,15 @@
+package net.buzzert.kordophonedroid.data
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import dagger.hilt.android.lifecycle.HiltViewModel
+import net.buzzert.kordophone.backend.server.ChatRepository
+import javax.inject.Inject
+
+@HiltViewModel
+class AppContainer @Inject constructor(
+ val repository: ChatRepository
+) : ViewModel() {
+
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt
new file mode 100644
index 0000000..48ff26b
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt
@@ -0,0 +1,110 @@
+package net.buzzert.kordophonedroid.ui
+
+import androidx.compose.material.AlertDialog
+import androidx.compose.material.Button
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavHost
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navDeepLink
+import kotlinx.coroutines.flow.collectLatest
+import net.buzzert.kordophone.backend.server.ChatRepository
+
+import net.buzzert.kordophonedroid.data.AppContainer
+import net.buzzert.kordophonedroid.ui.attachments.AttachmentViewer
+import net.buzzert.kordophonedroid.ui.theme.KordophoneTheme
+import net.buzzert.kordophonedroid.ui.conversationlist.ConversationListScreen
+import net.buzzert.kordophonedroid.ui.messagelist.MessageListScreen
+import net.buzzert.kordophonedroid.ui.settings.SettingsScreen
+
+sealed class Destination(val route: String) {
+ object ConversationList : Destination("conversations")
+
+ object Settings : Destination("settings")
+
+ object MessageList : Destination("messages/{id}") {
+ fun createRoute(data: String) = "messages/$data"
+ }
+
+ object AttachmentViewer : Destination("attachment/{guid}") {
+ fun createRoute(guid: String) = "attachment/$guid"
+ }
+}
+
+val LocalNavController = compositionLocalOf { error("No nav host") }
+
+@Composable
+fun ErrorDialog(title: String, body: String, onDismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = { onDismiss() },
+ title = { Text(title) },
+ text = { Text(body) },
+ confirmButton = {
+ Button(onClick = { onDismiss() }) {
+ Text("OK")
+ }
+ }
+ )
+}
+
+@Composable
+fun KordophoneApp(
+ appContainer: AppContainer = hiltViewModel(),
+) {
+ KordophoneTheme {
+ val navController = rememberNavController()
+ val errorVisible = remember { mutableStateOf(null) }
+ val error = appContainer.repository.errorEncounteredChannel.collectAsStateWithLifecycle(
+ initialValue = null
+ )
+
+ LaunchedEffect(key1 = error.value) {
+ errorVisible.value = error.value
+ }
+
+ CompositionLocalProvider(LocalNavController provides navController) {
+ NavHost(
+ navController = navController,
+ startDestination = Destination.ConversationList.route,
+ ) {
+ composable(Destination.ConversationList.route) {
+ ConversationListScreen()
+ }
+
+ composable(
+ route = Destination.MessageList.route,
+ deepLinks = listOf(navDeepLink { uriPattern = "kordophone://messages/{id}" })
+ ) {
+ val conversationID = it.arguments?.getString("id")!!
+ MessageListScreen(conversationGUID = conversationID)
+ }
+
+ composable(Destination.Settings.route) {
+ SettingsScreen()
+ }
+
+ composable(Destination.AttachmentViewer.route) {
+ val guid = it.arguments?.getString("guid")!!
+ AttachmentViewer(attachmentGuid = guid)
+ }
+ }
+ }
+
+ errorVisible.value?.let {
+ ErrorDialog(title = it.title, body = it.description) {
+ errorVisible.value = null
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewModel.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewModel.kt
new file mode 100644
index 0000000..8249693
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewModel.kt
@@ -0,0 +1,116 @@
+package net.buzzert.kordophonedroid.ui.attachments
+
+import android.content.Context
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import coil.Coil
+import coil.ImageLoader
+import coil.ImageLoaderFactory
+import coil.annotation.ExperimentalCoilApi
+import coil.decode.DataSource
+import coil.decode.ImageSource
+import coil.disk.DiskCache
+import coil.fetch.FetchResult
+import coil.fetch.Fetcher
+import coil.fetch.SourceResult
+import coil.request.Options
+import dagger.hilt.android.qualifiers.ApplicationContext
+import net.buzzert.kordophone.backend.server.ChatRepository
+
+const val AVM_LOG: String = "AttachmentImageLoader"
+
+data class AttachmentFetchData(
+ val guid: String,
+ val preview: Boolean = false
+)
+
+class AttachmentImageLoader(
+ private val repository: ChatRepository,
+ @ApplicationContext val application: Context,
+) : ViewModel(), ImageLoaderFactory, Fetcher.Factory
+{
+ init {
+ // Register Coil image loader
+ Coil.setImageLoader(this)
+ }
+
+ override fun newImageLoader(): ImageLoader {
+ val factory = this
+ return ImageLoader.Builder(application)
+ .components {
+ // Adds the FetcherFactory
+ add(factory)
+ }
+ .build()
+ }
+
+ override fun create(
+ data: AttachmentFetchData,
+ options: Options,
+ imageLoader: ImageLoader
+ ): Fetcher {
+ return AttachmentFetcher(repository, application, data)
+ }
+}
+
+private class AttachmentFetcher(
+ val repository: ChatRepository,
+ val context: Context,
+ val data: AttachmentFetchData
+): Fetcher {
+ val cache = DiskCache.Builder()
+ .directory(context.cacheDir.resolve("attachments"))
+ .maxSizePercent(0.02)
+ .build()
+
+ val cacheKey: String get() { return data.guid + if (data.preview) "_preview" else "" }
+
+ @OptIn(ExperimentalCoilApi::class)
+ override suspend fun fetch(): FetchResult {
+ // Try loading from cache
+ var snapshot = cache.openSnapshot(cacheKey)
+ if (snapshot != null) {
+ Log.d(AVM_LOG, "Found attachment ${data.guid} in disk cache")
+ return SourceResult(
+ source = snapshot.toImageSource(),
+ dataSource = DataSource.DISK,
+ mimeType = null,
+ )
+ }
+
+ Log.d(AVM_LOG, "Loading attachment ${data.guid} from network")
+ val source = repository.fetchAttachmentDataSource(data.guid, data.preview)
+
+ // Save to cache
+ val editor = cache.openEditor(cacheKey)
+ if (editor != null) {
+ Log.d(AVM_LOG, "Writing attachment ${data.guid} to disk cache")
+
+ cache.fileSystem.write(editor.data) {
+ source.readAll(this)
+ }
+
+ snapshot = editor.commitAndOpenSnapshot()
+ if (snapshot != null) {
+ return SourceResult(
+ source = snapshot.toImageSource(),
+ dataSource = DataSource.NETWORK,
+ mimeType = null
+ )
+ }
+ }
+
+ // We'll go down this path if for some reason we couldn't save to cache.
+ return SourceResult(
+ source = ImageSource(source, context),
+ dataSource = DataSource.NETWORK,
+ mimeType = null,
+ )
+ }
+
+ @OptIn(ExperimentalCoilApi::class)
+ private fun DiskCache.Snapshot.toImageSource(): ImageSource {
+ val fileSystem = cache.fileSystem
+ return ImageSource(data, fileSystem, cacheKey, this)
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewer.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewer.kt
new file mode 100644
index 0000000..24ff3f5
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/attachments/AttachmentViewer.kt
@@ -0,0 +1,70 @@
+package net.buzzert.kordophonedroid.ui.attachments
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import coil.compose.SubcomposeAsyncImage
+import coil.request.ImageRequest
+import net.buzzert.kordophonedroid.ui.LocalNavController
+import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
+import net.engawapg.lib.zoomable.rememberZoomState
+import net.engawapg.lib.zoomable.zoomable
+
+@Composable
+fun AttachmentViewer(attachmentGuid: String) {
+ var topBarVisible = remember { mutableStateOf(true) }
+ val navController = LocalNavController.current
+ Scaffold(topBar = {
+ KordophoneTopAppBar(
+ title = "Attachment",
+ backAction = { navController.popBackStack() },
+ visible = topBarVisible.value
+ )
+ }) { padding ->
+ val zoomState = rememberZoomState()
+ val interactionSource = remember { MutableInteractionSource() }
+ val data = AttachmentFetchData(attachmentGuid, preview = false)
+ Column(modifier = Modifier.padding(padding)) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ SubcomposeAsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(data)
+ .crossfade(true)
+ .build(),
+ contentDescription = "",
+ loading = {
+ Box {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ },
+ modifier = Modifier
+ .zoomable(zoomState)
+ .fillMaxWidth()
+ .align(Alignment.CenterHorizontally)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null
+ ) {
+ topBarVisible.value = !topBarVisible.value
+ }
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt
new file mode 100644
index 0000000..82ca221
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt
@@ -0,0 +1,248 @@
+package net.buzzert.kordophonedroid.ui.conversationlist
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.Divider
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.material.TopAppBar
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material.pullrefresh.PullRefreshIndicator
+import androidx.compose.material.pullrefresh.pullRefresh
+import androidx.compose.material.pullrefresh.rememberPullRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.launch
+import net.buzzert.kordophone.backend.model.Conversation
+import net.buzzert.kordophonedroid.R
+import net.buzzert.kordophonedroid.ui.Destination
+import net.buzzert.kordophonedroid.ui.LocalNavController
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Date
+
+fun formatDateTime(dateTime: LocalDateTime): String {
+ val formatter: DateTimeFormatter = if (LocalDate.now().isEqual(dateTime.toLocalDate())) {
+ DateTimeFormatter.ofPattern("HH:mm") // show just the time
+ } else {
+ DateTimeFormatter.ofPattern("M/d/yy") // show day/month/year
+ }
+
+ return dateTime.format(formatter)
+}
+
+@Composable
+fun ConversationListScreen(
+ viewModel: ConversationListViewModel = hiltViewModel(),
+) {
+ val conversations by viewModel.conversations.collectAsStateWithLifecycle(initialValue = emptyList())
+ val encounteredError by viewModel.encounteredConnectionError
+
+ ConversationListView(
+ conversations = conversations,
+ isConfigured = viewModel.isServerConfigured,
+ encounteredError = encounteredError,
+ onRefresh = suspend { viewModel.refresh() }
+ )
+}
+
+@Composable
+@OptIn(ExperimentalMaterialApi::class)
+fun ConversationListView(
+ conversations: List,
+ isConfigured: Boolean = true,
+ encounteredError: Boolean = false,
+ onRefresh: suspend () -> Unit,
+) {
+ val listState = rememberLazyListState()
+
+ val refreshScope = rememberCoroutineScope()
+ var refreshing by remember { mutableStateOf(false) }
+
+ fun refresh() = refreshScope.launch {
+ refreshing = true
+ onRefresh()
+ refreshing = false
+ }
+
+ val showErrorScreen = conversations.isEmpty() && encounteredError
+
+ val refreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = ::refresh)
+
+ val navController = LocalNavController.current
+ val onSettingsInvoked = { navController.navigate(Destination.Settings.route) }
+ Scaffold(
+ topBar = {
+ TopAppBar(title = { Text("Conversations") }, actions = {
+ IconButton(onClick = onSettingsInvoked) {
+ Icon(Icons.Rounded.Settings, contentDescription = "Settings")
+ }
+ })
+ }
+ ) {
+ if (showErrorScreen) {
+ NoContentView(
+ icon = R.drawable.error,
+ text = "Connection error",
+ onSettings = onSettingsInvoked
+ )
+ } else if (!isConfigured) {
+ NoContentView(
+ icon = R.drawable.storage,
+ text = "Server not configured",
+ onSettings = onSettingsInvoked
+ )
+ } else {
+ Box(Modifier.pullRefresh(refreshState)) {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier
+ .padding(it)
+ .fillMaxSize()
+ ) {
+ items(conversations) { conversation ->
+ val clickHandler = {
+ val route = Destination.MessageList.createRoute(conversation.guid)
+ navController.navigate(route)
+ }
+
+ ConversationListItem(
+ name = conversation.formattedDisplayName(),
+ id = conversation.guid,
+ isUnread = conversation.unreadCount > 0,
+ lastMessagePreview = conversation.lastMessagePreview ?: "",
+ date = conversation.date,
+ onClick = clickHandler
+ )
+ }
+ }
+
+ PullRefreshIndicator(
+ refreshing = refreshing,
+ state = refreshState,
+ modifier = Modifier.align(Alignment.TopCenter),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ConversationListItem(
+ name: String,
+ id: String,
+ isUnread: Boolean,
+ lastMessagePreview: String,
+ date: Date,
+ onClick: () -> Unit
+) {
+ val unreadSize = 12.dp
+ val horizontalPadding = 8.dp
+ val verticalPadding = 14.dp
+
+ Row(
+ Modifier
+ .clickable(onClick = onClick)
+ ) {
+ Spacer(Modifier.width(horizontalPadding))
+
+ // Unread icon
+ if (isUnread) {
+ UnreadIndicator(
+ size = unreadSize,
+ modifier = Modifier.align(Alignment.CenterVertically)
+ )
+ } else {
+ Spacer(modifier = Modifier.size(unreadSize))
+ }
+
+ Spacer(Modifier.width(horizontalPadding))
+
+ Column {
+ Spacer(Modifier.height(verticalPadding))
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ name,
+ style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold),
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ modifier = Modifier.weight(1f, fill = true)
+ )
+
+ Spacer(modifier = Modifier)
+
+ Text(
+ formatDateTime(
+ date.toInstant()
+ .atZone(ZoneId.systemDefault())
+ .toLocalDateTime()
+ ),
+ color = MaterialTheme.colors.onBackground.copy(alpha = 0.4f),
+ maxLines = 1,
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ ,
+ )
+
+ Spacer(Modifier.width(horizontalPadding))
+ }
+
+ Text(lastMessagePreview, maxLines = 1, overflow = TextOverflow.Ellipsis)
+
+ Spacer(Modifier.height(verticalPadding))
+ Divider()
+ }
+ }
+}
+
+@Composable
+fun UnreadIndicator(size: Dp, modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .size(size)
+ .background(
+ color = MaterialTheme.colors.primary,
+ shape = CircleShape
+ )
+ )
+}
+
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt
new file mode 100644
index 0000000..235399c
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt
@@ -0,0 +1,74 @@
+package net.buzzert.kordophonedroid.ui.conversationlist
+
+import android.util.Log
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import net.buzzert.kordophone.backend.model.Conversation
+import net.buzzert.kordophone.backend.server.APIClientFactory
+import net.buzzert.kordophone.backend.server.ChatRepository
+import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
+import javax.inject.Inject
+
+const val CL_VM_LOG: String = "ConversationListViewModel"
+
+@HiltViewModel
+class ConversationListViewModel @Inject constructor(
+ private val chatRepository: ChatRepository,
+ private val serverConfigRepository: ServerConfigRepository,
+) : ViewModel() {
+ val conversations: Flow>
+ get() = chatRepository.conversationChanges
+ .shareIn(viewModelScope, started = SharingStarted.WhileSubscribed())
+ .map {
+ it.sortedBy { it.date }
+ .reversed()
+ }
+
+ val isServerConfigured: Boolean
+ get() = chatRepository.isConfigured
+
+ val encounteredConnectionError: State
+ get() = _encounteredConnectionError
+
+ private val _encounteredConnectionError = mutableStateOf(false)
+
+ init {
+ // Watch for config changes
+ viewModelScope.launch {
+ serverConfigRepository.serverConfig.collect { config ->
+ Log.d(CL_VM_LOG, "Got settings change.")
+
+ // Make new APIClient
+ val baseURL = config.serverName
+ val authentication = config.authentication?.toBackendAuthentication()
+ val apiClient = APIClientFactory.createClient(baseURL, authentication)
+ chatRepository.updateAPIClient(apiClient)
+
+ // Perform db synchronization
+ withContext(Dispatchers.IO) {
+ chatRepository.synchronize()
+ }
+ }
+ }
+
+ viewModelScope.launch {
+ chatRepository.errorEncounteredChannel.collect {
+ _encounteredConnectionError.value = true
+ }
+ }
+ }
+
+ suspend fun refresh() {
+ chatRepository.synchronize()
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/NoContentView.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/NoContentView.kt
new file mode 100644
index 0000000..6d8a229
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/NoContentView.kt
@@ -0,0 +1,61 @@
+package net.buzzert.kordophonedroid.ui.conversationlist
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.Button
+import androidx.compose.material.Icon
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+
+@Composable
+fun NoContentView(
+ @DrawableRes icon: Int,
+ text: String,
+ onSettings: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .fillMaxHeight(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ painter = painterResource(icon),
+ "server icon",
+ modifier = Modifier
+ .height(150.dp)
+ .width(150.dp)
+ .alpha(0.5F)
+ )
+
+ Spacer(Modifier)
+
+ Text(
+ text = text,
+ fontSize = 5.0.em,
+ modifier = Modifier
+ .alpha(0.5F)
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(onClick = onSettings) {
+ Text("Settings")
+ }
+ }
+}
+
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageEntryView.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageEntryView.kt
new file mode 100644
index 0000000..6af3b9e
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageEntryView.kt
@@ -0,0 +1,166 @@
+package net.buzzert.kordophonedroid.ui.messagelist
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material.Divider
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.unit.dp
+import net.buzzert.kordophonedroid.R
+
+data class AttachmentRowItem(
+ val painter: Painter,
+ val id: String,
+)
+
+@Composable
+fun AttachmentRow(
+ attachmentItems: List,
+ onClear: () -> Unit,
+) {
+ Divider()
+
+ Row(
+ modifier = Modifier
+ .height(120.dp)
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.onSurface.copy(0.08f))
+ .padding(8.dp)
+ ) {
+ LazyRow {
+ attachmentItems.forEach { attachmentItem ->
+ item {
+ Image(
+ painter = attachmentItem.painter,
+ contentDescription = "attachment",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .aspectRatio(1.0f)
+ .clip(RoundedCornerShape(4.dp))
+ )
+
+ Spacer(Modifier.width(4.dp))
+ }
+ }
+ }
+
+ Spacer(Modifier.weight(1f))
+
+ ElevatedButton(
+ onClick = onClear,
+ colors = ButtonDefaults.elevatedButtonColors(
+ containerColor = MaterialTheme.colors.background
+ ),
+ modifier = Modifier.align(Alignment.CenterVertically)
+ ) {
+ Text("Remove")
+ }
+ }
+}
+
+@Composable
+fun MessageEntry(
+ textFieldValue: TextFieldValue,
+ attachmentItems: List = listOf(),
+
+ onAddAttachment: () -> Unit = {},
+ onClearAttachments: () -> Unit = {},
+ onTextChanged: (TextFieldValue) -> Unit,
+ onSend: () -> Unit,
+) {
+ Column {
+ if (attachmentItems.isNotEmpty()) {
+ AttachmentRow(attachmentItems, onClear = onClearAttachments)
+ }
+
+ Row(
+ modifier = Modifier
+ .background(MaterialTheme.colors.onSurface.copy(alpha = 0.18f))
+ .fillMaxWidth()
+ .padding(vertical = 8.dp, horizontal = 4.dp)
+ .imePadding()
+ .navigationBarsPadding()
+ ) {
+ IconButton(
+ onClick = onAddAttachment,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.attach_file),
+ contentDescription = "Attach File"
+ )
+ }
+
+ Spacer(Modifier.width(8.dp))
+
+ Surface(
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier
+ .weight(1f)
+ .align(Alignment.CenterVertically)
+ .shadow(3.dp)
+ ) {
+ BasicTextField(
+ value = textFieldValue,
+ onValueChange = { onTextChanged(it) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ cursorBrush = SolidColor(MaterialTheme.colors.onBackground),
+ textStyle = MaterialTheme.typography.body1.copy(MaterialTheme.colors.onBackground),
+ decorationBox = { textContent ->
+ if (textFieldValue.text.isEmpty()) {
+ Text(
+ text = "Message",
+ style = MaterialTheme.typography.body1.copy(
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
+ )
+ )
+ }
+
+ textContent()
+ }
+ )
+ }
+
+ Spacer(Modifier.width(8.dp))
+
+ Button(
+ onClick = onSend,
+ enabled = (attachmentItems.isNotEmpty() || textFieldValue.text.isNotEmpty())
+ ) {
+ Text(text = "Send")
+ }
+
+ Spacer(Modifier.width(8.dp))
+ }
+ }
+}
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt
new file mode 100644
index 0000000..b79e173
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt
@@ -0,0 +1,433 @@
+package net.buzzert.kordophonedroid.ui.messagelist
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.core.app.NotificationManagerCompat
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.compose.SubcomposeAsyncImage
+import coil.compose.rememberAsyncImagePainter
+import coil.request.ImageRequest
+import net.buzzert.kordophone.backend.model.GUID
+import net.buzzert.kordophonedroid.ui.Destination
+import net.buzzert.kordophonedroid.ui.LocalNavController
+import net.buzzert.kordophonedroid.ui.attachments.AttachmentFetchData
+import net.buzzert.kordophonedroid.ui.shared.LINK_ANNOTATION_TAG
+import net.buzzert.kordophonedroid.ui.shared.linkify
+import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
+import java.text.SimpleDateFormat
+import java.time.Duration
+import java.util.Date
+
+private val IncomingChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
+private val OutgoingChatBubbleShape = RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp)
+
+data class MessageMetadata(
+ val fromAddress: String,
+ val fromMe: Boolean,
+ val date: Date,
+ val delivered: Boolean = true,
+)
+
+interface MessageMetadataProvider {
+ val metadata: MessageMetadata
+}
+
+sealed class MessageListItem: MessageMetadataProvider {
+ data class TextMessage(val text: String, override val metadata: MessageMetadata): MessageListItem()
+ data class ImageAttachmentMessage(val guid: String, override val metadata: MessageMetadata): MessageListItem()
+}
+
+@Composable
+fun MessageListScreen(
+ conversationGUID: GUID,
+ viewModel: MessageListViewModel = hiltViewModel(),
+) {
+ viewModel.conversationGUID = conversationGUID
+
+ // Synchronize on launch
+ val context = LocalContext.current
+ LaunchedEffect(Unit) {
+ // Clear notifications for this conversation
+ with(NotificationManagerCompat.from(context)) {
+ // Not sure how to cancel individual notifications, or groups yet...
+ cancelAll()
+ }
+
+ viewModel.markAsRead()
+ viewModel.synchronize()
+ }
+
+ val messages by viewModel.messages.collectAsStateWithLifecycle(initialValue = listOf())
+
+ val messageItems = mutableListOf()
+ for (message in messages) {
+ val metadata = MessageMetadata(
+ fromMe = message.sender == null,
+ date = message.date,
+ fromAddress = message.sender ?: "",
+ delivered = !viewModel.isPendingMessage(message)
+ )
+
+ // Collect attachments
+ message.attachmentGUIDs?.let { guids ->
+ guids.forEach { guid ->
+ val item = MessageListItem.ImageAttachmentMessage(
+ guid = guid,
+ metadata = metadata
+ )
+
+ messageItems.add(item)
+ }
+ }
+
+ val displayText = message.displayText.trim()
+ if (displayText.isNotEmpty()) {
+ val textMessage = MessageListItem.TextMessage(text = displayText, metadata = metadata)
+ messageItems.add(textMessage)
+ }
+ }
+
+ var attachmentUris by remember { mutableStateOf>(mutableSetOf()) }
+ val imagePicker = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? ->
+ uri?.let {
+ attachmentUris = attachmentUris.plus(it)
+ }
+ }
+
+ val navController = LocalNavController.current
+ Scaffold(
+ topBar = {
+ KordophoneTopAppBar(title = viewModel.title, backAction = { navController.popBackStack() })
+ }) { padding ->
+ MessageTranscript(
+ messages = messageItems,
+ paddingValues = padding,
+ showSenders = viewModel.isGroupChat,
+ attachmentUris = attachmentUris,
+ onAddAttachment = {
+ imagePicker.launch("image/*")
+ },
+ onClearAttachments = {
+ attachmentUris = setOf()
+ },
+ onSendMessage = { text ->
+ viewModel.enqueueOutgoingMessage(
+ text = text,
+ attachmentUris = attachmentUris,
+ context = context
+ )
+
+ // Clear pending attachments
+ attachmentUris = setOf()
+ }
+ )
+ }
+}
+
+@Composable
+fun MessageTranscript(
+ messages: List,
+ paddingValues: PaddingValues,
+ showSenders: Boolean,
+ attachmentUris: Set,
+ onAddAttachment: () -> Unit,
+ onClearAttachments: () -> Unit,
+ onSendMessage: (text: String) -> Unit,
+) {
+ val scrollState = rememberLazyListState()
+ var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue())
+ }
+
+ val attachmentRowItems = attachmentUris.map {
+ AttachmentRowItem(
+ painter = rememberAsyncImagePainter(model = it),
+ id = "attachmentID"
+ )
+ }
+
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)) {
+
+ Messages(
+ messages = messages,
+ modifier = Modifier.weight(1f),
+ showSenders = showSenders,
+ scrollState = scrollState
+ )
+
+ MessageEntry(
+ onTextChanged = { textState = it },
+ textFieldValue = textState,
+ attachmentItems = attachmentRowItems,
+ onAddAttachment = onAddAttachment,
+ onClearAttachments = onClearAttachments,
+ onSend = {
+ onSendMessage(textState.text)
+
+ // Clear text state
+ textState = TextFieldValue()
+ },
+ )
+ }
+}
+
+@Composable
+fun Messages(
+ messages: List,
+ showSenders: Boolean,
+ modifier: Modifier = Modifier,
+ scrollState: LazyListState
+) {
+ Box(modifier = modifier) {
+ LazyColumn(
+ reverseLayout = true,
+ state = scrollState,
+ contentPadding = PaddingValues(vertical = 8.dp),
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp)
+ ) {
+ val dateFormatter = SimpleDateFormat.getDateTimeInstance()
+
+ for (index in messages.indices) {
+ val content = messages[index]
+
+ var previousMessage: MessageListItem? = null
+ if ((index + 1) < messages.count()) {
+ previousMessage = messages[index + 1]
+ }
+
+ val duration: Duration? = if (previousMessage != null) Duration.between(
+ previousMessage.metadata.date.toInstant(),
+ content.metadata.date.toInstant()
+ ) else null
+
+ val leapMessage = (
+ duration == null || (
+ duration.toMinutes() > 30
+ )
+ )
+
+ val repeatMessage = !leapMessage && (
+ previousMessage == null || (
+ (previousMessage.metadata.fromAddress == content.metadata.fromAddress)
+ )
+ )
+
+ // Remember: This is upside down.
+ item {
+ when (content) {
+ is MessageListItem.TextMessage -> {
+ MessageBubble(
+ text = content.text,
+ mine = content.metadata.fromMe,
+ modifier = Modifier
+ .alpha(if (!content.metadata.delivered) 0.5F else 1.0f)
+ )
+ }
+
+ is MessageListItem.ImageAttachmentMessage -> {
+ ImageBubble(guid = content.guid, mine = content.metadata.fromMe)
+ }
+ }
+
+
+ // Sender
+ if (!content.metadata.fromMe && showSenders && !repeatMessage) {
+ Text(
+ text = content.metadata.fromAddress,
+ style = MaterialTheme.typography.subtitle2.copy(
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
+ ),
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ }
+
+ // Greater than 30 minutes: show date:
+ if (duration != null) {
+ if (duration.toMinutes() > 30) {
+ val formattedDate = dateFormatter.format(content.metadata.date)
+ Text(
+ text = formattedDate,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.caption.copy(
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(18.dp),
+ )
+ }
+
+ // Greater than five minutes: add a bit of space.
+ else if (duration.toMinutes() > 5) {
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun BubbleScaffold(
+ mine: Boolean,
+ modifier: Modifier,
+ content: @Composable () -> Unit
+) {
+ Column() {
+ Row(modifier = modifier.fillMaxWidth()) {
+ if (mine) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(0.8f),
+ horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start,
+ ) {
+ content()
+ }
+
+ if (!mine) { Spacer(modifier = Modifier.weight(1f)) }
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+}
+
+@Composable
+fun MessageBubble(
+ text: String,
+ mine: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val backgroundBubbleColor = if (mine) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
+
+ // Linkify text
+ val annotatedString = text.linkify()
+ val urlHandler = LocalUriHandler.current
+
+ BubbleScaffold(mine = mine, modifier = modifier) {
+ Surface(
+ color = backgroundBubbleColor,
+ shape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape,
+ ) {
+ ClickableText(
+ text = annotatedString,
+ style = MaterialTheme.typography.body2,
+ modifier = Modifier
+ .padding(12.dp),
+ onClick = { index ->
+ annotatedString
+ .getStringAnnotations(LINK_ANNOTATION_TAG, index, index)
+ .firstOrNull()?.let {
+ urlHandler.openUri(it.item)
+ }
+ }
+ )
+ }
+ }
+}
+
+@Composable
+fun ImageBubble(
+ guid: String,
+ mine: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val shape: RoundedCornerShape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape
+ val attachmentFetchData = AttachmentFetchData(guid, preview = true)
+ val navController = LocalNavController.current
+
+ BubbleScaffold(mine = mine, modifier = modifier) {
+ SubcomposeAsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(attachmentFetchData)
+ .crossfade(true)
+ .build(),
+ loading = {
+ Box(
+ modifier = Modifier
+ .background(Color.LightGray)
+ .size(width = 220.dp, height = 200.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ },
+ error = {
+ val error = it.result.throwable.message
+ Surface(
+ color = Color.Red
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Text(
+ text = "Error loading attachment",
+ style = TextStyle.Default.copy(
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ )
+ )
+
+ Text("$error")
+ }
+ }
+ },
+ contentDescription = "Image attachment",
+ modifier = Modifier
+ .clip(shape)
+ .clickable {
+ navController.navigate(Destination.AttachmentViewer.createRoute(guid))
+ }
+ )
+ }
+}
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt
new file mode 100644
index 0000000..190729c
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt
@@ -0,0 +1,115 @@
+package net.buzzert.kordophonedroid.ui.messagelist
+
+import android.content.Context
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import net.buzzert.kordophone.backend.model.Conversation
+import net.buzzert.kordophone.backend.model.GUID
+import net.buzzert.kordophone.backend.model.Message
+import net.buzzert.kordophone.backend.model.OutgoingMessage
+import net.buzzert.kordophone.backend.model.UploadingAttachmentMetadata
+import net.buzzert.kordophone.backend.server.ChatRepository
+import net.buzzert.kordophonedroid.ui.attachments.AttachmentImageLoader
+import javax.inject.Inject
+
+const val MVM_LOG: String = "MessageListViewModel"
+
+@HiltViewModel
+class MessageListViewModel @Inject constructor(
+ private val repository: ChatRepository,
+ private val imageLoader: AttachmentImageLoader
+) : ViewModel()
+{
+ var conversationGUID: GUID? = null
+ set(value) {
+ field = value
+ value?.let {
+ conversation = repository.conversationForGuid(it)
+ }
+ }
+
+ private var conversation: Conversation? = null
+ private val pendingMessages: MutableStateFlow> = MutableStateFlow(listOf())
+
+ init {
+ // TODO: Need to handle settings changes here!!
+
+ viewModelScope.launch {
+ // Remove pending message after message is delivered.
+ // By now, the repository should've committed this to the store.
+ repository.messageDeliveredChannel.collectLatest { event ->
+ pendingMessages.value =
+ pendingMessages.value.filter { it.guid != event.requestGuid }
+ }
+ }
+ }
+
+ val messages: Flow>
+ get() = repository.messagesChanged(conversation!!)
+ .combine(pendingMessages) { a, b -> a.union(b.map { it.asMessage() }) }
+ .map { messages ->
+ messages
+ .sortedBy { it.date }
+ .reversed()
+ }
+
+ val title: String get() = conversation!!.formattedDisplayName()
+
+ val isGroupChat: Boolean get() = conversation!!.isGroupChat
+
+ fun enqueueOutgoingMessage(
+ text: String,
+ attachmentUris: Set,
+ context: Context,
+ ) {
+ val outgoingMessage = OutgoingMessage(
+ body = text,
+ conversation = conversation!!,
+ attachmentUris = attachmentUris,
+ attachmentDataSource = { uri ->
+ val resolver = context.contentResolver
+ val inputStream = resolver.openInputStream(uri)
+ val mimeType = resolver.getType(uri)
+
+ val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: "jpg"
+ val filename = uri.lastPathSegment + ".$extension"
+
+ if (inputStream != null && mimeType != null) {
+ UploadingAttachmentMetadata(
+ inputStream = inputStream,
+ mimeType = mimeType,
+ filename = filename
+ )
+ } else {
+ null
+ }
+ }
+ )
+
+ val outgoingGUID = repository.enqueueOutgoingMessage(outgoingMessage)
+ pendingMessages.value = pendingMessages.value + listOf(outgoingMessage)
+ }
+
+ fun isPendingMessage(message: Message): Boolean {
+ return pendingMessages.value.any { it.guid == message.guid }
+ }
+
+ fun markAsRead() = viewModelScope.launch {
+ repository.markConversationAsRead(conversation!!)
+ }
+
+ fun synchronize() = viewModelScope.launch {
+ repository.synchronizeConversation(conversation!!, limit = 100)
+ }
+}
+
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/settings/SettingsScreen.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..748e9d0
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/settings/SettingsScreen.kt
@@ -0,0 +1,226 @@
+package net.buzzert.kordophonedroid.ui.settings
+
+import android.provider.Settings
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Button
+import androidx.compose.material.Divider
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.hilt.navigation.compose.hiltViewModel
+import net.buzzert.kordophonedroid.R
+import net.buzzert.kordophonedroid.ui.LocalNavController
+import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
+
+@Composable
+fun SettingsScreen(
+ viewModel: SettingsViewModel = hiltViewModel(),
+) {
+ val navController = LocalNavController.current
+ Scaffold(
+ topBar = {
+ KordophoneTopAppBar(
+ title = "Settings",
+ backAction = { navController.popBackStack() },
+ )
+ },
+
+ ) {
+ SettingsFormView(
+ viewModel = viewModel,
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(it)
+ .padding(6.dp)
+ )
+ }
+}
+
+@Composable
+fun SettingsFormView(
+ viewModel: SettingsViewModel,
+ modifier: Modifier = Modifier
+) {
+ val serverName = viewModel.serverPreference.collectAsState()
+ val userName = viewModel.usernamePreference.collectAsState()
+ val password = viewModel.passwordPreference.collectAsState()
+
+ Column(modifier) {
+ var serverNameInput by remember { mutableStateOf(TextFieldValue(serverName.value)) }
+
+ SettingsTextField(
+ name = "Server",
+ icon = R.drawable.storage,
+ state = serverName,
+ onSave = { viewModel.saveServerPreference(serverNameInput.text) }
+ ) { state ->
+ TextField(serverNameInput, onValueChange = {
+ serverNameInput = it
+ })
+ }
+
+ var usernameInput by remember { mutableStateOf(TextFieldValue(userName.value)) }
+ var passwordInput by remember { mutableStateOf(TextFieldValue(password.value)) }
+ SettingsTextField(
+ name = "Authentication",
+ icon = R.drawable.account_circle,
+ state = userName,
+ onSave = {
+ viewModel.saveAuthenticationPreferences(usernameInput.text, passwordInput.text)
+ }
+ ) {
+ Column() {
+ TextField(
+ value = usernameInput,
+ onValueChange = { usernameInput = it },
+ label = { Text("Username") },
+ )
+
+ TextField(
+ value = passwordInput,
+ onValueChange = { passwordInput = it },
+ label = {Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterialApi::class)
+fun SettingsTextField(
+ name: String,
+ @DrawableRes icon: Int,
+ state: State,
+ onSave: () -> Unit,
+ dialogContent: @Composable (State) -> Unit,
+) {
+ var showingDialog by remember { mutableStateOf(false) }
+
+ if (showingDialog) {
+ Dialog(
+ onDismissRequest = { showingDialog = false }
+ ) {
+ EditDialog(
+ name = name,
+ onDismiss = {
+ onSave()
+ showingDialog = false
+ },
+ content = { dialogContent(state) }
+ )
+ }
+ }
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ ,
+ onClick = {
+ showingDialog = true
+ },
+ ) {
+ val valueString = state.value.toString().ifEmpty { "(Not set)" }
+
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start,
+ modifier = Modifier.padding(vertical = 8.dp)
+ ) {
+ Icon(
+ painterResource(id = icon),
+ contentDescription = "",
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.padding(8.dp)) {
+ // Title
+ Text(
+ text = name,
+ style = MaterialTheme.typography.body1,
+ textAlign = TextAlign.Start,
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Value
+ Text(
+ text = valueString,
+ style = MaterialTheme.typography.body2,
+ textAlign = TextAlign.Start,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
+ )
+ }
+ }
+
+ Divider()
+ }
+ }
+}
+
+@Composable
+private fun EditDialog(
+ name: String,
+ onDismiss: () -> Unit,
+ content: @Composable () -> Unit,
+) {
+ Surface() {
+ Column(
+ modifier = Modifier
+ .wrapContentHeight()
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Text(name)
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ content()
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row {
+ Spacer(modifier = Modifier.weight(1f))
+ Button(onClick = {
+ onDismiss()
+ }) {
+ Text("Save")
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..2c809dc
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,51 @@
+package net.buzzert.kordophonedroid.ui.settings
+
+import androidx.compose.runtime.MutableState
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import net.buzzert.kordophonedroid.ui.shared.ServerAuthentication
+import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
+import javax.inject.Inject
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ val serverConfigRepository: ServerConfigRepository
+) : ViewModel() {
+ private val _serverPreference: MutableStateFlow = MutableStateFlow("")
+ var serverPreference = _serverPreference.asStateFlow()
+
+ private val _usernamePreference: MutableStateFlow = MutableStateFlow("")
+ var usernamePreference = _usernamePreference.asStateFlow()
+
+ private val _passwordPreference: MutableStateFlow = MutableStateFlow("")
+ var passwordPreference = _passwordPreference.asStateFlow()
+
+ init {
+ val serverConfig = serverConfigRepository.serverConfig.value
+ serverConfig.serverName?.let { _serverPreference.value = it }
+ serverConfig.authentication?.let {
+ _usernamePreference.value = it.username
+ _passwordPreference.value = it.password
+ }
+ }
+
+ fun saveServerPreference(serverName: String) {
+ _serverPreference.value = serverName
+
+ serverConfigRepository.applyConfig {
+ this.serverName = serverName
+ }
+ }
+
+ fun saveAuthenticationPreferences(username: String, password: String) {
+ _usernamePreference.value = username
+ _passwordPreference.value = password
+
+ serverConfigRepository.applyConfig {
+ this.authentication = ServerAuthentication(username, password)
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/shared/ServerConfigRepository.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/shared/ServerConfigRepository.kt
new file mode 100644
index 0000000..4a30883
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/shared/ServerConfigRepository.kt
@@ -0,0 +1,107 @@
+package net.buzzert.kordophonedroid.ui.shared
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import net.buzzert.kordophone.backend.server.Authentication
+import java.lang.reflect.Constructor
+import javax.inject.Inject
+import javax.inject.Singleton
+
+
+data class ServerConfig(
+ var serverName: String? = null,
+ var authentication: ServerAuthentication? = null,
+) {
+ companion object {
+ private const val SHARED_PREF_NAME = "KordophonePreferences"
+
+ fun loadFromSettings(context: Context): ServerConfig {
+ val prefs = getSharedPreferences(context)
+ return ServerConfig(
+ serverName = prefs.getString("serverName", null),
+ authentication = ServerAuthentication.loadFromEncryptedSettings(context)
+ )
+ }
+
+ private fun getSharedPreferences(context: Context): SharedPreferences {
+ return context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
+ }
+ }
+
+ fun saveToSettings(context: Context) {
+ val prefs = getSharedPreferences(context)
+ prefs.edit {
+ putString("serverName", serverName)
+ apply()
+ }
+
+ authentication?.saveToEncryptedSettings(context)
+ }
+}
+
+data class ServerAuthentication(
+ val username: String,
+ val password: String,
+) {
+ companion object {
+ fun loadFromEncryptedSettings(context: Context): ServerAuthentication? {
+ val prefs = getEncryptedSharedPreferences(context)
+
+ val username = prefs.getString("username", null)
+ val password = prefs.getString("password", null)
+ if (username != null && password != null) {
+ return ServerAuthentication(username, password)
+ }
+
+ return null
+ }
+
+ private fun getEncryptedSharedPreferences(context: Context): SharedPreferences {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ return EncryptedSharedPreferences(
+ context,
+ "secrets",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+ }
+
+ fun saveToEncryptedSettings(context: Context) {
+ val prefs = getEncryptedSharedPreferences(context)
+ prefs.edit {
+ putString("username", username)
+ putString("password", password)
+ apply()
+ }
+ }
+
+ fun toBackendAuthentication(): Authentication {
+ return Authentication(username, password)
+ }
+}
+
+@Singleton
+class ServerConfigRepository @Inject constructor(
+ @ApplicationContext val context: Context,
+) {
+ // TODO: Initial config should be loaded from device settings.
+ private val _serverConfig = MutableStateFlow(ServerConfig.loadFromSettings(context)) // Initial config
+ val serverConfig: StateFlow = _serverConfig
+
+ fun applyConfig(applicator: ServerConfig.() -> Unit) {
+ val config = _serverConfig.value.copy()
+ _serverConfig.value = config.apply(applicator)
+ _serverConfig.value.saveToSettings(context)
+ }
+}
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/shared/StringUtils.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/shared/StringUtils.kt
new file mode 100644
index 0000000..65e0da2
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/shared/StringUtils.kt
@@ -0,0 +1,33 @@
+package net.buzzert.kordophonedroid.ui.shared
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextDecoration
+
+const val LINK_ANNOTATION_TAG = "link"
+private val LINK_REGEX = "(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]".toRegex()
+
+fun String.linkify(): AnnotatedString {
+ val text = this
+ val matches = LINK_REGEX.findAll(this)
+ return buildAnnotatedString {
+ append(text)
+
+ for (match in matches) {
+ val range = match.range.also {
+ // Make inclusive.
+ IntRange(it.first, it.last + 1)
+ }
+
+ // Annotate link
+ addStringAnnotation(LINK_ANNOTATION_TAG, match.value, range.first, range.last)
+
+ // Add style
+ addStyle(
+ style = SpanStyle(textDecoration = TextDecoration.Underline),
+ start = range.first, end = range.last
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Color.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Color.kt
new file mode 100644
index 0000000..3ac2fd9
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Color.kt
@@ -0,0 +1,8 @@
+package net.buzzert.kordophonedroid.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple200 = Color(0xFFBB86FC)
+val Purple500 = Color(0xFF6200EE)
+val Purple700 = Color(0xFF3700B3)
+val Teal200 = Color(0xFF03DAC5)
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Shape.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Shape.kt
new file mode 100644
index 0000000..db8a827
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Shape.kt
@@ -0,0 +1,11 @@
+package net.buzzert.kordophonedroid.ui.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Shapes
+import androidx.compose.ui.unit.dp
+
+val Shapes = Shapes(
+ small = RoundedCornerShape(4.dp),
+ medium = RoundedCornerShape(4.dp),
+ large = RoundedCornerShape(0.dp)
+)
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Theme.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Theme.kt
new file mode 100644
index 0000000..58c7daf
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Theme.kt
@@ -0,0 +1,83 @@
+package net.buzzert.kordophonedroid.ui.theme
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.TopAppBar
+import androidx.compose.material.darkColors
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.lightColors
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.style.TextOverflow
+
+private val DarkColorPalette = darkColors(
+ primary = Purple200,
+ primaryVariant = Purple700,
+ secondary = Teal200
+)
+
+private val LightColorPalette = lightColors(
+ primary = Purple500,
+ primaryVariant = Purple700,
+ secondary = Teal200
+
+ /* Other default colors to override
+ background = Color.White,
+ surface = Color.White,
+ onPrimary = Color.White,
+ onSecondary = Color.Black,
+ onBackground = Color.Black,
+ onSurface = Color.Black,
+ */
+)
+
+@Composable
+fun KordophoneTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val colors = if (darkTheme) {
+ DarkColorPalette
+ } else {
+ LightColorPalette
+ }
+
+ MaterialTheme(
+ colors = colors,
+ typography = Typography,
+ shapes = Shapes,
+ content = content
+ )
+}
+
+
+@Composable
+fun KordophoneTopAppBar(title: String, backAction: () -> Unit, visible: Boolean = true) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = slideInVertically { -it },
+ exit = slideOutVertically { -it },
+ ) {
+ TopAppBar(
+ title = {
+ Text(
+ text = title,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = backAction) {
+ Icon(Icons.Filled.ArrowBack, null)
+ }
+ },
+ actions = {}
+ )
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Type.kt b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Type.kt
new file mode 100644
index 0000000..9550812
--- /dev/null
+++ b/android/app/src/main/java/net/buzzert/kordophonedroid/ui/theme/Type.kt
@@ -0,0 +1,28 @@
+package net.buzzert.kordophonedroid.ui.theme
+
+import androidx.compose.material.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ body1 = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp
+ )
+ /* Other default text styles to override
+ button = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W500,
+ fontSize = 14.sp
+ ),
+ caption = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable/account_circle.xml b/android/app/src/main/res/drawable/account_circle.xml
new file mode 100644
index 0000000..fec33e7
--- /dev/null
+++ b/android/app/src/main/res/drawable/account_circle.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/attach_file.xml b/android/app/src/main/res/drawable/attach_file.xml
new file mode 100644
index 0000000..6d9ac73
--- /dev/null
+++ b/android/app/src/main/res/drawable/attach_file.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/error.xml b/android/app/src/main/res/drawable/error.xml
new file mode 100644
index 0000000..8355c11
--- /dev/null
+++ b/android/app/src/main/res/drawable/error.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..77c6fdf
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/kordophone_ic.png b/android/app/src/main/res/drawable/kordophone_ic.png
new file mode 100644
index 0000000..a376faa
Binary files /dev/null and b/android/app/src/main/res/drawable/kordophone_ic.png differ
diff --git a/android/app/src/main/res/drawable/kordophone_ic_small.png b/android/app/src/main/res/drawable/kordophone_ic_small.png
new file mode 100644
index 0000000..0916f33
Binary files /dev/null and b/android/app/src/main/res/drawable/kordophone_ic_small.png differ
diff --git a/android/app/src/main/res/drawable/sedona.jpeg b/android/app/src/main/res/drawable/sedona.jpeg
new file mode 100644
index 0000000..4d62506
Binary files /dev/null and b/android/app/src/main/res/drawable/sedona.jpeg differ
diff --git a/android/app/src/main/res/drawable/storage.xml b/android/app/src/main/res/drawable/storage.xml
new file mode 100644
index 0000000..4ec6318
--- /dev/null
+++ b/android/app/src/main/res/drawable/storage.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..c4a603d
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..c4a603d
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..57fef5f
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..2055bee
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..7c0e328
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..d6747a1
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..3a47f60
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..dc46401
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..bdda301
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..aa144fb
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..4e1edc1
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..452b49f
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..f861bac
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..63624a8
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..18a80ee
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..8f42ac1
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..03307f9
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..43c4a76
--- /dev/null
+++ b/android/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #3D3D3D
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..85cd962
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ KordophoneDroid
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..47c3957
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..fa0f996
--- /dev/null
+++ b/android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..f2abc68
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+ 10.0.2.2
+ tesseract.localdomain
+ buzzert.kordophone.nor
+
+
\ No newline at end of file
diff --git a/android/backend/.gitignore b/android/backend/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/android/backend/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android/backend/build.gradle b/android/backend/build.gradle
new file mode 100644
index 0000000..6e6ecde
--- /dev/null
+++ b/android/backend/build.gradle
@@ -0,0 +1,58 @@
+plugins {
+ id 'com.android.library'
+ id 'org.jetbrains.kotlin.android'
+ id 'io.realm.kotlin'
+}
+
+android {
+ namespace 'net.buzzert.kordophone.backend'
+ compileSdk 33
+
+ defaultConfig {
+ minSdk 30
+ targetSdk 33
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles "consumer-rules.pro"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8.toString()
+ }
+}
+
+dependencies {
+
+ implementation 'androidx.core:core-ktx:1.10.1'
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.google.android.material:material:1.9.0'
+ implementation 'androidx.core:core-ktx:1.10.1'
+
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+
+ // Third-party
+ implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+ implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
+ implementation 'com.google.code.gson:gson:2.9.0'
+ implementation 'com.auth0.android:jwtdecode:2.0.2'
+
+ // Realm
+ implementation "io.realm.kotlin:library-base:${realm_version}"
+
+ // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
+ implementation group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: '1.7.3', ext: 'pom'
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
+ testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.1'
+}
\ No newline at end of file
diff --git a/android/backend/chat-cache-test b/android/backend/chat-cache-test
new file mode 100644
index 0000000..650c929
Binary files /dev/null and b/android/backend/chat-cache-test differ
diff --git a/android/backend/chat-cache-test.lock b/android/backend/chat-cache-test.lock
new file mode 100644
index 0000000..ccc2fcb
Binary files /dev/null and b/android/backend/chat-cache-test.lock differ
diff --git a/android/backend/consumer-rules.pro b/android/backend/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/android/backend/proguard-rules.pro b/android/backend/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/android/backend/proguard-rules.pro
@@ -0,0 +1,21 @@
+# 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
\ No newline at end of file
diff --git a/android/backend/src/androidTest/java/net/buzzert/kordophone/backend/ExampleInstrumentedTest.kt b/android/backend/src/androidTest/java/net/buzzert/kordophone/backend/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..ec75aaf
--- /dev/null
+++ b/android/backend/src/androidTest/java/net/buzzert/kordophone/backend/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package net.buzzert.kordophone.backend
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * 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("net.buzzert.kordophone.backend.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/android/backend/src/main/AndroidManifest.xml b/android/backend/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..87c51ee
--- /dev/null
+++ b/android/backend/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt
new file mode 100644
index 0000000..4beb1e6
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt
@@ -0,0 +1,170 @@
+package net.buzzert.kordophone.backend.db
+
+import android.util.Log
+import io.realm.kotlin.MutableRealm
+import io.realm.kotlin.Realm
+import io.realm.kotlin.RealmConfiguration
+import io.realm.kotlin.UpdatePolicy
+import io.realm.kotlin.ext.toRealmList
+import io.realm.kotlin.query.find
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.map
+import net.buzzert.kordophone.backend.db.model.Conversation
+import net.buzzert.kordophone.backend.db.model.Message
+import net.buzzert.kordophone.backend.db.model.toDatabaseConversation
+import net.buzzert.kordophone.backend.db.model.toDatabaseMessage
+import net.buzzert.kordophone.backend.db.model.toRealmInstant
+import net.buzzert.kordophone.backend.model.GUID
+import net.buzzert.kordophone.backend.server.REPO_LOG
+import java.lang.IllegalArgumentException
+import net.buzzert.kordophone.backend.model.Conversation as ModelConversation
+import net.buzzert.kordophone.backend.model.Message as ModelMessage
+
+class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
+ companion object {
+ private val schema = setOf(Conversation::class, Message::class)
+
+ fun liveDatabase(): CachedChatDatabase {
+ return CachedChatDatabase(
+ RealmConfiguration.Builder(schema = schema)
+ .name("chat-cache")
+ .build()
+ )
+ }
+
+ fun testDatabase(): CachedChatDatabase {
+ return CachedChatDatabase(
+ RealmConfiguration.Builder(schema = schema)
+ .name("chat-cache-test")
+ .inMemory()
+ .build()
+ )
+ }
+ }
+
+ private val realm = runCatching {
+ Realm.open(realmConfig)
+ }.recover {
+ // We're just a caching layer, so in the event of a migration error, just delete and start over.
+ Log.d(REPO_LOG, "Error opening (${it.message}). Recovering by deleting database.")
+ Realm.deleteRealm(realmConfig)
+
+ return@recover Realm.open(realmConfig)
+ }.getOrThrow()
+
+ // Flow for watching changes to the database
+ val conversationChanges: Flow>
+ get() = realm.query(Conversation::class).find().asFlow().map {
+ realm.copyFromRealm(it.list)
+ .map { it.toConversation() }
+ }
+
+ // Flow for watching for message changes for a given conversation
+ fun messagesChanged(conversation: ModelConversation): Flow> {
+ return realm.query(Message::class, "conversationGUID == $0", conversation.guid)
+ .find()
+ .asFlow()
+ .map { it.list.map { it.toMessage(conversation) } }
+ }
+
+ fun updateConversations(incomingConversations: List) = realm.writeBlocking {
+ val incomingDatabaseConversations = incomingConversations.map { it.toDatabaseConversation() }
+
+ var deletedConversations = realm.query(Conversation::class).find()
+ .minus(incomingDatabaseConversations)
+
+ deletedConversations.forEach { conversation ->
+ findLatest(conversation)?.let {
+ delete(it)
+ }
+ }
+
+ writeManagedConversations(this, incomingDatabaseConversations)
+ }
+
+ fun writeConversations(conversations: List) = realm.writeBlocking {
+ writeManagedConversations(this, conversations.map { it.toDatabaseConversation() })
+ }
+
+ private fun writeManagedConversations(mutableRealm: MutableRealm, conversations: List) {
+ conversations.forEach {conversation ->
+ try {
+ val managedConversation = getManagedConversationByGuid(conversation.guid)
+ mutableRealm.findLatest(managedConversation)?.apply {
+ displayName = conversation.displayName
+ participants = conversation.participants
+ date = conversation.date
+ unreadCount = conversation.unreadCount
+ lastMessagePreview = conversation.lastMessagePreview
+ lastMessageGUID = conversation.lastMessageGUID
+ }
+ } catch (e: NoSuchElementException) {
+ // Conversation does not exist. Copy it to the realm.
+ mutableRealm.copyToRealm(conversation, updatePolicy = UpdatePolicy.ALL)
+ }
+ }
+ }
+
+ fun deleteConversations(conversations: List) = realm.writeBlocking {
+ conversations.forEach { inConversation ->
+ val conversation = getManagedConversationByGuid(inConversation.guid)
+ findLatest(conversation)?.let {
+ delete(it)
+ }
+ }
+ }
+
+ fun fetchConversations(): List {
+ val itemResults = realm.query(Conversation::class).find()
+ val items = realm.copyFromRealm(itemResults)
+ return items.map { it.toConversation() }
+ }
+
+ fun writeMessages(messages: List, conversation: ModelConversation, outgoing: Boolean = false) {
+ if (messages.isEmpty()) {
+ return
+ }
+
+ val dbConversation = getManagedConversationByGuid(conversation.guid)
+ realm.writeBlocking {
+ messages
+ .map { it.toDatabaseMessage(outgoing = outgoing) }
+ .map { copyToRealm(it, updatePolicy = UpdatePolicy.ALL) }
+
+ findLatest(dbConversation)?.let {
+ val lastMessage = messages.maxByOrNull { it.date }!!
+
+ val lastMessageDate = lastMessage.date.toInstant().toRealmInstant()
+ if (lastMessageDate > it.date) {
+ it.lastMessageGUID = lastMessage.guid
+ it.lastMessagePreview = lastMessage.displayText
+
+ // This will cause sort order to change. I think this ends
+ // up getting updated whenever we get conversation changes anyway.
+ // it.date = lastMessageDate
+ }
+ }
+ }
+ }
+
+ fun fetchMessages(conversation: ModelConversation): List {
+ return realm.query(Message::class, "conversationGUID == $0", conversation.guid)
+ .find()
+ .map { it.toMessage(conversation) }
+ }
+
+ fun close() {
+ realm.close()
+ }
+
+ private fun getManagedConversationByGuid(guid: GUID): Conversation {
+ return realm.query(Conversation::class, "guid == $0", guid)
+ .find()
+ .first()
+ }
+
+ fun getConversationByGuid(guid: GUID): Conversation {
+ return realm.copyFromRealm(getManagedConversationByGuid(guid))
+ }
+}
\ No newline at end of file
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Conversation.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Conversation.kt
new file mode 100644
index 0000000..0cf3dfb
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Conversation.kt
@@ -0,0 +1,75 @@
+package net.buzzert.kordophone.backend.db.model
+
+import io.realm.kotlin.ext.realmListOf
+import io.realm.kotlin.ext.toRealmList
+import io.realm.kotlin.types.RealmInstant
+import io.realm.kotlin.types.RealmList
+import io.realm.kotlin.types.RealmObject
+import io.realm.kotlin.types.annotations.PrimaryKey
+import net.buzzert.kordophone.backend.model.GUID
+import org.mongodb.kbson.ObjectId
+import net.buzzert.kordophone.backend.model.Conversation as ModelConversation
+import java.util.Date
+
+open class Conversation(
+ @PrimaryKey
+ var guid: GUID,
+
+ var displayName: String?,
+ var participants: RealmList,
+ var date: RealmInstant,
+ var unreadCount: Int,
+
+ var lastMessageGUID: String?,
+ var lastMessagePreview: String?,
+): RealmObject
+{
+ constructor() : this(
+ guid = ObjectId().toString(),
+
+ displayName = null,
+ participants = realmListOf(),
+ date = RealmInstant.now(),
+ unreadCount = 0,
+ lastMessagePreview = null,
+ lastMessageGUID = null,
+ )
+
+ fun toConversation(): ModelConversation {
+ return ModelConversation(
+ displayName = displayName,
+ participants = participants.toList(),
+ date = Date.from(date.toInstant()),
+ unreadCount = unreadCount,
+ guid = guid,
+ lastMessagePreview = lastMessagePreview,
+ lastMessage = null,
+ lastFetchedMessageGUID = lastMessageGUID
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other == null || javaClass != other.javaClass) return false
+
+ val o = other as Conversation
+ return guid == o.guid
+ }
+
+ override fun hashCode(): Int {
+ return guid.hashCode()
+ }
+}
+
+fun ModelConversation.toDatabaseConversation(): Conversation {
+ val from = this
+ return Conversation().apply {
+ displayName = from.displayName
+ participants = from.participants.toRealmList()
+ date = from.date.toInstant().toRealmInstant()
+ unreadCount = from.unreadCount
+ lastMessagePreview = from.lastMessagePreview
+ lastMessageGUID = from.lastFetchedMessageGUID
+ guid = from.guid
+ }
+}
+
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Date+Instant.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Date+Instant.kt
new file mode 100644
index 0000000..8cd9af1
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Date+Instant.kt
@@ -0,0 +1,37 @@
+package net.buzzert.kordophone.backend.db.model
+
+import io.realm.kotlin.types.RealmInstant
+import java.time.Instant
+
+// Copied from Realm's documentation
+// https://www.mongodb.com/docs/realm/sdk/kotlin/realm-database/schemas/supported-types/
+
+fun RealmInstant.toInstant(): Instant {
+ val sec: Long = this.epochSeconds
+ // The value always lies in the range `-999_999_999..999_999_999`.
+ // minus for timestamps before epoch, positive for after
+ val nano: Int = this.nanosecondsOfSecond
+ return if (sec >= 0) { // For positive timestamps, conversion can happen directly
+ Instant.ofEpochSecond(sec, nano.toLong())
+ } else {
+ // For negative timestamps, RealmInstant starts from the higher value with negative
+ // nanoseconds, while Instant starts from the lower value with positive nanoseconds
+ // TODO This probably breaks at edge cases like MIN/MAX
+ Instant.ofEpochSecond(sec - 1, 1_000_000 + nano.toLong())
+ }
+}
+
+fun Instant.toRealmInstant(): RealmInstant {
+ val sec: Long = this.epochSecond
+ // The value is always positive and lies in the range `0..999_999_999`.
+ val nano: Int = this.nano
+
+ return if (sec >= 0) { // For positive timestamps, conversion can happen directly
+ RealmInstant.from(sec, nano)
+ } else {
+ // For negative timestamps, RealmInstant starts from the higher value with negative
+ // nanoseconds, while Instant starts from the lower value with positive nanoseconds
+ // TODO This probably breaks at edge cases like MIN/MAX
+ RealmInstant.from(sec + 1, -1_000_000 + nano)
+ }
+}
\ No newline at end of file
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Message.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Message.kt
new file mode 100644
index 0000000..c9c8af1
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Message.kt
@@ -0,0 +1,64 @@
+package net.buzzert.kordophone.backend.db.model
+
+import android.view.Display.Mode
+import io.realm.kotlin.Realm
+import io.realm.kotlin.ext.realmListOf
+import io.realm.kotlin.ext.toRealmList
+import io.realm.kotlin.types.EmbeddedRealmObject
+import io.realm.kotlin.types.RealmInstant
+import io.realm.kotlin.types.RealmList
+import io.realm.kotlin.types.RealmObject
+import io.realm.kotlin.types.annotations.PrimaryKey
+import net.buzzert.kordophone.backend.db.model.Conversation
+import net.buzzert.kordophone.backend.model.GUID
+import org.mongodb.kbson.ObjectId
+import net.buzzert.kordophone.backend.model.Message as ModelMessage
+import net.buzzert.kordophone.backend.model.Conversation as ModelConversation
+import java.util.Date
+
+open class Message(
+ @PrimaryKey
+ var guid: GUID,
+
+ var text: String,
+ var sender: String?,
+ var date: RealmInstant,
+ var attachmentGUIDs: RealmList,
+
+ var conversationGUID: GUID,
+): RealmObject
+{
+ constructor() : this(
+ guid = ObjectId().toString(),
+ text = "",
+ sender = null,
+ date = RealmInstant.now(),
+ attachmentGUIDs = realmListOf(),
+ conversationGUID = ObjectId().toString(),
+ )
+
+ fun toMessage(parentConversation: ModelConversation): ModelMessage {
+ return ModelMessage(
+ text = text,
+ guid = guid,
+ sender = sender,
+ date = Date.from(date.toInstant()),
+ attachmentGUIDs = attachmentGUIDs.toList(),
+ conversation = parentConversation,
+ )
+ }
+}
+
+fun ModelMessage.toDatabaseMessage(outgoing: Boolean = false): Message {
+ val from = this
+ return Message().apply {
+ text = from.text
+ guid = from.guid
+ sender = from.sender
+ date = from.date.toInstant().toRealmInstant()
+ conversationGUID = from.conversation.guid
+ from.attachmentGUIDs?.let {
+ attachmentGUIDs = it.toRealmList()
+ }
+ }
+}
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/events/MessageDeliveredEvent.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/events/MessageDeliveredEvent.kt
new file mode 100644
index 0000000..b5c1971
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/events/MessageDeliveredEvent.kt
@@ -0,0 +1,11 @@
+package net.buzzert.kordophone.backend.events
+
+import net.buzzert.kordophone.backend.model.Conversation
+import net.buzzert.kordophone.backend.model.GUID
+import net.buzzert.kordophone.backend.model.Message
+
+data class MessageDeliveredEvent(
+ val message: Message,
+ val conversation: Conversation,
+ val requestGuid: GUID,
+)
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/model/Conversation.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/model/Conversation.kt
new file mode 100644
index 0000000..3af38a4
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/model/Conversation.kt
@@ -0,0 +1,79 @@
+package net.buzzert.kordophone.backend.model
+
+import com.google.gson.annotations.SerializedName
+import java.util.Date
+import java.util.UUID
+
+typealias GUID = String
+
+data class Conversation(
+ @SerializedName("guid")
+ val guid: GUID,
+
+ @SerializedName("date")
+ var date: Date,
+
+ @SerializedName("participantDisplayNames")
+ var participants: List,
+
+ @SerializedName("displayName")
+ var displayName: String?,
+
+ @SerializedName("unreadCount")
+ var unreadCount: Int,
+
+ @SerializedName("lastMessagePreview")
+ var lastMessagePreview: String?,
+
+ @SerializedName("lastMessage")
+ var lastMessage: Message?,
+
+ var lastFetchedMessageGUID: String?,
+) {
+ companion object {
+ fun generate(): Conversation {
+ return Conversation(
+ guid = UUID.randomUUID().toString(),
+ date = Date(),
+ participants = listOf("foo@foo.com"),
+ displayName = null,
+ unreadCount = 0,
+ lastMessagePreview = null,
+ lastMessage = null,
+ lastFetchedMessageGUID = null,
+ )
+ }
+ }
+
+ val isGroupChat: Boolean
+ get() = participants.count() > 1
+
+ fun formattedDisplayName(): String {
+ return displayName ?: participants.joinToString(", ")
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other == null || javaClass != other.javaClass) return false
+
+ val o = other as Conversation
+ return (
+ guid == o.guid &&
+ date == o.date &&
+ participants == o.participants &&
+ displayName == o.displayName &&
+ unreadCount == o.unreadCount &&
+ lastFetchedMessageGUID == o.lastFetchedMessageGUID
+ )
+ }
+
+ override fun hashCode(): Int {
+ var result = guid.hashCode()
+ result = 31 * result + date.hashCode()
+ result = 31 * result + participants.hashCode()
+ result = 31 * result + (displayName?.hashCode() ?: 0)
+ result = 31 * result + unreadCount
+ result = 31 * result + (lastMessage?.hashCode() ?: 0)
+ result = 31 * result + (lastFetchedMessageGUID?.hashCode() ?: 0)
+ return result
+ }
+}
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt
new file mode 100644
index 0000000..cfea6a1
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt
@@ -0,0 +1,99 @@
+package net.buzzert.kordophone.backend.model
+
+import android.net.Uri
+import com.google.gson.annotations.SerializedName
+import java.io.InputStream
+import java.util.Date
+import java.util.UUID
+
+data class Message(
+ @SerializedName("guid")
+ val guid: GUID,
+
+ @SerializedName("text")
+ val text: String,
+
+ @SerializedName("sender")
+ val sender: String?, // optional: nil means "from me"
+
+ @SerializedName("date")
+ val date: Date,
+
+ @SerializedName("fileTransferGUIDs")
+ val attachmentGUIDs: List?,
+
+ @Transient
+ var conversation: Conversation,
+) {
+ companion object {
+ fun generate(text: String, conversation: Conversation = Conversation.generate(), sender: String? = null): Message {
+ return Message(
+ guid = UUID.randomUUID().toString(),
+ text = text,
+ sender = sender,
+ date = Date(),
+ attachmentGUIDs = emptyList(),
+ conversation = conversation,
+ )
+ }
+ }
+
+ val displayText: String get() {
+ // Filter out attachment markers
+ val attachmentMarker = byteArrayOf(0xEF.toByte(), 0xBF.toByte(), 0xBC.toByte()).decodeToString()
+ return text.replace(attachmentMarker, "")
+ }
+
+ override fun toString(): String {
+ return "Message(guid=$guid, text=$text, sender=$sender, date=$date, parent=${conversation.guid})"
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other == null || javaClass != other.javaClass) return false
+
+ val o = other as Message
+ return (
+ guid == o.guid &&
+ text == o.text &&
+ sender == o.sender &&
+ date == o.date &&
+ conversation.guid == o.conversation.guid
+ )
+ }
+
+ override fun hashCode(): Int {
+ var result = guid.hashCode()
+ result = 31 * result + text.hashCode()
+ result = 31 * result + (sender?.hashCode() ?: 0)
+ result = 31 * result + date.hashCode()
+ result = 31 * result + conversation.guid.hashCode()
+ return result
+ }
+}
+
+data class UploadingAttachmentMetadata(
+ val inputStream: InputStream,
+ val mimeType: String,
+ val filename: String,
+)
+
+data class OutgoingMessage(
+ val body: String,
+ val conversation: Conversation,
+ val attachmentUris: Set,
+ val attachmentDataSource: (Uri) -> UploadingAttachmentMetadata?
+) {
+ val guid: String = UUID.randomUUID().toString()
+
+ fun asMessage(): Message {
+ return Message(
+ guid = guid,
+ text = body,
+ sender = null,
+ date = Date(),
+ attachmentGUIDs = listOf(), // TODO: What to do here?
+ conversation = conversation
+ )
+ }
+}
+
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/model/UpdateItem.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/model/UpdateItem.kt
new file mode 100644
index 0000000..559627d
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/model/UpdateItem.kt
@@ -0,0 +1,14 @@
+package net.buzzert.kordophone.backend.model
+
+import com.google.gson.annotations.SerializedName
+
+data class UpdateItem(
+ @SerializedName("messageSequenceNumber")
+ val sequence: Int,
+
+ @SerializedName("conversation")
+ val conversationChanged: Conversation? = null,
+
+ @SerializedName("message")
+ val messageAdded: Message? = null,
+)
\ No newline at end of file
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt
new file mode 100644
index 0000000..d291493
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt
@@ -0,0 +1,218 @@
+package net.buzzert.kordophone.backend.server
+
+import kotlinx.coroutines.runBlocking
+import net.buzzert.kordophone.backend.model.Conversation
+import net.buzzert.kordophone.backend.model.GUID
+import net.buzzert.kordophone.backend.model.Message
+import okhttp3.Authenticator
+import okhttp3.HttpUrl
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.Response
+import okhttp3.ResponseBody
+import okhttp3.Route
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.net.URL
+
+interface APIClient {
+ val isConfigured: Boolean
+
+ fun getAPIInterface(): APIInterface
+ fun getWebSocketClient(
+ serverPath: String,
+ queryParams: Map?,
+ listener: WebSocketListener
+ ): WebSocket
+}
+
+data class Authentication (
+ val username: String,
+ val password: String,
+)
+
+class TokenStore(val authentication: Authentication) {
+ var authenticationToken: String? = null
+}
+
+class AuthenticationInterceptor(
+ val tokenStore: TokenStore
+): Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ // If empty, allow the 401 to occur so we renew our token.
+ val token = tokenStore.authenticationToken ?:
+ return chain.proceed(chain.request())
+
+ val newRequest = chain.request().newBuilder()
+ .header("Authorization", "Bearer $token")
+ .build()
+
+ return chain.proceed(newRequest)
+ }
+}
+
+class TokenAuthenticator(
+ private val tokenStore: TokenStore,
+ private val baseURL: URL
+) : Authenticator {
+ private val retrofit: Retrofit = Retrofit.Builder()
+ .baseUrl(baseURL)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+
+ private val apiInterface: APIInterface
+ get() = retrofit.create(APIInterface::class.java)
+
+ override fun authenticate(route: Route?, response: Response): Request? {
+ // Fetch new token
+ val request = AuthenticationRequest(
+ username = tokenStore.authentication.username,
+ password = tokenStore.authentication.password
+ )
+
+ val token = runBlocking {
+ apiInterface.authenticate(request).body()
+ }
+
+ when (token) {
+ null -> {
+ // Auth failure.
+ // TODO: How to bubble this up?
+ return null
+ }
+
+ // Update token store
+ else -> {
+ tokenStore.authenticationToken = token.serializedToken
+
+ return response.request().newBuilder()
+ .header("Authorization", "Bearer ${token.serializedToken}")
+ .build()
+ }
+ }
+ }
+}
+
+class APIClientFactory {
+ companion object {
+ fun createClient(serverString: String?, authentication: Authentication?): APIClient {
+ if (serverString == null || authentication == null) {
+ return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.NOT_CONFIGURED)
+ }
+
+ // Try to parse server string
+ val serverURL = HttpUrl.parse(serverString)
+ ?: return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.INVALID_HOST_URL)
+
+ return RetrofitAPIClient(serverURL.url(), authentication)
+ }
+ }
+}
+
+// TODO: Is this a dumb idea?
+class InvalidConfigurationAPIClient(val issue: Issue): APIClient {
+ enum class Issue {
+ NOT_CONFIGURED,
+ INVALID_CONFIGURATION,
+ INVALID_HOST_URL,
+ }
+
+ class NotConfiguredError: Throwable(message = "Not configured.")
+ class InvalidConfigurationError(submessage: String): Throwable(message = "Invalid configuration: $submessage")
+
+ private class InvalidConfigurationAPIInterface(val issue: Issue): APIInterface {
+ private fun throwError(): Nothing {
+ when (issue) {
+ Issue.NOT_CONFIGURED -> throw NotConfiguredError()
+ Issue.INVALID_CONFIGURATION -> throw InvalidConfigurationError("Unknown.")
+ Issue.INVALID_HOST_URL -> throw InvalidConfigurationError("Invalid host URL.")
+ }
+ }
+
+ override suspend fun getVersion(): ResponseBody = throwError()
+ override suspend fun getConversations(): retrofit2.Response> = throwError()
+ override suspend fun sendMessage(request: SendMessageRequest): retrofit2.Response = throwError()
+ override suspend fun markConversation(conversationGUID: String): retrofit2.Response = throwError()
+ override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody = throwError()
+ override suspend fun uploadAttachment(filename: String, body: RequestBody): retrofit2.Response = throwError()
+ override suspend fun authenticate(request: AuthenticationRequest): retrofit2.Response = throwError()
+ override suspend fun getMessages(conversationGUID: String, limit: Int?, beforeMessageGUID: GUID?, afterMessageGUID: GUID?): retrofit2.Response> = throwError()
+ }
+
+ override val isConfigured: Boolean
+ get() { return issue != Issue.NOT_CONFIGURED }
+
+ override fun getAPIInterface(): APIInterface {
+ return InvalidConfigurationAPIInterface(issue)
+ }
+
+ override fun getWebSocketClient(
+ serverPath: String,
+ queryParams: Map?,
+ listener: WebSocketListener
+ ): WebSocket {
+ throw Error("Invalid configuration.")
+ }
+}
+
+class RetrofitAPIClient(
+ private val baseURL: URL,
+ private val authentication: Authentication,
+): APIClient {
+ private val tokenStore: TokenStore = TokenStore(authentication)
+
+ private val client: OkHttpClient = OkHttpClient.Builder()
+ .addInterceptor(AuthenticationInterceptor(tokenStore))
+ .authenticator(TokenAuthenticator(tokenStore, baseURL))
+ .build()
+
+ private val retrofit: Retrofit = Retrofit.Builder()
+ .client(client)
+ .baseUrl(baseURL)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+
+ override val isConfigured: Boolean
+ get() = true
+
+ override fun getAPIInterface(): APIInterface {
+ return retrofit.create(APIInterface::class.java)
+ }
+
+ override fun getWebSocketClient(
+ serverPath: String,
+ queryParams: Map?,
+ listener: WebSocketListener
+ ): WebSocket {
+ val params = (queryParams ?: hashMapOf()).toMutableMap()
+
+ val authToken = tokenStore.authenticationToken
+ if (authToken != null) {
+ params["token"] = authToken
+ }
+
+ val requestURL = baseURL.authenticatedWebSocketURL(serverPath, params)
+ val request = Request.Builder()
+ .url(requestURL)
+ .build()
+
+ return client.newWebSocket(request, listener)
+ }
+}
+
+fun URL.authenticatedWebSocketURL(serverPath: String, params: Map): URL {
+ val baseURI = HttpUrl.parse(this.toString())!!
+ val requestURL = baseURI.newBuilder()
+ .host(baseURI.host())
+ .addEncodedPathSegments(serverPath)
+
+ params.forEach { (key, value) ->
+ requestURL.addQueryParameter(key, value)
+ }
+
+ return URL(requestURL.build().toString())
+}
\ No newline at end of file
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt
new file mode 100644
index 0000000..821f0f8
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt
@@ -0,0 +1,96 @@
+package net.buzzert.kordophone.backend.server
+
+import com.auth0.android.jwt.JWT
+import com.google.gson.annotations.SerializedName
+import net.buzzert.kordophone.backend.model.Conversation
+import net.buzzert.kordophone.backend.model.GUID
+import net.buzzert.kordophone.backend.model.Message
+import okhttp3.RequestBody
+import okhttp3.ResponseBody
+import retrofit2.Call
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.Query
+import java.lang.Error
+import java.lang.Exception
+
+data class SendMessageRequest(
+ @SerializedName("guid")
+ val conversationGUID: String,
+
+ @SerializedName("body")
+ val body: String,
+
+ @SerializedName("fileTransferGUIDs")
+ val transferGUIDs: List?,
+)
+
+data class SendMessageResponse(
+ @SerializedName("guid")
+ val sentMessageGUID: String,
+)
+
+data class AuthenticationRequest(
+ @SerializedName("username")
+ val username: String,
+
+ @SerializedName("password")
+ val password: String,
+)
+
+data class AuthenticationResponse(
+ @SerializedName("jwt")
+ val serializedToken: String,
+) {
+ fun decodeToken(): JWT {
+ return JWT(serializedToken)
+ }
+}
+
+data class UploadAttachmentResponse(
+ @SerializedName("fileTransferGUID")
+ val transferGUID: String
+)
+
+interface APIInterface {
+ @GET("/version")
+ suspend fun getVersion(): ResponseBody
+
+ @GET("/conversations")
+ suspend fun getConversations(): Response>
+
+ @GET("/messages")
+ suspend fun getMessages(
+ @Query("guid") conversationGUID: String,
+ @Query("limit") limit: Int? = null,
+ @Query("beforeMessageGUID") beforeMessageGUID: GUID? = null,
+ @Query("afterMessageGUID") afterMessageGUID: GUID? = null,
+ ): Response>
+
+ @POST("/sendMessage")
+ suspend fun sendMessage(@Body request: SendMessageRequest): Response
+
+ @POST("/markConversation")
+ suspend fun markConversation(@Query("guid") conversationGUID: String): Response
+
+ @GET("/attachment")
+ suspend fun fetchAttachment(@Query("guid") guid: String, @Query("preview") preview: Boolean = false): ResponseBody
+
+ @POST("/uploadAttachment")
+ suspend fun uploadAttachment(@Query("filename") filename: String, @Body body: RequestBody): Response
+
+ @POST("/authenticate")
+ suspend fun authenticate(@Body request: AuthenticationRequest): Response
+}
+
+class ResponseDecodeError(val response: ResponseBody): Exception(response.string())
+
+fun Response.bodyOnSuccessOrThrow(): T {
+ if (isSuccessful) {
+ return body()!!
+ }
+
+ throw ResponseDecodeError(errorBody()!!)
+}
\ No newline at end of file
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt
new file mode 100644
index 0000000..4181c84
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt
@@ -0,0 +1,333 @@
+package net.buzzert.kordophone.backend.server
+
+import android.net.Uri
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import net.buzzert.kordophone.backend.db.CachedChatDatabase
+import net.buzzert.kordophone.backend.events.MessageDeliveredEvent
+import net.buzzert.kordophone.backend.model.Conversation
+import net.buzzert.kordophone.backend.model.GUID
+import net.buzzert.kordophone.backend.model.Message
+import net.buzzert.kordophone.backend.model.OutgoingMessage
+import okhttp3.MediaType
+import okhttp3.RequestBody
+import okio.BufferedSource
+import java.io.InputStream
+import java.util.Date
+import java.util.UUID
+import java.util.concurrent.ArrayBlockingQueue
+
+const val REPO_LOG: String = "ChatRepository"
+const val CONVERSATION_MESSAGE_SYNC_COUNT = 10
+
+class ChatRepository(
+ private var apiClient: APIClient,
+ private val database: CachedChatDatabase,
+) {
+ sealed class Error {
+ open val title: String = "Error"
+ open val description: String = "Generic Error"
+
+ data class ConnectionError(val message: String?): Error() {
+ override val title: String = "Connection Error"
+ override val description: String = message ?: "???"
+ }
+
+ data class AttachmentError(val message: String): Error() {
+ override val title: String = "Attachment Error"
+ override val description: String = message
+ }
+ }
+
+ // All (Cached) Conversations
+ val conversations: List
+ get() = database.fetchConversations()
+
+ // Channel that's signaled when an outgoing message is delivered.
+ val messageDeliveredChannel: SharedFlow
+ get() = _messageDeliveredChannel.asSharedFlow()
+
+ // Changes Flow
+ val conversationChanges: Flow>
+ get() = database.conversationChanges
+ .onEach { Log.d(REPO_LOG, "Got database conversations changed") }
+
+ // New Messages
+ val newMessages: SharedFlow
+ get() = _newMessageChannel.asSharedFlow()
+
+ // Errors channel
+ val errorEncounteredChannel: SharedFlow
+ get() = _errorEncounteredChannel.asSharedFlow()
+
+ val isConfigured: Boolean
+ get() = apiClient.isConfigured
+
+ // New messages for a particular conversation
+ fun messagesChanged(conversation: Conversation): Flow> =
+ database.messagesChanged(conversation)
+
+ // Testing harness
+ internal class TestingHarness(private val repository: ChatRepository) {
+ suspend fun fetchConversations(): List {
+ return repository.fetchConversations()
+ }
+
+ suspend fun fetchMessages(conversation: Conversation): List {
+ return repository.fetchMessages(conversation)
+ }
+ }
+
+ internal fun testingHarness(): TestingHarness = TestingHarness(this)
+
+ private var apiInterface = apiClient.getAPIInterface()
+ private val outgoingMessageQueue: ArrayBlockingQueue = ArrayBlockingQueue(16)
+ private var outgoingMessageThread: Thread? = null
+ private val _messageDeliveredChannel = MutableSharedFlow()
+ private val _errorEncounteredChannel = MutableSharedFlow()
+ private val _newMessageChannel = MutableSharedFlow()
+
+ private var updateMonitor = UpdateMonitor(apiClient)
+ private var updateWatchJob: Job? = null
+ private var updateWatchScope: CoroutineScope? = null
+
+ fun updateAPIClient(client: APIClient) {
+ this.apiClient = client
+ this.apiInterface = client.getAPIInterface()
+ this.updateMonitor = UpdateMonitor(client)
+
+ // Restart update watch job, if necessary.
+ if (this.updateWatchJob != null) {
+ stopWatchingForUpdates()
+ beginWatchingForUpdates(updateWatchScope!!)
+ }
+ }
+
+ suspend fun getVersion(): String {
+ return apiInterface.getVersion().string()
+ }
+
+ fun beginWatchingForUpdates(scope: CoroutineScope) {
+ updateWatchJob?.cancel()
+ updateWatchJob = CoroutineScope(scope.coroutineContext).launch {
+ launch {
+ updateMonitor.conversationChanged.collect { handleConversationChangedUpdate(it) }
+ }
+ launch {
+ updateMonitor.messageAdded.collect { handleMessageAddedUpdate(it) }
+ }
+ launch {
+ messageDeliveredChannel.collectLatest { handleMessageDelivered(it) }
+ }
+ }
+
+ updateWatchScope = scope
+ updateMonitor.beginMonitoringUpdates()
+ }
+
+ fun stopWatchingForUpdates() {
+ updateWatchJob?.cancel()
+ updateWatchJob = null
+
+ updateMonitor.stopMonitoringForUpdates()
+ }
+
+ fun enqueueOutgoingMessage(message: OutgoingMessage): GUID {
+ val guid = UUID.randomUUID().toString()
+
+ Log.d(REPO_LOG, "Enqueuing outgoing message: $message ($guid)")
+ outgoingMessageQueue.add(message)
+
+ if (outgoingMessageThread == null) {
+ outgoingMessageThread = Thread { outgoingMessageQueueMain() }
+ outgoingMessageThread?.start()
+ }
+
+ return guid
+ }
+
+ fun conversationForGuid(guid: GUID): Conversation {
+ return database.getConversationByGuid(guid).toConversation()
+ }
+
+ fun messagesForConversation(conversation: Conversation): List {
+ return database.fetchMessages(conversation)
+ }
+
+ suspend fun synchronize() = withErrorChannelHandling {
+ Log.d(REPO_LOG, "Synchronizing conversations")
+
+ // Sync conversations
+ val serverConversations = fetchConversations()
+ database.updateConversations(serverConversations)
+
+ // Sync top N number of conversations' message content
+ Log.d(REPO_LOG, "Synchronizing messages")
+ val sortedConversations = conversations.sortedBy { it.date }.reversed()
+ for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) {
+ synchronizeConversation(conversation)
+ }
+ }
+
+ suspend fun synchronizeConversation(conversation: Conversation, limit: Int = 15) = withErrorChannelHandling {
+ val messages = fetchMessages(conversation, limit = limit, afterGUID = conversation.lastFetchedMessageGUID)
+ database.writeMessages(messages, conversation)
+ }
+
+ suspend fun markConversationAsRead(conversation: Conversation) = withErrorChannelHandling(silent = true) {
+ apiInterface.markConversation(conversation.guid)
+ }
+
+ suspend fun fetchAttachmentDataSource(guid: String, preview: Boolean): BufferedSource {
+ return apiInterface.fetchAttachment(guid, preview).source()
+ }
+
+ private suspend fun uploadAttachment(filename: String, mediaType: String, source: InputStream): String {
+ val attachmentData = source.readBytes()
+ val requestBody = RequestBody.create(MediaType.get(mediaType), attachmentData)
+
+ withContext(Dispatchers.IO) {
+ source.close()
+ }
+
+ val response = apiInterface.uploadAttachment(filename, requestBody)
+ return response.bodyOnSuccessOrThrow().transferGUID
+ }
+
+ fun close() {
+ database.close()
+ }
+
+ // - private
+
+ private suspend fun withErrorChannelHandling(silent: Boolean = false, body: suspend () -> Unit) {
+ try {
+ body()
+ } catch (e: InvalidConfigurationAPIClient.NotConfiguredError) {
+ // Not configured yet: ignore.
+ } catch (e: java.lang.Exception) {
+ if (!silent) _errorEncounteredChannel.emit(Error.ConnectionError(e.message))
+ } catch (e: java.lang.Error) {
+ if (!silent) _errorEncounteredChannel.emit(Error.ConnectionError(e.message))
+ }
+ }
+
+ private suspend fun fetchConversations(): List {
+ return apiInterface.getConversations().bodyOnSuccessOrThrow()
+ }
+
+ private suspend fun fetchMessages(
+ conversation: Conversation,
+ limit: Int? = null,
+ beforeGUID: String? = null,
+ afterGUID: String? = null,
+ ): List {
+ return apiInterface.getMessages(conversation.guid, limit, beforeGUID, afterGUID)
+ .bodyOnSuccessOrThrow()
+ .onEach { it.conversation = conversation }
+ }
+
+ private fun handleConversationChangedUpdate(conversation: Conversation) {
+ Log.d(REPO_LOG, "Handling conversation changed update")
+ database.writeConversations(listOf(conversation))
+ }
+
+ private suspend fun handleMessageAddedUpdate(message: Message) {
+ Log.d(REPO_LOG, "Handling messages added update")
+ database.writeMessages(listOf(message), message.conversation)
+ _newMessageChannel.emit(message)
+ }
+
+ private suspend fun handleMessageDelivered(event: MessageDeliveredEvent) {
+ Log.d(REPO_LOG, "Handling successful delivery event")
+
+ // Unfortunate protocol reality: the server doesn't tell us about new messages that are from us,
+ // so we have to explicitly handle this like a messageAddedUpdate.
+ database.writeMessages(listOf(event.message), event.conversation, outgoing = true)
+ }
+
+ private suspend fun retryMessageSend(info: OutgoingMessage) {
+ delay(5000L)
+ outgoingMessageQueue.add(info)
+ }
+
+ private fun outgoingMessageQueueMain() {
+ Log.d(REPO_LOG, "Outgoing Message Queue Main")
+ while (true) {
+ outgoingMessageQueue.take().let {
+ runBlocking {
+ val conversation = it.conversation
+ val requestGuid = it.guid
+ val body = it.body
+
+ Log.d(REPO_LOG, "Sending message to $conversation: $requestGuid")
+
+ // Upload attachments first
+ val attachmentGUIDs = mutableListOf()
+ try {
+ for (uri: Uri in it.attachmentUris) {
+ val uploadData = it.attachmentDataSource(uri)
+ ?: throw java.lang.Exception("No upload data.")
+
+ val guid = uploadAttachment(uploadData.filename, uploadData.mimeType, uploadData.inputStream)
+ attachmentGUIDs.add(guid)
+ }
+ } catch (e: java.lang.Exception) {
+ Log.e(REPO_LOG, "Error uploading attachment (${e.message}). Dropping...")
+ _errorEncounteredChannel.emit(Error.AttachmentError("Upload error: ${e.message}"))
+ }
+
+ try {
+ val result = apiInterface.sendMessage(
+ SendMessageRequest(
+ conversationGUID = conversation.guid,
+ body = body,
+ transferGUIDs = attachmentGUIDs,
+ )
+ )
+
+ if (result.isSuccessful) {
+ val messageGuid = result.body()?.sentMessageGUID ?: requestGuid
+ Log.d(REPO_LOG, "Successfully sent message: $messageGuid")
+
+ val newMessage = Message(
+ guid = messageGuid,
+ text = body,
+ sender = null,
+ conversation = it.conversation,
+ date = Date(),
+ attachmentGUIDs = attachmentGUIDs,
+ )
+
+ _messageDeliveredChannel.emit(
+ MessageDeliveredEvent(
+ newMessage,
+ conversation,
+ requestGuid
+ )
+ )
+ } else {
+ Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.")
+ retryMessageSend(it)
+ }
+ } catch (e: java.lang.Exception) {
+ Log.e(REPO_LOG, "Error sending message: (${e.message}). Enqueuing for retry in 5 sec.")
+ retryMessageSend(it)
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt b/android/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt
new file mode 100644
index 0000000..5fb7527
--- /dev/null
+++ b/android/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt
@@ -0,0 +1,133 @@
+package net.buzzert.kordophone.backend.server
+
+import android.util.Log
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import net.buzzert.kordophone.backend.model.Conversation
+import net.buzzert.kordophone.backend.model.Message
+import net.buzzert.kordophone.backend.model.UpdateItem
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import okio.ByteString
+import java.lang.reflect.Type
+
+const val UPMON_LOG: String = "UpdateMonitor"
+
+class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
+ // Flow for getting conversation changed notifications
+ val conversationChanged: Flow
+ get() = _conversationChanged
+
+ // Flow for messages added notifications
+ val messageAdded: Flow
+ get() = _messageAdded
+
+ private val gson: Gson = Gson()
+ private val updateItemsType: Type = object : TypeToken>() {}.type
+ private var webSocket: WebSocket? = null
+ private var needsSocketReconnect: Boolean = false
+ private var messageSeq: Int = -1
+
+ private val _conversationChanged: MutableSharedFlow = MutableSharedFlow()
+ private val _messageAdded: MutableSharedFlow = MutableSharedFlow()
+
+ fun beginMonitoringUpdates() {
+ if (!client.isConfigured) {
+ Log.e(UPMON_LOG, "Closing websocket connection because client is not configured.")
+ return
+ }
+
+ Log.d(UPMON_LOG, "Opening websocket connection")
+ try {
+ this.webSocket = client.getWebSocketClient(
+ serverPath = "updates",
+ queryParams = mapOf("seq" to messageSeq.toString()),
+ listener = this
+ )
+ } catch (e: Error) {
+ Log.e(UPMON_LOG, "Error getting websocket client: ${e.message}")
+ }
+ }
+
+ fun stopMonitoringForUpdates() {
+ this.webSocket?.close(1000, "Closing on program request.")
+ }
+
+ private fun processEncodedSocketMessage(message: String) = runBlocking {
+ val reader = message.reader()
+ val jsonReader = gson.newJsonReader(reader)
+
+ val updateItems: List = gson.fromJson(message, updateItemsType)
+ for (updateItem: UpdateItem in updateItems) {
+ val conversationChanged = updateItem.conversationChanged
+ if (conversationChanged != null) {
+ _conversationChanged.emit(conversationChanged)
+ }
+
+ if (updateItem.messageAdded != null) {
+ _messageAdded.emit(updateItem.messageAdded.also {
+ it.conversation = conversationChanged!!
+ })
+ }
+
+ if (updateItem.sequence > messageSeq) {
+ messageSeq = updateItem.sequence
+ }
+ }
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ private fun setNeedsSocketReconnect() {
+ if (!needsSocketReconnect) {
+ needsSocketReconnect = true
+
+ GlobalScope.launch {
+ needsSocketReconnect = false
+
+ // Delay 5 seconds
+ delay(5000L)
+
+ beginMonitoringUpdates()
+ }
+ }
+ }
+
+ //
+
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ super.onOpen(webSocket, response)
+ Log.d(UPMON_LOG, "Update monitor websocket open")
+ }
+
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ super.onClosed(webSocket, code, reason)
+ Log.d(UPMON_LOG, "Update monitor socket closed")
+ setNeedsSocketReconnect()
+ }
+
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+ super.onFailure(webSocket, t, response)
+ Log.d(UPMON_LOG, "Update monitor socket failure: ${t.message} :: Response: ${response?.body()}. Reconnecting in 5 seconds.")
+ setNeedsSocketReconnect()
+ }
+
+ override fun onMessage(webSocket: WebSocket, text: String) {
+ super.onMessage(webSocket, text)
+ Log.d(UPMON_LOG, "Update monitor websocket received text message")
+ processEncodedSocketMessage(text)
+ }
+
+ override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
+ super.onMessage(webSocket, bytes)
+ Log.d(UPMON_LOG, "Update monitor websocket received bytes message")
+ processEncodedSocketMessage(bytes.utf8())
+ }
+}
\ No newline at end of file
diff --git a/android/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt b/android/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt
new file mode 100644
index 0000000..d4cb2cb
--- /dev/null
+++ b/android/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt
@@ -0,0 +1,345 @@
+package net.buzzert.kordophone.backend
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import net.buzzert.kordophone.backend.db.CachedChatDatabase
+import net.buzzert.kordophone.backend.model.Message
+import net.buzzert.kordophone.backend.model.OutgoingMessage
+import net.buzzert.kordophone.backend.server.APIClient
+import net.buzzert.kordophone.backend.server.APIInterface
+import net.buzzert.kordophone.backend.server.Authentication
+import net.buzzert.kordophone.backend.server.ChatRepository
+import net.buzzert.kordophone.backend.server.RetrofitAPIClient
+import net.buzzert.kordophone.backend.server.UpdateMonitor
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.net.URL
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+class BackendTests {
+ private fun liveRepository(host: String): Pair {
+ val client = RetrofitAPIClient(URL(host), authentication = Authentication("test", "test"))
+ val database = CachedChatDatabase.testDatabase()
+ val repository = ChatRepository(client, database)
+ return Pair(repository, client)
+ }
+
+ private fun mockRepository(): Pair {
+ val mockServer = MockServer()
+ val database = CachedChatDatabase.testDatabase()
+ val repository = ChatRepository(mockServer.getClient(), database)
+ return Pair(repository, mockServer)
+ }
+
+ @Test
+ fun testGetVersion() = runBlocking {
+ val (repository, mockServer) = mockRepository()
+ val version = repository.getVersion()
+ assertEquals(version, mockServer.version)
+
+ repository.close()
+ }
+
+ @Test
+ fun testFetchConversations() = runBlocking {
+ val (repository, mockServer) = mockRepository()
+
+ // Add conversation to mock server
+ val inConversation = mockServer.addTestConversations(1).first()
+
+ val conversations = repository.testingHarness().fetchConversations()
+ assertEquals(conversations.count(), 1)
+
+ val outConversation = conversations.first()
+ assertEquals(inConversation, outConversation)
+
+ repository.close()
+ }
+
+ @Test
+ fun testFetchMessages() = runBlocking {
+ val (repository, mockServer) = mockRepository()
+
+ // Add conversation & message to mock server
+ val inConversation = mockServer.addTestConversations(1).first()
+ val inMessage = mockServer.addTestMessages(1, inConversation).first()
+
+ val conversations = repository.testingHarness().fetchConversations()
+ val messages = repository.testingHarness().fetchMessages(conversations.first())
+ assertEquals(messages.count(), 1)
+
+ val outMessage = messages.first()
+ assertEquals(outMessage, inMessage)
+
+ repository.close()
+ }
+
+ @Test
+ fun testSendMessage() = runBlocking {
+ val (repository, mockServer) = mockRepository()
+
+ val conversation = mockServer.addTestConversations(1).first()
+ val generatedMessage = MockServer.generateMessage(conversation)
+ val outgoingMessage = OutgoingMessage(
+ body = generatedMessage.text,
+ conversation = conversation,
+ attachmentUris = setOf(),
+ attachmentDataSource = { null },
+ )
+
+ repository.enqueueOutgoingMessage(outgoingMessage)
+
+ val event = repository.messageDeliveredChannel.first()
+ assertEquals(event.message.text, outgoingMessage.body)
+
+ repository.close()
+ }
+
+ @Test
+ fun testConversationSynchronization() = runBlocking {
+ val (repo, mockServer) = mockRepository()
+
+ // Add some test convos
+ val conversations = mockServer.addTestConversations(10)
+
+ // Sync
+ repo.synchronize()
+
+ // Check our count.
+ assertEquals(10, repo.conversations.count())
+
+ // Sync again: let's ensure we're de-duplicating conversations.
+ repo.synchronize()
+
+ // Should be no change...
+ assertEquals(10, repo.conversations.count())
+
+ // Say unread count + lastMessage preview changes on server.
+ val someConversation = conversations.first().apply {
+ displayName = "COOL"
+ unreadCount = 2
+ }
+
+ // Sync again
+ repo.synchronize()
+
+ // Make sure change is reflected
+ val readConversation = repo.conversationForGuid(someConversation.guid)
+ assertEquals("COOL", readConversation.displayName)
+ assertEquals(2, readConversation.unreadCount)
+
+ repo.close()
+ }
+
+ @Test
+ fun testConversationFlowUpdates() = runBlocking {
+ val (repo, mockServer) = mockRepository()
+
+ // Set up flow watcher, asynchronously
+ val updateLatch = CountDownLatch(1)
+ val job = launch {
+ println("Watching for conversations changes...")
+ repo.conversationChanges.collect {
+ println("Changed conversations: $it")
+
+ // We got it.
+ if (it.isNotEmpty()) {
+ println("bink")
+ updateLatch.countDown()
+ cancel()
+ }
+ }
+ }
+
+ withContext(Dispatchers.IO) {
+ // Add a conversation
+ println("Adding conversation")
+ mockServer.addTestConversations(1)
+
+ // Sync. This should trigger an update
+ println("Synchronizing...")
+ repo.synchronize()
+
+ // Wait for the coroutine that is collecting the flow to finish
+ job.join()
+
+ // Ensure the updates have been processed before proceeding
+ assertTrue(updateLatch.await(1, TimeUnit.SECONDS))
+ }
+ }
+
+ @Test
+ fun testMessageFlowUpdates() = runBlocking {
+ val (repo, mockServer) = mockRepository()
+
+ // Add an existing conversation
+ println("Adding conversation")
+ val conversation = mockServer.addTestConversations(1).first()
+
+ // Initial sync
+ println("Initial sync")
+ repo.synchronize()
+
+ // Set up flow watcher, asynchronously
+ var messagesAdded: List? = null
+ val updateLatch = CountDownLatch(1)
+ val job = launch {
+ println("Watching for messages to be added...")
+ repo.messagesChanged(conversation).collect {
+ println("Messages changed: $it")
+
+ if (it.isNotEmpty()) {
+ messagesAdded = it
+ updateLatch.countDown()
+ cancel()
+ }
+ }
+ }
+
+ withContext(Dispatchers.IO) {
+ // Add a message
+ val messages = mockServer.addTestMessages(10, conversation)
+
+ // Sync. This should trigger an update
+ println("Synchronizing...")
+ repo.synchronize()
+
+ // Wait for the coroutine that is collecting the flow to finish
+ job.join()
+
+ // Ensure the updates have been processed before proceeding
+ assertTrue(updateLatch.await(1, TimeUnit.SECONDS))
+
+ // Check what we got back
+ assertEquals(messages, messagesAdded)
+ }
+ }
+
+ @Test
+ fun testUpdateMonitorForConversations() = runBlocking {
+ val mockServer = MockServer()
+ val mockAPIClient = mockServer.getClient()
+ val updateMonitor = UpdateMonitor(mockAPIClient)
+
+ // Set up flow watcher, asynchronously
+ val updateLatch = CountDownLatch(1)
+ val job = launch {
+ updateMonitor.beginMonitoringUpdates()
+ updateMonitor.conversationChanged.collect {
+ println("Got conversation changed: $it")
+ updateLatch.countDown()
+
+ updateMonitor.stopMonitoringForUpdates()
+ mockAPIClient.stopWatchingForUpdates()
+ cancel()
+ }
+ }
+
+ withContext(Dispatchers.IO) {
+ Thread.sleep(500)
+
+ // Add a conversation
+ println("Adding conversation")
+ mockServer.addTestConversations(1)
+
+ // Wait for the coroutine that is collecting the flow to finish
+ job.join()
+
+ // Ensure the updates have been processed before proceeding
+ assertTrue(updateLatch.await(1, TimeUnit.SECONDS))
+ }
+ }
+
+ @Test
+ fun testUpdateMonitorForMessages() = runBlocking {
+ val mockServer = MockServer()
+ val mockAPIClient = mockServer.getClient()
+ val updateMonitor = UpdateMonitor(mockAPIClient)
+
+ // Set up flow watcher, asynchronously
+ val updateLatch = CountDownLatch(1)
+ val job = launch {
+ updateMonitor.beginMonitoringUpdates()
+ updateMonitor.messageAdded.collect {
+ println("Got message added: $it")
+ updateLatch.countDown()
+
+ updateMonitor.stopMonitoringForUpdates()
+ mockAPIClient.stopWatchingForUpdates()
+ cancel()
+ }
+ }
+
+ withContext(Dispatchers.IO) {
+ Thread.sleep(500)
+
+ // Add a conversation
+ println("Adding conversation")
+ val convo = mockServer.addTestConversations(1).first()
+
+ // Add a test message
+ mockServer.addTestMessages(1, convo)
+
+ // Wait for the coroutine that is collecting the flow to finish
+ job.join()
+
+ // Ensure the updates have been processed before proceeding
+ assertTrue(updateLatch.await(1, TimeUnit.SECONDS))
+ }
+ }
+
+ @Test
+ fun testEndToEndMessageUpdates() = runBlocking {
+ val (repo, mockServer) = mockRepository()
+
+ val conversation = mockServer.addTestConversations(1).first()
+
+ // Initial sync
+ repo.synchronize()
+
+ // We're going to generate a couple of messages...
+ val messagesToGenerate = 5
+
+ // Start watching for N updates
+ val updateLatch = CountDownLatch(messagesToGenerate)
+ val monitorJob = launch {
+ repo.messagesChanged(conversation).collect {
+ println("Message changed: $it")
+
+ if (it.isNotEmpty()) {
+ updateLatch.countDown()
+ }
+
+ if (updateLatch.count == 0L) {
+ repo.stopWatchingForUpdates()
+ cancel()
+ }
+ }
+ }
+
+ withContext(Dispatchers.IO) {
+ repo.beginWatchingForUpdates(this)
+
+ Thread.sleep(500)
+
+ // Should trigger an update
+ println("Adding messages")
+ mockServer.addTestMessages(messagesToGenerate, conversation)
+
+ monitorJob.join()
+
+ assertTrue(updateLatch.await(1, TimeUnit.SECONDS))
+
+ // Check num messages
+ val allMessages = repo.messagesForConversation(conversation)
+ assertEquals(messagesToGenerate, allMessages.count())
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/backend/src/test/java/net/buzzert/kordophone/backend/DatabaseTests.kt b/android/backend/src/test/java/net/buzzert/kordophone/backend/DatabaseTests.kt
new file mode 100644
index 0000000..aea741a
--- /dev/null
+++ b/android/backend/src/test/java/net/buzzert/kordophone/backend/DatabaseTests.kt
@@ -0,0 +1,78 @@
+package net.buzzert.kordophone.backend
+
+import net.buzzert.kordophone.backend.db.CachedChatDatabase
+import net.buzzert.kordophone.backend.model.Conversation
+import net.buzzert.kordophone.backend.model.Message
+import org.junit.AfterClass
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.Date
+
+class DatabaseTests {
+ @Test
+ fun testConversationRetrieval() {
+ val db = CachedChatDatabase.testDatabase()
+
+ val conversation = MockServer.generateConversation()
+ db.writeConversations(listOf(conversation))
+
+ val readBackConversations = db.fetchConversations()
+ assertEquals(readBackConversations.count(), 1)
+
+ val readConversation = readBackConversations[0]
+ assertEquals(readConversation, conversation)
+
+ db.close()
+ }
+
+ @Test
+ fun testMessageRetrieval() {
+ val db = CachedChatDatabase.testDatabase()
+
+ val conversation = MockServer.generateConversation()
+ db.writeConversations(listOf(conversation))
+
+ var messages = listOf(
+ MockServer.generateMessage(conversation),
+ MockServer.generateMessage(conversation),
+ )
+ db.writeMessages(messages, conversation)
+
+ val readMessages = db.fetchMessages(conversation)
+
+ assertEquals(readMessages, messages)
+ assertEquals(readMessages[0].conversation.guid, conversation.guid)
+
+ db.close()
+ }
+
+ @Test
+ fun testConversationModification() {
+ val db = CachedChatDatabase.testDatabase()
+
+ var conversation = MockServer.generateConversation().apply {
+ displayName = "HooBoy"
+ }
+
+ db.writeConversations(listOf(conversation))
+
+ val readConversation = db.fetchConversations().first()
+ assertEquals(conversation.displayName, "HooBoy")
+
+ // Change display name
+ conversation.displayName = "wow"
+
+ // Write back
+ db.writeConversations(listOf(conversation))
+
+ val nowConversations = db.fetchConversations()
+
+ // Make sure we didn't duplicate
+ assertEquals(nowConversations.count(), 1)
+
+ // Make sure our new name was written
+ assertEquals(nowConversations.first().displayName, "wow")
+
+ db.close()
+ }
+}
\ No newline at end of file
diff --git a/android/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt b/android/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt
new file mode 100644
index 0000000..38230af
--- /dev/null
+++ b/android/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt
@@ -0,0 +1,292 @@
+package net.buzzert.kordophone.backend
+
+import com.google.gson.Gson
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import net.buzzert.kordophone.backend.model.Conversation
+import net.buzzert.kordophone.backend.model.GUID
+import net.buzzert.kordophone.backend.model.Message
+import net.buzzert.kordophone.backend.model.UpdateItem
+import net.buzzert.kordophone.backend.server.APIClient
+import net.buzzert.kordophone.backend.server.APIInterface
+import net.buzzert.kordophone.backend.server.AuthenticationRequest
+import net.buzzert.kordophone.backend.server.AuthenticationResponse
+import net.buzzert.kordophone.backend.server.SendMessageRequest
+import net.buzzert.kordophone.backend.server.SendMessageResponse
+import net.buzzert.kordophone.backend.server.UploadAttachmentResponse
+import net.buzzert.kordophone.backend.server.authenticatedWebSocketURL
+import okhttp3.HttpUrl
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.ResponseBody
+import okhttp3.ResponseBody.Companion.toResponseBody
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import retrofit2.Response
+import java.util.Date
+import java.util.UUID
+
+@OptIn(ExperimentalStdlibApi::class)
+class MockServer {
+ val version = "Kordophone-2.0"
+ val conversations: MutableList = mutableListOf()
+ val updateFlow: Flow get() = _updateFlow
+ var updateMessageSequence: Int = 0
+
+ private val messages: MutableMap> = mutableMapOf()
+ private val _updateFlow: MutableSharedFlow = MutableSharedFlow()
+
+ private val client = MockServerClient(this)
+
+ companion object {
+ fun generateMessage(parentConversation: Conversation): Message {
+ return Message(
+ date = Date(),
+ text = "This is a test!",
+ guid = UUID.randomUUID().toString(),
+ sender = null,
+ conversation = parentConversation,
+ attachmentGUIDs = null,
+ )
+ }
+
+ fun generateConversation(): Conversation {
+ return Conversation(
+ date = Date(),
+ participants = listOf("james@magahern.com"),
+ displayName = null,
+ unreadCount = 0,
+ lastMessagePreview = null,
+ lastMessage = null,
+ guid = UUID.randomUUID().toString(),
+ lastFetchedMessageGUID = null,
+ )
+ }
+ }
+
+ fun getServer(): MockWebServer = MockWebServer()
+ fun getClient(): MockServerClient = client
+ fun getAPIInterface(): APIInterface = MockServerClient(this).getAPIInterface()
+
+ fun addConversation(conversation: Conversation) {
+ conversations.add(conversation)
+ messages[conversation.guid] = mutableListOf()
+
+ runBlocking {
+ _updateFlow.emit(UpdateItem(
+ sequence = updateMessageSequence++,
+ conversationChanged = conversation
+ ))
+ }
+ }
+
+ fun updateConversation(conversation: Conversation) {
+ conversations.removeAll { it.guid == conversation.guid }
+ addConversation(conversation)
+ }
+
+ fun addMessagesToConversation(conversation: Conversation, messages: List) {
+ val guid = conversation.guid
+ this.messages[guid]?.addAll(messages)
+ conversation.lastMessage = messages.last()
+ conversation.lastMessagePreview = messages.last().text
+
+ runBlocking {
+ for (message in messages) {
+ _updateFlow.emit(
+ UpdateItem(
+ sequence = updateMessageSequence++,
+ conversationChanged = conversation,
+ messageAdded = message
+ )
+ )
+ }
+ }
+ }
+
+ fun addTestConversations(count: Int): List {
+ val testConversations = ArrayList()
+ for (i in 0.. {
+ val testMessages = ArrayList()
+ for (i in 0..? {
+ return messages[guid]?.toList()
+ }
+
+ internal fun sendMessage(body: String, toConversationGUID: GUID): Message {
+ val conversation = conversations.first { it.guid == toConversationGUID }
+
+ val message = Message(
+ text = body,
+ date = Date(),
+ guid = UUID.randomUUID().toString(),
+ sender = null, // me
+ conversation = conversation,
+ attachmentGUIDs = null,
+ )
+
+ addMessagesToConversation(conversation, listOf(message))
+ return message
+ }
+}
+
+class MockServerClient(private val server: MockServer): APIClient, WebSocketListener() {
+ private var updateWebSocket: WebSocket? = null
+ private var updateWatchJob: Job? = null
+ private val gson: Gson = Gson()
+
+ override val isConfigured: Boolean = true
+
+ override fun getAPIInterface(): APIInterface {
+ return MockServerInterface(server)
+ }
+
+ override fun getWebSocketClient(
+ serverPath: String,
+ queryParams: Map?,
+ listener: WebSocketListener
+ ): WebSocket {
+ val webServer = server.getServer()
+
+ val params = queryParams ?: mapOf()
+ val baseHTTPURL: HttpUrl = webServer.url("/")
+ val baseURL = baseHTTPURL.toUrl()
+ val requestURL = baseURL.authenticatedWebSocketURL(serverPath, params)
+ val request = Request.Builder()
+ .url(requestURL)
+ .build()
+
+ webServer.enqueue(MockResponse().withWebSocketUpgrade(this))
+
+ if (this.updateWatchJob == null) {
+ CoroutineScope(Job()).launch {
+ startWatchingForUpdates(this)
+ }
+ }
+
+ return OkHttpClient().newWebSocket(request, listener)
+ }
+
+ private fun startWatchingForUpdates(scope: CoroutineScope) {
+ this.updateWatchJob = scope.launch {
+ server.updateFlow.collect {
+ println("Mock WebSocket is sending a message")
+
+ // Encode to JSON and send to websocket
+ val updateItems = listOf(it)
+ val encodedUpdateItem = gson.toJson(updateItems)
+ updateWebSocket?.send(encodedUpdateItem)
+ }
+ }
+ }
+
+ fun stopWatchingForUpdates() = runBlocking {
+ updateWatchJob?.cancelAndJoin()
+ }
+
+ override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
+ super.onOpen(webSocket, response)
+
+ println("Mock WebSocket opened.")
+ this.updateWebSocket = webSocket
+ }
+
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ super.onClosed(webSocket, code, reason)
+
+ println("Mock WebSocket closed.")
+ this.updateWebSocket = null
+ }
+}
+
+class MockServerInterface(private val server: MockServer): APIInterface {
+ override suspend fun getVersion(): ResponseBody {
+ return server.version.toResponseBody("text/plain".toMediaType())
+ }
+
+ override suspend fun getConversations(): Response> {
+ return Response.success(server.conversations)
+ }
+
+ override suspend fun getMessages(
+ conversationGUID: String,
+ limit: Int?,
+ beforeMessageGUID: GUID?,
+ afterMessageGUID: GUID?
+ ): Response> {
+ val messages = server.getMessagesForConversationGUID(conversationGUID)
+
+ return if (messages != null) {
+ Response.success(messages)
+ } else {
+ Response.error(500, "GUID not found".toResponseBody())
+ }
+ }
+
+ override suspend fun sendMessage(request: SendMessageRequest): Response {
+ val message = server.sendMessage(request.body, request.conversationGUID)
+
+ val response = SendMessageResponse(message.guid)
+ return Response.success(response)
+ }
+
+ override suspend fun markConversation(conversationGUID: String): Response {
+ server.markConversationAsRead(conversationGUID)
+ return Response.success(null)
+ }
+
+ override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun uploadAttachment(
+ filename: String,
+ body: RequestBody
+ ): Response {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun authenticate(request: AuthenticationRequest): Response {
+ // Anything goes!
+ val response = AuthenticationResponse(
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
+ "eyJ1c2VybmFtZSI6InRlc3QiLCJleHAiOjE3MDk3OTQ5NjB9." +
+ "82UcI1gB4eARmgrKwAY6JnbEdWLXou1GWp29scnUhi8"
+ )
+
+ return Response.success(response)
+ }
+}
\ No newline at end of file
diff --git a/android/backend/src/test/java/net/buzzert/kordophone/backend/java/android/util/Log.kt b/android/backend/src/test/java/net/buzzert/kordophone/backend/java/android/util/Log.kt
new file mode 100644
index 0000000..10832a6
--- /dev/null
+++ b/android/backend/src/test/java/net/buzzert/kordophone/backend/java/android/util/Log.kt
@@ -0,0 +1,29 @@
+package android.util
+
+public class Log {
+ companion object {
+ @JvmStatic
+ fun d(tag: String, msg: String): Int {
+ println("DEBUG: $tag: $msg")
+ return 0
+ }
+
+ @JvmStatic
+ fun i(tag: String, msg: String): Int {
+ println("INFO: $tag: $msg")
+ return 0
+ }
+
+ @JvmStatic
+ fun w(tag: String, msg: String): Int {
+ println("WARN: $tag: $msg")
+ return 0
+ }
+
+ @JvmStatic
+ fun e(tag: String, msg: String): Int {
+ println("ERROR: $tag: $msg")
+ return 0
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..5d66039
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,16 @@
+buildscript {
+ ext {
+ kotlin_version = '1.8.22'
+ realm_version = '1.10.0'
+ hilt_version = '2.44'
+ }
+}
+
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id 'com.android.application' version '8.0.2' apply false
+ id 'com.android.library' version '8.0.2' apply false
+ id 'org.jetbrains.kotlin.android' version "${kotlin_version}" apply false
+ id 'io.realm.kotlin' version "${realm_version}" apply false
+ id 'com.google.dagger.hilt.android' version "${hilt_version}" apply false
+}
\ No newline at end of file
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..a2e90d8
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,25 @@
+# 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 -Dfile.encoding=UTF-8
+# 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
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+android.defaults.buildfeatures.buildconfig=true
+android.nonFinalResIds=false
\ No newline at end of file
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..696bfb2
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Sun Jun 11 18:08:06 PDT 2023
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..107acd3
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle b/android/settings.gradle
new file mode 100644
index 0000000..6ccd160
--- /dev/null
+++ b/android/settings.gradle
@@ -0,0 +1,20 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+plugins {
+ id 'org.gradle.toolchains.foojay-resolver-convention' version '0.4.0'
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "KordophoneDroid"
+include ':app'
+include ':backend'