Private
Public Access
1
0

Add 'android/' from commit '5d26ea956906cd31a6cc37e79b0a4cac77b3118b'

git-subtree-dir: android
git-subtree-mainline: 7fe2701272
git-subtree-split: 5d26ea9569
This commit is contained in:
2025-09-06 19:37:14 -07:00
101 changed files with 5387 additions and 0 deletions

32
android/.build.yml Normal file
View File

@@ -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

17
android/.gitignore vendored Normal file
View File

@@ -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/

3
android/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

6
android/.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" />
</component>
</project>

20
android/.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/backend" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@@ -0,0 +1,41 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

6
android/.idea/kotlinc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.22" />
</component>
</project>

9
android/.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

6
android/.idea/vcs.xml generated Normal file
View File

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

2
android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build
/release

155
android/app/build.gradle Normal file
View File

@@ -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
}

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

@@ -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

View File

@@ -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) "<me>" 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<MessageListItem>(
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()
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.KordophoneDroid"
android:name=".KordophoneApplication"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
>
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.KordophoneDroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<!-- Message List Deep Link -->
<data android:scheme="kordophone" android:host="messages" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<service
android:name=".UpdateMonitorService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}

View File

@@ -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() {
}

View File

@@ -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<NavHostController> { 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<ChatRepository.Error?>(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
}
}
}
}

View File

@@ -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<AttachmentFetchData>
{
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)
}
}

View File

@@ -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))
}
}
}

View File

@@ -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<Conversation>,
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
)
)
}

View File

@@ -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<List<Conversation>>
get() = chatRepository.conversationChanges
.shareIn(viewModelScope, started = SharingStarted.WhileSubscribed())
.map {
it.sortedBy { it.date }
.reversed()
}
val isServerConfigured: Boolean
get() = chatRepository.isConfigured
val encounteredConnectionError: State<Boolean>
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()
}
}

View File

@@ -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")
}
}
}

View File

@@ -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<AttachmentRowItem>,
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<AttachmentRowItem> = 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))
}
}
}

View File

@@ -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<MessageListItem>()
for (message in messages) {
val metadata = MessageMetadata(
fromMe = message.sender == null,
date = message.date,
fromAddress = message.sender ?: "<me>",
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<Set<Uri>>(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<MessageListItem>,
paddingValues: PaddingValues,
showSenders: Boolean,
attachmentUris: Set<Uri>,
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<MessageListItem>,
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))
}
)
}
}

View File

@@ -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<List<OutgoingMessage>> = 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<List<Message>>
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<Uri>,
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)
}
}

View File

@@ -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 <T> SettingsTextField(
name: String,
@DrawableRes icon: Int,
state: State<T>,
onSave: () -> Unit,
dialogContent: @Composable (State<T>) -> 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")
}
}
}
}
}

View File

@@ -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<String> = MutableStateFlow("")
var serverPreference = _serverPreference.asStateFlow()
private val _usernamePreference: MutableStateFlow<String> = MutableStateFlow("")
var usernamePreference = _usernamePreference.asStateFlow()
private val _passwordPreference: MutableStateFlow<String> = 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)
}
}
}

View File

@@ -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> = _serverConfig
fun applyConfig(applicator: ServerConfig.() -> Unit) {
val config = _serverConfig.value.copy()
_serverConfig.value = config.apply(applicator)
_serverConfig.value.saveToSettings(context)
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)

View File

@@ -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)
)

View File

@@ -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 = {}
)
}
}

View File

@@ -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
)
*/
)

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM480,520Q421,520 380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q533,800 580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800ZM480,440Q506,440 523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M720,630Q720,734 647,807Q574,880 470,880Q366,880 293,807Q220,734 220,630L220,260Q220,185 272.5,132.5Q325,80 400,80Q475,80 527.5,132.5Q580,185 580,260L580,610Q580,656 548,688Q516,720 470,720Q424,720 392,688Q360,656 360,610L360,240L440,240L440,610Q440,623 448.5,631.5Q457,640 470,640Q483,640 491.5,631.5Q500,623 500,610L500,260Q499,218 470.5,189Q442,160 400,160Q358,160 329,189Q300,218 300,260L300,630Q299,701 349,750.5Q399,800 470,800Q540,800 589,750.5Q638,701 640,630L640,240L720,240L720,630Z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:fillColor="@color/black"
>
<gradient
android:startColor="#000"
android:endColor="#333"
android:angle="1.0"
/>
</shape>

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M120,800L120,640L840,640L840,800L120,800ZM200,760L280,760L280,680L200,680L200,760ZM120,320L120,160L840,160L840,320L120,320ZM200,280L280,280L280,200L200,200L200,280ZM120,560L120,400L840,400L840,560L120,560ZM200,520L280,520L280,440L200,440L200,520Z"/>
</vector>

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

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

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">KordophoneDroid</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KordophoneDroid" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
</style>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">tesseract.localdomain</domain>
<domain includeSubdomains="true">buzzert.kordophone.nor</domain>
</domain-config>
</network-security-config>

1
android/backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -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'
}

Binary file not shown.

Binary file not shown.

View File

21
android/backend/proguard-rules.pro vendored Normal file
View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -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<List<ModelConversation>>
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<List<ModelMessage>> {
return realm.query(Message::class, "conversationGUID == $0", conversation.guid)
.find()
.asFlow()
.map { it.list.map { it.toMessage(conversation) } }
}
fun updateConversations(incomingConversations: List<ModelConversation>) = 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<ModelConversation>) = realm.writeBlocking {
writeManagedConversations(this, conversations.map { it.toDatabaseConversation() })
}
private fun writeManagedConversations(mutableRealm: MutableRealm, conversations: List<Conversation>) {
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<ModelConversation>) = realm.writeBlocking {
conversations.forEach { inConversation ->
val conversation = getManagedConversationByGuid(inConversation.guid)
findLatest(conversation)?.let {
delete(it)
}
}
}
fun fetchConversations(): List<ModelConversation> {
val itemResults = realm.query(Conversation::class).find()
val items = realm.copyFromRealm(itemResults)
return items.map { it.toConversation() }
}
fun writeMessages(messages: List<ModelMessage>, 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<ModelMessage> {
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))
}
}

View File

@@ -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<String>,
var date: RealmInstant,
var unreadCount: Int,
var lastMessageGUID: String?,
var lastMessagePreview: String?,
): RealmObject
{
constructor() : this(
guid = ObjectId().toString(),
displayName = null,
participants = realmListOf<String>(),
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
}
}

View File

@@ -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)
}
}

View File

@@ -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<String>,
var conversationGUID: GUID,
): RealmObject
{
constructor() : this(
guid = ObjectId().toString(),
text = "",
sender = null,
date = RealmInstant.now(),
attachmentGUIDs = realmListOf<String>(),
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()
}
}
}

View File

@@ -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,
)

View File

@@ -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<String>,
@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
}
}

View File

@@ -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<String>?,
@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<Uri>,
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
)
}
}

View File

@@ -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,
)

View File

@@ -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<String, String>?,
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<List<Conversation>> = throwError()
override suspend fun sendMessage(request: SendMessageRequest): retrofit2.Response<SendMessageResponse> = throwError()
override suspend fun markConversation(conversationGUID: String): retrofit2.Response<Void> = throwError()
override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody = throwError()
override suspend fun uploadAttachment(filename: String, body: RequestBody): retrofit2.Response<UploadAttachmentResponse> = throwError()
override suspend fun authenticate(request: AuthenticationRequest): retrofit2.Response<AuthenticationResponse> = throwError()
override suspend fun getMessages(conversationGUID: String, limit: Int?, beforeMessageGUID: GUID?, afterMessageGUID: GUID?): retrofit2.Response<List<Message>> = 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<String, String>?,
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<String, String>?,
listener: WebSocketListener
): WebSocket {
val params = (queryParams ?: hashMapOf<String, String>()).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<String, String>): 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())
}

View File

@@ -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<String>?,
)
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<List<Conversation>>
@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<List<Message>>
@POST("/sendMessage")
suspend fun sendMessage(@Body request: SendMessageRequest): Response<SendMessageResponse>
@POST("/markConversation")
suspend fun markConversation(@Query("guid") conversationGUID: String): Response<Void>
@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<UploadAttachmentResponse>
@POST("/authenticate")
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
}
class ResponseDecodeError(val response: ResponseBody): Exception(response.string())
fun <T> Response<T>.bodyOnSuccessOrThrow(): T {
if (isSuccessful) {
return body()!!
}
throw ResponseDecodeError(errorBody()!!)
}

View File

@@ -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<Conversation>
get() = database.fetchConversations()
// Channel that's signaled when an outgoing message is delivered.
val messageDeliveredChannel: SharedFlow<MessageDeliveredEvent>
get() = _messageDeliveredChannel.asSharedFlow()
// Changes Flow
val conversationChanges: Flow<List<Conversation>>
get() = database.conversationChanges
.onEach { Log.d(REPO_LOG, "Got database conversations changed") }
// New Messages
val newMessages: SharedFlow<Message>
get() = _newMessageChannel.asSharedFlow()
// Errors channel
val errorEncounteredChannel: SharedFlow<Error>
get() = _errorEncounteredChannel.asSharedFlow()
val isConfigured: Boolean
get() = apiClient.isConfigured
// New messages for a particular conversation
fun messagesChanged(conversation: Conversation): Flow<List<Message>> =
database.messagesChanged(conversation)
// Testing harness
internal class TestingHarness(private val repository: ChatRepository) {
suspend fun fetchConversations(): List<Conversation> {
return repository.fetchConversations()
}
suspend fun fetchMessages(conversation: Conversation): List<Message> {
return repository.fetchMessages(conversation)
}
}
internal fun testingHarness(): TestingHarness = TestingHarness(this)
private var apiInterface = apiClient.getAPIInterface()
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessage> = ArrayBlockingQueue(16)
private var outgoingMessageThread: Thread? = null
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
private val _errorEncounteredChannel = MutableSharedFlow<Error>()
private val _newMessageChannel = MutableSharedFlow<Message>()
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<Message> {
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<Conversation> {
return apiInterface.getConversations().bodyOnSuccessOrThrow()
}
private suspend fun fetchMessages(
conversation: Conversation,
limit: Int? = null,
beforeGUID: String? = null,
afterGUID: String? = null,
): List<Message> {
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<String>()
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)
}
}
}
}
}
}

View File

@@ -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<Conversation>
get() = _conversationChanged
// Flow for messages added notifications
val messageAdded: Flow<Message>
get() = _messageAdded
private val gson: Gson = Gson()
private val updateItemsType: Type = object : TypeToken<ArrayList<UpdateItem>>() {}.type
private var webSocket: WebSocket? = null
private var needsSocketReconnect: Boolean = false
private var messageSeq: Int = -1
private val _conversationChanged: MutableSharedFlow<Conversation> = MutableSharedFlow()
private val _messageAdded: MutableSharedFlow<Message> = 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<UpdateItem> = 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()
}
}
}
// <WebSocketListener>
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())
}
}

View File

@@ -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<ChatRepository, RetrofitAPIClient> {
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<ChatRepository, MockServer> {
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<Message>? = 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())
}
}
}

View File

@@ -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()
}
}

View File

@@ -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<Conversation> = mutableListOf()
val updateFlow: Flow<UpdateItem> get() = _updateFlow
var updateMessageSequence: Int = 0
private val messages: MutableMap<String, MutableList<Message>> = mutableMapOf()
private val _updateFlow: MutableSharedFlow<UpdateItem> = 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<Message>) {
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<Conversation> {
val testConversations = ArrayList<Conversation>()
for (i in 0..<count) {
val conversation = MockServer.generateConversation()
testConversations.add(conversation)
addConversation(conversation)
}
return testConversations
}
fun addTestMessages(count: Int, conversation: Conversation): List<Message> {
val testMessages = ArrayList<Message>()
for (i in 0..<count) {
val message = MockServer.generateMessage(conversation)
testMessages.add(message)
}
addMessagesToConversation(conversation, testMessages)
return testMessages
}
fun markConversationAsRead(guid: GUID) {
val conversation = conversations.first { it.guid == guid }
conversation.unreadCount = 0
updateConversation(conversation)
}
internal fun getMessagesForConversationGUID(guid: GUID): List<Message>? {
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<String, String>?,
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<List<Conversation>> {
return Response.success(server.conversations)
}
override suspend fun getMessages(
conversationGUID: String,
limit: Int?,
beforeMessageGUID: GUID?,
afterMessageGUID: GUID?
): Response<List<Message>> {
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<SendMessageResponse> {
val message = server.sendMessage(request.body, request.conversationGUID)
val response = SendMessageResponse(message.guid)
return Response.success(response)
}
override suspend fun markConversation(conversationGUID: String): Response<Void> {
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<UploadAttachmentResponse> {
TODO("Not yet implemented")
}
override suspend fun authenticate(request: AuthenticationRequest): Response<AuthenticationResponse> {
// Anything goes!
val response = AuthenticationResponse(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
"eyJ1c2VybmFtZSI6InRlc3QiLCJleHAiOjE3MDk3OTQ5NjB9." +
"82UcI1gB4eARmgrKwAY6JnbEdWLXou1GWp29scnUhi8"
)
return Response.success(response)
}
}

View File

@@ -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
}
}
}

16
android/build.gradle Normal file
View File

@@ -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
}

25
android/gradle.properties Normal file
View File

@@ -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

Binary file not shown.

View File

@@ -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

185
android/gradlew vendored Executable file
View File

@@ -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" "$@"

89
android/gradlew.bat vendored Normal file
View File

@@ -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

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