Add 'android/' from commit '5d26ea956906cd31a6cc37e79b0a4cac77b3118b'
git-subtree-dir: android git-subtree-mainline:7fe2701272git-subtree-split:5d26ea9569
32
android/.build.yml
Normal 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
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
6
android/.idea/compiler.xml
generated
Normal 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
@@ -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>
|
||||||
41
android/.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
/build
|
||||||
|
/release
|
||||||
155
android/app/build.gradle
Normal 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
@@ -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
|
||||||
137
android/app/src/debug/kotlin/previews/Previews.kt
Normal 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()
|
||||||
|
}
|
||||||
49
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
BIN
android/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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 = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
10
android/app/src/main/res/drawable/account_circle.xml
Normal 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>
|
||||||
10
android/app/src/main/res/drawable/attach_file.xml
Normal 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>
|
||||||
10
android/app/src/main/res/drawable/error.xml
Normal 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>
|
||||||
14
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||||
BIN
android/app/src/main/res/drawable/kordophone_ic.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
android/app/src/main/res/drawable/kordophone_ic_small.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
android/app/src/main/res/drawable/sedona.jpeg
Normal file
|
After Width: | Height: | Size: 659 KiB |
10
android/app/src/main/res/drawable/storage.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 840 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
10
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#3D3D3D</color>
|
||||||
|
</resources>
|
||||||
3
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">KordophoneDroid</string>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values/themes.xml
Normal 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>
|
||||||
13
android/app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||||
19
android/app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||||
8
android/app/src/main/res/xml/network_security_config.xml
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
58
android/backend/build.gradle
Normal 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'
|
||||||
|
}
|
||||||
BIN
android/backend/chat-cache-test
Normal file
BIN
android/backend/chat-cache-test.lock
Normal file
0
android/backend/consumer-rules.pro
Normal file
21
android/backend/proguard-rules.pro
vendored
Normal 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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
android/backend/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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()!!)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
6
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
@@ -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
|
||||||