diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6541566 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: +- package-ecosystem: gradle + directory: / + schedule: + interval: daily + open-pull-requests-limit: 3 diff --git a/.github/readme/google-play-badge.png b/.github/readme/google-play-badge.png new file mode 100644 index 0000000..c77b746 Binary files /dev/null and b/.github/readme/google-play-badge.png differ diff --git a/.github/workflows/assemble.yml b/.github/workflows/assemble.yml index 07f752e..5dc3d99 100644 --- a/.github/workflows/assemble.yml +++ b/.github/workflows/assemble.yml @@ -17,19 +17,11 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - uses: actions/setup-java@v2 with: distribution: 'adopt' java-version: '11' + - uses: gradle/gradle-build-action@v2 - name: Assemble debug variant run: ./gradlew assembleDebug --no-daemon diff --git a/.github/workflows/check_size.yml b/.github/workflows/check_size.yml new file mode 100644 index 0000000..1a855e0 --- /dev/null +++ b/.github/workflows/check_size.yml @@ -0,0 +1,45 @@ +name: Check Size + +on: + pull_request: + +jobs: + check-size: + name: Check Size + runs-on: ubuntu-latest + + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-java@v2 + with: + distribution: 'adopt' + java-version: '11' + - uses: gradle/gradle-build-action@v2 + + - name: Fetch bundletool + run: | + curl -s -L https://github.com/google/bundletool/releases/download/1.9.0/bundletool-all-1.9.0.jar --create-dirs -o bin/bundletool.jar + chmod +x bin/bundletool.jar + echo "#!/bin/bash" >> bin/bundletool + echo 'java -jar $(dirname "$0")/bundletool.jar "$@"' >> bin/bundletool + chmod +x bin/bundletool + echo "$(pwd)/bin" >> $GITHUB_PATH + + - name: Save Size + env: + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + run: | + mkdir -p ./apk_size + echo $(./tools/check-size.sh | tail -1 | cut -d ',' -f2-) > ./apk_size/size.txt + echo $PULL_REQUEST_NUMBER > ./apk_size/pr_number.txt + - uses: actions/upload-artifact@v3 + with: + name: apk-size + path: | + apk_size/size.txt + apk_size/pr_number.txt + retention-days: 5 diff --git a/.github/workflows/comment_size.yml b/.github/workflows/comment_size.yml new file mode 100644 index 0000000..288b98c --- /dev/null +++ b/.github/workflows/comment_size.yml @@ -0,0 +1,44 @@ +name: Comment APK Size + +on: + workflow_run: + workflows: [ "Check Size" ] + types: + - completed + +jobs: + comment-size: + name: Comment Size + runs-on: ubuntu-latest + if: > + ${{ github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' }} + + steps: + - uses: dawidd6/action-download-artifact@v2 + with: + name: apk-size + workflow: ${{ github.event.workflow_run.workflow_id }} + + - name: Check release size + run: | + ls -R + echo "::set-output name=APK_SIZE::$(cat size.txt)" + echo "::set-output name=PR_NUMBER::$(cat pr_number.txt)" + id: size + + - name: Find Comment + uses: peter-evans/find-comment@v1 + id: fc + with: + issue-number: ${{ steps.size.outputs.PR_NUMBER }} + comment-author: 'github-actions[bot]' + body-includes: APK Size + - name: Publish size to PR + uses: peter-evans/create-or-update-comment@v1 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + issue-number: ${{ steps.size.outputs.PR_NUMBER }} + body: | + APK Size: ${{ steps.size.outputs.APK_SIZE }} + edit-mode: replace \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 02289f3..d84ef48 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,29 +17,26 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - uses: actions/setup-java@v2 with: distribution: 'adopt' java-version: '11' + - uses: gradle/gradle-build-action@v2 + + - name: Create pip requirements + run: | + echo "matrix-synapse==v1.60.0" > requirements.txt - name: Set up Python 3.8 uses: actions/setup-python@v2 with: python-version: 3.8 + cache: 'pip' - name: Start synapse server run: | - pip install matrix-synapse - curl -sL https://raw.githubusercontent.com/matrix-org/synapse/develop/demo/start.sh \ + pip install -r requirements.txt + curl -sL https://gist.githubusercontent.com/ouchadam/e3ad09ec382bd91a66d88ab575ea7c31/raw/run.sh \ | bash -s -- --no-rate-limit - name: Run all unit tests diff --git a/.github/workflows/update-gradle-wrapper.yml b/.github/workflows/update-gradle-wrapper.yml new file mode 100644 index 0000000..50143be --- /dev/null +++ b/.github/workflows/update-gradle-wrapper.yml @@ -0,0 +1,15 @@ +name: Update Gradle Wrapper + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + update-gradle-wrapper: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Update Gradle Wrapper + uses: gradle-update/update-gradle-wrapper-action@v1 diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..bd2a505 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,29 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md index a3911a3..b94975e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ -# SmallTalk [![Assemble](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml/badge.svg)](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml) [![codecov](https://codecov.io/gh/ouchadam/small-talk/branch/main/graph/badge.svg?token=ETFSLZ9FCI)](https://codecov.io/gh/ouchadam/small-talk) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +# SmallTalk [![Assemble](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml/badge.svg)](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml) [![codecov](https://codecov.io/gh/ouchadam/small-talk/branch/main/graph/badge.svg?token=ETFSLZ9FCI)](https://codecov.io/gh/ouchadam/small-talk) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ![](https://img.shields.io/github/v/release/ouchadam/small-talk?include_prereleases) ![](https://img.shields.io/badge/%5Bmatrix%5D%20-%23small--talk%3Aiswell.cool-blueviolet) `SmallTalk` is a minimal, modern, friends and family focused Android messenger. Heavily inspired by Whatsapp and Signal, powered by Matrix. + ![header](https://github.com/ouchadam/small-talk/blob/main/.github/readme/header.png?raw=true) +[](https://play.google.com/store/apps/details?id=app.dapk.st) ---- +
+
+
-Project mantra -- Tiny app size - currently 1.72mb~ when provided via app bundle. + +### Project mantra +- Tiny app size - currently 1.80mb~ when provided via app bundle. - Focused on reliability and stability. - Bare-bones feature set. @@ -15,16 +20,17 @@ Project mantra --- -#### Feature list +### Feature list -- Login with username/password (home servers must serve `${domain}.well-known/matrix/client`) +- Login with Matrix ID/Password - Combined Room and DM interface - End to end encryption - Message bubbles, supporting text, replies and edits - Push notifications (DMs always notify, Rooms notify once) - Importing of E2E room keys from Element clients +- [UnifiedPush](https://unifiedpush.org/) -#### Planned +### Planned - Device verification (technically supported but has no UI) - Invitations (technically supported but has no UI) @@ -47,4 +53,8 @@ Project mantra - Greenfield matrix SDK implementation, focus on separation, testability and parallelisation. - Heavily optimised build script, clean _cacheless_ builds are sub 10 seconds with a warmed up gradle daemon. - Avoids code generation where possible in favour of build speed, this mainly means manual DI. -- A pure kotlin test harness to allow for critical flow assertions [Smoke Tests](https://github.com/ouchadam/small-talk/blob/main/test-harness/src/test/kotlin/SmokeTest.kt), currently Linux x86-64 only. +- A pure kotlin test harness to allow for critical flow assertions [Smoke Tests](https://github.com/ouchadam/small-talk/blob/main/test-harness/src/test/kotlin/SmokeTest.kt), currently Linux x86-64 only. + +--- + +#### Join the conversation @ https://matrix.to/#/#small-talk:iswell.cool diff --git a/app/build.gradle b/app/build.gradle index 5509a8a..5ed4c0c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,9 +10,20 @@ android { ndkVersion "25.0.8141415" defaultConfig { applicationId "app.dapk.st" - versionCode 2 - versionName "0.0.1-alpha1" - resConfigs "en" + def versionJson = new groovy.json.JsonSlurper().parseText(rootProject.file('version.json').text) + versionCode versionJson.code + versionName versionJson.name + + if (isDebugBuild) { + resConfigs "en", "xxhdpi" + variantFilter { variant -> + if (variant.buildType.name == "release") { + setIgnore(true) + } + } + } else { + resConfigs "en" + } } bundle { @@ -23,6 +34,7 @@ android { buildTypes { debug { + versionNameSuffix = " [debug]" matchingFallbacks = ['release'] signingConfig.storeFile rootProject.file("tools/debug.keystore") } @@ -33,16 +45,24 @@ android { 'proguard/app.pro', "proguard/serializationx.pro", "proguard/olm.pro" + + // actual releases are signed with a different config signingConfig = buildTypes.debug.signingConfig } } + compileOptions { + coreLibraryDesugaringEnabled true + } + packagingOptions { resources.excludes += "DebugProbesKt.bin" } } dependencies { + coreLibraryDesugaring Dependencies.google.jdkLibs + implementation project(":features:home") implementation project(":features:directory") implementation project(":features:login") @@ -51,8 +71,10 @@ dependencies { implementation project(":features:messenger") implementation project(":features:profile") implementation project(":features:navigator") + implementation project(":features:share-entry") implementation project(':domains:store') + implementation project(":domains:android:compose-core") implementation project(":domains:android:core") implementation project(":domains:android:tracking") implementation project(":domains:android:push") @@ -79,5 +101,5 @@ dependencies { implementation Dependencies.mavenCentral.matrixOlm implementation Dependencies.mavenCentral.kotlinSerializationJson - debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' + debugImplementation Dependencies.mavenCentral.leakCanary } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fe65c3e..6e493a2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,11 @@ + + + diff --git a/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt b/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt index 1ec1edb..8db7750 100644 --- a/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt +++ b/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt @@ -33,7 +33,7 @@ internal class SharedPreferencesDelegate( override suspend fun clear() { coroutineDispatchers.withIoContext { - preferences.edit().clear().apply() + preferences.edit().clear().commit() } } } \ No newline at end of file diff --git a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt index a2de52e..0590253 100644 --- a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt +++ b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt @@ -1,26 +1,29 @@ package app.dapk.st import android.app.Application +import android.content.Intent import android.util.Log import app.dapk.st.core.CoreAndroidModule import app.dapk.st.core.ModuleProvider import app.dapk.st.core.ProvidableModule import app.dapk.st.core.attachAppLogger +import app.dapk.st.core.extensions.ResettableUnsafeLazy import app.dapk.st.core.extensions.Scope -import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.directory.DirectoryModule -import app.dapk.st.messenger.MessengerModule +import app.dapk.st.domain.StoreModule import app.dapk.st.graph.AppModule -import app.dapk.st.graph.FeatureModules import app.dapk.st.home.HomeModule import app.dapk.st.login.LoginModule +import app.dapk.st.messenger.MessengerModule import app.dapk.st.notifications.NotificationsModule import app.dapk.st.profile.ProfileModule +import app.dapk.st.push.firebase.FirebasePushService +import app.dapk.st.push.PushModule import app.dapk.st.settings.SettingsModule +import app.dapk.st.share.ShareEntryModule import app.dapk.st.work.TaskRunnerModule import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.cancel import kotlin.reflect.KClass class SmallTalkApplication : Application(), ModuleProvider { @@ -28,8 +31,10 @@ class SmallTalkApplication : Application(), ModuleProvider { private val appLogger: (String, String) -> Unit = { tag, message -> _appLogger?.invoke(tag, message) } private var _appLogger: ((String, String) -> Unit)? = null - private val appModule: AppModule by unsafeLazy { AppModule(this, appLogger) } - private val featureModules: FeatureModules by unsafeLazy { appModule.featureModules } + private val lazyAppModule = ResettableUnsafeLazy { AppModule(this, appLogger) } + private val lazyFeatureModules = ResettableUnsafeLazy { appModule.featureModules } + private val appModule by lazyAppModule + private val featureModules by lazyFeatureModules private val applicationScope = Scope(Dispatchers.IO) override fun onCreate() { @@ -40,15 +45,17 @@ class SmallTalkApplication : Application(), ModuleProvider { val logger: (String, String) -> Unit = { tag, message -> Log.e(tag, message) - GlobalScope.launch { - eventLogStore.insert(tag, message) - } + applicationScope.launch { eventLogStore.insert(tag, message) } } attachAppLogger(logger) _appLogger = logger + onApplicationLaunch(notificationsModule, storeModule) + } + + private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) { applicationScope.launch { - notificationsModule.firebasePushTokenUseCase().registerCurrentToken() + featureModules.pushModule.pushTokenRegistrar().registerCurrentToken() storeModule.localEchoStore.preload() } @@ -67,10 +74,24 @@ class SmallTalkApplication : Application(), ModuleProvider { SettingsModule::class -> featureModules.settingsModule ProfileModule::class -> featureModules.profileModule NotificationsModule::class -> featureModules.notificationsModule + PushModule::class -> featureModules.pushModule MessengerModule::class -> featureModules.messengerModule TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule CoreAndroidModule::class -> appModule.coreAndroidModule + ShareEntryModule::class -> featureModules.shareEntryModule else -> throw IllegalArgumentException("Unknown: $klass") } as T } + + override fun reset() { + featureModules.pushModule.pushTokenRegistrar().unregister() + appModule.coroutineDispatchers.io.cancel() + applicationScope.cancel() + lazyAppModule.reset() + lazyFeatureModules.reset() + + val notificationsModule = featureModules.notificationsModule + val storeModule = appModule.storeModule.value + onApplicationLaunch(notificationsModule, storeModule) + } } diff --git a/app/src/main/kotlin/app/dapk/st/graph/AndroidBase64.kt b/app/src/main/kotlin/app/dapk/st/graph/AndroidBase64.kt new file mode 100644 index 0000000..dad547f --- /dev/null +++ b/app/src/main/kotlin/app/dapk/st/graph/AndroidBase64.kt @@ -0,0 +1,13 @@ +package app.dapk.st.graph + +import app.dapk.st.core.Base64 + +class AndroidBase64 : Base64 { + override fun encode(input: ByteArray): String { + return android.util.Base64.encodeToString(input, android.util.Base64.DEFAULT) + } + + override fun decode(input: String): ByteArray { + return android.util.Base64.decode(input, android.util.Base64.DEFAULT) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index a5868be..24e0232 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -1,16 +1,17 @@ package app.dapk.st.graph -import android.app.Activity import android.app.Application +import android.app.PendingIntent +import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build import app.dapk.db.DapkDb import app.dapk.st.BuildConfig import app.dapk.st.SharedPreferencesDelegate -import app.dapk.st.core.BuildMeta -import app.dapk.st.core.CoreAndroidModule -import app.dapk.st.core.CoroutineDispatchers -import app.dapk.st.core.SingletonFlows +import app.dapk.st.core.* import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.directory.DirectoryModule @@ -20,8 +21,6 @@ import app.dapk.st.home.MainActivity import app.dapk.st.imageloader.ImageLoaderModule import app.dapk.st.login.LoginModule import app.dapk.st.matrix.MatrixClient -import app.dapk.st.matrix.MatrixTaskRunner -import app.dapk.st.matrix.MatrixTaskRunner.MatrixTask import app.dapk.st.matrix.auth.authService import app.dapk.st.matrix.auth.installAuthService import app.dapk.st.matrix.common.* @@ -34,7 +33,11 @@ import app.dapk.st.matrix.device.installEncryptionService import app.dapk.st.matrix.device.internal.ApiMessage import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory -import app.dapk.st.matrix.message.* +import app.dapk.st.matrix.message.MessageEncrypter +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.message.installMessageService +import app.dapk.st.matrix.message.internal.ImageContentReader +import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.push.pushService import app.dapk.st.matrix.room.* @@ -44,6 +47,8 @@ import app.dapk.st.matrix.sync.internal.room.MessageDecrypter import app.dapk.st.messenger.MessengerActivity import app.dapk.st.messenger.MessengerModule import app.dapk.st.navigator.IntentFactory +import app.dapk.st.navigator.MessageAttachment +import app.dapk.st.notifications.MatrixPushHandler import app.dapk.st.notifications.NotificationsModule import app.dapk.st.olm.DeviceKeyFactory import app.dapk.st.olm.OlmPersistenceWrapper @@ -51,18 +56,17 @@ import app.dapk.st.olm.OlmWrapper import app.dapk.st.profile.ProfileModule import app.dapk.st.push.PushModule import app.dapk.st.settings.SettingsModule +import app.dapk.st.share.ShareEntryModule import app.dapk.st.tracking.TrackingModule -import app.dapk.st.work.TaskRunner import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.WorkModule -import app.dapk.st.work.WorkScheduler import com.squareup.sqldelight.android.AndroidSqliteDriver import kotlinx.coroutines.Dispatchers import java.time.Clock internal class AppModule(context: Application, logger: MatrixLogger) { - private val buildMeta = BuildMeta(BuildConfig.VERSION_NAME) + private val buildMeta = BuildMeta(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) private val trackingModule by unsafeLazy { TrackingModule( isCrashTrackingEnabled = !BuildConfig.DEBUG @@ -71,8 +75,8 @@ internal class AppModule(context: Application, logger: MatrixLogger) { private val driver = AndroidSqliteDriver(DapkDb.Schema, context, "dapk.db") private val database = DapkDb(driver) - - private val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO) + private val clock = Clock.systemUTC() + val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO) val storeModule = unsafeLazy { StoreModule( @@ -80,31 +84,41 @@ internal class AppModule(context: Application, logger: MatrixLogger) { preferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-user-preferences", coroutineDispatchers), errorTracker = trackingModule.errorTracker, credentialPreferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-credentials-preferences", coroutineDispatchers), - databaseDropper = { includeCryptoAccount -> - val cursor = driver.executeQuery( - identifier = null, - sql = "SELECT name FROM sqlite_master WHERE type = 'table' ${if (includeCryptoAccount) "" else "AND name != 'dbCryptoAccount'"}", - parameters = 0 - ) - while (cursor.next()) { - cursor.getString(0)?.let { - driver.execute(null, "DELETE FROM $it", 0) - } - } - }, + databaseDropper = DefaultDatabaseDropper(coroutineDispatchers, driver), coroutineDispatchers = coroutineDispatchers ) } private val workModule = WorkModule(context) private val imageLoaderModule = ImageLoaderModule(context) - private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers) - val domainModules = DomainModules(matrixModules, trackingModule.errorTracker) + private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, context.contentResolver) + val domainModules = DomainModules(matrixModules, trackingModule.errorTracker, workModule, storeModule, context, coroutineDispatchers) val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory { - override fun home(activity: Activity) = Intent(activity, MainActivity::class.java) - override fun messenger(activity: Activity, roomId: RoomId) = MessengerActivity.newInstance(activity, roomId) - override fun messengerShortcut(activity: Activity, roomId: RoomId) = MessengerActivity.newShortcutInstance(activity, roomId) + override fun notificationOpenApp(context: Context) = PendingIntent.getActivity( + context, + 1000, + home(context) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + + override fun notificationOpenMessage(context: Context, roomId: RoomId) = PendingIntent.getActivity( + context, + roomId.hashCode(), + messenger(context, roomId) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + + override fun home(context: Context) = Intent(context, MainActivity::class.java) + override fun messenger(context: Context, roomId: RoomId) = MessengerActivity.newInstance(context, roomId) + override fun messengerShortcut(context: Context, roomId: RoomId) = MessengerActivity.newShortcutInstance(context, roomId) + override fun messengerAttachments(context: Context, roomId: RoomId, attachments: List) = MessengerActivity.newMessageAttachment( + context, + roomId, + attachments + ) }) val featureModules = FeatureModules( @@ -112,10 +126,12 @@ internal class AppModule(context: Application, logger: MatrixLogger) { matrixModules, domainModules, trackingModule, + coreAndroidModule, imageLoaderModule, context, buildMeta, coroutineDispatchers, + clock, ) } @@ -124,10 +140,12 @@ internal class FeatureModules internal constructor( private val matrixModules: MatrixModules, private val domainModules: DomainModules, private val trackingModule: TrackingModule, + private val coreAndroidModule: CoreAndroidModule, imageLoaderModule: ImageLoaderModule, context: Context, buildMeta: BuildMeta, coroutineDispatchers: CoroutineDispatchers, + clock: Clock, ) { val directoryModule by unsafeLazy { @@ -155,12 +173,14 @@ internal class FeatureModules internal constructor( matrixModules.room, storeModule.value.credentialsStore(), storeModule.value.roomStore(), + clock ) } - val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile) } + val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile, buildMeta) } val settingsModule by unsafeLazy { SettingsModule( storeModule.value, + pushModule, matrixModules.crypto, matrixModules.sync, context.contentResolver, @@ -168,19 +188,26 @@ internal class FeatureModules internal constructor( coroutineDispatchers ) } - val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync) } + val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync, matrixModules.room, trackingModule.errorTracker) } val notificationsModule by unsafeLazy { NotificationsModule( - matrixModules.push, - matrixModules.sync, - storeModule.value.credentialsStore(), - domainModules.pushModule.registerFirebasePushTokenUseCase(), imageLoaderModule.iconLoader(), storeModule.value.roomStore(), context, + intentFactory = coreAndroidModule.intentFactory(), + dispatchers = coroutineDispatchers, + deviceMeta = DeviceMeta(Build.VERSION.SDK_INT) ) } + val shareEntryModule by unsafeLazy { + ShareEntryModule(matrixModules.sync, matrixModules.room) + } + + val pushModule by unsafeLazy { + domainModules.pushModule + } + } internal class MatrixModules( @@ -189,6 +216,7 @@ internal class MatrixModules( private val workModule: WorkModule, private val logger: MatrixLogger, private val coroutineDispatchers: CoroutineDispatchers, + private val contentResolver: ContentResolver, ) { val matrix by unsafeLazy { @@ -205,7 +233,8 @@ internal class MatrixModules( installAuthService(credentialsStore) installEncryptionService(store.knownDevicesStore()) - val olmAccountStore = OlmPersistenceWrapper(store.olmStore()) + val base64 = AndroidBase64() + val olmAccountStore = OlmPersistenceWrapper(store.olmStore(), base64) val singletonFlows = SingletonFlows(coroutineDispatchers) val olm = OlmWrapper( olmStore = olmAccountStore, @@ -225,13 +254,16 @@ internal class MatrixModules( services.roomService().joinedMembers(it).map { it.userId } } }, + base64 = base64, coroutineDispatchers = coroutineDispatchers, ) - installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler())) { serviceProvider -> + val imageContentReader = AndroidImageContentReader(contentResolver) + installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader) { serviceProvider -> MessageEncrypter { message -> val result = serviceProvider.cryptoService().encrypt( roomId = when (message) { is MessageService.Message.TextMessage -> message.roomId + is MessageService.Message.ImageMessage -> message.roomId }, credentials = credentialsStore.credentials()!!, when (message) { @@ -246,6 +278,8 @@ internal class MatrixModules( ) ) ) + + is MessageService.Message.ImageMessage -> TODO() } ) @@ -283,6 +317,14 @@ internal class MatrixModules( store.roomStore(), store.syncStore(), store.filterStore(), + deviceNotifier = { services -> + val encryption = services.deviceService() + val crypto = services.cryptoService() + DeviceNotifier { userIds, syncToken -> + encryption.updateStaleDevices(userIds) + crypto.updateOlmSession(userIds, syncToken) + } + }, messageDecrypter = { serviceProvider -> val cryptoService = serviceProvider.cryptoService() MessageDecrypter { @@ -296,9 +338,9 @@ internal class MatrixModules( } }, verificationHandler = { services -> - logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it") val cryptoService = services.cryptoService() VerificationHandler { apiEvent -> + logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it") cryptoService.onVerificationEvent( when (apiEvent) { is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested( @@ -308,12 +350,14 @@ internal class MatrixModules( apiEvent.content.methods, apiEvent.content.timestampPosix, ) + is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( apiEvent.sender, apiEvent.content.fromDevice, apiEvent.content.transactionId, apiEvent.content.methods, ) + is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( apiEvent.sender, apiEvent.content.fromDevice, @@ -324,6 +368,7 @@ internal class MatrixModules( apiEvent.content.short, apiEvent.content.transactionId, ) + is ApiToDeviceEvent.VerificationCancel -> TODO() is ApiToDeviceEvent.VerificationAccept -> TODO() is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( @@ -331,6 +376,7 @@ internal class MatrixModules( apiEvent.content.transactionId, apiEvent.content.key ) + is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( apiEvent.sender, apiEvent.content.transactionId, @@ -341,14 +387,6 @@ internal class MatrixModules( ) } }, - deviceNotifier = { services -> - val encryption = services.deviceService() - val crypto = services.cryptoService() - DeviceNotifier { userIds, syncToken -> - encryption.updateStaleDevices(userIds) - crypto.updateOlmSession(userIds, syncToken) - } - }, oneTimeKeyProducer = { services -> val cryptoService = services.cryptoService() MaybeCreateMoreKeys { @@ -359,6 +397,7 @@ internal class MatrixModules( val roomService = services.roomService() object : RoomMembersService { override suspend fun find(roomId: RoomId, userIds: List) = roomService.findMembers(roomId, userIds) + override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId) override suspend fun insert(roomId: RoomId, members: List) = roomService.insertMembers(roomId, members) } }, @@ -367,8 +406,6 @@ internal class MatrixModules( ) installPushService(credentialsStore) - - } } } @@ -385,32 +422,49 @@ internal class MatrixModules( internal class DomainModules( private val matrixModules: MatrixModules, private val errorTracker: ErrorTracker, + private val workModule: WorkModule, + private val storeModule: Lazy, + private val context: Application, + private val dispatchers: CoroutineDispatchers, ) { - val pushModule by unsafeLazy { PushModule(matrixModules.push, errorTracker) } - val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run)) } -} - -class BackgroundWorkAdapter(private val workScheduler: WorkScheduler) : BackgroundScheduler { - override fun schedule(key: String, task: BackgroundScheduler.Task) { - workScheduler.schedule( - WorkScheduler.WorkTask( - jobId = 1, - type = task.type, - jsonPayload = task.jsonPayload, - ) + val pushModule by unsafeLazy { + val store = storeModule.value + val pushHandler = MatrixPushHandler( + workScheduler = workModule.workScheduler(), + credentialsStore = store.credentialsStore(), + matrixModules.sync, + store.roomStore(), + ) + PushModule( + errorTracker, + pushHandler, + context, + dispatchers, + SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-user-preferences", dispatchers) ) } + val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run, AppTaskRunner(matrixModules.push))) } } -class TaskRunnerAdapter(private val matrixTaskRunner: suspend (MatrixTask) -> MatrixTaskRunner.TaskResult) : TaskRunner { +internal class AndroidImageContentReader(private val contentResolver: ContentResolver) : ImageContentReader { + override fun read(uri: String): ImageContentReader.ImageContent { + val androidUri = Uri.parse(uri) + val fileStream = contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri") - override suspend fun run(tasks: List): List { - return tasks.map { - when (val result = matrixTaskRunner(MatrixTask(it.task.type, it.task.jsonPayload))) { - is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(it.source, canRetry = result.canRetry) - MatrixTaskRunner.TaskResult.Success -> TaskRunner.TaskResult.Success(it.source) - } - } + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(fileStream, null, options) + + return contentResolver.openInputStream(androidUri)?.use { stream -> + val output = stream.readBytes() + ImageContentReader.ImageContent( + height = options.outHeight, + width = options.outWidth, + size = output.size.toLong(), + mimeType = options.outMimeType, + fileName = androidUri.lastPathSegment ?: "file", + content = output + ) + } ?: throw IllegalArgumentException("Could not process $uri") } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt b/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt new file mode 100644 index 0000000..ca8bbf9 --- /dev/null +++ b/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt @@ -0,0 +1,37 @@ +package app.dapk.st.graph + +import app.dapk.st.matrix.push.PushService +import app.dapk.st.push.PushTokenPayload +import app.dapk.st.work.TaskRunner +import io.ktor.client.plugins.* +import kotlinx.serialization.json.Json + +class AppTaskRunner( + private val pushService: PushService, +) { + + suspend fun run(workTask: TaskRunner.RunnableWorkTask): TaskRunner.TaskResult { + return when (val type = workTask.task.type) { + "push_token" -> { + runCatching { + val payload = Json.decodeFromString(PushTokenPayload.serializer(), workTask.task.jsonPayload) + pushService.registerPush(payload.token, payload.gatewayUrl) + }.fold( + onSuccess = { TaskRunner.TaskResult.Success(workTask.source) }, + onFailure = { + val canRetry = if (it is ClientRequestException) { + it.response.status.value !in (400 until 500) + } else { + true + } + TaskRunner.TaskResult.Failure(workTask.source, canRetry = canRetry) + } + ) + } + + else -> throw IllegalArgumentException("Unknown work type: $type") + } + + } + +} diff --git a/app/src/main/kotlin/app/dapk/st/graph/BackgroundWorkAdapter.kt b/app/src/main/kotlin/app/dapk/st/graph/BackgroundWorkAdapter.kt new file mode 100644 index 0000000..c35db37 --- /dev/null +++ b/app/src/main/kotlin/app/dapk/st/graph/BackgroundWorkAdapter.kt @@ -0,0 +1,16 @@ +package app.dapk.st.graph + +import app.dapk.st.matrix.message.BackgroundScheduler +import app.dapk.st.work.WorkScheduler + +class BackgroundWorkAdapter(private val workScheduler: WorkScheduler) : BackgroundScheduler { + override fun schedule(key: String, task: BackgroundScheduler.Task) { + workScheduler.schedule( + WorkScheduler.WorkTask( + jobId = 1, + type = task.type, + jsonPayload = task.jsonPayload, + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/app/dapk/st/graph/DefaultDatabaseDropper.kt b/app/src/main/kotlin/app/dapk/st/graph/DefaultDatabaseDropper.kt new file mode 100644 index 0000000..6966bfe --- /dev/null +++ b/app/src/main/kotlin/app/dapk/st/graph/DefaultDatabaseDropper.kt @@ -0,0 +1,33 @@ +package app.dapk.st.graph + +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.withIoContext +import app.dapk.st.domain.DatabaseDropper +import com.squareup.sqldelight.android.AndroidSqliteDriver + +class DefaultDatabaseDropper( + private val coroutineDispatchers: CoroutineDispatchers, + private val driver: AndroidSqliteDriver, +) : DatabaseDropper { + + override suspend fun dropAllTables(deleteCrypto: Boolean) { + coroutineDispatchers.withIoContext { + val cursor = driver.executeQuery( + identifier = null, + sql = "SELECT name FROM sqlite_master WHERE type = 'table'", + parameters = 0 + ) + cursor.use { + while (cursor.next()) { + cursor.getString(0)?.let { + if (!deleteCrypto && it.startsWith("dbCrypto")) { + // skip + } else { + driver.execute(null, "DELETE FROM $it", 0) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/app/dapk/st/graph/TaskRunnerAdapter.kt b/app/src/main/kotlin/app/dapk/st/graph/TaskRunnerAdapter.kt new file mode 100644 index 0000000..5f9f717 --- /dev/null +++ b/app/src/main/kotlin/app/dapk/st/graph/TaskRunnerAdapter.kt @@ -0,0 +1,24 @@ +package app.dapk.st.graph + +import app.dapk.st.matrix.MatrixTaskRunner +import app.dapk.st.work.TaskRunner + +class TaskRunnerAdapter( + private val matrixTaskRunner: suspend (MatrixTaskRunner.MatrixTask) -> MatrixTaskRunner.TaskResult, + private val appTaskRunner: AppTaskRunner, +) : TaskRunner { + + override suspend fun run(tasks: List): List { + return tasks.map { + when { + it.task.type.startsWith("matrix") -> { + when (val result = matrixTaskRunner(MatrixTaskRunner.MatrixTask(it.task.type, it.task.jsonPayload))) { + is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(it.source, canRetry = result.canRetry) + MatrixTaskRunner.TaskResult.Success -> TaskRunner.TaskResult.Success(it.source) + } + } + else -> appTaskRunner.run(it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..e727fee --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7bcba9f..c28a7fb 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ buildscript { classpath Dependencies.mavenCentral.kotlinGradlePlugin classpath Dependencies.mavenCentral.sqldelightGradlePlugin classpath Dependencies.mavenCentral.kotlinSerializationGradlePlugin - classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1' + classpath Dependencies.google.firebaseCrashlyticsPlugin } } @@ -18,14 +18,16 @@ def launchTask = getGradle() .getTaskRequests() .toString() .toLowerCase() +def isReleaseBuild = launchTask.contains("release") +ext.isDebugBuild = !isReleaseBuild subprojects { tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { kotlinOptions { jvmTarget = "1.8" freeCompilerArgs = [ - '-Xopt-in=kotlin.contracts.ExperimentalContracts', - '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', + '-opt-in=kotlin.contracts.ExperimentalContracts', + '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', ] } } @@ -52,7 +54,7 @@ ext.applyLibraryPlugins = { project -> project.apply plugin: 'kotlin-android' } -ext.androidSdkVersion = 31 +ext.androidSdkVersion = 32 ext.applyCommonAndroidParameters = { project -> def android = project.android @@ -63,14 +65,9 @@ ext.applyCommonAndroidParameters = { project -> incremental = true } android.defaultConfig { - minSdkVersion 29 + minSdkVersion 24 targetSdkVersion androidSdkVersion } - - android.buildFeatures.compose = true - android.composeOptions { - kotlinCompilerExtensionVersion = Dependencies.google.kotlinCompilerExtensionVersion - } } ext.applyLibraryModuleOptimisations = { project -> @@ -101,17 +98,26 @@ ext.applyCompose = { project -> dependencies.implementation Dependencies.google.androidxComposeMaterial dependencies.implementation Dependencies.google.androidxComposeIconsExtended dependencies.implementation Dependencies.google.androidxActivityCompose + + def android = project.android + android.buildFeatures.compose = true + android.composeOptions { + kotlinCompilerExtensionVersion = Dependencies.google.kotlinCompilerExtensionVersion + } +} + +ext.applyAndroidComposeLibraryModule = { project -> + applyAndroidLibraryModule(project) + applyCompose(project) } ext.applyAndroidLibraryModule = { project -> applyLibraryPlugins(project) applyCommonAndroidParameters(project) applyLibraryModuleOptimisations(project) - applyCompose(project) } ext.applyCrashlyticsIfRelease = { project -> - def isReleaseBuild = launchTask.contains("release") if (isReleaseBuild) { project.apply plugin: 'com.google.firebase.crashlytics' project.afterEvaluate { @@ -126,16 +132,17 @@ ext.kotlinTest = { dependencies -> dependencies.testImplementation Dependencies.mavenCentral.kluent dependencies.testImplementation Dependencies.mavenCentral.kotlinTest dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10" - dependencies.testImplementation 'io.mockk:mockk:1.12.2' - dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0' + dependencies.testImplementation 'io.mockk:mockk:1.12.7' + dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' - dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' - dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' + dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0' + dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0' } ext.kotlinFixtures = { dependencies -> - dependencies.testFixturesImplementation 'io.mockk:mockk:1.12.2' + dependencies.testFixturesImplementation 'io.mockk:mockk:1.12.7' dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent + dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore } ext.androidImportFixturesWorkaround = { project, fixtures -> diff --git a/core/build.gradle b/core/build.gradle index 9388e48..b573f32 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -4,9 +4,9 @@ plugins { } dependencies { - implementation Dependencies.mavenCentral.kotlinCoroutinesCore + api Dependencies.mavenCentral.kotlinCoroutinesCore testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore testFixturesImplementation Dependencies.mavenCentral.kluent - testFixturesImplementation 'io.mockk:mockk:1.12.2' - testFixturesImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0' + testFixturesImplementation Dependencies.mavenCentral.mockk + testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest } \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/AndroidUri.kt b/core/src/main/kotlin/app/dapk/st/core/AndroidUri.kt new file mode 100644 index 0000000..eaf6813 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/AndroidUri.kt @@ -0,0 +1,4 @@ +package app.dapk.st.core + +@JvmInline +value class AndroidUri(val value: String) \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/Base64.kt b/core/src/main/kotlin/app/dapk/st/core/Base64.kt new file mode 100644 index 0000000..3ad71f3 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/Base64.kt @@ -0,0 +1,6 @@ +package app.dapk.st.core + +interface Base64 { + fun encode(input: ByteArray): String + fun decode(input: String): ByteArray +} \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt b/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt index 4e965e9..3fd09ac 100644 --- a/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt +++ b/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt @@ -2,4 +2,5 @@ package app.dapk.st.core data class BuildMeta( val versionName: String, -) \ No newline at end of file + val versionCode: Int, +) diff --git a/core/src/main/kotlin/app/dapk/st/core/CoroutineDispatchers.kt b/core/src/main/kotlin/app/dapk/st/core/CoroutineDispatchers.kt index 14e8ca2..5281166 100644 --- a/core/src/main/kotlin/app/dapk/st/core/CoroutineDispatchers.kt +++ b/core/src/main/kotlin/app/dapk/st/core/CoroutineDispatchers.kt @@ -2,7 +2,11 @@ package app.dapk.st.core import kotlinx.coroutines.* -data class CoroutineDispatchers(val io: CoroutineDispatcher = Dispatchers.IO, val global: CoroutineScope = GlobalScope) +data class CoroutineDispatchers( + val io: CoroutineDispatcher = Dispatchers.IO, + val main: CoroutineDispatcher = Dispatchers.Main, + val global: CoroutineScope = GlobalScope, +) suspend fun CoroutineDispatchers.withIoContext( block: suspend CoroutineScope.() -> T diff --git a/core/src/main/kotlin/app/dapk/st/core/DeviceMeta.kt b/core/src/main/kotlin/app/dapk/st/core/DeviceMeta.kt new file mode 100644 index 0000000..28529d2 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/DeviceMeta.kt @@ -0,0 +1,5 @@ +package app.dapk.st.core + +data class DeviceMeta( + val apiVersion: Int +) \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/LRUCache.kt b/core/src/main/kotlin/app/dapk/st/core/LRUCache.kt new file mode 100644 index 0000000..373d0ca --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/LRUCache.kt @@ -0,0 +1,27 @@ +package app.dapk.st.core + +class LRUCache(val maxSize: Int) { + + private val internalCache = object : LinkedHashMap(0, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > maxSize + } + } + + fun put(key: K, value: V) { + internalCache[key] = value + } + + fun get(key: K): V? { + return internalCache[key] + } + + fun getOrPut(key: K, value: () -> V): V { + return get(key) ?: value().also { put(key, it) } + } + + fun size() = internalCache.size + +} + +fun LRUCache<*, *>?.isNullOrEmpty() = this == null || this.size() == 0 \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/MimeType.kt b/core/src/main/kotlin/app/dapk/st/core/MimeType.kt new file mode 100644 index 0000000..4d24bf8 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/MimeType.kt @@ -0,0 +1,5 @@ +package app.dapk.st.core + +sealed interface MimeType { + object Image: MimeType +} \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/ModuleProvider.kt b/core/src/main/kotlin/app/dapk/st/core/ModuleProvider.kt index 67f75a7..7dad034 100644 --- a/core/src/main/kotlin/app/dapk/st/core/ModuleProvider.kt +++ b/core/src/main/kotlin/app/dapk/st/core/ModuleProvider.kt @@ -4,7 +4,8 @@ import kotlin.reflect.KClass interface ModuleProvider { - fun provide(klass: KClass): T + fun provide(klass: KClass): T + fun reset() } interface ProvidableModule \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/SingletonFlows.kt b/core/src/main/kotlin/app/dapk/st/core/SingletonFlows.kt index 1d09273..64f66b8 100644 --- a/core/src/main/kotlin/app/dapk/st/core/SingletonFlows.kt +++ b/core/src/main/kotlin/app/dapk/st/core/SingletonFlows.kt @@ -31,10 +31,12 @@ class SingletonFlows( } } + @Suppress("UNCHECKED_CAST") fun get(key: String): Flow { return cache[key]!! as Flow } + @Suppress("UNCHECKED_CAST") suspend fun update(key: String, value: T) { (cache[key] as? MutableSharedFlow)?.emit(value) } diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt index 5675590..1c947a7 100644 --- a/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt @@ -21,3 +21,25 @@ inline fun Iterable.firstOrNull(predicate: (T) -> Boolean } fun unsafeLazy(initializer: () -> T): Lazy = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer) + +class ResettableUnsafeLazy(private val initializer: () -> T) : Lazy { + + private var _value: T? = null + + override val value: T + get() { + return if (_value == null) { + initializer().also { _value = it } + } else { + _value!! + } + } + + override fun isInitialized(): Boolean { + return _value != null + } + + fun reset() { + _value = null + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/MapExtensions.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/MapExtensions.kt new file mode 100644 index 0000000..5a59d12 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/MapExtensions.kt @@ -0,0 +1,8 @@ +package app.dapk.st.core.extensions + +fun Map?.containsKey(key: K) = this?.containsKey(key) ?: false + +fun MutableMap.clearAndPutAll(input: Map) { + this.clear() + this.putAll(input) +} \ No newline at end of file diff --git a/core/src/testFixtures/kotlin/fixture/CoroutineDispatchersFixture.kt b/core/src/testFixtures/kotlin/fixture/CoroutineDispatchersFixture.kt new file mode 100644 index 0000000..f7347c2 --- /dev/null +++ b/core/src/testFixtures/kotlin/fixture/CoroutineDispatchersFixture.kt @@ -0,0 +1,14 @@ +package fixture + +import app.dapk.st.core.CoroutineDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers + +object CoroutineDispatchersFixture { + + fun aCoroutineDispatchers() = CoroutineDispatchers( + Dispatchers.Unconfined, + main = Dispatchers.Unconfined, + global = CoroutineScope(Dispatchers.Unconfined) + ) +} \ No newline at end of file diff --git a/core/src/testFixtures/kotlin/test/ExpectTestScope.kt b/core/src/testFixtures/kotlin/test/ExpectTestScope.kt index 127b31f..f9f3db7 100644 --- a/core/src/testFixtures/kotlin/test/ExpectTestScope.kt +++ b/core/src/testFixtures/kotlin/test/ExpectTestScope.kt @@ -1,9 +1,6 @@ package test -import io.mockk.MockKMatcherScope -import io.mockk.MockKVerificationScope -import io.mockk.coJustRun -import io.mockk.coVerifyAll +import io.mockk.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import kotlin.coroutines.CoroutineContext @@ -12,23 +9,30 @@ fun runExpectTest(testBody: suspend ExpectTestScope.() -> Unit) { runTest { testBody(ExpectTest(coroutineContext)) } } - class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope { - private val expects = mutableListOf Unit>() + private val expects = mutableListOf Unit>>() + private val groups = mutableListOf Unit>() - override fun verifyExpects() = coVerifyAll { expects.forEach { it.invoke(this@coVerifyAll) } } - - override fun T.expectUnit(block: suspend MockKMatcherScope.(T) -> Unit) { - coJustRun { block(this@expectUnit) }.ignore() - expects.add { block(this@expectUnit) } + override fun verifyExpects() { + expects.forEach { (times, block) -> coVerify(exactly = times) { block.invoke(this) } } + groups.forEach { coVerifyOrder { it.invoke(this) } } } + override fun T.expectUnit(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) { + coJustRun { block(this@expectUnit) }.ignore() + expects.add(times to { block(this@expectUnit) }) + } + + override fun T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) { + groups.add { block(this@captureExpects) } + } } private fun Any.ignore() = Unit interface ExpectTestScope : CoroutineScope { fun verifyExpects() - fun T.expectUnit(block: suspend MockKMatcherScope.(T) -> Unit) + fun T.expectUnit(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit) + fun T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) } \ No newline at end of file diff --git a/core/src/testFixtures/kotlin/test/MockkExtensions.kt b/core/src/testFixtures/kotlin/test/MockkExtensions.kt index bcddce0..2bafc94 100644 --- a/core/src/testFixtures/kotlin/test/MockkExtensions.kt +++ b/core/src/testFixtures/kotlin/test/MockkExtensions.kt @@ -1,6 +1,8 @@ package test import io.mockk.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf inline fun T.expect(crossinline block: suspend MockKMatcherScope.(T) -> R) { coEvery { block(this@expect) } returns mockk(relaxed = true) @@ -16,11 +18,22 @@ fun MockKStubScope.delegateReturn() = object : Returns { } } +fun MockKStubScope, B>.delegateEmit() = object : Emits { + override fun emits(vararg values: T) { + answers(ConstantAnswer(flowOf(*values))) + } +} + + fun returns(block: (T) -> Unit) = object : Returns { override fun returns(value: T) = block(value) override fun throws(value: Throwable) = throw value } +interface Emits { + fun emits(vararg values: T) +} + interface Returns { fun returns(value: T) fun throws(value: Throwable) diff --git a/dependencies.gradle b/dependencies.gradle index 8b77960..8bb353c 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -10,6 +10,13 @@ ext.Dependencies.with { } } + repositories.maven { + url 'https://jitpack.io' + content { + includeGroup "com.github.UnifiedPush" + } + } + repositories.mavenCentral { content { includeGroupByRegex "org\\.jetbrains.*" @@ -88,45 +95,64 @@ ext.Dependencies.with { } } - def kotlinVer = "1.6.10" + def kotlinVer = "1.7.10" def sqldelightVer = "1.5.3" - def composeVer = "1.1.0" + def composeVer = "1.2.1" + def ktorVer = "2.1.0" google = new DependenciesContainer() google.with { - androidGradlePlugin = "com.android.tools.build:gradle:7.1.2" + androidGradlePlugin = "com.android.tools.build:gradle:7.2.1" androidxComposeUi = "androidx.compose.ui:ui:${composeVer}" androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}" androidxComposeMaterial = "androidx.compose.material:material:${composeVer}" androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}" androidxActivityCompose = "androidx.activity:activity-compose:1.4.0" - kotlinCompilerExtensionVersion = "1.1.0-rc02" + kotlinCompilerExtensionVersion = "1.3.0" + + firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.1" + jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5" } mavenCentral = new DependenciesContainer() mavenCentral.with { kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVer}" kotlinSerializationGradlePlugin = "org.jetbrains.kotlin:kotlin-serialization:${kotlinVer}" - kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0" - kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-RC2" + kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0" + kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" + kotlinCoroutinesTest = 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' kotlinTest = "org.jetbrains.kotlin:kotlin-test-junit:${kotlinVer}" sqldelightGradlePlugin = "com.squareup.sqldelight:gradle-plugin:${sqldelightVer}" sqldelightAndroid = "com.squareup.sqldelight:android-driver:${sqldelightVer}" sqldelightInMemory = "com.squareup.sqldelight:sqlite-driver:${sqldelightVer}" - ktorAndroid = "io.ktor:ktor-client-android:1.6.4" - ktorCore = "io.ktor:ktor-client-core:1.6.2" - ktorSerialization = "io.ktor:ktor-client-serialization:1.5.0" - ktorLogging = "io.ktor:ktor-client-logging-jvm:1.6.2" - ktorJava = "io.ktor:ktor-client-java:1.6.2" + leakCanary = 'com.squareup.leakcanary:leakcanary-android:2.9.1' + + ktorAndroid = "io.ktor:ktor-client-android:${ktorVer}" + ktorCore = "io.ktor:ktor-client-core:${ktorVer}" + ktorSerialization = "io.ktor:ktor-client-serialization:${ktorVer}" + ktorJson = "io.ktor:ktor-serialization-kotlinx-json:${ktorVer}" + ktorLogging = "io.ktor:ktor-client-logging-jvm:${ktorVer}" + ktorJava = "io.ktor:ktor-client-java:${ktorVer}" + ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}" + + coil = "io.coil-kt:coil-compose:2.2.0" + accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.25.1" junit = "junit:junit:4.13.2" kluent = "org.amshove.kluent:kluent:1.68" + mockk = 'io.mockk:mockk:1.12.7' - matrixOlm = "org.matrix.android:olm-sdk:3.2.10" + matrixOlm = "org.matrix.android:olm-sdk:3.2.12" } + + jitPack = new DependenciesContainer() + jitPack.with { + unifiedPush = "com.github.UnifiedPush:android-connector:2.0.1" + } + } class DependenciesContainer extends GroovyObjectSupport { diff --git a/design-library/build.gradle b/design-library/build.gradle index f15ea08..578435a 100644 --- a/design-library/build.gradle +++ b/design-library/build.gradle @@ -1,7 +1,7 @@ -applyAndroidLibraryModule(project) +applyAndroidComposeLibraryModule(project) dependencies { implementation project(":core") - implementation("io.coil-kt:coil-compose:1.4.0") - implementation "com.google.accompanist:accompanist-systemuicontroller:0.24.1-alpha" + implementation Dependencies.mavenCentral.coil + implementation Dependencies.mavenCentral.accompanistSystemuicontroller } \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Empty.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Empty.kt new file mode 100644 index 0000000..3b13536 --- /dev/null +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Empty.kt @@ -0,0 +1,18 @@ +package app.dapk.st.design.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun GenericEmpty() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Nothing to see here...") + } + } +} \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Error.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Error.kt new file mode 100644 index 0000000..3242945 --- /dev/null +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Error.kt @@ -0,0 +1,22 @@ +package app.dapk.st.design.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun GenericError(retryAction: () -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Something went wrong...") + Button(onClick = { retryAction() }) { + Text("Retry") + } + } + } +} \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Icon.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Icon.kt index 8db9a78..93f9529 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Icon.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Icon.kt @@ -10,12 +10,14 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.ExperimentalUnitApi import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType -import coil.compose.rememberImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest import coil.transform.CircleCropTransformation @OptIn(ExperimentalUnitApi::class) @@ -25,7 +27,8 @@ fun BoxScope.CircleishAvatar(avatarUrl: String?, fallbackLabel: String, size: Dp null -> { val colors = SmallTalkTheme.extendedColors.getMissingImageColor(fallbackLabel) Box( - Modifier.align(Alignment.Center) + Modifier + .align(Alignment.Center) .background(color = colors.first, shape = CircleShape) .size(size), contentAlignment = Alignment.Center @@ -40,14 +43,16 @@ fun BoxScope.CircleishAvatar(avatarUrl: String?, fallbackLabel: String, size: Dp } else -> { Image( - painter = rememberImagePainter( - data = avatarUrl, - builder = { - transformations(CircleCropTransformation()) - } + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(avatarUrl) + .transformations(CircleCropTransformation()) + .build() ), contentDescription = null, - modifier = Modifier.size(size).align(Alignment.Center) + modifier = Modifier + .size(size) + .align(Alignment.Center) ) } } @@ -56,7 +61,11 @@ fun BoxScope.CircleishAvatar(avatarUrl: String?, fallbackLabel: String, size: Dp @Composable fun MissingAvatarIcon(displayName: String, displayImageSize: Dp) { val colors = SmallTalkTheme.extendedColors.getMissingImageColor(displayName) - Box(Modifier.background(color = colors.first, shape = CircleShape).size(displayImageSize), contentAlignment = Alignment.Center) { + Box( + Modifier + .background(color = colors.first, shape = CircleShape) + .size(displayImageSize), contentAlignment = Alignment.Center + ) { Text( text = (displayName).first().toString().uppercase(), color = colors.second @@ -67,11 +76,11 @@ fun MissingAvatarIcon(displayName: String, displayImageSize: Dp) { @Composable fun MessengerUrlIcon(avatarUrl: String, displayImageSize: Dp) { Image( - painter = rememberImagePainter( - data = avatarUrl, - builder = { - transformations(CircleCropTransformation()) - } + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(avatarUrl) + .transformations(CircleCropTransformation()) + .build() ), contentDescription = null, modifier = Modifier.size(displayImageSize) diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt index 89753c5..826694b 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt @@ -27,14 +27,13 @@ fun Spider(currentPage: SpiderPage, onNavigate: (SpiderPage? } Column { - Toolbar( - onNavigate = navigateAndPopStack, - title = currentPage.label - ) - - currentPage.parent?.let { - BackHandler(onBack = navigateAndPopStack) + if (currentPage.hasToolbar) { + Toolbar( + onNavigate = navigateAndPopStack, + title = currentPage.label + ) } + BackHandler(onBack = navigateAndPopStack) computedWeb[currentPage.route]!!.invoke(currentPage.state) } } diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt index 60567c7..c79b436 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt @@ -1,27 +1,38 @@ package app.dapk.st.design.components -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @Composable -fun ColumnScope.Toolbar(onNavigate: () -> Unit, title: String? = null, actions: @Composable RowScope.() -> Unit = {}) { +fun Toolbar( + onNavigate: (() -> Unit)? = null, + title: String? = null, + offset: (Density.() -> IntOffset)? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + val navigationIcon = foo(onNavigate) + TopAppBar( - modifier = Modifier.height(72.dp), - backgroundColor = Color.Transparent, - navigationIcon = { - IconButton(onClick = { onNavigate() }) { - Icon(Icons.Default.ArrowBack, contentDescription = null) + modifier = Modifier.height(72.dp).run { + if (offset == null) { + this + } else { + this.offset(offset) } }, + backgroundColor = MaterialTheme.colors.background, + navigationIcon = navigationIcon, title = title?.let { { Text(it, maxLines = 2) } } ?: {}, @@ -29,4 +40,18 @@ fun ColumnScope.Toolbar(onNavigate: () -> Unit, title: String? = null, actions: elevation = 0.dp ) Divider(modifier = Modifier.fillMaxWidth(), color = Color.Black.copy(alpha = 0.2f), thickness = 0.5.dp) +} + + +private fun foo(onNavigate: (() -> Unit)?): (@Composable () -> Unit)? { + return onNavigate?.let { + { NavigationIcon(it) } + } +} + +@Composable +private fun NavigationIcon(onNavigate: () -> Unit) { + IconButton(onClick = { onNavigate.invoke() }) { + Icon(Icons.Default.ArrowBack, contentDescription = null) + } } \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt index d76316e..6fdc9fa 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable -fun TextRow(title: String, content: String? = null, includeDivider: Boolean = true, onClick: (() -> Unit)? = null) { +fun TextRow(title: String, content: String? = null, includeDivider: Boolean = true, onClick: (() -> Unit)? = null, body: @Composable () -> Unit = {}) { val modifier = Modifier.padding(horizontal = 24.dp) Column( Modifier @@ -31,6 +31,7 @@ fun TextRow(title: String, content: String? = null, includeDivider: Boolean = tr Text(text = content, fontSize = 18.sp) } } + body() Spacer(modifier = Modifier.height(24.dp)) } if (includeDivider) { diff --git a/domains/android/compose-core/build.gradle b/domains/android/compose-core/build.gradle new file mode 100644 index 0000000..c79d69b --- /dev/null +++ b/domains/android/compose-core/build.gradle @@ -0,0 +1,7 @@ +applyAndroidComposeLibraryModule(project) + +dependencies { + implementation project(":core") + implementation project(":features:navigator") + api project(":domains:android:core") +} diff --git a/domains/android/core/src/main/AndroidManifest.xml b/domains/android/compose-core/src/main/AndroidManifest.xml similarity index 100% rename from domains/android/core/src/main/AndroidManifest.xml rename to domains/android/compose-core/src/main/AndroidManifest.xml diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt similarity index 100% rename from domains/android/core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt rename to domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt similarity index 95% rename from domains/android/core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt rename to domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt index 4166d6b..a06b4b7 100644 --- a/domains/android/core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt +++ b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ComposeExtensions.kt @@ -41,6 +41,9 @@ fun LifecycleEffect(onStart: () -> Unit = {}, onStop: () -> Unit = {}) { when (event) { Lifecycle.Event.ON_START -> onStart() Lifecycle.Event.ON_STOP -> onStop() + else -> { + // ignored + } } } diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt similarity index 100% rename from domains/android/core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt rename to domains/android/compose-core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt similarity index 100% rename from domains/android/core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt rename to domains/android/compose-core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/components/Components.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/components/Components.kt similarity index 100% rename from domains/android/core/src/main/kotlin/app/dapk/st/core/components/Components.kt rename to domains/android/compose-core/src/main/kotlin/app/dapk/st/core/components/Components.kt diff --git a/domains/android/core/build.gradle b/domains/android/core/build.gradle index 896c7c4..6a11a06 100644 --- a/domains/android/core/build.gradle +++ b/domains/android/core/build.gradle @@ -1,6 +1,6 @@ -applyAndroidLibraryModule(project) +plugins { id 'kotlin' } dependencies { + compileOnly project(":domains:android:stub") implementation project(":core") - implementation project(":features:navigator") } diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt index 0d426b0..c36ac65 100644 --- a/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt @@ -2,5 +2,6 @@ package app.dapk.st.core import android.content.Context -inline fun Context.module() = - (this.applicationContext as ModuleProvider).provide(T::class) \ No newline at end of file +inline fun Context.module() = (this.applicationContext as ModuleProvider).provide(T::class) + +fun Context.resetModules() = (this.applicationContext as ModuleProvider).reset() \ No newline at end of file diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt new file mode 100644 index 0000000..3cc7e00 --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt @@ -0,0 +1,19 @@ +package app.dapk.st.core + +import android.os.Build + +fun DeviceMeta.isAtLeastO(block: () -> T, fallback: () -> T = { throw IllegalStateException("not handled") }): T { + return if (this.apiVersion >= Build.VERSION_CODES.O) block() else fallback() +} + +fun DeviceMeta.onAtLeastO(block: () -> Unit) { + if (this.apiVersion >= Build.VERSION_CODES.O) block() +} + +inline fun DeviceMeta.whenPOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.P, block, fallback) +inline fun DeviceMeta.whenOOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.O, block, fallback) + +inline fun DeviceMeta.whenXOrHigher(version: Int, block: () -> T, fallback: () -> T): T { + return if (this.apiVersion >= version) block() else fallback() +} + diff --git a/domains/android/imageloader/build.gradle b/domains/android/imageloader/build.gradle index 3ed97a3..45821be 100644 --- a/domains/android/imageloader/build.gradle +++ b/domains/android/imageloader/build.gradle @@ -2,5 +2,5 @@ applyAndroidLibraryModule(project) dependencies { implementation project(":core") - implementation "io.coil-kt:coil:1.4.0" + implementation Dependencies.mavenCentral.coil } diff --git a/domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoader.kt b/domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoader.kt index f84db5c..06cd47a 100644 --- a/domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoader.kt +++ b/domains/android/imageloader/src/main/kotlin/app/dapk/st/imageloader/ImageLoader.kt @@ -1,12 +1,13 @@ package app.dapk.st.imageloader import android.content.Context +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.Icon import android.widget.ImageView -import androidx.core.graphics.drawable.toBitmap import coil.imageLoader import coil.request.ImageRequest +import coil.request.ImageResult import coil.transform.CircleCropTransformation import coil.transform.Transformation import coil.load as coilLoad @@ -14,7 +15,6 @@ import coil.load as coilLoad interface ImageLoader { suspend fun load(url: String, transformation: Transformation? = null): Drawable? - } interface IconLoader { @@ -31,19 +31,24 @@ class CachedIcons(private val imageLoader: ImageLoader) : IconLoader { override suspend fun load(url: String): Icon? { return cache.getOrPut(url) { - imageLoader.load(url, transformation = circleCrop)?.toBitmap()?.let { + imageLoader.load(url, transformation = circleCrop)?.asBitmap()?.let { Icon.createWithBitmap(it) } } } } +private fun Drawable.asBitmap() = (this as? BitmapDrawable)?.bitmap internal class CoilImageLoader(private val context: Context) : ImageLoader { private val coil = context.imageLoader override suspend fun load(url: String, transformation: Transformation?): Drawable? { + return internalLoad(url, transformation).drawable + } + + private suspend fun internalLoad(url: String, transformation: Transformation?): ImageResult { val request = ImageRequest.Builder(context) .data(url) .let { @@ -53,7 +58,7 @@ internal class CoilImageLoader(private val context: Context) : ImageLoader { } } .build() - return coil.execute(request).drawable + return coil.execute(request) } } diff --git a/domains/android/push/build.gradle b/domains/android/push/build.gradle index 4f21b7a..dbcfe03 100644 --- a/domains/android/push/build.gradle +++ b/domains/android/push/build.gradle @@ -1,8 +1,13 @@ applyAndroidLibraryModule(project) +apply plugin: "org.jetbrains.kotlin.plugin.serialization" dependencies { implementation project(':core') + implementation project(':domains:android:core') + implementation project(':domains:store') implementation project(':matrix:services:push') implementation platform('com.google.firebase:firebase-bom:29.0.3') implementation 'com.google.firebase:firebase-messaging' + implementation Dependencies.mavenCentral.kotlinSerializationJson + implementation Dependencies.jitPack.unifiedPush } diff --git a/domains/android/push/src/main/AndroidManifest.xml b/domains/android/push/src/main/AndroidManifest.xml index a675a3a..61024b7 100644 --- a/domains/android/push/src/main/AndroidManifest.xml +++ b/domains/android/push/src/main/AndroidManifest.xml @@ -1,2 +1,24 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushHandler.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushHandler.kt new file mode 100644 index 0000000..5b7fa75 --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushHandler.kt @@ -0,0 +1,17 @@ +package app.dapk.st.push + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +interface PushHandler { + fun onNewToken(payload: PushTokenPayload) + fun onMessageReceived(eventId: EventId?, roomId: RoomId?) +} + +@Serializable +data class PushTokenPayload( + @SerialName("token") val token: String, + @SerialName("gateway_url") val gatewayUrl: String, +) \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt index 5978b95..5b923c7 100644 --- a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt @@ -1,16 +1,40 @@ package app.dapk.st.push +import android.content.Context +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.ProvidableModule import app.dapk.st.core.extensions.ErrorTracker -import app.dapk.st.matrix.push.PushService +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.domain.Preferences +import app.dapk.st.domain.push.PushTokenRegistrarPreferences +import app.dapk.st.push.firebase.FirebasePushTokenRegistrar +import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar class PushModule( - private val pushService: PushService, private val errorTracker: ErrorTracker, -) { + private val pushHandler: PushHandler, + private val context: Context, + private val dispatchers: CoroutineDispatchers, + private val preferences: Preferences, +) : ProvidableModule { - fun registerFirebasePushTokenUseCase() = RegisterFirebasePushTokenUseCase( - pushService, - errorTracker, - ) + private val registrars by unsafeLazy { + PushTokenRegistrars( + context, + FirebasePushTokenRegistrar( + errorTracker, + context, + pushHandler, + ), + UnifiedPushRegistrar(context), + PushTokenRegistrarPreferences(preferences) + ) + } -} \ No newline at end of file + fun pushTokenRegistrars() = registrars + + fun pushTokenRegistrar(): PushTokenRegistrar = pushTokenRegistrars() + fun pushHandler() = pushHandler + fun dispatcher() = dispatchers + +} diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrar.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrar.kt new file mode 100644 index 0000000..6cd6a1e --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrar.kt @@ -0,0 +1,6 @@ +package app.dapk.st.push + +interface PushTokenRegistrar { + suspend fun registerCurrentToken() + fun unregister() +} diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrars.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrars.kt new file mode 100644 index 0000000..7ce0658 --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrars.kt @@ -0,0 +1,76 @@ +package app.dapk.st.push + +import android.content.Context +import app.dapk.st.domain.push.PushTokenRegistrarPreferences +import app.dapk.st.push.firebase.FirebasePushTokenRegistrar +import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar +import org.unifiedpush.android.connector.UnifiedPush + +private val FIREBASE_OPTION = Registrar("Google - Firebase (FCM)") +private val NONE = Registrar("None") + +class PushTokenRegistrars( + private val context: Context, + private val firebasePushTokenRegistrar: FirebasePushTokenRegistrar, + private val unifiedPushRegistrar: UnifiedPushRegistrar, + private val pushTokenStore: PushTokenRegistrarPreferences, +) : PushTokenRegistrar { + + private var selection: Registrar? = null + + fun options(): List { + return listOf(NONE, FIREBASE_OPTION) + UnifiedPush.getDistributors(context).map { Registrar(it) } + } + + suspend fun currentSelection() = selection ?: (pushTokenStore.currentSelection()?.let { Registrar(it) } ?: FIREBASE_OPTION).also { selection = it } + + suspend fun makeSelection(option: Registrar) { + selection = option + pushTokenStore.store(option.id) + when (option) { + NONE -> { + firebasePushTokenRegistrar.unregister() + unifiedPushRegistrar.unregister() + } + + FIREBASE_OPTION -> { + unifiedPushRegistrar.unregister() + firebasePushTokenRegistrar.registerCurrentToken() + } + + else -> { + firebasePushTokenRegistrar.unregister() + unifiedPushRegistrar.registerSelection(option) + } + } + } + + override suspend fun registerCurrentToken() { + when (selection) { + FIREBASE_OPTION -> firebasePushTokenRegistrar.registerCurrentToken() + NONE -> { + // do nothing + } + + else -> unifiedPushRegistrar.registerCurrentToken() + } + } + + override fun unregister() { + when (selection) { + FIREBASE_OPTION -> firebasePushTokenRegistrar.unregister() + NONE -> { + runCatching { + firebasePushTokenRegistrar.unregister() + unifiedPushRegistrar.unregister() + } + } + + else -> unifiedPushRegistrar.unregister() + } + } + +} + +@JvmInline +value class Registrar(val id: String) \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/RegisterFirebasePushTokenUseCase.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/RegisterFirebasePushTokenUseCase.kt deleted file mode 100644 index 4ef213b..0000000 --- a/domains/android/push/src/main/kotlin/app/dapk/st/push/RegisterFirebasePushTokenUseCase.kt +++ /dev/null @@ -1,27 +0,0 @@ -package app.dapk.st.push - -import app.dapk.st.core.AppLogTag -import app.dapk.st.core.extensions.CrashScope -import app.dapk.st.core.extensions.ErrorTracker -import app.dapk.st.core.log -import app.dapk.st.matrix.push.PushService -import com.google.firebase.messaging.FirebaseMessaging - -class RegisterFirebasePushTokenUseCase( - private val pushService: PushService, - override val errorTracker: ErrorTracker, -) : CrashScope { - - suspend fun registerCurrentToken() { - kotlin.runCatching { - FirebaseMessaging.getInstance().token().also { - pushService.registerPush(it) - } - } - .trackFailure() - .onSuccess { - log(AppLogTag.PUSH, "registered new push token") - } - } - -} \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/FirebaseMessagingExtensions.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebaseMessagingExtensions.kt similarity index 86% rename from domains/android/push/src/main/kotlin/app/dapk/st/push/FirebaseMessagingExtensions.kt rename to domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebaseMessagingExtensions.kt index 78a8033..9ff0ac7 100644 --- a/domains/android/push/src/main/kotlin/app/dapk/st/push/FirebaseMessagingExtensions.kt +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebaseMessagingExtensions.kt @@ -1,4 +1,4 @@ -package app.dapk.st.push +package app.dapk.st.push.firebase import com.google.firebase.messaging.FirebaseMessaging import kotlin.coroutines.resume diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushService.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushService.kt new file mode 100644 index 0000000..e1d79db --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushService.kt @@ -0,0 +1,36 @@ +package app.dapk.st.push.firebase + +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.core.log +import app.dapk.st.core.module +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.push.PushModule +import app.dapk.st.push.PushTokenPayload +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage + +private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify" + +class FirebasePushService : FirebaseMessagingService() { + + private val handler by unsafeLazy { module().pushHandler() } + + override fun onNewToken(token: String) { + log(AppLogTag.PUSH, "FCM onNewToken") + handler.onNewToken( + PushTokenPayload( + token = token, + gatewayUrl = SYGNAL_GATEWAY, + ) + ) + } + + override fun onMessageReceived(message: RemoteMessage) { + log(AppLogTag.PUSH, "FCM onMessage") + val eventId = message.data["event_id"]?.let { EventId(it) } + val roomId = message.data["room_id"]?.let { RoomId(it) } + handler.onMessageReceived(eventId, roomId) + } +} diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushTokenRegistrar.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushTokenRegistrar.kt new file mode 100644 index 0000000..2c9321a --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushTokenRegistrar.kt @@ -0,0 +1,61 @@ +package app.dapk.st.push.firebase + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.extensions.CrashScope +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.log +import app.dapk.st.push.PushHandler +import app.dapk.st.push.PushTokenPayload +import app.dapk.st.push.PushTokenRegistrar +import app.dapk.st.push.unifiedpush.UnifiedPushMessageReceiver +import com.google.firebase.messaging.FirebaseMessaging + +private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify" + +class FirebasePushTokenRegistrar( + override val errorTracker: ErrorTracker, + private val context: Context, + private val pushHandler: PushHandler, +) : PushTokenRegistrar, CrashScope { + + override suspend fun registerCurrentToken() { + log(AppLogTag.PUSH, "FCM - register current token") + context.packageManager.setComponentEnabledSetting( + ComponentName(context, FirebasePushService::class.java), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP, + ) + + kotlin.runCatching { + FirebaseMessaging.getInstance().token().also { + pushHandler.onNewToken( + PushTokenPayload( + token = it, + gatewayUrl = SYGNAL_GATEWAY, + ) + ) + } + } + .trackFailure() + .onSuccess { + log(AppLogTag.PUSH, "registered new push token") + } + } + + override fun unregister() { + log(AppLogTag.PUSH, "FCM - unregister") + FirebaseMessaging.getInstance().deleteToken() + context.stopService(Intent(context, FirebasePushService::class.java)) + + context.packageManager.setComponentEnabledSetting( + ComponentName(context, FirebasePushService::class.java), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP, + ) + } + +} \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageReceiver.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageReceiver.kt new file mode 100644 index 0000000..011b0dc --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageReceiver.kt @@ -0,0 +1,77 @@ +package app.dapk.st.push.unifiedpush + +import android.content.Context +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.log +import app.dapk.st.core.module +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.push.PushModule +import app.dapk.st.push.PushTokenPayload +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.unifiedpush.android.connector.MessagingReceiver +import java.net.URL + +private val json = Json { ignoreUnknownKeys = true } + +private const val FALLBACK_UNIFIED_PUSH_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify" + +class UnifiedPushMessageReceiver : MessagingReceiver() { + + private val scope = CoroutineScope(SupervisorJob()) + + override fun onMessage(context: Context, message: ByteArray, instance: String) { + log(AppLogTag.PUSH, "UnifiedPush onMessage, $message") + val module = context.module() + val handler = module.pushHandler() + scope.launch { + withContext(module.dispatcher().io) { + val payload = json.decodeFromString(UnifiedPushMessagePayload.serializer(), String(message)) + handler.onMessageReceived(payload.notification.eventId?.let { EventId(it) }, payload.notification.roomId?.let { RoomId(it) }) + } + } + } + + override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + log(AppLogTag.PUSH, "UnifiedPush onNewEndpoint $endpoint") + val module = context.module() + val handler = module.pushHandler() + scope.launch { + withContext(module.dispatcher().io) { + val matrixEndpoint = URL(endpoint).let { URL("${it.protocol}://${it.host}/_matrix/push/v1/notify") } + val content = runCatching { matrixEndpoint.openStream().use { String(it.readBytes()) } }.getOrNull() ?: "" + val gatewayUrl = when { + content.contains("\"gateway\":\"matrix\"") -> matrixEndpoint.toString() + else -> FALLBACK_UNIFIED_PUSH_GATEWAY + } + handler.onNewToken(PushTokenPayload(token = endpoint, gatewayUrl = gatewayUrl)) + } + } + } + + override fun onRegistrationFailed(context: Context, instance: String) { + log(AppLogTag.PUSH, "UnifiedPush onRegistrationFailed") + } + + override fun onUnregistered(context: Context, instance: String) { + log(AppLogTag.PUSH, "UnifiedPush onUnregistered") + } + + @Serializable + private data class UnifiedPushMessagePayload( + @SerialName("notification") val notification: Notification, + ) { + + @Serializable + data class Notification( + @SerialName("event_id") val eventId: String?, + @SerialName("room_id") val roomId: String?, + ) + } +} \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushRegistrar.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushRegistrar.kt new file mode 100644 index 0000000..cbb5ad5 --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushRegistrar.kt @@ -0,0 +1,47 @@ +package app.dapk.st.push.unifiedpush + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.log +import app.dapk.st.push.PushTokenRegistrar +import app.dapk.st.push.Registrar +import org.unifiedpush.android.connector.UnifiedPush + +class UnifiedPushRegistrar( + private val context: Context, +) : PushTokenRegistrar { + + fun registerSelection(registrar: Registrar) { + log(AppLogTag.PUSH, "UnifiedPush - register: $registrar") + UnifiedPush.saveDistributor(context, registrar.id) + registerApp() + } + + override suspend fun registerCurrentToken() { + log(AppLogTag.PUSH, "UnifiedPush - register current token") + if (UnifiedPush.getDistributor(context).isNotEmpty()) { + registerApp() + } + } + + private fun registerApp() { + context.packageManager.setComponentEnabledSetting( + ComponentName(context, UnifiedPushMessageReceiver::class.java), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP, + ) + UnifiedPush.registerApp(context) + } + + override fun unregister() { + UnifiedPush.unregisterApp(context) + context.packageManager.setComponentEnabledSetting( + ComponentName(context, UnifiedPushMessageReceiver::class.java), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP, + ) + } + +} diff --git a/domains/android/stub/build.gradle b/domains/android/stub/build.gradle index 8ee9c3d..4a7ad4a 100644 --- a/domains/android/stub/build.gradle +++ b/domains/android/stub/build.gradle @@ -13,6 +13,8 @@ if (localProperties.exists()) { dependencies { def androidVer = androidSdkVersion + api files("${properties.getProperty("sdk.dir")}/platforms/android-${androidVer}/android.jar") + kotlinFixtures(it) testFixturesImplementation testFixtures(project(":core")) testFixturesImplementation files("${properties.getProperty("sdk.dir")}/platforms/android-${androidVer}/android.jar") diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt new file mode 100644 index 0000000..623c98c --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt @@ -0,0 +1,8 @@ +package fake + +import android.content.Context +import io.mockk.mockk + +class FakeContext { + val instance = mockk() +} \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeInboxStyle.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeInboxStyle.kt new file mode 100644 index 0000000..910cfa7 --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeInboxStyle.kt @@ -0,0 +1,22 @@ +package fake + +import android.app.Notification +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot + +class FakeInboxStyle { + private val _summary = slot() + + val instance = mockk() + val lines = mutableListOf() + val summary: String + get() = _summary.captured + + fun captureInteractions() { + every { instance.addLine(capture(lines)) } returns instance + every { instance.setSummaryText(capture(_summary)) } returns instance + } + + +} \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeMessagingStyle.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeMessagingStyle.kt new file mode 100644 index 0000000..d76b96a --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeMessagingStyle.kt @@ -0,0 +1,14 @@ +package fake + +import android.app.Notification +import android.app.Person +import io.mockk.every +import io.mockk.mockk + +class FakeMessagingStyle { + var user: Person? = null + val instance = mockk() + +} + +fun aFakeMessagingStyle() = FakeMessagingStyle().instance \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotification.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotification.kt new file mode 100644 index 0000000..a07820a --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotification.kt @@ -0,0 +1,12 @@ +package fake + +import android.app.Notification +import io.mockk.mockk + +class FakeNotification { + + val instance = mockk() + +} + +fun aFakeNotification() = FakeNotification().instance \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt new file mode 100644 index 0000000..67aa3a0 --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt @@ -0,0 +1,8 @@ +package fake + +import android.app.Notification +import io.mockk.mockk + +class FakeNotificationBuilder { + val instance = mockk(relaxed = true) +} \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationManager.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationManager.kt new file mode 100644 index 0000000..b8573e3 --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationManager.kt @@ -0,0 +1,14 @@ +package fake + +import android.app.NotificationManager +import io.mockk.mockk +import io.mockk.verify + +class FakeNotificationManager { + + val instance = mockk() + + fun verifyCancelled(tag: String, id: Int) { + verify { instance.cancel(tag, id) } + } +} \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakePersonBuilder.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakePersonBuilder.kt new file mode 100644 index 0000000..e254d7d --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakePersonBuilder.kt @@ -0,0 +1,8 @@ +package fake + +import android.app.Person +import io.mockk.mockk + +class FakePersonBuilder { + val instance = mockk(relaxed = true) +} \ No newline at end of file diff --git a/domains/android/viewmodel-stub/src/main/kotlin/androidx/compose/runtime/MutableState.kt b/domains/android/viewmodel-stub/src/main/kotlin/androidx/compose/runtime/MutableState.kt index 543df16..c1bfc7a 100644 --- a/domains/android/viewmodel-stub/src/main/kotlin/androidx/compose/runtime/MutableState.kt +++ b/domains/android/viewmodel-stub/src/main/kotlin/androidx/compose/runtime/MutableState.kt @@ -1,5 +1,5 @@ @file:JvmName("SnapshotStateKt") - +@file:Suppress("UNUSED") package androidx.compose.runtime import kotlin.reflect.KProperty diff --git a/domains/android/viewmodel/build.gradle b/domains/android/viewmodel/build.gradle index dac308a..c3cce48 100644 --- a/domains/android/viewmodel/build.gradle +++ b/domains/android/viewmodel/build.gradle @@ -9,7 +9,7 @@ dependencies { kotlinFixtures(it) testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore - testFixturesImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0' + testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest testFixturesImplementation testFixtures(project(":core")) testFixturesCompileOnly project(":domains:android:viewmodel-stub") } \ No newline at end of file diff --git a/domains/android/viewmodel/src/testFixtures/kotlin/ViewModelTest.kt b/domains/android/viewmodel/src/testFixtures/kotlin/ViewModelTest.kt index 9cdadde..018c6cd 100644 --- a/domains/android/viewmodel/src/testFixtures/kotlin/ViewModelTest.kt +++ b/domains/android/viewmodel/src/testFixtures/kotlin/ViewModelTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import test.ExpectTest +@Suppress("UNCHECKED_CAST") class ViewModelTest { var instance: TestMutableState? = null diff --git a/domains/android/work/src/main/kotlin/app/dapk/st/work/TaskRunner.kt b/domains/android/work/src/main/kotlin/app/dapk/st/work/TaskRunner.kt index a1dff08..e60c0ac 100644 --- a/domains/android/work/src/main/kotlin/app/dapk/st/work/TaskRunner.kt +++ b/domains/android/work/src/main/kotlin/app/dapk/st/work/TaskRunner.kt @@ -8,15 +8,15 @@ interface TaskRunner { suspend fun run(tasks: List): List data class RunnableWorkTask( - val source: JobWorkItem, + val source: JobWorkItem?, val task: WorkTask ) sealed interface TaskResult { - val source: JobWorkItem + val source: JobWorkItem? - data class Success(override val source: JobWorkItem) : TaskResult - data class Failure(override val source: JobWorkItem, val canRetry: Boolean) : TaskResult + data class Success(override val source: JobWorkItem?) : TaskResult + data class Failure(override val source: JobWorkItem?, val canRetry: Boolean) : TaskResult } } \ No newline at end of file diff --git a/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkAndroidService.kt b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkAndroidService.kt index e712235..edb4b38 100644 --- a/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkAndroidService.kt +++ b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkAndroidService.kt @@ -3,6 +3,7 @@ package app.dapk.st.work import android.app.job.JobParameters import android.app.job.JobService import android.app.job.JobWorkItem +import android.os.Build import app.dapk.st.core.extensions.Scope import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.core.module @@ -24,11 +25,15 @@ class WorkAndroidService : JobService() { when (it) { is TaskRunner.TaskResult.Failure -> { if (!it.canRetry) { - params.completeWork(it.source) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + params.completeWork(it.source!!) + } } } is TaskRunner.TaskResult.Success -> { - params.completeWork(it.source) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + params.completeWork(it.source!!) + } } } } @@ -40,24 +45,37 @@ class WorkAndroidService : JobService() { } private fun JobParameters.collectAllTasks(): List { - var work: JobWorkItem? - val tasks = mutableListOf() - do { - work = this.dequeueWork() - work?.intent?.also { intent -> - tasks.add( - RunnableWorkTask( - source = work, - task = WorkTask( - jobId = this.jobId, - type = intent.getStringExtra("task-type")!!, - jsonPayload = intent.getStringExtra("task-payload")!!, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + var work: JobWorkItem? + val tasks = mutableListOf() + do { + work = this.dequeueWork() + work?.intent?.also { intent -> + tasks.add( + RunnableWorkTask( + source = work, + task = WorkTask( + jobId = this.jobId, + type = intent.getStringExtra("task-type")!!, + jsonPayload = intent.getStringExtra("task-payload")!!, + ) ) ) + } + } while (work != null) + return tasks + } else { + return listOf( + RunnableWorkTask( + source = null, + task = WorkTask( + jobId = this.jobId, + type = this.extras.getString("task-type")!!, + jsonPayload = this.extras.getString("task-payload")!!, + ) ) - } - } while (work != null) - return tasks + ) + } } override fun onStopJob(params: JobParameters): Boolean { diff --git a/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkSchedulingJobScheduler.kt b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkSchedulingJobScheduler.kt index b4a70ef..ce93d61 100644 --- a/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkSchedulingJobScheduler.kt +++ b/domains/android/work/src/main/kotlin/app/dapk/st/work/WorkSchedulingJobScheduler.kt @@ -6,6 +6,7 @@ import android.app.job.JobWorkItem import android.content.ComponentName import android.content.Context import android.content.Intent +import android.os.Build internal class WorkSchedulingJobScheduler( private val context: Context, @@ -23,12 +24,17 @@ internal class WorkSchedulingJobScheduler( .setRequiresDeviceIdle(false) .build() - val item = JobWorkItem( - Intent() - .putExtra("task-type", task.type) - .putExtra("task-payload", task.jsonPayload) - ) - - jobScheduler.enqueue(job, item) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val item = JobWorkItem( + Intent() + .putExtra("task-type", task.type) + .putExtra("task-payload", task.jsonPayload) + ) + jobScheduler.enqueue(job, item) + } else { + job.extras.putString("task-type", task.type) + job.extras.putString("task-payload", task.jsonPayload) + jobScheduler.schedule(job) + } } } diff --git a/domains/olm-stub/build.gradle b/domains/olm-stub/build.gradle index 5717824..b35101f 100644 --- a/domains/olm-stub/build.gradle +++ b/domains/olm-stub/build.gradle @@ -3,5 +3,5 @@ plugins { } dependencies { - compileOnly 'org.json:json:20211205' + compileOnly 'org.json:json:20220320' } \ No newline at end of file diff --git a/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmPersistenceWrapper.kt b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmPersistenceWrapper.kt index 36869c1..c924186 100644 --- a/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmPersistenceWrapper.kt +++ b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmPersistenceWrapper.kt @@ -1,5 +1,6 @@ package app.dapk.st.olm +import app.dapk.st.core.Base64 import app.dapk.st.domain.OlmPersistence import app.dapk.st.domain.SerializedObject import app.dapk.st.matrix.common.Curve25519 @@ -10,10 +11,10 @@ import org.matrix.olm.OlmInboundGroupSession import org.matrix.olm.OlmOutboundGroupSession import org.matrix.olm.OlmSession import java.io.* -import java.util.* class OlmPersistenceWrapper( private val olmPersistence: OlmPersistence, + private val base64: Base64, ) : OlmStore { override suspend fun read(): OlmAccount? { @@ -49,21 +50,21 @@ class OlmPersistenceWrapper( override suspend fun readInbound(sessionId: SessionId): OlmInboundGroupSession? { return olmPersistence.readInbound(sessionId)?.value?.deserialize() } -} -private fun T.serialize(): String { - val baos = ByteArrayOutputStream() - ObjectOutputStream(baos).use { - it.writeObject(this) + private fun T.serialize(): String { + val baos = ByteArrayOutputStream() + ObjectOutputStream(baos).use { + it.writeObject(this) + } + return base64.encode(baos.toByteArray()) } - return Base64.getEncoder().encode(baos.toByteArray()).toString(Charsets.UTF_8) -} -@Suppress("UNCHECKED_CAST") -private fun String.deserialize(): T { - val decoded = Base64.getDecoder().decode(this) - val baos = ByteArrayInputStream(decoded) - return ObjectInputStream(baos).use { - it.readObject() as T + @Suppress("UNCHECKED_CAST") + private fun String.deserialize(): T { + val decoded = base64.decode(this) + val baos = ByteArrayInputStream(decoded) + return ObjectInputStream(baos).use { + it.readObject() as T + } } } diff --git a/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt index ceee2ba..6a5531a 100644 --- a/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt +++ b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt @@ -254,7 +254,7 @@ class OlmWrapper( return readSession.firstNotNullOfOrNull { (_, session) -> kotlin.runCatching { - when (type.toInt()) { + when (type) { OlmMessage.MESSAGE_TYPE_PRE_KEY -> { if (session.matchesInboundSession(body.value)) { logger.matrixLog(CRYPTO, "matched inbound session, attempting decrypt") @@ -270,6 +270,8 @@ class OlmWrapper( session.decryptMessage(olmMessage)?.let { JsonString(it) }?.also { logger.crypto("alt flow identity: $senderKey : ${session.sessionIdentifier()}") olmStore.persistSession(senderKey, SessionId(session.sessionIdentifier()), session) + }.also { + session.releaseSession() } } } @@ -287,6 +289,8 @@ class OlmWrapper( }.ifNull { logger.matrixLog(CRYPTO, "failed to decrypt olm session") DecryptionResult.Failed(errors.joinToString { it.message ?: "N/A" }) + }.also { + readSession.forEach { it.second.releaseSession() } } } @@ -310,7 +314,9 @@ class OlmWrapper( errorTracker.track(it) DecryptionResult.Failed(it.message ?: "Unknown") } - ) + ).also { + megolmSession.releaseSession() + } } } } diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/ApplicationPreferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/ApplicationPreferences.kt new file mode 100644 index 0000000..5570a73 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/ApplicationPreferences.kt @@ -0,0 +1,19 @@ +package app.dapk.st.domain + +class ApplicationPreferences( + private val preferences: Preferences, +) { + + suspend fun readVersion(): ApplicationVersion? { + return preferences.readString("version")?.let { ApplicationVersion(it.toInt()) } + } + + suspend fun setVersion(version: ApplicationVersion) { + return preferences.store("version", version.value.toString()) + } + +} + +@JvmInline +value class ApplicationVersion(val value: Int) + diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/MemberPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/MemberPersistence.kt index 101b39f..4b3b20e 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/MemberPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/MemberPersistence.kt @@ -35,4 +35,12 @@ class MemberPersistence( .map { Json.decodeFromString(RoomMember.serializer(), it) } } } + + override suspend fun query(roomId: RoomId, limit: Int): List { + return coroutineDispatchers.withIoContext { + database.roomMemberQueries.selectMembersByRoom(roomId.value, limit.toLong()) + .executeAsList() + .map { Json.decodeFromString(RoomMember.serializer(), it) } + } + } } \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt index c478116..15cbaf1 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt @@ -7,6 +7,7 @@ import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.domain.eventlog.EventLogPersistence import app.dapk.st.domain.localecho.LocalEchoPersistence import app.dapk.st.domain.profile.ProfilePersistence +import app.dapk.st.domain.push.PushTokenRegistrarPreferences import app.dapk.st.domain.sync.OverviewPersistence import app.dapk.st.domain.sync.RoomPersistence import app.dapk.st.matrix.common.CredentialsStore @@ -34,6 +35,10 @@ class StoreModule( fun filterStore(): FilterStore = FilterPreferences(preferences) val localEchoStore: LocalEchoStore by unsafeLazy { LocalEchoPersistence(errorTracker, database) } + fun pushStore() = PushTokenRegistrarPreferences(preferences) + + fun applicationStore() = ApplicationPreferences(preferences) + fun olmStore() = OlmPersistence(database, credentialsStore()) fun knownDevicesStore() = DevicePersistence(database, KnownDevicesCache(), coroutineDispatchers) diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/SyncTokenPreferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/SyncTokenPreferences.kt index 2be221e..b4d5346 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/SyncTokenPreferences.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/SyncTokenPreferences.kt @@ -1,5 +1,7 @@ package app.dapk.st.domain +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.log import app.dapk.st.matrix.common.SyncToken import app.dapk.st.matrix.sync.SyncStore import app.dapk.st.matrix.sync.SyncStore.SyncKey diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/localecho/LocalEchoPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/localecho/LocalEchoPersistence.kt index bb28e31..3ae9a1b 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/localecho/LocalEchoPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/localecho/LocalEchoPersistence.kt @@ -33,11 +33,13 @@ class LocalEchoPersistence( inMemoryEchos.value = echos.groupBy { when (val message = it.message) { is MessageService.Message.TextMessage -> message.roomId + is MessageService.Message.ImageMessage -> message.roomId } }.mapValues { it.value.associateBy { when (val message = it.message) { is MessageService.Message.TextMessage -> message.localId + is MessageService.Message.ImageMessage -> message.localId } } } @@ -56,6 +58,7 @@ class LocalEchoPersistence( database.transaction { when (message) { is MessageService.Message.TextMessage -> database.localEchoQueries.delete(message.localId) + is MessageService.Message.ImageMessage -> database.localEchoQueries.delete(message.localId) } } } catch (error: Exception) { @@ -84,6 +87,14 @@ class LocalEchoPersistence( Json.encodeToString(MessageService.LocalEcho.serializer(), localEcho) ) ) + + is MessageService.Message.ImageMessage -> database.localEchoQueries.insert( + DbLocalEcho( + message.localId, + message.roomId.value, + Json.encodeToString(MessageService.LocalEcho.serializer(), localEcho) + ) + ) } } } diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/push/PushTokenRegistrarPreferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/push/PushTokenRegistrarPreferences.kt new file mode 100644 index 0000000..38110ef --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/push/PushTokenRegistrarPreferences.kt @@ -0,0 +1,16 @@ +package app.dapk.st.domain.push + +import app.dapk.st.domain.Preferences + +private const val SELECTION_KEY = "push_token_selection" + +class PushTokenRegistrarPreferences( + private val preferences: Preferences, +) { + + suspend fun currentSelection() = preferences.readString(SELECTION_KEY) + + suspend fun store(registrar: String) { + preferences.store(SELECTION_KEY, registrar) + } +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt index 7c6a9c6..b9bb5f8 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt @@ -11,10 +11,8 @@ import app.dapk.st.matrix.sync.RoomInvite import app.dapk.st.matrix.sync.RoomOverview import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.mapToList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json private val json = Json @@ -31,11 +29,22 @@ internal class OverviewPersistence( .map { it.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } } } + override suspend fun removeRooms(roomsToRemove: List) { + dispatchers.withIoContext { + database.transaction { + roomsToRemove.forEach { + database.inviteStateQueries.remove(it.value) + database.overviewStateQueries.remove(it.value) + } + } + } + } + override suspend fun persistInvites(invites: List) { dispatchers.withIoContext { database.inviteStateQueries.transaction { invites.forEach { - database.inviteStateQueries.insert(it.roomId.value) + database.inviteStateQueries.insert(it.roomId.value, json.encodeToString(RoomInvite.serializer(), it)) } } } @@ -45,7 +54,15 @@ internal class OverviewPersistence( return database.inviteStateQueries.selectAll() .asFlow() .mapToList() - .map { it.map { RoomInvite(RoomId(it)) } } + .map { it.map { json.decodeFromString(RoomInvite.serializer(), it.blob) } } + } + + override suspend fun removeInvites(invites: List) { + dispatchers.withIoContext { + database.inviteStateQueries.transaction { + invites.forEach { database.inviteStateQueries.remove(it.value) } + } + } } override suspend fun persist(overviewState: OverviewState) { @@ -59,7 +76,7 @@ internal class OverviewPersistence( } override suspend fun retrieve(): OverviewState { - return withContext(Dispatchers.IO) { + return dispatchers.withIoContext { val overviews = database.overviewStateQueries.selectAll().executeAsList() overviews.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } } diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt index 0ed472b..451effd 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt @@ -37,6 +37,13 @@ internal class RoomPersistence( } } + override suspend fun remove(rooms: List) { + coroutineDispatchers + database.roomEventQueries.transaction { + rooms.forEach { database.roomEventQueries.remove(it.value) } + } + } + override fun latest(roomId: RoomId): Flow { val overviewFlow = database.overviewStateQueries.selectRoom(roomId.value).asFlow().mapToOneNotNull().map { json.decodeFromString(RoomOverview.serializer(), it) @@ -75,7 +82,7 @@ internal class RoomPersistence( } } - override suspend fun observeUnread(): Flow>> { + override fun observeUnread(): Flow>> { return database.roomEventQueries.selectAllUnread() .asFlow() .mapToList() @@ -91,7 +98,7 @@ internal class RoomPersistence( } } - override suspend fun observeUnreadCountById(): Flow> { + override fun observeUnreadCountById(): Flow> { return database.roomEventQueries.selectAllUnread() .asFlow() .mapToList() @@ -107,7 +114,7 @@ internal class RoomPersistence( } } - override suspend fun observeEvent(eventId: EventId): Flow { + override fun observeEvent(eventId: EventId): Flow { return database.roomEventQueries.selectEvent(event_id = eventId.value) .asFlow() .mapToOneNotNull() diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq index d29ad00..d30ddbe 100644 --- a/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq @@ -1,12 +1,17 @@ CREATE TABLE dbInviteState ( room_id TEXT NOT NULL, + blob TEXT NOT NULL, PRIMARY KEY (room_id) ); selectAll: -SELECT room_id +SELECT room_id, blob FROM dbInviteState; insert: -INSERT OR REPLACE INTO dbInviteState(room_id) -VALUES (?); \ No newline at end of file +INSERT OR REPLACE INTO dbInviteState(room_id, blob) +VALUES (?, ?); + +remove: +DELETE FROM dbInviteState +WHERE room_id = ?; \ No newline at end of file diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq index afc9da1..100d397 100644 --- a/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq @@ -18,4 +18,8 @@ WHERE room_id = ?; insert: INSERT OR REPLACE INTO dbOverviewState(room_id, latest_activity_timestamp_utc, read_marker, blob) -VALUES (?, ?, ?, ?); \ No newline at end of file +VALUES (?, ?, ?, ?); + +remove: +DELETE FROM dbOverviewState +WHERE room_id = ?; \ No newline at end of file diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq index ce5a04b..46883cf 100644 --- a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq @@ -30,4 +30,10 @@ WHERE event_id = ?; selectAllUnread: SELECT dbRoomEvent.blob, dbRoomEvent.room_id FROM dbUnreadEvent -INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id; +INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id +ORDER BY dbRoomEvent.timestamp_utc DESC +LIMIT 100; + +remove: +DELETE FROM dbRoomEvent +WHERE room_id = ?; \ No newline at end of file diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq index eff8ab1..081bccd 100644 --- a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq @@ -10,6 +10,13 @@ SELECT blob FROM dbRoomMember WHERE room_id = ? AND user_id IN ?; +selectMembersByRoom: +SELECT blob +FROM dbRoomMember +WHERE room_id = ? +LIMIT ?; + + insert: INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob) VALUES (?, ?, ?); \ No newline at end of file diff --git a/features/directory/build.gradle b/features/directory/build.gradle index 1eb3d4b..b005881 100644 --- a/features/directory/build.gradle +++ b/features/directory/build.gradle @@ -1,13 +1,23 @@ -applyAndroidLibraryModule(project) +applyAndroidComposeLibraryModule(project) dependencies { implementation project(":matrix:services:sync") implementation project(":matrix:services:message") implementation project(":matrix:services:room") - implementation project(":domains:android:core") + implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") implementation project(":features:messenger") implementation project(":core") implementation project(":design-library") - implementation("io.coil-kt:coil-compose:1.4.0") + implementation Dependencies.mavenCentral.coil + + kotlinTest(it) + + androidImportFixturesWorkaround(project, project(":matrix:services:sync")) + androidImportFixturesWorkaround(project, project(":matrix:services:message")) + androidImportFixturesWorkaround(project, project(":matrix:common")) + androidImportFixturesWorkaround(project, project(":core")) + androidImportFixturesWorkaround(project, project(":domains:store")) + androidImportFixturesWorkaround(project, project(":domains:android:viewmodel")) + androidImportFixturesWorkaround(project, project(":domains:android:stub")) } \ No newline at end of file diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt index af0cf3a..5042a4e 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt @@ -10,29 +10,34 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Button import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.dapk.st.core.LifecycleEffect import app.dapk.st.core.StartObserving import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.design.components.CircleishAvatar +import app.dapk.st.design.components.GenericEmpty +import app.dapk.st.design.components.GenericError +import app.dapk.st.design.components.Toolbar import app.dapk.st.directory.DirectoryEvent.OpenDownloadUrl import app.dapk.st.directory.DirectoryScreenState.Content import app.dapk.st.directory.DirectoryScreenState.EmptyLoading -import app.dapk.st.design.components.CircleishAvatar import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.SyncService @@ -44,36 +49,56 @@ import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit +import kotlin.math.roundToInt @Composable fun DirectoryScreen(directoryViewModel: DirectoryViewModel) { val state = directoryViewModel.state - directoryViewModel.ObserveEvents() + + val listState: LazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = 0, + ) + + val toolbarHeight = 72.dp + val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() } + val toolbarOffsetHeightPx = remember { mutableStateOf(0f) } + + directoryViewModel.ObserveEvents(listState, toolbarOffsetHeightPx) + LifecycleEffect( onStart = { directoryViewModel.start() }, onStop = { directoryViewModel.stop() } ) - when (state) { - is Content -> { - Content(state) - } - EmptyLoading -> CenteredLoading() - is Error -> { - Box(contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Something went wrong...") - Button(onClick = {}) { - Text("Retry") - } - } + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.y + val newOffset = toolbarOffsetHeightPx.value + delta + toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f) + return Offset.Zero } } } + Box( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection) + ) { + when (state) { + EmptyLoading -> CenteredLoading() + DirectoryScreenState.Empty -> GenericEmpty() + is Error -> GenericError { + // TODO + } + is Content -> Content(listState, state) + } + Toolbar(title = "Messages", offset = { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) }) + } } @Composable -private fun DirectoryViewModel.ObserveEvents() { +private fun DirectoryViewModel.ObserveEvents(listState: LazyListState, toolbarPosition: MutableState) { val context = LocalContext.current StartObserving { this@ObserveEvents.events.launch { @@ -81,21 +106,24 @@ private fun DirectoryViewModel.ObserveEvents() { is OpenDownloadUrl -> { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.url))) } + DirectoryEvent.ScrollToTop -> { + toolbarPosition.value = 0f + listState.scrollToItem(0) + } } } } } + +val clock = Clock.systemUTC() + @Composable -private fun Content(state: Content) { +private fun Content(listState: LazyListState, state: Content) { val context = LocalContext.current val navigateToRoom = { roomId: RoomId -> context.startActivity(MessengerActivity.newInstance(context, roomId)) } - val clock = Clock.systemUTC() - val listState: LazyListState = rememberLazyListState( - initialFirstVisibleItemIndex = 0, - ) val scope = rememberCoroutineScope() LaunchedEffect(key1 = state.overviewState) { @@ -103,7 +131,7 @@ private fun Content(state: Content) { scope.launch { listState.scrollToItem(0) } } } - LazyColumn(Modifier.fillMaxSize(), state = listState) { + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(top = 72.dp)) { items( items = state.overviewState, key = { it.overview.roomId.value }, @@ -119,9 +147,13 @@ private fun DirectoryItem(room: RoomFoo, onClick: (RoomId) -> Unit, clock: Clock val roomName = overview.roomName ?: "Empty room" val hasUnread = room.unreadCount.value > 0 - Box(Modifier.height(IntrinsicSize.Min).fillMaxWidth().clickable { - onClick(overview.roomId) - }) { + Box( + Modifier + .height(IntrinsicSize.Min) + .fillMaxWidth() + .clickable { + onClick(overview.roomId) + }) { Row(Modifier.padding(20.dp)) { val secondaryText = MaterialTheme.colors.onBackground.copy(alpha = 0.5f) @@ -164,13 +196,24 @@ private fun DirectoryItem(room: RoomFoo, onClick: (RoomId) -> Unit, clock: Clock Spacer(modifier = Modifier.width(6.dp)) Box(Modifier.align(Alignment.CenterVertically)) { Box( - Modifier.align(Alignment.Center).background(color = MaterialTheme.colors.primary, shape = CircleShape).size(22.dp), + Modifier + .align(Alignment.Center) + .background(color = MaterialTheme.colors.primary, shape = CircleShape) + .size(22.dp), contentAlignment = Alignment.Center ) { + val unreadTextSize = when (room.unreadCount.value > 99) { + true -> 9.sp + false -> 10.sp + } + val unreadLabelContent = when { + room.unreadCount.value > 99 -> "99+" + else -> room.unreadCount.value.toString() + } Text( - fontSize = 10.sp, + fontSize = unreadTextSize, fontWeight = FontWeight.Medium, - text = room.unreadCount.value.toString(), + text = unreadLabelContent, color = MaterialTheme.colors.onPrimary ) } diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt index d52fb1d..2cf51fa 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt @@ -3,6 +3,7 @@ package app.dapk.st.directory sealed interface DirectoryScreenState { object EmptyLoading : DirectoryScreenState + object Empty : DirectoryScreenState data class Content( val overviewState: DirectoryState, ) : DirectoryScreenState @@ -10,5 +11,6 @@ sealed interface DirectoryScreenState { sealed interface DirectoryEvent { data class OpenDownloadUrl(val url: String) : DirectoryEvent + object ScrollToTop : DirectoryEvent } diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt index 1d553ed..b6a7bf6 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt @@ -8,15 +8,18 @@ import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.sync.* import app.dapk.st.matrix.sync.SyncService.SyncEvent.Typing -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.* @JvmInline value class UnreadCount(val value: Int) typealias DirectoryState = List -data class RoomFoo(val overview: RoomOverview, val unreadCount: UnreadCount, val typing: Typing?) +data class RoomFoo( + val overview: RoomOverview, + val unreadCount: UnreadCount, + val typing: Typing? +) class DirectoryUseCase( private val syncService: SyncService, @@ -26,28 +29,35 @@ class DirectoryUseCase( private val roomStore: RoomStore, ) { - suspend fun startSyncing(): Flow { - return syncService.startSyncing() - } - - suspend fun state(): Flow { - val userId = credentialsStore.credentials()!!.userId - return combine( - syncService.overview(), - messageService.localEchos(), - roomStore.observeUnreadCountById(), - syncService.events() - ) { overviewState, localEchos, unread, events -> - overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview -> - RoomFoo( - overview = roomOverview, - unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0), - typing = events.filterIsInstance().firstOrNull { it.roomId == roomOverview.roomId } - ) + fun state(): Flow { + return flow { emit(credentialsStore.credentials()!!.userId) }.flatMapMerge { userId -> + combine( + overviewDatasource(), + messageService.localEchos(), + roomStore.observeUnreadCountById(), + syncService.events() + ) { overviewState, localEchos, unread, events -> + overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview -> + RoomFoo( + overview = roomOverview, + unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0), + typing = events.filterIsInstance().firstOrNull { it.roomId == roomOverview.roomId } + ) + } } } } + private fun overviewDatasource() = combine( + syncService.startSyncing().map { false }.onStart { emit(true) }, + syncService.overview() + ) { isFirstLoad, overview -> + when { + isFirstLoad && overview.isEmpty() -> null + else -> overview + } + }.filterNotNull() + private suspend fun OverviewState.mergeWithLocalEchos(localEchos: Map>, userId: UserId): OverviewState { return when { localEchos.isEmpty() -> this @@ -74,6 +84,7 @@ class DirectoryUseCase( lastMessage = LastMessage( content = when (val message = latestEcho.message) { is MessageService.Message.TextMessage -> message.content.body + is MessageService.Message.ImageMessage -> "\uD83D\uDCF7" }, utcTimestamp = latestEcho.timestampUtc, author = member, diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt index a1b3117..d8c4fdc 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt @@ -1,9 +1,10 @@ package app.dapk.st.directory import androidx.lifecycle.viewModelScope -import app.dapk.st.directory.DirectoryScreenState.Content -import app.dapk.st.directory.DirectoryScreenState.EmptyLoading +import app.dapk.st.directory.DirectoryScreenState.* import app.dapk.st.viewmodel.DapkViewModel +import app.dapk.st.viewmodel.MutableStateFactory +import app.dapk.st.viewmodel.defaultStateFactory import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach @@ -12,22 +13,21 @@ import kotlinx.coroutines.launch class DirectoryViewModel( private val shortcutHandler: ShortcutHandler, private val directoryUseCase: DirectoryUseCase, + factory: MutableStateFactory = defaultStateFactory(), ) : DapkViewModel( - initialState = EmptyLoading + initialState = EmptyLoading, + factory, ) { private var syncJob: Job? = null fun start() { syncJob = viewModelScope.launch { - directoryUseCase.startSyncing().collect() - } - - viewModelScope.launch { directoryUseCase.state().onEach { shortcutHandler.onDirectoryUpdate(it.map { it.overview }) - if (it.isNotEmpty()) { - state = Content(it) + state = when (it.isEmpty()) { + true -> Empty + false -> Content(it) } }.collect() } @@ -36,5 +36,9 @@ class DirectoryViewModel( fun stop() { syncJob?.cancel() } + + fun scrollToTopOfMessages() { + _events.tryEmit(DirectoryEvent.ScrollToTop) + } } diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt index b3f835b..87b4980 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt @@ -2,6 +2,7 @@ package app.dapk.st.directory import android.content.Context import android.content.pm.ShortcutInfo +import androidx.core.app.Person import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import app.dapk.st.matrix.common.RoomId @@ -13,23 +14,27 @@ class ShortcutHandler(private val context: Context) { private val cachedRoomIds = mutableListOf() fun onDirectoryUpdate(overviews: List) { - val update = overviews.map { it.roomId } - if (cachedRoomIds != update) { cachedRoomIds.clear() cachedRoomIds.addAll(update) - val currentShortcuts = ShortcutManagerCompat.getShortcuts(context, ShortcutManagerCompat.FLAG_MATCH_DYNAMIC) val maxShortcutCountPerActivity = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) - overviews .take(maxShortcutCountPerActivity) - .filterNot { roomUpdate -> currentShortcuts.any { it.id == roomUpdate.roomId.value } } .forEachIndexed { index, room -> val build = ShortcutInfoCompat.Builder(context, room.roomId.value) .setShortLabel(room.roomName ?: "N/A") + .setLongLabel(room.roomName ?: "N/A") .setRank(index) + .run { + this.setPerson( + Person.Builder() + .setName(room.roomName ?: "N/A") + .setKey(room.roomId.value) + .build() + ) + } .setIntent(MessengerActivity.newShortcutInstance(context, room.roomId)) .setLongLived(true) .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION)) diff --git a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryViewModelTest.kt b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryViewModelTest.kt new file mode 100644 index 0000000..752805a --- /dev/null +++ b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryViewModelTest.kt @@ -0,0 +1,52 @@ +package app.dapk.st.directory + +import ViewModelTest +import fixture.aRoomOverview +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import org.junit.Test +import test.delegateReturn + +private val AN_OVERVIEW = aRoomOverview() +private val AN_OVERVIEW_STATE = RoomFoo(AN_OVERVIEW, UnreadCount(1), null) + +class DirectoryViewModelTest { + + private val runViewModelTest = ViewModelTest() + private val fakeDirectoryUseCase = FakeDirectoryUseCase() + private val fakeShortcutHandler = FakeShortcutHandler() + + private val viewModel = DirectoryViewModel( + fakeShortcutHandler.instance, + fakeDirectoryUseCase.instance, + runViewModelTest.testMutableStateFactory(), + ) + + @Test + fun `when creating view model, then initial state is empty loading`() = runViewModelTest { + viewModel.test() + + assertInitialState(DirectoryScreenState.EmptyLoading) + } + + @Test + fun `when starting, then updates shortcuts and emits room state`() = runViewModelTest { + fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) } + fakeDirectoryUseCase.given().returns(flowOf(listOf(AN_OVERVIEW_STATE))) + + viewModel.test().start() + + assertStates(DirectoryScreenState.Content(listOf(AN_OVERVIEW_STATE))) + verifyExpects() + } +} + +class FakeShortcutHandler { + val instance = mockk() +} + +class FakeDirectoryUseCase { + val instance = mockk() + fun given() = every { instance.state() }.delegateReturn() +} \ No newline at end of file diff --git a/features/home/build.gradle b/features/home/build.gradle index 0c30581..5507d8c 100644 --- a/features/home/build.gradle +++ b/features/home/build.gradle @@ -1,4 +1,4 @@ -applyAndroidLibraryModule(project) +applyAndroidComposeLibraryModule(project) dependencies { implementation project(":matrix:services:profile") @@ -7,7 +7,7 @@ dependencies { implementation project(":features:login") implementation project(":features:settings") implementation project(":features:profile") - implementation project(":domains:android:core") + implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") implementation project(':domains:store') implementation project(":core") diff --git a/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt b/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt new file mode 100644 index 0000000..7897223 --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt @@ -0,0 +1,26 @@ +package app.dapk.st.home + +import app.dapk.st.core.BuildMeta +import app.dapk.st.domain.ApplicationPreferences +import app.dapk.st.domain.ApplicationVersion +import kotlinx.coroutines.runBlocking + +class BetaVersionUpgradeUseCase( + private val applicationPreferences: ApplicationPreferences, + private val buildMeta: BuildMeta, +) { + + fun hasVersionChanged(): Boolean { + return runBlocking { + val previousVersion = applicationPreferences.readVersion()?.value + val currentVersion = buildMeta.versionCode + when (previousVersion) { + null -> false + else -> currentVersion > previousVersion + }.also { + applicationPreferences.setVersion(ApplicationVersion(currentVersion)) + } + } + } + +} \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt index 12b1c35..1d33dd8 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt @@ -1,5 +1,6 @@ package app.dapk.st.home +import app.dapk.st.core.BuildMeta import app.dapk.st.core.ProvidableModule import app.dapk.st.directory.DirectoryViewModel import app.dapk.st.domain.StoreModule @@ -10,9 +11,22 @@ import app.dapk.st.profile.ProfileViewModel class HomeModule( private val storeModule: StoreModule, private val profileService: ProfileService, + private val buildMeta: BuildMeta, ) : ProvidableModule { fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel { - return HomeViewModel(storeModule.credentialsStore(), directory, login, profileViewModel, profileService) + return HomeViewModel( + storeModule.credentialsStore(), + directory, + login, + profileViewModel, + profileService, + storeModule.cacheCleaner(), + BetaVersionUpgradeUseCase( + storeModule.applicationStore(), + buildMeta, + ), + ) } + } \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt index ec85869..250265c 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt @@ -1,6 +1,5 @@ package app.dapk.st.home -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable @@ -26,6 +25,7 @@ fun HomeScreen(homeViewModel: HomeViewModel) { homeViewModel.start() } + when (val state = homeViewModel.state) { Loading -> CenteredLoading() is SignedIn -> { @@ -38,8 +38,9 @@ fun HomeScreen(homeViewModel: HomeViewModel) { when (state.page) { Directory -> DirectoryScreen(homeViewModel.directory()) Profile -> { - BackHandler { homeViewModel.changePage(Directory) } - ProfileScreen(homeViewModel.profile()) + ProfileScreen(homeViewModel.profile()) { + homeViewModel.changePage(Directory) + } } } } @@ -66,7 +67,12 @@ private fun BottomBar(state: SignedIn, homeViewModel: HomeViewModel) { Directory -> BottomNavigationItem( icon = { Icon(page.icon, contentDescription = null) }, selected = state.page == page, - onClick = { homeViewModel.changePage(page) }, + onClick = { + when { + state.page == page -> homeViewModel.scrollToTopOfMessages() + else -> homeViewModel.changePage(page) + } + }, ) Profile -> BottomNavigationItem( icon = { diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt index 7012560..ca25e6f 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt @@ -19,5 +19,7 @@ sealed interface HomeScreenState { } -sealed interface HomeEvent +sealed interface HomeEvent { + object Relaunch : HomeEvent +} diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt index b1ba463..877314a 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt @@ -2,6 +2,7 @@ package app.dapk.st.home import androidx.lifecycle.viewModelScope import app.dapk.st.directory.DirectoryViewModel +import app.dapk.st.domain.StoreCleaner import app.dapk.st.home.HomeScreenState.* import app.dapk.st.login.LoginViewModel import app.dapk.st.matrix.common.CredentialsStore @@ -17,6 +18,8 @@ class HomeViewModel( private val loginViewModel: LoginViewModel, private val profileViewModel: ProfileViewModel, private val profileService: ProfileService, + private val cacheCleaner: StoreCleaner, + private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, ) : DapkViewModel( initialState = Loading ) { @@ -43,8 +46,17 @@ class HomeViewModel( } } - fun loggedOut() { - state = SignedOut + fun hasVersionChanged() = betaVersionUpgradeUseCase.hasVersionChanged() + + fun clearCache() { + viewModelScope.launch { + cacheCleaner.cleanCache(removeCredentials = false) + _events.emit(HomeEvent.Relaunch) + } + } + + fun scrollToTopOfMessages() { + directoryViewModel.scrollToTopOfMessages() } fun changePage(page: Page) { diff --git a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt index aadb758..9f7838d 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt @@ -2,12 +2,19 @@ package app.dapk.st.home import android.os.Bundle import androidx.activity.compose.setContent +import androidx.compose.material.AlertDialog +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.lifecycle.lifecycleScope import app.dapk.st.core.DapkActivity import app.dapk.st.core.module import app.dapk.st.core.viewModel import app.dapk.st.directory.DirectoryModule import app.dapk.st.login.LoginModule import app.dapk.st.profile.ProfileModule +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach class MainActivity : DapkActivity() { @@ -18,9 +25,34 @@ class MainActivity : DapkActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + homeViewModel.events.onEach { + when (it) { + HomeEvent.Relaunch -> recreate() + } + }.launchIn(lifecycleScope) + setContent { - HomeScreen(homeViewModel) + if (homeViewModel.hasVersionChanged()) { + BetaUpgradeDialog() + } else { + HomeScreen(homeViewModel) + } } } -} + @Composable + private fun BetaUpgradeDialog() { + AlertDialog( + title = { Text(text = "BETA") }, + text = { Text(text = "During the BETA, version upgrades require a cache clear") }, + onDismissRequest = { + + }, + confirmButton = { + TextButton(onClick = { homeViewModel.clearCache() }) { + Text(text = "Clear cache".uppercase()) + } + }, + ) + } +} diff --git a/features/login/build.gradle b/features/login/build.gradle index 0913f47..3a3af95 100644 --- a/features/login/build.gradle +++ b/features/login/build.gradle @@ -1,7 +1,7 @@ -applyAndroidLibraryModule(project) +applyAndroidComposeLibraryModule(project) dependencies { - implementation project(":domains:android:core") + implementation project(":domains:android:compose-core") implementation project(":domains:android:push") implementation project(":domains:android:viewmodel") implementation project(":matrix:services:auth") diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt index 3b50c9b..9180e52 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt @@ -3,7 +3,6 @@ package app.dapk.st.login import app.dapk.st.core.ProvidableModule import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.matrix.auth.AuthService -import app.dapk.st.matrix.crypto.CryptoService import app.dapk.st.matrix.room.ProfileService import app.dapk.st.push.PushModule @@ -15,6 +14,6 @@ class LoginModule( ) : ProvidableModule { fun loginViewModel(): LoginViewModel { - return LoginViewModel(authService, pushModule.registerFirebasePushTokenUseCase(), profileService, errorTracker) + return LoginViewModel(authService, pushModule.pushTokenRegistrar(), profileService, errorTracker) } } \ No newline at end of file diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt index 485c645..28f8e0f 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt @@ -1,5 +1,6 @@ package app.dapk.st.login +import android.widget.Toast import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -7,13 +8,21 @@ import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Web import androidx.compose.material.icons.outlined.Lock import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.composed import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -25,6 +34,7 @@ import app.dapk.st.core.StartObserving import app.dapk.st.login.LoginEvent.LoginComplete import app.dapk.st.login.LoginScreenState.* +@OptIn(ExperimentalComposeUiApi::class) @Composable fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { loginViewModel.ObserveEvents(onLoggedIn) @@ -34,8 +44,10 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { var userName by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } + var serverUrl by rememberSaveable { mutableStateOf("") } + val keyboardController = LocalSoftwareKeyboardController.current - when (loginViewModel.state) { + when (val state = loginViewModel.state) { is Error -> { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -54,7 +66,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { CircularProgressIndicator() } } - Idle -> + is Content -> Row { Spacer(modifier = Modifier.weight(0.1f)) Column( @@ -74,7 +86,9 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { val focusManager = LocalFocusManager.current TextField( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .autofill(listOf(AutofillType.Username), onFill = { userName = it }), value = userName, onValueChange = { userName = it }, singleLine = true, @@ -87,10 +101,16 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Email, imeAction = ImeAction.Next) ) - val canDoLoginAttempt = userName.isNotEmpty() && password.isNotEmpty() + val canDoLoginAttempt = if (state.showServerUrl) { + userName.isNotEmpty() && password.isNotEmpty() && serverUrl.isNotEmpty() + } else { + userName.isNotEmpty() && password.isNotEmpty() + } TextField( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .autofill(listOf(AutofillType.Password), onFill = { password = it }), value = password, onValueChange = { password = it }, label = { Text("Password") }, @@ -98,10 +118,13 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { leadingIcon = { Icon(imageVector = Icons.Outlined.Lock, contentDescription = null) }, - keyboardActions = KeyboardActions(onDone = { loginViewModel.login(userName, password) }), + keyboardActions = KeyboardActions( + onDone = { loginViewModel.login(userName, password, serverUrl) }, + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), keyboardOptions = KeyboardOptions( autoCorrect = false, - imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.None, + imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.Next.takeIf { state.showServerUrl } ?: ImeAction.None, keyboardType = KeyboardType.Password ), visualTransformation = if (passwordVisibility) VisualTransformation.None else PasswordVisualTransformation(), @@ -113,11 +136,33 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { } ) + if (state.showServerUrl) { + TextField( + modifier = Modifier.fillMaxWidth(), + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text("Server URL") }, + singleLine = true, + leadingIcon = { + Icon(imageVector = Icons.Default.Web, contentDescription = null) + }, + keyboardActions = KeyboardActions(onDone = { loginViewModel.login(userName, password, serverUrl) }), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.None, + keyboardType = KeyboardType.Uri + ), + ) + } + Spacer(Modifier.height(4.dp)) Button( modifier = Modifier.fillMaxWidth(), - onClick = { loginViewModel.login(userName, password) }, + onClick = { + keyboardController?.hide() + loginViewModel.login(userName, password, serverUrl) + }, enabled = canDoLoginAttempt ) { Text("Sign in".uppercase(), fontSize = 18.sp) @@ -128,12 +173,38 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { } } +@OptIn(ExperimentalComposeUiApi::class) +private fun Modifier.autofill( + autofillTypes: List, + onFill: ((String) -> Unit), +) = composed { + val autofill = LocalAutofill.current + val autofillNode = AutofillNode(onFill = onFill, autofillTypes = autofillTypes) + LocalAutofillTree.current += autofillNode + + this + .onGloballyPositioned { autofillNode.boundingBox = it.boundsInWindow() } + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) + } + } + } +} + @Composable private fun LoginViewModel.ObserveEvents(onLoggedIn: () -> Unit) { + val context = LocalContext.current StartObserving { this@ObserveEvents.events.launch { when (it) { LoginComplete -> onLoggedIn() + LoginEvent.WellKnownMissing -> { + Toast.makeText(context, "Couldn't find the homeserver, please enter the server URL", Toast.LENGTH_LONG).show() + } } } } diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt index 5e0245b..f4d0d24 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt @@ -2,12 +2,13 @@ package app.dapk.st.login sealed interface LoginScreenState { - object Idle : LoginScreenState + data class Content(val showServerUrl: Boolean) : LoginScreenState object Loading : LoginScreenState data class Error(val cause: Throwable) : LoginScreenState } sealed interface LoginEvent { object LoginComplete : LoginEvent + object WellKnownMissing : LoginEvent } diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt index 2969031..f82c80d 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt @@ -7,7 +7,7 @@ import app.dapk.st.login.LoginEvent.LoginComplete import app.dapk.st.login.LoginScreenState.* import app.dapk.st.matrix.auth.AuthService import app.dapk.st.matrix.room.ProfileService -import app.dapk.st.push.RegisterFirebasePushTokenUseCase +import app.dapk.st.push.PushTokenRegistrar import app.dapk.st.viewmodel.DapkViewModel import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -15,30 +15,38 @@ import kotlinx.coroutines.launch class LoginViewModel( private val authService: AuthService, - private val firebasePushTokenUseCase: RegisterFirebasePushTokenUseCase, + private val pushTokenRegistrar: PushTokenRegistrar, private val profileService: ProfileService, private val errorTracker: ErrorTracker, ) : DapkViewModel( - initialState = Idle + initialState = Content(showServerUrl = false) ) { - fun login(userName: String, password: String) { + private var previousState: LoginScreenState? = null + + fun login(userName: String, password: String, serverUrl: String?) { state = Loading viewModelScope.launch { - kotlin.runCatching { - logP("login") { - authService.login(userName, password).also { - listOf( - async { firebasePushTokenUseCase.registerCurrentToken() }, - async { preloadMe() }, - ).awaitAll() + logP("login") { + when (val result = authService.login(AuthService.LoginRequest(userName, password, serverUrl.takeIfNotEmpty()))) { + is AuthService.LoginResult.Success -> { + runCatching { + listOf( + async { pushTokenRegistrar.registerCurrentToken() }, + async { preloadMe() }, + ).awaitAll() + } + _events.tryEmit(LoginComplete) + } + is AuthService.LoginResult.Error -> { + errorTracker.track(result.cause) + state = Error(result.cause) + } + AuthService.LoginResult.MissingWellKnown -> { + _events.tryEmit(LoginEvent.WellKnownMissing) + state = Content(showServerUrl = true) } } - }.onFailure { - errorTracker.track(it) - state = Error(it) - }.onSuccess { - _events.tryEmit(LoginComplete) } } } @@ -46,6 +54,10 @@ class LoginViewModel( private suspend fun preloadMe() = profileService.me(forceRefresh = false) fun start() { - state = Idle + val showServerUrl = previousState?.let { it is Content && it.showServerUrl } ?: false + state = Content(showServerUrl = showServerUrl) } } + + +private fun String?.takeIfNotEmpty() = this?.takeIf { it.isNotEmpty() } \ No newline at end of file diff --git a/features/messenger/build.gradle b/features/messenger/build.gradle index 60caf38..4603773 100644 --- a/features/messenger/build.gradle +++ b/features/messenger/build.gradle @@ -1,14 +1,24 @@ -applyAndroidLibraryModule(project) +applyAndroidComposeLibraryModule(project) apply plugin: 'kotlin-parcelize' dependencies { implementation project(":matrix:services:sync") implementation project(":matrix:services:message") implementation project(":matrix:services:room") - implementation project(":domains:android:core") + implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") implementation project(":core") implementation project(":features:navigator") implementation project(":design-library") - implementation("io.coil-kt:coil-compose:1.4.0") + implementation Dependencies.mavenCentral.coil + + kotlinTest(it) + + androidImportFixturesWorkaround(project, project(":matrix:services:sync")) + androidImportFixturesWorkaround(project, project(":matrix:services:message")) + androidImportFixturesWorkaround(project, project(":matrix:common")) + androidImportFixturesWorkaround(project, project(":core")) + androidImportFixturesWorkaround(project, project(":domains:store")) + androidImportFixturesWorkaround(project, project(":domains:android:viewmodel")) + androidImportFixturesWorkaround(project, project(":domains:android:stub")) } \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt new file mode 100644 index 0000000..0c89d90 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt @@ -0,0 +1,75 @@ +package app.dapk.st.messenger + +import android.content.Context +import android.util.Base64 +import app.dapk.st.matrix.sync.RoomEvent +import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.fetch.SourceResult +import coil.request.Options +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okio.Buffer +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +private const val CRYPTO_BUFFER_SIZE = 32 * 1024 +private const val CIPHER_ALGORITHM = "AES/CTR/NoPadding" +private const val SECRET_KEY_SPEC_ALGORITHM = "AES" +private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" + +class DecryptingFetcherFactory(private val context: Context) : Fetcher.Factory { + override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher { + return DecryptingFetcher(data, context) + } +} + +private val http = OkHttpClient() + +class DecryptingFetcher(private val data: RoomEvent.Image, private val context: Context) : Fetcher { + + override suspend fun fetch(): FetchResult { + val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute() + val outputStream = when { + data.imageMeta.keys != null -> handleEncrypted(response, data.imageMeta.keys!!) + else -> response.body?.source() ?: throw IllegalArgumentException("No bitmap response found") + } + return SourceResult(ImageSource(outputStream, context), null, DataSource.NETWORK) + } + + private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { + val key = Base64.decode(keys.k.replace('-', '+').replace('_', '/'), Base64.DEFAULT) + val initVectorBytes = Base64.decode(keys.iv, Base64.DEFAULT) + + val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) + val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) + val ivParameterSpec = IvParameterSpec(initVectorBytes) + decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) + + val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) + + var read: Int + val d = ByteArray(CRYPTO_BUFFER_SIZE) + var decodedBytes: ByteArray + + val outputStream = Buffer() + response.body?.let { + it.byteStream().use { + read = it.read(d) + while (read != -1) { + messageDigest.update(d, 0, read) + decodedBytes = decryptCipher.update(d, 0, read) + outputStream.write(decodedBytes) + read = it.read(d) + } + } + } + return outputStream + } +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalEchoMapper.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalEchoMapper.kt new file mode 100644 index 0000000..a27894f --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalEchoMapper.kt @@ -0,0 +1,55 @@ +package app.dapk.st.messenger + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.sync.MessageMeta +import app.dapk.st.matrix.sync.RoomEvent + +internal class LocalEchoMapper(private val metaMapper: MetaMapper) { + + fun MessageService.LocalEcho.toMessage(member: RoomMember): RoomEvent { + return when (val message = this.message) { + is MessageService.Message.TextMessage -> { + RoomEvent.Message( + eventId = this.eventId ?: EventId(this.localId), + content = message.content.body, + author = member, + utcTimestamp = message.timestampUtc, + meta = metaMapper.toMeta(this) + ) + } + is MessageService.Message.ImageMessage -> { + RoomEvent.Image( + eventId = this.eventId ?: EventId(this.localId), + author = member, + utcTimestamp = message.timestampUtc, + meta = metaMapper.toMeta(this), + imageMeta = RoomEvent.Image.ImageMeta(100, 100, message.content.uri, null), + ) + } + } + } + + fun RoomEvent.mergeWith(echo: MessageService.LocalEcho): RoomEvent = when (this) { + is RoomEvent.Message -> this.copy(meta = metaMapper.toMeta(echo)) + is RoomEvent.Reply -> this.copy(message = this.message.mergeWith(echo)) + is RoomEvent.Image -> this.copy(meta = metaMapper.toMeta(echo)) + } +} + +internal class MetaMapper { + + fun toMeta(echo: MessageService.LocalEcho) = MessageMeta.LocalEcho( + echoId = echo.localId, + state = when (val localEchoState = echo.state) { + MessageService.LocalEcho.State.Sending -> MessageMeta.LocalEcho.State.Sending + MessageService.LocalEcho.State.Sent -> MessageMeta.LocalEcho.State.Sent + is MessageService.LocalEcho.State.Error -> MessageMeta.LocalEcho.State.Error( + localEchoState.message, + type = MessageMeta.LocalEcho.State.Error.Type.UNKNOWN, + ) + } + ) + +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalIdFactory.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalIdFactory.kt new file mode 100644 index 0000000..ecb1777 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalIdFactory.kt @@ -0,0 +1,7 @@ +package app.dapk.st.messenger + +import java.util.* + +internal class LocalIdFactory { + fun create() = "local.${UUID.randomUUID()}" +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt index 65cba27..ba23d34 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt @@ -3,63 +3,44 @@ package app.dapk.st.messenger import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.sync.MessageMeta import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomState internal typealias MergeWithLocalEchosUseCase = (RoomState, RoomMember, List) -> RoomState -internal class MergeWithLocalEchosUseCaseImpl : MergeWithLocalEchosUseCase { +internal class MergeWithLocalEchosUseCaseImpl( + private val localEventMapper: LocalEchoMapper, +) : MergeWithLocalEchosUseCase { override fun invoke(roomState: RoomState, member: RoomMember, echos: List): RoomState { val echosByEventId = echos.associateBy { it.eventId } val stateByEventId = roomState.events.associateBy { it.eventId } - val uniqueEchos = echos.filter { echo -> - echo.eventId == null || stateByEventId[echo.eventId] == null - }.map { localEcho -> - when (val message = localEcho.message) { - is MessageService.Message.TextMessage -> { - createMessage(localEcho, message, member) - } - } - } + val uniqueEchos = uniqueEchos(echos, stateByEventId, member) + val existingWithEcho = updateExistingEventsWithLocalEchoMeta(roomState, echosByEventId) - val existingWithEcho = roomState.events.map { - when (val echo = echosByEventId[it.eventId]) { - null -> it - else -> when (it) { - is RoomEvent.Message -> it.copy( - meta = echo.toMeta() - ) - is RoomEvent.Reply -> it.copy(message = it.message.copy(meta = echo.toMeta())) - } - } - } val sortedEvents = (existingWithEcho + uniqueEchos) - .sortedByDescending { if (it is RoomEvent.Message) it.utcTimestamp else null } + .sortedByDescending { it.utcTimestamp } .distinctBy { it.eventId } return roomState.copy(events = sortedEvents) } -} - -private fun createMessage(localEcho: MessageService.LocalEcho, message: MessageService.Message.TextMessage, member: RoomMember) = RoomEvent.Message( - eventId = localEcho.eventId ?: EventId(localEcho.localId), - content = message.content.body, - author = member, - utcTimestamp = message.timestampUtc, - meta = localEcho.toMeta() -) - -private fun MessageService.LocalEcho.toMeta() = MessageMeta.LocalEcho( - echoId = this.localId, - state = when (val localEchoState = this.state) { - MessageService.LocalEcho.State.Sending -> MessageMeta.LocalEcho.State.Sending - MessageService.LocalEcho.State.Sent -> MessageMeta.LocalEcho.State.Sent - is MessageService.LocalEcho.State.Error -> MessageMeta.LocalEcho.State.Error( - localEchoState.message, - type = MessageMeta.LocalEcho.State.Error.Type.UNKNOWN, - ) + private fun uniqueEchos(echos: List, stateByEventId: Map, member: RoomMember): List { + return with(localEventMapper) { + echos + .filter { echo -> echo.eventId == null || stateByEventId[echo.eventId] == null } + .map { localEcho -> localEcho.toMessage(member) } + } } -) + + private fun updateExistingEventsWithLocalEchoMeta(roomState: RoomState, echosByEventId: Map): List { + return with(localEventMapper) { + roomState.events.map { roomEvent -> + when (val echo = echosByEventId[roomEvent.eventId]) { + null -> roomEvent + else -> roomEvent.mergeWith(echo) + } + } + } + } +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt index 4eb5dab..6f707f9 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt @@ -9,11 +9,10 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.Surface import androidx.compose.ui.Modifier +import app.dapk.st.core.* import app.dapk.st.design.components.SmallTalkTheme -import app.dapk.st.core.DapkActivity -import app.dapk.st.core.module -import app.dapk.st.core.viewModel import app.dapk.st.matrix.common.RoomId +import app.dapk.st.navigator.MessageAttachment import kotlinx.parcelize.Parcelize class MessengerActivity : DapkActivity() { @@ -34,15 +33,22 @@ class MessengerActivity : DapkActivity() { putExtra("shortcut_key", roomId.value) } } + + fun newMessageAttachment(context: Context, roomId: RoomId, attachments: List): Intent { + return Intent(context, MessengerActivity::class.java).apply { + putExtra("key", MessagerActivityPayload(roomId.value, attachments)) + } + } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val payload = readPayload() + log(AppLogTag.ERROR_NON_FATAL, payload) setContent { SmallTalkTheme { Surface(Modifier.fillMaxSize()) { - MessengerScreen(RoomId(payload.roomId), viewModel, navigator) + MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator) } } } @@ -51,7 +57,10 @@ class MessengerActivity : DapkActivity() { @Parcelize data class MessagerActivityPayload( - val roomId: String + val roomId: String, + val attachments: List? = null ) : Parcelable -fun Activity.readPayload(): T = intent.getParcelableExtra("key")!! \ No newline at end of file +fun Activity.readPayload(): T = intent.getParcelableExtra("key") ?: intent.getStringExtra("shortcut_key")!!.let { + MessagerActivityPayload(it) as T +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt index 45a48dc..622019c 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt @@ -6,6 +6,7 @@ import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.matrix.sync.SyncService +import java.time.Clock class MessengerModule( private val syncService: SyncService, @@ -13,9 +14,15 @@ class MessengerModule( private val roomService: RoomService, private val credentialsStore: CredentialsStore, private val roomStore: RoomStore, + private val clock: Clock, ) : ProvidableModule { - fun messengerViewModel(): MessengerViewModel { - return MessengerViewModel(syncService, messageService, roomService, roomStore, credentialsStore) + internal fun messengerViewModel(): MessengerViewModel { + return MessengerViewModel(messageService, roomService, roomStore, credentialsStore, timelineUseCase(), LocalIdFactory(), clock) + } + + private fun timelineUseCase(): TimelineUseCaseImpl { + val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(LocalEchoMapper(MetaMapper())) + return TimelineUseCaseImpl(syncService, messageService, roomService, mergeWithLocalEchosUseCase) } } \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 14672c8..690f1f5 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -1,5 +1,7 @@ package app.dapk.st.messenger +import android.content.res.Configuration +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* @@ -7,6 +9,7 @@ import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check @@ -19,11 +22,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.* +import androidx.core.net.toUri import app.dapk.st.core.Lce import app.dapk.st.core.LifecycleEffect import app.dapk.st.core.StartObserving @@ -35,16 +40,19 @@ import app.dapk.st.matrix.sync.MessageMeta import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomEvent.Message import app.dapk.st.matrix.sync.RoomState +import app.dapk.st.navigator.MessageAttachment import app.dapk.st.navigator.Navigator +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest import kotlinx.coroutines.launch @Composable -fun MessengerScreen(roomId: RoomId, viewModel: MessengerViewModel, navigator: Navigator) { +internal fun MessengerScreen(roomId: RoomId, attachments: List?, viewModel: MessengerViewModel, navigator: Navigator) { val state = viewModel.state viewModel.ObserveEvents() LifecycleEffect( - onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId)) }, + onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }, onStop = { viewModel.post(MessengerAction.OnMessengerGone) } ) @@ -61,15 +69,23 @@ fun MessengerScreen(roomId: RoomId, viewModel: MessengerViewModel, navigator: Na } } }) - Room(state.roomState) when (state.composerState) { is ComposerState.Text -> { - Composer( - state.composerState.value, + Room(state.roomState) + TextComposer( + state.composerState, onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) }, onSend = { viewModel.post(MessengerAction.ComposerSendText) }, ) } + + is ComposerState.Attachments -> { + AttachmentComposer( + state.composerState, + onSend = { viewModel.post(MessengerAction.ComposerSendText) }, + onCancel = { viewModel.post(MessengerAction.ComposerClear) } + ) + } } } } @@ -78,8 +94,7 @@ fun MessengerScreen(roomId: RoomId, viewModel: MessengerViewModel, navigator: Na private fun MessengerViewModel.ObserveEvents() { StartObserving { this@ObserveEvents.events.launch { - when (it) { - } + // TODO() } } } @@ -117,6 +132,7 @@ private fun ColumnScope.Room(roomStateLce: Lce) { } } } + is Lce.Error -> { Box(contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -155,30 +171,33 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState) { items = state.events, key = { _, item -> item.eventId.value }, ) { index, item -> - when (item) { - is Message -> { - val wasPreviousMessageSameSender = when (val previousEvent = if (index != 0) state.events[index - 1] else null) { - null -> false - is Message -> previousEvent.author.id == item.author.id - is RoomEvent.Reply -> previousEvent.message.author.id == item.author.id - } - Message(self, item, wasPreviousMessageSameSender) - } - is RoomEvent.Reply -> { - val wasPreviousMessageSameSender = when (val previousEvent = if (index != 0) state.events[index - 1] else null) { - null -> false - is Message -> previousEvent.author.id == item.message.author.id - is RoomEvent.Reply -> previousEvent.message.author.id == item.message.author.id - } - Reply(self, item, wasPreviousMessageSameSender) + val previousEvent = if (index != 0) state.events[index - 1] else null + val wasPreviousMessageSameSender = previousEvent?.author?.id == item.author.id + AlignedBubble(item, self, wasPreviousMessageSameSender) { + when (item) { + is RoomEvent.Image -> MessageImage(it as BubbleContent) + is Message -> TextBubbleContent(it as BubbleContent) + is RoomEvent.Reply -> ReplyBubbleContent(it as BubbleContent) } } } } } +private data class BubbleContent( + val shape: RoundedCornerShape, + val background: Color, + val isNotSelf: Boolean, + val message: T +) + @Composable -private fun LazyItemScope.Message(self: UserId, message: Message, wasPreviousMessageSameSender: Boolean) { +private fun LazyItemScope.AlignedBubble( + message: T, + self: UserId, + wasPreviousMessageSameSender: Boolean, + content: @Composable (BubbleContent) -> Unit +) { when (message.author.id == self) { true -> { Box(modifier = Modifier.fillParentMaxWidth(), contentAlignment = Alignment.TopEnd) { @@ -188,11 +207,12 @@ private fun LazyItemScope.Message(self: UserId, message: Message, wasPreviousMes isNotSelf = false, wasPreviousMessageSameSender = wasPreviousMessageSameSender ) { - TextBubbleContent(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, false, message) + content(BubbleContent(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, false, message)) } } } } + false -> { Box(modifier = Modifier.fillParentMaxWidth(0.95f), contentAlignment = Alignment.TopStart) { Bubble( @@ -200,7 +220,7 @@ private fun LazyItemScope.Message(self: UserId, message: Message, wasPreviousMes isNotSelf = true, wasPreviousMessageSameSender = wasPreviousMessageSameSender ) { - TextBubbleContent(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, true, message) + content(BubbleContent(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, true, message)) } } } @@ -208,42 +228,89 @@ private fun LazyItemScope.Message(self: UserId, message: Message, wasPreviousMes } @Composable -private fun LazyItemScope.Reply(self: UserId, message: RoomEvent.Reply, wasPreviousMessageSameSender: Boolean) { - when (message.message.author.id == self) { - true -> { - Box(modifier = Modifier.fillParentMaxWidth(), contentAlignment = Alignment.TopEnd) { - Box(modifier = Modifier.fillParentMaxWidth(0.85f), contentAlignment = Alignment.TopEnd) { - Bubble( - message = message.message, - isNotSelf = false, - wasPreviousMessageSameSender = wasPreviousMessageSameSender - ) { - ReplyBubbleContent(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, false, message) - } +private fun MessageImage(content: BubbleContent) { + val context = LocalContext.current + val fetcherFactory = remember { DecryptingFetcherFactory(context) } + + Box(modifier = Modifier.padding(start = 6.dp)) { + Box( + Modifier + .padding(4.dp) + .clip(content.shape) + .background(content.background) + .height(IntrinsicSize.Max), + ) { + Column( + Modifier + .padding(8.dp) + .width(IntrinsicSize.Max) + .defaultMinSize(minWidth = 50.dp) + ) { + if (content.isNotSelf) { + Text( + fontSize = 11.sp, + text = content.message.author.displayName ?: content.message.author.id.value, + maxLines = 1, + color = MaterialTheme.colors.onPrimary + ) } - } - } - false -> { - Box(modifier = Modifier.fillParentMaxWidth(0.95f), contentAlignment = Alignment.TopStart) { - Bubble( - message = message.message, - isNotSelf = true, - wasPreviousMessageSameSender = wasPreviousMessageSameSender - ) { - ReplyBubbleContent(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, true, message) + + Spacer(modifier = Modifier.height(4.dp)) + Image( + modifier = Modifier.size(content.message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .fetcherFactory(fetcherFactory) + .data(content.message) + .build() + ), + contentDescription = null, + ) + Spacer(modifier = Modifier.height(4.dp)) + + Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + val editedPrefix = if (content.message.edited) "(edited) " else null + Text( + fontSize = 9.sp, + text = "${editedPrefix ?: ""}${content.message.time}", + textAlign = TextAlign.End, + color = MaterialTheme.colors.onPrimary, + modifier = Modifier.wrapContentSize() + ) + SendStatus(content.message) } } } } } +private fun RoomEvent.Image.ImageMeta.scale(density: Density, configuration: Configuration): DpSize { + val height = this@scale.height ?: 250 + val width = this@scale.width ?: 250 + return with(density) { + val scaler = minOf( + height.scalerFor(configuration.screenHeightDp.dp.toPx() * 0.5f), + width.scalerFor(configuration.screenWidthDp.dp.toPx() * 0.6f) + ) + + DpSize( + width = (width * scaler).toDp(), + height = (height * scaler).toDp(), + ) + } +} + + +private fun Int.scalerFor(max: Float): Float { + return max / this +} private val selfBackgroundShape = RoundedCornerShape(12.dp, 0.dp, 12.dp, 12.dp) private val othersBackgroundShape = RoundedCornerShape(0.dp, 12.dp, 12.dp, 12.dp) @Composable private fun Bubble( - message: Message, + message: RoomEvent, isNotSelf: Boolean, wasPreviousMessageSameSender: Boolean, content: @Composable () -> Unit @@ -256,9 +323,11 @@ private fun Bubble( wasPreviousMessageSameSender -> { Spacer(modifier = Modifier.width(displayImageSize)) } + message.author.avatarUrl == null -> { MissingAvatarIcon(message.author.displayName ?: message.author.id.value, displayImageSize) } + else -> { MessengerUrlIcon(message.author.avatarUrl!!.value, displayImageSize) } @@ -270,13 +339,13 @@ private fun Bubble( } @Composable -private fun TextBubbleContent(shape: RoundedCornerShape, background: Color, isNotSelf: Boolean, message: Message) { +private fun TextBubbleContent(content: BubbleContent) { Box(modifier = Modifier.padding(start = 6.dp)) { Box( Modifier .padding(4.dp) - .clip(shape) - .background(background) + .clip(content.shape) + .background(content.background) .height(IntrinsicSize.Max), ) { Column( @@ -285,16 +354,16 @@ private fun TextBubbleContent(shape: RoundedCornerShape, background: Color, isNo .width(IntrinsicSize.Max) .defaultMinSize(minWidth = 50.dp) ) { - if (isNotSelf) { + if (content.isNotSelf) { Text( fontSize = 11.sp, - text = message.author.displayName ?: message.author.id.value, + text = content.message.author.displayName ?: content.message.author.id.value, maxLines = 1, color = MaterialTheme.colors.onPrimary ) } Text( - text = message.content, + text = content.message.content, color = MaterialTheme.colors.onPrimary, fontSize = 15.sp, modifier = Modifier.wrapContentSize(), @@ -303,15 +372,15 @@ private fun TextBubbleContent(shape: RoundedCornerShape, background: Color, isNo Spacer(modifier = Modifier.height(2.dp)) Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - val editedPrefix = if (message.edited) "(edited) " else null + val editedPrefix = if (content.message.edited) "(edited) " else null Text( fontSize = 9.sp, - text = "${editedPrefix ?: ""}${message.time}", + text = "${editedPrefix ?: ""}${content.message.time}", textAlign = TextAlign.End, color = MaterialTheme.colors.onPrimary, modifier = Modifier.wrapContentSize() ) - SendStatus(message) + SendStatus(content.message) } } } @@ -319,13 +388,13 @@ private fun TextBubbleContent(shape: RoundedCornerShape, background: Color, isNo } @Composable -private fun ReplyBubbleContent(shape: RoundedCornerShape, background: Color, isNotSelf: Boolean, reply: RoomEvent.Reply) { +private fun ReplyBubbleContent(content: BubbleContent) { Box(modifier = Modifier.padding(start = 6.dp)) { Box( Modifier .padding(4.dp) - .clip(shape) - .background(background) + .clip(content.shape) + .background(content.background) .height(IntrinsicSize.Max), ) { Column( @@ -334,55 +403,104 @@ private fun ReplyBubbleContent(shape: RoundedCornerShape, background: Color, isN .width(IntrinsicSize.Max) .defaultMinSize(minWidth = 50.dp) ) { + val context = LocalContext.current + val fetcherFactory = remember { DecryptingFetcherFactory(context) } Column( Modifier - .background(if (isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground) + .background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground) .padding(4.dp) ) { - val replyName = if (!isNotSelf && reply.replyingToSelf) "You" else reply.replyingTo.author.displayName ?: reply.replyingTo.author.id.value + val replyName = if (!content.isNotSelf && content.message.replyingToSelf) "You" else content.message.replyingTo.author.displayName + ?: content.message.replyingTo.author.id.value Text( fontSize = 11.sp, text = replyName, maxLines = 1, color = MaterialTheme.colors.onPrimary ) - Text( - text = reply.replyingTo.content, - color = MaterialTheme.colors.onPrimary, - fontSize = 15.sp, - modifier = Modifier.wrapContentSize(), - textAlign = TextAlign.Start, - ) + when (val replyingTo = content.message.replyingTo) { + is Message -> { + Text( + text = replyingTo.content, + color = MaterialTheme.colors.onPrimary, + fontSize = 15.sp, + modifier = Modifier.wrapContentSize(), + textAlign = TextAlign.Start, + ) + } + + is RoomEvent.Image -> { + Spacer(modifier = Modifier.height(4.dp)) + Image( + modifier = Modifier.size(replyingTo.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .fetcherFactory(fetcherFactory) + .data(replyingTo) + .build() + ), + contentDescription = null, + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + is RoomEvent.Reply -> { + // TODO - a reply to a reply + } + } } Spacer(modifier = Modifier.height(12.dp)) - if (isNotSelf) { + if (content.isNotSelf) { Text( fontSize = 11.sp, - text = reply.message.author.displayName ?: reply.message.author.id.value, + text = content.message.message.author.displayName ?: content.message.message.author.id.value, maxLines = 1, color = MaterialTheme.colors.onPrimary ) } - Text( - text = reply.message.content, - color = MaterialTheme.colors.onPrimary, - fontSize = 15.sp, - modifier = Modifier.wrapContentSize(), - textAlign = TextAlign.Start, - ) + when (val message = content.message.message) { + is Message -> { + Text( + text = message.content, + color = MaterialTheme.colors.onPrimary, + fontSize = 15.sp, + modifier = Modifier.wrapContentSize(), + textAlign = TextAlign.Start, + ) + } + + is RoomEvent.Image -> { + Spacer(modifier = Modifier.height(4.dp)) + Image( + modifier = Modifier.size(message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(content.message) + .fetcherFactory(fetcherFactory) + .build() + ), + contentDescription = null, + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + is RoomEvent.Reply -> { + // TODO - a reply to a reply + } + } Spacer(modifier = Modifier.height(2.dp)) Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( fontSize = 9.sp, - text = reply.time, + text = content.message.time, textAlign = TextAlign.End, color = MaterialTheme.colors.onPrimary, modifier = Modifier.wrapContentSize() ) - SendStatus(reply.message) + SendStatus(content.message.message) } } } @@ -391,11 +509,12 @@ private fun ReplyBubbleContent(shape: RoundedCornerShape, background: Color, isN @Composable -private fun RowScope.SendStatus(message: Message) { +private fun RowScope.SendStatus(message: RoomEvent) { when (val meta = message.meta) { MessageMeta.FromServer -> { // last message is self } + is MessageMeta.LocalEcho -> { when (val state = meta.state) { MessageMeta.LocalEcho.State.Sending, MessageMeta.LocalEcho.State.Sent -> { @@ -412,6 +531,7 @@ private fun RowScope.SendStatus(message: Message) { } } } + is MessageMeta.LocalEcho.State.Error -> { Spacer(modifier = Modifier.width(4.dp)) Box( @@ -429,7 +549,7 @@ private fun RowScope.SendStatus(message: Message) { } @Composable -private fun Composer(message: String, onTextChange: (String) -> Unit, onSend: () -> Unit) { +private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit) { Row( Modifier .fillMaxWidth() @@ -446,25 +566,26 @@ private fun Composer(message: String, onTextChange: (String) -> Unit, onSend: () contentAlignment = Alignment.TopStart, ) { Box(Modifier.padding(14.dp)) { - if (message.isEmpty()) { + if (state.value.isEmpty()) { Text("Message") } BasicTextField( modifier = Modifier.fillMaxWidth(), - value = message, + value = state.value, onValueChange = { onTextChange(it) }, cursorBrush = SolidColor(MaterialTheme.colors.primary), - textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current.copy(LocalContentAlpha.current)) + textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current.copy(LocalContentAlpha.current)), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true) ) } } Spacer(modifier = Modifier.width(6.dp)) var size by remember { mutableStateOf(IntSize(0, 0)) } IconButton( - enabled = message.isNotEmpty(), + enabled = state.value.isNotEmpty(), modifier = Modifier .clip(CircleShape) - .background(if (message.isEmpty()) Color.DarkGray else MaterialTheme.colors.primary) + .background(if (state.value.isEmpty()) Color.DarkGray else MaterialTheme.colors.primary) .run { if (size.height == 0 || size.width == 0) { this @@ -488,3 +609,36 @@ private fun Composer(message: String, onTextChange: (String) -> Unit, onSend: () } } } + +@Composable +private fun AttachmentComposer(state: ComposerState.Attachments, onSend: () -> Unit, onCancel: () -> Unit) { + Box(modifier = Modifier.fillMaxSize()) { + val context = LocalContext.current + Image( + modifier = Modifier.fillMaxHeight().wrapContentWidth().align(Alignment.Center), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(state.values.first().uri.value.toUri()) + .build() + ), + contentDescription = null, + ) + + Box(Modifier.align(Alignment.BottomEnd).padding(12.dp)) { + IconButton( + enabled = true, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colors.primary), + onClick = onSend, + ) { + Icon( + imageVector = Icons.Filled.Send, + contentDescription = "", + tint = MaterialTheme.colors.onPrimary, + ) + } + } + } +} + diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt index 80e1aa1..17bcd11 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt @@ -2,6 +2,7 @@ package app.dapk.st.messenger import app.dapk.st.core.Lce import app.dapk.st.matrix.common.RoomId +import app.dapk.st.navigator.MessageAttachment data class MessengerScreenState( val roomId: RoomId?, @@ -17,4 +18,8 @@ sealed interface ComposerState { val value: String, ) : ComposerState + data class Attachments( + val values: List, + ) : ComposerState + } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt index 6fd6f81..86930b0 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt @@ -4,92 +4,137 @@ import androidx.lifecycle.viewModelScope import app.dapk.st.core.Lce import app.dapk.st.core.extensions.takeIfContent import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomStore -import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.navigator.MessageAttachment import app.dapk.st.viewmodel.DapkViewModel -import kotlinx.coroutines.Job +import app.dapk.st.viewmodel.MutableStateFactory +import app.dapk.st.viewmodel.defaultStateFactory +import kotlinx.coroutines.* import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch +import java.time.Clock -class MessengerViewModel( - syncService: SyncService, +internal class MessengerViewModel( private val messageService: MessageService, private val roomService: RoomService, private val roomStore: RoomStore, private val credentialsStore: CredentialsStore, + private val observeTimeline: ObserveTimelineUseCase, + private val localIdFactory: LocalIdFactory, + private val clock: Clock, + factory: MutableStateFactory = defaultStateFactory(), ) : DapkViewModel( initialState = MessengerScreenState( roomId = null, roomState = Lce.Loading(), composerState = ComposerState.Text(value = "") - ) + ), + factory = factory, ) { private var syncJob: Job? = null - private val useCase: TimelineUseCase = TimelineUseCase(syncService, messageService, roomService, MergeWithLocalEchosUseCaseImpl()) fun post(action: MessengerAction) { when (action) { - is MessengerAction.ComposerTextUpdate -> { - updateState { copy(composerState = ComposerState.Text(action.newValue)) } - } - is MessengerAction.OnMessengerVisible -> { - updateState { copy(roomId = action.roomId) } + is MessengerAction.OnMessengerVisible -> start(action) + MessengerAction.OnMessengerGone -> syncJob?.cancel() + is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue)) } + MessengerAction.ComposerSendText -> sendMessage() + MessengerAction.ComposerClear -> updateState { copy(composerState = ComposerState.Text("")) } + } + } - syncJob = viewModelScope.launch { - useCase.startSyncing().collect() - } - viewModelScope.launch { - roomStore.markRead(action.roomId) + private fun start(action: MessengerAction.OnMessengerVisible) { + updateState { copy(roomId = action.roomId, composerState = action.attachments?.let { ComposerState.Attachments(it) } ?: composerState) } + syncJob = viewModelScope.launch { + roomStore.markRead(action.roomId) - val credentials = credentialsStore.credentials()!! - useCase.state(action.roomId, credentials.userId).distinctUntilChanged().onEach { state -> - state.roomState.events.filterIsInstance().filterNot { it.author.id == credentials.userId }.firstOrNull()?.let { - roomService.markFullyRead(state.roomState.roomOverview.roomId, it.eventId) - roomStore.markRead(state.roomState.roomOverview.roomId) - } - updateState { copy(roomState = Lce.Content(state)) } - }.collect() - } - } - MessengerAction.OnMessengerGone -> { - syncJob?.cancel() - } - MessengerAction.ComposerSendText -> { - when (val composerState = state.composerState) { - is ComposerState.Text -> { - val copy = composerState.copy() - updateState { copy(composerState = composerState.copy(value = "")) } - - state.roomState.takeIfContent()?.let { content -> - val roomState = content.roomState - viewModelScope.launch { - messageService.scheduleMessage( - MessageService.Message.TextMessage( - MessageService.Message.Content.TextContent(body = copy.value), - roomId = roomState.roomOverview.roomId, - sendEncrypted = roomState.roomOverview.isEncrypted, - ) - ) - } - } + val credentials = credentialsStore.credentials()!! + var lastKnownReadEvent: EventId? = null + observeTimeline.invoke(action.roomId, credentials.userId).distinctUntilChanged().onEach { state -> + state.latestMessageEventFromOthers(self = credentials.userId)?.let { + if (lastKnownReadEvent != it) { + updateRoomReadStateAsync(latestReadEvent = it, state) + lastKnownReadEvent = it } } + updateState { copy(roomState = Lce.Content(state)) } + }.collect() + } + } + + private fun CoroutineScope.updateRoomReadStateAsync(latestReadEvent: EventId, state: MessengerState): Deferred { + return async { + runCatching { + roomService.markFullyRead(state.roomState.roomOverview.roomId, latestReadEvent) + roomStore.markRead(state.roomState.roomOverview.roomId) + } + } + } + + private fun sendMessage() { + when (val composerState = state.composerState) { + is ComposerState.Text -> { + val copy = composerState.copy() + updateState { copy(composerState = composerState.copy(value = "")) } + + state.roomState.takeIfContent()?.let { content -> + val roomState = content.roomState + viewModelScope.launch { + messageService.scheduleMessage( + MessageService.Message.TextMessage( + MessageService.Message.Content.TextContent(body = copy.value), + roomId = roomState.roomOverview.roomId, + sendEncrypted = roomState.roomOverview.isEncrypted, + localId = localIdFactory.create(), + timestampUtc = clock.millis(), + ) + ) + } + } + } + is ComposerState.Attachments -> { + val copy = composerState.copy() + updateState { copy(composerState = ComposerState.Text("")) } + + state.roomState.takeIfContent()?.let { content -> + val roomState = content.roomState + viewModelScope.launch { + messageService.scheduleMessage( + MessageService.Message.ImageMessage( + MessageService.Message.Content.ApiImageContent(uri = copy.values.first().uri.value), + roomId = roomState.roomOverview.roomId, + sendEncrypted = roomState.roomOverview.isEncrypted, + localId = localIdFactory.create(), + timestampUtc = clock.millis(), + ) + ) + } + } + } } } } +private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roomState.events + .filterIsInstance() + .filterNot { it.author.id == self } + .firstOrNull() + ?.eventId + sealed interface MessengerAction { data class ComposerTextUpdate(val newValue: String) : MessengerAction object ComposerSendText : MessengerAction - data class OnMessengerVisible(val roomId: RoomId) : MessengerAction + object ComposerClear : MessengerAction + data class OnMessengerVisible(val roomId: RoomId, val attachments: List?) : MessengerAction object OnMessengerGone : MessengerAction } \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt index 854e2cb..c117f39 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt @@ -9,38 +9,34 @@ import app.dapk.st.matrix.sync.RoomState import app.dapk.st.matrix.sync.SyncService import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart -data class MessengerState( - val self: UserId, - val roomState: RoomState, - val typing: SyncService.SyncEvent.Typing? -) +internal typealias ObserveTimelineUseCase = (RoomId, UserId) -> Flow -internal class TimelineUseCase( +internal class TimelineUseCaseImpl( private val syncService: SyncService, private val messageService: MessageService, private val roomService: RoomService, private val mergeWithLocalEchosUseCase: MergeWithLocalEchosUseCase -) { +) : ObserveTimelineUseCase { - suspend fun startSyncing(): Flow { - return syncService.startSyncing() - } - - suspend fun state(roomId: RoomId, userId: UserId): Flow { - return combine(syncService.room(roomId), messageService.localEchos(roomId), syncService.events()) { roomState, localEchos, events -> + override fun invoke(roomId: RoomId, userId: UserId): Flow { + return combine( + roomDatasource(roomId), + messageService.localEchos(roomId), + syncService.events() + ) { roomState, localEchos, events -> MessengerState( roomState = when { localEchos.isEmpty() -> roomState - else -> mergeWithLocalEchosUseCase.invoke( - roomState, - roomService.findMember(roomId, userId) ?: RoomMember( - userId, - null, - avatarUrl = null, - ), - localEchos, - ) + else -> { + mergeWithLocalEchosUseCase.invoke( + roomState, + roomService.findMember(roomId, userId) ?: userId.toFallbackMember(), + localEchos, + ) + } }, typing = events.filterIsInstance().firstOrNull { it.roomId == roomId }, self = userId, @@ -48,4 +44,16 @@ internal class TimelineUseCase( } } + private fun roomDatasource(roomId: RoomId) = combine( + syncService.startSyncing().map { false }.onStart { emit(true) }, + syncService.room(roomId) + ) { _, room -> room } } + +private fun UserId.toFallbackMember() = RoomMember(this, displayName = null, avatarUrl = null) + +data class MessengerState( + val self: UserId, + val roomState: RoomState, + val typing: SyncService.SyncEvent.Typing? +) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt index 79f8fe9..fe564b8 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt @@ -36,7 +36,7 @@ class RoomSettingsActivity : DapkActivity() { setContent { SmallTalkTheme { Surface(Modifier.fillMaxSize()) { - MessengerScreen(RoomId(payload.roomId), viewModel, navigator) +// MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator) } } } diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/LocalEchoMapperTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/LocalEchoMapperTest.kt new file mode 100644 index 0000000..29b8e59 --- /dev/null +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/LocalEchoMapperTest.kt @@ -0,0 +1,69 @@ +package app.dapk.st.messenger + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.sync.MessageMeta +import fixture.* +import internalfake.FakeMetaMapper +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val A_META = MessageMeta.LocalEcho("echo-id", MessageMeta.LocalEcho.State.Sent) +private val AN_ECHO_CONTENT = aTextMessage(localId = "a-local-id") +private val A_ROOM_MEMBER = aRoomMember() + +class LocalEchoMapperTest { + + private val fakeMetaMapper = FakeMetaMapper() + + private val localEchoMapper = LocalEchoMapper(fakeMetaMapper.instance) + + @Test + fun `given echo with event id when mapping to message then uses event id`() = runWith(localEchoMapper) { + val echo = givenEcho(eventId = anEventId("a-known-id")) + + val result = echo.toMessage(A_ROOM_MEMBER) + + result shouldBeEqualTo aRoomMessageEvent( + eventId = echo.eventId!!, + content = AN_ECHO_CONTENT.content.body, + meta = A_META + ) + } + + @Test + fun `given echo without event id when mapping to message then uses local id`() = runWith(localEchoMapper) { + val echo = givenEcho(eventId = null, localId = "a-local-id") + + val result = echo.toMessage(A_ROOM_MEMBER) + + result shouldBeEqualTo aRoomMessageEvent( + eventId = anEventId(echo.localId), + content = AN_ECHO_CONTENT.content.body, + meta = A_META + ) + } + + @Test + fun `when merging with echo then updates meta with the echos meta`() = runWith(localEchoMapper) { + val previousMeta = MessageMeta.LocalEcho("previous", MessageMeta.LocalEcho.State.Sending) + val event = aRoomMessageEvent(meta = previousMeta) + val echo = aLocalEcho() + fakeMetaMapper.given(echo).returns(A_META) + + val result = event.mergeWith(echo) + + result shouldBeEqualTo aRoomMessageEvent(meta = A_META) + } + + private fun givenEcho(eventId: EventId? = null, localId: String = "", meta: MessageMeta.LocalEcho = A_META): MessageService.LocalEcho { + return aLocalEcho(eventId = eventId, message = aTextMessage(localId = localId)).also { + fakeMetaMapper.given(it).returns(meta) + } + } +} + + +fun runWith(context: T, block: T.() -> Unit) { + block(context) +} diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCaseTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCaseTest.kt new file mode 100644 index 0000000..f5bcdaf --- /dev/null +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCaseTest.kt @@ -0,0 +1,63 @@ +package app.dapk.st.messenger + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.message.MessageService +import fixture.* +import internalfake.FakeLocalEventMapper +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val A_ROOM_MESSAGE_EVENT = aRoomMessageEvent(eventId = anEventId("1")) +private val A_ROOM_IMAGE_MESSAGE_EVENT = aRoomMessageEvent(eventId = anEventId("2")) +private val A_LOCAL_ECHO_EVENT_ID = anEventId("2") +private const val A_LOCAL_ECHO_BODY = "body" +private val A_ROOM_MEMBER = aRoomMember() +private val ANOTHER_ROOM_MESSAGE_EVENT = A_ROOM_MESSAGE_EVENT.copy(eventId = anEventId("a second room event")) + +class MergeWithLocalEchosUseCaseTest { + + private val fakeLocalEchoMapper = FakeLocalEventMapper() + private val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(fakeLocalEchoMapper.instance) + + @Test + fun `given no local echos, when merging text message, then returns original state`() { + val roomState = aRoomState(events = listOf(A_ROOM_MESSAGE_EVENT)) + + val result = mergeWithLocalEchosUseCase.invoke(roomState, A_ROOM_MEMBER, emptyList()) + + result shouldBeEqualTo roomState + } + + @Test + fun `given no local echos, when merging events, then returns original ordered by timestamp descending`() { + val roomState = aRoomState(events = listOf(A_ROOM_IMAGE_MESSAGE_EVENT.copy(utcTimestamp = 1500), A_ROOM_MESSAGE_EVENT.copy(utcTimestamp = 1000))) + + val result = mergeWithLocalEchosUseCase.invoke(roomState, A_ROOM_MEMBER, emptyList()) + + result shouldBeEqualTo roomState.copy(events = roomState.events.sortedByDescending { it.utcTimestamp }) + } + + @Test + fun `given local echo with sending state, when merging then maps to room event with local echo state`() { + val second = createLocalEcho(A_LOCAL_ECHO_EVENT_ID, A_LOCAL_ECHO_BODY, state = MessageService.LocalEcho.State.Sending) + fakeLocalEchoMapper.givenMapping(second, A_ROOM_MEMBER).returns(ANOTHER_ROOM_MESSAGE_EVENT) + val roomState = aRoomState(events = listOf(A_ROOM_MESSAGE_EVENT)) + + val result = mergeWithLocalEchosUseCase.invoke(roomState, A_ROOM_MEMBER, listOf(second)) + + result shouldBeEqualTo roomState.copy( + events = listOf( + A_ROOM_MESSAGE_EVENT, + ANOTHER_ROOM_MESSAGE_EVENT, + ) + ) + } + + private fun createLocalEcho(eventId: EventId, body: String, state: MessageService.LocalEcho.State) = aLocalEcho( + eventId, + aTextMessage(aTextContent(body)), + state, + ) +} diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt new file mode 100644 index 0000000..bca16fe --- /dev/null +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt @@ -0,0 +1,152 @@ +package app.dapk.st.messenger + +import ViewModelTest +import app.dapk.st.core.Lce +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.room.RoomService +import app.dapk.st.matrix.sync.RoomState +import app.dapk.st.matrix.sync.SyncService +import fake.FakeCredentialsStore +import fake.FakeRoomStore +import fixture.* +import internalfake.FakeLocalIdFactory +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import org.junit.Test +import test.delegateReturn +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +private const val A_CURRENT_TIMESTAMP = 10000L +private val A_ROOM_ID = aRoomId("messenger state room id") +private const val A_MESSAGE_CONTENT = "message content" +private const val A_LOCAL_ID = "local.1111-2222-3333" +private val AN_EVENT_ID = anEventId("state event") +private val A_SELF_ID = aUserId("self") + +class MessengerViewModelTest { + + private val runViewModelTest = ViewModelTest() + + private val fakeMessageService = FakeMessageService() + private val fakeRoomService = FakeRoomService() + private val fakeRoomStore = FakeRoomStore() + private val fakeCredentialsStore = FakeCredentialsStore().also { it.givenCredentials().returns(aUserCredentials(userId = A_SELF_ID)) } + private val fakeObserveTimelineUseCase = FakeObserveTimelineUseCase() + + private val viewModel = MessengerViewModel( + fakeMessageService, + fakeRoomService, + fakeRoomStore, + fakeCredentialsStore, + fakeObserveTimelineUseCase, + localIdFactory = FakeLocalIdFactory().also { it.givenCreate().returns(A_LOCAL_ID) }.instance, + clock = fixedClock(A_CURRENT_TIMESTAMP), + factory = runViewModelTest.testMutableStateFactory(), + ) + + @Test + fun `when creating view model, then initial state is loading room state`() = runViewModelTest { + viewModel.test() + + assertInitialState( + MessengerScreenState( + roomId = null, + roomState = Lce.Loading(), + composerState = ComposerState.Text(value = "") + ) + ) + } + + @Test + fun `given timeline emits state, when starting, then updates state and marks room and events as read`() = runViewModelTest { + fakeRoomStore.expectUnit(times = 2) { it.markRead(A_ROOM_ID) } + fakeRoomService.expectUnit { it.markFullyRead(A_ROOM_ID, AN_EVENT_ID) } + val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID) + fakeObserveTimelineUseCase.given(A_ROOM_ID, A_SELF_ID).returns(flowOf(state)) + + viewModel.test().post(MessengerAction.OnMessengerVisible(A_ROOM_ID, attachments = null)) + + assertStates( + { copy(roomId = A_ROOM_ID) }, + { copy(roomState = Lce.Content(state)) }, + ) + verifyExpects() + } + + @Test + fun `when posting composer update, then updates state`() = runViewModelTest { + viewModel.test().post(MessengerAction.ComposerTextUpdate(A_MESSAGE_CONTENT)) + + assertStates({ + copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT)) + }) + } + + @Test + fun `given composer message state when posting send text, then resets composer state and sends message`() = runViewModelTest { + fakeMessageService.expectUnit { it.scheduleMessage(expectEncryptedMessage(A_ROOM_ID, A_LOCAL_ID, A_CURRENT_TIMESTAMP, A_MESSAGE_CONTENT)) } + + viewModel.test(initialState = initialStateWithComposerMessage(A_ROOM_ID, A_MESSAGE_CONTENT)).post(MessengerAction.ComposerSendText) + + assertStates({ copy(composerState = ComposerState.Text("")) }) + verifyExpects() + } + + private fun initialStateWithComposerMessage(roomId: RoomId, messageContent: String): MessengerScreenState { + val roomState = RoomState( + aRoomOverview(roomId = roomId, isEncrypted = true), + listOf(anEncryptedRoomMessageEvent(utcTimestamp = 1)) + ) + return aMessageScreenState(roomId, aMessengerState(roomState = roomState), messageContent) + } + + private fun expectEncryptedMessage(roomId: RoomId, localId: String, timestamp: Long, messageContent: String): MessageService.Message.TextMessage { + val content = MessageService.Message.Content.TextContent(body = messageContent) + return MessageService.Message.TextMessage(content, sendEncrypted = true, roomId, localId, timestamp) + } + + private fun aMessengerStateWithEvent(eventId: EventId, selfId: UserId) = aRoomStateWithEventId(eventId).toMessengerState(selfId) + + private fun RoomState.toMessengerState(selfId: UserId) = aMessengerState(self = selfId, roomState = this) + + private fun aRoomStateWithEventId(eventId: EventId): RoomState { + val element = anEncryptedRoomMessageEvent(eventId = eventId, utcTimestamp = 1) + return RoomState(aRoomOverview(roomId = A_ROOM_ID, isEncrypted = true), listOf(element)) + } + +} + +fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState( + roomId = roomId, + roomState = Lce.Content(roomState), + composerState = ComposerState.Text(value = messageContent ?: "") +) + +fun aMessengerState( + self: UserId = aUserId(), + roomState: RoomState, + typing: SyncService.SyncEvent.Typing? = null +) = MessengerState(self, roomState, typing) + +class FakeObserveTimelineUseCase : ObserveTimelineUseCase by mockk() { + fun given(roomId: RoomId, selfId: UserId) = coEvery { this@FakeObserveTimelineUseCase.invoke(roomId, selfId) }.delegateReturn() +} + +class FakeMessageService : MessageService by mockk() { + + fun givenEchos(roomId: RoomId) = every { localEchos(roomId) }.delegateReturn() + +} + +class FakeRoomService : RoomService by mockk() { + fun givenFindMember(roomId: RoomId, userId: UserId) = coEvery { findMember(roomId, userId) }.delegateReturn() +} + +fun fixedClock(timestamp: Long = 0) = Clock.fixed(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MetaMapperTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MetaMapperTest.kt new file mode 100644 index 0000000..27419ce --- /dev/null +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MetaMapperTest.kt @@ -0,0 +1,60 @@ +package app.dapk.st.messenger + +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.sync.MessageMeta +import fixture.aLocalEcho +import fixture.aTextMessage +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private const val A_LOCAL_ECHO_ID = "a-local-echo-id" + +class MetaMapperTest { + + private val metaMapper = MetaMapper() + + @Test + fun `given echo with sending meta then maps to sending state`() { + val result = metaMapper.toMeta( + aLocalEcho( + state = MessageService.LocalEcho.State.Sending, + message = aTextMessage(localId = A_LOCAL_ECHO_ID) + ) + ) + + result shouldBeEqualTo MessageMeta.LocalEcho( + echoId = A_LOCAL_ECHO_ID, + state = MessageMeta.LocalEcho.State.Sending + ) + } + + @Test + fun `given echo with sent meta then maps to sent state`() { + val result = metaMapper.toMeta( + aLocalEcho( + state = MessageService.LocalEcho.State.Sent, + message = aTextMessage(localId = A_LOCAL_ECHO_ID) + ) + ) + + result shouldBeEqualTo MessageMeta.LocalEcho( + echoId = A_LOCAL_ECHO_ID, + state = MessageMeta.LocalEcho.State.Sent + ) + } + + @Test + fun `given echo with error meta then maps to error state`() { + val result = metaMapper.toMeta( + aLocalEcho( + state = MessageService.LocalEcho.State.Error("an error", MessageService.LocalEcho.State.Error.Type.UNKNOWN), + message = aTextMessage(localId = A_LOCAL_ECHO_ID) + ) + ) + + result shouldBeEqualTo MessageMeta.LocalEcho( + echoId = A_LOCAL_ECHO_ID, + state = MessageMeta.LocalEcho.State.Error("an error", MessageMeta.LocalEcho.State.Error.Type.UNKNOWN) + ) + } +} \ No newline at end of file diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/TimelineUseCaseTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/TimelineUseCaseTest.kt new file mode 100644 index 0000000..8f5447f --- /dev/null +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/TimelineUseCaseTest.kt @@ -0,0 +1,114 @@ +package app.dapk.st.messenger + +import FlowTestObserver +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.sync.RoomState +import app.dapk.st.matrix.sync.SyncService +import fake.FakeSyncService +import fixture.* +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import test.delegateReturn + +private val A_ROOM_ID = aRoomId() +private val AN_USER_ID = aUserId() +private val A_ROOM_STATE = aRoomState() +private val A_MERGED_ROOM_STATE = A_ROOM_STATE.copy(events = listOf(aRoomMessageEvent(content = "a merged event"))) +private val A_LOCAL_ECHOS_LIST = listOf(aLocalEcho()) +private val A_ROOM_MEMBER = aRoomMember() + +class TimelineUseCaseTest { + + private val fakeSyncService = FakeSyncService() + private val fakeMessageService = FakeMessageService() + private val fakeRoomService = FakeRoomService() + private val fakeMergeWithLocalEchosUseCase = FakeMergeWithLocalEchosUseCase() + + private val timelineUseCase = TimelineUseCaseImpl( + fakeSyncService, + fakeMessageService, + fakeRoomService, + fakeMergeWithLocalEchosUseCase, + ) + + @Test + fun `when observing timeline, then emits sync emission`() = runTest { + givenSyncEmission(roomState = A_ROOM_STATE) + + timelineUseCase.invoke(A_ROOM_ID, AN_USER_ID) + .test(this) + .assertValues( + listOf( + aMessengerState(self = AN_USER_ID, roomState = A_ROOM_STATE) + ) + ) + } + + @Test + fun `given local echos, when observing timeline, then merges room and local echos`() = runTest { + givenSyncEmission(roomState = A_ROOM_STATE, echos = A_LOCAL_ECHOS_LIST) + fakeRoomService.givenFindMember(A_ROOM_ID, AN_USER_ID).returns(A_ROOM_MEMBER) + + fakeMergeWithLocalEchosUseCase.givenMerging(A_ROOM_STATE, A_ROOM_MEMBER, A_LOCAL_ECHOS_LIST).returns(A_MERGED_ROOM_STATE) + + timelineUseCase.invoke(A_ROOM_ID, AN_USER_ID) + .test(this) + .assertValues( + listOf( + aMessengerState(self = AN_USER_ID, roomState = A_MERGED_ROOM_STATE) + ) + ) + } + + @Test + fun `given sync events from current and other rooms, when observing timeline, then filters by current room`() = runTest { + givenSyncEmission( + events = listOf( + aTypingSyncEvent(aRoomId("another room"), members = listOf(A_ROOM_MEMBER)), + aTypingSyncEvent(A_ROOM_ID, members = listOf(A_ROOM_MEMBER)), + ) + ) + + timelineUseCase.invoke(A_ROOM_ID, AN_USER_ID) + .test(this) + .assertValues( + listOf( + aMessengerState(self = AN_USER_ID, roomState = A_ROOM_STATE, typing = aTypingSyncEvent(A_ROOM_ID, members = listOf(A_ROOM_MEMBER))) + ) + ) + } + + private fun givenSyncEmission( + roomState: RoomState = A_ROOM_STATE, + echos: List = emptyList(), + events: List = emptyList() + ) { + fakeSyncService.givenStartsSyncing() + fakeSyncService.givenRoom(A_ROOM_ID).returns(flowOf(roomState)) + fakeMessageService.givenEchos(A_ROOM_ID).returns(flowOf(echos)) + fakeSyncService.givenEvents(A_ROOM_ID).returns(flowOf(events)) + } +} + +suspend fun Flow.test(scope: CoroutineScope) = FlowTestObserver(scope, this).also { + this.collect() +} + +class FakeMergeWithLocalEchosUseCase : MergeWithLocalEchosUseCase by mockk() { + fun givenMerging(roomState: RoomState, roomMember: RoomMember, echos: List) = every { + this@FakeMergeWithLocalEchosUseCase.invoke(roomState, roomMember, echos) + }.delegateReturn() +} + +fun aTypingSyncEvent( + roomId: RoomId = aRoomId(), + members: List = listOf(aRoomMember()) +) = SyncService.SyncEvent.Typing(roomId, members) \ No newline at end of file diff --git a/features/messenger/src/test/kotlin/internalfake/FakeLocalEventMapper.kt b/features/messenger/src/test/kotlin/internalfake/FakeLocalEventMapper.kt new file mode 100644 index 0000000..7e3d6e1 --- /dev/null +++ b/features/messenger/src/test/kotlin/internalfake/FakeLocalEventMapper.kt @@ -0,0 +1,14 @@ +package internalfake + +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.messenger.LocalEchoMapper +import io.mockk.every +import io.mockk.mockk + +internal class FakeLocalEventMapper { + val instance = mockk() + fun givenMapping(echo: MessageService.LocalEcho, roomMember: RoomMember) = every { + with(instance) { echo.toMessage(roomMember) } + } +} \ No newline at end of file diff --git a/features/messenger/src/test/kotlin/internalfake/FakeLocalIdFactory.kt b/features/messenger/src/test/kotlin/internalfake/FakeLocalIdFactory.kt new file mode 100644 index 0000000..6b70cfb --- /dev/null +++ b/features/messenger/src/test/kotlin/internalfake/FakeLocalIdFactory.kt @@ -0,0 +1,11 @@ +package internalfake + +import app.dapk.st.messenger.LocalIdFactory +import io.mockk.every +import io.mockk.mockk +import test.delegateReturn + +internal class FakeLocalIdFactory { + val instance = mockk() + fun givenCreate() = every { instance.create() }.delegateReturn() +} \ No newline at end of file diff --git a/features/messenger/src/test/kotlin/internalfake/FakeMetaMapper.kt b/features/messenger/src/test/kotlin/internalfake/FakeMetaMapper.kt new file mode 100644 index 0000000..6762d99 --- /dev/null +++ b/features/messenger/src/test/kotlin/internalfake/FakeMetaMapper.kt @@ -0,0 +1,12 @@ +package internalfake + +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.messenger.MetaMapper +import io.mockk.every +import io.mockk.mockk +import test.delegateReturn + +internal class FakeMetaMapper { + val instance = mockk() + fun given(echo: MessageService.LocalEcho) = every { instance.toMeta(echo) }.delegateReturn() +} \ No newline at end of file diff --git a/features/navigator/build.gradle b/features/navigator/build.gradle index c6b5dba..6c97c96 100644 --- a/features/navigator/build.gradle +++ b/features/navigator/build.gradle @@ -1,6 +1,8 @@ applyAndroidLibraryModule(project) +apply plugin: 'kotlin-parcelize' dependencies { + compileOnly project(":domains:android:stub") implementation project(":core") implementation project(":matrix:common") } \ No newline at end of file diff --git a/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt b/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt index 28f7287..1e6efea 100644 --- a/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt +++ b/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt @@ -1,8 +1,16 @@ package app.dapk.st.navigator import android.app.Activity +import android.app.PendingIntent +import android.content.Context import android.content.Intent +import android.os.Parcel +import android.os.Parcelable +import app.dapk.st.core.AndroidUri +import app.dapk.st.core.MimeType import app.dapk.st.matrix.common.RoomId +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty @@ -28,6 +36,11 @@ interface Navigator { activity.navigateUpTo(intentFactory.home(activity)) } + fun toMessenger(roomId: RoomId, attachments: List) { + val intent = intentFactory.messengerAttachments(activity, roomId, attachments) + activity.startActivity(intent) + } + fun toFilePicker(requestCode: Int) { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) @@ -39,10 +52,12 @@ interface Navigator { interface IntentFactory { - fun home(activity: Activity): Intent - fun messenger(activity: Activity, roomId: RoomId): Intent - fun messengerShortcut(activity: Activity, roomId: RoomId): Intent - + fun notificationOpenApp(context: Context): PendingIntent + fun notificationOpenMessage(context: Context, roomId: RoomId): PendingIntent + fun home(context: Context): Intent + fun messenger(context: Context, roomId: RoomId): Intent + fun messengerShortcut(context: Context, roomId: RoomId): Intent + fun messengerAttachments(context: Context, roomId: RoomId, attachments: List): Intent } @@ -61,3 +76,24 @@ private class DefaultNavigator(activity: Activity, intentFactory: IntentFactory) override val navigate: Navigator.Dsl = Navigator.Dsl(activity, intentFactory) } +@Parcelize +data class MessageAttachment(val uri: AndroidUri, val type: MimeType) : Parcelable { + private companion object : Parceler { + override fun create(parcel: Parcel): MessageAttachment { + val uri = AndroidUri(parcel.readString()!!) + val type = when(parcel.readString()!!) { + "mimetype-image" -> MimeType.Image + else -> throw IllegalStateException() + } + return MessageAttachment(uri, type) + } + + override fun MessageAttachment.write(parcel: Parcel, flags: Int) { + parcel.writeString(uri.value) + when (type) { + MimeType.Image -> parcel.writeString("mimetype-image") + } + } + } + +} \ No newline at end of file diff --git a/features/notifications/build.gradle b/features/notifications/build.gradle index 6ccaa59..377a496 100644 --- a/features/notifications/build.gradle +++ b/features/notifications/build.gradle @@ -4,12 +4,22 @@ dependencies { implementation project(":matrix:services:push") implementation project(":matrix:services:sync") implementation project(':domains:store') + implementation project(":domains:android:work") implementation project(':domains:android:push') implementation project(":domains:android:core") implementation project(":core") implementation project(":domains:android:imageloader") implementation project(":features:messenger") + implementation project(":features:navigator") implementation platform('com.google.firebase:firebase-bom:29.0.3') implementation 'com.google.firebase:firebase-messaging' + implementation Dependencies.mavenCentral.kotlinSerializationJson + + kotlinTest(it) + + androidImportFixturesWorkaround(project, project(":core")) + androidImportFixturesWorkaround(project, project(":matrix:common")) + androidImportFixturesWorkaround(project, project(":matrix:services:sync")) + androidImportFixturesWorkaround(project, project(":domains:android:stub")) } \ No newline at end of file diff --git a/features/notifications/src/main/AndroidManifest.xml b/features/notifications/src/main/AndroidManifest.xml index 11acb21..e76fe66 100644 --- a/features/notifications/src/main/AndroidManifest.xml +++ b/features/notifications/src/main/AndroidManifest.xml @@ -1,14 +1,2 @@ - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt new file mode 100644 index 0000000..6f49daa --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt @@ -0,0 +1,75 @@ +package app.dapk.st.notifications + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.graphics.drawable.Icon +import app.dapk.st.core.DeviceMeta +import app.dapk.st.core.isAtLeastO +import app.dapk.st.core.onAtLeastO + +@SuppressLint("NewApi") +@Suppress("ObjectPropertyName") +private val _builderFactory: (Context, String, DeviceMeta) -> Notification.Builder = { context, channel, deviceMeta -> + deviceMeta.isAtLeastO( + block = { Notification.Builder(context, channel) }, + fallback = { Notification.Builder(context) } + ) +} + +class AndroidNotificationBuilder( + private val context: Context, + private val deviceMeta: DeviceMeta, + private val notificationStyleBuilder: AndroidNotificationStyleBuilder, + private val builderFactory: (Context, String, DeviceMeta) -> Notification.Builder = _builderFactory +) { + @SuppressLint("NewApi") + fun build(notification: AndroidNotification): Notification { + return builder(notification.channelId) + .apply { setOnlyAlertOnce(!notification.alertMoreThanOnce) } + .apply { setAutoCancel(notification.autoCancel) } + .apply { setGroupSummary(notification.isGroupSummary) } + .ifNotNull(notification.groupId) { setGroup(it) } + .ifNotNull(notification.messageStyle) { style = it.build(notificationStyleBuilder) } + .ifNotNull(notification.contentIntent) { setContentIntent(it) } + .ifNotNull(notification.whenTimestamp) { + setShowWhen(true) + setWhen(it) + } + .ifNotNull(notification.category) { setCategory(it) } + .ifNotNull(notification.shortcutId) { + deviceMeta.onAtLeastO { setShortcutId(notification.shortcutId) } + } + .ifNotNull(notification.smallIcon) { setSmallIcon(it) } + .ifNotNull(notification.largeIcon) { setLargeIcon(it) } + .build() + } + + private fun Notification.Builder.ifNotNull(value: T?, action: Notification.Builder.(T) -> Unit): Notification.Builder { + if (value != null) { + action(value) + } + return this + } + + private fun builder(channel: String) = builderFactory(context, channel, deviceMeta) +} + +data class AndroidNotification( + val channelId: String, + val whenTimestamp: Long? = null, + val isGroupSummary: Boolean = false, + val groupId: String? = null, + val groupAlertBehavior: Int? = null, + val shortcutId: String? = null, + val alertMoreThanOnce: Boolean, + val contentIntent: PendingIntent? = null, + val messageStyle: AndroidNotificationStyle? = null, + val category: String? = null, + val smallIcon: Int? = null, + val largeIcon: Icon? = null, + val autoCancel: Boolean = true, +) { + fun build(builder: AndroidNotificationBuilder) = builder.build(this) +} diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyle.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyle.kt new file mode 100644 index 0000000..05dd95e --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyle.kt @@ -0,0 +1,30 @@ +package app.dapk.st.notifications + +import android.app.Notification +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.RequiresApi + +sealed interface AndroidNotificationStyle { + + fun build(builder: AndroidNotificationStyleBuilder): Notification.Style + + data class Inbox(val lines: List, val summary: String? = null) : AndroidNotificationStyle { + override fun build(builder: AndroidNotificationStyleBuilder) = builder.build(this) + } + + data class Messaging( + val person: AndroidPerson, + val title: String?, + val isGroup: Boolean, + val content: List, + ) : AndroidNotificationStyle { + + @RequiresApi(Build.VERSION_CODES.P) + override fun build(builder: AndroidNotificationStyleBuilder) = builder.build(this) + + data class AndroidPerson(val name: String, val key: String, val icon: Icon? = null) + data class AndroidMessage(val sender: AndroidPerson, val content: String, val timestamp: Long) + } + +} \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilder.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilder.kt new file mode 100644 index 0000000..ec91fba --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilder.kt @@ -0,0 +1,49 @@ +package app.dapk.st.notifications + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.Notification.InboxStyle +import android.app.Notification.MessagingStyle +import android.app.Person +import android.os.Build +import androidx.annotation.RequiresApi + +@SuppressLint("NewApi") +class AndroidNotificationStyleBuilder( + private val personBuilderFactory: () -> Person.Builder = { Person.Builder() }, + private val inboxStyleFactory: () -> InboxStyle = { InboxStyle() }, + private val messagingStyleFactory: (Person) -> MessagingStyle = { MessagingStyle(it) }, +) { + + fun build(style: AndroidNotificationStyle): Notification.Style { + return when (style) { + is AndroidNotificationStyle.Inbox -> style.buildInboxStyle() + is AndroidNotificationStyle.Messaging -> style.buildMessagingStyle() + } + } + + private fun AndroidNotificationStyle.Inbox.buildInboxStyle() = inboxStyleFactory().also { inboxStyle -> + lines.forEach { inboxStyle.addLine(it) } + inboxStyle.setSummaryText(summary) + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun AndroidNotificationStyle.Messaging.buildMessagingStyle() = messagingStyleFactory( + personBuilderFactory() + .setName(person.name) + .setKey(person.key) + .build() + ).also { style -> + style.conversationTitle = title + style.isGroupConversation = isGroup + content.forEach { + val sender = personBuilderFactory() + .setName(it.sender.name) + .setKey(it.sender.key) + .setIcon(it.sender.icon) + .build() + style.addMessage(MessagingStyle.Message(it.content, it.timestamp, sender)) + } + } + +} \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/MatrixPushHandler.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/MatrixPushHandler.kt new file mode 100644 index 0000000..5b39268 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/MatrixPushHandler.kt @@ -0,0 +1,85 @@ +package app.dapk.st.notifications + +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.log +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomStore +import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.push.PushHandler +import app.dapk.st.push.PushTokenPayload +import app.dapk.st.work.WorkScheduler +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.serialization.json.Json + +private var previousJob: Job? = null + +@OptIn(DelicateCoroutinesApi::class) +class MatrixPushHandler( + private val workScheduler: WorkScheduler, + private val credentialsStore: CredentialsStore, + private val syncService: SyncService, + private val roomStore: RoomStore, +) : PushHandler { + + override fun onNewToken(payload: PushTokenPayload) { + log(AppLogTag.PUSH, "new push token received") + workScheduler.schedule( + WorkScheduler.WorkTask( + type = "push_token", + jobId = 2, + jsonPayload = Json.encodeToString(PushTokenPayload.serializer(), payload) + ) + ) + } + + override fun onMessageReceived(eventId: EventId?, roomId: RoomId?) { + log(AppLogTag.PUSH, "push received") + previousJob?.cancel() + previousJob = GlobalScope.launch { + when (credentialsStore.credentials()) { + null -> log(AppLogTag.PUSH, "push ignored due to missing api credentials") + else -> doSync(roomId, eventId) + } + } + } + + private suspend fun doSync(roomId: RoomId?, eventId: EventId?) { + when (roomId) { + null -> { + log(AppLogTag.PUSH, "empty push payload - keeping sync alive until unread changes") + waitForUnreadChange(60_000) ?: log(AppLogTag.PUSH, "timed out waiting for sync") + } + + else -> { + log(AppLogTag.PUSH, "push with eventId payload - keeping sync alive until the event shows up in the sync response") + waitForEvent( + timeout = 60_000, + eventId!!, + ) ?: log(AppLogTag.PUSH, "timed out waiting for sync") + } + } + log(AppLogTag.PUSH, "push sync finished") + } + + private suspend fun waitForEvent(timeout: Long, eventId: EventId): EventId? { + return withTimeoutOrNull(timeout) { + combine(syncService.startSyncing().startInstantly(), syncService.observeEvent(eventId)) { _, event -> event } + .firstOrNull { + it == eventId + } + } + } + + private suspend fun waitForUnreadChange(timeout: Long): String? { + return withTimeoutOrNull(timeout) { + combine(syncService.startSyncing().startInstantly(), roomStore.observeUnread()) { _, unread -> unread } + .first() + "ignored" + } + } + + private fun Flow.startInstantly() = this.onStart { emit(Unit) } +} diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationChannels.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationChannels.kt new file mode 100644 index 0000000..12dbd8d --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationChannels.kt @@ -0,0 +1,62 @@ +package app.dapk.st.notifications + +import android.app.NotificationChannel +import android.app.NotificationChannelGroup +import android.app.NotificationManager +import android.os.Build + +const val DIRECT_CHANNEL_ID = "direct_channel_id" +const val GROUP_CHANNEL_ID = "group_channel_id" +const val SUMMARY_CHANNEL_ID = "summary_channel_id" + +private const val CHATS_NOTIFICATION_GROUP_ID = "chats_notification_group" + +class NotificationChannels( + private val notificationManager: NotificationManager +) { + + fun initChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.createNotificationChannelGroup(NotificationChannelGroup(CHATS_NOTIFICATION_GROUP_ID, "Chats")) + + if (notificationManager.getNotificationChannel(DIRECT_CHANNEL_ID) == null) { + notificationManager.createNotificationChannel( + NotificationChannel( + DIRECT_CHANNEL_ID, + "Direct notifications", + NotificationManager.IMPORTANCE_HIGH, + ).also { + it.enableVibration(true) + it.enableLights(true) + it.group = CHATS_NOTIFICATION_GROUP_ID + } + ) + } + + if (notificationManager.getNotificationChannel(GROUP_CHANNEL_ID) == null) { + notificationManager.createNotificationChannel( + NotificationChannel( + GROUP_CHANNEL_ID, + "Group notifications", + NotificationManager.IMPORTANCE_HIGH, + ).also { + it.group = CHATS_NOTIFICATION_GROUP_ID + } + ) + } + + if (notificationManager.getNotificationChannel(SUMMARY_CHANNEL_ID) == null) { + notificationManager.createNotificationChannel( + NotificationChannel( + SUMMARY_CHANNEL_ID, + "Other notifications", + NotificationManager.IMPORTANCE_DEFAULT, + ).also { + it.group = CHATS_NOTIFICATION_GROUP_ID + } + ) + } + } + } + +} \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt new file mode 100644 index 0000000..13d26a1 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt @@ -0,0 +1,91 @@ +package app.dapk.st.notifications + +import android.app.Notification +import android.content.Context +import app.dapk.st.core.DeviceMeta +import app.dapk.st.core.whenPOrHigher +import app.dapk.st.imageloader.IconLoader +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.navigator.IntentFactory + +private const val GROUP_ID = "st" + +class NotificationFactory( + private val context: Context, + private val notificationStyleFactory: NotificationStyleFactory, + private val intentFactory: IntentFactory, + private val iconLoader: IconLoader, + private val deviceMeta: DeviceMeta, +) { + private val shouldAlwaysAlertDms = true + + suspend fun createMessageNotification( + events: List, + roomOverview: RoomOverview, + roomsWithNewEvents: Set, + newRooms: Set + ): NotificationTypes { + val sortedEvents = events.sortedBy { it.utcTimestamp } + val messageStyle = notificationStyleFactory.message(sortedEvents, roomOverview) + val openRoomIntent = intentFactory.notificationOpenMessage(context, roomOverview.roomId) + val shouldAlertMoreThanOnce = when { + roomOverview.isDm() -> roomsWithNewEvents.contains(roomOverview.roomId) && shouldAlwaysAlertDms + else -> newRooms.contains(roomOverview.roomId) + } + + val last = sortedEvents.last() + return NotificationTypes.Room( + AndroidNotification( + channelId = SUMMARY_CHANNEL_ID, + whenTimestamp = last.utcTimestamp, + groupId = GROUP_ID, + groupAlertBehavior = deviceMeta.whenPOrHigher( + block = { Notification.GROUP_ALERT_SUMMARY }, + fallback = { null } + ), + shortcutId = roomOverview.roomId.value, + alertMoreThanOnce = false, + contentIntent = openRoomIntent, + messageStyle = messageStyle, + category = Notification.CATEGORY_MESSAGE, + smallIcon = R.drawable.ic_notification_small_icon, + largeIcon = roomOverview.roomAvatarUrl?.let { iconLoader.load(it.value) }, + autoCancel = true + ), + roomId = roomOverview.roomId, + summary = last.content, + messageCount = sortedEvents.size, + isAlerting = shouldAlertMoreThanOnce, + summaryChannelId = when { + roomOverview.isDm() -> DIRECT_CHANNEL_ID + else -> GROUP_CHANNEL_ID + } + ) + } + + fun createSummary(notifications: List): AndroidNotification { + val summaryInboxStyle = notificationStyleFactory.summary(notifications) + val openAppIntent = intentFactory.notificationOpenApp(context) + val mostRecent = notifications.mostRecent() + return AndroidNotification( + channelId = mostRecent.summaryChannelId, + messageStyle = summaryInboxStyle, + whenTimestamp = mostRecent.notification.whenTimestamp, + alertMoreThanOnce = notifications.any { it.isAlerting }, + smallIcon = R.drawable.ic_notification_small_icon, + contentIntent = openAppIntent, + groupId = GROUP_ID, + groupAlertBehavior = deviceMeta.whenPOrHigher( + block = { Notification.GROUP_ALERT_SUMMARY }, + fallback = { null } + ), + isGroupSummary = true, + category = Notification.CATEGORY_MESSAGE, + ) + } +} + +private fun List.mostRecent() = this.sortedBy { it.notification.whenTimestamp }.first() + +private fun RoomOverview.isDm() = !this.isGroup diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt new file mode 100644 index 0000000..5a2cc7c --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt @@ -0,0 +1,76 @@ +package app.dapk.st.notifications + +import android.app.NotificationManager +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.extensions.ifNull +import app.dapk.st.core.log +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import kotlinx.coroutines.withContext + +private const val SUMMARY_NOTIFICATION_ID = 101 +private const val MESSAGE_NOTIFICATION_ID = 100 + +class NotificationRenderer( + private val notificationManager: NotificationManager, + private val notificationStateMapper: NotificationStateMapper, + private val androidNotificationBuilder: AndroidNotificationBuilder, + private val dispatchers: CoroutineDispatchers, +) { + + suspend fun render(state: NotificationState) { + state.removedRooms.forEach { notificationManager.cancel(it.value, MESSAGE_NOTIFICATION_ID) } + val notifications = notificationStateMapper.mapToNotifications(state) + + withContext(dispatchers.main) { + notifications.summaryNotification.ifNull { + log(AppLogTag.NOTIFICATION, "cancelling summary") + notificationManager.cancel(SUMMARY_NOTIFICATION_ID) + } + + val onlyContainsRemovals = state.onlyContainsRemovals() + notifications.delegates.forEach { + when (it) { + is NotificationTypes.DismissRoom -> notificationManager.cancel(it.roomId.value, MESSAGE_NOTIFICATION_ID) + is NotificationTypes.Room -> { + if (!onlyContainsRemovals) { + log(AppLogTag.NOTIFICATION, "notifying ${it.roomId.value}") + notificationManager.notify(it.roomId.value, MESSAGE_NOTIFICATION_ID, it.notification.build(androidNotificationBuilder)) + } + } + } + } + + notifications.summaryNotification?.let { + if (notifications.delegates.filterIsInstance().isNotEmpty() && !onlyContainsRemovals) { + log(AppLogTag.NOTIFICATION, "notifying summary") + notificationManager.notify(SUMMARY_NOTIFICATION_ID, it.build(androidNotificationBuilder)) + } + } + } + } +} + +data class NotificationState( + val allUnread: Map>, + val removedRooms: Set, + val roomsWithNewEvents: Set, + val newRooms: Set +) + +private fun NotificationState.onlyContainsRemovals() = this.removedRooms.isNotEmpty() && this.roomsWithNewEvents.isEmpty() + +sealed interface NotificationTypes { + data class Room( + val notification: AndroidNotification, + val roomId: RoomId, + val summary: String, + val messageCount: Int, + val isAlerting: Boolean, + val summaryChannelId: String, + ) : NotificationTypes + + data class DismissRoom(val roomId: RoomId) : NotificationTypes +} \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStateMapper.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStateMapper.kt new file mode 100644 index 0000000..4fe1496 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStateMapper.kt @@ -0,0 +1,38 @@ +package app.dapk.st.notifications + +class NotificationStateMapper( + private val roomEventsToNotifiableMapper: RoomEventsToNotifiableMapper, + private val notificationFactory: NotificationFactory, +) { + + suspend fun mapToNotifications(state: NotificationState): Notifications { + val messageNotifications = createMessageNotifications(state) + val roomNotifications = messageNotifications.filterIsInstance() + val summaryNotification = maybeCreateSummary(roomNotifications) + return Notifications(summaryNotification, messageNotifications) + } + + private suspend fun createMessageNotifications(state: NotificationState) = state.allUnread.map { (roomOverview, events) -> + val messageEvents = roomEventsToNotifiableMapper.map(events) + when (messageEvents.isEmpty()) { + true -> NotificationTypes.DismissRoom(roomOverview.roomId) + false -> { + notificationFactory.createMessageNotification( + events = messageEvents, + roomOverview = roomOverview, + roomsWithNewEvents = state.roomsWithNewEvents, + newRooms = state.newRooms + ) + } + } + } + + private fun maybeCreateSummary(roomNotifications: List) = when { + roomNotifications.isNotEmpty() -> { + notificationFactory.createSummary(roomNotifications) + } + else -> null + } +} + +data class Notifications(val summaryNotification: AndroidNotification?, val delegates: List) diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStyleFactory.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStyleFactory.kt new file mode 100644 index 0000000..232c273 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStyleFactory.kt @@ -0,0 +1,53 @@ +package app.dapk.st.notifications + +import android.annotation.SuppressLint +import app.dapk.st.core.DeviceMeta +import app.dapk.st.core.whenPOrHigher +import app.dapk.st.imageloader.IconLoader +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.notifications.AndroidNotificationStyle.Inbox +import app.dapk.st.notifications.AndroidNotificationStyle.Messaging + +@SuppressLint("NewApi") +class NotificationStyleFactory( + private val iconLoader: IconLoader, + private val deviceMeta: DeviceMeta, +) { + + fun summary(notifications: List) = Inbox( + lines = notifications + .sortedBy { it.notification.whenTimestamp } + .map { it.summary }, + summary = "${notifications.countMessages()} messages from ${notifications.size} chats", + ) + + private fun List.countMessages() = this.sumOf { it.messageCount } + + suspend fun message(events: List, roomOverview: RoomOverview): AndroidNotificationStyle { + return deviceMeta.whenPOrHigher( + block = { createMessageStyle(events, roomOverview) }, + fallback = { + val lines = events.map { "${it.author.displayName ?: it.author.id.value}: ${it.content}" } + Inbox(lines) + } + ) + } + + private suspend fun createMessageStyle(events: List, roomOverview: RoomOverview) = Messaging( + Messaging.AndroidPerson(name = "me", key = roomOverview.roomId.value), + title = roomOverview.roomName.takeIf { roomOverview.isGroup }, + isGroup = roomOverview.isGroup, + content = events.map { message -> + Messaging.AndroidMessage( + Messaging.AndroidPerson( + name = message.author.displayName ?: message.author.id.value, + icon = message.author.avatarUrl?.let { iconLoader.load(it.value) }, + key = message.author.id.value, + ), + content = message.content, + timestamp = message.utcTimestamp, + ) + } + ) + +} diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt index d71de40..f3501b9 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt @@ -2,34 +2,42 @@ package app.dapk.st.notifications import android.app.NotificationManager import android.content.Context +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.DeviceMeta import app.dapk.st.core.ProvidableModule import app.dapk.st.imageloader.IconLoader -import app.dapk.st.matrix.common.CredentialsStore -import app.dapk.st.matrix.push.PushService import app.dapk.st.matrix.sync.RoomStore -import app.dapk.st.matrix.sync.SyncService -import app.dapk.st.push.RegisterFirebasePushTokenUseCase +import app.dapk.st.navigator.IntentFactory class NotificationsModule( - private val pushService: PushService, - private val syncService: SyncService, - private val credentialsStore: CredentialsStore, - private val firebasePushTokenUseCase: RegisterFirebasePushTokenUseCase, private val iconLoader: IconLoader, private val roomStore: RoomStore, private val context: Context, + private val intentFactory: IntentFactory, + private val dispatchers: CoroutineDispatchers, + private val deviceMeta: DeviceMeta, ) : ProvidableModule { - fun pushUseCase() = pushService - fun syncService() = syncService - fun credentialProvider() = credentialsStore - fun roomStore() = roomStore - fun iconLoader() = iconLoader - fun firebasePushTokenUseCase() = firebasePushTokenUseCase - fun notificationsUseCase() = NotificationsUseCase( - roomStore, - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager, - iconLoader, - context, + fun notificationsUseCase() = RenderNotificationsUseCase( + notificationRenderer = NotificationRenderer( + notificationManager(), + NotificationStateMapper( + RoomEventsToNotifiableMapper(), + NotificationFactory( + context, + NotificationStyleFactory(iconLoader, deviceMeta), + intentFactory, + iconLoader, + deviceMeta, + ) + ), + AndroidNotificationBuilder(context, deviceMeta, AndroidNotificationStyleBuilder()), + dispatchers + ), + observeRenderableUnreadEventsUseCase = ObserveUnreadNotificationsUseCaseImpl(roomStore), + notificationChannels = NotificationChannels(notificationManager()), ) + + private fun notificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt deleted file mode 100644 index d587adc..0000000 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt +++ /dev/null @@ -1,175 +0,0 @@ -package app.dapk.st.notifications - -import android.app.* -import android.app.Notification.InboxStyle -import android.content.Context -import app.dapk.st.core.AppLogTag.NOTIFICATION -import app.dapk.st.core.log -import app.dapk.st.imageloader.IconLoader -import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.sync.RoomEvent -import app.dapk.st.matrix.sync.RoomOverview -import app.dapk.st.matrix.sync.RoomStore -import app.dapk.st.messenger.MessengerActivity -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.onEach - -private const val SUMMARY_NOTIFICATION_ID = 101 -private const val MESSAGE_NOTIFICATION_ID = 100 -private const val GROUP_ID = "st" - -class NotificationsUseCase( - private val roomStore: RoomStore, - private val notificationManager: NotificationManager, - private val iconLoader: IconLoader, - private val context: Context, -) { - - private val inferredCurrentNotifications = mutableSetOf() - private val channelId = "message" - - init { - if (notificationManager.getNotificationChannel(channelId) == null) { - notificationManager.createNotificationChannel( - NotificationChannel( - channelId, - "messages", - NotificationManager.IMPORTANCE_HIGH, - ) - ) - } - } - - suspend fun listenForNotificationChanges() { - // TODO handle redactions by removing and edits by not notifying - - roomStore.observeUnread() - .drop(1) - .onEach { result -> - log(NOTIFICATION, "unread changed - render notifications") - - val asRooms = result.keys.map { it.roomId }.toSet() - val removedRooms = inferredCurrentNotifications - asRooms - removedRooms.forEach { notificationManager.cancel(it.value, MESSAGE_NOTIFICATION_ID) } - - inferredCurrentNotifications.clear() - inferredCurrentNotifications.addAll(asRooms) - - val notifications = result.map { (roomOverview, events) -> - val messageEvents = events.filterIsInstance() - when (messageEvents.isEmpty()) { - true -> NotificationDelegate.DismissRoom(roomOverview.roomId) - false -> createNotification(messageEvents, roomOverview) - } - } - - - val summaryNotification = if (notifications.filterIsInstance().size > 1) { - createSummary(notifications) - } else { - null - } - - if (summaryNotification == null) { - notificationManager.cancel(101) - } - - notifications.forEach { - when (it) { - is NotificationDelegate.DismissRoom -> notificationManager.cancel(it.roomId.value, MESSAGE_NOTIFICATION_ID) - is NotificationDelegate.Room -> notificationManager.notify(it.roomId.value, MESSAGE_NOTIFICATION_ID, it.notification) - } - } - - if (summaryNotification != null) { - notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification) - } - - } - .collect() - } - - private fun createSummary(notifications: List): Notification { - val summaryInboxStyle = InboxStyle().also { style -> - notifications.forEach { - when (it) { - is NotificationDelegate.DismissRoom -> { - // do nothing - } - is NotificationDelegate.Room -> style.addLine(it.summary) - } - } - } - - return Notification.Builder(context, channelId) - .setStyle(summaryInboxStyle) - .setSmallIcon(R.drawable.ic_notification_small_icon) - .setCategory(Notification.CATEGORY_MESSAGE) - .setGroupSummary(true) - .setGroup(GROUP_ID) - .build() - } - - private suspend fun createNotification(events: List, roomOverview: RoomOverview): NotificationDelegate { - val messageStyle = Notification.MessagingStyle( - Person.Builder() - .setName("me") - .setKey(roomOverview.roomId.value) - .build() - ) - - messageStyle.conversationTitle = roomOverview.roomName.takeIf { roomOverview.isGroup } - messageStyle.isGroupConversation = roomOverview.isGroup - - events.sortedBy { it.utcTimestamp }.forEach { message -> - val sender = Person.Builder() - .setName(message.author.displayName ?: message.author.id.value) - .setIcon(message.author.avatarUrl?.let { iconLoader.load(it.value) }) - .setKey(message.author.id.value) - .build() - messageStyle.addMessage( - Notification.MessagingStyle.Message( - message.content, - message.utcTimestamp, - sender, - ) - ) - } - - val openRoomIntent = PendingIntent.getActivity( - context, - 55, - MessengerActivity.newInstance(context, roomOverview.roomId), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - return NotificationDelegate.Room( - Notification.Builder(context, channelId) - .setWhen(messageStyle.messages.last().timestamp) - .setShowWhen(true) - .setGroup(GROUP_ID) - .setOnlyAlertOnce(roomOverview.isGroup) - .setContentIntent(openRoomIntent) - .setStyle(messageStyle) - .setCategory(Notification.CATEGORY_MESSAGE) - .setShortcutId(roomOverview.roomId.value) - .setSmallIcon(R.drawable.ic_notification_small_icon) - .setLargeIcon(roomOverview.roomAvatarUrl?.let { iconLoader.load(it.value) }) - .setAutoCancel(true) - .build(), - roomId = roomOverview.roomId, - summary = messageStyle.messages.last().text.toString() - ) - - } - -} - -sealed interface NotificationDelegate { - - data class Room(val notification: Notification, val roomId: RoomId, val summary: String) : NotificationDelegate - data class DismissRoom(val roomId: RoomId) : NotificationDelegate - - -} \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseImpl.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseImpl.kt new file mode 100644 index 0000000..8a3860d --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseImpl.kt @@ -0,0 +1,101 @@ +package app.dapk.st.notifications + +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.extensions.clearAndPutAll +import app.dapk.st.core.extensions.containsKey +import app.dapk.st.core.log +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.matrix.sync.RoomStore +import kotlinx.coroutines.flow.* + +typealias UnreadNotifications = Pair>, NotificationDiff> +internal typealias ObserveUnreadNotificationsUseCase = suspend () -> Flow + +class ObserveUnreadNotificationsUseCaseImpl(private val roomStore: RoomStore) : ObserveUnreadNotificationsUseCase { + + override suspend fun invoke(): Flow { + return roomStore.observeUnread() + .mapWithDiff() + .avoidShowingPreviousNotificationsOnLaunch() + .onlyRenderableChanges() + } + +} + +private fun Flow.onlyRenderableChanges(): Flow { + val inferredCurrentNotifications = mutableMapOf>() + return this + .filter { (_, diff) -> + when { + diff.changedOrNew.isEmpty() && diff.removed.isEmpty() -> { + log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no renderable changes") + false + } + + inferredCurrentNotifications.isEmpty() && diff.removed.isNotEmpty() -> { + log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no currently showing messages and changes are all messages marked as read") + false + } + + else -> true + } + } + .onEach { (allUnread, _) -> inferredCurrentNotifications.clearAndPutAll(allUnread.mapKeys { it.key.roomId }) } +} + +private fun Flow>>.mapWithDiff(): Flow>, NotificationDiff>> { + val previousUnreadEvents = mutableMapOf>() + return this.map { each -> + val allUnreadIds = each.toTimestampedIds() + val notificationDiff = calculateDiff(allUnreadIds, previousUnreadEvents) + previousUnreadEvents.clearAndPutAll(allUnreadIds) + each to notificationDiff + } +} + +private fun calculateDiff(allUnread: Map>, previousUnread: Map>?): NotificationDiff { + val previousLatestEventTimestamps = previousUnread.toLatestTimestamps() + val newRooms = allUnread.filter { !previousUnread.containsKey(it.key) }.keys + + val unchanged = previousUnread?.filter { + allUnread.containsKey(it.key) && (it.value == allUnread[it.key]) + } ?: emptyMap() + val changedOrNew = allUnread.filterNot { unchanged.containsKey(it.key) }.mapValues { (key, value) -> + val isChangedRoom = !newRooms.contains(key) + if (isChangedRoom) { + val latest = previousLatestEventTimestamps[key] ?: 0L + value.filter { + val isExistingEvent = (previousUnread?.get(key)?.contains(it) ?: false) + !isExistingEvent && it.second > latest + } + } else { + value + } + }.filter { it.value.isNotEmpty() } + val removed = previousUnread?.filter { !allUnread.containsKey(it.key) } ?: emptyMap() + return NotificationDiff(unchanged.toEventIds(), changedOrNew.toEventIds(), removed.toEventIds(), newRooms) +} + +private fun Map>?.toLatestTimestamps() = this?.mapValues { it.value.maxOf { it.second } } ?: emptyMap() + +private fun Map>.toEventIds() = this.mapValues { it.value.map { it.first } } + +private fun Map>.toTimestampedIds() = this + .mapValues { it.value.toEventIds() } + .mapKeys { it.key.roomId } + +private fun List.toEventIds() = this.map { it.eventId to it.utcTimestamp } + +private fun Flow.avoidShowingPreviousNotificationsOnLaunch() = drop(1) + +data class NotificationDiff( + val unchanged: Map>, + val changedOrNew: Map>, + val removed: Map>, + val newRooms: Set +) + +typealias TimestampedEventId = Pair \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt deleted file mode 100644 index 20bbeb4..0000000 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt +++ /dev/null @@ -1,81 +0,0 @@ -package app.dapk.st.notifications - -import android.content.Context -import app.dapk.st.core.AppLogTag.PUSH -import app.dapk.st.core.extensions.unsafeLazy -import app.dapk.st.core.log -import app.dapk.st.core.module -import app.dapk.st.matrix.common.EventId -import app.dapk.st.matrix.common.RoomId -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge - -private var previousJob: Job? = null - -@OptIn(DelicateCoroutinesApi::class) -class PushAndroidService : FirebaseMessagingService() { - - private val module by unsafeLazy { module() } - private lateinit var context: Context - - override fun onCreate() { - super.onCreate() - context = applicationContext - } - - override fun onNewToken(token: String) { - GlobalScope.launch { - module.pushUseCase().registerPush(token) - } - } - - override fun onMessageReceived(message: RemoteMessage) { - val eventId = message.data["event_id"]?.let { EventId(it) } - val roomId = message.data["room_id"]?.let { RoomId(it) } - - log(PUSH, "push received") - previousJob?.cancel() - previousJob = GlobalScope.launch { - when (module.credentialProvider().credentials()) { - null -> log(PUSH, "push ignored due to missing api credentials") - else -> doSync(roomId, eventId) - } - } - } - - private suspend fun doSync(roomId: RoomId?, eventId: EventId?) { - when (roomId) { - null -> { - log(PUSH, "empty push payload - triggering a sync if not running") - withTimeoutOrNull(60_000) { - log(PUSH, "got empty event, forcing a sync") - module.syncService().startSyncing().first() - } ?: log(PUSH, "timed out waiting for sync") - } - else -> { - log(PUSH, "push with eventId payload - keeping sync alive until the event shows up in the sync response") - waitForEvent( - timeout = 60_000, - eventId!!, - ) ?: log(PUSH, "timed out waiting for sync") - } - } - log(PUSH, "push sync finished") - } - - private suspend fun waitForEvent(timeout: Long, eventId: EventId): EventId? { - return withTimeoutOrNull(timeout) { - val syncFlow = module.syncService().startSyncing().map { it as EventId } - merge(syncFlow, module.syncService().observeEvent(eventId)) - .firstOrNull { - it == eventId - } - } - } - -} diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt new file mode 100644 index 0000000..59128eb --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt @@ -0,0 +1,32 @@ +package app.dapk.st.notifications + +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart + +class RenderNotificationsUseCase( + private val notificationRenderer: NotificationRenderer, + private val observeRenderableUnreadEventsUseCase: ObserveUnreadNotificationsUseCase, + private val notificationChannels: NotificationChannels, +) { + + suspend fun listenForNotificationChanges() { + observeRenderableUnreadEventsUseCase() + .onStart { notificationChannels.initChannels() } + .onEach { (each, diff) -> renderUnreadChange(each, diff) } + .collect() + } + + private suspend fun renderUnreadChange(allUnread: Map>, diff: NotificationDiff) { + notificationRenderer.render( + NotificationState( + allUnread = allUnread, + removedRooms = diff.removed.keys, + roomsWithNewEvents = diff.changedOrNew.keys, + newRooms = diff.newRooms, + ) + ) + } +} diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt new file mode 100644 index 0000000..9f5c05d --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt @@ -0,0 +1,26 @@ +package app.dapk.st.notifications + +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.sync.RoomEvent + +class RoomEventsToNotifiableMapper { + + fun map(events: List): List { + return events.map { + when (it) { + is RoomEvent.Image -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) + is RoomEvent.Message -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) + is RoomEvent.Reply -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) + } + } + } + + private fun RoomEvent.toNotifiableContent(): String = when (this) { + is RoomEvent.Image -> "\uD83D\uDCF7" + is RoomEvent.Message -> this.content + is RoomEvent.Reply -> this.message.toNotifiableContent() + } + +} + +data class Notifiable(val content: String, val utcTimestamp: Long, val author: RoomMember) diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationBuilderTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationBuilderTest.kt new file mode 100644 index 0000000..ce1743f --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationBuilderTest.kt @@ -0,0 +1,59 @@ +package app.dapk.st.notifications + +import app.dapk.st.core.DeviceMeta +import fixture.NotificationDelegateFixtures.anAndroidNotification +import fake.FakeContext +import fake.FakeNotificationBuilder +import fake.aFakeMessagingStyle +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import test.delegateReturn +import test.runExpectTest + +private val A_MESSAGING_STYLE = aFakeMessagingStyle() + +class AndroidNotificationBuilderTest { + + private val fakeContext = FakeContext() + private val fakeNotificationBuilder = FakeNotificationBuilder() + private val fakeAndroidNotificationStyleBuilder = FakeAndroidNotificationStyleBuilder() + + private val builder = AndroidNotificationBuilder( + fakeContext.instance, + DeviceMeta(apiVersion = 26), + fakeAndroidNotificationStyleBuilder.instance, + builderFactory = { _, _, _ -> fakeNotificationBuilder.instance }, + ) + + @Test + fun `applies all builder options`() = runExpectTest { + val notification = anAndroidNotification() + fakeAndroidNotificationStyleBuilder.given(notification.messageStyle!!).returns(A_MESSAGING_STYLE) + fakeNotificationBuilder.instance.captureExpects { + it.setOnlyAlertOnce(!notification.alertMoreThanOnce) + it.setAutoCancel(notification.autoCancel) + it.setGroupSummary(notification.isGroupSummary) + it.setGroup(notification.groupId) + it.setStyle(A_MESSAGING_STYLE) + it.setContentIntent(notification.contentIntent) + it.setShowWhen(true) + it.setWhen(notification.whenTimestamp!!) + it.setCategory(notification.category) + it.setShortcutId(notification.shortcutId) + it.setSmallIcon(notification.smallIcon!!) + it.setLargeIcon(notification.largeIcon) + it.build() + } + + val ignoredResult = builder.build(notification) + + verifyExpects() + } +} + +class FakeAndroidNotificationStyleBuilder { + val instance = mockk() + + fun given(style: AndroidNotificationStyle) = every { instance.build(style) }.delegateReturn() +} diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilderTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilderTest.kt new file mode 100644 index 0000000..564dc4a --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilderTest.kt @@ -0,0 +1,38 @@ +package app.dapk.st.notifications + +import fake.FakeInboxStyle +import fake.FakeMessagingStyle +import fake.FakePersonBuilder +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +class AndroidNotificationStyleBuilderTest { + + private val fakePersonBuilder = FakePersonBuilder() + private val fakeInbox = FakeInboxStyle().also { it.captureInteractions() } + private val fakeMessagingStyle = FakeMessagingStyle() + + private val styleBuilder = AndroidNotificationStyleBuilder( + personBuilderFactory = { fakePersonBuilder.instance }, + inboxStyleFactory = { fakeInbox.instance }, + messagingStyleFactory = { + fakeMessagingStyle.user = it + fakeMessagingStyle.instance + }, + ) + + @Test + fun `given an inbox style, when building android style, then returns framework version`() { + val input = AndroidNotificationStyle.Inbox( + lines = listOf("hello", "world"), + summary = "a summary" + ) + + val result = styleBuilder.build(input) + + result shouldBeEqualTo fakeInbox.instance + fakeInbox.lines shouldBeEqualTo input.lines + fakeInbox.summary shouldBeEqualTo input.summary + } + +} diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt new file mode 100644 index 0000000..60d5e0e --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt @@ -0,0 +1,175 @@ +package app.dapk.st.notifications + +import android.app.Notification +import android.app.PendingIntent +import android.os.Build +import app.dapk.st.core.DeviceMeta +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.sync.RoomOverview +import fake.FakeContext +import fixture.NotificationDelegateFixtures.anAndroidNotification +import fixture.NotificationDelegateFixtures.anInboxStyle +import fixture.NotificationFixtures.aRoomNotification +import fixture.aRoomId +import fixture.aRoomOverview +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private const val A_CHANNEL_ID = "a channel id" +private val AN_OPEN_APP_INTENT = aPendingIntent() +private val AN_OPEN_ROOM_INTENT = aPendingIntent() +private val A_NOTIFICATION_STYLE = anInboxStyle() +private val A_ROOM_ID = aRoomId() +private val A_DM_ROOM_OVERVIEW = aRoomOverview(roomId = A_ROOM_ID, roomAvatarUrl = AvatarUrl("https://a-url.gif"), isGroup = false) +private val A_GROUP_ROOM_OVERVIEW = aRoomOverview(roomId = A_ROOM_ID, roomAvatarUrl = AvatarUrl("https://a-url.gif"), isGroup = true) +private val A_ROOM_ICON = anIcon() +private val LATEST_EVENT = aNotifiable("message three", utcTimestamp = 3) +private val EVENTS = listOf( + aNotifiable("message one", utcTimestamp = 1), + LATEST_EVENT, + aNotifiable("message two", utcTimestamp = 2), +) + +class NotificationFactoryTest { + + private val fakeContext = FakeContext() + private val fakeNotificationStyleFactory = FakeNotificationStyleFactory() + private val fakeIntentFactory = FakeIntentFactory() + private val fakeIconLoader = FakeIconLoader() + + private val notificationFactory = NotificationFactory( + fakeContext.instance, + fakeNotificationStyleFactory.instance, + fakeIntentFactory, + fakeIconLoader, + DeviceMeta(26), + ) + + @Test + fun `given alerting room notification, when creating summary, then is alerting`() { + val notifications = listOf( + aRoomNotification( + summaryChannelId = A_CHANNEL_ID, + notification = anAndroidNotification(channelId = A_CHANNEL_ID), isAlerting = true + ) + ) + fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT) + fakeNotificationStyleFactory.givenSummary(notifications).returns(anInboxStyle()) + + val result = notificationFactory.createSummary(notifications) + + result shouldBeEqualTo expectedSummary(notifications.first().notification, shouldAlertMoreThanOnce = true) + } + + @Test + fun `given non alerting room notification, when creating summary, then is alerting`() { + val notifications = listOf( + aRoomNotification( + summaryChannelId = A_CHANNEL_ID, + notification = anAndroidNotification(channelId = A_CHANNEL_ID), isAlerting = false + ) + ) + fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT) + fakeNotificationStyleFactory.givenSummary(notifications).returns(anInboxStyle()) + + val result = notificationFactory.createSummary(notifications) + + result shouldBeEqualTo expectedSummary(notifications.first().notification, shouldAlertMoreThanOnce = false) + } + + @Test + fun `given new events in a new group room, when creating message, then alerts`() = runTest { + givenEventsFor(A_GROUP_ROOM_OVERVIEW) + + val result = notificationFactory.createMessageNotification(EVENTS, A_GROUP_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = setOf(A_ROOM_ID)) + + result shouldBeEqualTo expectedMessage( + channel = GROUP_CHANNEL_ID, + shouldAlertMoreThanOnce = true, + ) + } + + @Test + fun `given new events in an existing group room, when creating message, then does not alert`() = runTest { + givenEventsFor(A_GROUP_ROOM_OVERVIEW) + + val result = notificationFactory.createMessageNotification(EVENTS, A_GROUP_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = emptySet()) + + result shouldBeEqualTo expectedMessage( + channel = GROUP_CHANNEL_ID, + shouldAlertMoreThanOnce = false, + ) + } + + @Test + fun `given new events in a new DM room, when creating message, then alerts`() = runTest { + givenEventsFor(A_DM_ROOM_OVERVIEW) + + val result = notificationFactory.createMessageNotification(EVENTS, A_DM_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = setOf(A_ROOM_ID)) + + result shouldBeEqualTo expectedMessage( + channel = DIRECT_CHANNEL_ID, + shouldAlertMoreThanOnce = true, + ) + } + + @Test + fun `given new events in an existing DM room, when creating message, then alerts`() = runTest { + givenEventsFor(A_DM_ROOM_OVERVIEW) + + val result = notificationFactory.createMessageNotification(EVENTS, A_DM_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = emptySet()) + + result shouldBeEqualTo expectedMessage( + channel = DIRECT_CHANNEL_ID, + shouldAlertMoreThanOnce = true, + ) + } + + private fun givenEventsFor(roomOverview: RoomOverview) { + fakeIntentFactory.givenNotificationOpenMessage(fakeContext.instance, roomOverview.roomId).returns(AN_OPEN_ROOM_INTENT) + fakeNotificationStyleFactory.givenMessage(EVENTS.sortedBy { it.utcTimestamp }, roomOverview).returns(A_NOTIFICATION_STYLE) + fakeIconLoader.given(roomOverview.roomAvatarUrl!!.value).returns(A_ROOM_ICON) + } + + private fun expectedMessage( + channel: String, + shouldAlertMoreThanOnce: Boolean, + ) = NotificationTypes.Room( + AndroidNotification( + channelId = SUMMARY_CHANNEL_ID, + whenTimestamp = LATEST_EVENT.utcTimestamp, + groupId = "st", + groupAlertBehavior = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.GROUP_ALERT_SUMMARY else null, + shortcutId = A_ROOM_ID.value, + alertMoreThanOnce = false, + contentIntent = AN_OPEN_ROOM_INTENT, + messageStyle = A_NOTIFICATION_STYLE, + category = Notification.CATEGORY_MESSAGE, + smallIcon = R.drawable.ic_notification_small_icon, + largeIcon = A_ROOM_ICON, + autoCancel = true + ), + A_ROOM_ID, + summary = LATEST_EVENT.content, + messageCount = EVENTS.size, + isAlerting = shouldAlertMoreThanOnce, + summaryChannelId = channel, + ) + + private fun expectedSummary(notification: AndroidNotification, shouldAlertMoreThanOnce: Boolean) = AndroidNotification( + channelId = notification.channelId, + whenTimestamp = notification.whenTimestamp, + messageStyle = A_NOTIFICATION_STYLE, + alertMoreThanOnce = shouldAlertMoreThanOnce, + smallIcon = R.drawable.ic_notification_small_icon, + contentIntent = AN_OPEN_APP_INTENT, + groupId = "st", + category = Notification.CATEGORY_MESSAGE, + isGroupSummary = true, + autoCancel = true + ) +} + +fun aPendingIntent() = mockk() \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt new file mode 100644 index 0000000..ed128f2 --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt @@ -0,0 +1,103 @@ +package app.dapk.st.notifications + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import fake.FakeNotificationFactory +import fake.FakeNotificationManager +import fake.aFakeNotification +import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers +import fixture.NotificationDelegateFixtures.anAndroidNotification +import fixture.NotificationFixtures.aNotifications +import fixture.NotificationFixtures.aRoomNotification +import fixture.aRoomId +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import test.delegateReturn +import test.expect +import test.runExpectTest + +private const val SUMMARY_ID = 101 +private const val ROOM_MESSAGE_ID = 100 +private val A_SUMMARY_ANDROID_NOTIFICATION = anAndroidNotification(isGroupSummary = true) +private val A_NOTIFICATION = aFakeNotification() + +class FakeAndroidNotificationBuilder { + val instance = mockk() + + fun given(notification: AndroidNotification) = every { instance.build(notification) }.delegateReturn() +} + +class NotificationRendererTest { + + private val fakeNotificationManager = FakeNotificationManager() + private val fakeNotificationFactory = FakeNotificationFactory() + private val fakeAndroidNotificationBuilder = FakeAndroidNotificationBuilder() + + private val notificationRenderer = NotificationRenderer( + fakeNotificationManager.instance, + fakeNotificationFactory.instance, + fakeAndroidNotificationBuilder.instance, + aCoroutineDispatchers() + ) + + @Test + fun `given removed rooms when rendering then cancels notifications with cancelled room ids`() = runExpectTest { + val removedRooms = setOf(aRoomId("id-1"), aRoomId("id-2")) + fakeNotificationFactory.instance.expect { it.mapToNotifications(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet())) } + fakeNotificationManager.instance.expectUnit { + removedRooms.forEach { removedRoom -> it.cancel(removedRoom.value, ROOM_MESSAGE_ID) } + } + + notificationRenderer.render(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet())) + verifyExpects() + } + + @Test + fun `given summary notification is not created, when rendering, then cancels summary notification`() = runExpectTest { + fakeNotificationFactory.givenNotifications(aNotificationState()).returns(aNotifications(summaryNotification = null)) + fakeNotificationManager.instance.expectUnit { it.cancel(SUMMARY_ID) } + + notificationRenderer.render(NotificationState(emptyMap(), emptySet(), emptySet(), emptySet())) + + verifyExpects() + } + + @Test + fun `given update is only removals, when rendering, then only renders room dismiss`() = runExpectTest { + fakeNotificationFactory.givenNotifications(aNotificationState()).returns(aNotifications(summaryNotification = null)) + fakeNotificationManager.instance.expectUnit { it.cancel(SUMMARY_ID) } + + notificationRenderer.render(NotificationState(emptyMap(), emptySet(), emptySet(), emptySet())) + + verifyExpects() + } + + @Test + fun `given rooms with events, when rendering, then notifies summary and new rooms`() = runExpectTest { + val roomNotification = aRoomNotification() + val roomsWithNewEvents = setOf(roomNotification.roomId) + + fakeAndroidNotificationBuilder.given(roomNotification.notification).returns(A_NOTIFICATION) + fakeAndroidNotificationBuilder.given(A_SUMMARY_ANDROID_NOTIFICATION).returns(A_NOTIFICATION) + + fakeNotificationFactory.givenNotifications(aNotificationState(roomsWithNewEvents = roomsWithNewEvents)).returns( + aNotifications(summaryNotification = A_SUMMARY_ANDROID_NOTIFICATION, delegates = listOf(roomNotification)) + ) + fakeNotificationManager.instance.expectUnit { it.notify(SUMMARY_ID, A_NOTIFICATION) } + fakeNotificationManager.instance.expectUnit { it.notify(roomNotification.roomId.value, ROOM_MESSAGE_ID, A_NOTIFICATION) } + + notificationRenderer.render(NotificationState(emptyMap(), emptySet(), roomsWithNewEvents, emptySet())) + + verifyExpects() + } +} + +fun aNotificationState( + allUnread: Map> = emptyMap(), + removedRooms: Set = emptySet(), + roomsWithNewEvents: Set = emptySet(), + newRooms: Set = emptySet(), +) = NotificationState(allUnread, removedRooms, roomsWithNewEvents, newRooms) + diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStateMapperTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStateMapperTest.kt new file mode 100644 index 0000000..045ed36 --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStateMapperTest.kt @@ -0,0 +1,142 @@ +package app.dapk.st.notifications + +import android.content.Context +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.navigator.IntentFactory +import fixture.NotificationDelegateFixtures.anAndroidNotification +import fixture.NotificationFixtures.aDismissRoomNotification +import fixture.NotificationFixtures.aRoomNotification +import fixture.aRoomMessageEvent +import fixture.aRoomOverview +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.delegateReturn + +private val A_SUMMARY_NOTIFICATION = anAndroidNotification() +private val A_ROOM_OVERVIEW = aRoomOverview() + +class NotificationStateMapperTest { + + private val fakeRoomEventsToNotifiableMapper = FakeRoomEventsToNotifiableMapper() + private val fakeNotificationFactory = FakeNotificationFactory() + + private val factory = NotificationStateMapper( + fakeRoomEventsToNotifiableMapper.instance, + fakeNotificationFactory.instance, + ) + + @Test + fun `given no room message events, when mapping notifications, then creates doesn't create summary and dismisses rooms`() = runTest { + val notificationState = aNotificationState(allUnread = mapOf(A_ROOM_OVERVIEW to listOf())) + fakeRoomEventsToNotifiableMapper.given(emptyList()).returns(emptyList()) + + val result = factory.mapToNotifications(notificationState) + + result shouldBeEqualTo Notifications( + summaryNotification = null, + delegates = listOf(aDismissRoomNotification(A_ROOM_OVERVIEW.roomId)) + ) + } + + @Test + fun `given room message events, when mapping notifications, then creates summary and message notifications`() = runTest { + val notificationState = aNotificationState(allUnread = mapOf(aRoomOverview() to listOf(aRoomMessageEvent()))) + val expectedNotification = givenCreatesNotification(notificationState, aRoomNotification()) + fakeNotificationFactory.givenCreateSummary(listOf(expectedNotification)).returns(A_SUMMARY_NOTIFICATION) + + val result = factory.mapToNotifications(notificationState) + + result shouldBeEqualTo Notifications( + summaryNotification = A_SUMMARY_NOTIFICATION, + delegates = listOf(expectedNotification) + ) + +// +// val allUnread = listOf(aRoomMessageEvent()) +// val value = listOf(aNotifiable()) +// fakeRoomEventsToNotifiableMapper.given(allUnread).returns(value) +// +// fakeIntentFactory.notificationOpenApp() +// fakeIntentFactory.notificationOpenMessage() +// +// fakeNotificationStyleFactory.givenMessage(value, aRoomOverview()).returns(aMessagingStyle()) +// fakeNotificationStyleFactory.givenSummary(listOf()).returns(anInboxStyle()) +// +// val result = factory.mapToNotifications( +// allUnread = mapOf( +// aRoomOverview() to allUnread +// ), +// roomsWithNewEvents = setOf(), +// newRooms = setOf() +// ) +// +// +// result shouldBeEqualTo Notifications( +// summaryNotification = anAndroidNotification(), +// delegates = listOf( +// NotificationTypes.Room( +// anAndroidNotification(), +// aRoomId(), +// summary = "a summary", +// messageCount = 1, +// isAlerting = false +// ) +// ) +// ) + } + + private fun givenCreatesNotification(state: NotificationState, result: NotificationTypes.Room): NotificationTypes.Room { + state.allUnread.map { (roomOverview, events) -> + val value = listOf(aNotifiable()) + fakeRoomEventsToNotifiableMapper.given(events).returns(value) + fakeNotificationFactory.givenCreateMessage( + value, + roomOverview, + state.roomsWithNewEvents, + state.newRooms + ).returns(result) + } + return result + } +} + +class FakeIntentFactory : IntentFactory by mockk() { + fun givenNotificationOpenApp(context: Context) = every { notificationOpenApp(context) }.delegateReturn() + fun givenNotificationOpenMessage(context: Context, roomId: RoomId) = every { notificationOpenMessage(context, roomId) }.delegateReturn() +} + +class FakeNotificationStyleFactory { + val instance = mockk() + + fun givenMessage(events: List, roomOverview: RoomOverview) = coEvery { + instance.message(events, roomOverview) + }.delegateReturn() + + fun givenSummary(notifications: List) = every { instance.summary(notifications) }.delegateReturn() + +} + +class FakeRoomEventsToNotifiableMapper { + val instance = mockk() + + fun given(events: List) = every { instance.map(events) }.delegateReturn() +} + +class FakeNotificationFactory { + val instance = mockk() + + fun givenCreateMessage( + events: List, + roomOverview: RoomOverview, + roomsWithNewEvents: Set, + newRooms: Set + ) = coEvery { instance.createMessageNotification(events, roomOverview, roomsWithNewEvents, newRooms) }.delegateReturn() + + fun givenCreateSummary(roomNotifications: List) = every { instance.createSummary(roomNotifications) }.delegateReturn() +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStyleFactoryTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStyleFactoryTest.kt new file mode 100644 index 0000000..c004cb8 --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStyleFactoryTest.kt @@ -0,0 +1,89 @@ +package app.dapk.st.notifications + +import android.graphics.drawable.Icon +import app.dapk.st.core.DeviceMeta +import app.dapk.st.imageloader.IconLoader +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.notifications.AndroidNotificationStyle.Inbox +import app.dapk.st.notifications.AndroidNotificationStyle.Messaging +import fixture.NotificationDelegateFixtures.anAndroidPerson +import fixture.NotificationFixtures.aRoomNotification +import fixture.aRoomMember +import fixture.aRoomOverview +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.delegateReturn + +private val A_GROUP_ROOM_OVERVIEW = aRoomOverview(roomName = "my awesome room", isGroup = true) + +class NotificationStyleFactoryTest { + + private val fakeIconLoader = FakeIconLoader() + + private val styleFactory = NotificationStyleFactory( + fakeIconLoader, + DeviceMeta(28), + ) + + @Test + fun `when creating summary style, then creates android framework inbox style`() { + val result = styleFactory.summary( + listOf( + aRoomNotification(summary = "room 1 summary", messageCount = 10), + aRoomNotification(summary = "room 2 summary", messageCount = 1), + ) + ) + + result shouldBeEqualTo Inbox( + lines = listOf("room 1 summary", "room 2 summary"), + summary = "11 messages from 2 chats" + ) + } + + @Test + fun `when creating message style, then creates android framework messaging style`() = runTest { + val aMessage = aNotifiable(author = aRoomMember(displayName = "a display name", avatarUrl = AvatarUrl("a-url"))) + val authorIcon = anIcon() + fakeIconLoader.given(aMessage.author.avatarUrl!!.value).returns(authorIcon) + + val result = styleFactory.message(listOf(aMessage), A_GROUP_ROOM_OVERVIEW) + + result shouldBeEqualTo Messaging( + person = Messaging.AndroidPerson(name = "me", key = A_GROUP_ROOM_OVERVIEW.roomId.value, icon = null), + title = A_GROUP_ROOM_OVERVIEW.roomName, + isGroup = true, + content = listOf(aMessage.toAndroidMessage(authorIcon)) + ) + } + +} + +private fun Notifiable.toAndroidMessage(expectedAuthorIcon: Icon) = Messaging.AndroidMessage( + anAndroidPerson( + name = author.displayName!!, + key = author.id.value, + icon = expectedAuthorIcon + ), + content = content, + timestamp = utcTimestamp, +) + +fun aNotifiable( + content: String = "notifiable content", + utcTimestamp: Long = 1000, + author: RoomMember = aRoomMember() +) = Notifiable(content, utcTimestamp, author) + +class FakeIconLoader : IconLoader by mockk() { + fun given(url: String) = coEvery { load(url) }.delegateReturn() +} + +class FakeIcon { + val instance = mockk() +} + +fun anIcon() = FakeIcon().instance \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadRenderNotificationsUseCaseTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadRenderNotificationsUseCaseTest.kt new file mode 100644 index 0000000..1a932fe --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadRenderNotificationsUseCaseTest.kt @@ -0,0 +1,117 @@ +package app.dapk.st.notifications + +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import fake.FakeRoomStore +import fixture.NotificationDiffFixtures.aNotificationDiff +import fixture.aRoomId +import fixture.aRoomMessageEvent +import fixture.aRoomOverview +import fixture.anEventId +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val NO_UNREADS = emptyMap>() +private val A_MESSAGE = aRoomMessageEvent(eventId = anEventId("1"), content = "hello", utcTimestamp = 1000) +private val A_MESSAGE_2 = aRoomMessageEvent(eventId = anEventId("2"), content = "world", utcTimestamp = 2000) +private val A_ROOM_OVERVIEW = aRoomOverview(roomId = aRoomId("1")) +private val A_ROOM_OVERVIEW_2 = aRoomOverview(roomId = aRoomId("2")) + +class ObserveUnreadRenderNotificationsUseCaseTest { + + private val fakeRoomStore = FakeRoomStore() + + private val useCase = ObserveUnreadNotificationsUseCaseImpl(fakeRoomStore) + + @Test + fun `given no initial unreads, when receiving new message, then emits message`() = runTest { + givenNoInitialUnreads(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE)) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo listOf( + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) to aNotificationDiff( + changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE), + newRooms = setOf(A_ROOM_OVERVIEW.roomId) + ) + ) + } + + @Test + fun `given no initial unreads, when receiving multiple messages, then emits messages`() = runTest { + givenNoInitialUnreads(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2)) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo listOf( + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) to aNotificationDiff( + changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE), + newRooms = setOf(A_ROOM_OVERVIEW.roomId) + ), + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE_2)) + ) + } + + @Test + fun `given initial unreads, when receiving new message, then emits all messages`() = runTest { + fakeRoomStore.givenUnreadEvents( + flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2)) + ) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo listOf( + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE_2)) + ) + } + + @Test + fun `given initial unreads, when reading a message, then emits nothing`() = runTest { + fakeRoomStore.givenUnreadEvents( + flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) + A_ROOM_OVERVIEW_2.withUnreads(A_MESSAGE_2), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE)) + ) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo emptyList() + } + + @Test + fun `given new and then historical message, when reading a message, then only emits the latest`() = runTest { + fakeRoomStore.givenUnreadEvents( + flowOf( + NO_UNREADS, + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE.copy(eventId = anEventId("old"), utcTimestamp = -1)) + ) + ) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo listOf( + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) to aNotificationDiff( + changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE), + newRooms = setOf(A_ROOM_OVERVIEW.roomId) + ), + ) + } + + @Test + fun `given initial unreads, when reading a duplicate unread, then emits nothing`() = runTest { + fakeRoomStore.givenUnreadEvents( + flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE)) + ) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo emptyList() + } + + private fun givenNoInitialUnreads(vararg unreads: Map>) = fakeRoomStore.givenUnreadEvents(flowOf(NO_UNREADS, *unreads)) +} + +private fun RoomOverview.withUnreads(vararg events: RoomEvent) = mapOf(this to events.toList()) +private fun RoomOverview.toDiff(vararg events: RoomEvent) = mapOf(this.roomId to events.map { it.eventId }) diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt new file mode 100644 index 0000000..6cc7836 --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt @@ -0,0 +1,46 @@ +package app.dapk.st.notifications + +import fake.FakeNotificationChannels +import fake.FakeNotificationRenderer +import fake.FakeObserveUnreadNotificationsUseCase +import fixture.NotificationDiffFixtures.aNotificationDiff +import kotlinx.coroutines.test.runTest +import org.junit.Test +import test.expect + +private val AN_UNREAD_NOTIFICATIONS = UnreadNotifications(emptyMap(), aNotificationDiff()) + +class RenderNotificationsUseCaseTest { + + private val fakeNotificationRenderer = FakeNotificationRenderer() + private val fakeObserveUnreadNotificationsUseCase = FakeObserveUnreadNotificationsUseCase() + private val fakeNotificationChannels = FakeNotificationChannels().also { + it.instance.expect { it.initChannels() } + } + + private val renderNotificationsUseCase = RenderNotificationsUseCase( + fakeNotificationRenderer.instance, + fakeObserveUnreadNotificationsUseCase, + fakeNotificationChannels.instance, + ) + + @Test + fun `given events, when listening for changes then initiates channels once`() = runTest { + fakeNotificationRenderer.instance.expect { it.render(any()) } + fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS) + + renderNotificationsUseCase.listenForNotificationChanges() + + fakeNotificationChannels.verifyInitiated() + } + + @Test + fun `given renderable unread events, when listening for changes, then renders change`() = runTest { + fakeNotificationRenderer.instance.expect { it.render(any()) } + fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS) + + renderNotificationsUseCase.listenForNotificationChanges() + + fakeNotificationRenderer.verifyRenders(AN_UNREAD_NOTIFICATIONS) + } +} diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapperTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapperTest.kt new file mode 100644 index 0000000..11d8241 --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapperTest.kt @@ -0,0 +1,73 @@ +package app.dapk.st.notifications + +import fixture.aRoomImageMessageEvent +import fixture.aRoomMessageEvent +import fixture.aRoomReplyMessageEvent +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +class RoomEventsToNotifiableMapperTest { + + private val mapper = RoomEventsToNotifiableMapper() + + @Test + fun `given message event, when mapping, then uses original content`() { + val event = aRoomMessageEvent() + + val result = mapper.map(listOf(event)) + + result shouldBeEqualTo listOf( + Notifiable( + content = event.content, + utcTimestamp = event.utcTimestamp, + author = event.author + ) + ) + } + + @Test + fun `given image event, when mapping, then replaces content with camera emoji`() { + val event = aRoomImageMessageEvent() + + val result = mapper.map(listOf(event)) + + result shouldBeEqualTo listOf( + Notifiable( + content = "📷", + utcTimestamp = event.utcTimestamp, + author = event.author + ) + ) + } + + @Test + fun `given reply event with message, when mapping, then uses message for content`() { + val reply = aRoomMessageEvent(utcTimestamp = -1, content = "hello") + val event = aRoomReplyMessageEvent(reply, replyingTo = aRoomImageMessageEvent(utcTimestamp = -1)) + + val result = mapper.map(listOf(event)) + + result shouldBeEqualTo listOf( + Notifiable( + content = reply.content, + utcTimestamp = event.utcTimestamp, + author = event.author + ) + ) + } + + @Test + fun `given reply event with image, when mapping, then uses camera emoji for content`() { + val event = aRoomReplyMessageEvent(aRoomImageMessageEvent(utcTimestamp = -1)) + + val result = mapper.map(listOf(event)) + + result shouldBeEqualTo listOf( + Notifiable( + content = "📷", + utcTimestamp = event.utcTimestamp, + author = event.author + ) + ) + } +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fake/FakeNotificationChannels.kt b/features/notifications/src/test/kotlin/fake/FakeNotificationChannels.kt new file mode 100644 index 0000000..ed51858 --- /dev/null +++ b/features/notifications/src/test/kotlin/fake/FakeNotificationChannels.kt @@ -0,0 +1,13 @@ +package fake + +import app.dapk.st.notifications.NotificationChannels +import io.mockk.mockk +import io.mockk.verify + +class FakeNotificationChannels { + val instance = mockk() + + fun verifyInitiated() { + verify { instance.initChannels() } + } +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fake/FakeNotificationFactory.kt b/features/notifications/src/test/kotlin/fake/FakeNotificationFactory.kt new file mode 100644 index 0000000..012fc25 --- /dev/null +++ b/features/notifications/src/test/kotlin/fake/FakeNotificationFactory.kt @@ -0,0 +1,15 @@ +package fake + +import app.dapk.st.notifications.NotificationState +import app.dapk.st.notifications.NotificationStateMapper +import io.mockk.coEvery +import io.mockk.mockk +import test.delegateReturn + +class FakeNotificationFactory { + + val instance = mockk() + + fun givenNotifications(state: NotificationState) = coEvery { instance.mapToNotifications(state) }.delegateReturn() + +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fake/FakeNotificationRenderer.kt b/features/notifications/src/test/kotlin/fake/FakeNotificationRenderer.kt new file mode 100644 index 0000000..fcdc118 --- /dev/null +++ b/features/notifications/src/test/kotlin/fake/FakeNotificationRenderer.kt @@ -0,0 +1,26 @@ +package fake + +import app.dapk.st.notifications.NotificationRenderer +import app.dapk.st.notifications.NotificationState +import app.dapk.st.notifications.UnreadNotifications +import io.mockk.coVerify +import io.mockk.mockk + +class FakeNotificationRenderer { + val instance = mockk() + + fun verifyRenders(vararg unreadNotifications: UnreadNotifications) { + unreadNotifications.forEach { unread -> + coVerify { + instance.render( + NotificationState( + allUnread = unread.first, + removedRooms = unread.second.removed.keys, + roomsWithNewEvents = unread.second.changedOrNew.keys, + newRooms = unread.second.newRooms, + ) + ) + } + } + } +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fake/FakeObserveUnreadNotificationsUseCase.kt b/features/notifications/src/test/kotlin/fake/FakeObserveUnreadNotificationsUseCase.kt new file mode 100644 index 0000000..dd881d2 --- /dev/null +++ b/features/notifications/src/test/kotlin/fake/FakeObserveUnreadNotificationsUseCase.kt @@ -0,0 +1,10 @@ +package fake + +import app.dapk.st.notifications.ObserveUnreadNotificationsUseCase +import io.mockk.coEvery +import io.mockk.mockk +import test.delegateEmit + +class FakeObserveUnreadNotificationsUseCase : ObserveUnreadNotificationsUseCase by mockk() { + fun given() = coEvery { this@FakeObserveUnreadNotificationsUseCase.invoke() }.delegateEmit() +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fixture/NotificationDelegateFixtures.kt b/features/notifications/src/test/kotlin/fixture/NotificationDelegateFixtures.kt new file mode 100644 index 0000000..93f5718 --- /dev/null +++ b/features/notifications/src/test/kotlin/fixture/NotificationDelegateFixtures.kt @@ -0,0 +1,65 @@ +package fixture + +import android.app.PendingIntent +import android.graphics.drawable.Icon +import app.dapk.st.notifications.AndroidNotification +import app.dapk.st.notifications.AndroidNotificationStyle +import io.mockk.mockk + +object NotificationDelegateFixtures { + + fun anAndroidNotification( + channelId: String = "a channel id", + whenTimestamp: Long? = 10000, + isGroupSummary: Boolean = false, + groupId: String? = "group id", + groupAlertBehavior: Int? = 5, + shortcutId: String? = "shortcut id", + alertMoreThanOnce: Boolean = false, + contentIntent: PendingIntent? = mockk(), + messageStyle: AndroidNotificationStyle? = aMessagingStyle(), + category: String? = "a category", + smallIcon: Int? = 500, + largeIcon: Icon? = mockk(), + autoCancel: Boolean = true, + ) = AndroidNotification( + channelId = channelId, + whenTimestamp = whenTimestamp, + isGroupSummary = isGroupSummary, + groupId = groupId, + groupAlertBehavior = groupAlertBehavior, + shortcutId = shortcutId, + alertMoreThanOnce = alertMoreThanOnce, + contentIntent = contentIntent, + messageStyle = messageStyle, + category = category, + smallIcon = smallIcon, + largeIcon = largeIcon, + autoCancel = autoCancel, + ) + + + fun aMessagingStyle() = AndroidNotificationStyle.Messaging( + anAndroidPerson(), + title = null, + isGroup = false, + content = listOf( + AndroidNotificationStyle.Messaging.AndroidMessage( + anAndroidPerson(), content = "message content", + timestamp = 1000 + ) + ) + ) + + fun anInboxStyle() = AndroidNotificationStyle.Inbox( + lines = listOf("first line"), + summary = null, + ) + + fun anAndroidPerson( + name: String = "a name", + key: String = "a unique key", + icon: Icon? = null, + ) = AndroidNotificationStyle.Messaging.AndroidPerson(name, key, icon) + +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fixture/NotificationDiffFixtures.kt b/features/notifications/src/test/kotlin/fixture/NotificationDiffFixtures.kt new file mode 100644 index 0000000..7b9f0e9 --- /dev/null +++ b/features/notifications/src/test/kotlin/fixture/NotificationDiffFixtures.kt @@ -0,0 +1,16 @@ +package fixture + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.notifications.NotificationDiff + +object NotificationDiffFixtures { + + fun aNotificationDiff( + unchanged: Map> = emptyMap(), + changedOrNew: Map> = emptyMap(), + removed: Map> = emptyMap(), + newRooms: Set = emptySet(), + ) = NotificationDiff(unchanged, changedOrNew, removed, newRooms) + +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fixture/NotificationFixtures.kt b/features/notifications/src/test/kotlin/fixture/NotificationFixtures.kt new file mode 100644 index 0000000..46d824a --- /dev/null +++ b/features/notifications/src/test/kotlin/fixture/NotificationFixtures.kt @@ -0,0 +1,35 @@ +package fixture + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.notifications.AndroidNotification +import app.dapk.st.notifications.NotificationTypes +import app.dapk.st.notifications.Notifications +import fixture.NotificationDelegateFixtures.anAndroidNotification + +object NotificationFixtures { + + fun aNotifications( + summaryNotification: AndroidNotification? = null, + delegates: List = emptyList(), + ) = Notifications(summaryNotification, delegates) + + fun aRoomNotification( + notification: AndroidNotification = anAndroidNotification(), + summary: String = "a summary line", + messageCount: Int = 1, + isAlerting: Boolean = false, + summaryChannelId: String = "a-summary-channel-id", + ) = NotificationTypes.Room( + notification, + aRoomId(), + summary = summary, + messageCount = messageCount, + isAlerting = isAlerting, + summaryChannelId = summaryChannelId + ) + + fun aDismissRoomNotification( + roomId: RoomId = aRoomId() + ) = NotificationTypes.DismissRoom(roomId) + +} \ No newline at end of file diff --git a/features/profile/build.gradle b/features/profile/build.gradle index efb3058..6a2f7b0 100644 --- a/features/profile/build.gradle +++ b/features/profile/build.gradle @@ -1,11 +1,12 @@ -applyAndroidLibraryModule(project) +applyAndroidComposeLibraryModule(project) dependencies { implementation project(":matrix:services:sync") + implementation project(":matrix:services:room") implementation project(":matrix:services:profile") implementation project(":features:settings") implementation project(':domains:store') - implementation project(":domains:android:core") + implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") implementation project(":design-library") implementation project(":core") diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt index cc83b7b..43d7d44 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt @@ -1,16 +1,20 @@ package app.dapk.st.profile import app.dapk.st.core.ProvidableModule +import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.sync.SyncService class ProfileModule( private val profileService: ProfileService, private val syncService: SyncService, + private val roomService: RoomService, + private val errorTracker: ErrorTracker, ) : ProvidableModule { fun profileViewModel(): ProfileViewModel { - return ProfileViewModel(profileService, syncService) + return ProfileViewModel(profileService, syncService, roomService, errorTracker) } } \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt index 5587329..7dbb2a3 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt @@ -1,12 +1,13 @@ package app.dapk.st.profile +import android.content.Context import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.Settings @@ -16,25 +17,44 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import app.dapk.st.core.Lce import app.dapk.st.core.LifecycleEffect import app.dapk.st.core.StartObserving import app.dapk.st.core.components.CenteredLoading -import app.dapk.st.design.components.CircleishAvatar -import app.dapk.st.design.components.Spider -import app.dapk.st.design.components.TextRow -import app.dapk.st.design.components.percentOfHeight +import app.dapk.st.design.components.* +import app.dapk.st.matrix.sync.InviteMeta +import app.dapk.st.matrix.sync.RoomInvite import app.dapk.st.settings.SettingsActivity @Composable -fun ProfileScreen(viewModel: ProfileViewModel) { +fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) { viewModel.ObserveEvents() - LifecycleEffect(onStart = { - viewModel.start() - }) + LifecycleEffect( + onStart = { viewModel.start() }, + onStop = { viewModel.stop() } + ) val context = LocalContext.current + val onNavigate: (SpiderPage?) -> Unit = { + when (it) { + null -> onTopLevelBack() + else -> viewModel.goTo(it) + } + } + Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) { + item(Page.Routes.profile) { + ProfilePage(context, viewModel, it) + } + item(Page.Routes.invitation) { + Invitations(viewModel, it) + } + } +} + +@Composable +private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile: Page.Profile) { Box( modifier = Modifier .fillMaxWidth() @@ -45,20 +65,21 @@ fun ProfileScreen(viewModel: ProfileViewModel) { } } - when (val state = viewModel.state) { - ProfileScreenState.Loading -> CenteredLoading() - is ProfileScreenState.Content -> { + when (val state = profile.content) { + is Lce.Loading -> CenteredLoading() + is Lce.Error -> GenericError { viewModel.start() } + is Lce.Content -> { val configuration = LocalConfiguration.current - + val content = state.value Column { Spacer(modifier = Modifier.fillMaxHeight(0.05f)) Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - val fallbackLabel = state.me.displayName ?: state.me.userId.value + val fallbackLabel = content.me.displayName ?: content.me.userId.value val avatarSize = configuration.percentOfHeight(0.2f) Box { - CircleishAvatar(state.me.avatarUrl?.value, fallbackLabel, avatarSize) + CircleishAvatar(content.me.avatarUrl?.value, fallbackLabel, avatarSize) - // TODO enable once edit support it added + // TODO enable once edit support is added if (false) { IconButton(modifier = Modifier .size(avatarSize * 0.314f) @@ -76,20 +97,21 @@ fun ProfileScreen(viewModel: ProfileViewModel) { TextRow( title = "Display name", - content = state.me.displayName ?: "Not set", + content = content.me.displayName ?: "Not set", ) TextRow( title = "User id", - content = state.me.userId.value, + content = content.me.userId.value, ) TextRow( title = "Homeserver", - content = state.me.homeServerUrl.value, + content = content.me.homeServerUrl.value, ) TextRow( title = "Invitations", - content = "${state.invitationsCount} pending", + content = "${content.invitationsCount} pending", + onClick = { viewModel.goToInvitations() } ) } } @@ -97,12 +119,45 @@ fun ProfileScreen(viewModel: ProfileViewModel) { } @Composable -private fun ProfileViewModel.ObserveEvents() { - val context = LocalContext.current - StartObserving { - this@ObserveEvents.events.launch { - when (it) { +private fun Invitations(viewModel: ProfileViewModel, invitations: Page.Invitations) { + when (val state = invitations.content) { + is Lce.Loading -> CenteredLoading() + is Lce.Content -> { + LazyColumn { + items(state.value) { + val text = when (val meta = it.inviteMeta) { + InviteMeta.DirectMessage -> "${it.inviterName()} has invited you to chat" + is InviteMeta.Room -> "${it.inviterName()} has invited you to ${meta.roomName ?: "unnamed room"}" + } + + TextRow(title = text, includeDivider = false) { + Spacer(modifier = Modifier.height(4.dp)) + Row { + Button(modifier = Modifier.weight(1f), onClick = { viewModel.rejectRoomInvite(it.roomId) }) { + Text("Reject".uppercase()) + } + Spacer(modifier = Modifier.fillMaxWidth(0.1f)) + Button(modifier = Modifier.weight(1f), onClick = { viewModel.acceptRoomInvite(it.roomId) }) { + Text("Accept".uppercase()) + } + } + } + + } } } + is Lce.Error -> TODO() } +} + +private fun RoomInvite.inviterName() = this.from.displayName?.let { "$it (${this.from.id.value})" } ?: this.from.id.value + +@Composable +private fun ProfileViewModel.ObserveEvents() { +// StartObserving { +// this@ObserveEvents.events.launch { +// when (it) { +// } +// } +// } } \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt index ab5acac..5e0b6e2 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt @@ -1,14 +1,30 @@ package app.dapk.st.profile +import app.dapk.st.core.Lce +import app.dapk.st.design.components.Route +import app.dapk.st.design.components.SpiderPage +import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.matrix.sync.RoomInvite -sealed interface ProfileScreenState { - object Loading : ProfileScreenState - data class Content( - val me: ProfileService.Me, - val invitationsCount: Int, - ) : ProfileScreenState +data class ProfileScreenState( + val page: SpiderPage, +) +sealed interface Page { + data class Profile(val content: Lce) : Page { + data class Content( + val me: ProfileService.Me, + val invitationsCount: Int, + ) + } + + data class Invitations(val content: Lce>): Page + + object Routes { + val profile = Route("Profile") + val invitation = Route("Invitations") + } } sealed interface ProfileEvent { diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt index 3bb17b2..ff4a017 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt @@ -1,27 +1,83 @@ package app.dapk.st.profile import androidx.lifecycle.viewModelScope +import app.dapk.st.core.Lce +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.design.components.SpiderPage +import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.sync.SyncService import app.dapk.st.viewmodel.DapkViewModel -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch class ProfileViewModel( private val profileService: ProfileService, private val syncService: SyncService, + private val roomService: RoomService, + private val errorTracker: ErrorTracker, ) : DapkViewModel( - initialState = ProfileScreenState.Loading + ProfileScreenState(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false)) ) { + private var syncingJob: Job? = null + private var currentPageJob: Job? = null + fun start() { - viewModelScope.launch { - val invitationsCount = syncService.invites().firstOrNull()?.size ?: 0 - val me = profileService.me(forceRefresh = true) - state = ProfileScreenState.Content(me, invitationsCount = invitationsCount) + goToProfile() + } + + private fun goToProfile() { + syncingJob = syncService.startSyncing().launchIn(viewModelScope) + + combine( + flow { + val result = runCatching { profileService.me(forceRefresh = true) } + .onFailure { errorTracker.track(it, "Loading profile") } + emit(result) + }, + syncService.invites(), + transform = { me, invites -> me to invites } + ) + .onEach { (me, invites) -> + updatePageState { + when (me.isSuccess) { + true -> copy(content = Lce.Content(Page.Profile.Content(me.getOrThrow(), invites.size))) + false -> copy(content = Lce.Error(me.exceptionOrNull()!!)) + } + } + } + .launchPageJob() + } + + + fun goToInvitations() { + updateState { copy(page = SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()))) } + + syncService.invites() + .onEach { + updatePageState { + copy(content = Lce.Content(it)) + } + } + .launchPageJob() + } + + fun goTo(page: SpiderPage) { + currentPageJob?.cancel() + updateState { copy(page = page) } + when (page.state) { + is Page.Invitations -> goToInvitations() + is Page.Profile -> goToProfile() } } + private fun Flow.launchPageJob() { + currentPageJob?.cancel() + currentPageJob = this.launchIn(viewModelScope) + } fun updateDisplayName() { // TODO @@ -31,4 +87,40 @@ class ProfileViewModel( // TODO } + fun acceptRoomInvite(roomId: RoomId) { + launchCatching { roomService.joinRoom(roomId) }.fold( + onError = {} + ) + } + + fun rejectRoomInvite(roomId: RoomId) { + launchCatching { roomService.rejectJoinRoom(roomId) }.fold( + onError = {} + ) + } + + fun stop() { + syncingJob?.cancel() + } + + @Suppress("UNCHECKED_CAST") + private inline fun updatePageState(crossinline block: S.() -> S) { + val page = state.page + val currentState = page.state + require(currentState is S) + updateState { copy(page = (page as SpiderPage).copy(state = block(page.state))) } + } + } + +fun DapkViewModel.launchCatching(block: suspend () -> T): LaunchCatching { + return object : LaunchCatching { + override fun fold(onSuccess: (T) -> Unit, onError: (Throwable) -> Unit) { + viewModelScope.launch { runCatching { block() }.fold(onSuccess, onError) } + } + } +} + +interface LaunchCatching { + fun fold(onSuccess: (T) -> Unit = {}, onError: (Throwable) -> Unit = {}) +} \ No newline at end of file diff --git a/features/settings/build.gradle b/features/settings/build.gradle index 4a91dc2..00ef2c0 100644 --- a/features/settings/build.gradle +++ b/features/settings/build.gradle @@ -1,11 +1,12 @@ -applyAndroidLibraryModule(project) +applyAndroidComposeLibraryModule(project) dependencies { implementation project(":matrix:services:sync") implementation project(":matrix:services:crypto") implementation project(":features:navigator") implementation project(':domains:store') - implementation project(":domains:android:core") + implementation project(':domains:android:push') + implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") implementation project(":design-library") implementation project(":core") diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsActivity.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsActivity.kt index f89d251..471d387 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsActivity.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsActivity.kt @@ -7,6 +7,7 @@ import androidx.compose.material.Surface import androidx.compose.ui.Modifier import app.dapk.st.core.DapkActivity import app.dapk.st.core.module +import app.dapk.st.core.resetModules import app.dapk.st.core.viewModel import app.dapk.st.design.components.SmallTalkTheme @@ -20,6 +21,7 @@ class SettingsActivity : DapkActivity() { SmallTalkTheme { Surface(Modifier.fillMaxSize()) { SettingsScreen(settingsViewModel, onSignOut = { + resetModules() navigator.navigate.toHome() finish() }, navigator) diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt index 4068ff4..fbb7547 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt @@ -1,13 +1,20 @@ package app.dapk.st.settings import app.dapk.st.core.BuildMeta +import app.dapk.st.push.PushTokenRegistrars -internal class SettingsItemFactory(private val buildMeta: BuildMeta) { +internal class SettingsItemFactory( + private val buildMeta: BuildMeta, + private val pushTokenRegistrars: PushTokenRegistrars, +) { - fun root() = listOf( + suspend fun root() = listOf( SettingItem.Header("General"), SettingItem.Text(SettingItem.Id.Encryption, "Encryption"), SettingItem.Text(SettingItem.Id.EventLog, "Event log"), + SettingItem.Text(SettingItem.Id.PushProvider, "Push provider", pushTokenRegistrars.currentSelection().id), + SettingItem.Header("Data"), + SettingItem.Text(SettingItem.Id.ClearCache, "Clear cache"), SettingItem.Header("Account"), SettingItem.Text(SettingItem.Id.SignOut, "Sign out"), SettingItem.Header("About"), diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt index 00a0d67..0e93856 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt @@ -7,10 +7,12 @@ import app.dapk.st.core.ProvidableModule import app.dapk.st.domain.StoreModule import app.dapk.st.matrix.crypto.CryptoService import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.push.PushModule import app.dapk.st.settings.eventlogger.EventLoggerViewModel class SettingsModule( private val storeModule: StoreModule, + private val pushModule: PushModule, private val cryptoService: CryptoService, private val syncService: SyncService, private val contentResolver: ContentResolver, @@ -19,13 +21,13 @@ class SettingsModule( ) : ProvidableModule { internal fun settingsViewModel() = SettingsViewModel( - storeModule.credentialsStore(), storeModule.cacheCleaner(), contentResolver, cryptoService, syncService, UriFilenameResolver(contentResolver, coroutineDispatchers), - SettingsItemFactory(buildMeta), + SettingsItemFactory(buildMeta, pushModule.pushTokenRegistrars()), + pushModule.pushTokenRegistrars(), ) internal fun eventLogViewModel(): EventLoggerViewModel { diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt index 6495835..7b1862d 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt @@ -21,10 +21,12 @@ import androidx.compose.material.icons.outlined.Lock import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -45,6 +47,7 @@ import app.dapk.st.navigator.Navigator import app.dapk.st.settings.SettingsEvent.* import app.dapk.st.settings.eventlogger.EventLogActivity +@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, navigator: Navigator) { viewModel.ObserveEvents(onSignOut) @@ -65,6 +68,9 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, item(Page.Routes.encryption) { Encryption(viewModel, it) } + item(Page.Routes.pushProviders) { + PushProviders(viewModel, it) + } item(Page.Routes.importRoomKeys) { when (it.importProgress) { null -> { @@ -80,7 +86,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, viewModel.fileSelected(it) } } - + val keyboardController = LocalSoftwareKeyboardController.current Button(modifier = Modifier.fillMaxWidth(), onClick = { launcher.launch("text/*") }) { Text(text = "SELECT FILE".uppercase()) } @@ -92,7 +98,10 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, var passphrase by rememberSaveable { mutableStateOf("") } var passwordVisibility by rememberSaveable { mutableStateOf(false) } - val startImportAction = { viewModel.importFromFileKeys(it.selectedFile.uri, passphrase) } + val startImportAction = { + keyboardController?.hide() + viewModel.importFromFileKeys(it.selectedFile.uri, passphrase) + } TextField( modifier = Modifier.fillMaxWidth(), @@ -126,6 +135,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, } } } + is Lce.Content -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -136,6 +146,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, } } } + is Lce.Error -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -146,6 +157,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, } } } + is Lce.Loading -> CenteredLoading() } } @@ -170,6 +182,7 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) { SettingsTextRow(item.content, item.subtitle, itemOnClick) } + is SettingItem.AccessToken -> { Row( Modifier @@ -187,24 +200,57 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) { } } } + is SettingItem.Header -> Header(item.label) } } item { Spacer(Modifier.height(12.dp)) } } } + + is Lce.Error -> { + // TODO + } + + is Lce.Loading -> { + // TODO + } } } @Composable private fun Encryption(viewModel: SettingsViewModel, page: Page.Security) { Column { - TextRow("Import room keys", includeDivider = false) { - viewModel.goToImportRoom() - } + TextRow("Import room keys", includeDivider = false, onClick = { viewModel.goToImportRoom() }) } } + +@Composable +private fun PushProviders(viewModel: SettingsViewModel, state: Page.PushProviders) { + LaunchedEffect(true) { + viewModel.fetchPushProviders() + } + + when (val lce = state.options) { + null -> {} + is Lce.Loading -> CenteredLoading() + is Lce.Content -> { + LazyColumn { + items(lce.value) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = it == state.selection, onClick = { viewModel.selectPushProvider(it) }) + Text(it.id) + } + } + } + } + + is Lce.Error -> TODO() + } +} + + @Composable private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) { val context = LocalContext.current @@ -217,10 +263,12 @@ private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) { clipboard.setPrimaryClip(ClipData.newPlainText("dapk token", it.content)) Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() } + is SettingsEvent.Toast -> Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() OpenEventLog -> { context.startActivity(Intent(context, EventLogActivity::class.java)) } + is OpenUrl -> { context.startActivity(Intent(Intent.ACTION_VIEW).apply { data = it.url.toUri() }) } diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt index 467627c..fa78752 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt @@ -4,6 +4,7 @@ import android.net.Uri import app.dapk.st.core.Lce import app.dapk.st.design.components.Route import app.dapk.st.design.components.SpiderPage +import app.dapk.st.push.Registrar internal data class SettingsScreenState( val page: SpiderPage, @@ -17,9 +18,15 @@ internal sealed interface Page { val importProgress: Lce? = null, ) : Page + data class PushProviders( + val selection: Registrar? = null, + val options: Lce>? = Lce.Loading() + ) : Page + object Routes { val root = Route("Settings") val encryption = Route("Encryption") + val pushProviders = Route("PushProviders") val importRoomKeys = Route("ImportRoomKey") } } @@ -42,6 +49,7 @@ internal sealed interface SettingItem { AccessToken, ClearCache, EventLog, + PushProvider, Encryption, PrivacyPolicy, Ignored, diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt index 1847878..6644542 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt @@ -6,9 +6,10 @@ import androidx.lifecycle.viewModelScope import app.dapk.st.core.Lce import app.dapk.st.design.components.SpiderPage import app.dapk.st.domain.StoreCleaner -import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.crypto.CryptoService import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.push.PushTokenRegistrars +import app.dapk.st.push.Registrar import app.dapk.st.settings.SettingItem.Id.* import app.dapk.st.settings.SettingsEvent.* import app.dapk.st.viewmodel.DapkViewModel @@ -19,13 +20,13 @@ import kotlinx.coroutines.launch private const val PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/" internal class SettingsViewModel( - private val credentialsStore: CredentialsStore, private val cacheCleaner: StoreCleaner, private val contentResolver: ContentResolver, private val cryptoService: CryptoService, private val syncService: SyncService, private val uriFilenameResolver: UriFilenameResolver, private val settingsItemFactory: SettingsItemFactory, + private val pushTokenRegistrars: PushTokenRegistrars, factory: MutableStateFactory = defaultStateFactory(), ) : DapkViewModel( initialState = SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading()))), @@ -37,7 +38,6 @@ internal class SettingsViewModel( val root = Page.Root(Lce.Content(settingsItemFactory.root())) val rootPage = SpiderPage(Page.Routes.root, "Settings", null, root) updateState { copy(page = rootPage) } - println("state updated") } } @@ -49,44 +49,76 @@ internal class SettingsViewModel( when (item.id) { SignOut -> { viewModelScope.launch { - credentialsStore.clear() + cacheCleaner.cleanCache(removeCredentials = true) _events.emit(SignedOut) - println("emitted") } } + AccessToken -> { viewModelScope.launch { require(item is SettingItem.AccessToken) _events.emit(CopyToClipboard("Token copied", item.accessToken)) } } + ClearCache -> { viewModelScope.launch { cacheCleaner.cleanCache(removeCredentials = false) _events.emit(Toast(message = "Cache deleted")) } } + EventLog -> { viewModelScope.launch { _events.emit(OpenEventLog) } } + Encryption -> { updateState { copy(page = SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security)) } } + PrivacyPolicy -> { viewModelScope.launch { _events.emit(OpenUrl(PRIVACY_POLICY_URL)) } } + + PushProvider -> { + updateState { + copy(page = SpiderPage(Page.Routes.pushProviders, "Push providers", Page.Routes.root, Page.PushProviders())) + } + } + Ignored -> { // do nothing } } } + fun fetchPushProviders() { + updatePageState { copy(options = Lce.Loading()) } + viewModelScope.launch { + val currentSelection = pushTokenRegistrars.currentSelection() + val options = pushTokenRegistrars.options() + updatePageState { + copy( + selection = currentSelection, + options = Lce.Content(options) + ) + } + } + } + + fun selectPushProvider(registrar: Registrar) { + viewModelScope.launch { + pushTokenRegistrars.makeSelection(registrar) + fetchPushProviders() + } + } + fun importFromFileKeys(file: Uri, passphrase: String) { updatePageState { copy(importProgress = Lce.Loading()) } viewModelScope.launch { diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt index 885c985..43a24dd 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt @@ -42,6 +42,13 @@ fun EventLogScreen(viewModel: EventLoggerViewModel) { } } } + + is Lce.Error -> { + // TODO + } + is Lce.Loading -> { + // TODO + } } } diff --git a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt index 6d9be17..d8885d9 100644 --- a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt +++ b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt @@ -1,25 +1,40 @@ package app.dapk.st.settings import app.dapk.st.core.BuildMeta +import app.dapk.st.push.PushTokenRegistrars +import app.dapk.st.push.Registrar import internalfixture.aSettingHeaderItem import internalfixture.aSettingTextItem +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.Test +import test.delegateReturn + +private val A_SELECTION = Registrar("A_SELECTION") class SettingsItemFactoryTest { - private val buildMeta = BuildMeta(versionName = "a-version-name") + private val buildMeta = BuildMeta(versionName = "a-version-name", versionCode = 100) + private val fakePushTokenRegistrars = FakePushRegistrars() - private val settingsItemFactory = SettingsItemFactory(buildMeta) + private val settingsItemFactory = SettingsItemFactory(buildMeta, fakePushTokenRegistrars.instance) @Test - fun `when creating root items, then is expected`() { + fun `when creating root items, then is expected`() = runTest { + fakePushTokenRegistrars.givenCurrentSelection().returns(A_SELECTION) + val result = settingsItemFactory.root() result shouldBeEqualTo listOf( aSettingHeaderItem("General"), aSettingTextItem(SettingItem.Id.Encryption, "Encryption"), aSettingTextItem(SettingItem.Id.EventLog, "Event log"), + aSettingTextItem(SettingItem.Id.PushProvider, "Push provider", A_SELECTION.id), + aSettingHeaderItem("Data"), + aSettingTextItem(SettingItem.Id.ClearCache, "Clear cache"), aSettingHeaderItem("Account"), aSettingTextItem(SettingItem.Id.SignOut, "Sign out"), aSettingHeaderItem("About"), @@ -27,4 +42,12 @@ class SettingsItemFactoryTest { aSettingTextItem(SettingItem.Id.Ignored, "Version", buildMeta.versionName), ) } +} + +class FakePushRegistrars { + + val instance = mockk() + + fun givenCurrentSelection() = coEvery { instance.currentSelection() }.delegateReturn() + } \ No newline at end of file diff --git a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt index 7d9c152..5fd5279 100644 --- a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt +++ b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt @@ -29,22 +29,22 @@ internal class SettingsViewModelTest { private val runViewModelTest = ViewModelTest() - private val fakeCredentialsStore = FakeCredentialsStore() private val fakeStoreCleaner = FakeStoreCleaner() private val fakeContentResolver = FakeContentResolver() private val fakeCryptoService = FakeCryptoService() private val fakeSyncService = FakeSyncService() private val fakeUriFilenameResolver = FakeUriFilenameResolver() + private val fakePushTokenRegistrars = FakePushRegistrars() private val fakeSettingsItemFactory = FakeSettingsItemFactory() private val viewModel = SettingsViewModel( - fakeCredentialsStore, fakeStoreCleaner, fakeContentResolver.instance, fakeCryptoService, fakeSyncService, fakeUriFilenameResolver.instance, fakeSettingsItemFactory.instance, + fakePushTokenRegistrars.instance, runViewModelTest.testMutableStateFactory(), ) @@ -77,8 +77,8 @@ internal class SettingsViewModelTest { } @Test - fun `when sign out clicked, then clears credentials`() = runViewModelTest { - fakeCredentialsStore.expectUnit { it.clear() } + fun `when sign out clicked, then clears store`() = runViewModelTest { + fakeStoreCleaner.expectUnit { it.cleanCache(removeCredentials = true) } val aSignOutItem = aSettingTextItem(id = SettingItem.Id.SignOut) viewModel.test().onClick(aSignOutItem) diff --git a/features/settings/src/test/kotlin/internalfake/FakeSettingsItemFactory.kt b/features/settings/src/test/kotlin/internalfake/FakeSettingsItemFactory.kt index 1b90817..226cec9 100644 --- a/features/settings/src/test/kotlin/internalfake/FakeSettingsItemFactory.kt +++ b/features/settings/src/test/kotlin/internalfake/FakeSettingsItemFactory.kt @@ -1,6 +1,7 @@ package internalfake import app.dapk.st.settings.SettingsItemFactory +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import test.delegateReturn @@ -8,5 +9,5 @@ import test.delegateReturn internal class FakeSettingsItemFactory { val instance = mockk() - fun givenRoot() = every { instance.root() }.delegateReturn() + fun givenRoot() = coEvery { instance.root() }.delegateReturn() } \ No newline at end of file diff --git a/features/share-entry/build.gradle b/features/share-entry/build.gradle new file mode 100644 index 0000000..814f902 --- /dev/null +++ b/features/share-entry/build.gradle @@ -0,0 +1,13 @@ +applyAndroidComposeLibraryModule(project) + +dependencies { + implementation project(":domains:android:compose-core") + implementation project(":domains:android:viewmodel") + implementation project(':domains:store') + implementation project(':matrix:services:sync') + implementation project(':matrix:services:room') + implementation project(':matrix:services:message') + implementation project(":core") + implementation project(":design-library") + implementation project(":features:navigator") +} \ No newline at end of file diff --git a/features/share-entry/src/main/AndroidManifest.xml b/features/share-entry/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2a83f28 --- /dev/null +++ b/features/share-entry/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/features/share-entry/src/main/kotlin/app/dapk/st/share/FetchRoomsUseCase.kt b/features/share-entry/src/main/kotlin/app/dapk/st/share/FetchRoomsUseCase.kt new file mode 100644 index 0000000..18aca0c --- /dev/null +++ b/features/share-entry/src/main/kotlin/app/dapk/st/share/FetchRoomsUseCase.kt @@ -0,0 +1,22 @@ +package app.dapk.st.share + +import app.dapk.st.matrix.room.RoomService +import app.dapk.st.matrix.sync.SyncService +import kotlinx.coroutines.flow.first + +class FetchRoomsUseCase( + private val syncSyncService: SyncService, + private val roomService: RoomService, +) { + + suspend fun bar(): List { + return syncSyncService.overview().first().map { + Item( + it.roomId, + it.roomAvatarUrl, + it.roomName ?: "", + roomService.findMembersSummary(it.roomId).map { it.displayName ?: it.id.value } + ) + } + } +} \ No newline at end of file diff --git a/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryActivity.kt b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryActivity.kt new file mode 100644 index 0000000..da44704 --- /dev/null +++ b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryActivity.kt @@ -0,0 +1,52 @@ +package app.dapk.st.share + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Parcelable +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.ui.Modifier +import app.dapk.st.core.DapkActivity +import app.dapk.st.core.module +import app.dapk.st.core.viewModel +import app.dapk.st.design.components.SmallTalkTheme + +class ShareEntryActivity : DapkActivity() { + + private val viewModel by viewModel { module().shareEntryViewModel() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val urisToShare = intent.readSendUrisOrNull() ?: throw IllegalArgumentException("Expected deeplink uris but they were missing") + setContent { + SmallTalkTheme { + Surface(Modifier.fillMaxSize()) { + ShareEntryScreen(navigator, viewModel) + } + } + } + viewModel.withUris(urisToShare) + } +} + +private fun Intent.readSendUrisOrNull(): List? { + return when (this.action) { + Intent.ACTION_SEND -> { + if (this.hasExtra(Intent.EXTRA_STREAM)) { + listOf(this.getParcelableExtra(Intent.EXTRA_STREAM) as Uri) + } else { + null + } + } + Intent.ACTION_SEND_MULTIPLE -> { + if (this.hasExtra(Intent.EXTRA_STREAM)) { + (this.getParcelableArrayExtra(Intent.EXTRA_STREAM) as Array).toList() + } else { + null + } + } + else -> null + } +} \ No newline at end of file diff --git a/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryModule.kt b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryModule.kt new file mode 100644 index 0000000..ac0f61b --- /dev/null +++ b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryModule.kt @@ -0,0 +1,15 @@ +package app.dapk.st.share + +import app.dapk.st.core.ProvidableModule +import app.dapk.st.matrix.room.RoomService +import app.dapk.st.matrix.sync.SyncService + +class ShareEntryModule( + private val syncService: SyncService, + private val roomService: RoomService, +) : ProvidableModule { + + fun shareEntryViewModel(): ShareEntryViewModel { + return ShareEntryViewModel(FetchRoomsUseCase(syncService, roomService)) + } +} \ No newline at end of file diff --git a/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryScreen.kt b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryScreen.kt new file mode 100644 index 0000000..1dbc7e7 --- /dev/null +++ b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryScreen.kt @@ -0,0 +1,121 @@ +package app.dapk.st.share + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +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.sp +import app.dapk.st.core.LifecycleEffect +import app.dapk.st.core.MimeType +import app.dapk.st.core.StartObserving +import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.design.components.CircleishAvatar +import app.dapk.st.design.components.GenericEmpty +import app.dapk.st.design.components.GenericError +import app.dapk.st.design.components.Toolbar +import app.dapk.st.navigator.MessageAttachment +import app.dapk.st.navigator.Navigator +import app.dapk.st.share.DirectoryScreenState.* + +@Composable +fun ShareEntryScreen(navigator: Navigator, viewModel: ShareEntryViewModel) { + val state = viewModel.state + viewModel.ObserveEvents(navigator) + + LifecycleEffect( + onStart = { viewModel.start() }, + onStop = { viewModel.stop() } + ) + + val listState: LazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = 0, + ) + Box(modifier = Modifier.fillMaxSize()) { + Toolbar(title = "Send to...") + when (state) { + EmptyLoading -> CenteredLoading() + Empty -> GenericEmpty() + is Error -> GenericError { + // TODO + } + is Content -> Content(listState, state) { + viewModel.onRoomSelected(it) + } + } + } +} + +@Composable +private fun ShareEntryViewModel.ObserveEvents(navigator: Navigator) { + StartObserving { + this@ObserveEvents.events.launch { + when (it) { + is DirectoryEvent.SelectRoom -> { + navigator.navigate.toMessenger(it.item.id, it.uris.map { MessageAttachment(it, MimeType.Image) }) + } + } + } + } +} + + +@Composable +private fun Content(listState: LazyListState, state: Content, onClick: (Item) -> Unit) { + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(top = 72.dp)) { + items( + items = state.items, + key = { it.id.value }, + ) { + DirectoryItem(it, onClick = onClick) + } + } +} + +@Composable +private fun DirectoryItem(item: Item, onClick: (Item) -> Unit) { + val roomName = item.roomName.ifEmpty { "Empty " } + + Box( + Modifier + .height(IntrinsicSize.Min) + .fillMaxWidth() + .clickable { onClick(item) } + ) { + Row(Modifier.padding(20.dp)) { + val secondaryText = MaterialTheme.colors.onBackground.copy(alpha = 0.5f) + + Box(Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) { + CircleishAvatar(item.roomAvatarUrl?.value, roomName, size = 50.dp) + } + Spacer(Modifier.width(20.dp)) + Column { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.weight(1f), + maxLines = 1, + fontSize = 18.sp, + text = roomName, + overflow = TextOverflow.Ellipsis, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.onBackground + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Text(text = item.members.joinToString(), color = secondaryText, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + diff --git a/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryState.kt b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryState.kt new file mode 100644 index 0000000..57a21f3 --- /dev/null +++ b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryState.kt @@ -0,0 +1,20 @@ +package app.dapk.st.share + +import app.dapk.st.core.AndroidUri +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.common.RoomId + +sealed interface DirectoryScreenState { + + object EmptyLoading : DirectoryScreenState + object Empty : DirectoryScreenState + data class Content( + val items: List, + ) : DirectoryScreenState +} + +sealed interface DirectoryEvent { + data class SelectRoom(val item: Item, val uris: List) : DirectoryEvent +} + +data class Item(val id: RoomId, val roomAvatarUrl: AvatarUrl?, val roomName: String, val members: List) diff --git a/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryViewModel.kt b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryViewModel.kt new file mode 100644 index 0000000..ddd6fbe --- /dev/null +++ b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryViewModel.kt @@ -0,0 +1,43 @@ +package app.dapk.st.share + +import android.net.Uri +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.AndroidUri +import app.dapk.st.viewmodel.DapkViewModel +import app.dapk.st.viewmodel.MutableStateFactory +import app.dapk.st.viewmodel.defaultStateFactory +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class ShareEntryViewModel( + private val fetchRoomsUseCase: FetchRoomsUseCase, + factory: MutableStateFactory = defaultStateFactory(), +) : DapkViewModel( + initialState = DirectoryScreenState.EmptyLoading, + factory, +) { + + private var urisToShare: List? = null + private var syncJob: Job? = null + + fun start() { + syncJob = viewModelScope.launch { + state = DirectoryScreenState.Content(fetchRoomsUseCase.bar()) + } + } + + fun stop() { + syncJob?.cancel() + } + + fun withUris(urisToShare: List) { + this.urisToShare = urisToShare.map { AndroidUri(it.toString()) } + } + + fun onRoomSelected(item: Item) { + viewModelScope.launch { + _events.emit(DirectoryEvent.SelectRoom(item, uris = urisToShare ?: throw IllegalArgumentException("Not uris set"))) + } + } + +} diff --git a/features/verification/build.gradle b/features/verification/build.gradle index 8a489fc..989463e 100644 --- a/features/verification/build.gradle +++ b/features/verification/build.gradle @@ -1,8 +1,8 @@ -applyAndroidLibraryModule(project) +applyAndroidComposeLibraryModule(project) dependencies { implementation project(":matrix:services:crypto") - implementation project(":domains:android:core") + implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") implementation project(":design-library") implementation project(":core") diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180..249e583 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 41dfb87..b916c04 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionSha256Sum=f6b8596b10cce501591e92f229816aa4046424f3b24d771751b06779d58c8ec4 +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c787..a69d9cb 100755 --- a/gradlew +++ b/gradlew @@ -205,6 +205,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index ac1b06f..53a6b23 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 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 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventType.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventType.kt index 4f1c309..85a6e4e 100644 --- a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventType.kt +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/EventType.kt @@ -14,5 +14,6 @@ enum class EventType(val value: String) { } enum class MessageType(val value: String) { - TEXT("m.text") + TEXT("m.text"), + IMAGE("m.image"), } \ No newline at end of file diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MatrixLogger.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MatrixLogger.kt index ad710ca..738813d 100644 --- a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MatrixLogger.kt +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/MatrixLogger.kt @@ -21,7 +21,30 @@ fun MatrixLogger.matrixLog(message: Any) { matrixLog(tag = MatrixLogTag.MATRIX, message = message) } -suspend fun MatrixLogger.logP(area: String, block: suspend () -> T): T { +fun MatrixLogger.logP(area: String): PerfTracker { + val start = System.currentTimeMillis() + var lastCheckpoint = start + return object : PerfTracker { + override fun checkpoint(label: String) { + val now = System.currentTimeMillis() + val timeTaken = (now - lastCheckpoint) + lastCheckpoint = now + matrixLog(MatrixLogTag.PERF, "$area - $label: took $timeTaken ms") + } + + override fun stop() { + val timeTaken = System.currentTimeMillis() - start + matrixLog(MatrixLogTag.PERF, "$area: took $timeTaken ms") + } + } +} + +interface PerfTracker { + fun checkpoint(label: String) + fun stop() +} + +inline fun MatrixLogger.logP(area: String, block: () -> T): T { val start = System.currentTimeMillis() return block().also { val timeTaken = System.currentTimeMillis() - start diff --git a/matrix/matrix-http-ktor/build.gradle b/matrix/matrix-http-ktor/build.gradle index 00105fb..bf47bed 100644 --- a/matrix/matrix-http-ktor/build.gradle +++ b/matrix/matrix-http-ktor/build.gradle @@ -9,5 +9,6 @@ dependencies { implementation Dependencies.mavenCentral.ktorCore implementation Dependencies.mavenCentral.ktorSerialization implementation Dependencies.mavenCentral.ktorLogging - implementation Dependencies.mavenCentral.kotlinSerializationJson + implementation Dependencies.mavenCentral.ktorContentNegotiation + implementation Dependencies.mavenCentral.ktorJson } \ No newline at end of file diff --git a/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/KtorMatrixHttpClientFactory.kt b/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/KtorMatrixHttpClientFactory.kt index 2ccb558..3dcb046 100644 --- a/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/KtorMatrixHttpClientFactory.kt +++ b/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/KtorMatrixHttpClientFactory.kt @@ -4,10 +4,9 @@ import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ktor.internal.KtorMatrixHttpClient import io.ktor.client.* -import io.ktor.client.features.json.* -import io.ktor.client.features.json.serializer.* -import io.ktor.client.features.logging.* -import io.ktor.http.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json class KtorMatrixHttpClientFactory( @@ -15,12 +14,12 @@ class KtorMatrixHttpClientFactory( private val includeLogging: Boolean, ) : MatrixHttpClient.Factory { - override fun create(json: Json): MatrixHttpClient { + override fun create(jsonInstance: Json): MatrixHttpClient { val client = HttpClient { - install(JsonFeature) { - serializer = KotlinxSerializer(json) + install(ContentNegotiation) { + json(jsonInstance) } - + expectSuccess = true if (includeLogging) { install(Logging) { logger = Logger.SIMPLE diff --git a/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/internal/KtorMatrixHttpClient.kt b/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/internal/KtorMatrixHttpClient.kt index fe2725b..5cb157e 100644 --- a/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/internal/KtorMatrixHttpClient.kt +++ b/matrix/matrix-http-ktor/src/main/kotlin/app/dapk/st/matrix/http/ktor/internal/KtorMatrixHttpClient.kt @@ -5,10 +5,10 @@ import app.dapk.st.matrix.common.UserCredentials import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.MatrixHttpClient.Method import io.ktor.client.* -import io.ktor.client.features.* +import io.ktor.client.plugins.* import io.ktor.client.request.* -import io.ktor.client.statement.* import io.ktor.http.* +import io.ktor.util.* internal class KtorMatrixHttpClient( private val client: HttpClient, @@ -67,6 +67,7 @@ internal class KtorMatrixHttpClient( // return tokenResult.accessToken } + @OptIn(InternalAPI::class) private fun HttpRequestBuilder.buildRequest( credentials: UserCredentials?, request: MatrixHttpClient.HttpRequest @@ -100,7 +101,7 @@ internal class KtorMatrixHttpClient( @Suppress("UNCHECKED_CAST") private suspend fun MatrixHttpClient.HttpRequest.execute(requestBuilder: HttpRequestBuilder.() -> Unit): T { - return client.request { requestBuilder(this) }.call.receive(this.typeInfo) as T + return client.request { requestBuilder(this) }.call.body(this.typeInfo) as T } } diff --git a/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/HttpExtensions.kt b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/HttpExtensions.kt index 2074396..edf24b3 100644 --- a/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/HttpExtensions.kt +++ b/matrix/matrix-http/src/main/kotlin/app/dapk/st/matrix/http/HttpExtensions.kt @@ -4,6 +4,6 @@ fun String.ensureTrailingSlash(): String { return if (this.endsWith("/")) this else "$this/" } -fun String.ensureHttps(): String { - return if (this.startsWith("https")) this else "https://$this" +fun String.ensureHttpsIfMissing(): String { + return if (this.startsWith("http")) this else "https://$this" } diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt index caa47d2..95db7f4 100644 --- a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt @@ -10,16 +10,24 @@ import app.dapk.st.matrix.common.UserCredentials private val SERVICE_KEY = AuthService::class interface AuthService : MatrixService { - suspend fun login(userName: String, password: String): UserCredentials + suspend fun login(request: LoginRequest): LoginResult suspend fun register(userName: String, password: String, homeServer: String): UserCredentials + + + sealed interface LoginResult { + data class Success(val userCredentials: UserCredentials) : LoginResult + object MissingWellKnown : LoginResult + data class Error(val cause: Throwable) : LoginResult + } + + data class LoginRequest(val userName: String, val password: String, val serverUrl: String?) } fun MatrixServiceInstaller.installAuthService( credentialsStore: CredentialsStore, - authConfig: AuthConfig = AuthConfig(), ) { this.install { (httpClient, json) -> - SERVICE_KEY to DefaultAuthService(httpClient, credentialsStore, json, authConfig) + SERVICE_KEY to DefaultAuthService(httpClient, credentialsStore, json) } } diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/AuthRequest.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/AuthRequest.kt index 015a7fa..ef48a15 100644 --- a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/AuthRequest.kt +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/AuthRequest.kt @@ -42,13 +42,17 @@ internal fun registerRequest(userName: String, password: String, baseUrl: String baseUrl = baseUrl, ) -internal fun wellKnownRequest(baseUrl: String) = httpRequest( +internal fun wellKnownRequest(baseUrl: String) = httpRequest( path = ".well-known/matrix/client", method = MatrixHttpClient.Method.GET, baseUrl = baseUrl, authenticated = false, ) +typealias RawResponse = ByteArray + +fun RawResponse.readString() = this.toString(Charsets.UTF_8) + internal data class Auth( val session: String, val type: String, diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/DefaultAuthService.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/DefaultAuthService.kt index b86eb87..c017b27 100644 --- a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/DefaultAuthService.kt +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/DefaultAuthService.kt @@ -1,25 +1,33 @@ package app.dapk.st.matrix.auth.internal -import app.dapk.st.matrix.auth.AuthConfig import app.dapk.st.matrix.auth.AuthService import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.HomeServerUrl import app.dapk.st.matrix.common.UserCredentials import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.ensureHttpsIfMissing +import app.dapk.st.matrix.http.ensureTrailingSlash import kotlinx.serialization.json.Json internal class DefaultAuthService( httpClient: MatrixHttpClient, credentialsStore: CredentialsStore, json: Json, - authConfig: AuthConfig, ) : AuthService { private val fetchWellKnownUseCase = FetchWellKnownUseCaseImpl(httpClient, json) - private val loginUseCase = LoginUseCase(httpClient, credentialsStore, fetchWellKnownUseCase, authConfig) - private val registerCase = RegisterUseCase(httpClient, credentialsStore, json, fetchWellKnownUseCase, authConfig) + private val loginUseCase = LoginWithUserPasswordUseCase(httpClient, credentialsStore, fetchWellKnownUseCase) + private val loginServerUseCase = LoginWithUserPasswordServerUseCase(httpClient, credentialsStore) + private val registerCase = RegisterUseCase(httpClient, credentialsStore, json, fetchWellKnownUseCase) - override suspend fun login(userName: String, password: String): UserCredentials { - return loginUseCase.login(userName, password) + override suspend fun login(request: AuthService.LoginRequest): AuthService.LoginResult { + return when { + request.serverUrl == null -> loginUseCase.login(request.userName, request.password) + else -> { + val serverUrl = HomeServerUrl(request.serverUrl.ensureHttpsIfMissing().ensureTrailingSlash()) + loginServerUseCase.login(request.userName, request.password, serverUrl) + } + } } override suspend fun register(userName: String, password: String, homeServer: String): UserCredentials { diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/FetchWellKnownUseCaseImpl.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/FetchWellKnownUseCaseImpl.kt index 308447d..cc175fa 100644 --- a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/FetchWellKnownUseCaseImpl.kt +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/FetchWellKnownUseCaseImpl.kt @@ -1,19 +1,51 @@ package app.dapk.st.matrix.auth.internal import app.dapk.st.matrix.http.MatrixHttpClient +import io.ktor.client.plugins.* +import io.ktor.http.* +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +import java.net.UnknownHostException +import java.nio.charset.Charset -internal typealias FetchWellKnownUseCase = suspend (String) -> ApiWellKnown +internal typealias FetchWellKnownUseCase = suspend (String) -> WellKnownResult internal class FetchWellKnownUseCaseImpl( private val httpClient: MatrixHttpClient, private val json: Json, ) : FetchWellKnownUseCase { - override suspend fun invoke(domainUrl: String): ApiWellKnown { - // workaround for matrix.org not returning a content-type - val raw = httpClient.execute(wellKnownRequest(domainUrl)) - return json.decodeFromString(ApiWellKnown.serializer(), raw) + override suspend fun invoke(domainUrl: String): WellKnownResult { + return runCatching { + val rawResponse = httpClient.execute(rawWellKnownRequestForServersWithoutContentTypes(domainUrl)) + json.decodeFromString(ApiWellKnown.serializer(), rawResponse.readString()) + } + .fold( + onSuccess = { WellKnownResult.Success(it) }, + onFailure = { + when (it) { + is UnknownHostException -> WellKnownResult.MissingWellKnown + is ClientRequestException -> when { + it.response.status.is404() -> WellKnownResult.MissingWellKnown + else -> WellKnownResult.Error(it) + } + is SerializationException -> WellKnownResult.InvalidWellKnown + else -> WellKnownResult.Error(it) + } + }, + ) } -} \ No newline at end of file + private fun rawWellKnownRequestForServersWithoutContentTypes(domainUrl: String) = wellKnownRequest(domainUrl) + +} + +sealed interface WellKnownResult { + data class Success(val wellKnown: ApiWellKnown) : WellKnownResult + object MissingWellKnown : WellKnownResult + object InvalidWellKnown : WellKnownResult + data class Error(val cause: Throwable) : WellKnownResult + +} + +fun HttpStatusCode.is404() = this.value == 404 \ No newline at end of file diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginWithUserPasswordServerUseCase.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginWithUserPasswordServerUseCase.kt new file mode 100644 index 0000000..aac15fe --- /dev/null +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginWithUserPasswordServerUseCase.kt @@ -0,0 +1,33 @@ +package app.dapk.st.matrix.auth.internal + +import app.dapk.st.matrix.auth.AuthService +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.http.MatrixHttpClient + +class LoginWithUserPasswordServerUseCase( + private val httpClient: MatrixHttpClient, + private val credentialsProvider: CredentialsStore, +) { + + suspend fun login(userName: String, password: String, serverUrl: HomeServerUrl): AuthService.LoginResult { + return runCatching { + authenticate(serverUrl, UserId(userName.substringBefore(":")), password) + }.fold( + onSuccess = { AuthService.LoginResult.Success(it) }, + onFailure = { AuthService.LoginResult.Error(it) } + ) + } + + private suspend fun authenticate(baseUrl: HomeServerUrl, fullUserId: UserId, password: String): UserCredentials { + val authResponse = httpClient.execute(loginRequest(fullUserId, password, baseUrl.value)) + return UserCredentials( + authResponse.accessToken, + baseUrl, + authResponse.userId, + authResponse.deviceId, + ).also { credentialsProvider.update(it) } + } +} \ No newline at end of file diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginUseCase.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginWithUserPasswordUseCase.kt similarity index 63% rename from matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginUseCase.kt rename to matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginWithUserPasswordUseCase.kt index ac73a8a..94eba51 100644 --- a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginUseCase.kt +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/LoginWithUserPasswordUseCase.kt @@ -1,6 +1,6 @@ package app.dapk.st.matrix.auth.internal -import app.dapk.st.matrix.auth.AuthConfig +import app.dapk.st.matrix.auth.AuthService import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.HomeServerUrl import app.dapk.st.matrix.common.UserCredentials @@ -10,23 +10,27 @@ import app.dapk.st.matrix.http.ensureTrailingSlash private const val MATRIX_DOT_ORG_DOMAIN = "matrix.org" -class LoginUseCase( +class LoginWithUserPasswordUseCase( private val httpClient: MatrixHttpClient, private val credentialsProvider: CredentialsStore, private val fetchWellKnownUseCase: FetchWellKnownUseCase, - private val authConfig: AuthConfig ) { - suspend fun login(userName: String, password: String): UserCredentials { + suspend fun login(userName: String, password: String): AuthService.LoginResult { val (domainUrl, fullUserId) = generateUserAccessInfo(userName) - val baseUrl = fetchWellKnownUseCase(domainUrl).homeServer.baseUrl.ensureTrailingSlash() - val authResponse = httpClient.execute(loginRequest(fullUserId, password, baseUrl.value)) - return UserCredentials( - authResponse.accessToken, - baseUrl, - authResponse.userId, - authResponse.deviceId, - ).also { credentialsProvider.update(it) } + return when (val wellKnownResult = fetchWellKnownUseCase(domainUrl)) { + is WellKnownResult.Success -> { + runCatching { + authenticate(wellKnownResult.wellKnown.homeServer.baseUrl.ensureTrailingSlash(), fullUserId, password) + }.fold( + onSuccess = { AuthService.LoginResult.Success(it) }, + onFailure = { AuthService.LoginResult.Error(it) } + ) + } + WellKnownResult.InvalidWellKnown -> AuthService.LoginResult.MissingWellKnown + WellKnownResult.MissingWellKnown -> AuthService.LoginResult.MissingWellKnown + is WellKnownResult.Error -> AuthService.LoginResult.Error(wellKnownResult.cause) + } } private fun generateUserAccessInfo(userName: String): Pair { @@ -37,14 +41,20 @@ class LoginUseCase( return Pair(domainUrl, UserId(fullUserId)) } + private suspend fun authenticate(baseUrl: HomeServerUrl, fullUserId: UserId, password: String): UserCredentials { + val authResponse = httpClient.execute(loginRequest(fullUserId, password, baseUrl.value)) + return UserCredentials( + authResponse.accessToken, + baseUrl, + authResponse.userId, + authResponse.deviceId, + ).also { credentialsProvider.update(it) } + } + private fun String.findDomain(fallback: String) = this.substringAfter(":", missingDelimiterValue = fallback) private fun String.asHttpsUrl(): String { - val schema = when (authConfig.forceHttp) { - true -> "http://" - false -> "https://" - } - return "$schema$this".ensureTrailingSlash() + return "https://$this".ensureTrailingSlash() } } diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/RegisterUseCase.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/RegisterUseCase.kt index e3438ab..d611e88 100644 --- a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/RegisterUseCase.kt +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/internal/RegisterUseCase.kt @@ -1,11 +1,10 @@ package app.dapk.st.matrix.auth.internal -import app.dapk.st.matrix.auth.AuthConfig import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.UserCredentials import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ensureTrailingSlash -import io.ktor.client.features.* +import io.ktor.client.plugins.* import io.ktor.client.statement.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -16,7 +15,6 @@ class RegisterUseCase( private val credentialsProvider: CredentialsStore, private val json: Json, private val fetchWellKnownUseCase: FetchWellKnownUseCase, - private val authConfig: AuthConfig, ) { suspend fun register(userName: String, password: String, homeServer: String): UserCredentials { @@ -28,7 +26,7 @@ class RegisterUseCase( } catch (error: ClientRequestException) { when (error.response.status.value) { 401 -> { - val stage0 = json.decodeFromString(ApiUserInteractive.serializer(), error.response.readText()) + val stage0 = json.decodeFromString(ApiUserInteractive.serializer(), error.response.bodyAsText()) val supportsDummy = stage0.flows.any { it.stages.any { it == "m.login.dummy" } } if (supportsDummy) { registerAccount(userName, password, baseUrl, stage0.session) @@ -46,7 +44,12 @@ class RegisterUseCase( registerRequest(userName, password, baseUrl, Auth(session, "m.login.dummy")) ) val homeServerUrl = when (authResponse.wellKnown == null) { - true -> fetchWellKnownUseCase(baseUrl).homeServer.baseUrl + true -> when (val wellKnownResult = fetchWellKnownUseCase(baseUrl)) { + is WellKnownResult.Error, -> TODO() + WellKnownResult.InvalidWellKnown -> TODO() + WellKnownResult.MissingWellKnown -> TODO() + is WellKnownResult.Success -> wellKnownResult.wellKnown.homeServer.baseUrl + } false -> authResponse.wellKnown.homeServer.baseUrl } return UserCredentials( diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt index d7ae472..47bc545 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt @@ -1,5 +1,6 @@ package app.dapk.st.matrix.crypto +import app.dapk.st.core.Base64 import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixServiceInstaller @@ -118,6 +119,7 @@ fun MatrixServiceInstaller.installCryptoService( credentialsStore: CredentialsStore, olm: Olm, roomMembersProvider: ServiceDepFactory, + base64: Base64, coroutineDispatchers: CoroutineDispatchers, ) { this.install { (_, _, services, logger) -> @@ -148,7 +150,7 @@ fun MatrixServiceInstaller.installCryptoService( logger ) val verificationHandler = VerificationHandler(deviceService, credentialsStore, logger, JsonCanonicalizer(), olm) - val roomKeyImporter = RoomKeyImporter(coroutineDispatchers) + val roomKeyImporter = RoomKeyImporter(base64, coroutineDispatchers) SERVICE_KEY to DefaultCryptoService(olmCrypto, verificationHandler, roomKeyImporter, logger) } } diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt index 3904a93..3a559dd 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt @@ -1,5 +1,6 @@ package app.dapk.st.matrix.crypto.internal +import app.dapk.st.core.Base64 import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.withIoContext import app.dapk.st.matrix.common.AlgorithmName @@ -12,7 +13,6 @@ import kotlinx.serialization.json.Json import java.io.IOException import java.io.InputStream import java.nio.charset.Charset -import java.util.* import javax.crypto.Cipher import javax.crypto.Mac import javax.crypto.spec.IvParameterSpec @@ -23,11 +23,13 @@ private const val HEADER_LINE = "-----BEGIN MEGOLM SESSION DATA-----" private const val TRAILER_LINE = "-----END MEGOLM SESSION DATA-----" private val importJson = Json { ignoreUnknownKeys = true } -class RoomKeyImporter(private val dispatchers: CoroutineDispatchers) { +class RoomKeyImporter( + private val base64: Base64, + private val dispatchers: CoroutineDispatchers, +) { suspend fun InputStream.importRoomKeys(password: String, onChunk: suspend (List) -> Unit): List { return dispatchers.withIoContext { - val decoder = Base64.getDecoder() val decryptCipher = Cipher.getInstance("AES/CTR/NoPadding") var jsonSegment = "" @@ -61,7 +63,7 @@ class RoomKeyImporter(private val dispatchers: CoroutineDispatchers) { .withIndex() .map { (index, it) -> val line = it.joinToString(separator = "").replace("\n", "") - val toByteArray = decoder.decode(line) + val toByteArray = base64.decode(line) if (index == 0) { decryptCipher.initialize(toByteArray, password) toByteArray.copyOfRange(37, toByteArray.size).decrypt(decryptCipher).also { diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/VerificationHandler.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/VerificationHandler.kt index 035709f..47d6594 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/VerificationHandler.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/VerificationHandler.kt @@ -182,6 +182,9 @@ internal class VerificationHandler( sendToDevice(ToDevicePayload.VerificationDone(verificationTransaction.transactionId)) stateFlow.emit(Verification.State.Done) } + is Verification.Event.Done -> { + // TODO + } } } diff --git a/matrix/services/message/build.gradle b/matrix/services/message/build.gradle index 3dcc229..60041a2 100644 --- a/matrix/services/message/build.gradle +++ b/matrix/services/message/build.gradle @@ -1 +1,8 @@ +plugins { id 'java-test-fixtures' } applyMatrixServiceModule(project) + +dependencies { + kotlinFixtures(it) + testFixturesImplementation(testFixtures(project(":core"))) + testFixturesImplementation(testFixtures(project(":matrix:common"))) +} diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/ApiSendResponse.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/ApiSendResponse.kt index ebcefaf..97fdc57 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/ApiSendResponse.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/ApiSendResponse.kt @@ -1,10 +1,16 @@ package app.dapk.st.matrix.message import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.MxUrl import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class ApiSendResponse( @SerialName("event_id") val eventId: EventId, +) + +@Serializable +data class ApiUploadResponse( + @SerialName("content_uri") val contentUri: MxUrl, ) \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt index e49360a..2684194 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt @@ -4,15 +4,13 @@ import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixServiceInstaller import app.dapk.st.matrix.MatrixServiceProvider import app.dapk.st.matrix.ServiceDepFactory -import app.dapk.st.matrix.common.AlgorithmName -import app.dapk.st.matrix.common.EventId -import app.dapk.st.matrix.common.MessageType -import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.* import app.dapk.st.matrix.message.internal.DefaultMessageService +import app.dapk.st.matrix.message.internal.ImageContentReader import kotlinx.coroutines.flow.Flow import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.util.* +import kotlinx.serialization.Transient private val SERVICE_KEY = MessageService::class @@ -42,8 +40,18 @@ interface MessageService : MatrixService { @SerialName("content") val content: Content.TextContent, @SerialName("send_encrypted") val sendEncrypted: Boolean, @SerialName("room_id") val roomId: RoomId, - @SerialName("local_id") val localId: String = "local.${UUID.randomUUID()}", - @SerialName("timestamp") val timestampUtc: Long = System.currentTimeMillis(), + @SerialName("local_id") val localId: String, + @SerialName("timestamp") val timestampUtc: Long, + ) : Message() + + @Serializable + @SerialName("image_message") + data class ImageMessage( + @SerialName("content") val content: Content.ApiImageContent, + @SerialName("send_encrypted") val sendEncrypted: Boolean, + @SerialName("room_id") val roomId: RoomId, + @SerialName("local_id") val localId: String, + @SerialName("timestamp") val timestampUtc: Long, ) : Message() @Serializable @@ -53,6 +61,27 @@ interface MessageService : MatrixService { @SerialName("body") val body: String, @SerialName("msgtype") val type: String = MessageType.TEXT.value, ) : Content() + + @Serializable + data class ApiImageContent( + @SerialName("uri") val uri: String, + ) : Content() + + @Serializable + data class ImageContent( + @SerialName("url") val url: MxUrl, + @SerialName("body") val filename: String, + @SerialName("info") val info: Info, + @SerialName("msgtype") val type: String = MessageType.IMAGE.value, + ) : Content() { + + @Serializable + data class Info( + @SerialName("h") val height: Int, + @SerialName("w") val width: Int, + @SerialName("size") val size: Long, + ) + } } } @@ -66,16 +95,19 @@ interface MessageService : MatrixService { @Transient val timestampUtc = when (message) { is Message.TextMessage -> message.timestampUtc + is Message.ImageMessage -> message.timestampUtc } @Transient val roomId = when (message) { is Message.TextMessage -> message.roomId + is Message.ImageMessage -> message.roomId } @Transient val localId = when (message) { is Message.TextMessage -> message.localId + is Message.ImageMessage -> message.localId } @Serializable @@ -108,10 +140,11 @@ interface MessageService : MatrixService { fun MatrixServiceInstaller.installMessageService( localEchoStore: LocalEchoStore, backgroundScheduler: BackgroundScheduler, + imageContentReader: ImageContentReader, messageEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMessageEncrypter }, ) { this.install { (httpClient, _, installedServices) -> - SERVICE_KEY to DefaultMessageService(httpClient, localEchoStore, backgroundScheduler, messageEncrypter.create(installedServices)) + SERVICE_KEY to DefaultMessageService(httpClient, localEchoStore, backgroundScheduler, messageEncrypter.create(installedServices), imageContentReader) } } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt index c4f6f72..14d4f08 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt @@ -12,21 +12,28 @@ import kotlinx.serialization.json.Json import java.net.SocketException import java.net.UnknownHostException +private const val MATRIX_MESSAGE_TASK_TYPE = "matrix-text-message" +private const val MATRIX_IMAGE_MESSAGE_TASK_TYPE = "matrix-image-message" + internal class DefaultMessageService( httpClient: MatrixHttpClient, private val localEchoStore: LocalEchoStore, private val backgroundScheduler: BackgroundScheduler, messageEncrypter: MessageEncrypter, + imageContentReader: ImageContentReader, ) : MessageService, MatrixTaskRunner { - private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter) + private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, imageContentReader) private val sendEventMessageUseCase = SendEventMessageUseCase(httpClient) - override suspend fun canRun(task: MatrixTaskRunner.MatrixTask) = task.type == "text-message" + override suspend fun canRun(task: MatrixTaskRunner.MatrixTask) = task.type == MATRIX_MESSAGE_TASK_TYPE || task.type == MATRIX_IMAGE_MESSAGE_TASK_TYPE override suspend fun run(task: MatrixTaskRunner.MatrixTask): MatrixTaskRunner.TaskResult { - require(task.type == "text-message") - val message = Json.decodeFromString(MessageService.Message.TextMessage.serializer(), task.jsonPayload) + val message = when(task.type) { + MATRIX_MESSAGE_TASK_TYPE -> Json.decodeFromString(MessageService.Message.TextMessage.serializer(), task.jsonPayload) + MATRIX_IMAGE_MESSAGE_TASK_TYPE -> Json.decodeFromString(MessageService.Message.ImageMessage.serializer(), task.jsonPayload) + else -> throw IllegalStateException("Unhandled task type: ${task.type}") + } return try { sendMessage(message) MatrixTaskRunner.TaskResult.Success @@ -48,6 +55,7 @@ internal class DefaultMessageService( localEchoStore.markSending(message) val localId = when (message) { is MessageService.Message.TextMessage -> message.localId + is MessageService.Message.ImageMessage -> message.localId } backgroundScheduler.schedule(key = localId, message.toTask()) } @@ -61,8 +69,16 @@ internal class DefaultMessageService( private fun MessageService.Message.toTask(): BackgroundScheduler.Task { return when (this) { is MessageService.Message.TextMessage -> { - BackgroundScheduler.Task(type = "text-message", Json.encodeToString(MessageService.Message.TextMessage.serializer(), this)) + BackgroundScheduler.Task( + type = MATRIX_MESSAGE_TASK_TYPE, + Json.encodeToString(MessageService.Message.TextMessage.serializer(), this) + ) } + + is MessageService.Message.ImageMessage -> BackgroundScheduler.Task( + type = MATRIX_IMAGE_MESSAGE_TASK_TYPE, + Json.encodeToString(MessageService.Message.ImageMessage.serializer(), this) + ) } } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt new file mode 100644 index 0000000..8395092 --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt @@ -0,0 +1,36 @@ +package app.dapk.st.matrix.message.internal + +interface ImageContentReader { + fun read(uri: String): ImageContent + + data class ImageContent( + val height: Int, + val width: Int, + val size: Long, + val fileName: String, + val mimeType: String, + val content: ByteArray + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ImageContent + + if (height != other.height) return false + if (width != other.width) return false + if (size != other.size) return false + if (!content.contentEquals(other.content)) return false + + return true + } + + override fun hashCode(): Int { + var result = height + result = 31 * result + width + result = 31 * result + size.hashCode() + result = 31 * result + content.contentHashCode() + return result + } + } +} \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index 6a6e2be..b094e4f 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -9,6 +9,7 @@ import app.dapk.st.matrix.message.MessageService internal class SendMessageUseCase( private val httpClient: MatrixHttpClient, private val messageEncrypter: MessageEncrypter, + private val imageContentReader: ImageContentReader, ) { suspend fun sendMessage(message: MessageService.Message): EventId { @@ -23,6 +24,7 @@ internal class SendMessageUseCase( content = messageEncrypter.encrypt(message), ) } + false -> { sendRequest( roomId = message.roomId, @@ -34,6 +36,26 @@ internal class SendMessageUseCase( } httpClient.execute(request).eventId } + + is MessageService.Message.ImageMessage -> { + val imageContent = imageContentReader.read(message.content.uri) + val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri + val request = sendRequest( + roomId = message.roomId, + eventType = EventType.ROOM_MESSAGE, + txId = message.localId, + content = MessageService.Message.Content.ImageContent( + url = uri, + filename = imageContent.fileName, + MessageService.Message.Content.ImageContent.Info( + height = imageContent.height, + width = imageContent.width, + size = imageContent.size + ) + ), + ) + httpClient.execute(request).eventId + } } } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt index 53df1c3..5cf1d64 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt @@ -6,9 +6,12 @@ import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest.Companion.httpRequest import app.dapk.st.matrix.http.jsonBody import app.dapk.st.matrix.message.ApiSendResponse +import app.dapk.st.matrix.message.ApiUploadResponse import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.EventMessage import app.dapk.st.matrix.message.MessageService.Message +import io.ktor.content.* +import io.ktor.http.* import java.util.* internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: Message.Content) = httpRequest( @@ -16,6 +19,8 @@ internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, con method = MatrixHttpClient.Method.PUT, body = when (content) { is Message.Content.TextContent -> jsonBody(Message.Content.TextContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) + is Message.Content.ImageContent -> jsonBody(Message.Content.ImageContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) + is Message.Content.ApiImageContent -> throw IllegalArgumentException() } ) @@ -33,4 +38,12 @@ internal fun sendRequest(roomId: RoomId, eventType: EventType, content: EventMes } ) +internal fun uploadRequest(body: ByteArray, filename: String, contentType: String) = httpRequest( + path = "_matrix/media/r0/upload/?filename=$filename", + headers = listOf("Content-Type" to contentType), + method = MatrixHttpClient.Method.POST, + body = ByteArrayContent(body, ContentType.parse(contentType)), +) + + fun txId() = "local.${UUID.randomUUID()}" \ No newline at end of file diff --git a/matrix/services/message/src/testFixtures/kotlin/fixture/LocalEchoFixture.kt b/matrix/services/message/src/testFixtures/kotlin/fixture/LocalEchoFixture.kt new file mode 100644 index 0000000..28506b2 --- /dev/null +++ b/matrix/services/message/src/testFixtures/kotlin/fixture/LocalEchoFixture.kt @@ -0,0 +1,10 @@ +package fixture + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.message.MessageService + +fun aLocalEcho( + eventId: EventId? = anEventId(), + message: MessageService.Message = aTextMessage(), + state: MessageService.LocalEcho.State = MessageService.LocalEcho.State.Sending, +) = MessageService.LocalEcho(eventId, message, state) diff --git a/matrix/services/message/src/testFixtures/kotlin/fixture/MessageFixture.kt b/matrix/services/message/src/testFixtures/kotlin/fixture/MessageFixture.kt new file mode 100644 index 0000000..79c9e57 --- /dev/null +++ b/matrix/services/message/src/testFixtures/kotlin/fixture/MessageFixture.kt @@ -0,0 +1,18 @@ +package fixture + +import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.message.MessageService + +fun aTextMessage( + content: MessageService.Message.Content.TextContent = aTextContent(), + sendEncrypted: Boolean = false, + roomId: RoomId = aRoomId(), + localId: String = "a-local-id", + timestampUtc: Long = 0, +) = MessageService.Message.TextMessage(content, sendEncrypted, roomId, localId, timestampUtc) + +fun aTextContent( + body: String = "text content body", + type: String = MessageType.TEXT.value, +) = MessageService.Message.Content.TextContent(body, type) diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt index b985c07..5402ed3 100644 --- a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt @@ -12,7 +12,7 @@ private val SERVICE_KEY = PushService::class interface PushService : MatrixService { - suspend fun registerPush(token: String) + suspend fun registerPush(token: String, gatewayUrl: String) @Serializable data class PushRequest( diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt index 7b9945e..92a462a 100644 --- a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt @@ -13,8 +13,8 @@ class DefaultPushService( private val useCase = RegisterPushUseCase(httpClient, credentialsStore, logger) - override suspend fun registerPush(token: String) { - useCase.registerPushToken(token) + override suspend fun registerPush(token: String, gatewayUrl: String) { + useCase.registerPushToken(token, gatewayUrl) } } \ No newline at end of file diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt index c1e8587..45711c3 100644 --- a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt @@ -13,7 +13,7 @@ internal class RegisterPushUseCase( private val logger: MatrixLogger, ) { - suspend fun registerPushToken(token: String) { + suspend fun registerPushToken(token: String, gatewayUrl: String) { if (credentialsStore.isSignedIn()) { logger.matrixLog("register push token: $token") matrixClient.execute( @@ -29,7 +29,7 @@ internal class RegisterPushUseCase( append = false, data = PushRequest.Payload( format = "event_id_only", - url = "https://sygnal.dapk.app/_matrix/push/v1/notify", + url = gatewayUrl, ), ) ) diff --git a/matrix/services/room/build.gradle b/matrix/services/room/build.gradle index 3dcc229..eaa3259 100644 --- a/matrix/services/room/build.gradle +++ b/matrix/services/room/build.gradle @@ -1 +1,5 @@ applyMatrixServiceModule(project) + +dependencies { + implementation project(":core") +} \ No newline at end of file diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt index 0f4164b..92bc1d0 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt @@ -10,6 +10,7 @@ import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.room.internal.DefaultRoomService import app.dapk.st.matrix.room.internal.RoomMembers +import app.dapk.st.matrix.room.internal.RoomMembersCache private val SERVICE_KEY = RoomService::class @@ -20,15 +21,17 @@ interface RoomService : MatrixService { suspend fun findMember(roomId: RoomId, userId: UserId): RoomMember? suspend fun findMembers(roomId: RoomId, userIds: List): List + suspend fun findMembersSummary(roomId: RoomId): List suspend fun insertMembers(roomId: RoomId, members: List) suspend fun createDm(userId: UserId, encrypted: Boolean): RoomId suspend fun joinRoom(roomId: RoomId) + suspend fun rejectJoinRoom(roomId: RoomId) data class JoinedMember( val userId: UserId, - val displayName: String, + val displayName: String?, val avatarUrl: String?, ) @@ -39,7 +42,7 @@ fun MatrixServiceInstaller.installRoomService( roomMessenger: ServiceDepFactory, ) { this.install { (httpClient, _, services, logger) -> - SERVICE_KEY to DefaultRoomService(httpClient, logger, RoomMembers(memberStore), roomMessenger.create(services)) + SERVICE_KEY to DefaultRoomService(httpClient, logger, RoomMembers(memberStore, RoomMembersCache()), roomMessenger.create(services)) } } @@ -48,6 +51,7 @@ fun MatrixServiceProvider.roomService(): RoomService = this.getService(key = SER interface MemberStore { suspend fun insert(roomId: RoomId, members: List) suspend fun query(roomId: RoomId, userIds: List): List + suspend fun query(roomId: RoomId, limit: Int): List } interface RoomMessenger { diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt index 686d9b2..4ad8ef5 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt @@ -39,6 +39,10 @@ class DefaultRoomService( return roomMembers.findMembers(roomId, userIds) } + override suspend fun findMembersSummary(roomId: RoomId): List { + return roomMembers.findMembersSummary(roomId) + } + override suspend fun insertMembers(roomId: RoomId, members: List) { roomMembers.insert(roomId, members) } @@ -62,6 +66,10 @@ class DefaultRoomService( override suspend fun joinRoom(roomId: RoomId) { httpClient.execute(joinRoomRequest(roomId)) } + + override suspend fun rejectJoinRoom(roomId: RoomId) { + httpClient.execute(rejectJoinRoomRequest(roomId)) + } } internal fun joinedMembersRequest(roomId: RoomId) = httpRequest( @@ -87,6 +95,13 @@ internal fun joinRoomRequest(roomId: RoomId) = httpRequest( body = emptyJsonBody() ) +internal fun rejectJoinRoomRequest(roomId: RoomId) = httpRequest( + path = "_matrix/client/r0/rooms/${roomId.value}/leave", + method = MatrixHttpClient.Method.POST, + body = emptyJsonBody() +) + + @Suppress("EnumEntryName") @Serializable enum class RoomVisibility { @@ -120,6 +135,6 @@ internal data class JoinedMembersResponse( @Serializable internal data class ApiJoinedMember( - @SerialName("display_name") val displayName: String, + @SerialName("display_name") val displayName: String? = null, @SerialName("avatar_url") val avatarUrl: String? = null, ) \ No newline at end of file diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomMembers.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomMembers.kt index ae156d2..9c7a03b 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomMembers.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomMembers.kt @@ -1,26 +1,26 @@ package app.dapk.st.matrix.room.internal +import app.dapk.st.core.LRUCache +import app.dapk.st.core.isNullOrEmpty import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.room.MemberStore -class RoomMembers(private val memberStore: MemberStore) { - - private val cache = mutableMapOf>() +class RoomMembers(private val memberStore: MemberStore, private val membersCache: RoomMembersCache) { suspend fun findMember(roomId: RoomId, userId: UserId): RoomMember? { return findMembers(roomId, listOf(userId)).firstOrNull() } suspend fun findMembers(roomId: RoomId, userIds: List): List { - val roomCache = cache[roomId] + val roomCache = membersCache.room(roomId) return if (roomCache.isNullOrEmpty()) { - memberStore.query(roomId, userIds).also { cache(roomId, it) } + memberStore.query(roomId, userIds).also { membersCache.insert(roomId, it) } } else { val (cachedMembers, missingIds) = userIds.fold(mutableListOf() to mutableListOf()) { acc, current -> - when (val member = roomCache[current]) { + when (val member = roomCache?.get(current)) { null -> acc.second.add(current) else -> acc.first.add(member) } @@ -29,21 +29,29 @@ class RoomMembers(private val memberStore: MemberStore) { when { missingIds.isNotEmpty() -> { - (memberStore.query(roomId, missingIds).also { cache(roomId, it) } + cachedMembers) + (memberStore.query(roomId, missingIds).also { membersCache.insert(roomId, it) } + cachedMembers) } else -> cachedMembers } } } + suspend fun findMembersSummary(roomId: RoomId) = memberStore.query(roomId, limit = 8) + suspend fun insert(roomId: RoomId, members: List) { - cache(roomId, members) + membersCache.insert(roomId, members) memberStore.insert(roomId, members) } +} - private fun cache(roomId: RoomId, members: List) { - val map = cache.getOrPut(roomId) { mutableMapOf() } - members.forEach { map[it.id] = it } +class RoomMembersCache { + + private val cache = LRUCache>(maxSize = 12) + + fun room(roomId: RoomId) = cache.get(roomId) + + fun insert(roomId: RoomId, members: List) { + val map = cache.getOrPut(roomId) { LRUCache(maxSize = 25) } + members.forEach { map.put(it.id, it) } } - -} \ No newline at end of file +} diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt index b914799..75bf8ad 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt @@ -31,5 +31,18 @@ data class LastMessage( @Serializable data class RoomInvite( + @SerialName("from") val from: RoomMember, @SerialName("room_id") val roomId: RoomId, + @SerialName("meta") val inviteMeta: InviteMeta, ) + +@Serializable +sealed class InviteMeta { + @Serializable + @SerialName("direct_message") + object DirectMessage : InviteMeta() + + @Serializable + @SerialName("room") + data class Room(val roomName: String? = null) : InviteMeta() +} diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt index 9eec44b..139c7cc 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt @@ -4,7 +4,6 @@ import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.matrix.common.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime @@ -23,6 +22,8 @@ sealed class RoomEvent { abstract val eventId: EventId abstract val utcTimestamp: Long + abstract val author: RoomMember + abstract val meta: MessageMeta @Serializable @SerialName("message") @@ -30,8 +31,8 @@ sealed class RoomEvent { @SerialName("event_id") override val eventId: EventId, @SerialName("timestamp") override val utcTimestamp: Long, @SerialName("content") val content: String, - @SerialName("author") val author: RoomMember, - @SerialName("meta") val meta: MessageMeta, + @SerialName("author") override val author: RoomMember, + @SerialName("meta") override val meta: MessageMeta, @SerialName("encrypted_content") val encryptedContent: MegOlmV1? = null, @SerialName("edited") val edited: Boolean = false, ) : RoomEvent() { @@ -44,7 +45,6 @@ sealed class RoomEvent { @SerialName("session_id") val sessionId: SessionId, ) - @Transient val time: String by unsafeLazy { val instant = Instant.ofEpochMilli(utcTimestamp) ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) @@ -54,22 +54,59 @@ sealed class RoomEvent { @Serializable @SerialName("reply") data class Reply( - @SerialName("message") val message: Message, - @SerialName("in_reply_to") val replyingTo: Message, + @SerialName("message") val message: RoomEvent, + @SerialName("in_reply_to") val replyingTo: RoomEvent, ) : RoomEvent() { override val eventId: EventId = message.eventId override val utcTimestamp: Long = message.utcTimestamp + override val author: RoomMember = message.author + override val meta: MessageMeta = message.meta val replyingToSelf = replyingTo.author == message.author - @Transient val time: String by unsafeLazy { val instant = Instant.ofEpochMilli(utcTimestamp) ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) } } + @Serializable + @SerialName("image") + data class Image( + @SerialName("event_id") override val eventId: EventId, + @SerialName("timestamp") override val utcTimestamp: Long, + @SerialName("image_meta") val imageMeta: ImageMeta, + @SerialName("author") override val author: RoomMember, + @SerialName("meta") override val meta: MessageMeta, + @SerialName("encrypted_content") val encryptedContent: Message.MegOlmV1? = null, + @SerialName("edited") val edited: Boolean = false, + ) : RoomEvent() { + + val time: String by unsafeLazy { + val instant = Instant.ofEpochMilli(utcTimestamp) + ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) + } + + @Serializable + data class ImageMeta( + @SerialName("width") val width: Int?, + @SerialName("height") val height: Int?, + @SerialName("url") val url: String, + @SerialName("keys") val keys: Keys?, + ) { + + @Serializable + data class Keys( + @SerialName("k") val k: String, + @SerialName("iv") val iv: String, + @SerialName("v") val v: String, + @SerialName("hashes") val hashes: Map, + ) + + } + } + } @Serializable diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt index e0f123e..6964ff4 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt @@ -8,13 +8,14 @@ import kotlinx.coroutines.flow.Flow interface RoomStore { suspend fun persist(roomId: RoomId, state: RoomState) + suspend fun remove(rooms: List) suspend fun retrieve(roomId: RoomId): RoomState? fun latest(roomId: RoomId): Flow suspend fun insertUnread(roomId: RoomId, eventIds: List) suspend fun markRead(roomId: RoomId) - suspend fun observeUnread(): Flow>> - suspend fun observeUnreadCountById(): Flow> - suspend fun observeEvent(eventId: EventId): Flow + fun observeUnread(): Flow>> + fun observeUnreadCountById(): Flow> + fun observeEvent(eventId: EventId): Flow suspend fun findEvent(eventId: EventId): RoomEvent? } @@ -22,12 +23,12 @@ interface RoomStore { interface FilterStore { suspend fun store(key: String, filterId: String) - suspend fun read(key: String): String? } interface OverviewStore { + suspend fun removeRooms(roomsToRemove: List) suspend fun persistInvites(invite: List) suspend fun persist(overviewState: OverviewState) @@ -35,6 +36,7 @@ interface OverviewStore { fun latest(): Flow fun latestInvites(): Flow> + suspend fun removeInvites(map: List) } interface SyncStore { diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt index eb8efba..f0c8530 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt @@ -8,7 +8,6 @@ import app.dapk.st.matrix.MatrixServiceInstaller import app.dapk.st.matrix.ServiceDepFactory import app.dapk.st.matrix.common.* import app.dapk.st.matrix.sync.internal.DefaultSyncService -import app.dapk.st.matrix.sync.internal.SideEffectFlowIterator import app.dapk.st.matrix.sync.internal.request.* import app.dapk.st.matrix.sync.internal.room.MessageDecrypter import app.dapk.st.matrix.sync.internal.room.MissingMessageDecrypter @@ -19,11 +18,11 @@ private val SERVICE_KEY = SyncService::class interface SyncService : MatrixService { - suspend fun invites(): Flow - suspend fun overview(): Flow - suspend fun room(roomId: RoomId): Flow - suspend fun startSyncing(): Flow - suspend fun events(): Flow> + fun invites(): Flow + fun overview(): Flow + fun room(roomId: RoomId): Flow + fun startSyncing(): Flow + fun events(): Flow> suspend fun observeEvent(eventId: EventId): Flow suspend fun forceManualRefresh(roomIds: List) @@ -129,6 +128,7 @@ internal object NoOpKeySharer : KeySharer { interface RoomMembersService { suspend fun find(roomId: RoomId, userIds: List): List + suspend fun findSummary(roomId: RoomId): List suspend fun insert(roomId: RoomId, members: List) } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt index 26d9885..bf8a7ae 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt @@ -2,6 +2,7 @@ package app.dapk.st.matrix.sync.internal import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.withIoContext import app.dapk.st.matrix.common.* import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.sync.* @@ -12,11 +13,15 @@ import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter import app.dapk.st.matrix.sync.internal.room.SyncEventDecrypter import app.dapk.st.matrix.sync.internal.room.SyncSideEffects import app.dapk.st.matrix.sync.internal.sync.* -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.* import kotlinx.serialization.json.Json import java.util.concurrent.atomic.AtomicInteger +private val syncSubscriptionCount = AtomicInteger() + internal class DefaultSyncService( httpClient: MatrixHttpClient, syncStore: SyncStore, @@ -30,15 +35,14 @@ internal class DefaultSyncService( json: Json, oneTimeKeyProducer: MaybeCreateMoreKeys, scope: CoroutineScope, - credentialsStore: CredentialsStore, + private val credentialsStore: CredentialsStore, roomMembersService: RoomMembersService, logger: MatrixLogger, errorTracker: ErrorTracker, - coroutineDispatchers: CoroutineDispatchers, + private val coroutineDispatchers: CoroutineDispatchers, syncConfig: SyncConfig, ) : SyncService { - private val syncSubscriptionCount = AtomicInteger() private val syncEventsFlow = MutableStateFlow>(emptyList()) private val roomDataSource by lazy { RoomDataSource(roomStore, logger) } @@ -53,17 +57,19 @@ internal class DefaultSyncService( roomMembersService, roomDataSource, TimelineEventsProcessor( - RoomEventCreator(roomMembersService, logger, errorTracker), + RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService)), roomEventsDecrypter, eventDecrypter, EventLookupUseCase(roomStore) ), RoomOverviewProcessor(roomMembersService), - UnreadEventsUseCase(roomStore, logger), + UnreadEventsProcessor(roomStore, logger), EphemeralEventsUseCase(roomMembersService, syncEventsFlow), ), roomRefresher, + roomDataSource, logger, + errorTracker, coroutineDispatchers, ) SyncUseCase( @@ -98,17 +104,17 @@ internal class DefaultSyncService( } } - override suspend fun startSyncing() = syncFlow - override suspend fun invites() = overviewStore.latestInvites() - override suspend fun overview() = overviewStore.latest() - override suspend fun room(roomId: RoomId) = roomStore.latest(roomId) - override suspend fun events() = syncEventsFlow + override fun startSyncing() = syncFlow + override fun invites() = overviewStore.latestInvites() + override fun overview() = overviewStore.latest() + override fun room(roomId: RoomId) = roomStore.latest(roomId) + override fun events() = syncEventsFlow override suspend fun observeEvent(eventId: EventId) = roomStore.observeEvent(eventId) override suspend fun forceManualRefresh(roomIds: List) { - withContext(Dispatchers.IO) { + coroutineDispatchers.withIoContext { roomIds.map { async { - roomRefresher.refreshRoomContent(it)?.also { + roomRefresher.refreshRoomContent(it, credentialsStore.credentials()!!)?.also { overviewStore.persist(listOf(it.roomOverview)) } } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/FlowIterator.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/FlowIterator.kt index 14be144..16a6a3b 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/FlowIterator.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/FlowIterator.kt @@ -6,15 +6,16 @@ import app.dapk.st.matrix.common.matrixLog import kotlinx.coroutines.* internal class SideEffectFlowIterator(private val logger: MatrixLogger) { - suspend fun loop(initial: T?, action: suspend (T?) -> T?) { + suspend fun loop(initial: T?, onPost: suspend () -> Unit, onIteration: suspend (T?) -> T?) { var previousState = initial while (currentCoroutineContext().isActive) { logger.matrixLog(SYNC, "loop iteration") try { previousState = withContext(NonCancellable) { - action(previousState) + onIteration(previousState) } + onPost() } catch (error: Throwable) { logger.matrixLog(SYNC, "on loop error: ${error.message}") error.printStackTrace() diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiAccountEvent.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiAccountEvent.kt new file mode 100644 index 0000000..6d0cb1b --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiAccountEvent.kt @@ -0,0 +1,33 @@ +package app.dapk.st.matrix.sync.internal.request + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class ApiAccountEvent { + + @Serializable + @SerialName("m.direct") + data class Direct( + @SerialName("content") val content: Map> + ) : ApiAccountEvent() + + @Serializable + @SerialName("m.fully_read") + data class FullyRead( + @SerialName("content") val content: Content, + ) : ApiAccountEvent() { + + @Serializable + data class Content( + @SerialName("event_id") val eventId: EventId, + ) + + } + + @Serializable + object Ignored : ApiAccountEvent() +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiEncryptedContent.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiEncryptedContent.kt new file mode 100644 index 0000000..5551ecd --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiEncryptedContent.kt @@ -0,0 +1,35 @@ +package app.dapk.st.matrix.sync.internal.request + +import app.dapk.st.matrix.common.CipherText +import app.dapk.st.matrix.common.Curve25519 +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.SessionId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable(with = EncryptedContentDeserializer::class) +internal sealed class ApiEncryptedContent { + @Serializable + data class OlmV1( + @SerialName("ciphertext") val cipherText: Map, + @SerialName("sender_key") val senderKey: Curve25519, + ) : ApiEncryptedContent() + + @Serializable + data class MegOlmV1( + @SerialName("ciphertext") val cipherText: CipherText, + @SerialName("device_id") val deviceId: DeviceId, + @SerialName("sender_key") val senderKey: String, + @SerialName("session_id") val sessionId: SessionId, + @SerialName("m.relates_to") val relation: ApiTimelineEvent.TimelineMessage.Relation? = null, + ) : ApiEncryptedContent() + + @Serializable + data class CipherTextInfo( + @SerialName("body") val body: CipherText, + @SerialName("type") val type: Int, + ) + + @Serializable + object Unknown : ApiEncryptedContent() +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiStrippedEvent.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiStrippedEvent.kt new file mode 100644 index 0000000..efaee47 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiStrippedEvent.kt @@ -0,0 +1,41 @@ +package app.dapk.st.matrix.sync.internal.request + +import app.dapk.st.matrix.common.MxUrl +import app.dapk.st.matrix.common.UserId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class ApiStrippedEvent { + + @Serializable + @SerialName("m.room.member") + internal data class RoomMember( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiStrippedEvent() { + + @Serializable + internal data class Content( + @SerialName("displayname") val displayName: String? = null, + @SerialName("membership") val membership: ApiTimelineEvent.RoomMember.Content.Membership? = null, + @SerialName("is_direct") val isDirect: Boolean? = null, + @SerialName("avatar_url") val avatarUrl: MxUrl? = null, + ) + } + + @Serializable + @SerialName("m.room.name") + internal data class RoomName( + @SerialName("content") val content: Content, + ) : ApiStrippedEvent() { + + @Serializable + internal data class Content( + @SerialName("name") val name: String? = null + ) + } + + @Serializable + object Ignored : ApiStrippedEvent() +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt index f068675..b4bc9e9 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt @@ -1,16 +1,11 @@ package app.dapk.st.matrix.sync.internal.request -import app.dapk.st.matrix.common.* -import kotlinx.serialization.KSerializer +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.ServerKeyCount +import app.dapk.st.matrix.common.SyncToken +import app.dapk.st.matrix.common.UserId import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive @Serializable internal data class ApiSyncResponse( @@ -28,32 +23,6 @@ data class ApiAccountData( @SerialName("events") val events: List ) -@Serializable -sealed class ApiAccountEvent { - - @Serializable - @SerialName("m.direct") - data class Direct( - @SerialName("content") val content: Map> - ) : ApiAccountEvent() - - @Serializable - @SerialName("m.fully_read") - data class FullyRead( - @SerialName("content") val content: Content, - ) : ApiAccountEvent() { - - @Serializable - data class Content( - @SerialName("event_id") val eventId: EventId, - ) - - } - - @Serializable - object Ignored : ApiAccountEvent() -} - @Serializable internal data class DeviceLists( @SerialName("changed") val changed: List? = null @@ -64,156 +33,11 @@ internal data class ToDevice( @SerialName("events") val events: List ) -@Serializable -sealed class ApiToDeviceEvent { - - @Serializable - @SerialName("m.room.encrypted") - internal data class Encrypted( - @SerialName("sender") val senderId: UserId, - @SerialName("content") val content: ApiEncryptedContent, - ) : ApiToDeviceEvent() - - @Serializable - @SerialName("m.room_key") - data class RoomKey( - @SerialName("sender") val sender: UserId, - @SerialName("content") val content: Content, - ) : ApiToDeviceEvent() { - @Serializable - data class Content( - @SerialName("room_id") val roomId: RoomId, - @SerialName("algorithm") val algorithmName: AlgorithmName, - @SerialName("session_id") val sessionId: SessionId, - @SerialName("session_key") val sessionKey: String, - @SerialName("chain_index") val chainIndex: Long, - ) - } - - @Serializable - @SerialName("m.key.verification.request") - data class VerificationRequest( - @SerialName("content") val content: Content, - @SerialName("sender") val sender: UserId, - ) : ApiToDeviceEvent(), ApiVerificationEvent { - - @Serializable - data class Content( - @SerialName("from_device") val fromDevice: DeviceId, - @SerialName("methods") val methods: List, - @SerialName("timestamp") val timestampPosix: Long, - @SerialName("transaction_id") val transactionId: String, - ) - } - - @Serializable - @SerialName("m.key.verification.ready") - data class VerificationReady( - @SerialName("content") val content: Content, - @SerialName("sender") val sender: UserId, - ) : ApiToDeviceEvent(), ApiVerificationEvent { - - @Serializable - data class Content( - @SerialName("from_device") val fromDevice: DeviceId, - @SerialName("methods") val methods: List, - @SerialName("transaction_id") val transactionId: String, - ) - } - - @Serializable - @SerialName("m.key.verification.start") - data class VerificationStart( - @SerialName("content") val content: Content, - @SerialName("sender") val sender: UserId, - ) : ApiToDeviceEvent(), ApiVerificationEvent { - - @Serializable - data class Content( - @SerialName("from_device") val fromDevice: DeviceId, - @SerialName("method") val method: String, - @SerialName("key_agreement_protocols") val protocols: List, - @SerialName("hashes") val hashes: List, - @SerialName("message_authentication_codes") val codes: List, - @SerialName("short_authentication_string") val short: List, - @SerialName("transaction_id") val transactionId: String, - ) - } - - @Serializable - @SerialName("m.key.verification.accept") - data class VerificationAccept( - @SerialName("content") val content: Content, - @SerialName("sender") val sender: UserId, - ) : ApiToDeviceEvent(), ApiVerificationEvent { - - @Serializable - data class Content( - @SerialName("from_device") val fromDevice: DeviceId, - @SerialName("method") val method: String, - @SerialName("key_agreement_protocol") val protocol: String, - @SerialName("hash") val hash: String, - @SerialName("message_authentication_code") val code: String, - @SerialName("short_authentication_string") val short: List, - @SerialName("commitment") val commitment: String, - @SerialName("transaction_id") val transactionId: String, - ) - } - - @Serializable - @SerialName("m.key.verification.key") - data class VerificationKey( - @SerialName("content") val content: Content, - @SerialName("sender") val sender: UserId, - ) : ApiToDeviceEvent(), ApiVerificationEvent { - - @Serializable - data class Content( - @SerialName("transaction_id") val transactionId: String, - @SerialName("key") val key: String, - ) - } - - @Serializable - @SerialName("m.key.verification.mac") - data class VerificationMac( - @SerialName("content") val content: Content, - @SerialName("sender") val sender: UserId, - ) : ApiToDeviceEvent(), ApiVerificationEvent { - - @Serializable - data class Content( - @SerialName("transaction_id") val transactionId: String, - @SerialName("keys") val keys: String, - @SerialName("mac") val mac: Map, - ) - } - - @Serializable - @SerialName("m.key.verification.cancel") - data class VerificationCancel( - @SerialName("content") val content: Content, - ) : ApiToDeviceEvent(), ApiVerificationEvent { - - @Serializable - data class Content( - @SerialName("code") val code: String, - @SerialName("reason") val reason: String, - @SerialName("transaction_id") val transactionId: String, - ) - } - - @Serializable - object Ignored : ApiToDeviceEvent() - - - sealed interface ApiVerificationEvent -} - @Serializable internal data class ApiSyncRooms( @SerialName("join") val join: Map? = null, @SerialName("invite") val invite: Map? = null, + @SerialName("leave") val leave: Map? = null, ) @Serializable @@ -226,25 +50,6 @@ internal data class ApiInviteEvents( @SerialName("events") val events: List ) -@Serializable -sealed class ApiStrippedEvent { - - @Serializable - @SerialName("m.room.create") - internal data class RoomCreate( - @SerialName("content") val content: Content, - ) : ApiStrippedEvent() { - - @Serializable - internal data class Content( - @SerialName("type") val type: String? = null - ) - } - - @Serializable - object Ignored : ApiStrippedEvent() -} - @Serializable internal data class ApiSyncRoom( @SerialName("timeline") val timeline: ApiSyncRoomTimeline, @@ -294,203 +99,9 @@ internal sealed class DecryptedContent { @Serializable @SerialName("m.room.message") internal data class TimelineText( - @SerialName("content") val content: ApiTimelineEvent.TimelineText.Content, + @SerialName("content") val content: ApiTimelineEvent.TimelineMessage.Content, ) : DecryptedContent() @Serializable object Ignored : DecryptedContent() } - - -@Serializable(with = EncryptedContentDeserializer::class) -internal sealed class ApiEncryptedContent { - @Serializable - data class OlmV1( - @SerialName("ciphertext") val cipherText: Map, - @SerialName("sender_key") val senderKey: Curve25519, - ) : ApiEncryptedContent() - - @Serializable - data class MegOlmV1( - @SerialName("ciphertext") val cipherText: CipherText, - @SerialName("device_id") val deviceId: DeviceId, - @SerialName("sender_key") val senderKey: String, - @SerialName("session_id") val sessionId: SessionId, - @SerialName("m.relates_to") val relation: ApiTimelineEvent.TimelineText.Relation? = null, - ) : ApiEncryptedContent() - - @Serializable - data class CipherTextInfo( - @SerialName("body") val body: CipherText, - @SerialName("type") val type: Int, - ) - - @Serializable - object Unknown : ApiEncryptedContent() -} - -@Serializable -internal sealed class ApiTimelineEvent { - - @Serializable - @SerialName("m.room.create") - internal data class RoomCreate( - @SerialName("event_id") val id: EventId, - @SerialName("origin_server_ts") val utcTimestamp: Long, - @SerialName("content") val content: Content, - ) : ApiTimelineEvent() { - - @Serializable - internal data class Content( - @SerialName("type") val type: String? = null - ) - } - - @Serializable - @SerialName("m.room.topic") - internal data class RoomTopic( - @SerialName("event_id") val id: EventId, - @SerialName("content") val content: Content, - ) : ApiTimelineEvent() { - - @Serializable - internal data class Content( - @SerialName("topic") val topic: String - ) - } - - @Serializable - @SerialName("m.room.name") - internal data class RoomName( - @SerialName("event_id") val id: EventId, - @SerialName("content") val content: Content, - ) : ApiTimelineEvent() { - - @Serializable - internal data class Content( - @SerialName("name") val name: String - ) - } - - - @Serializable - @SerialName("m.room.avatar") - internal data class RoomAvatar( - @SerialName("event_id") val id: EventId, - @SerialName("content") val content: Content, - ) : ApiTimelineEvent() { - - @Serializable - internal data class Content( - @SerialName("url") val url: MxUrl? = null - ) - } - - - @Serializable - @SerialName("m.room.member") - internal data class RoomMember( - @SerialName("event_id") val id: EventId, - @SerialName("content") val content: Content, - @SerialName("sender") val senderId: UserId, - ) : ApiTimelineEvent() { - - @Serializable - internal data class Content( - @SerialName("displayname") val displayName: String? = null, - @SerialName("membership") val membership: Membership, - @SerialName("avatar_url") val avatarUrl: MxUrl? = null, - ) { - - @JvmInline - @Serializable - value class Membership(val value: String) { - fun isJoin() = value == "join" - fun isInvite() = value == "invite" - } - - } - } - - @Serializable - internal data class DecryptionStatus( - @SerialName("is_verified") val isVerified: Boolean - ) - - @Serializable - @SerialName("m.room.message") - internal data class TimelineText( - @SerialName("event_id") val id: EventId, - @SerialName("sender") val senderId: UserId, - @SerialName("content") val content: Content, - @SerialName("origin_server_ts") val utcTimestamp: Long, - @SerialName("st.decryption_status") val decryptionStatus: DecryptionStatus? = null - ) : ApiTimelineEvent() { - - @Serializable - internal data class Content( - @SerialName("body") val body: String? = null, - @SerialName("formatted_body") val formattedBody: String? = null, - @SerialName("msgtype") val type: String? = null, - @SerialName("m.relates_to") val relation: Relation? = null, - ) - - @Serializable - data class Relation( - @SerialName("m.in_reply_to") val inReplyTo: InReplyTo? = null, - @SerialName("rel_type") val relationType: String? = null, - @SerialName("event_id") val eventId: EventId? = null - ) - - @Serializable - data class InReplyTo( - @SerialName("event_id") val eventId: EventId - ) - } - - - @Serializable - @SerialName("m.room.encryption") - data class Encryption( - @SerialName("content") val content: Content, - ) : ApiTimelineEvent() { - @Serializable - data class Content( - @SerialName("algorithm") val algorithm: AlgorithmName, - @SerialName("rotation_period_ms") val rotationMs: Long? = null, - @SerialName("rotation_period_msgs") val rotationMessages: Long? = null, - ) - } - - @Serializable - @SerialName("m.room.encrypted") - internal data class Encrypted( - @SerialName("sender") val senderId: UserId, - @SerialName("content") val encryptedContent: ApiEncryptedContent, - @SerialName("event_id") val eventId: EventId, - @SerialName("origin_server_ts") val utcTimestamp: Long, - ) : ApiTimelineEvent() - - @Serializable - object Ignored : ApiTimelineEvent() -} - - -internal object EncryptedContentDeserializer : KSerializer { - - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("encryptedContent") - - override fun deserialize(decoder: Decoder): ApiEncryptedContent { - require(decoder is JsonDecoder) - val element = decoder.decodeJsonElement() - return when (val algorithm = element.jsonObject["algorithm"]?.jsonPrimitive?.content) { - "m.olm.v1.curve25519-aes-sha2" -> ApiEncryptedContent.OlmV1.serializer().deserialize(decoder) - "m.megolm.v1.aes-sha2" -> ApiEncryptedContent.MegOlmV1.serializer().deserialize(decoder) - null -> ApiEncryptedContent.Unknown - else -> throw IllegalArgumentException("Unknown algorithm : $algorithm") - } - } - - override fun serialize(encoder: Encoder, value: ApiEncryptedContent) = TODO("Not yet implemented") - -} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiTimelineEvent.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiTimelineEvent.kt new file mode 100644 index 0000000..dfd0a64 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiTimelineEvent.kt @@ -0,0 +1,197 @@ +package app.dapk.st.matrix.sync.internal.request + +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.MxUrl +import app.dapk.st.matrix.common.UserId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class ApiTimelineEvent { + + @Serializable + @SerialName("m.room.create") + internal data class RoomCreate( + @SerialName("event_id") val id: EventId, + @SerialName("origin_server_ts") val utcTimestamp: Long, + @SerialName("content") val content: Content, + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("type") val type: String? = null + ) + } + + @Serializable + @SerialName("m.room.topic") + internal data class RoomTopic( + @SerialName("event_id") val id: EventId, + @SerialName("content") val content: Content, + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("topic") val topic: String + ) + } + + @Serializable + @SerialName("m.room.name") + internal data class RoomName( + @SerialName("event_id") val id: EventId, + @SerialName("content") val content: Content, + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("name") val name: String + ) + } + + + @Serializable + @SerialName("m.room.avatar") + internal data class RoomAvatar( + @SerialName("event_id") val id: EventId, + @SerialName("content") val content: Content, + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("url") val url: MxUrl? = null + ) + } + + + @Serializable + @SerialName("m.room.member") + internal data class RoomMember( + @SerialName("event_id") val id: EventId, + @SerialName("content") val content: Content, + @SerialName("sender") val senderId: UserId, + ) : ApiTimelineEvent() { + + @Serializable + internal data class Content( + @SerialName("displayname") val displayName: String? = null, + @SerialName("membership") val membership: Membership, + @SerialName("avatar_url") val avatarUrl: MxUrl? = null, + ) { + + @JvmInline + @Serializable + value class Membership(val value: String) { + fun isJoin() = value == "join" + fun isInvite() = value == "invite" + fun isLeave() = value == "leave" + } + + } + } + + @Serializable + internal data class DecryptionStatus( + @SerialName("is_verified") val isVerified: Boolean + ) + + @Serializable + @SerialName("m.room.message") + internal data class TimelineMessage( + @SerialName("event_id") val id: EventId, + @SerialName("sender") val senderId: UserId, + @SerialName("content") val content: Content, + @SerialName("origin_server_ts") val utcTimestamp: Long, + @SerialName("st.decryption_status") val decryptionStatus: DecryptionStatus? = null + ) : ApiTimelineEvent() { + + @Serializable(with = ApiTimelineMessageContentDeserializer::class) + internal sealed interface Content { + val relation: Relation? + + @Serializable + data class Text( + @SerialName("body") val body: String? = null, + @SerialName("formatted_body") val formattedBody: String? = null, + @SerialName("m.relates_to") override val relation: Relation? = null, + @SerialName("msgtype") val messageType: String = "m.text", + ) : Content + + @Serializable + data class Image( + @SerialName("url") val url: MxUrl? = null, + @SerialName("file") val file: File? = null, + @SerialName("info") val info: Info? = null, + @SerialName("m.relates_to") override val relation: Relation? = null, + @SerialName("msgtype") val messageType: String = "m.image", + ) : Content { + + @Serializable + data class File( + @SerialName("url") val url: MxUrl, + @SerialName("iv") val iv: String, + @SerialName("v") val v: String, + @SerialName("hashes") val hashes: Map, + @SerialName("key") val key: Key, + ) { + + @Serializable + data class Key( + @SerialName("k") val k: String, + ) + + } + + @Serializable + internal data class Info( + @SerialName("h") val height: Int? = null, + @SerialName("w") val width: Int? = null, + ) + } + + @Serializable + object Ignored : Content { + override val relation: Relation? = null + } + } + + @Serializable + data class Relation( + @SerialName("m.in_reply_to") val inReplyTo: InReplyTo? = null, + @SerialName("rel_type") val relationType: String? = null, + @SerialName("event_id") val eventId: EventId? = null + ) + + @Serializable + data class InReplyTo( + @SerialName("event_id") val eventId: EventId + ) + } + + + @Serializable + @SerialName("m.room.encryption") + data class Encryption( + @SerialName("content") val content: Content, + ) : ApiTimelineEvent() { + @Serializable + data class Content( + @SerialName("algorithm") val algorithm: AlgorithmName, + @SerialName("rotation_period_ms") val rotationMs: Long? = null, + @SerialName("rotation_period_msgs") val rotationMessages: Long? = null, + ) + } + + @Serializable + @SerialName("m.room.encrypted") + internal data class Encrypted( + @SerialName("sender") val senderId: UserId, + @SerialName("content") val encryptedContent: ApiEncryptedContent, + @SerialName("event_id") val eventId: EventId, + @SerialName("origin_server_ts") val utcTimestamp: Long, + ) : ApiTimelineEvent() + + @Serializable + object Ignored : ApiTimelineEvent() +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiTimelineMessageContentDeserializer.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiTimelineMessageContentDeserializer.kt new file mode 100644 index 0000000..324e73e --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiTimelineMessageContentDeserializer.kt @@ -0,0 +1,32 @@ +package app.dapk.st.matrix.sync.internal.request + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal object ApiTimelineMessageContentDeserializer : KSerializer { + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("messageContent") + + override fun deserialize(decoder: Decoder): ApiTimelineEvent.TimelineMessage.Content { + require(decoder is JsonDecoder) + val element = decoder.decodeJsonElement() + return when (element.jsonObject["msgtype"]?.jsonPrimitive?.content) { + "m.text" -> decoder.json.decodeFromJsonElement(ApiTimelineEvent.TimelineMessage.Content.Text.serializer(), element) + "m.image" -> decoder.json.decodeFromJsonElement(ApiTimelineEvent.TimelineMessage.Content.Image.serializer(), element) + else -> ApiTimelineEvent.TimelineMessage.Content.Ignored + } + } + + override fun serialize(encoder: Encoder, value: ApiTimelineEvent.TimelineMessage.Content) = when (value) { + ApiTimelineEvent.TimelineMessage.Content.Ignored -> {} + is ApiTimelineEvent.TimelineMessage.Content.Image -> ApiTimelineEvent.TimelineMessage.Content.Image.serializer().serialize(encoder, value) + is ApiTimelineEvent.TimelineMessage.Content.Text -> ApiTimelineEvent.TimelineMessage.Content.Text.serializer().serialize(encoder, value) + } + +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiToDeviceEvent.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiToDeviceEvent.kt new file mode 100644 index 0000000..eacd1e4 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiToDeviceEvent.kt @@ -0,0 +1,151 @@ +package app.dapk.st.matrix.sync.internal.request + +import app.dapk.st.matrix.common.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class ApiToDeviceEvent { + + @Serializable + @SerialName("m.room.encrypted") + internal data class Encrypted( + @SerialName("sender") val senderId: UserId, + @SerialName("content") val content: ApiEncryptedContent, + ) : ApiToDeviceEvent() + + @Serializable + @SerialName("m.room_key") + data class RoomKey( + @SerialName("sender") val sender: UserId, + @SerialName("content") val content: Content, + ) : ApiToDeviceEvent() { + @Serializable + data class Content( + @SerialName("room_id") val roomId: RoomId, + @SerialName("algorithm") val algorithmName: AlgorithmName, + @SerialName("session_id") val sessionId: SessionId, + @SerialName("session_key") val sessionKey: String, + @SerialName("chain_index") val chainIndex: Long, + ) + } + + @Serializable + @SerialName("m.key.verification.request") + data class VerificationRequest( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("methods") val methods: List, + @SerialName("timestamp") val timestampPosix: Long, + @SerialName("transaction_id") val transactionId: String, + ) + } + + @Serializable + @SerialName("m.key.verification.ready") + data class VerificationReady( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("methods") val methods: List, + @SerialName("transaction_id") val transactionId: String, + ) + } + + @Serializable + @SerialName("m.key.verification.start") + data class VerificationStart( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("method") val method: String, + @SerialName("key_agreement_protocols") val protocols: List, + @SerialName("hashes") val hashes: List, + @SerialName("message_authentication_codes") val codes: List, + @SerialName("short_authentication_string") val short: List, + @SerialName("transaction_id") val transactionId: String, + ) + } + + @Serializable + @SerialName("m.key.verification.accept") + data class VerificationAccept( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("from_device") val fromDevice: DeviceId, + @SerialName("method") val method: String, + @SerialName("key_agreement_protocol") val protocol: String, + @SerialName("hash") val hash: String, + @SerialName("message_authentication_code") val code: String, + @SerialName("short_authentication_string") val short: List, + @SerialName("commitment") val commitment: String, + @SerialName("transaction_id") val transactionId: String, + ) + } + + @Serializable + @SerialName("m.key.verification.key") + data class VerificationKey( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("transaction_id") val transactionId: String, + @SerialName("key") val key: String, + ) + } + + @Serializable + @SerialName("m.key.verification.mac") + data class VerificationMac( + @SerialName("content") val content: Content, + @SerialName("sender") val sender: UserId, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("transaction_id") val transactionId: String, + @SerialName("keys") val keys: String, + @SerialName("mac") val mac: Map, + ) + } + + @Serializable + @SerialName("m.key.verification.cancel") + data class VerificationCancel( + @SerialName("content") val content: Content, + ) : ApiToDeviceEvent(), ApiVerificationEvent { + + @Serializable + data class Content( + @SerialName("code") val code: String, + @SerialName("reason") val reason: String, + @SerialName("transaction_id") val transactionId: String, + ) + } + + @Serializable + object Ignored : ApiToDeviceEvent() + + + sealed interface ApiVerificationEvent +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/EncryptedContentDeserializer.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/EncryptedContentDeserializer.kt new file mode 100644 index 0000000..cd3c529 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/EncryptedContentDeserializer.kt @@ -0,0 +1,29 @@ +package app.dapk.st.matrix.sync.internal.request + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal object EncryptedContentDeserializer : KSerializer { + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("encryptedContent") + + override fun deserialize(decoder: Decoder): ApiEncryptedContent { + require(decoder is JsonDecoder) + val element = decoder.decodeJsonElement() + return when (val algorithm = element.jsonObject["algorithm"]?.jsonPrimitive?.content) { + "m.olm.v1.curve25519-aes-sha2" -> decoder.json.decodeFromJsonElement(ApiEncryptedContent.OlmV1.serializer(), element) + "m.megolm.v1.aes-sha2" -> decoder.json.decodeFromJsonElement(ApiEncryptedContent.MegOlmV1.serializer(), element) + null -> ApiEncryptedContent.Unknown + else -> throw IllegalArgumentException("Unknown algorithm : $algorithm") + } + } + + override fun serialize(encoder: Encoder, value: ApiEncryptedContent) = TODO("Not yet implemented") + +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt index 83d9bad..b14f5b4 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt @@ -2,6 +2,7 @@ package app.dapk.st.matrix.sync.internal.room import app.dapk.st.matrix.common.* import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent import app.dapk.st.matrix.sync.internal.request.DecryptedContent import kotlinx.serialization.json.Json @@ -11,13 +12,38 @@ internal class RoomEventsDecrypter( private val logger: MatrixLogger, ) { - suspend fun decryptRoomEvents(events: List) = events.map { event -> - when (event) { - is RoomEvent.Message -> event.decrypt() - is RoomEvent.Reply -> RoomEvent.Reply( - message = event.message.decrypt(), - replyingTo = event.replyingTo.decrypt(), - ) + suspend fun decryptRoomEvents(userCredentials: UserCredentials, events: List) = events.map { event -> + decryptEvent(event, userCredentials) + } + + private suspend fun decryptEvent(event: RoomEvent, userCredentials: UserCredentials): RoomEvent = when (event) { + is RoomEvent.Message -> event.decrypt() + is RoomEvent.Reply -> RoomEvent.Reply( + message = decryptEvent(event.message, userCredentials), + replyingTo = decryptEvent(event.replyingTo, userCredentials), + ) + is RoomEvent.Image -> event.decrypt(userCredentials) + } + + private suspend fun RoomEvent.Image.decrypt(userCredentials: UserCredentials) = when (this.encryptedContent) { + null -> this + else -> when (val result = messageDecrypter.decrypt(this.encryptedContent.toModel())) { + is DecryptionResult.Failed -> this.also { logger.crypto("Failed to decrypt ${it.eventId}") } + is DecryptionResult.Success -> when (val model = result.payload.toModel()) { + DecryptedContent.Ignored -> this + is DecryptedContent.TimelineText -> { + val content = model.content as ApiTimelineEvent.TimelineMessage.Content.Image + this.copy( + imageMeta = RoomEvent.Image.ImageMeta( + width = content.info?.width, + height = content.info?.height, + url = content.file?.url?.convertMxUrToUrl(userCredentials.homeServer) ?: content.url!!.convertMxUrToUrl(userCredentials.homeServer), + keys = content.file?.let { RoomEvent.Image.ImageMeta.Keys(it.key.k, it.iv, it.v, it.hashes) } + ), + encryptedContent = null, + ) + } + } } } @@ -35,10 +61,9 @@ internal class RoomEventsDecrypter( private fun JsonString.toModel() = json.decodeFromString(DecryptedContent.serializer(), this.value) private fun RoomEvent.Message.copyWithDecryptedContent(decryptedContent: DecryptedContent.TimelineText) = this.copy( - content = decryptedContent.content.body ?: "", + content = (decryptedContent.content as ApiTimelineEvent.TimelineMessage.Content.Text).body ?: "", encryptedContent = null ) - } private fun RoomEvent.Message.MegOlmV1.toModel() = EncryptedMessageContent.MegOlmV1( diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncEventDecrypter.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncEventDecrypter.kt index 50f0cb0..78f7c28 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncEventDecrypter.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/SyncEventDecrypter.kt @@ -36,10 +36,14 @@ internal class SyncEventDecrypter( ApiEncryptedContent.Unknown -> null } when (it) { - is DecryptedContent.TimelineText -> ApiTimelineEvent.TimelineText( + is DecryptedContent.TimelineText -> ApiTimelineEvent.TimelineMessage( event.eventId, event.senderId, - it.content.copy(relation = relation), + when (it.content) { + is ApiTimelineEvent.TimelineMessage.Content.Image -> it.content.copy(relation = relation) + is ApiTimelineEvent.TimelineMessage.Content.Text -> it.content.copy(relation = relation) + ApiTimelineEvent.TimelineMessage.Content.Ignored -> it.content + }, event.utcTimestamp, ).also { logger.matrixLog("decrypted to timeline text: $it") } DecryptedContent.Ignored -> event diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCase.kt index 779addf..9eda9dd 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCase.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCase.kt @@ -16,7 +16,7 @@ internal class EventLookupUseCase( } private fun DecryptedTimeline.lookup(id: EventId) = this.value - .filterIsInstance() + .filterIsInstance() .firstOrNull { it.id == id } ?.let { LookupResult(apiTimelineEvent = it, roomEvent = null) } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/LookupResult.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/LookupResult.kt index e4cbe6d..24aea5a 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/LookupResult.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/LookupResult.kt @@ -4,12 +4,12 @@ import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent internal data class LookupResult( - private val apiTimelineEvent: ApiTimelineEvent.TimelineText?, + private val apiTimelineEvent: ApiTimelineEvent.TimelineMessage?, private val roomEvent: RoomEvent?, ) { inline fun fold( - onApiTimelineEvent: (ApiTimelineEvent.TimelineText) -> T?, + onApiTimelineEvent: (ApiTimelineEvent.TimelineMessage) -> T?, onRoomEvent: (RoomEvent) -> T?, onEmpty: () -> T?, ): T? { diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt index d58026b..df4bb80 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt @@ -14,6 +14,8 @@ class RoomDataSource( private val roomCache = mutableMapOf() + fun contains(roomId: RoomId) = roomCache.containsKey(roomId) + suspend fun read(roomId: RoomId) = when (val cached = roomCache[roomId]) { null -> roomStore.retrieve(roomId)?.also { roomCache[roomId] = it } else -> cached @@ -27,4 +29,9 @@ class RoomDataSource( roomStore.persist(roomId, newState) } } + + suspend fun remove(roomsLeft: List) { + roomsLeft.forEach { roomCache.remove(it) } + roomStore.remove(roomsLeft) + } } \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt index 8384c9c..7363989 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt @@ -4,9 +4,8 @@ import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.ifOrNull import app.dapk.st.core.extensions.nullAndTrack import app.dapk.st.matrix.common.EventId -import app.dapk.st.matrix.common.MatrixLogger import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.common.matrixLog +import app.dapk.st.matrix.common.UserCredentials import app.dapk.st.matrix.sync.MessageMeta import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomMembersService @@ -18,8 +17,8 @@ private typealias Lookup = suspend (EventId) -> LookupResult internal class RoomEventCreator( private val roomMembersService: RoomMembersService, - private val logger: MatrixLogger, private val errorTracker: ErrorTracker, + private val roomEventFactory: RoomEventFactory, ) { suspend fun ApiTimelineEvent.Encrypted.toRoomEvent(roomId: RoomId): RoomEvent? { @@ -44,89 +43,127 @@ internal class RoomEventCreator( } } - suspend fun ApiTimelineEvent.TimelineText.toRoomEvent(roomId: RoomId, lookup: Lookup): RoomEvent? { + suspend fun ApiTimelineEvent.TimelineMessage.toRoomEvent(userCredentials: UserCredentials, roomId: RoomId, lookup: Lookup): RoomEvent? { + return TimelineEventMapper(userCredentials, roomId, roomEventFactory).mapToRoomEvent(this, lookup) + } +} + +internal class TimelineEventMapper( + private val userCredentials: UserCredentials, + private val roomId: RoomId, + private val roomEventFactory: RoomEventFactory, +) { + + suspend fun mapToRoomEvent(event: ApiTimelineEvent.TimelineMessage, lookup: Lookup): RoomEvent? { return when { - this.isEdit() -> handleEdit(roomId, this.content.relation!!.eventId!!, lookup) - this.isReply() -> handleReply(roomId, lookup) - else -> this.toMessage(roomId) + event.content == ApiTimelineEvent.TimelineMessage.Content.Ignored -> null + event.isEdit() -> event.handleEdit(editedEventId = event.content.relation!!.eventId!!, lookup) + event.isReply() -> event.handleReply(replyToId = event.content.relation!!.inReplyTo!!.eventId, lookup) + else -> roomEventFactory.mapToRoomEvent(event) } } - private suspend fun ApiTimelineEvent.TimelineText.handleEdit(roomId: RoomId, editedEventId: EventId, lookup: Lookup): RoomEvent? { - return lookup(editedEventId).fold( - onApiTimelineEvent = { - ifOrNull(this.utcTimestamp > it.utcTimestamp) { - it.toMessage( - roomId, - utcTimestamp = this.utcTimestamp, - content = this.content.body?.removePrefix(" * ")?.trim() ?: "redacted", - edited = true, - ) - } - }, - onRoomEvent = { - ifOrNull(this.utcTimestamp > it.utcTimestamp) { - when (it) { - is RoomEvent.Message -> it.edited(this) - is RoomEvent.Reply -> it.copy(message = it.message.edited(this)) - } - } - }, - onEmpty = { this.toMessage(roomId, edited = true) } - ) - } - - private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineText) = this.copy( - content = edit.content.body?.removePrefix(" * ")?.trim() ?: "redacted", - utcTimestamp = edit.utcTimestamp, - edited = true, - ) - - private suspend fun ApiTimelineEvent.TimelineText.handleReply(roomId: RoomId, lookup: Lookup): RoomEvent { - val replyTo = this.content.relation!!.inReplyTo!! - - val relationEvent = lookup(replyTo.eventId).fold( - onApiTimelineEvent = { it.toMessage(roomId) }, + private suspend fun ApiTimelineEvent.TimelineMessage.handleReply(replyToId: EventId, lookup: Lookup): RoomEvent { + val relationEvent = lookup(replyToId).fold( + onApiTimelineEvent = { it.toMessage() }, onRoomEvent = { it }, onEmpty = { null } ) - logger.matrixLog("found relation: $relationEvent") - return when (relationEvent) { - null -> this.toMessage(roomId) + null -> this.toMessage() else -> { RoomEvent.Reply( - message = this.toMessage(roomId, content = this.content.formattedBody?.stripTags() ?: "redacted"), + message = roomEventFactory.mapToRoomEvent(this), replyingTo = when (relationEvent) { is RoomEvent.Message -> relationEvent is RoomEvent.Reply -> relationEvent.message + is RoomEvent.Image -> relationEvent } ) } } } - private suspend fun ApiTimelineEvent.TimelineText.toMessage( - roomId: RoomId, - content: String = this.content.body ?: "redacted", + private suspend fun ApiTimelineEvent.TimelineMessage.toMessage() = when (this.content) { + is ApiTimelineEvent.TimelineMessage.Content.Image -> this.toImageMessage() + is ApiTimelineEvent.TimelineMessage.Content.Text -> this.toFallbackTextMessage() + ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException() + } + + private suspend fun ApiTimelineEvent.TimelineMessage.toFallbackTextMessage() = this.toTextMessage(content = this.asTextContent().body ?: "redacted") + + private suspend fun ApiTimelineEvent.TimelineMessage.handleEdit(editedEventId: EventId, lookup: Lookup): RoomEvent? { + return lookup(editedEventId).fold( + onApiTimelineEvent = { editApiEvent(original = it, incomingEdit = this) }, + onRoomEvent = { editRoomEvent(original = it, incomingEdit = this) }, + onEmpty = { this.toTextMessage(edited = true) } + ) + } + + private fun editRoomEvent(original: RoomEvent, incomingEdit: ApiTimelineEvent.TimelineMessage): RoomEvent? { + return ifOrNull(incomingEdit.utcTimestamp > original.utcTimestamp) { + when (original) { + is RoomEvent.Message -> original.edited(incomingEdit) + is RoomEvent.Reply -> original.copy( + message = when (original.message) { + is RoomEvent.Image -> original.message + is RoomEvent.Message -> original.message.edited(incomingEdit) + is RoomEvent.Reply -> original.message + } + ) + is RoomEvent.Image -> { + // can't edit images + null + } + } + } + } + + private suspend fun editApiEvent(original: ApiTimelineEvent.TimelineMessage, incomingEdit: ApiTimelineEvent.TimelineMessage): RoomEvent? { + return ifOrNull(incomingEdit.utcTimestamp > original.utcTimestamp) { + when (original.content) { + is ApiTimelineEvent.TimelineMessage.Content.Image -> original.toImageMessage( + utcTimestamp = incomingEdit.utcTimestamp, + edited = true, + ) + is ApiTimelineEvent.TimelineMessage.Content.Text -> original.toTextMessage( + utcTimestamp = incomingEdit.utcTimestamp, + content = incomingEdit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted", + edited = true, + ) + ApiTimelineEvent.TimelineMessage.Content.Ignored -> null + } + } + } + + private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineMessage) = this.copy( + content = edit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted", + utcTimestamp = edit.utcTimestamp, + edited = true, + ) + + private suspend fun RoomEventFactory.mapToRoomEvent(source: ApiTimelineEvent.TimelineMessage): RoomEvent { + return when (source.content) { + is ApiTimelineEvent.TimelineMessage.Content.Image -> source.toImageMessage(userCredentials, roomId) + is ApiTimelineEvent.TimelineMessage.Content.Text -> source.toTextMessage(roomId) + ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException() + } + } + + private suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage( + content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted", edited: Boolean = false, utcTimestamp: Long = this.utcTimestamp, - ) = RoomEvent.Message( - eventId = this.id, - content = content, - author = roomMembersService.find(roomId, this.senderId)!!, - utcTimestamp = utcTimestamp, - meta = MessageMeta.FromServer, - edited = edited, - ) + ) = with(roomEventFactory) { toTextMessage(roomId, content, edited, utcTimestamp) } + + private suspend fun ApiTimelineEvent.TimelineMessage.toImageMessage( + edited: Boolean = false, + utcTimestamp: Long = this.utcTimestamp, + ) = with(roomEventFactory) { toImageMessage(userCredentials, roomId, edited, utcTimestamp) } } -private fun String.stripTags() = this.substring(this.indexOf("") + "".length) - .trim() - .replace("", "") - .replace("", "") - -private fun ApiTimelineEvent.TimelineText.isEdit() = this.content.relation?.relationType == "m.replace" && this.content.relation.eventId != null -private fun ApiTimelineEvent.TimelineText.isReply() = this.content.relation?.inReplyTo != null \ No newline at end of file +private fun ApiTimelineEvent.TimelineMessage.isEdit() = this.content.relation?.relationType == "m.replace" && this.content.relation?.eventId != null +private fun ApiTimelineEvent.TimelineMessage.isReply() = this.content.relation?.inReplyTo != null +private fun ApiTimelineEvent.TimelineMessage.asTextContent() = this.content as ApiTimelineEvent.TimelineMessage.Content.Text diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt new file mode 100644 index 0000000..4dfcd7b --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt @@ -0,0 +1,81 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.matrix.common.convertMxUrToUrl +import app.dapk.st.matrix.sync.MessageMeta +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomMembersService +import app.dapk.st.matrix.sync.find +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent + +internal class RoomEventFactory( + private val roomMembersService: RoomMembersService +) { + + suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage( + roomId: RoomId, + content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted", + edited: Boolean = false, + utcTimestamp: Long = this.utcTimestamp, + ) = RoomEvent.Message( + eventId = this.id, + content = content, + author = roomMembersService.find(roomId, this.senderId)!!, + utcTimestamp = utcTimestamp, + meta = MessageMeta.FromServer, + edited = edited, + ) + + suspend fun ApiTimelineEvent.TimelineMessage.toImageMessage( + userCredentials: UserCredentials, + roomId: RoomId, + edited: Boolean = false, + utcTimestamp: Long = this.utcTimestamp, + imageMeta: RoomEvent.Image.ImageMeta = this.readImageMeta(userCredentials) + ) = RoomEvent.Image( + eventId = this.id, + imageMeta = imageMeta, + author = roomMembersService.find(roomId, this.senderId)!!, + utcTimestamp = utcTimestamp, + meta = MessageMeta.FromServer, + edited = edited, + ) + + private fun ApiTimelineEvent.TimelineMessage.readImageMeta(userCredentials: UserCredentials): RoomEvent.Image.ImageMeta { + val content = this.content as ApiTimelineEvent.TimelineMessage.Content.Image + return RoomEvent.Image.ImageMeta( + content.info?.width, + content.info?.height, + content.file?.url?.convertMxUrToUrl(userCredentials.homeServer) ?: content.url!!.convertMxUrToUrl(userCredentials.homeServer), + keys = content.file?.let { RoomEvent.Image.ImageMeta.Keys(it.key.k, it.iv, it.v, it.hashes) } + ) + } +} + + +private fun String.indexOfOrNull(string: String) = this.indexOf(string).takeIf { it != -1 } + +fun String.stripTags() = this + .run { + this.indexOfOrNull("")?.let { + this.substring(it + "".length) + } ?: this + } + .trim() + .replaceLinks() + .replace("", "") + .replace("", "") + .replace(""", "\"") + .replace("'", "'") + +private fun String.replaceLinks(): String { + return this.indexOfOrNull("")!! + val end = indexOfOrNull("")!! + val content = this.substring(openTagClose + "\">".length, end) + this.replaceRange(start, end + "".length, content) + } ?: this +} + +private fun ApiTimelineEvent.TimelineMessage.asTextContent() = this.content as ApiTimelineEvent.TimelineMessage.Content.Text diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt index a20488e..f851f46 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt @@ -1,8 +1,8 @@ package app.dapk.st.matrix.sync.internal.sync -import app.dapk.st.matrix.common.UserCredentials import app.dapk.st.matrix.common.AvatarUrl import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserCredentials import app.dapk.st.matrix.common.convertMxUrToUrl import app.dapk.st.matrix.sync.* import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom @@ -13,7 +13,7 @@ internal class RoomProcessor( private val roomDataSource: RoomDataSource, private val timelineEventsProcessor: TimelineEventsProcessor, private val roomOverviewProcessor: RoomOverviewProcessor, - private val unreadEventsUseCase: UnreadEventsUseCase, + private val unreadEventsProcessor: UnreadEventsProcessor, private val ephemeralEventsUseCase: EphemeralEventsUseCase, ) { @@ -29,7 +29,7 @@ internal class RoomProcessor( ) val overview = createRoomOverview(distinctEvents, roomToProcess, previousState) - unreadEventsUseCase.processUnreadState(overview, previousState?.roomOverview, newEvents, roomToProcess.userCredentials.userId, isInitialSync) + unreadEventsProcessor.processUnreadState(overview, previousState?.roomOverview, newEvents, roomToProcess.userCredentials.userId, isInitialSync) return RoomState(overview, distinctEvents).also { roomDataSource.persist(roomToProcess.roomId, previousState, it) @@ -61,13 +61,18 @@ private fun ApiSyncRoom.collectMembers(userCredentials: UserCredentials): List.findLastMessage(): LastMessage? { - return this.filterIsInstance().firstOrNull()?.let { + return this.firstOrNull()?.let { LastMessage( - content = it.content, + content = it.toTextContent(), utcTimestamp = it.utcTimestamp, author = it.author, ) } } + +private fun RoomEvent.toTextContent(): String = when (this) { + is RoomEvent.Image -> "\uD83D\uDCF7" + is RoomEvent.Message -> this.content + is RoomEvent.Reply -> this.message.toTextContent() +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresher.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresher.kt index 5529797..6be702a 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresher.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresher.kt @@ -1,9 +1,6 @@ package app.dapk.st.matrix.sync.internal.sync -import app.dapk.st.matrix.common.MatrixLogTag -import app.dapk.st.matrix.common.MatrixLogger -import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.common.matrixLog +import app.dapk.st.matrix.common.* import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomState import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter @@ -14,13 +11,13 @@ internal class RoomRefresher( private val logger: MatrixLogger ) { - suspend fun refreshRoomContent(roomId: RoomId): RoomState? { + suspend fun refreshRoomContent(roomId: RoomId, userCredentials: UserCredentials): RoomState? { logger.matrixLog(MatrixLogTag.SYNC, "reducing side effect: $roomId") return when (val previousState = roomDataSource.read(roomId)) { null -> null.also { logger.matrixLog(MatrixLogTag.SYNC, "no previous state to update") } else -> { logger.matrixLog(MatrixLogTag.SYNC, "previous state updated") - val decryptedEvents = previousState.events.decryptEvents() + val decryptedEvents = previousState.events.decryptEvents(userCredentials) val lastMessage = decryptedEvents.sortedByDescending { it.utcTimestamp }.findLastMessage() previousState.copy(events = decryptedEvents, roomOverview = previousState.roomOverview.copy(lastMessage = lastMessage)).also { @@ -30,6 +27,6 @@ internal class RoomRefresher( } } - private suspend fun List.decryptEvents() = roomEventsDecrypter.decryptRoomEvents(this) + private suspend fun List.decryptEvents(userCredentials: UserCredentials) = roomEventsDecrypter.decryptRoomEvents(userCredentials, this) } \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt index 24eebc9..62cd990 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt @@ -1,56 +1,94 @@ package app.dapk.st.matrix.sync.internal.sync import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.withIoContextAsync import app.dapk.st.matrix.common.* import app.dapk.st.matrix.common.MatrixLogTag.SYNC +import app.dapk.st.matrix.sync.InviteMeta import app.dapk.st.matrix.sync.RoomInvite import app.dapk.st.matrix.sync.RoomState -import app.dapk.st.matrix.sync.internal.request.ApiAccountEvent -import app.dapk.st.matrix.sync.internal.request.ApiSyncResponse -import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom +import app.dapk.st.matrix.sync.internal.request.* import app.dapk.st.matrix.sync.internal.room.SideEffectResult import kotlinx.coroutines.awaitAll internal class SyncReducer( private val roomProcessor: RoomProcessor, private val roomRefresher: RoomRefresher, + private val roomDataSource: RoomDataSource, private val logger: MatrixLogger, + private val errorTracker: ErrorTracker, private val coroutineDispatchers: CoroutineDispatchers, ) { data class ReducerResult( + val newRoomsJoined: List, val roomState: List, - val invites: List + val invites: List, + val roomsLeft: List ) suspend fun reduce(isInitialSync: Boolean, sideEffects: SideEffectResult, response: ApiSyncResponse, userCredentials: UserCredentials): ReducerResult { val directMessages = response.directMessages() - val invites = response.rooms?.invite?.keys?.map { RoomInvite(it) } ?: emptyList() + val invites = response.rooms?.invite?.map { roomInvite(it, userCredentials) } ?: emptyList() + val roomsLeft = findRoomsLeft(response, userCredentials) + val newRooms = response.rooms?.join?.keys?.filterNot { roomDataSource.contains(it) } ?: emptyList() + val apiUpdatedRooms = response.rooms?.join?.keepRoomsWithChanges() - val apiRoomsToProcess = apiUpdatedRooms?.map { (roomId, apiRoom) -> + val apiRoomsToProcess = apiUpdatedRooms?.mapNotNull { (roomId, apiRoom) -> logger.matrixLog(SYNC, "reducing: $roomId") coroutineDispatchers.withIoContextAsync { - roomProcessor.processRoom( - roomToProcess = RoomToProcess( - roomId = roomId, - apiSyncRoom = apiRoom, - directMessage = directMessages[roomId], - userCredentials = userCredentials, - ), - isInitialSync = isInitialSync - ) + runCatching { + roomProcessor.processRoom( + roomToProcess = RoomToProcess( + roomId = roomId, + apiSyncRoom = apiRoom, + directMessage = directMessages[roomId], + userCredentials = userCredentials, + ), + isInitialSync = isInitialSync + ) + } + .onFailure { errorTracker.track(it, "failed to reduce: $roomId, skipping") } + .getOrNull() } } ?: emptyList() val roomsWithSideEffects = sideEffects.roomsToRefresh(alreadyHandledRooms = apiUpdatedRooms?.keys ?: emptySet()).map { roomId -> coroutineDispatchers.withIoContextAsync { - roomRefresher.refreshRoomContent(roomId) + roomRefresher.refreshRoomContent(roomId, userCredentials) } } - return ReducerResult((apiRoomsToProcess + roomsWithSideEffects).awaitAll().filterNotNull(), invites) + roomDataSource.remove(roomsLeft) + + return ReducerResult( + newRooms, + (apiRoomsToProcess + roomsWithSideEffects).awaitAll().filterNotNull(), + invites, + roomsLeft + ) + } + + private fun findRoomsLeft(response: ApiSyncResponse, userCredentials: UserCredentials) = response.rooms?.leave?.filter { + it.value.state.stateEvents.filterIsInstance().any { + it.content.membership.isLeave() && it.senderId == userCredentials.userId + } + }?.map { it.key } ?: emptyList() + + private fun roomInvite(entry: Map.Entry, userCredentials: UserCredentials): RoomInvite { + val memberEvents = entry.value.state.events.filterIsInstance() + val invitee = memberEvents.first { it.content.membership?.isInvite() ?: false } + val from = memberEvents.first { it.sender == invitee.sender } + return RoomInvite( + RoomMember(from.sender, from.content.displayName, from.content.avatarUrl?.convertMxUrToUrl(userCredentials.homeServer)?.let { AvatarUrl(it) }), + roomId = entry.key, + inviteMeta = when (invitee.content.isDirect) { + true -> InviteMeta.DirectMessage + null, false -> InviteMeta.Room(entry.value.state.events.filterIsInstance().firstOrNull()?.content?.name) + }, + ) } } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt index d56e8a8..dd4301b 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt @@ -26,41 +26,55 @@ internal class SyncUseCase( ) { fun sync(): Flow { - return flow { - logger.matrixLog("flow instance: ${hashCode()}") + return flow { val credentials = credentialsStore.credentials()!! val filterId = filterUseCase.reducedFilter(credentials.userId) - with(flowIterator) { - loop(initial = null) { previousState -> - logger.matrixLog("looper : ${hashCode()}") - val syncToken = syncStore.read(key = SyncStore.SyncKey.Overview) - val response = doSyncRequest(filterId, syncToken) - logger.logP("sync processing") { - syncStore.store(key = SyncStore.SyncKey.Overview, syncToken = response.nextBatch) - val sideEffects = logger.logP("side effects processing") { - syncSideEffects.blockingSideEffects(credentials.userId, response, syncToken) - } - - val isInitialSync = syncToken == null - val nextState = logger.logP("reducing") { syncReducer.reduce(isInitialSync, sideEffects, response, credentials) } - val overview = nextState.roomState.map { it.roomOverview } - - if (nextState.invites.isNotEmpty()) { - persistence.persistInvites(nextState.invites) - } - - when { - previousState == overview -> previousState.also { logger.matrixLog(SYNC, "no changes, not persisting new state") } - overview.isNotEmpty() -> overview.also { persistence.persist(overview) } - else -> previousState.also { logger.matrixLog(SYNC, "nothing to do") } - } - } - } + loop( + initial = null, + onPost = { emit(Unit) }, + onIteration = { onEachSyncIteration(filterId, credentials, previousState = it) } + ) } }.cancellable() } + private suspend fun onEachSyncIteration(filterId: SyncService.FilterId, credentials: UserCredentials, previousState: OverviewState?): OverviewState? { + val syncToken = syncStore.read(key = SyncStore.SyncKey.Overview) + val response = doSyncRequest(filterId, syncToken) + return if (credentialsStore.isSignedIn()) { + logger.logP("sync processing") { + syncStore.store(key = SyncStore.SyncKey.Overview, syncToken = response.nextBatch) + val sideEffects = logger.logP("side effects processing") { + syncSideEffects.blockingSideEffects(credentials.userId, response, syncToken) + } + + val isInitialSync = syncToken == null + val nextState = logger.logP("reducing") { syncReducer.reduce(isInitialSync, sideEffects, response, credentials) } + val overview = nextState.roomState.map { it.roomOverview } + + if (nextState.roomsLeft.isNotEmpty()) { + persistence.removeRooms(nextState.roomsLeft) + } + if (nextState.invites.isNotEmpty()) { + persistence.persistInvites(nextState.invites) + } + if (nextState.newRoomsJoined.isNotEmpty()) { + persistence.removeInvites(nextState.newRoomsJoined) + } + + when { + previousState == overview -> previousState.also { logger.matrixLog(SYNC, "no changes, not persisting new state") } + overview.isNotEmpty() -> overview.also { persistence.persist(overview) } + else -> previousState.also { logger.matrixLog(SYNC, "nothing to do") } + } + } + } else { + logger.matrixLog(SYNC, "sync processing skipped due to being signed out") + null + } + } + private suspend fun doSyncRequest(filterId: SyncService.FilterId, syncToken: SyncToken?) = logger.logP("sync api") { client.execute( syncRequest( diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt index e0ab144..ee68463 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt @@ -1,5 +1,6 @@ package app.dapk.st.matrix.sync.internal.sync +import app.dapk.st.matrix.common.UserCredentials import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter @@ -22,13 +23,13 @@ internal class TimelineEventsProcessor( private suspend fun processNewEvents(roomToProcess: RoomToProcess, previousEvents: List): List { val decryptedTimeline = roomToProcess.apiSyncRoom.timeline.apiTimelineEvents.decryptEvents() - val decryptedPreviousEvents = previousEvents.decryptEvents() + val decryptedPreviousEvents = previousEvents.decryptEvents(roomToProcess.userCredentials) val newEvents = with(roomEventCreator) { decryptedTimeline.value.mapNotNull { event -> val roomEvent = when (event) { is ApiTimelineEvent.Encrypted -> event.toRoomEvent(roomToProcess.roomId) - is ApiTimelineEvent.TimelineText -> event.toRoomEvent(roomToProcess.roomId) { eventId -> + is ApiTimelineEvent.TimelineMessage -> event.toRoomEvent(roomToProcess.userCredentials, roomToProcess.roomId) { eventId -> eventLookupUseCase.lookup(eventId, decryptedTimeline, decryptedPreviousEvents) } is ApiTimelineEvent.Encryption -> null @@ -46,7 +47,8 @@ internal class TimelineEventsProcessor( } private suspend fun List.decryptEvents() = DecryptedTimeline(eventDecrypter.decryptTimelineEvents(this)) - private suspend fun List.decryptEvents() = DecryptedRoomEvents(roomEventsDecrypter.decryptRoomEvents(this)) + private suspend fun List.decryptEvents(userCredentials: UserCredentials) = + DecryptedRoomEvents(roomEventsDecrypter.decryptRoomEvents(userCredentials, this)) } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt similarity index 94% rename from matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCase.kt rename to matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt index 9defc92..b4f82a7 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCase.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt @@ -8,7 +8,7 @@ import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomStore -internal class UnreadEventsUseCase( +internal class UnreadEventsProcessor( private val roomStore: RoomStore, private val logger: MatrixLogger, ) { @@ -42,6 +42,7 @@ internal class UnreadEventsUseCase( when (it) { is RoomEvent.Message -> it.author.id == selfId is RoomEvent.Reply -> it.message.author.id == selfId + is RoomEvent.Image -> it.author.id == selfId } }.map { it.eventId } roomStore.insertUnread(overview.roomId, eventsFromOthers) diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt index 368edf9..3178ae9 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt @@ -21,6 +21,9 @@ private val AN_ENCRYPTED_ROOM_REPLY = aRoomReplyMessageEvent( replyingTo = AN_ENCRYPTED_ROOM_MESSAGE.copy(eventId = anEventId("other-event")) ) private val A_DECRYPTED_CONTENT = DecryptedContent.TimelineText(aTimelineTextEventContent(body = A_DECRYPTED_MESSAGE_CONTENT)) +private val A_USER_CREDENTIALS = aUserCredentials() + +private val json = Json { encodeDefaults = true } class RoomEventsDecrypterTest { @@ -35,7 +38,7 @@ class RoomEventsDecrypterTest { @Test fun `given clear message event, when decrypting, then does nothing`() = runTest { val aClearMessageEvent = aRoomMessageEvent(encryptedContent = null) - val result = roomEventsDecrypter.decryptRoomEvents(listOf(aClearMessageEvent)) + val result = roomEventsDecrypter.decryptRoomEvents(A_USER_CREDENTIALS, listOf(aClearMessageEvent)) result shouldBeEqualTo listOf(aClearMessageEvent) } @@ -44,7 +47,7 @@ class RoomEventsDecrypterTest { fun `given encrypted message event, when decrypting, then applies decrypted body and removes encrypted content`() = runTest { givenEncryptedMessage(AN_ENCRYPTED_ROOM_MESSAGE, decryptsTo = A_DECRYPTED_CONTENT) - val result = roomEventsDecrypter.decryptRoomEvents(listOf(AN_ENCRYPTED_ROOM_MESSAGE)) + val result = roomEventsDecrypter.decryptRoomEvents(A_USER_CREDENTIALS, listOf(AN_ENCRYPTED_ROOM_MESSAGE)) result shouldBeEqualTo listOf(AN_ENCRYPTED_ROOM_MESSAGE.copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null)) } @@ -53,12 +56,12 @@ class RoomEventsDecrypterTest { fun `given encrypted reply event, when decrypting, then decrypts message and replyTo`() = runTest { givenEncryptedReply(AN_ENCRYPTED_ROOM_REPLY, decryptsTo = A_DECRYPTED_CONTENT) - val result = roomEventsDecrypter.decryptRoomEvents(listOf(AN_ENCRYPTED_ROOM_REPLY)) + val result = roomEventsDecrypter.decryptRoomEvents(A_USER_CREDENTIALS, listOf(AN_ENCRYPTED_ROOM_REPLY)) result shouldBeEqualTo listOf( AN_ENCRYPTED_ROOM_REPLY.copy( - message = AN_ENCRYPTED_ROOM_REPLY.message.copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null), - replyingTo = AN_ENCRYPTED_ROOM_REPLY.replyingTo.copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null), + message = (AN_ENCRYPTED_ROOM_REPLY.message as RoomEvent.Message).copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null), + replyingTo = (AN_ENCRYPTED_ROOM_REPLY.replyingTo as RoomEvent.Message).copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null), ) ) } @@ -66,12 +69,12 @@ class RoomEventsDecrypterTest { private fun givenEncryptedMessage(roomMessage: RoomEvent.Message, decryptsTo: DecryptedContent) { val model = roomMessage.encryptedContent!!.toModel() fakeMessageDecrypter.givenDecrypt(model) - .returns(aDecryptionSuccessResult(payload = JsonString(Json.encodeToString(DecryptedContent.serializer(), decryptsTo)))) + .returns(aDecryptionSuccessResult(payload = JsonString(json.encodeToString(DecryptedContent.serializer(), decryptsTo)))) } private fun givenEncryptedReply(roomReply: RoomEvent.Reply, decryptsTo: DecryptedContent) { - givenEncryptedMessage(roomReply.message, decryptsTo) - givenEncryptedMessage(roomReply.replyingTo, decryptsTo) + givenEncryptedMessage(roomReply.message as RoomEvent.Message, decryptsTo) + givenEncryptedMessage(roomReply.replyingTo as RoomEvent.Message, decryptsTo) } } diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt index 14ae3f9..f0b9a94 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt @@ -26,12 +26,13 @@ private val A_TEXT_EVENT_WITHOUT_CONTENT = anApiTimelineTextEvent( senderId = A_SENDER.id, content = aTimelineTextEventContent(body = null) ) +private val A_USER_CREDENTIALS = aUserCredentials() internal class RoomEventCreatorTest { private val fakeRoomMembersService = FakeRoomMembersService() - private val roomEventCreator = RoomEventCreator(fakeRoomMembersService, FakeMatrixLogger(), FakeErrorTracker()) + private val roomEventCreator = RoomEventCreator(fakeRoomMembersService, FakeErrorTracker(), RoomEventFactory(fakeRoomMembersService)) @Test fun `given Megolm encrypted event then maps to encrypted room message`() = runTest { @@ -71,7 +72,7 @@ internal class RoomEventCreatorTest { fun `given text event then maps to room message`() = runTest { fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) - val result = with(roomEventCreator) { A_TEXT_EVENT.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) } + val result = with(roomEventCreator) { A_TEXT_EVENT.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) } result shouldBeEqualTo aRoomMessageEvent( eventId = A_TEXT_EVENT.id, @@ -85,7 +86,7 @@ internal class RoomEventCreatorTest { fun `given text event without body then maps to redacted room message`() = runTest { fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) - val result = with(roomEventCreator) { A_TEXT_EVENT_WITHOUT_CONTENT.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) } + val result = with(roomEventCreator) { A_TEXT_EVENT_WITHOUT_CONTENT.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) } result shouldBeEqualTo aRoomMessageEvent( eventId = A_TEXT_EVENT_WITHOUT_CONTENT.id, @@ -100,12 +101,12 @@ internal class RoomEventCreatorTest { fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) val editEvent = anApiTimelineTextEvent().toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE) - val result = with(roomEventCreator) { editEvent.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) } + val result = with(roomEventCreator) { editEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) } result shouldBeEqualTo aRoomMessageEvent( eventId = editEvent.id, utcTimestamp = editEvent.utcTimestamp, - content = editEvent.content.body!!, + content = editEvent.asTextContent().body!!, author = A_SENDER, edited = true ) @@ -118,7 +119,7 @@ internal class RoomEventCreatorTest { val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) - val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) } + val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } result shouldBeEqualTo aRoomMessageEvent( eventId = originalMessage.id, @@ -136,7 +137,7 @@ internal class RoomEventCreatorTest { val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) - val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) } + val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } result shouldBeEqualTo aRoomMessageEvent( eventId = originalMessage.eventId, @@ -151,10 +152,10 @@ internal class RoomEventCreatorTest { fun `given edited event which relates to a room reply event then only updates message`() = runTest { fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) val originalMessage = aRoomReplyMessageEvent(message = aRoomMessageEvent()) - val editedMessage = originalMessage.message.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) + val editedMessage = (originalMessage.message as RoomEvent.Message).toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) - val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) } + val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } result shouldBeEqualTo aRoomReplyMessageEvent( replyingTo = originalMessage.replyingTo, @@ -174,7 +175,7 @@ internal class RoomEventCreatorTest { val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) - val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) } + val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } result shouldBeEqualTo null } @@ -185,22 +186,23 @@ internal class RoomEventCreatorTest { val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) - val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) } + val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } result shouldBeEqualTo null } @Test - fun `given reply event with no relation then maps to new room message`() = runTest { + fun `given reply event with no relation then maps to new room message using the full body`() = runTest { fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) val replyEvent = anApiTimelineTextEvent().toReplyEvent(messageContent = A_TEXT_EVENT_MESSAGE) - val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) } + println(replyEvent.content) + val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) } result shouldBeEqualTo aRoomMessageEvent( eventId = replyEvent.id, utcTimestamp = replyEvent.utcTimestamp, - content = "${replyEvent.content.body}", + content = replyEvent.asTextContent().body!!, author = A_SENDER, ) } @@ -212,13 +214,13 @@ internal class RoomEventCreatorTest { val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) - val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_ROOM_ID, lookup) } + val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } result shouldBeEqualTo aRoomReplyMessageEvent( replyingTo = aRoomMessageEvent( eventId = originalMessage.id, utcTimestamp = originalMessage.utcTimestamp, - content = originalMessage.content.body!!, + content = originalMessage.asTextContent().body!!, author = A_SENDER, ), message = aRoomMessageEvent( @@ -237,7 +239,7 @@ internal class RoomEventCreatorTest { val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) - val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_ROOM_ID, lookup) } + val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } result shouldBeEqualTo aRoomReplyMessageEvent( replyingTo = originalMessage, @@ -254,10 +256,10 @@ internal class RoomEventCreatorTest { fun `given reply event which relates to another room reply event then maps to reply with the reply's message`() = runTest { fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) val originalMessage = aRoomReplyMessageEvent() - val replyMessage = originalMessage.message.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) + val replyMessage = (originalMessage.message as RoomEvent.Message).toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) - val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_ROOM_ID, lookup) } + val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } result shouldBeEqualTo aRoomReplyMessageEvent( replyingTo = originalMessage.message, @@ -270,7 +272,7 @@ internal class RoomEventCreatorTest { ) } - private fun givenLookup(event: ApiTimelineEvent.TimelineText): suspend (EventId) -> LookupResult { + private fun givenLookup(event: ApiTimelineEvent.TimelineMessage): suspend (EventId) -> LookupResult { return { if (it == event.id) LookupResult(event, roomEvent = null) else throw IllegalArgumentException("unexpected id: $it") } @@ -283,7 +285,7 @@ internal class RoomEventCreatorTest { } } -private fun ApiTimelineEvent.TimelineText.toEditEvent(newTimestamp: Long, messageContent: String) = this.copy( +private fun ApiTimelineEvent.TimelineMessage.toEditEvent(newTimestamp: Long, messageContent: String) = this.copy( id = anEventId("a-new-event-id"), utcTimestamp = newTimestamp, content = aTimelineTextEventContent( @@ -301,7 +303,7 @@ private fun RoomEvent.Message.toEditEvent(newTimestamp: Long, messageContent: St ) ) -private fun ApiTimelineEvent.TimelineText.toReplyEvent(messageContent: String) = anApiTimelineTextEvent( +private fun ApiTimelineEvent.TimelineMessage.toReplyEvent(messageContent: String) = anApiTimelineTextEvent( id = anEventId("a-new-event-id"), content = aTimelineTextEventContent( body = "${this.content} $messageContent", @@ -326,4 +328,6 @@ private fun ApiEncryptedContent.toMegolm(): RoomEvent.Message.MegOlmV1 { private class FakeLookup(private val result: LookupResult) : suspend (EventId) -> LookupResult { override suspend fun invoke(p1: EventId) = result -} \ No newline at end of file +} + +private fun ApiTimelineEvent.TimelineMessage.asTextContent() = this.content as ApiTimelineEvent.TimelineMessage.Content.Text diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt index 1b5019e..60a76fb 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt @@ -22,6 +22,7 @@ private object ARoom { val DECRYPTED_EVENTS = listOf(MESSAGE_EVENT, DECRYPTED_EVENT) val NEW_STATE = RoomState(aRoomOverview(lastMessage = DECRYPTED_EVENT.asLastMessage()), DECRYPTED_EVENTS) } +private val A_USER_CREDENTIALS = aUserCredentials() internal class RoomRefresherTest { @@ -38,7 +39,7 @@ internal class RoomRefresherTest { fun `given no existing room when refreshing then does nothing`() = runTest { fakeRoomDataSource.givenNoCachedRoom(A_ROOM_ID) - val result = roomRefresher.refreshRoomContent(aRoomId()) + val result = roomRefresher.refreshRoomContent(aRoomId(), A_USER_CREDENTIALS) result shouldBeEqualTo null fakeRoomDataSource.verifyNoChanges() @@ -48,9 +49,9 @@ internal class RoomRefresherTest { fun `given existing room when refreshing then processes existing state`() = runTest { fakeRoomDataSource.expect { it.instance.persist(RoomId(any()), any(), any()) } fakeRoomDataSource.givenRoom(A_ROOM_ID, ARoom.PREVIOUS_STATE) - fakeRoomEventsDecrypter.givenDecrypts(ARoom.PREVIOUS_STATE.events, ARoom.DECRYPTED_EVENTS) + fakeRoomEventsDecrypter.givenDecrypts(A_USER_CREDENTIALS, ARoom.PREVIOUS_STATE.events, ARoom.DECRYPTED_EVENTS) - val result = roomRefresher.refreshRoomContent(aRoomId()) + val result = roomRefresher.refreshRoomContent(aRoomId(), A_USER_CREDENTIALS) fakeRoomDataSource.verifyRoomUpdated(ARoom.PREVIOUS_STATE, ARoom.NEW_STATE) result shouldBeEqualTo ARoom.NEW_STATE diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt index 6eb1ae9..531351a 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt @@ -22,6 +22,7 @@ private val A_TEXT_TIMELINE_EVENT = anApiTimelineTextEvent() private val A_MESSAGE_ROOM_EVENT = aRoomMessageEvent(anEventId("a-message")) private val AN_ENCRYPTED_ROOM_EVENT = anEncryptedRoomMessageEvent(anEventId("encrypted-message")) private val A_LOOKUP_EVENT_ID = anEventId("lookup-id") +private val A_USER_CREDENTIALS = aUserCredentials() class TimelineEventsProcessorTest { @@ -41,7 +42,7 @@ class TimelineEventsProcessorTest { fun `given a room with no events then returns empty`() = runTest { val previousEvents = emptyList() val roomToProcess = aRoomToProcess() - fakeRoomEventsDecrypter.givenDecrypts(previousEvents) + fakeRoomEventsDecrypter.givenDecrypts(A_USER_CREDENTIALS, previousEvents) fakeSyncEventDecrypter.givenDecrypts(roomToProcess.apiSyncRoom.timeline.apiTimelineEvents) val result = timelineEventsProcessor.process(roomToProcess, previousEvents) @@ -54,11 +55,18 @@ class TimelineEventsProcessorTest { val previousEvents = listOf(aRoomMessageEvent(eventId = anEventId("previous-event"))) val newTimelineEvents = listOf(AN_ENCRYPTED_TIMELINE_EVENT, A_TEXT_TIMELINE_EVENT) val roomToProcess = aRoomToProcess(apiSyncRoom = anApiSyncRoom(anApiSyncRoomTimeline(newTimelineEvents))) - fakeRoomEventsDecrypter.givenDecrypts(previousEvents) + fakeRoomEventsDecrypter.givenDecrypts(A_USER_CREDENTIALS, previousEvents) fakeSyncEventDecrypter.givenDecrypts(newTimelineEvents) fakeEventLookup.givenLookup(A_LOOKUP_EVENT_ID, DecryptedTimeline(newTimelineEvents), DecryptedRoomEvents(previousEvents), ANY_LOOKUP_RESULT) fakeRoomEventCreator.givenCreates(A_ROOM_ID, AN_ENCRYPTED_TIMELINE_EVENT, AN_ENCRYPTED_ROOM_EVENT) - fakeRoomEventCreator.givenCreatesUsingLookup(A_ROOM_ID, A_LOOKUP_EVENT_ID, A_TEXT_TIMELINE_EVENT, A_MESSAGE_ROOM_EVENT, ANY_LOOKUP_RESULT) + fakeRoomEventCreator.givenCreatesUsingLookup( + A_USER_CREDENTIALS, + A_ROOM_ID, + A_LOOKUP_EVENT_ID, + A_TEXT_TIMELINE_EVENT, + A_MESSAGE_ROOM_EVENT, + ANY_LOOKUP_RESULT + ) val result = timelineEventsProcessor.process(roomToProcess, previousEvents) @@ -79,7 +87,7 @@ class TimelineEventsProcessorTest { anIgnoredApiTimelineEvent() ) val roomToProcess = aRoomToProcess(apiSyncRoom = anApiSyncRoom(anApiSyncRoomTimeline(newTimelineEvents))) - fakeRoomEventsDecrypter.givenDecrypts(previousEvents) + fakeRoomEventsDecrypter.givenDecrypts(A_USER_CREDENTIALS, previousEvents) fakeSyncEventDecrypter.givenDecrypts(newTimelineEvents) val result = timelineEventsProcessor.process(roomToProcess, previousEvents) diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCaseTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessorTest.kt similarity index 88% rename from matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCaseTest.kt rename to matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessorTest.kt index eafbcf7..9fd79c2 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCaseTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessorTest.kt @@ -14,18 +14,18 @@ private val A_ROOM_MESSAGE_FROM_OTHER = aRoomMessageEvent( author = aRoomMember(id = aUserId("a-different-user")) ) -internal class UnreadEventsUseCaseTest { +internal class UnreadEventsProcessorTest { private val fakeRoomStore = FakeRoomStore() - private val unreadEventsUseCase = UnreadEventsUseCase( + private val unreadEventsProcessor = UnreadEventsProcessor( fakeRoomStore, FakeMatrixLogger() ) @Test fun `given initial sync when processing unread then does mark any events as unread`() = runTest { - unreadEventsUseCase.processUnreadState( + unreadEventsProcessor.processUnreadState( isInitialSync = true, overview = aRoomOverview(), previousState = null, @@ -40,7 +40,7 @@ internal class UnreadEventsUseCaseTest { fun `given read marker has changed when processing unread then marks room read`() = runTest { fakeRoomStore.expect { it.markRead(RoomId(any())) } - unreadEventsUseCase.processUnreadState( + unreadEventsProcessor.processUnreadState( isInitialSync = false, overview = A_ROOM_OVERVIEW.copy(readMarker = anEventId("an-updated-marker")), previousState = A_ROOM_OVERVIEW, @@ -55,7 +55,7 @@ internal class UnreadEventsUseCaseTest { fun `given new events from other users when processing unread then inserts events as unread`() = runTest { fakeRoomStore.expect { it.insertUnread(RoomId(any()), any()) } - unreadEventsUseCase.processUnreadState( + unreadEventsProcessor.processUnreadState( isInitialSync = false, overview = A_ROOM_OVERVIEW, previousState = null, diff --git a/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventCreator.kt b/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventCreator.kt index 9cc02ea..4d0ee6b 100644 --- a/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventCreator.kt +++ b/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventCreator.kt @@ -2,6 +2,7 @@ package internalfake import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserCredentials import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent import app.dapk.st.matrix.sync.internal.sync.LookupResult @@ -18,9 +19,16 @@ internal class FakeRoomEventCreator { coEvery { with(instance) { event.toRoomEvent(roomId) } } returns result } - fun givenCreatesUsingLookup(roomId: RoomId, eventIdToLookup: EventId, event: ApiTimelineEvent.TimelineText, result: RoomEvent, lookupResult: LookupResult) { + fun givenCreatesUsingLookup( + userCredentials: UserCredentials, + roomId: RoomId, + eventIdToLookup: EventId, + event: ApiTimelineEvent.TimelineMessage, + result: RoomEvent, + lookupResult: LookupResult + ) { val slot = slot LookupResult>() - coEvery { with(instance) { event.toRoomEvent(roomId, capture(slot)) } } answers { + coEvery { with(instance) { event.toRoomEvent(userCredentials, roomId, capture(slot)) } } answers { runBlocking { if (slot.captured.invoke(eventIdToLookup) == lookupResult) { result diff --git a/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventsDecrypter.kt b/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventsDecrypter.kt index 64e8769..773832d 100644 --- a/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventsDecrypter.kt +++ b/matrix/services/sync/src/test/kotlin/internalfake/FakeRoomEventsDecrypter.kt @@ -1,5 +1,6 @@ package internalfake +import app.dapk.st.matrix.common.UserCredentials import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter import io.mockk.coEvery @@ -8,7 +9,7 @@ import io.mockk.mockk internal class FakeRoomEventsDecrypter { val instance = mockk() - fun givenDecrypts(previousEvents: List, result: List = previousEvents) { - coEvery { instance.decryptRoomEvents(previousEvents) } returns result + fun givenDecrypts(userCredentials: UserCredentials, previousEvents: List, result: List = previousEvents) { + coEvery { instance.decryptRoomEvents(userCredentials, previousEvents) } returns result } } \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/internalfixture/ApiSyncRoomFixture.kt b/matrix/services/sync/src/test/kotlin/internalfixture/ApiSyncRoomFixture.kt index 7c1fe23..8e069ea 100644 --- a/matrix/services/sync/src/test/kotlin/internalfixture/ApiSyncRoomFixture.kt +++ b/matrix/services/sync/src/test/kotlin/internalfixture/ApiSyncRoomFixture.kt @@ -31,28 +31,27 @@ internal fun anEphemeralTypingEvent( internal fun anApiTimelineTextEvent( id: EventId = anEventId(), senderId: UserId = aUserId(), - content: ApiTimelineEvent.TimelineText.Content = aTimelineTextEventContent(), + content: ApiTimelineEvent.TimelineMessage.Content = aTimelineTextEventContent(), utcTimestamp: Long = 0L, decryptionStatus: ApiTimelineEvent.DecryptionStatus? = null -) = ApiTimelineEvent.TimelineText(id, senderId, content, utcTimestamp, decryptionStatus) +) = ApiTimelineEvent.TimelineMessage(id, senderId, content, utcTimestamp, decryptionStatus) internal fun aTimelineTextEventContent( body: String? = null, formattedBody: String? = null, - type: String? = null, - relation: ApiTimelineEvent.TimelineText.Relation? = null, -) = ApiTimelineEvent.TimelineText.Content(body, formattedBody, type, relation) + relation: ApiTimelineEvent.TimelineMessage.Relation? = null, +) = ApiTimelineEvent.TimelineMessage.Content.Text(body, formattedBody, relation) -internal fun anEditRelation(originalId: EventId) = ApiTimelineEvent.TimelineText.Relation( +internal fun anEditRelation(originalId: EventId) = ApiTimelineEvent.TimelineMessage.Relation( relationType = "m.replace", inReplyTo = null, eventId = originalId, ) -internal fun aReplyRelation(originalId: EventId) = ApiTimelineEvent.TimelineText.Relation( +internal fun aReplyRelation(originalId: EventId) = ApiTimelineEvent.TimelineMessage.Relation( relationType = null, eventId = null, - inReplyTo = ApiTimelineEvent.TimelineText.InReplyTo(originalId), + inReplyTo = ApiTimelineEvent.TimelineMessage.InReplyTo(originalId), ) internal fun anEncryptedApiTimelineEvent( @@ -104,7 +103,7 @@ internal fun aMegolmApiEncryptedContent( deviceId: DeviceId = aDeviceId(), senderKey: String = "a-sender-key", sessionId: SessionId = aSessionId(), - relation: ApiTimelineEvent.TimelineText.Relation? = null, + relation: ApiTimelineEvent.TimelineMessage.Relation? = null, ) = ApiEncryptedContent.MegOlmV1(cipherText, deviceId, senderKey, sessionId, relation) internal fun anOlmApiEncryptedContent( diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt index 6324cf6..7719a53 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt @@ -3,10 +3,13 @@ package fake import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomStore import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.Flow class FakeRoomStore : RoomStore by mockk() { @@ -27,4 +30,8 @@ class FakeRoomStore : RoomStore by mockk() { coEvery { findEvent(eventId) } returns result } + fun givenUnreadEvents(unreadEvents: Flow>>) { + every { observeUnread() } returns unreadEvents + } + } \ No newline at end of file diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt index 8d40e52..f264259 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt @@ -1,6 +1,19 @@ package fake +import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.sync.SyncService +import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import test.delegateReturn -class FakeSyncService : SyncService by mockk() +class FakeSyncService : SyncService by mockk() { + fun givenStartsSyncing() { + every { startSyncing() }.returns(emptyFlow()) + } + + fun givenRoom(roomId: RoomId) = every { room(roomId) }.delegateReturn() + + fun givenEvents(roomId: RoomId) = every { events() }.delegateReturn() + +} diff --git a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt index f8f8524..65b1c09 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt @@ -14,9 +14,19 @@ fun aRoomMessageEvent( edited: Boolean = false, ) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, encryptedContent, edited) +fun aRoomImageMessageEvent( + eventId: EventId = anEventId(), + utcTimestamp: Long = 0L, + content: RoomEvent.Image.ImageMeta = anImageMeta(), + author: RoomMember = aRoomMember(), + meta: MessageMeta = MessageMeta.FromServer, + encryptedContent: RoomEvent.Message.MegOlmV1? = null, + edited: Boolean = false, +) = RoomEvent.Image(eventId, utcTimestamp, content, author, meta, encryptedContent, edited) + fun aRoomReplyMessageEvent( - message: RoomEvent.Message = aRoomMessageEvent(), - replyingTo: RoomEvent.Message = aRoomMessageEvent(eventId = anEventId("in-reply-to-id")), + message: RoomEvent = aRoomMessageEvent(), + replyingTo: RoomEvent = aRoomMessageEvent(eventId = anEventId("in-reply-to-id")), ) = RoomEvent.Reply(message, replyingTo) fun anEncryptedRoomMessageEvent( @@ -34,4 +44,11 @@ fun aMegolmV1( deviceId: DeviceId = aDeviceId(), senderKey: String = "a-sender-key", sessionId: SessionId = aSessionId(), -) = RoomEvent.Message.MegOlmV1(cipherText, deviceId, senderKey, sessionId) \ No newline at end of file +) = RoomEvent.Message.MegOlmV1(cipherText, deviceId, senderKey, sessionId) + +fun anImageMeta( + width: Int? = 100, + height: Int? = 100, + url: String = "https://a-url.com", + keys: RoomEvent.Image.ImageMeta.Keys? = null +) = RoomEvent.Image.ImageMeta(width, height, url, keys) \ No newline at end of file diff --git a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomStateFixture.kt b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomStateFixture.kt new file mode 100644 index 0000000..260e46d --- /dev/null +++ b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomStateFixture.kt @@ -0,0 +1,10 @@ +package fixture + +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.matrix.sync.RoomState + +fun aRoomState( + roomOverview: RoomOverview = aRoomOverview(), + events: List = listOf(aRoomMessageEvent()), +) = RoomState(roomOverview, events) \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index ba9cc6b..d457b3a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,9 +19,11 @@ include ':features:notifications' include ':features:messenger' include ':features:navigator' include ':features:verification' +include ':features:share-entry' include ':domains:android:stub' include ':domains:android:core' +include ':domains:android:compose-core' include ':domains:android:imageloader' include ':domains:android:work' include ':domains:android:tracking' diff --git a/test-harness/build.gradle b/test-harness/build.gradle index 5312167..4664053 100644 --- a/test-harness/build.gradle +++ b/test-harness/build.gradle @@ -9,7 +9,7 @@ test { dependencies { kotlinTest(it) - testImplementation 'app.cash.turbine:turbine:0.7.0' + testImplementation 'app.cash.turbine:turbine:0.9.0' testImplementation Dependencies.mavenCentral.kotlinSerializationJson @@ -28,7 +28,7 @@ dependencies { testImplementation project(":matrix:services:crypto") testImplementation rootProject.files("external/jolm.jar") - testImplementation 'org.json:json:20211205' + testImplementation 'org.json:json:20220320' testImplementation Dependencies.mavenCentral.ktorJava testImplementation Dependencies.mavenCentral.sqldelightInMemory diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index 4243e73..d153626 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -1,3 +1,4 @@ +import app.dapk.st.matrix.auth.AuthService import app.dapk.st.matrix.auth.authService import app.dapk.st.matrix.common.HomeServerUrl import app.dapk.st.matrix.common.RoomId @@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeInstanceOf import org.amshove.kluent.shouldNotBeEqualTo import org.junit.jupiter.api.MethodOrderer import org.junit.jupiter.api.Order @@ -20,10 +22,10 @@ import test.MatrixTestScope import test.TestMatrix import test.flowTest import test.restoreLoginAndInitialSync +import java.nio.file.Paths import java.util.* -private const val TEST_SERVER_URL_REDIRECT = "http://localhost:8080/" -private const val HTTPS_TEST_SERVER_URL = "https://localhost:8480/" +private const val HTTPS_TEST_SERVER_URL = "https://localhost:8080/" @TestMethodOrder(MethodOrderer.OrderAnnotation::class) class SmokeTest { @@ -64,31 +66,22 @@ class SmokeTest { @Test @Order(4) - fun `can send and receive encrypted messages`() = testAfterInitialSync { alice, bob -> - val message = "from alice to bob : ${System.currentTimeMillis()}".from(SharedState.alice.roomMember) - alice.sendEncryptedMessage(SharedState.sharedRoom, message.content) - bob.expectMessage(SharedState.sharedRoom, message) - - val message2 = "from bob to alice : ${System.currentTimeMillis()}".from(SharedState.bob.roomMember) - bob.sendEncryptedMessage(SharedState.sharedRoom, message2.content) - alice.expectMessage(SharedState.sharedRoom, message2) - - val aliceSecondDevice = TestMatrix(SharedState.alice).also { it.newlogin() } - aliceSecondDevice.client.syncService().startSyncing().collectAsync { - val message3 = "from alice to bob and alice's second device : ${System.currentTimeMillis()}".from(SharedState.alice.roomMember) - alice.sendEncryptedMessage(SharedState.sharedRoom, message3.content) - aliceSecondDevice.expectMessage(SharedState.sharedRoom, message3) - bob.expectMessage(SharedState.sharedRoom, message3) - - val message4 = "from alice's second device to bob and alice's first device : ${System.currentTimeMillis()}".from(SharedState.alice.roomMember) - aliceSecondDevice.sendEncryptedMessage(SharedState.sharedRoom, message4.content) - alice.expectMessage(SharedState.sharedRoom, message4) - bob.expectMessage(SharedState.sharedRoom, message4) - } - } + fun `can send and receive clear text messages`() = testTextMessaging(isEncrypted = false) @Test @Order(5) + fun `can send and receive encrypted text messages`() = testTextMessaging(isEncrypted = true) + + @Test + @Order(6) + fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> + val testImage = loadResourceFile("test-image.png") + alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) + bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) + } + + @Test + @Order(7) fun `can request and verify devices`() = testAfterInitialSync { alice, bob -> alice.client.cryptoService().verificationAction(Verification.Action.Request(bob.userId(), bob.deviceId())) alice.client.cryptoService().verificationState().automaticVerification(alice).expectAsync { it == Verification.State.Done } @@ -101,7 +94,7 @@ class SmokeTest { fun `can import E2E room keys file`() = runTest { val ignoredUser = TestUser("ignored", RoomMember(UserId("ignored"), null, null), "ignored") val cryptoService = TestMatrix(ignoredUser, includeLogging = true).client.cryptoService() - val stream = Thread.currentThread().contextClassLoader.getResourceAsStream("element-keys.txt")!! + val stream = loadResourceStream("element-keys.txt") val result = with(cryptoService) { stream.importRoomKeys(password = "aaaaaa") @@ -109,11 +102,35 @@ class SmokeTest { result shouldBeEqualTo listOf(RoomId(value = "!qOSENTtFUuCEKJSVzl:matrix.org")) } + + private fun testTextMessaging(isEncrypted: Boolean) = testAfterInitialSync { alice, bob -> + val message = "from alice to bob".from(SharedState.alice.roomMember) + alice.sendTextMessage(SharedState.sharedRoom, message.content, isEncrypted) + bob.expectTextMessage(SharedState.sharedRoom, message) + + val message2 = "from bob to alice".from(SharedState.bob.roomMember) + bob.sendTextMessage(SharedState.sharedRoom, message2.content, isEncrypted) + alice.expectTextMessage(SharedState.sharedRoom, message2) + + // Needs investigation +// val aliceSecondDevice = testMatrix(SharedState.alice, isTemp = true, withLogging = true).also { it.newlogin() } +// aliceSecondDevice.client.syncService().startSyncing().collectAsync { +// val message3 = "from alice to bob and alice's second device".from(SharedState.alice.roomMember) +// alice.sendTextMessage(SharedState.sharedRoom, message3.content, isEncrypted) +// aliceSecondDevice.expectTextMessage(SharedState.sharedRoom, message3) +// bob.expectTextMessage(SharedState.sharedRoom, message3) +// +// val message4 = "from alice's second device to bob and alice's first device".from(SharedState.alice.roomMember) +// aliceSecondDevice.sendTextMessage(SharedState.sharedRoom, message4.content, isEncrypted) +// alice.expectTextMessage(SharedState.sharedRoom, message4) +// bob.expectTextMessage(SharedState.sharedRoom, message4) +// } + } } private suspend fun createAndRegisterAccount(): TestUser { val aUserName = "${UUID.randomUUID()}" - val userId = UserId("@$aUserName:localhost:8480") + val userId = UserId("@$aUserName:localhost:8080") val aUser = TestUser("aaaa11111zzzz", RoomMember(userId, aUserName, null), HTTPS_TEST_SERVER_URL) val result = TestMatrix(aUser, includeLogging = true, includeHttpLogging = true) @@ -122,7 +139,7 @@ private suspend fun createAndRegisterAccount(): TestUser { .register(aUserName, aUser.password, homeServer = HTTPS_TEST_SERVER_URL) result.accessToken shouldNotBeEqualTo null - result.homeServer shouldBeEqualTo HomeServerUrl(TEST_SERVER_URL_REDIRECT) + result.homeServer shouldBeEqualTo HomeServerUrl(HTTPS_TEST_SERVER_URL) result.userId shouldBeEqualTo userId return aUser } @@ -132,13 +149,16 @@ private suspend fun login(user: TestUser) { val result = testMatrix .client .authService() - .login(userName = user.roomMember.id.value, password = user.password) + .login(AuthService.LoginRequest(userName = user.roomMember.id.value, password = user.password, serverUrl = null)) - result.accessToken shouldNotBeEqualTo null - result.homeServer shouldBeEqualTo HomeServerUrl(TEST_SERVER_URL_REDIRECT) - result.userId shouldBeEqualTo user.roomMember.id + result shouldBeInstanceOf AuthService.LoginResult.Success::class.java + (result as AuthService.LoginResult.Success).userCredentials.let { credentials -> + credentials.accessToken shouldNotBeEqualTo null + credentials.homeServer shouldBeEqualTo HomeServerUrl(HTTPS_TEST_SERVER_URL) + credentials.userId shouldBeEqualTo user.roomMember.id - testMatrix.saveLogin(result) + testMatrix.saveLogin(credentials) + } } object SharedState { @@ -158,7 +178,7 @@ object SharedState { data class TestUser(val password: String, val roomMember: RoomMember, val homeServer: String) data class TestMessage(val content: String, val author: RoomMember) -fun String.from(roomMember: RoomMember) = TestMessage(this, roomMember) +fun String.from(roomMember: RoomMember) = TestMessage("$this - ${UUID.randomUUID()}", roomMember) fun testAfterInitialSync(block: suspend MatrixTestScope.(TestMatrix, TestMatrix) -> Unit) { restoreLoginAndInitialSync(TestMatrix(SharedState.alice, includeLogging = false), TestMatrix(SharedState.bob, includeLogging = false), block) @@ -167,5 +187,11 @@ fun testAfterInitialSync(block: suspend MatrixTestScope.(TestMatrix, TestMatrix) private fun Flow.automaticVerification(testMatrix: TestMatrix) = this.onEach { when (it) { is Verification.State.WaitingForMatchConfirmation -> testMatrix.client.cryptoService().verificationAction(Verification.Action.AcknowledgeMatch) + else -> { + // do nothing + } } -} \ No newline at end of file +} + +private fun loadResourceStream(name: String) = Thread.currentThread().contextClassLoader.getResourceAsStream(name)!! +private fun loadResourceFile(name: String) = Paths.get(Thread.currentThread().contextClassLoader.getResource(name)!!.toURI()).toFile() diff --git a/test-harness/src/test/kotlin/test/Test.kt b/test-harness/src/test/kotlin/test/Test.kt index f712d70..91da2a0 100644 --- a/test-harness/src/test/kotlin/test/Test.kt +++ b/test-harness/src/test/kotlin/test/Test.kt @@ -3,18 +3,34 @@ package test import TestMessage +import TestUser import app.dapk.st.core.extensions.ifNull +import app.dapk.st.matrix.common.MxUrl import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.convertMxUrToUrl +import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.syncService +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.util.cio.* +import io.ktor.utils.io.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.amshove.kluent.fail import org.amshove.kluent.shouldBeEqualTo +import java.io.File +import java.math.BigInteger +import java.net.URL +import java.security.MessageDigest +import java.util.* fun flowTest(block: suspend MatrixTestScope.() -> Unit) { runTest { @@ -30,6 +46,8 @@ fun restoreLoginAndInitialSync(m1: TestMatrix, m2: TestMatrix, testBody: suspend println("restore login 2") m2.restoreLogin() val testHelper = MatrixTestScope(this) + testHelper.testMatrix(m1) + testHelper.testMatrix(m2) with(testHelper) { combine(m1.client.syncService().startSyncing(), m2.client.syncService().startSyncing()) { _, _ -> }.collectAsync { m1.client.syncService().overview().first() @@ -37,6 +55,7 @@ fun restoreLoginAndInitialSync(m1: TestMatrix, m2: TestMatrix, testBody: suspend testBody(testHelper, m1, m2) } } + testHelper.release() } } @@ -53,6 +72,7 @@ suspend fun Flow.collectAsync(scope: CoroutineScope, block: suspend () -> class MatrixTestScope(private val testScope: TestScope) { private val inProgressExpects = mutableListOf>() + private val inProgressInstances = mutableListOf() suspend fun Flow.collectAsync(block: suspend () -> Unit) { collectAsync(testScope, block) @@ -98,6 +118,7 @@ class MatrixTestScope(private val testScope: TestScope) { val collected = mutableListOf() val work = testScope.async { flow.onEach { + println("found: $it") collected.add(it) }.first { it == expected } } @@ -117,18 +138,51 @@ class MatrixTestScope(private val testScope: TestScope) { .expect { it.any { it.roomId == roomId } } } - suspend fun TestMatrix.expectMessage(roomId: RoomId, message: TestMessage) { + suspend fun TestMatrix.expectTextMessage(roomId: RoomId, message: TestMessage) { + println("expecting ${message.content}") this.client.syncService().room(roomId) .map { it.events.filterIsInstance().map { TestMessage(it.content, it.author) }.firstOrNull() } .assert(message) } - suspend fun TestMatrix.sendEncryptedMessage(roomId: RoomId, content: String) { + suspend fun TestMatrix.expectImageMessage(roomId: RoomId, image: File, author: RoomMember) { + println("expecting ${image.absolutePath} from ${author.displayName}") + this.client.syncService().room(roomId) + .map { + it.events.filterIsInstance().map { + println("found: ${it.imageMeta.url}") + val output = File(image.parentFile.absolutePath, "output.png") + HttpClient().request(it.imageMeta.url).bodyAsChannel().copyAndClose(output.writeChannel()) + output.readBytes().md5Hash() to it.author + }.firstOrNull() + } + .assert(image.readBytes().md5Hash() to author) + } + + suspend fun TestMatrix.sendTextMessage(roomId: RoomId, content: String, isEncrypted: Boolean) { + println("sending $content") this.client.messageService().scheduleMessage( MessageService.Message.TextMessage( content = MessageService.Message.Content.TextContent(body = content), roomId = roomId, - sendEncrypted = true, + sendEncrypted = isEncrypted, + localId = "local.${UUID.randomUUID()}", + timestampUtc = System.currentTimeMillis(), + ) + ) + } + + suspend fun TestMatrix.sendImageMessage(roomId: RoomId, file: File, isEncrypted: Boolean) { + println("sending ${file.name}") + this.client.messageService().scheduleMessage( + MessageService.Message.ImageMessage( + content = MessageService.Message.Content.ApiImageContent( + uri = file.absolutePath, + ), + roomId = roomId, + sendEncrypted = isEncrypted, + localId = "local.${UUID.randomUUID()}", + timestampUtc = System.currentTimeMillis(), ) ) } @@ -140,4 +194,21 @@ class MatrixTestScope(private val testScope: TestScope) { } } + fun testMatrix(user: TestUser, isTemp: Boolean, withLogging: Boolean = false) = TestMatrix( + user, + temporaryDatabase = isTemp, + includeLogging = withLogging + ).also { inProgressInstances.add(it) } + + fun testMatrix(testMatrix: TestMatrix) = inProgressInstances.add(testMatrix) + + suspend fun release() { + inProgressInstances.forEach { it.release() } + } +} + +private fun ByteArray.md5Hash(): String { + val md = MessageDigest.getInstance("MD5") + val bigInt = BigInteger(1, md.digest(this)) + return String.format("%032x", bigInt) } \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index c5de509..b758207 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -1,11 +1,12 @@ package test import TestUser +import app.dapk.st.core.Base64 import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.SingletonFlows import app.dapk.st.domain.StoreModule import app.dapk.st.matrix.MatrixClient -import app.dapk.st.matrix.auth.AuthConfig +import app.dapk.st.matrix.auth.AuthService import app.dapk.st.matrix.auth.authService import app.dapk.st.matrix.auth.installAuthService import app.dapk.st.matrix.common.* @@ -21,6 +22,7 @@ import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.installMessageService +import app.dapk.st.matrix.message.internal.ImageContentReader import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.room.RoomMessenger @@ -32,18 +34,21 @@ import app.dapk.st.matrix.sync.internal.room.MessageDecrypter import app.dapk.st.olm.DeviceKeyFactory import app.dapk.st.olm.OlmPersistenceWrapper import app.dapk.st.olm.OlmWrapper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.* import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.amshove.kluent.fail import test.impl.InMemoryDatabase import test.impl.InMemoryPreferences import test.impl.InstantScheduler import test.impl.PrintingErrorTracking +import java.io.File import java.time.Clock +import javax.imageio.ImageIO class TestMatrix( private val user: TestUser, + temporaryDatabase: Boolean = false, includeHttpLogging: Boolean = false, includeLogging: Boolean = false, ) { @@ -56,8 +61,15 @@ class TestMatrix( } private val preferences = InMemoryPreferences() - private val database = InMemoryDatabase.realInstance(user.roomMember.id.value) - private val coroutineDispatchers = CoroutineDispatchers(Dispatchers.Unconfined, CoroutineScope(Dispatchers.Unconfined)) + private val database = when (temporaryDatabase) { + true -> InMemoryDatabase.temp() + false -> InMemoryDatabase.realInstance(user.roomMember.id.value) + } + private val coroutineDispatchers = CoroutineDispatchers( + Dispatchers.Unconfined, + main = Dispatchers.Unconfined, + global = CoroutineScope(Dispatchers.Unconfined) + ) private val storeModule = StoreModule( database = database, @@ -78,10 +90,11 @@ class TestMatrix( logger ).also { it.install { - installAuthService(storeModule.credentialsStore(), AuthConfig(forceHttp = false)) + installAuthService(storeModule.credentialsStore()) installEncryptionService(storeModule.knownDevicesStore()) - val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore()) + val base64 = JavaBase64() + val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore(), base64) val olm = OlmWrapper( olmStore = olmAccountStore, singletonFlows = SingletonFlows(coroutineDispatchers), @@ -100,14 +113,16 @@ class TestMatrix( services.roomService().joinedMembers(it).map { it.userId } } }, + base64 = base64, coroutineDispatchers = coroutineDispatchers, ) - installMessageService(storeModule.localEchoStore, InstantScheduler(it)) { serviceProvider -> + installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader()) { serviceProvider -> MessageEncrypter { message -> val result = serviceProvider.cryptoService().encrypt( roomId = when (message) { is MessageService.Message.TextMessage -> message.roomId + is MessageService.Message.ImageMessage -> message.roomId }, credentials = storeModule.credentialsStore().credentials()!!, when (message) { @@ -121,6 +136,8 @@ class TestMatrix( ) ) ) + + is MessageService.Message.ImageMessage -> TODO() } ) @@ -156,6 +173,14 @@ class TestMatrix( storeModule.roomStore(), storeModule.syncStore(), storeModule.filterStore(), + deviceNotifier = { services -> + val encryptionService = services.deviceService() + val cryptoService = services.cryptoService() + DeviceNotifier { userIds, syncToken -> + encryptionService.updateStaleDevices(userIds) + cryptoService.updateOlmSession(userIds, syncToken) + } + }, messageDecrypter = { serviceProvider -> MessageDecrypter { serviceProvider.cryptoService().decrypt(it) @@ -179,12 +204,14 @@ class TestMatrix( apiEvent.content.methods, apiEvent.content.timestampPosix, ) + is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( apiEvent.sender, apiEvent.content.fromDevice, apiEvent.content.transactionId, apiEvent.content.methods, ) + is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( apiEvent.sender, apiEvent.content.fromDevice, @@ -195,6 +222,7 @@ class TestMatrix( apiEvent.content.short, apiEvent.content.transactionId, ) + is ApiToDeviceEvent.VerificationAccept -> Verification.Event.Accepted( apiEvent.sender, apiEvent.content.fromDevice, @@ -205,12 +233,14 @@ class TestMatrix( apiEvent.content.short, apiEvent.content.transactionId, ) + is ApiToDeviceEvent.VerificationCancel -> TODO() is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( apiEvent.sender, apiEvent.content.transactionId, apiEvent.content.key ) + is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( apiEvent.sender, apiEvent.content.transactionId, @@ -221,14 +251,6 @@ class TestMatrix( ) } }, - deviceNotifier = { services -> - val encryptionService = services.deviceService() - val cryptoService = services.cryptoService() - DeviceNotifier { userIds, syncToken -> - encryptionService.updateStaleDevices(userIds) - cryptoService.updateOlmSession(userIds, syncToken) - } - }, oneTimeKeyProducer = { services -> val cryptoService = services.cryptoService() MaybeCreateMoreKeys { @@ -239,6 +261,7 @@ class TestMatrix( val roomService = services.roomService() object : RoomMembersService { override suspend fun find(roomId: RoomId, userIds: List) = roomService.findMembers(roomId, userIds) + override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId) override suspend fun insert(roomId: RoomId, members: List) = roomService.insertMembers(roomId, members) } }, @@ -251,8 +274,12 @@ class TestMatrix( } suspend fun newlogin() { - client.authService() - .login(user.roomMember.id.value, user.password) + val result = client.authService() + .login(AuthService.LoginRequest(user.roomMember.id.value, user.password, null)) + + if (result !is AuthService.LoginResult.Success) { + fail("Login failed: $result") + } } suspend fun restoreLogin() { @@ -270,4 +297,51 @@ class TestMatrix( suspend fun deviceId() = storeModule.credentialsStore().credentials()!!.deviceId suspend fun userId() = storeModule.credentialsStore().credentials()!!.userId + suspend fun credentials() = storeModule.credentialsStore().credentials()!! + + suspend fun release() { + coroutineDispatchers.global.waitForCancel() + coroutineDispatchers.io.waitForCancel() + coroutineDispatchers.main.waitForCancel() + } } + +private suspend fun CoroutineDispatcher.waitForCancel() { + if (this.isActive) { + this.job.cancelAndJoin() + } +} + +private suspend fun CoroutineScope.waitForCancel() { + if (this.isActive) { + this.coroutineContext.job.cancelAndJoin() + } +} + +class JavaBase64 : Base64 { + override fun encode(input: ByteArray): String { + return java.util.Base64.getEncoder().encode(input).toString(Charsets.UTF_8) + } + + override fun decode(input: String): ByteArray { + return java.util.Base64.getDecoder().decode(input) + } +} + +class JavaImageContentReader : ImageContentReader { + + override fun read(uri: String): ImageContentReader.ImageContent { + val file = File(uri) + val size = file.length() + val image = ImageIO.read(file) + return ImageContentReader.ImageContent( + height = image.height, + width = image.width, + size = size, + mimeType = "image/${file.extension}", + fileName = file.name, + content = file.readBytes() + ) + } + +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/impl/InMemoryDatabase.kt b/test-harness/src/test/kotlin/test/impl/InMemoryDatabase.kt index a249e3d..887b1e0 100644 --- a/test-harness/src/test/kotlin/test/impl/InMemoryDatabase.kt +++ b/test-harness/src/test/kotlin/test/impl/InMemoryDatabase.kt @@ -19,4 +19,11 @@ object InMemoryDatabase { }) } + fun temp(): DapkDb { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + return DapkDb(driver).also { + DapkDb.Schema.create(driver) + } + } + } \ No newline at end of file diff --git a/test-harness/src/test/resources/test-image.png b/test-harness/src/test/resources/test-image.png new file mode 100644 index 0000000..3127893 Binary files /dev/null and b/test-harness/src/test/resources/test-image.png differ diff --git a/tools/coverage.gradle b/tools/coverage.gradle index af65bfb..15d22bd 100644 --- a/tools/coverage.gradle +++ b/tools/coverage.gradle @@ -23,11 +23,12 @@ def excludes = [ // Tmp until serializationx can ignore generated '**/Api*', + '**/RoomEvent*', ] def initializeReport(report, projects, classExcludes) { projects.each { project -> project.apply plugin: 'jacoco' } - report.executionData { fileTree(rootProject.rootDir.absolutePath).include("**/build/jacoco/*.exec") } + report.executionData { fileTree(rootProject.rootDir.absolutePath).include("**/build/**/*.exec") } report.reports { xml.enabled true @@ -89,7 +90,7 @@ def collectProjects(predicate) { task allCodeCoverageReport(type: JacocoReport) { outputs.upToDateWhen { false } rootProject.apply plugin: 'jacoco' - def projects = collectProjects { true } + def projects = collectProjects { !it.name.contains("stub") } dependsOn { ["app:assembleDebug"] + projects*.test } initializeReport(it, projects, excludes) } diff --git a/version.json b/version.json new file mode 100644 index 0000000..c8f2797 --- /dev/null +++ b/version.json @@ -0,0 +1,4 @@ +{ + "name": "0.0.1-alpha03", + "code": 5 +} \ No newline at end of file