Add 'android/' from commit '5d26ea956906cd31a6cc37e79b0a4cac77b3118b'
git-subtree-dir: android git-subtree-mainline:7fe2701272git-subtree-split:5d26ea9569
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>
|
||||