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