Merge pull request #306 from ouchadam/release-candidate

[Auto] Release Candidate
This commit is contained in:
Adam Brown 2023-01-08 12:46:01 +00:00 committed by GitHub
commit a8345f9a7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
441 changed files with 2179 additions and 18111 deletions

View File

@ -1,7 +0,0 @@
version: 2
updates:
- package-ecosystem: gradle
directory: /
schedule:
interval: daily
open-pull-requests-limit: 3

View File

@ -16,7 +16,10 @@ jobs:
cancel-in-progress: true
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: 'recursive'
- uses: actions/setup-java@v3
with:
distribution: 'adopt'

View File

@ -13,8 +13,11 @@ jobs:
cancel-in-progress: true
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v2
- uses: actions/checkout@v3
with:
submodules: 'recursive'
- uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '11'

View File

@ -28,14 +28,14 @@ jobs:
id: size
- name: Find Comment
uses: peter-evans/find-comment@v1
uses: peter-evans/find-comment@v2
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
uses: peter-evans/create-or-update-comment@v2
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ steps.size.outputs.PR_NUMBER }}

View File

@ -15,8 +15,11 @@ jobs:
cancel-in-progress: true
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v2
- uses: actions/checkout@v3
with:
submodules: 'recursive'
- uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '11'

View File

@ -15,7 +15,9 @@ jobs:
cancel-in-progress: true
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: 'recursive'
- uses: actions/setup-node@v3
with:

View File

@ -16,32 +16,19 @@ jobs:
cancel-in-progress: true
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v2
- uses: actions/checkout@v3
with:
submodules: 'recursive'
- uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '11'
- uses: gradle/gradle-build-action@v2
- name: Create pip requirements
run: |
echo "matrix-synapse" > 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 -r requirements.txt
curl -sL https://gist.githubusercontent.com/ouchadam/e3ad09ec382bd91a66d88ab575ea7c31/raw/run.sh \
| bash -s -- --no-rate-limit
- name: Run all unit tests
run: ./gradlew clean allCodeCoverageReport --no-daemon
run: ./gradlew allCodeCoverageReport
- uses: codecov/codecov-action@v2
- uses: codecov/codecov-action@v3
with:
files: ./build/reports/jacoco/allCodeCoverageReport/allCodeCoverageReport.xml
verbose: true
files: ./build/reports/jacoco/allCodeCoverageReport/allCodeCoverageReport.xml

View File

@ -1,15 +0,0 @@
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

9
.gitmodules vendored Normal file
View File

@ -0,0 +1,9 @@
[submodule "screen-state"]
path = screen-state
url = git@github.com:ouchadam/screen-state.git
[submodule "chat-engine"]
path = chat-engine
url = git@github.com:ouchadam/chat-engine.git
[submodule "tools/conventions"]
path = tools/conventions
url = git@github.com:ouchadam/conventions.git

View File

@ -28,16 +28,16 @@
- Importing of E2E room keys from Element clients
- [UnifiedPush](https://unifiedpush.org/)
- FOSS variant
- Minimal HTML formatting
- Invitations
- Image attachments
### Planned
- Device verification (technically supported but has no UI)
- Invitations (technically supported but has no UI)
- Room history
- Message media
- Cross signing
- Google drive backups
- Markdown subset (bold, italic, blocks)
- Changing user name/avatar
- Room settings and information
- Exporting E2E room keys
@ -118,4 +118,25 @@
---
#### Repositories
`SmallTalk` is split into multiple repositories through git submodules.
##### [small-talk](https://github.com/ouchadam/small-talk)
- The main repository, responsibile for rendering data into _screens_ and generating the android application artifact.
##### [chat-engine](https://github.com/ouchadam/chat-engine)
- All things chat and matrix, where the vast majority of business logic sits.
- Pure kotlin with no UI.
##### [conventions](https://github.com/ouchadam/conventions)
- Reusable gradle plugins for modular android projects
##### [screen-state](https://github.com/ouchadam/screen-state)
- Reusable state management and generic screen flow components.
- Wrapper around android's `ViewModel` focused on testability.
---
#### Join the conversation @ https://matrix.to/#/#small-talk:iswell.cool

View File

@ -1,76 +1,27 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id "st-application-conventions"
alias libs.plugins.firebase.crashlytics apply false
}
applyCommonAndroidParameters(project)
applyCrashlyticsIfRelease(project)
applyCrashlyticsIfRelease()
android {
ndkVersion "25.0.8141415"
namespace "app.dapk.st"
defaultConfig {
applicationId "app.dapk.st"
def versionJson = new groovy.json.JsonSlurper().parseText(rootProject.file('version.json').text)
versionCode versionJson.code
versionName versionJson.name
if (isDebugBuild) {
resConfigs "en", "xxhdpi"
} else {
resConfigs "en"
}
if (isFoss()) {
archivesBaseName = "$archivesBaseName-foss"
}
}
bundle {
abi.enableSplit true
density.enableSplit true
language.enableSplit true
}
buildTypes {
debug {
versionNameSuffix = " [debug]"
matchingFallbacks = ['release']
signingConfig.storeFile rootProject.file("tools/debug.keystore")
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard/app.pro',
"proguard/serializationx.pro",
"proguard/olm.pro"
if (project.hasProperty("unsigned")) {
// releases are signed externally
} else {
signingConfig = buildTypes.debug.signingConfig
}
}
}
compileOptions {
coreLibraryDesugaringEnabled true
}
packagingOptions {
resources.excludes += "DebugProbesKt.bin"
}
}
if (isDebugBuild) {
androidComponents {
def release = selector().withBuildType("release")
beforeVariants(release) { it.enabled = false }
}
}
dependencies {
coreLibraryDesugaring Dependencies.google.jdkLibs
coreLibraryDesugaring libs.android.desugar
implementation project(":features:home")
implementation project(":features:directory")
@ -82,38 +33,37 @@ dependencies {
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")
implementation project(":domains:android:work")
implementation project(":domains:android:imageloader")
implementation project(":domains:olm")
implementation project(":domains:store")
firebase(it, "messaging")
implementation project(":matrix:matrix")
implementation project(":matrix:matrix-http-ktor")
implementation project(":matrix:services:auth")
implementation project(":matrix:services:sync")
implementation project(":matrix:services:room")
implementation project(":matrix:services:push")
implementation project(":matrix:services:message")
implementation project(":matrix:services:device")
implementation project(":matrix:services:crypto")
implementation project(":matrix:services:profile")
implementation project(":core")
implementation project(":chat-engine")
implementation project(":matrix-chat-engine")
implementation "chat-engine:chat-engine"
implementation "chat-engine:matrix-chat-engine"
implementation "chat-engine.matrix:store"
implementation Dependencies.google.androidxComposeUi
implementation Dependencies.mavenCentral.ktorAndroid
implementation Dependencies.mavenCentral.sqldelightAndroid
implementation Dependencies.mavenCentral.matrixOlm
implementation libs.ktor.android
implementation libs.sqldelight.android
implementation libs.matrix.olm
implementation Dependencies.mavenCentral.kotlinSerializationJson
debugImplementation Dependencies.mavenCentral.leakCanary
implementation libs.kotlin.serialization
debugImplementation libs.leakcanary
}
def applyCrashlyticsIfRelease() {
if (isReleaseBuild && !isFoss()) {
project.apply plugin: libs.plugins.firebase.crashlytics.get().pluginId
project.afterEvaluate {
project.tasks.withType(com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask).configureEach {
it.googleServicesResourceRoot.set(project.file("src/release/res/"))
}
}
}
}

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.dapk.st">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<meta-data

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.dapk.st">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>

View File

@ -9,7 +9,6 @@ import app.dapk.st.core.attachAppLogger
import app.dapk.st.core.extensions.ResettableUnsafeLazy
import app.dapk.st.core.extensions.Scope
import app.dapk.st.directory.DirectoryModule
import app.dapk.st.domain.StoreModule
import app.dapk.st.firebase.messaging.MessagingModule
import app.dapk.st.graph.AppModule
import app.dapk.st.home.HomeModule
@ -55,23 +54,20 @@ class SmallTalkApplication : Application(), ModuleProvider {
attachAppLogger(logger)
_appLogger = logger
onApplicationLaunch(notificationsModule, storeModule)
onApplicationLaunch(notificationsModule)
}
private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) {
private fun onApplicationLaunch(notificationsModule: NotificationsModule) {
applicationScope.launch {
featureModules.homeModule.betaVersionUpgradeUseCase.waitUnitReady()
storeModule.credentialsStore().credentials()?.let {
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
}
runCatching { storeModule.localEchoStore.preload() }
featureModules.chatEngineModule.engine.preload()
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
val notificationsUseCase = notificationsModule.notificationsUseCase()
notificationsUseCase.listenForNotificationChanges(this)
}
}
@Suppress("UNCHECKED_CAST", "IMPLICIT_CAST_TO_ANY")
@Suppress("UNCHECKED_CAST")
override fun <T : ProvidableModule> provide(klass: KClass<T>): T {
return when (klass) {
DirectoryModule::class -> featureModules.directoryModule
@ -99,7 +95,6 @@ class SmallTalkApplication : Application(), ModuleProvider {
lazyFeatureModules.reset()
val notificationsModule = featureModules.notificationsModule
val storeModule = appModule.storeModule.value
onApplicationLaunch(notificationsModule, storeModule)
onApplicationLaunch(notificationsModule)
}
}

View File

@ -2,42 +2,38 @@ package app.dapk.st.graph
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.media.ExifInterface
import android.net.Uri
import android.os.Build
import android.provider.OpenableColumns
import app.dapk.db.DapkDb
import app.dapk.db.app.StDb
import app.dapk.engine.core.Base64
import app.dapk.st.BuildConfig
import app.dapk.st.SharedPreferencesDelegate
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
import app.dapk.st.domain.MatrixStoreModule
import app.dapk.st.domain.StoreModule
import app.dapk.st.engine.ImageContentReader
import app.dapk.st.engine.MatrixEngine
import app.dapk.st.firebase.messaging.MessagingModule
import app.dapk.st.home.BetaVersionUpgradeUseCase
import app.dapk.st.home.HomeModule
import app.dapk.st.home.MainActivity
import app.dapk.st.imageloader.ImageLoaderModule
import app.dapk.st.impl.*
import app.dapk.st.login.LoginModule
import app.dapk.st.matrix.auth.DeviceDisplayNameGenerator
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.JsonString
import app.dapk.st.matrix.common.MatrixLogger
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.message.internal.ImageContentReader
import app.dapk.st.messenger.MessengerActivity
import app.dapk.st.messenger.MessengerModule
import app.dapk.st.messenger.gallery.ImageGalleryModule
import app.dapk.st.navigator.IntentFactory
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.notifications.NotificationsModule
import app.dapk.st.olm.OlmPersistenceWrapper
import app.dapk.st.profile.ProfileModule
import app.dapk.st.push.PushHandler
import app.dapk.st.push.PushModule
@ -51,7 +47,6 @@ import app.dapk.st.work.WorkModule
import com.squareup.sqldelight.android.AndroidSqliteDriver
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.Json
import java.io.InputStream
internal class AppModule(context: Application, logger: MatrixLogger) {
@ -64,26 +59,48 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
}
private val driver = AndroidSqliteDriver(DapkDb.Schema, context, "dapk.db")
private val database = DapkDb(driver)
private val stDriver = AndroidSqliteDriver(DapkDb.Schema, context, "stdb.db")
private val engineDatabase = DapkDb(driver)
private val stDatabase = StDb(stDriver)
val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO)
private val base64 = AndroidBase64()
val storeModule = unsafeLazy {
StoreModule(
database = database,
database = stDatabase,
preferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-user-preferences", coroutineDispatchers),
errorTracker = trackingModule.errorTracker,
credentialPreferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-credentials-preferences", coroutineDispatchers),
databaseDropper = DefaultDatabaseDropper(coroutineDispatchers, driver),
coroutineDispatchers = coroutineDispatchers
)
}
private val workModule = WorkModule(context)
private val imageLoaderModule = ImageLoaderModule(context)
private val imageContentReader by unsafeLazy { AndroidImageContentReader(context.contentResolver) }
private val chatEngineModule =
ChatEngineModule(storeModule, trackingModule, workModule, logger, coroutineDispatchers, imageContentReader, base64, buildMeta)
private val chatEngineModule = ChatEngineModule(
unsafeLazy { matrixStoreModule() },
trackingModule,
workModule,
logger,
coroutineDispatchers,
imageContentReader,
base64,
buildMeta
)
private fun matrixStoreModule(): MatrixStoreModule {
val value = storeModule.value
return MatrixStoreModule(
engineDatabase,
value.preferences.engine(),
value.credentialPreferences.engine(),
trackingModule.errorTracker.engine(),
coroutineDispatchers.engine(),
)
}
val domainModules = DomainModules(chatEngineModule, trackingModule.errorTracker, context, coroutineDispatchers)
@ -133,7 +150,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
internal class FeatureModules internal constructor(
private val storeModule: Lazy<StoreModule>,
private val chatEngineModule: ChatEngineModule,
val chatEngineModule: ChatEngineModule,
private val domainModules: DomainModules,
private val trackingModule: TrackingModule,
private val coreAndroidModule: CoreAndroidModule,
@ -173,6 +190,9 @@ internal class FeatureModules internal constructor(
storeModule.value.applicationStore(),
buildMeta,
),
profileModule,
loginModule,
directoryModule
)
}
val settingsModule by unsafeLazy {
@ -220,7 +240,7 @@ internal class FeatureModules internal constructor(
}
internal class ChatEngineModule(
private val storeModule: Lazy<StoreModule>,
private val matrixStoreModule: Lazy<MatrixStoreModule>,
private val trackingModule: TrackingModule,
private val workModule: WorkModule,
private val logger: MatrixLogger,
@ -231,26 +251,22 @@ internal class ChatEngineModule(
) {
val engine by unsafeLazy {
val store = storeModule.value
val matrixCoroutineDispatchers = app.dapk.engine.core.CoroutineDispatchers(
coroutineDispatchers.io,
coroutineDispatchers.main,
coroutineDispatchers.global
)
val matrixStore = matrixStoreModule.value
MatrixEngine.Factory().create(
base64,
buildMeta,
logger,
SmallTalkDeviceNameGenerator(),
coroutineDispatchers,
trackingModule.errorTracker,
matrixCoroutineDispatchers,
trackingModule.errorTracker.engine(),
imageContentReader,
BackgroundWorkAdapter(workModule.workScheduler()),
store.memberStore(),
store.roomStore(),
store.profileStore(),
store.syncStore(),
store.overviewStore(),
store.filterStore(),
store.localEchoStore,
store.credentialsStore(),
store.knownDevicesStore(),
OlmPersistenceWrapper(store.olmStore(), base64),
matrixStore,
includeLogging = buildMeta.isDebug,
)
}
@ -289,43 +305,23 @@ internal class DomainModules(
val taskRunnerModule by unsafeLazy {
TaskRunnerModule(TaskRunnerAdapter(chatEngineModule.engine, AppTaskRunner(chatEngineModule.engine)))
}
}
internal class AndroidImageContentReader(private val contentResolver: ContentResolver) : ImageContentReader {
override fun meta(uri: String): ImageContentReader.ImageContent {
val androidUri = Uri.parse(uri)
val fileStream = contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")
private fun CoroutineDispatchers.engine() = app.dapk.engine.core.CoroutineDispatchers(this.io, this.main, this.global)
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(fileStream, null, options)
val fileSize = contentResolver.query(androidUri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
val columnIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.getLong(columnIndex)
} ?: throw IllegalArgumentException("Could not process $uri")
val shouldSwapSizes = ExifInterface(contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")).let {
val orientation = it.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270
}
return ImageContentReader.ImageContent(
height = if (shouldSwapSizes) options.outWidth else options.outHeight,
width = if (shouldSwapSizes) options.outHeight else options.outWidth,
size = fileSize,
mimeType = options.outMimeType,
fileName = androidUri.lastPathSegment ?: "file",
)
private fun ErrorTracker.engine(): app.dapk.engine.core.extensions.ErrorTracker {
val tracker = this
return object : app.dapk.engine.core.extensions.ErrorTracker {
override fun track(throwable: Throwable, extra: String) = tracker.track(throwable, extra)
}
override fun inputStream(uri: String): InputStream = contentResolver.openInputStream(Uri.parse(uri))!!
}
internal class SmallTalkDeviceNameGenerator : DeviceDisplayNameGenerator {
override fun generate(): String {
val randomIdentifier = (('A'..'Z') + ('a'..'z') + ('0'..'9')).shuffled().take(4).joinToString("")
return "SmallTalk Android ($randomIdentifier)"
private fun Preferences.engine(): app.dapk.engine.core.Preferences {
val prefs = this
return object : app.dapk.engine.core.Preferences {
override suspend fun store(key: String, value: String) = prefs.store(key, value)
override suspend fun readString(key: String) = prefs.readString(key)
override suspend fun clear() = prefs.clear()
override suspend fun remove(key: String) = prefs.remove(key)
}
}

View File

@ -1,8 +1,8 @@
package app.dapk.st.graph
package app.dapk.st.impl
import app.dapk.st.core.Base64
import app.dapk.engine.core.Base64
class AndroidBase64 : Base64 {
internal class AndroidBase64 : Base64 {
override fun encode(input: ByteArray): String {
return android.util.Base64.encodeToString(input, android.util.Base64.DEFAULT)
}

View File

@ -0,0 +1,40 @@
package app.dapk.st.impl
import android.content.ContentResolver
import android.graphics.BitmapFactory
import android.media.ExifInterface
import android.net.Uri
import android.provider.OpenableColumns
import app.dapk.st.engine.ImageContentReader
import java.io.InputStream
internal class AndroidImageContentReader(private val contentResolver: ContentResolver) : ImageContentReader {
override fun meta(uri: String): ImageContentReader.ImageContent {
val androidUri = Uri.parse(uri)
val fileStream = contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(fileStream, null, options)
val fileSize = contentResolver.query(androidUri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
val columnIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.getLong(columnIndex)
} ?: throw IllegalArgumentException("Could not process $uri")
val shouldSwapSizes = ExifInterface(contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")).let {
val orientation = it.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270
}
return ImageContentReader.ImageContent(
height = if (shouldSwapSizes) options.outWidth else options.outHeight,
width = if (shouldSwapSizes) options.outHeight else options.outWidth,
size = fileSize,
mimeType = options.outMimeType,
fileName = androidUri.lastPathSegment ?: "file",
)
}
override fun inputStream(uri: String): InputStream = contentResolver.openInputStream(Uri.parse(uri))!!
}

View File

@ -1,4 +1,4 @@
package app.dapk.st.graph
package app.dapk.st.impl
import app.dapk.st.engine.ChatEngine
import app.dapk.st.push.PushTokenPayload

View File

@ -1,6 +1,6 @@
package app.dapk.st.graph
package app.dapk.st.impl
import app.dapk.st.matrix.message.BackgroundScheduler
import app.dapk.st.engine.BackgroundScheduler
import app.dapk.st.work.WorkScheduler
class BackgroundWorkAdapter(private val workScheduler: WorkScheduler) : BackgroundScheduler {

View File

@ -1,4 +1,4 @@
package app.dapk.st.graph
package app.dapk.st.impl
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext

View File

@ -1,4 +1,4 @@
package app.dapk.st
package app.dapk.st.impl
import android.content.Context
import app.dapk.st.core.CoroutineDispatchers

View File

@ -0,0 +1,10 @@
package app.dapk.st.impl
import app.dapk.st.engine.DeviceDisplayNameGenerator
internal class SmallTalkDeviceNameGenerator : DeviceDisplayNameGenerator {
override fun generate(): String {
val randomIdentifier = (('A'..'Z') + ('a'..'z') + ('0'..'9')).shuffled().take(4).joinToString("")
return "SmallTalk Android ($randomIdentifier)"
}
}

View File

@ -1,4 +1,4 @@
package app.dapk.st.graph
package app.dapk.st.impl
import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.ChatEngineTask

View File

@ -1,16 +1,5 @@
buildscript {
apply from: "dependencies.gradle"
repositories {
Dependencies._repositories.call(it)
}
dependencies {
classpath Dependencies.google.androidGradlePlugin
classpath Dependencies.mavenCentral.kotlinGradlePlugin
classpath Dependencies.mavenCentral.sqldelightGradlePlugin
classpath Dependencies.mavenCentral.kotlinSerializationGradlePlugin
classpath Dependencies.google.firebaseCrashlyticsPlugin
}
plugins {
id "st-base-conventions" apply false
}
def launchTask = getGradle()
@ -18,7 +7,7 @@ def launchTask = getGradle()
.getTaskRequests()
.toString()
.toLowerCase()
def isReleaseBuild = launchTask.contains("release")
ext.isReleaseBuild = launchTask.contains("bundlerelease") || launchTask.contains("assemblerelease")
ext.isDebugBuild = !isReleaseBuild
subprojects {
@ -37,112 +26,17 @@ task clean(type: Delete) {
delete rootProject.buildDir
}
ext.applyMatrixServiceModule = { project ->
project.apply plugin: 'kotlin'
project.apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
def dependencies = project.dependencies
dependencies.api project.project(":matrix:matrix")
dependencies.api project.project(":matrix:common")
dependencies.implementation project.project(":matrix:matrix-http")
dependencies.implementation Dependencies.mavenCentral.kotlinSerializationJson
}
ext.applyLibraryPlugins = { project ->
project.apply plugin: 'com.android.library'
project.apply plugin: 'kotlin-android'
}
ext.androidSdkVersion = 33
ext.applyCommonAndroidParameters = { project ->
def android = project.android
android.compileSdk androidSdkVersion
android.compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
incremental = true
}
android.defaultConfig {
minSdkVersion 24
targetSdkVersion androidSdkVersion
}
}
ext.applyLibraryModuleOptimisations = { project ->
project.android {
variantFilter { variant ->
if (variant.name == "debug") {
variant.ignore = true
}
}
buildFeatures {
buildConfig = false
dataBinding = false
aidl = false
renderScript = false
resValues = false
shaders = false
viewBinding = false
}
}
}
ext.applyCompose = { project ->
def dependencies = project.dependencies
dependencies.implementation Dependencies.google.androidxComposeUi
dependencies.implementation Dependencies.google.androidxComposeFoundation
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)
}
ext.applyCrashlyticsIfRelease = { project ->
if (isReleaseBuild && !isFoss()) {
project.apply plugin: 'com.google.firebase.crashlytics'
project.afterEvaluate {
project.tasks.withType(com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask).configureEach {
it.googleServicesResourceRoot.set(project.file("src/release/res/"))
}
}
}
}
ext.kotlinTest = { dependencies ->
dependencies.testImplementation Dependencies.mavenCentral.kluent
dependencies.testImplementation Dependencies.mavenCentral.kotlinTest
dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.7.20"
dependencies.testImplementation Dependencies.mavenCentral.mockk
dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1'
dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.1'
dependencies.testImplementation libs.kluent
dependencies.testImplementation libs.kotlin.test
dependencies.testImplementation libs.mockk
dependencies.testImplementation libs.kotlin.coroutines.test
}
ext.kotlinFixtures = { dependencies ->
dependencies.testFixturesImplementation Dependencies.mavenCentral.mockk
dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent
dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
dependencies.testFixturesImplementation libs.mockk
dependencies.testFixturesImplementation libs.kluent
dependencies.testFixturesImplementation libs.kotlin.coroutines
}
ext.androidImportFixturesWorkaround = { project, fixtures ->
@ -163,7 +57,6 @@ ext.firebase = { dependencies, name ->
}
}
if (launchTask.contains("codeCoverageReport".toLowerCase())) {
apply from: 'tools/coverage.gradle'
}

1
chat-engine Submodule

@ -0,0 +1 @@
Subproject commit 04178168e2107c8a11d8259d7cb3be499f55f30c

View File

@ -1,13 +0,0 @@
plugins {
id 'kotlin'
id 'java-test-fixtures'
}
dependencies {
api Dependencies.mavenCentral.kotlinCoroutinesCore
api project(":matrix:common")
kotlinFixtures(it)
testFixturesImplementation(testFixtures(project(":matrix:common")))
testFixturesImplementation(testFixtures(project(":core")))
}

View File

@ -1,84 +0,0 @@
package app.dapk.st.engine
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.JsonString
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.RoomMember
import kotlinx.coroutines.flow.Flow
import java.io.InputStream
interface ChatEngine : TaskRunner {
fun directory(): Flow<DirectoryState>
fun invites(): Flow<InviteState>
fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow<MessengerPageState>
fun notificationsMessages(): Flow<UnreadNotifications>
fun notificationsInvites(): Flow<InviteNotification>
suspend fun login(request: LoginRequest): LoginResult
suspend fun me(forceRefresh: Boolean): Me
suspend fun InputStream.importRoomKeys(password: String): Flow<ImportResult>
suspend fun send(message: SendMessage, room: RoomOverview)
suspend fun registerPushToken(token: String, gatewayUrl: String)
suspend fun joinRoom(roomId: RoomId)
suspend fun rejectJoinRoom(roomId: RoomId)
suspend fun findMembersSummary(roomId: RoomId): List<RoomMember>
fun mediaDecrypter(): MediaDecrypter
fun pushHandler(): PushHandler
suspend fun muteRoom(roomId: RoomId)
suspend fun unmuteRoom(roomId: RoomId)
}
interface TaskRunner {
suspend fun runTask(task: ChatEngineTask): TaskResult
sealed interface TaskResult {
object Success : TaskResult
data class Failure(val canRetry: Boolean) : TaskResult
}
}
data class ChatEngineTask(val type: String, val jsonPayload: String)
interface MediaDecrypter {
fun decrypt(input: InputStream, k: String, iv: String): Collector
fun interface Collector {
fun collect(partial: (ByteArray) -> Unit)
}
}
interface PushHandler {
fun onNewToken(payload: JsonString)
fun onMessageReceived(eventId: EventId?, roomId: RoomId?)
}
typealias UnreadNotifications = Pair<Map<RoomOverview, List<RoomEvent>>, NotificationDiff>
data class NotificationDiff(
val unchanged: Map<RoomId, List<EventId>>,
val changedOrNew: Map<RoomId, List<EventId>>,
val removed: Map<RoomId, List<EventId>>,
val newRooms: Set<RoomId>
)
data class InviteNotification(
val content: String,
val roomId: RoomId
)

View File

@ -1,233 +0,0 @@
package app.dapk.st.engine
import app.dapk.st.matrix.common.*
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
typealias DirectoryState = List<DirectoryItem>
typealias OverviewState = List<RoomOverview>
typealias InviteState = List<RoomInvite>
data class DirectoryItem(
val overview: RoomOverview,
val unreadCount: UnreadCount,
val typing: Typing?,
val isMuted: Boolean,
)
data class RoomOverview(
val roomId: RoomId,
val roomCreationUtc: Long,
val roomName: String?,
val roomAvatarUrl: AvatarUrl?,
val lastMessage: LastMessage?,
val isGroup: Boolean,
val readMarker: EventId?,
val isEncrypted: Boolean,
) {
data class LastMessage(
val content: String,
val utcTimestamp: Long,
val author: RoomMember,
)
}
data class RoomInvite(
val from: RoomMember,
val roomId: RoomId,
val inviteMeta: InviteMeta,
) {
sealed class InviteMeta {
object DirectMessage : InviteMeta()
data class Room(val roomName: String? = null) : InviteMeta()
}
}
@JvmInline
value class UnreadCount(val value: Int)
data class Typing(val roomId: RoomId, val members: List<RoomMember>)
data class LoginRequest(val userName: String, val password: String, val serverUrl: String?)
sealed interface LoginResult {
data class Success(val userCredentials: UserCredentials) : LoginResult
object MissingWellKnown : LoginResult
data class Error(val cause: Throwable) : LoginResult
}
data class Me(
val userId: UserId,
val displayName: String?,
val avatarUrl: AvatarUrl?,
val homeServerUrl: HomeServerUrl,
)
sealed interface ImportResult {
data class Success(val roomIds: Set<RoomId>, val totalImportedKeysCount: Long) : ImportResult
data class Error(val cause: Type) : ImportResult {
sealed interface Type {
data class Unknown(val cause: Throwable) : Type
object NoKeysFound : Type
object UnexpectedDecryptionOutput : Type
object UnableToOpenFile : Type
object InvalidFile : Type
}
}
data class Update(val importedKeysCount: Long) : ImportResult
}
data class MessengerPageState(
val self: UserId,
val roomState: RoomState,
val typing: Typing?,
val isMuted: Boolean,
)
data class RoomState(
val roomOverview: RoomOverview,
val events: List<RoomEvent>,
)
internal val DEFAULT_ZONE = ZoneId.systemDefault()
internal val MESSAGE_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm")
sealed class RoomEvent {
abstract val eventId: EventId
abstract val utcTimestamp: Long
abstract val author: RoomMember
abstract val meta: MessageMeta
abstract val edited: Boolean
val time: String by lazy(mode = LazyThreadSafetyMode.NONE) {
val instant = Instant.ofEpochMilli(utcTimestamp)
ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT)
}
data class Encrypted(
override val eventId: EventId,
override val utcTimestamp: Long,
override val author: RoomMember,
override val meta: MessageMeta,
) : RoomEvent() {
override val edited: Boolean = false
}
data class Redacted(
override val eventId: EventId,
override val utcTimestamp: Long,
override val author: RoomMember,
) : RoomEvent() {
override val edited: Boolean = false
override val meta: MessageMeta = MessageMeta.FromServer
}
data class Message(
override val eventId: EventId,
override val utcTimestamp: Long,
val content: RichText,
override val author: RoomMember,
override val meta: MessageMeta,
override val edited: Boolean = false,
) : RoomEvent()
data class Reply(
val message: RoomEvent,
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
override val edited: Boolean = message.edited
val replyingToSelf = replyingTo.author == message.author
}
data class Image(
override val eventId: EventId,
override val utcTimestamp: Long,
val imageMeta: ImageMeta,
override val author: RoomMember,
override val meta: MessageMeta,
override val edited: Boolean = false,
) : RoomEvent() {
data class ImageMeta(
val width: Int?,
val height: Int?,
val url: String,
val keys: Keys?,
) {
data class Keys(
val k: String,
val iv: String,
val v: String,
val hashes: Map<String, String>,
)
}
}
}
sealed class MessageMeta {
object FromServer : MessageMeta()
data class LocalEcho(
val echoId: String,
val state: State
) : MessageMeta() {
sealed class State {
object Sending : State()
object Sent : State()
data class Error(
val message: String,
val type: Type,
) : State() {
enum class Type {
UNKNOWN
}
}
}
}
}
sealed interface SendMessage {
data class TextMessage(
val content: String,
val reply: Reply? = null,
) : SendMessage {
data class Reply(
val author: RoomMember,
val originalMessage: String,
val eventId: EventId,
val timestampUtc: Long,
)
}
data class ImageMessage(
val uri: String,
) : SendMessage
}

View File

@ -1,24 +0,0 @@
package fake
import app.dapk.st.engine.ChatEngine
import app.dapk.st.matrix.common.RoomId
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import test.delegateEmit
import test.delegateReturn
import java.io.InputStream
class FakeChatEngine : ChatEngine by mockk() {
fun givenMessages(roomId: RoomId, disableReadReceipts: Boolean) = every { messages(roomId, disableReadReceipts) }.delegateReturn()
fun givenDirectory() = every { directory() }.delegateReturn()
fun givenImportKeys(inputStream: InputStream, passphrase: String) = coEvery { inputStream.importRoomKeys(passphrase) }.delegateReturn()
fun givenNotificationsInvites() = every { notificationsInvites() }.delegateEmit()
fun givenNotificationsMessages() = every { notificationsMessages() }.delegateEmit()
}

View File

@ -1,77 +0,0 @@
package fixture
import app.dapk.st.engine.*
import app.dapk.st.matrix.common.*
fun aMessengerState(
self: UserId = aUserId(),
roomState: RoomState,
typing: Typing? = null,
isMuted: Boolean = false,
) = MessengerPageState(self, roomState, typing, isMuted)
fun aRoomOverview(
roomId: RoomId = aRoomId(),
roomCreationUtc: Long = 0L,
roomName: String? = null,
roomAvatarUrl: AvatarUrl? = null,
lastMessage: RoomOverview.LastMessage? = null,
isGroup: Boolean = false,
readMarker: EventId? = null,
isEncrypted: Boolean = false,
) = RoomOverview(roomId, roomCreationUtc, roomName, roomAvatarUrl, lastMessage, isGroup, readMarker, isEncrypted)
fun anEncryptedRoomMessageEvent(
eventId: EventId = anEventId(),
utcTimestamp: Long = 0L,
content: RichText = RichText.of("encrypted-content"),
author: RoomMember = aRoomMember(),
meta: MessageMeta = MessageMeta.FromServer,
edited: Boolean = false,
) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, edited)
fun aRoomImageMessageEvent(
eventId: EventId = anEventId(),
utcTimestamp: Long = 0L,
content: RoomEvent.Image.ImageMeta = anImageMeta(),
author: RoomMember = aRoomMember(),
meta: MessageMeta = MessageMeta.FromServer,
edited: Boolean = false,
) = RoomEvent.Image(eventId, utcTimestamp, content, author, meta, edited)
fun aRoomReplyMessageEvent(
message: RoomEvent = aRoomMessageEvent(),
replyingTo: RoomEvent = aRoomMessageEvent(eventId = anEventId("in-reply-to-id")),
) = RoomEvent.Reply(message, replyingTo)
fun aRoomMessageEvent(
eventId: EventId = anEventId(),
utcTimestamp: Long = 0L,
content: RichText = RichText.of("message-content"),
author: RoomMember = aRoomMember(),
meta: MessageMeta = MessageMeta.FromServer,
edited: Boolean = false,
) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, edited)
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)
fun aRoomState(
roomOverview: RoomOverview = aRoomOverview(),
events: List<RoomEvent> = listOf(aRoomMessageEvent()),
) = RoomState(roomOverview, events)
fun aRoomInvite(
from: RoomMember = aRoomMember(),
roomId: RoomId = aRoomId(),
inviteMeta: RoomInvite.InviteMeta = RoomInvite.InviteMeta.DirectMessage,
) = RoomInvite(from, roomId, inviteMeta)
fun aTypingEvent(
roomId: RoomId = aRoomId(),
members: List<RoomMember> = listOf(aRoomMember())
) = Typing(roomId, members)

View File

@ -1,16 +0,0 @@
package fixture
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.engine.NotificationDiff
object NotificationDiffFixtures {
fun aNotificationDiff(
unchanged: Map<RoomId, List<EventId>> = emptyMap(),
changedOrNew: Map<RoomId, List<EventId>> = emptyMap(),
removed: Map<RoomId, List<EventId>> = emptyMap(),
newRooms: Set<RoomId> = emptySet(),
) = NotificationDiff(unchanged, changedOrNew, removed, newRooms)
}

View File

@ -4,9 +4,9 @@ plugins {
}
dependencies {
api Dependencies.mavenCentral.kotlinCoroutinesCore
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
testFixturesImplementation Dependencies.mavenCentral.kluent
testFixturesImplementation Dependencies.mavenCentral.mockk
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest
api libs.kotlin.coroutines
testFixturesImplementation libs.kotlin.coroutines
testFixturesImplementation libs.kluent
testFixturesImplementation libs.mockk
testFixturesImplementation libs.kotlin.coroutines.test
}

View File

@ -1,6 +0,0 @@
package app.dapk.st.core
interface Base64 {
fun encode(input: ByteArray): String
fun decode(input: String): ByteArray
}

View File

@ -25,4 +25,8 @@ class JobBag {
jobs.remove(key.java.canonicalName)?.cancel()
}
fun cancelAll() {
jobs.values.forEach { it.cancel() }
}
}

View File

@ -1,7 +1,13 @@
applyAndroidComposeLibraryModule(project)
plugins {
id "st-android-compose-library-conventions"
}
android {
namespace "app.dapk.st.design"
}
dependencies {
implementation project(":core")
implementation Dependencies.mavenCentral.coil
implementation Dependencies.mavenCentral.accompanistSystemuicontroller
}
implementation libs.compose.coil
implementation libs.accompanist.systemuicontroller
}

View File

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="app.dapk.st.design"/>

View File

@ -1,64 +0,0 @@
package app.dapk.st.design.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@Composable
fun <T : Any> Spider(currentPage: SpiderPage<T>, onNavigate: (SpiderPage<out T>?) -> Unit, graph: SpiderScope.() -> Unit) {
val pageCache = remember { mutableMapOf<Route<*>, SpiderPage<out T>>() }
pageCache[currentPage.route] = currentPage
val navigateAndPopStack = {
pageCache.remove(currentPage.route)
onNavigate(pageCache[currentPage.parent])
}
val itemScope = object : SpiderItemScope {
override fun goBack() {
navigateAndPopStack()
}
}
val computedWeb = remember(true) {
mutableMapOf<Route<*>, @Composable (T) -> Unit>().also { computedWeb ->
val scope = object : SpiderScope {
override fun <T> item(route: Route<T>, content: @Composable SpiderItemScope.(T) -> Unit) {
computedWeb[route] = { content(itemScope, it as T) }
}
}
graph.invoke(scope)
}
}
Column {
if (currentPage.hasToolbar) {
Toolbar(
onNavigate = navigateAndPopStack,
title = currentPage.label
)
}
BackHandler(onBack = navigateAndPopStack)
computedWeb[currentPage.route]!!.invoke(currentPage.state)
}
}
interface SpiderScope {
fun <T> item(route: Route<T>, content: @Composable SpiderItemScope.(T) -> Unit)
}
interface SpiderItemScope {
fun goBack()
}
data class SpiderPage<T>(
val route: Route<T>,
val label: String,
val parent: Route<*>?,
val state: T,
val hasToolbar: Boolean = true,
)
@JvmInline
value class Route<out S>(val value: String)

View File

@ -1,9 +1,10 @@
applyAndroidComposeLibraryModule(project)
plugins {
id "st-android-compose-library-conventions"
}
dependencies {
implementation project(":core")
implementation project(":features:navigator")
implementation project(":design-library")
api project(":domains:android:core")
api project(":domains:state")
}

View File

@ -21,53 +21,3 @@ inline fun <reified VM : ViewModel> ComponentActivity.viewModel(
}
return ViewModelLazy(VM::class, { viewModelStore }, { factoryPromise })
}
inline fun <reified S, E> ComponentActivity.state(
noinline factory: () -> State<S, E>
): Lazy<State<S, E>> {
val factoryPromise = object : Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when(modelClass) {
StateViewModel::class.java -> factory() as T
else -> throw Error()
}
}
}
return KeyedViewModelLazy(
key = S::class.java.canonicalName!!,
StateViewModel::class,
{ viewModelStore },
{ factoryPromise }
) as Lazy<State<S, E>>
}
class KeyedViewModelLazy<VM : ViewModel> @JvmOverloads constructor(
private val key: String,
private val viewModelClass: KClass<VM>,
private val storeProducer: () -> ViewModelStore,
private val factoryProducer: () -> ViewModelProvider.Factory,
) : Lazy<VM> {
private var cached: VM? = null
override val value: VM
get() {
val viewModel = cached
return if (viewModel == null) {
val factory = factoryProducer()
val store = storeProducer()
ViewModelProvider(
store,
factory,
CreationExtras.Empty
).get(key, viewModelClass.java).also {
cached = it
}
} else {
viewModel
}
}
override fun isInitialized(): Boolean = cached != null
}

View File

@ -11,6 +11,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -27,6 +28,10 @@ class StartScope(private val scope: CoroutineScope) {
fun <T> SharedFlow<T>.launch(onEach: suspend (T) -> Unit) {
this.onEach(onEach).launchIn(scope)
}
fun <T> Flow<T>.launch(onEach: suspend (T) -> Unit) {
this.onEach(onEach).launchIn(scope)
}
}
interface EffectScope {

View File

@ -1,48 +0,0 @@
package app.dapk.st.core
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.dapk.state.Action
import app.dapk.state.ReducerFactory
import app.dapk.state.Store
import app.dapk.state.createStore
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
class StateViewModel<S, E>(
reducerFactory: ReducerFactory<S>,
eventSource: MutableSharedFlow<E>,
) : ViewModel(), State<S, E> {
private val store: Store<S> = createStore(reducerFactory, viewModelScope)
override val events: SharedFlow<E> = eventSource
override val current
get() = _state!!
private var _state: S by mutableStateOf(store.getState())
init {
_state = store.getState()
store.subscribe {
_state = it
}
}
override fun dispatch(action: Action) {
store.dispatch(action)
}
}
fun <S, E> createStateViewModel(block: (suspend (E) -> Unit) -> ReducerFactory<S>): StateViewModel<S, E> {
val eventSource = MutableSharedFlow<E>(extraBufferCapacity = 1)
val reducer = block { eventSource.emit(it) }
return StateViewModel(reducer, eventSource)
}
interface State<S, E> {
fun dispatch(action: Action)
val events: SharedFlow<E>
val current: S
}

View File

@ -1,95 +0,0 @@
package app.dapk.st.core.page
import app.dapk.st.design.components.SpiderPage
import app.dapk.state.*
import kotlin.reflect.KClass
sealed interface PageAction<out P> : Action {
data class GoTo<P : Any>(val page: SpiderPage<P>) : PageAction<P>
}
sealed interface PageStateChange : Action {
data class ChangePage<P : Any>(val previous: SpiderPage<out P>, val newPage: SpiderPage<out P>) : PageAction<P>
data class UpdatePage<P : Any>(val pageContent: P) : PageAction<P>
}
data class PageContainer<P>(
val page: SpiderPage<out P>
)
interface PageReducerScope<P> {
fun <PC : Any> withPageContent(page: KClass<PC>, block: PageDispatchScope<PC>.() -> Unit)
fun rawPage(): SpiderPage<out P>
}
interface PageDispatchScope<PC> {
fun ReducerScope<*>.pageDispatch(action: PageAction<PC>)
fun getPageState(): PC?
}
fun <P : Any, S : Any> createPageReducer(
initialPage: SpiderPage<out P>,
factory: PageReducerScope<P>.() -> ReducerFactory<S>,
): ReducerFactory<Combined2<PageContainer<P>, S>> = shareState {
combineReducers(createPageReducer(initialPage), factory(pageReducerScope()))
}
private fun <P : Any, S : Any> SharedStateScope<Combined2<PageContainer<P>, S>>.pageReducerScope() = object : PageReducerScope<P> {
override fun <PC : Any> withPageContent(page: KClass<PC>, block: PageDispatchScope<PC>.() -> Unit) {
val currentPage = getSharedState().state1.page.state
if (currentPage::class == page) {
val pageDispatchScope = object : PageDispatchScope<PC> {
override fun ReducerScope<*>.pageDispatch(action: PageAction<PC>) {
val currentPageGuard = getSharedState().state1.page.state
if (currentPageGuard::class == page) {
dispatch(action)
}
}
override fun getPageState() = getSharedState().state1.page.state as? PC
}
block(pageDispatchScope)
}
}
override fun rawPage() = getSharedState().state1.page
}
@Suppress("UNCHECKED_CAST")
private fun <P : Any> createPageReducer(
initialPage: SpiderPage<out P>
): ReducerFactory<PageContainer<P>> {
return createReducer(
initialState = PageContainer(
page = initialPage
),
async(PageAction.GoTo::class) { action ->
val state = getState()
if (state.page.state::class != action.page.state::class) {
dispatch(PageStateChange.ChangePage(previous = state.page, newPage = action.page))
} else {
dispatch(PageStateChange.UpdatePage(action.page.state))
}
},
change(PageStateChange.ChangePage::class) { action, state ->
state.copy(page = action.newPage as SpiderPage<out P>)
},
change(PageStateChange.UpdatePage::class) { action, state ->
val isSamePage = state.page.state::class == action.pageContent::class
if (isSamePage) {
val updatedPageContent = (state.page as SpiderPage<Any>).copy(state = action.pageContent)
state.copy(page = updatedPageContent as SpiderPage<out P>)
} else {
state
}
},
)
}
inline fun <reified PC : Any> PageReducerScope<*>.withPageContext(crossinline block: PageDispatchScope<PC>.(PC) -> Unit) {
withPageContent(PC::class) { getPageState()?.let { block(it) } }
}

View File

@ -1,4 +1,6 @@
plugins { id 'kotlin' }
plugins {
id "kotlin"
}
dependencies {
compileOnly project(":domains:android:stub")

View File

@ -1,6 +1,8 @@
applyAndroidLibraryModule(project)
plugins {
id "st-android-library-conventions"
}
dependencies {
implementation project(":core")
implementation Dependencies.mavenCentral.coil
implementation libs.compose.coil
}

View File

@ -1,18 +1,21 @@
applyAndroidLibraryModule(project)
apply plugin: "org.jetbrains.kotlin.plugin.serialization"
plugins {
id "st-android-library-conventions"
alias libs.plugins.kotlin.serialization
}
dependencies {
implementation "chat-engine:chat-engine"
implementation project(':core')
implementation project(':domains:android:core')
implementation project(':domains:store')
implementation project(':domains:android:core')
firebase(it, "messaging")
implementation Dependencies.mavenCentral.kotlinSerializationJson
implementation Dependencies.jitPack.unifiedPush
implementation libs.kotlin.serialization
implementation libs.unifiedpush
kotlinTest(it)
testImplementation 'chat-engine:chat-engine-test'
androidImportFixturesWorkaround(project, project(":core"))
androidImportFixturesWorkaround(project, project(":matrix:common"))
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
}

View File

@ -5,13 +5,13 @@ 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,
)
)
interface PushHandler {
fun onNewToken(payload: PushTokenPayload)
fun onMessageReceived(eventId: EventId?, roomId: RoomId?)
}

View File

@ -1,5 +1,5 @@
plugins {
id 'kotlin'
id "kotlin"
id 'java-test-fixtures'
}
@ -12,7 +12,7 @@ if (localProperties.exists()) {
}
dependencies {
def androidVer = androidSdkVersion
def androidVer = 33
api files("${properties.getProperty("sdk.dir")}/platforms/android-${androidVer}/android.jar")
kotlinFixtures(it)

View File

@ -1,4 +1,6 @@
applyAndroidLibraryModule(project)
plugins {
id "st-android-library-conventions"
}
dependencies {
implementation project(':core')

View File

@ -10,6 +10,19 @@ class CrashTrackerLogger : ErrorTracker {
override fun track(throwable: Throwable, extra: String) {
Log.e("ST", throwable.message, throwable)
log(AppLogTag.ERROR_NON_FATAL, "${throwable.message ?: "N/A"} extra=$extra")
throwable.findCauseMessage()?.let {
if (throwable.message != it) {
log(AppLogTag.ERROR_NON_FATAL, it)
}
}
}
}
private fun Throwable.findCauseMessage(): String? {
return when (val inner = this.cause) {
null -> this.message ?: ""
else -> inner.findCauseMessage()
}
}

View File

@ -1 +1,3 @@
plugins { id 'kotlin' }
plugins {
id "kotlin"
}

View File

@ -1,15 +1,15 @@
plugins {
id 'kotlin'
id "kotlin"
id 'java-test-fixtures'
}
dependencies {
compileOnly project(":domains:android:viewmodel-stub")
implementation Dependencies.mavenCentral.kotlinCoroutinesCore
implementation libs.kotlin.coroutines
kotlinFixtures(it)
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest
testFixturesImplementation libs.kotlin.coroutines
testFixturesImplementation libs.kotlin.coroutines.test
testFixturesImplementation testFixtures(project(":core"))
testFixturesCompileOnly project(":domains:android:viewmodel-stub")
}

View File

@ -1,4 +1,6 @@
applyAndroidLibraryModule(project)
plugins {
id "st-android-library-conventions"
}
dependencies {
implementation project(':core')

View File

@ -1,4 +1,6 @@
plugins { id 'kotlin' }
plugins {
id "kotlin"
}
dependencies {
implementation project(':core')

View File

@ -1,7 +1,9 @@
applyAndroidLibraryModule(project)
plugins {
id "st-android-library-conventions"
}
dependencies {
implementation project(':core')
implementation platform(Dependencies.google.firebaseBom)
implementation platform(libs.firebase.bom)
implementation 'com.google.firebase:firebase-crashlytics'
}

View File

@ -1,6 +1,8 @@
applyAndroidLibraryModule(project)
plugins {
id "st-android-library-conventions"
}
dependencies {
implementation project(':core')
implementation project(':matrix:common')
implementation "chat-engine:chat-engine"
}

View File

@ -1,9 +1,11 @@
applyAndroidLibraryModule(project)
plugins {
id "st-android-library-conventions"
}
dependencies {
implementation project(':core')
implementation project(':domains:android:core')
implementation project(':matrix:common')
implementation platform('com.google.firebase:firebase-bom:29.0.3')
implementation "chat-engine:chat-engine"
implementation platform(libs.firebase.bom)
implementation 'com.google.firebase:firebase-messaging'
}

View File

@ -1,7 +0,0 @@
plugins {
id 'kotlin'
}
dependencies {
compileOnly 'org.json:json:20220924'
}

View File

@ -1,85 +0,0 @@
package org.matrix.olm;
import java.io.Serializable;
import java.util.Map;
public class OlmAccount implements Serializable {
public static final String JSON_KEY_ONE_TIME_KEY = "curve25519";
public static final String JSON_KEY_IDENTITY_KEY = "curve25519";
public static final String JSON_KEY_FINGER_PRINT_KEY = "ed25519";
public OlmAccount() throws OlmException {
throw new RuntimeException("stub");
}
long getOlmAccountId() {
throw new RuntimeException("stub");
}
public void releaseAccount() {
throw new RuntimeException("stub");
}
public boolean isReleased() {
throw new RuntimeException("stub");
}
public Map<String, String> identityKeys() throws OlmException {
throw new RuntimeException("stub");
}
public long maxOneTimeKeys() {
throw new RuntimeException("stub");
}
public void generateOneTimeKeys(int aNumberOfKeys) throws OlmException {
throw new RuntimeException("stub");
}
public Map<String, Map<String, String>> oneTimeKeys() throws OlmException {
throw new RuntimeException("stub");
}
public void removeOneTimeKeys(OlmSession aSession) throws OlmException {
throw new RuntimeException("stub");
}
public void markOneTimeKeysAsPublished() throws OlmException {
throw new RuntimeException("stub");
}
public String signMessage(String aMessage) throws OlmException {
throw new RuntimeException("stub");
}
protected byte[] serialize(byte[] aKey, StringBuffer aErrorMsg) {
throw new RuntimeException("stub");
}
protected void deserialize(byte[] aSerializedData, byte[] aKey) throws Exception {
throw new RuntimeException("stub");
}
public byte[] pickle(byte[] aKey, StringBuffer aErrorMsg) {
throw new RuntimeException("stub");
}
public void unpickle(byte[] aSerializedData, byte[] aKey) throws Exception {
throw new RuntimeException("stub");
}
public void generateFallbackKey() throws OlmException {
throw new RuntimeException("stub");
}
public Map<String, Map<String, String>> fallbackKey() throws OlmException {
throw new RuntimeException("stub");
}
public void forgetFallbackKey() throws OlmException {
throw new RuntimeException("stub");
}
}

View File

@ -1,70 +0,0 @@
package org.matrix.olm;
import java.io.IOException;
public class OlmException extends IOException {
public static final int EXCEPTION_CODE_INIT_ACCOUNT_CREATION = 10;
public static final int EXCEPTION_CODE_ACCOUNT_SERIALIZATION = 100;
public static final int EXCEPTION_CODE_ACCOUNT_DESERIALIZATION = 101;
public static final int EXCEPTION_CODE_ACCOUNT_IDENTITY_KEYS = 102;
public static final int EXCEPTION_CODE_ACCOUNT_GENERATE_ONE_TIME_KEYS = 103;
public static final int EXCEPTION_CODE_ACCOUNT_ONE_TIME_KEYS = 104;
public static final int EXCEPTION_CODE_ACCOUNT_REMOVE_ONE_TIME_KEYS = 105;
public static final int EXCEPTION_CODE_ACCOUNT_MARK_ONE_KEYS_AS_PUBLISHED = 106;
public static final int EXCEPTION_CODE_ACCOUNT_SIGN_MESSAGE = 107;
public static final int EXCEPTION_CODE_ACCOUNT_GENERATE_FALLBACK_KEY = 108;
public static final int EXCEPTION_CODE_ACCOUNT_FALLBACK_KEY = 109;
public static final int EXCEPTION_CODE_ACCOUNT_FORGET_FALLBACK_KEY = 110;
public static final int EXCEPTION_CODE_CREATE_INBOUND_GROUP_SESSION = 200;
public static final int EXCEPTION_CODE_INIT_INBOUND_GROUP_SESSION = 201;
public static final int EXCEPTION_CODE_INBOUND_GROUP_SESSION_IDENTIFIER = 202;
public static final int EXCEPTION_CODE_INBOUND_GROUP_SESSION_DECRYPT_SESSION = 203;
public static final int EXCEPTION_CODE_INBOUND_GROUP_SESSION_FIRST_KNOWN_INDEX = 204;
public static final int EXCEPTION_CODE_INBOUND_GROUP_SESSION_IS_VERIFIED = 205;
public static final int EXCEPTION_CODE_INBOUND_GROUP_SESSION_EXPORT = 206;
public static final int EXCEPTION_CODE_CREATE_OUTBOUND_GROUP_SESSION = 300;
public static final int EXCEPTION_CODE_INIT_OUTBOUND_GROUP_SESSION = 301;
public static final int EXCEPTION_CODE_OUTBOUND_GROUP_SESSION_IDENTIFIER = 302;
public static final int EXCEPTION_CODE_OUTBOUND_GROUP_SESSION_KEY = 303;
public static final int EXCEPTION_CODE_OUTBOUND_GROUP_ENCRYPT_MESSAGE = 304;
public static final int EXCEPTION_CODE_INIT_SESSION_CREATION = 400;
public static final int EXCEPTION_CODE_SESSION_INIT_OUTBOUND_SESSION = 401;
public static final int EXCEPTION_CODE_SESSION_INIT_INBOUND_SESSION = 402;
public static final int EXCEPTION_CODE_SESSION_INIT_INBOUND_SESSION_FROM = 403;
public static final int EXCEPTION_CODE_SESSION_ENCRYPT_MESSAGE = 404;
public static final int EXCEPTION_CODE_SESSION_DECRYPT_MESSAGE = 405;
public static final int EXCEPTION_CODE_SESSION_SESSION_IDENTIFIER = 406;
public static final int EXCEPTION_CODE_UTILITY_CREATION = 500;
public static final int EXCEPTION_CODE_UTILITY_VERIFY_SIGNATURE = 501;
public static final int EXCEPTION_CODE_PK_ENCRYPTION_CREATION = 600;
public static final int EXCEPTION_CODE_PK_ENCRYPTION_SET_RECIPIENT_KEY = 601;
public static final int EXCEPTION_CODE_PK_ENCRYPTION_ENCRYPT = 602;
public static final int EXCEPTION_CODE_PK_DECRYPTION_CREATION = 700;
public static final int EXCEPTION_CODE_PK_DECRYPTION_GENERATE_KEY = 701;
public static final int EXCEPTION_CODE_PK_DECRYPTION_DECRYPT = 702;
public static final int EXCEPTION_CODE_PK_DECRYPTION_SET_PRIVATE_KEY = 703;
public static final int EXCEPTION_CODE_PK_DECRYPTION_PRIVATE_KEY = 704;
public static final int EXCEPTION_CODE_PK_SIGNING_CREATION = 800;
public static final int EXCEPTION_CODE_PK_SIGNING_GENERATE_SEED = 801;
public static final int EXCEPTION_CODE_PK_SIGNING_INIT_WITH_SEED = 802;
public static final int EXCEPTION_CODE_PK_SIGNING_SIGN = 803;
public static final int EXCEPTION_CODE_SAS_CREATION = 900;
public static final int EXCEPTION_CODE_SAS_ERROR = 901;
public static final int EXCEPTION_CODE_SAS_MISSING_THEIR_PKEY = 902;
public static final int EXCEPTION_CODE_SAS_GENERATE_SHORT_CODE = 903;
public static final String EXCEPTION_MSG_INVALID_PARAMS_DESERIALIZATION = "invalid de-serialized parameters";
private final int mCode;
private final String mMessage;
public OlmException(int aExceptionCode, String aExceptionMessage) {
throw new RuntimeException("stub");
}
public int getExceptionCode() {
throw new RuntimeException("stub");
}
public String getMessage() {
throw new RuntimeException("stub");
}
}

View File

@ -1,59 +0,0 @@
package org.matrix.olm;
import java.io.Serializable;
public class OlmInboundGroupSession implements Serializable {
public OlmInboundGroupSession(String aSessionKey) throws OlmException {
throw new RuntimeException("stub");
}
public static OlmInboundGroupSession importSession(String exported) throws OlmException {
throw new RuntimeException("stub");
}
public void releaseSession() {
throw new RuntimeException("stub");
}
public boolean isReleased() {
throw new RuntimeException("stub");
}
public String sessionIdentifier() throws OlmException {
throw new RuntimeException("stub");
}
public long getFirstKnownIndex() throws OlmException {
throw new RuntimeException("stub");
}
public boolean isVerified() throws OlmException {
throw new RuntimeException("stub");
}
public String export(long messageIndex) throws OlmException {
throw new RuntimeException("stub");
}
public OlmInboundGroupSession.DecryptMessageResult decryptMessage(String aEncryptedMsg) throws OlmException {
throw new RuntimeException("stub");
}
protected byte[] serialize(byte[] aKey, StringBuffer aErrorMsg) {
throw new RuntimeException("stub");
}
protected void deserialize(byte[] aSerializedData, byte[] aKey) throws Exception {
throw new RuntimeException("stub");
}
public static class DecryptMessageResult {
public String mDecryptedMessage;
public long mIndex;
public DecryptMessageResult() {
throw new RuntimeException("stub");
}
}
}

View File

@ -1,14 +0,0 @@
package org.matrix.olm;
public class OlmManager {
public OlmManager() {
throw new RuntimeException("stub");
}
public String getOlmLibVersion() {
throw new RuntimeException("stub");
}
public native String getOlmLibVersionJni();
}

View File

@ -1,12 +0,0 @@
package org.matrix.olm;
public class OlmMessage {
public static final int MESSAGE_TYPE_PRE_KEY = 0;
public static final int MESSAGE_TYPE_MESSAGE = 1;
public String mCipherText;
public long mType;
public OlmMessage() {
throw new RuntimeException("stub");
}
}

View File

@ -1,43 +0,0 @@
package org.matrix.olm;
import java.io.Serializable;
public class OlmOutboundGroupSession implements Serializable {
public OlmOutboundGroupSession() throws OlmException {
throw new RuntimeException("stub");
}
public void releaseSession() {
throw new RuntimeException("stub");
}
public boolean isReleased() {
throw new RuntimeException("stub");
}
public String sessionIdentifier() throws OlmException {
throw new RuntimeException("stub");
}
public int messageIndex() {
throw new RuntimeException("stub");
}
public String sessionKey() throws OlmException {
throw new RuntimeException("stub");
}
public String encryptMessage(String aClearMsg) throws OlmException {
throw new RuntimeException("stub");
}
protected byte[] serialize(byte[] aKey, StringBuffer aErrorMsg) {
throw new RuntimeException("stub");
}
protected void deserialize(byte[] aSerializedData, byte[] aKey) throws Exception {
throw new RuntimeException("stub");
}
}

View File

@ -1,32 +0,0 @@
package org.matrix.olm;
public class OlmSAS {
public OlmSAS() throws OlmException {
throw new RuntimeException("stub");
}
public String getPublicKey() throws OlmException {
throw new RuntimeException("stub");
}
public void setTheirPublicKey(String otherPkey) throws OlmException {
throw new RuntimeException("stub");
}
public byte[] generateShortCode(String info, int byteNumber) throws OlmException {
throw new RuntimeException("stub");
}
public String calculateMac(String message, String info) throws OlmException {
throw new RuntimeException("stub");
}
public String calculateMacLongKdf(String message, String info) throws OlmException {
throw new RuntimeException("stub");
}
public void releaseSas() {
throw new RuntimeException("stub");
}
}

View File

@ -1,63 +0,0 @@
package org.matrix.olm;
import java.io.Serializable;
public class OlmSession implements Serializable {
public OlmSession() throws OlmException {
throw new RuntimeException("stub");
}
long getOlmSessionId() {
throw new RuntimeException("stub");
}
public void releaseSession() {
throw new RuntimeException("stub");
}
public boolean isReleased() {
throw new RuntimeException("stub");
}
public void initOutboundSession(OlmAccount aAccount, String aTheirIdentityKey, String aTheirOneTimeKey) throws OlmException {
throw new RuntimeException("stub");
}
public void initInboundSession(OlmAccount aAccount, String aPreKeyMsg) throws OlmException {
throw new RuntimeException("stub");
}
public void initInboundSessionFrom(OlmAccount aAccount, String aTheirIdentityKey, String aPreKeyMsg) throws OlmException {
throw new RuntimeException("stub");
}
public String sessionIdentifier() throws OlmException {
throw new RuntimeException("stub");
}
public boolean matchesInboundSession(String aOneTimeKeyMsg) {
throw new RuntimeException("stub");
}
public boolean matchesInboundSessionFrom(String aTheirIdentityKey, String aOneTimeKeyMsg) {
throw new RuntimeException("stub");
}
public OlmMessage encryptMessage(String aClearMsg) throws OlmException {
throw new RuntimeException("stub");
}
public String decryptMessage(OlmMessage aEncryptedMsg) throws OlmException {
throw new RuntimeException("stub");
}
protected byte[] serialize(byte[] aKey, StringBuffer aErrorMsg) {
throw new RuntimeException("stub");
}
protected void deserialize(byte[] aSerializedData, byte[] aKey) throws Exception {
throw new RuntimeException("stub");
}
}

View File

@ -1,41 +0,0 @@
package org.matrix.olm;
import org.json.JSONObject;
import java.util.Map;
public class OlmUtility {
public static final int RANDOM_KEY_SIZE = 32;
public OlmUtility() throws OlmException {
throw new RuntimeException("stub");
}
public void releaseUtility() {
throw new RuntimeException("stub");
}
public void verifyEd25519Signature(String aSignature, String aFingerprintKey, String aMessage) throws OlmException {
throw new RuntimeException("stub");
}
public String sha256(String aMessageToHash) {
throw new RuntimeException("stub");
}
public static byte[] getRandomKey() {
throw new RuntimeException("stub");
}
public boolean isReleased() {
throw new RuntimeException("stub");
}
public static Map<String, String> toStringMap(JSONObject jsonObject) {
throw new RuntimeException("stub");
}
public static Map<String, Map<String, String>> toStringMapMap(JSONObject jsonObject) {
throw new RuntimeException("stub");
}
}

View File

@ -1,15 +0,0 @@
plugins {
id 'kotlin'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation Dependencies.mavenCentral.kotlinSerializationJson
implementation Dependencies.mavenCentral.kotlinCoroutinesCore
implementation project(":core")
implementation project(":domains:store")
implementation project(":matrix:services:crypto")
implementation project(":matrix:services:device")
compileOnly project(":domains:olm-stub")
}

View File

@ -1,53 +0,0 @@
package app.dapk.st.olm
import app.dapk.st.matrix.common.DeviceId
import app.dapk.st.matrix.common.Ed25519
import app.dapk.st.matrix.common.UserId
import app.dapk.st.matrix.crypto.Olm
import org.matrix.olm.OlmSAS
import org.matrix.olm.OlmUtility
internal class DefaultSasSession(private val selfFingerprint: Ed25519) : Olm.SasSession {
private val olmSAS = OlmSAS()
override fun publicKey(): String {
return olmSAS.publicKey
}
override suspend fun generateCommitment(hash: String, startJsonString: String): String {
val utility = OlmUtility()
return utility.sha256(olmSAS.publicKey + startJsonString).also {
utility.releaseUtility()
}
}
override suspend fun calculateMac(
selfUserId: UserId,
selfDeviceId: DeviceId,
otherUserId: UserId,
otherDeviceId: DeviceId,
transactionId: String
): Olm.MacResult {
val baseInfo = "MATRIX_KEY_VERIFICATION_MAC" +
selfUserId.value +
selfDeviceId.value +
otherUserId.value +
otherDeviceId.value +
transactionId
val deviceKeyId = "ed25519:${selfDeviceId.value}"
val macMap = mapOf(
deviceKeyId to olmSAS.calculateMac(selfFingerprint.value, baseInfo + deviceKeyId)
)
val keys = olmSAS.calculateMac(macMap.keys.sorted().joinToString(separator = ","), baseInfo + "KEY_IDS")
return Olm.MacResult(macMap, keys)
}
override fun setTheirPublicKey(key: String) {
olmSAS.setTheirPublicKey(key)
}
override fun release() {
olmSAS.releaseSas()
}
}

View File

@ -1,39 +0,0 @@
package app.dapk.st.olm
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.common.extensions.toJsonString
import app.dapk.st.matrix.crypto.Olm
import app.dapk.st.matrix.device.internal.DeviceKeys
import org.matrix.olm.OlmAccount
class DeviceKeyFactory(
private val jsonCanonicalizer: JsonCanonicalizer,
) {
fun create(userId: UserId, deviceId: DeviceId, identityKey: Ed25519, senderKey: Curve25519, olmAccount: OlmAccount): DeviceKeys {
val signable = mapOf(
"device_id" to deviceId.value,
"user_id" to userId.value,
"algorithms" to listOf(Olm.ALGORITHM_MEGOLM.value, Olm.ALGORITHM_OLM.value),
"keys" to mapOf(
"curve25519:${deviceId.value}" to senderKey.value,
"ed25519:${deviceId.value}" to identityKey.value,
)
).toJsonString()
return DeviceKeys(
userId,
deviceId,
algorithms = listOf(Olm.ALGORITHM_MEGOLM, Olm.ALGORITHM_OLM),
keys = mapOf(
"curve25519:${deviceId.value}" to senderKey.value,
"ed25519:${deviceId.value}" to identityKey.value,
),
signatures = mapOf(
userId.value to mapOf(
"ed25519:${deviceId.value}" to olmAccount.signMessage(jsonCanonicalizer.canonicalize(signable))
)
)
)
}
}

View File

@ -1,14 +0,0 @@
package app.dapk.st.olm
import app.dapk.st.matrix.common.Curve25519
import app.dapk.st.matrix.common.Ed25519
import org.matrix.olm.OlmAccount
fun OlmAccount.readIdentityKeys(): Pair<Ed25519, Curve25519> {
val identityKeys = this.identityKeys()
return Ed25519(identityKeys["ed25519"]!!) to Curve25519(identityKeys["curve25519"]!!)
}
fun OlmAccount.oneTimeCurveKeys(): List<Pair<String, Curve25519>> {
return this.oneTimeKeys()["curve25519"]?.map { it.key to Curve25519(it.value) } ?: emptyList()
}

View File

@ -1,74 +0,0 @@
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
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.SessionId
import org.matrix.olm.OlmAccount
import org.matrix.olm.OlmInboundGroupSession
import org.matrix.olm.OlmOutboundGroupSession
import org.matrix.olm.OlmSession
import java.io.*
class OlmPersistenceWrapper(
private val olmPersistence: OlmPersistence,
private val base64: Base64,
) : OlmStore {
override suspend fun read(): OlmAccount? {
return olmPersistence.read()?.deserialize()
}
override suspend fun persist(olmAccount: OlmAccount) {
olmPersistence.persist(SerializedObject(olmAccount.serialize()))
}
override suspend fun readOutbound(roomId: RoomId): Pair<Long, OlmOutboundGroupSession>? {
return olmPersistence.readOutbound(roomId)?.let {
it.first to it.second.deserialize()
}
}
override suspend fun persistOutbound(roomId: RoomId, creationTimestampUtc: Long, outboundGroupSession: OlmOutboundGroupSession) {
olmPersistence.persistOutbound(roomId, creationTimestampUtc, SerializedObject(outboundGroupSession.serialize()))
}
override suspend fun persistSession(identity: Curve25519, sessionId: SessionId, olmSession: OlmSession) {
olmPersistence.persistSession(identity, sessionId, SerializedObject(olmSession.serialize()))
}
override suspend fun readSessions(identities: List<Curve25519>): List<Pair<Curve25519, OlmSession>>? {
return olmPersistence.readSessions(identities)?.map { it.first to it.second.deserialize() }
}
override suspend fun persist(sessionId: SessionId, inboundGroupSession: OlmInboundGroupSession) {
olmPersistence.persist(sessionId, SerializedObject(inboundGroupSession.serialize()))
}
override suspend fun transaction(action: suspend () -> Unit) {
olmPersistence.startTransaction { action() }
}
override suspend fun readInbound(sessionId: SessionId): OlmInboundGroupSession? {
return olmPersistence.readInbound(sessionId)?.value?.deserialize()
}
private fun <T : Serializable> T.serialize(): String {
val baos = ByteArrayOutputStream()
ObjectOutputStream(baos).use {
it.writeObject(this)
}
return base64.encode(baos.toByteArray())
}
@Suppress("UNCHECKED_CAST")
private fun <T : Serializable> String.deserialize(): T {
val decoded = base64.decode(this)
val baos = ByteArrayInputStream(decoded)
return ObjectInputStream(baos).use {
it.readObject() as T
}
}
}

View File

@ -1,22 +0,0 @@
package app.dapk.st.olm
import app.dapk.st.matrix.common.Curve25519
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.SessionId
import org.matrix.olm.OlmAccount
import org.matrix.olm.OlmInboundGroupSession
import org.matrix.olm.OlmOutboundGroupSession
import org.matrix.olm.OlmSession
interface OlmStore {
suspend fun read(): OlmAccount?
suspend fun persist(olmAccount: OlmAccount)
suspend fun transaction(action: suspend () -> Unit)
suspend fun readOutbound(roomId: RoomId): Pair<Long, OlmOutboundGroupSession>?
suspend fun persistOutbound(roomId: RoomId, creationTimestampUtc: Long, outboundGroupSession: OlmOutboundGroupSession)
suspend fun persistSession(identity: Curve25519, sessionId: SessionId, olmSession: OlmSession)
suspend fun readSessions(identities: List<Curve25519>): List<Pair<Curve25519, OlmSession>>?
suspend fun persist(sessionId: SessionId, inboundGroupSession: OlmInboundGroupSession)
suspend fun readInbound(sessionId: SessionId): OlmInboundGroupSession?
}

View File

@ -1,389 +0,0 @@
package app.dapk.st.olm
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.SingletonFlows
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.extensions.ifNull
import app.dapk.st.core.withIoContext
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.common.MatrixLogTag.CRYPTO
import app.dapk.st.matrix.crypto.Olm
import app.dapk.st.matrix.crypto.Olm.*
import app.dapk.st.matrix.device.DeviceService
import app.dapk.st.matrix.device.internal.DeviceKeys
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.matrix.olm.*
import java.time.Clock
private const val SEVEN_DAYS_MILLIS = 604800000
private const val MEGOLM_ROTATION_MESSAGE_COUNT = 100
private const val INIT_OLM = "init-olm"
class OlmWrapper(
private val olmStore: OlmStore,
private val singletonFlows: SingletonFlows,
private val jsonCanonicalizer: JsonCanonicalizer,
private val deviceKeyFactory: DeviceKeyFactory,
private val errorTracker: ErrorTracker,
private val logger: MatrixLogger,
private val clock: Clock,
coroutineDispatchers: CoroutineDispatchers
) : Olm {
init {
coroutineDispatchers.global.launch {
coroutineDispatchers.withIoContext {
singletonFlows.getOrPut(INIT_OLM) {
OlmManager()
}.collect()
}
}
}
override suspend fun import(keys: List<SharedRoomKey>) {
interactWithOlm()
olmStore.transaction {
keys.forEach {
val inBound = when (it.isExported) {
true -> OlmInboundGroupSession.importSession(it.sessionKey)
false -> OlmInboundGroupSession(it.sessionKey)
}
olmStore.persist(it.sessionId, inBound)
}
}
}
override suspend fun ensureAccountCrypto(deviceCredentials: DeviceCredentials, onCreate: suspend (AccountCryptoSession) -> Unit): AccountCryptoSession {
interactWithOlm()
return singletonFlows.getOrPut("account-crypto") {
accountCrypto(deviceCredentials) ?: createAccountCrypto(deviceCredentials, onCreate)
}.first()
}
private suspend fun accountCrypto(deviceCredentials: DeviceCredentials): AccountCryptoSession? {
return olmStore.read()?.let { olmAccount ->
createAccountCryptoSession(deviceCredentials, olmAccount, isNew = false)
}
}
override suspend fun AccountCryptoSession.generateOneTimeKeys(
count: Int,
credentials: DeviceCredentials,
publishKeys: suspend (DeviceService.OneTimeKeys) -> Unit
) {
interactWithOlm()
val olmAccount = this.olmAccount as OlmAccount
olmAccount.generateOneTimeKeys(count)
val oneTimeKeys = DeviceService.OneTimeKeys(olmAccount.oneTimeCurveKeys().map { (key, value) ->
DeviceService.OneTimeKeys.Key.SignedCurve(
keyId = key,
value = value.value,
signature = DeviceService.OneTimeKeys.Key.SignedCurve.Ed25519Signature(
value = value.value.toSignedJson(olmAccount),
deviceId = credentials.deviceId,
userId = credentials.userId,
)
)
})
publishKeys(oneTimeKeys)
olmAccount.markOneTimeKeysAsPublished()
updateAccountInstance(olmAccount)
}
private suspend fun createAccountCrypto(deviceCredentials: DeviceCredentials, action: suspend (AccountCryptoSession) -> Unit): AccountCryptoSession {
val olmAccount = OlmAccount()
return createAccountCryptoSession(deviceCredentials, olmAccount, isNew = true).also {
action(it)
olmStore.persist(olmAccount)
}
}
private fun createAccountCryptoSession(credentials: DeviceCredentials, olmAccount: OlmAccount, isNew: Boolean): AccountCryptoSession {
val (identityKey, senderKey) = olmAccount.readIdentityKeys()
return AccountCryptoSession(
fingerprint = identityKey,
senderKey = senderKey,
deviceKeys = deviceKeyFactory.create(credentials.userId, credentials.deviceId, identityKey, senderKey, olmAccount),
olmAccount = olmAccount,
maxKeys = olmAccount.maxOneTimeKeys().toInt(),
hasKeys = !isNew,
)
}
override suspend fun ensureRoomCrypto(
roomId: RoomId,
accountSession: AccountCryptoSession,
): RoomCryptoSession {
interactWithOlm()
return singletonFlows.getOrPut("room-${roomId.value}") {
roomCrypto(roomId, accountSession) ?: createRoomCrypto(roomId, accountSession)
}
.first()
.maybeRotateRoomSession(roomId, accountSession)
}
private suspend fun RoomCryptoSession.maybeRotateRoomSession(roomId: RoomId, accountSession: AccountCryptoSession): RoomCryptoSession {
val now = clock.millis()
return when {
this.messageIndex > MEGOLM_ROTATION_MESSAGE_COUNT || (now - this.creationTimestampUtc) > SEVEN_DAYS_MILLIS -> {
logger.matrixLog(CRYPTO, "rotating megolm for room ${roomId.value}")
createRoomCrypto(roomId, accountSession).also { rotatedSession ->
singletonFlows.update("room-${roomId.value}", rotatedSession)
}
}
else -> this
}
}
private suspend fun roomCrypto(roomId: RoomId, accountCryptoSession: AccountCryptoSession): RoomCryptoSession? {
return olmStore.readOutbound(roomId)?.let { (timestampUtc, outBound) ->
RoomCryptoSession(
creationTimestampUtc = timestampUtc,
key = outBound.sessionKey(),
messageIndex = outBound.messageIndex(),
accountCryptoSession = accountCryptoSession,
id = SessionId(outBound.sessionIdentifier()),
outBound = outBound
)
}
}
private suspend fun createRoomCrypto(roomId: RoomId, accountCryptoSession: AccountCryptoSession): RoomCryptoSession {
val outBound = OlmOutboundGroupSession()
val roomCryptoSession = RoomCryptoSession(
creationTimestampUtc = clock.millis(),
key = outBound.sessionKey(),
messageIndex = outBound.messageIndex(),
accountCryptoSession = accountCryptoSession,
id = SessionId(outBound.sessionIdentifier()),
outBound = outBound
)
olmStore.persistOutbound(roomId, roomCryptoSession.creationTimestampUtc, outBound)
val inBound = OlmInboundGroupSession(roomCryptoSession.key)
olmStore.persist(roomCryptoSession.id, inBound)
logger.crypto("Creating megolm: ${roomCryptoSession.id}")
return roomCryptoSession
}
override suspend fun ensureDeviceCrypto(input: OlmSessionInput, olmAccount: AccountCryptoSession): DeviceCryptoSession {
interactWithOlm()
return deviceCrypto(input) ?: createDeviceCrypto(olmAccount, input)
}
private suspend fun deviceCrypto(input: OlmSessionInput): DeviceCryptoSession? {
return olmStore.readSessions(listOf(input.identity))?.let {
DeviceCryptoSession(
input.deviceId, input.userId, input.identity, input.fingerprint, it.map { it.second }
)
}
}
private suspend fun createDeviceCrypto(accountCryptoSession: AccountCryptoSession, input: OlmSessionInput): DeviceCryptoSession {
val olmSession = OlmSession()
olmSession.initOutboundSession(accountCryptoSession.olmAccount as OlmAccount, input.identity.value, input.oneTimeKey)
val sessionId = SessionId(olmSession.sessionIdentifier())
logger.crypto("creating olm session: $sessionId ${input.identity} ${input.userId} ${input.deviceId}")
olmStore.persistSession(input.identity, sessionId, olmSession)
return DeviceCryptoSession(input.deviceId, input.userId, input.identity, input.fingerprint, listOf(olmSession))
}
@Suppress("UNCHECKED_CAST")
override suspend fun DeviceCryptoSession.encrypt(messageJson: JsonString): EncryptionResult {
interactWithOlm()
val olmSession = this.olmSession as List<OlmSession>
logger.crypto("encrypting with session(s) ${olmSession.size}")
val (result, session) = olmSession.firstNotNullOf {
kotlin.runCatching {
it.encryptMessage(jsonCanonicalizer.canonicalize(messageJson)) to it
}.getOrNull()
}
logger.crypto("encrypt flow identity: ${this.identity}")
olmStore.persistSession(this.identity, SessionId(session.sessionIdentifier()), session)
return EncryptionResult(
cipherText = CipherText(result.mCipherText),
type = result.mType,
)
}
override suspend fun RoomCryptoSession.encrypt(roomId: RoomId, messageJson: JsonString): CipherText {
interactWithOlm()
val messagePayloadString = jsonCanonicalizer.canonicalize(messageJson)
val outBound = this.outBound as OlmOutboundGroupSession
val encryptedMessage = CipherText(outBound.encryptMessage(messagePayloadString))
singletonFlows.update(
"room-${roomId.value}",
this.copy(outBound = outBound, messageIndex = outBound.messageIndex())
)
olmStore.persistOutbound(roomId, this.creationTimestampUtc, outBound)
return encryptedMessage
}
private fun String.toSignedJson(olmAccount: OlmAccount): SignedJson {
val json = JsonString(Json.encodeToString(mapOf("key" to this)))
return SignedJson(olmAccount.signMessage(jsonCanonicalizer.canonicalize(json)))
}
override suspend fun decryptOlm(olmAccount: AccountCryptoSession, senderKey: Curve25519, type: Int, body: CipherText): DecryptionResult {
interactWithOlm()
val olmMessage = OlmMessage().apply {
this.mType = type.toLong()
this.mCipherText = body.value
}
val readSession = olmStore.readSessions(listOf(senderKey)).let {
if (it == null) {
logger.crypto("no olm session found for $senderKey, creating a new one")
listOf(senderKey to OlmSession())
} else {
logger.crypto("found olm session(s) ${it.size}")
it.forEach {
logger.crypto("${it.first} ${it.second.sessionIdentifier()}")
}
it
}
}
val errors = mutableListOf<Throwable>()
return readSession.firstNotNullOfOrNull { (_, session) ->
kotlin.runCatching {
when (type) {
OlmMessage.MESSAGE_TYPE_PRE_KEY -> {
if (session.matchesInboundSession(body.value)) {
logger.matrixLog(CRYPTO, "matched inbound session, attempting decrypt")
session.decryptMessage(olmMessage)?.let { JsonString(it) }
} else {
logger.matrixLog(CRYPTO, "prekey has no inbound session, doing alternative flow")
val account = olmAccount.olmAccount as OlmAccount
val session = OlmSession()
session.initInboundSessionFrom(account, senderKey.value, body.value)
account.removeOneTimeKeys(session)
olmAccount.updateAccountInstance(account)
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()
}
}
}
OlmMessage.MESSAGE_TYPE_MESSAGE -> {
logger.crypto("decrypting olm message type")
session.decryptMessage(olmMessage)?.let { JsonString(it) }
}
else -> throw IllegalArgumentException("Unknown message type: $type")
}
}.onFailure {
errors.add(it)
logger.crypto("error code: ${(it as? OlmException)?.exceptionCode}")
errorTracker.track(it, "failed to decrypt olm")
}.getOrNull()?.let { DecryptionResult.Success(it, isVerified = false) }
}.ifNull {
logger.matrixLog(CRYPTO, "failed to decrypt olm session")
DecryptionResult.Failed(errors.joinToString { it.message ?: "N/A" })
}.also {
readSession.forEach { it.second.releaseSession() }
}
}
private suspend fun AccountCryptoSession.updateAccountInstance(olmAccount: OlmAccount) {
singletonFlows.update("account-crypto", this.copy(olmAccount = olmAccount, hasKeys = true))
olmStore.persist(olmAccount)
}
override suspend fun decryptMegOlm(sessionId: SessionId, cipherText: CipherText): DecryptionResult {
interactWithOlm()
return when (val megolmSession = olmStore.readInbound(sessionId)) {
null -> DecryptionResult.Failed("no megolm session found for id: $sessionId")
else -> {
runCatching {
JsonString(megolmSession.decryptMessage(cipherText.value).mDecryptedMessage).also {
olmStore.persist(sessionId, megolmSession)
}
}.fold(
onSuccess = { DecryptionResult.Success(it, isVerified = false) },
onFailure = {
errorTracker.track(it)
DecryptionResult.Failed(it.message ?: "Unknown")
}
).also {
megolmSession.releaseSession()
}
}
}
}
override suspend fun verifyExternalUser(keys: Ed25519?, recipeientKeys: Ed25519?): Boolean {
return false
}
private suspend fun interactWithOlm() = singletonFlows.get<Unit>(INIT_OLM).first()
override suspend fun olmSessions(devices: List<DeviceKeys>, onMissing: suspend (List<DeviceKeys>) -> List<DeviceCryptoSession>): List<DeviceCryptoSession> {
interactWithOlm()
val inputByIdentity = devices.groupBy { it.keys().first }
val inputByKeys = devices.associateBy { it.keys() }
val inputs = inputByKeys.map { (keys, deviceKeys) ->
val (identity, fingerprint) = keys
Olm.OlmSessionInput(oneTimeKey = "ignored", identity = identity, deviceKeys.deviceId, deviceKeys.userId, fingerprint)
}
val requestedIdentities = inputs.map { it.identity }
val foundSessions = olmStore.readSessions(requestedIdentities) ?: emptyList()
val foundSessionsByIdentity = foundSessions.groupBy { it.first }
val foundSessionIdentities = foundSessions.map { it.first }
val missingIdentities = requestedIdentities - foundSessionIdentities.toSet()
val newOlmSessions = if (missingIdentities.isNotEmpty()) {
onMissing(missingIdentities.map { inputByIdentity[it]!! }.flatten())
} else emptyList()
return (inputs.filterNot { missingIdentities.contains(it.identity) }.map {
val olmSession = foundSessionsByIdentity[it.identity]!!.map { it.second }
logger.crypto("found ${olmSession.size} olm session(s) for ${it.identity}")
olmSession.forEach {
logger.crypto(it.sessionIdentifier())
}
DeviceCryptoSession(
deviceId = it.deviceId,
userId = it.userId,
identity = it.identity,
fingerprint = it.fingerprint,
olmSession = olmSession
)
}) + newOlmSessions
}
override suspend fun sasSession(deviceCredentials: DeviceCredentials): SasSession {
val account = ensureAccountCrypto(deviceCredentials, onCreate = {})
return DefaultSasSession(account.fingerprint)
}
}
private fun DeviceKeys.keys(): Pair<Curve25519, Ed25519> {
val identity = Curve25519(this.keys.filter { it.key.startsWith("curve25519:") }.values.first())
val fingerprint = Ed25519(this.keys.filter { it.key.startsWith("ed25519:") }.values.first())
return identity to fingerprint
}

View File

@ -1,14 +0,0 @@
plugins {
id 'kotlin'
id 'java-test-fixtures'
}
dependencies {
implementation Dependencies.mavenCentral.kotlinCoroutinesCore
testFixturesImplementation testFixtures(project(":core"))
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
testFixturesImplementation Dependencies.mavenCentral.kluent
testFixturesImplementation Dependencies.mavenCentral.mockk
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest
}

View File

@ -1,194 +0,0 @@
package app.dapk.state
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.reflect.KClass
fun <S> createStore(reducerFactory: ReducerFactory<S>, coroutineScope: CoroutineScope): Store<S> {
val subscribers = mutableListOf<(S) -> Unit>()
var state: S = reducerFactory.initialState()
return object : Store<S> {
private val scope = createScope(coroutineScope, this)
private val reducer = reducerFactory.create(scope)
override fun dispatch(action: Action) {
coroutineScope.launch {
state = reducer.reduce(action).also { nextState ->
if (nextState != state) {
subscribers.forEach { it.invoke(nextState) }
}
}
}
}
override fun getState() = state
override fun subscribe(subscriber: (S) -> Unit) {
subscribers.add(subscriber)
}
}
}
interface ReducerFactory<S> {
fun create(scope: ReducerScope<S>): Reducer<S>
fun initialState(): S
}
fun interface Reducer<S> {
fun reduce(action: Action): S
}
private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = object : ReducerScope<S> {
override val coroutineScope = coroutineScope
override fun dispatch(action: Action) = store.dispatch(action)
override fun getState(): S = store.getState()
}
interface Store<S> {
fun dispatch(action: Action)
fun getState(): S
fun subscribe(subscriber: (S) -> Unit)
}
interface ReducerScope<S> {
val coroutineScope: CoroutineScope
fun dispatch(action: Action)
fun getState(): S
}
sealed interface ActionHandler<S> {
val key: KClass<Action>
class Async<S>(override val key: KClass<Action>, val handler: suspend ReducerScope<S>.(Action) -> Unit) : ActionHandler<S>
class Sync<S>(override val key: KClass<Action>, val handler: (Action, S) -> S) : ActionHandler<S>
class Delegate<S>(override val key: KClass<Action>, val handler: ReducerScope<S>.(Action) -> ActionHandler<S>) : ActionHandler<S>
}
data class Combined2<S1, S2>(val state1: S1, val state2: S2)
fun interface SharedStateScope<C> {
fun getSharedState(): C
}
fun <S> shareState(block: SharedStateScope<S>.() -> ReducerFactory<S>): ReducerFactory<S> {
var internalScope: ReducerScope<S>? = null
val scope = SharedStateScope { internalScope!!.getState() }
val combinedFactory = block(scope)
return object : ReducerFactory<S> {
override fun create(scope: ReducerScope<S>) = combinedFactory.create(scope).also { internalScope = scope }
override fun initialState() = combinedFactory.initialState()
}
}
fun <S1, S2> combineReducers(r1: ReducerFactory<S1>, r2: ReducerFactory<S2>): ReducerFactory<Combined2<S1, S2>> {
return object : ReducerFactory<Combined2<S1, S2>> {
override fun create(scope: ReducerScope<Combined2<S1, S2>>): Reducer<Combined2<S1, S2>> {
val r1Scope = createReducerScope(scope) { scope.getState().state1 }
val r2Scope = createReducerScope(scope) { scope.getState().state2 }
val r1Reducer = r1.create(r1Scope)
val r2Reducer = r2.create(r2Scope)
return Reducer {
Combined2(r1Reducer.reduce(it), r2Reducer.reduce(it))
}
}
override fun initialState(): Combined2<S1, S2> = Combined2(r1.initialState(), r2.initialState())
}
}
private fun <S> createReducerScope(scope: ReducerScope<*>, state: () -> S) = object : ReducerScope<S> {
override val coroutineScope: CoroutineScope = scope.coroutineScope
override fun dispatch(action: Action) = scope.dispatch(action)
override fun getState() = state.invoke()
}
fun <S> createReducer(
initialState: S,
vararg reducers: (ReducerScope<S>) -> ActionHandler<S>,
): ReducerFactory<S> {
return object : ReducerFactory<S> {
override fun create(scope: ReducerScope<S>): Reducer<S> {
val reducersMap = reducers
.map { it.invoke(scope) }
.groupBy { it.key }
return Reducer { action ->
val result = reducersMap.keys
.filter { it.java.isAssignableFrom(action::class.java) }
.fold(scope.getState()) { acc, key ->
val actionHandlers = reducersMap[key]!!
actionHandlers.fold(acc) { acc, handler ->
when (handler) {
is ActionHandler.Async -> {
scope.coroutineScope.launch {
handler.handler.invoke(scope, action)
}
acc
}
is ActionHandler.Sync -> handler.handler.invoke(action, acc)
is ActionHandler.Delegate -> when (val next = handler.handler.invoke(scope, action)) {
is ActionHandler.Async -> {
scope.coroutineScope.launch {
next.handler.invoke(scope, action)
}
acc
}
is ActionHandler.Sync -> next.handler.invoke(action, acc)
is ActionHandler.Delegate -> error("is not possible")
}
}
}
}
result
}
}
override fun initialState(): S = initialState
}
}
fun <A : Action, S> sideEffect(klass: KClass<A>, block: suspend (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> {
return {
ActionHandler.Async(key = klass as KClass<Action>) { action -> block(action as A, getState()) }
}
}
fun <A : Action, S> change(klass: KClass<A>, block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S> {
return {
ActionHandler.Sync(key = klass as KClass<Action>, block as (Action, S) -> S)
}
}
fun <A : Action, S> async(klass: KClass<A>, block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S> {
return {
ActionHandler.Async(key = klass as KClass<Action>, block as suspend ReducerScope<S>.(Action) -> Unit)
}
}
fun <A : Action, S> multi(klass: KClass<A>, block: Multi<A, S>.(A) -> (ReducerScope<S>) -> ActionHandler<S>): (ReducerScope<S>) -> ActionHandler<S> {
val multiScope = object : Multi<A, S> {
override fun sideEffect(block: suspend (S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = sideEffect(klass) { _, state -> block(state) }
override fun change(block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S> = change(klass, block)
override fun async(block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = async(klass, block)
override fun nothing() = sideEffect { }
}
return {
ActionHandler.Delegate(key = klass as KClass<Action>) { action ->
block(multiScope, action as A).invoke(this)
}
}
}
interface Multi<A : Action, S> {
fun sideEffect(block: suspend (S) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
fun nothing(): (ReducerScope<S>) -> ActionHandler<S>
fun change(block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S>
fun async(block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
}
interface Action

View File

@ -1,20 +0,0 @@
package fake
import org.amshove.kluent.internal.assertEquals
class FakeEventSource<E> : (E) -> Unit {
private val captures = mutableListOf<E>()
override fun invoke(event: E) {
captures.add(event)
}
fun assertEvents(expected: List<E>) {
assertEquals(expected, captures)
}
fun assertNoEvents() {
assertEquals(emptyList(), captures)
}
}

View File

@ -1,153 +0,0 @@
package test
import app.dapk.state.Action
import app.dapk.state.Reducer
import app.dapk.state.ReducerFactory
import app.dapk.state.ReducerScope
import fake.FakeEventSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.internal.assertEquals
import org.amshove.kluent.shouldBeEqualTo
interface ReducerTest<S, E> {
operator fun invoke(block: suspend ReducerTestScope<S, E>.() -> Unit)
}
fun <S, E> testReducer(block: ((E) -> Unit) -> ReducerFactory<S>): ReducerTest<S, E> {
val fakeEventSource = FakeEventSource<E>()
val reducerFactory = block(fakeEventSource)
return object : ReducerTest<S, E> {
override fun invoke(block: suspend ReducerTestScope<S, E>.() -> Unit) {
runReducerTest(reducerFactory, fakeEventSource, block)
}
}
}
fun <S, E> runReducerTest(reducerFactory: ReducerFactory<S>, fakeEventSource: FakeEventSource<E>, block: suspend ReducerTestScope<S, E>.() -> Unit) {
runTest {
val expectTestScope = ExpectTest(coroutineContext)
block(ReducerTestScope(reducerFactory, fakeEventSource, expectTestScope))
expectTestScope.verifyExpects()
}
}
class ReducerTestScope<S, E>(
private val reducerFactory: ReducerFactory<S>,
private val fakeEventSource: FakeEventSource<E>,
private val expectTestScope: ExpectTestScope
) : ExpectTestScope by expectTestScope, Reducer<S> {
private var invalidateCapturedState: Boolean = false
private val actionSideEffects = mutableMapOf<Action, () -> S>()
private var manualState: S? = null
private var capturedResult: S? = null
private val actionCaptures = mutableListOf<Action>()
private val reducerScope = object : ReducerScope<S> {
override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher())
override fun dispatch(action: Action) {
actionCaptures.add(action)
if (actionSideEffects.containsKey(action)) {
setState(actionSideEffects.getValue(action).invoke(), invalidateCapturedState = true)
}
}
override fun getState() = manualState ?: reducerFactory.initialState()
}
private val reducer: Reducer<S> = reducerFactory.create(reducerScope)
override fun reduce(action: Action) = reducer.reduce(action).also {
capturedResult = if (invalidateCapturedState) manualState else it
}
fun actionSideEffect(action: Action, handler: () -> S) {
actionSideEffects[action] = handler
}
fun setState(state: S, invalidateCapturedState: Boolean = false) {
manualState = state
this.invalidateCapturedState = invalidateCapturedState
}
fun setState(block: (S) -> S) {
setState(block(reducerScope.getState()))
}
fun assertInitialState(expected: S) {
reducerFactory.initialState() shouldBeEqualTo expected
}
fun assertEvents(events: List<E>) {
fakeEventSource.assertEvents(events)
}
fun assertOnlyStateChange(expected: S) {
assertStateChange(expected)
assertNoDispatches()
fakeEventSource.assertNoEvents()
}
fun assertOnlyStateChange(block: (S) -> S) {
val expected = block(reducerScope.getState())
assertStateChange(expected)
assertNoDispatches()
fakeEventSource.assertNoEvents()
}
fun assertStateChange(expected: S) {
capturedResult shouldBeEqualTo expected
}
fun assertDispatches(expected: List<Action>) {
assertEquals(expected, actionCaptures)
}
fun assertNoDispatches() {
assertEquals(emptyList(), actionCaptures)
}
fun assertNoStateChange() {
assertEquals(reducerScope.getState(), capturedResult)
}
fun assertNoEvents() {
fakeEventSource.assertNoEvents()
}
fun assertOnlyDispatches(expected: List<Action>) {
assertDispatches(expected)
fakeEventSource.assertNoEvents()
assertNoStateChange()
}
fun assertOnlyEvents(events: List<E>) {
fakeEventSource.assertEvents(events)
assertNoDispatches()
assertNoStateChange()
}
fun assertNoChanges() {
assertNoStateChange()
assertNoEvents()
assertNoDispatches()
}
}
fun <S, E> ReducerTestScope<S, E>.assertOnlyDispatches(vararg action: Action) {
this.assertOnlyDispatches(action.toList())
}
fun <S, E> ReducerTestScope<S, E>.assertDispatches(vararg action: Action) {
this.assertDispatches(action.toList())
}
fun <S, E> ReducerTestScope<S, E>.assertEvents(vararg event: E) {
this.assertEvents(event.toList())
}
fun <S, E> ReducerTestScope<S, E>.assertOnlyEvents(vararg event: E) {
this.assertOnlyEvents(event.toList())
}

View File

@ -1,28 +1,22 @@
plugins {
id 'kotlin'
id 'com.squareup.sqldelight'
id 'org.jetbrains.kotlin.plugin.serialization'
alias libs.plugins.kotlin.serialization
alias libs.plugins.sqldelight
id 'java-test-fixtures'
}
sqldelight {
DapkDb {
packageName = "app.dapk.db"
StDb {
packageName = "app.dapk.db.app"
}
linkSqlite = true
}
dependencies {
api project(":matrix:common")
implementation project(":matrix:services:sync")
implementation project(":matrix:services:message")
implementation project(":matrix:services:profile")
implementation project(":matrix:services:device")
implementation project(":matrix:services:room")
implementation project(":core")
implementation Dependencies.mavenCentral.kotlinSerializationJson
implementation Dependencies.mavenCentral.kotlinCoroutinesCore
implementation "com.squareup.sqldelight:coroutines-extensions:1.5.4"
implementation "chat-engine:chat-engine"
implementation libs.kotlin.serialization
implementation libs.sqldelight.extensions
kotlinFixtures(it)
testImplementation(testFixtures(project(":core")))

View File

@ -16,6 +16,5 @@ class ApplicationPreferences(
}
@JvmInline
value class ApplicationVersion(val value: Int)
data class ApplicationVersion(val value: Int)

View File

@ -1,25 +0,0 @@
package app.dapk.st.domain
import app.dapk.st.core.Preferences
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.UserCredentials
internal class CredentialsPreferences(
private val preferences: Preferences,
) : CredentialsStore {
override suspend fun credentials(): UserCredentials? {
return preferences.readString("credentials")?.let { json ->
with(UserCredentials) { json.fromJson() }
}
}
override suspend fun update(credentials: UserCredentials) {
val json = with(UserCredentials) { credentials.toJson() }
preferences.store("credentials", json)
}
override suspend fun clear() {
preferences.clear()
}
}

View File

@ -1,99 +0,0 @@
package app.dapk.st.domain
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
import app.dapk.db.DapkDb
import app.dapk.st.matrix.common.DeviceId
import app.dapk.st.matrix.common.SessionId
import app.dapk.st.matrix.common.UserId
import app.dapk.st.matrix.device.KnownDeviceStore
import app.dapk.st.matrix.device.internal.DeviceKeys
import kotlinx.serialization.json.Json
class DevicePersistence(
private val database: DapkDb,
private val devicesCache: KnownDevicesCache,
private val dispatchers: CoroutineDispatchers,
) : KnownDeviceStore {
override suspend fun associateSession(sessionId: SessionId, deviceIds: List<DeviceId>) {
dispatchers.withIoContext {
database.deviceQueries.transaction {
deviceIds.forEach {
database.deviceQueries.insertDeviceToMegolmSession(
device_id = it.value,
session_id = sessionId.value
)
}
}
}
}
override suspend fun markOutdated(userIds: List<UserId>) {
devicesCache.updateOutdated(userIds)
database.deviceQueries.markOutdated(userIds.map { it.value })
}
override suspend fun maybeConsumeOutdated(userIds: List<UserId>): List<UserId> {
return devicesCache.consumeOutdated(userIds).also {
database.deviceQueries.markIndate(userIds.map { it.value })
}
}
override suspend fun updateDevices(devices: Map<UserId, Map<DeviceId, DeviceKeys>>): List<DeviceKeys> {
devicesCache.putAll(devices)
database.deviceQueries.transaction {
devices.forEach { (userId, innerMap) ->
innerMap.forEach { (deviceId, keys) ->
database.deviceQueries.insertDevice(
user_id = userId.value,
device_id = deviceId.value,
blob = Json.encodeToString(DeviceKeys.serializer(), keys),
)
}
}
}
return devicesCache.devices()
}
override suspend fun devicesMegolmSession(userIds: List<UserId>, sessionId: SessionId): List<DeviceKeys> {
return database.deviceQueries.selectUserDevicesWithSessions(userIds.map { it.value }, sessionId.value).executeAsList().map {
Json.decodeFromString(DeviceKeys.serializer(), it.blob)
}
}
override suspend fun device(userId: UserId, deviceId: DeviceId): DeviceKeys? {
return devicesCache.device(userId, deviceId) ?: database.deviceQueries.selectDevice(deviceId.value).executeAsOneOrNull()?.let {
Json.decodeFromString(DeviceKeys.serializer(), it)
}?.also { devicesCache.putAll(mapOf(userId to mapOf(deviceId to it))) }
}
}
class KnownDevicesCache(
private val devicesCache: Map<UserId, MutableMap<DeviceId, DeviceKeys>> = mutableMapOf(),
private var outdatedUserIds: MutableSet<UserId> = mutableSetOf()
) {
fun consumeOutdated(userIds: List<UserId>): List<UserId> {
val outdatedToConsume = outdatedUserIds.filter { userIds.contains(it) }
// val unknownIds = userIds.filter { devicesCache[it] == null }
outdatedUserIds = (outdatedUserIds - outdatedToConsume.toSet()).toMutableSet()
return outdatedToConsume
}
fun updateOutdated(userIds: List<UserId>) {
outdatedUserIds.addAll(userIds)
}
fun putAll(devices: Map<UserId, Map<DeviceId, DeviceKeys>>) {
devices.mapValues { it.value.toMutableMap() }
}
fun devices(): List<DeviceKeys> {
return devicesCache.values.map { it.values }.flatten()
}
fun device(userId: UserId, deviceId: DeviceId): DeviceKeys? {
return devicesCache[userId]?.get(deviceId)
}
}

View File

@ -1,17 +0,0 @@
package app.dapk.st.domain
import app.dapk.st.core.Preferences
import app.dapk.st.matrix.sync.FilterStore
internal class FilterPreferences(
private val preferences: Preferences
) : FilterStore {
override suspend fun store(key: String, filterId: String) {
preferences.store(key, filterId)
}
override suspend fun read(key: String): String? {
return preferences.readString(key)
}
}

View File

@ -1,46 +0,0 @@
package app.dapk.st.domain
import app.dapk.db.DapkDb
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
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
import kotlinx.serialization.json.Json
class MemberPersistence(
private val database: DapkDb,
private val coroutineDispatchers: CoroutineDispatchers,
) : MemberStore {
override suspend fun insert(roomId: RoomId, members: List<RoomMember>) {
coroutineDispatchers.withIoContext {
database.roomMemberQueries.transaction {
members.forEach {
database.roomMemberQueries.insert(
user_id = it.id.value,
room_id = roomId.value,
blob = Json.encodeToString(RoomMember.serializer(), it),
)
}
}
}
}
override suspend fun query(roomId: RoomId, userIds: List<UserId>): List<RoomMember> {
return coroutineDispatchers.withIoContext {
database.roomMemberQueries.selectMembersByRoomAndId(roomId.value, userIds.map { it.value })
.executeAsList()
.map { Json.decodeFromString(RoomMember.serializer(), it) }
}
}
override suspend fun query(roomId: RoomId, limit: Int): List<RoomMember> {
return coroutineDispatchers.withIoContext {
database.roomMemberQueries.selectMembersByRoom(roomId.value, limit.toLong())
.executeAsList()
.map { Json.decodeFromString(RoomMember.serializer(), it) }
}
}
}

View File

@ -1,119 +0,0 @@
package app.dapk.st.domain
import app.dapk.db.DapkDb
import app.dapk.db.model.DbCryptoAccount
import app.dapk.db.model.DbCryptoMegolmInbound
import app.dapk.db.model.DbCryptoMegolmOutbound
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.Curve25519
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.SessionId
import com.squareup.sqldelight.TransactionWithoutReturn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class OlmPersistence(
private val database: DapkDb,
private val credentialsStore: CredentialsStore,
private val dispatchers: CoroutineDispatchers,
) {
suspend fun read(): String? {
return dispatchers.withIoContext {
database.cryptoQueries
.selectAccount(credentialsStore.credentials()!!.userId.value)
.executeAsOneOrNull()
}
}
suspend fun persist(olmAccount: SerializedObject) {
dispatchers.withIoContext {
database.cryptoQueries.insertAccount(
DbCryptoAccount(
user_id = credentialsStore.credentials()!!.userId.value,
blob = olmAccount.value
)
)
}
}
suspend fun readOutbound(roomId: RoomId): Pair<Long, String>? {
return dispatchers.withIoContext {
database.cryptoQueries
.selectMegolmOutbound(roomId.value)
.executeAsOneOrNull()?.let {
it.utcEpochMillis to it.blob
}
}
}
suspend fun persistOutbound(roomId: RoomId, creationTimestampUtc: Long, outboundGroupSession: SerializedObject) {
dispatchers.withIoContext {
database.cryptoQueries.insertMegolmOutbound(
DbCryptoMegolmOutbound(
room_id = roomId.value,
blob = outboundGroupSession.value,
utcEpochMillis = creationTimestampUtc,
)
)
}
}
suspend fun persistSession(identity: Curve25519, sessionId: SessionId, olmSession: SerializedObject) {
withContext(dispatchers.io) {
database.cryptoQueries.insertOlmSession(
identity_key = identity.value,
session_id = sessionId.value,
blob = olmSession.value,
)
}
}
suspend fun readSessions(identities: List<Curve25519>): List<Pair<Curve25519, String>>? {
return withContext(dispatchers.io) {
database.cryptoQueries
.selectOlmSession(identities.map { it.value })
.executeAsList()
.map { Curve25519(it.identity_key) to it.blob }
.takeIf { it.isNotEmpty() }
}
}
suspend fun startTransaction(action: suspend TransactionWithoutReturn.() -> Unit) {
val transaction = suspendCoroutine { continuation ->
database.cryptoQueries.transaction {
continuation.resume(this)
}
}
action(transaction)
}
suspend fun persist(sessionId: SessionId, inboundGroupSession: SerializedObject) {
withContext(dispatchers.io) {
database.cryptoQueries.insertMegolmInbound(
DbCryptoMegolmInbound(
session_id = sessionId.value,
blob = inboundGroupSession.value
)
)
}
}
suspend fun readInbound(sessionId: SessionId): SerializedObject? {
return withContext(dispatchers.io) {
database.cryptoQueries
.selectMegolmInbound(sessionId.value)
.executeAsOneOrNull()
?.let { SerializedObject((it)) }
}
}
}
@JvmInline
value class SerializedObject(val value: String)

View File

@ -1,56 +1,23 @@
package app.dapk.st.domain
import app.dapk.db.DapkDb
import app.dapk.db.app.StDb
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.Preferences
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.domain.application.eventlog.EventLogPersistence
import app.dapk.st.domain.application.eventlog.LoggingStore
import app.dapk.st.domain.application.message.MessageOptionsStore
import app.dapk.st.domain.localecho.LocalEchoPersistence
import app.dapk.st.domain.preference.CachingPreferences
import app.dapk.st.domain.preference.PropertyCache
import app.dapk.st.domain.profile.ProfilePersistence
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
import app.dapk.st.domain.room.MutedStorePersistence
import app.dapk.st.domain.sync.OverviewPersistence
import app.dapk.st.domain.sync.RoomPersistence
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.message.LocalEchoStore
import app.dapk.st.matrix.room.MemberStore
import app.dapk.st.matrix.room.ProfileStore
import app.dapk.st.matrix.sync.FilterStore
import app.dapk.st.matrix.sync.OverviewStore
import app.dapk.st.matrix.sync.RoomStore
import app.dapk.st.matrix.sync.SyncStore
class StoreModule(
private val database: DapkDb,
private val database: StDb,
private val databaseDropper: DatabaseDropper,
val preferences: Preferences,
private val credentialPreferences: Preferences,
private val errorTracker: ErrorTracker,
val credentialPreferences: Preferences,
private val coroutineDispatchers: CoroutineDispatchers,
) {
private val muteableStore by unsafeLazy { MutedStorePersistence(database, coroutineDispatchers) }
fun overviewStore(): OverviewStore = OverviewPersistence(database, coroutineDispatchers)
fun roomStore(): RoomStore {
return RoomPersistence(
database = database,
overviewPersistence = OverviewPersistence(database, coroutineDispatchers),
coroutineDispatchers = coroutineDispatchers,
muteableStore = muteableStore,
)
}
fun credentialsStore(): CredentialsStore = CredentialsPreferences(credentialPreferences)
fun syncStore(): SyncStore = SyncTokenPreferences(preferences)
fun filterStore(): FilterStore = FilterPreferences(preferences)
val localEchoStore: LocalEchoStore by unsafeLazy { LocalEchoPersistence(errorTracker, database) }
private val cache = PropertyCache()
val cachingPreferences = CachingPreferences(cache, preferences)
@ -58,11 +25,6 @@ class StoreModule(
fun applicationStore() = ApplicationPreferences(preferences)
fun olmStore() = OlmPersistence(database, credentialsStore(), coroutineDispatchers)
fun knownDevicesStore() = DevicePersistence(database, KnownDevicesCache(), coroutineDispatchers)
fun profileStore(): ProfileStore = ProfilePersistence(preferences)
fun cacheCleaner() = StoreCleaner { cleanCredentials ->
if (cleanCredentials) {
credentialPreferences.clear()
@ -79,8 +41,4 @@ class StoreModule(
fun messageStore(): MessageOptionsStore = MessageOptionsStore(cachingPreferences)
fun memberStore(): MemberStore {
return MemberPersistence(database, coroutineDispatchers)
}
}

View File

@ -1,25 +0,0 @@
package app.dapk.st.domain
import app.dapk.st.core.Preferences
import app.dapk.st.matrix.common.SyncToken
import app.dapk.st.matrix.sync.SyncStore
import app.dapk.st.matrix.sync.SyncStore.SyncKey
internal class SyncTokenPreferences(
private val preferences: Preferences
) : SyncStore {
override suspend fun store(key: SyncKey, syncToken: SyncToken) {
preferences.store(key.value, syncToken.value)
}
override suspend fun read(key: SyncKey): SyncToken? {
return preferences.readString(key.value)?.let {
SyncToken(it)
}
}
override suspend fun remove(key: SyncKey) {
preferences.remove(key.value)
}
}

View File

@ -1,6 +1,6 @@
package app.dapk.st.domain.application.eventlog
import app.dapk.db.DapkDb
import app.dapk.db.app.StDb
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
import com.squareup.sqldelight.runtime.coroutines.asFlow
@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class EventLogPersistence(
private val database: DapkDb,
private val database: StDb,
private val coroutineDispatchers: CoroutineDispatchers,
) {

View File

@ -1,120 +0,0 @@
package app.dapk.st.domain.localecho
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.extensions.Scope
import app.dapk.db.DapkDb
import app.dapk.db.model.DbLocalEcho
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.message.LocalEchoStore
import app.dapk.st.matrix.message.MessageService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
private typealias LocalEchoCache = Map<RoomId, Map<String, MessageService.LocalEcho>>
class LocalEchoPersistence(
private val errorTracker: ErrorTracker,
private val database: DapkDb,
) : LocalEchoStore {
private val inMemoryEchos = MutableStateFlow<LocalEchoCache>(emptyMap())
private val mirrorScope = Scope(newSingleThreadContext("local-echo-thread"))
override suspend fun preload() {
withContext(Dispatchers.IO) {
val echos = database.localEchoQueries.selectAll().executeAsList().map {
Json.decodeFromString(MessageService.LocalEcho.serializer(), it.blob)
}
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
}
}
}
}
}
override fun markSending(message: MessageService.Message) {
emitUpdate(MessageService.LocalEcho(eventId = null, message, state = MessageService.LocalEcho.State.Sending))
}
override suspend fun messageTransaction(message: MessageService.Message, action: suspend () -> EventId) {
emitUpdate(MessageService.LocalEcho(eventId = null, message, state = MessageService.LocalEcho.State.Sending))
try {
val eventId = action.invoke()
emitUpdate(MessageService.LocalEcho(eventId = eventId, message, state = MessageService.LocalEcho.State.Sent))
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) {
emitUpdate(
MessageService.LocalEcho(
eventId = null,
message,
state = MessageService.LocalEcho.State.Error(error.message ?: "", MessageService.LocalEcho.State.Error.Type.UNKNOWN)
)
)
errorTracker.track(error)
throw error
}
}
private fun emitUpdate(localEcho: MessageService.LocalEcho) {
val newValue = inMemoryEchos.value.addEcho(localEcho)
inMemoryEchos.tryEmit(newValue)
mirrorScope.launch {
when (val message = localEcho.message) {
is MessageService.Message.TextMessage -> database.localEchoQueries.insert(
DbLocalEcho(
message.localId,
message.roomId.value,
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)
)
)
}
}
}
override fun observeLocalEchos(roomId: RoomId) = inMemoryEchos.map {
it[roomId]?.values?.toList() ?: emptyList()
}
override fun observeLocalEchos() = inMemoryEchos.map {
it.mapValues { it.value.values.toList() }
}
}
private fun LocalEchoCache.addEcho(localEcho: MessageService.LocalEcho): MutableMap<RoomId, Map<String, MessageService.LocalEcho>> {
val newValue = this.toMutableMap()
val roomEchos = newValue.getOrPut(localEcho.roomId) { emptyMap() }
newValue[localEcho.roomId] = roomEchos.toMutableMap().also { it.update(localEcho) }
return newValue
}
private fun MutableMap<String, MessageService.LocalEcho>.update(localEcho: MessageService.LocalEcho) {
this[localEcho.localId] = localEcho
}

View File

@ -1,51 +0,0 @@
package app.dapk.st.domain.profile
import app.dapk.st.core.Preferences
import app.dapk.st.matrix.common.AvatarUrl
import app.dapk.st.matrix.common.HomeServerUrl
import app.dapk.st.matrix.common.UserId
import app.dapk.st.matrix.room.ProfileService
import app.dapk.st.matrix.room.ProfileStore
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
internal class ProfilePersistence(
private val preferences: Preferences,
) : ProfileStore {
override suspend fun storeMe(me: ProfileService.Me) {
preferences.store(
"me", Json.encodeToString(
StoreMe.serializer(), StoreMe(
userId = me.userId,
displayName = me.displayName,
avatarUrl = me.avatarUrl,
homeServer = me.homeServerUrl,
)
)
)
}
override suspend fun readMe(): ProfileService.Me? {
return preferences.readString("me")?.let {
Json.decodeFromString(StoreMe.serializer(), it).let {
ProfileService.Me(
userId = it.userId,
displayName = it.displayName,
avatarUrl = it.avatarUrl,
homeServerUrl = it.homeServer
)
}
}
}
}
@Serializable
private class StoreMe(
@SerialName("user_id") val userId: UserId,
@SerialName("display_name") val displayName: String?,
@SerialName("avatar_url") val avatarUrl: AvatarUrl?,
@SerialName("homeserver") val homeServer: HomeServerUrl,
)

View File

@ -1,41 +0,0 @@
package app.dapk.st.domain.room
import app.dapk.db.DapkDb
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.MuteableStore
import com.squareup.sqldelight.runtime.coroutines.asFlow
import com.squareup.sqldelight.runtime.coroutines.mapToList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
internal class MutedStorePersistence(
private val database: DapkDb,
private val coroutineDispatchers: CoroutineDispatchers,
) : MuteableStore {
private val allMutedFlow = MutableSharedFlow<Set<RoomId>>(replay = 1)
override suspend fun mute(roomId: RoomId) {
coroutineDispatchers.withIoContext {
database.mutedRoomQueries.insertMuted(roomId.value)
}
}
override suspend fun unmute(roomId: RoomId) {
coroutineDispatchers.withIoContext {
database.mutedRoomQueries.removeMuted(roomId.value)
}
}
override suspend fun isMuted(roomId: RoomId) = allMutedFlow.firstOrNull()?.contains(roomId) ?: false
override fun observeMuted(): Flow<Set<RoomId>> = database.mutedRoomQueries.select()
.asFlow()
.mapToList()
.map { it.map { RoomId(it) }.toSet() }
}

View File

@ -1,99 +0,0 @@
package app.dapk.st.domain.sync
import app.dapk.db.DapkDb
import app.dapk.db.model.OverviewStateQueries
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.OverviewState
import app.dapk.st.matrix.sync.OverviewStore
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.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
private val json = Json
internal class OverviewPersistence(
private val database: DapkDb,
private val dispatchers: CoroutineDispatchers,
) : OverviewStore {
override fun latest(): Flow<OverviewState> {
return database.overviewStateQueries.selectAll()
.asFlow()
.mapToList()
.map { it.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } }
}
override suspend fun removeRooms(roomsToRemove: List<RoomId>) {
dispatchers.withIoContext {
database.transaction {
roomsToRemove.forEach {
database.inviteStateQueries.remove(it.value)
database.overviewStateQueries.remove(it.value)
}
}
}
}
override suspend fun persistInvites(invites: List<RoomInvite>) {
dispatchers.withIoContext {
database.inviteStateQueries.transaction {
invites.forEach {
database.inviteStateQueries.insert(it.roomId.value, json.encodeToString(RoomInvite.serializer(), it))
}
}
}
}
override fun latestInvites(): Flow<List<RoomInvite>> {
return database.inviteStateQueries.selectAll()
.asFlow()
.mapToList()
.map { it.map { json.decodeFromString(RoomInvite.serializer(), it.blob) } }
}
override suspend fun removeInvites(invites: List<RoomId>) {
dispatchers.withIoContext {
database.inviteStateQueries.transaction {
invites.forEach { database.inviteStateQueries.remove(it.value) }
}
}
}
override suspend fun persist(overviewState: OverviewState) {
dispatchers.withIoContext {
database.transaction {
overviewState.forEach {
database.overviewStateQueries.insertStateOverview(it)
}
}
}
}
override suspend fun retrieve(): OverviewState {
return dispatchers.withIoContext {
val overviews = database.overviewStateQueries.selectAll().executeAsList()
overviews.map { json.decodeFromString(RoomOverview.serializer(), it.blob) }
}
}
internal fun retrieve(roomId: RoomId): RoomOverview? {
return database.overviewStateQueries.selectRoom(roomId.value).executeAsOneOrNull()?.let {
json.decodeFromString(RoomOverview.serializer(), it)
}
}
}
private fun OverviewStateQueries.insertStateOverview(roomOverview: RoomOverview) {
this.insert(
room_id = roomOverview.roomId.value,
latest_activity_timestamp_utc = roomOverview.lastMessage?.utcTimestamp ?: roomOverview.roomCreationUtc,
blob = json.encodeToString(RoomOverview.serializer(), roomOverview),
read_marker = roomOverview.readMarker?.value
)
}

View File

@ -1,162 +0,0 @@
package app.dapk.st.domain.sync
import app.dapk.db.DapkDb
import app.dapk.db.model.RoomEventQueries
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
import app.dapk.st.domain.room.MutedStorePersistence
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.*
import com.squareup.sqldelight.Query
import com.squareup.sqldelight.runtime.coroutines.asFlow
import com.squareup.sqldelight.runtime.coroutines.mapToList
import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
private val json = Json
internal class RoomPersistence(
private val database: DapkDb,
private val overviewPersistence: OverviewPersistence,
private val coroutineDispatchers: CoroutineDispatchers,
private val muteableStore: MutedStorePersistence,
) : RoomStore, MuteableStore by muteableStore {
override suspend fun persist(roomId: RoomId, events: List<RoomEvent>) {
coroutineDispatchers.withIoContext {
database.transaction {
events.forEach {
database.roomEventQueries.insertRoomEvent(roomId, it)
}
}
}
}
override suspend fun remove(rooms: List<RoomId>) {
coroutineDispatchers.withIoContext {
database.roomEventQueries.transaction {
rooms.forEach { database.roomEventQueries.remove(it.value) }
}
}
}
override suspend fun remove(eventId: EventId) {
coroutineDispatchers.withIoContext {
database.roomEventQueries.removeEvent(eventId.value)
}
}
override fun latest(roomId: RoomId): Flow<RoomState> {
val overviewFlow = database.overviewStateQueries.selectRoom(roomId.value).asFlow().mapToOneNotNull().map {
json.decodeFromString(RoomOverview.serializer(), it)
}.distinctUntilChanged()
return database.roomEventQueries.selectRoom(roomId.value)
.distinctFlowList()
.map { it.map { json.decodeFromString(RoomEvent.serializer(), it) } }
.combine(overviewFlow) { events, overview ->
RoomState(overview, events)
}
}
override suspend fun retrieve(roomId: RoomId): RoomState? {
return coroutineDispatchers.withIoContext {
overviewPersistence.retrieve(roomId)?.let { overview ->
val roomEvents = database.roomEventQueries.selectRoom(roomId.value).executeAsList().map {
json.decodeFromString(RoomEvent.serializer(), it)
}
RoomState(overview, roomEvents)
}
}
}
override suspend fun insertUnread(roomId: RoomId, eventIds: List<EventId>) {
coroutineDispatchers.withIoContext {
database.transaction {
eventIds.forEach { eventId ->
database.unreadEventQueries.insertUnread(
event_id = eventId.value,
room_id = roomId.value,
)
}
}
}
}
override fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
return database.roomEventQueries.selectAllUnread()
.distinctFlowList()
.map {
it.groupBy { RoomId(it.room_id) }
.mapKeys { overviewPersistence.retrieve(it.key)!! }
.mapValues {
it.value.map {
json.decodeFromString(RoomEvent.serializer(), it.blob)
}
}
}
}
override fun observeUnreadCountById(): Flow<Map<RoomId, Int>> {
return database.roomEventQueries.selectAllUnread()
.asFlow()
.mapToList()
.map {
it.groupBy { RoomId(it.room_id) }
.mapValues { it.value.size }
}
}
override fun observeNotMutedUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
return database.roomEventQueries.selectNotMutedUnread()
.distinctFlowList()
.map {
it.groupBy { RoomId(it.room_id) }
.mapKeys { overviewPersistence.retrieve(it.key)!! }
.mapValues {
it.value.map {
json.decodeFromString(RoomEvent.serializer(), it.blob)
}
}
}
}
private fun <T : Any> Query<T>.distinctFlowList() = this.asFlow().mapToList().distinctUntilChanged()
override suspend fun markRead(roomId: RoomId) {
coroutineDispatchers.withIoContext {
database.unreadEventQueries.removeRead(room_id = roomId.value)
}
}
override fun observeEvent(eventId: EventId): Flow<EventId> {
return database.roomEventQueries.selectEvent(event_id = eventId.value)
.asFlow()
.mapToOneNotNull()
.map { EventId(it) }
}
override suspend fun findEvent(eventId: EventId): RoomEvent? {
return coroutineDispatchers.withIoContext {
database.roomEventQueries.selectEventContent(event_id = eventId.value)
.executeAsOneOrNull()
?.let { json.decodeFromString(RoomEvent.serializer(), it) }
}
}
}
private fun RoomEventQueries.insertRoomEvent(roomId: RoomId, roomEvent: RoomEvent) {
this.insert(
app.dapk.db.model.DbRoomEvent(
event_id = roomEvent.eventId.value,
room_id = roomId.value,
timestamp_utc = roomEvent.utcTimestamp,
blob = json.encodeToString(RoomEvent.serializer(), roomEvent),
)
)
}

View File

@ -1,61 +0,0 @@
CREATE TABLE dbCryptoAccount (
user_id TEXT NOT NULL,
blob TEXT NOT NULL,
PRIMARY KEY (user_id)
);
CREATE TABLE dbCryptoOlmSession (
identity_key TEXT NOT NULL,
session_id TEXT NOT NULL,
blob TEXT NOT NULL,
PRIMARY KEY (identity_key, session_id)
);
CREATE TABLE dbCryptoMegolmInbound (
session_id TEXT NOT NULL,
blob TEXT NOT NULL,
PRIMARY KEY (session_id)
);
CREATE TABLE dbCryptoMegolmOutbound (
room_id TEXT NOT NULL,
utcEpochMillis INTEGER NOT NULL,
blob TEXT NOT NULL,
PRIMARY KEY (room_id)
);
selectAccount:
SELECT blob
FROM dbCryptoAccount
WHERE user_id = ?;
insertAccount:
INSERT OR REPLACE INTO dbCryptoAccount(user_id, blob)
VALUES ?;
selectOlmSession:
SELECT blob, identity_key
FROM dbCryptoOlmSession
WHERE identity_key IN ?;
insertOlmSession:
INSERT OR REPLACE INTO dbCryptoOlmSession(identity_key, session_id, blob)
VALUES (?, ?, ?);
selectMegolmInbound:
SELECT blob
FROM dbCryptoMegolmInbound
WHERE session_id = ?;
insertMegolmInbound:
INSERT OR REPLACE INTO dbCryptoMegolmInbound(session_id, blob)
VALUES ?;
selectMegolmOutbound:
SELECT blob, utcEpochMillis
FROM dbCryptoMegolmOutbound
WHERE room_id = ?;
insertMegolmOutbound:
INSERT OR REPLACE INTO dbCryptoMegolmOutbound(room_id, utcEpochMillis, blob)
VALUES ?;

View File

@ -1,47 +0,0 @@
CREATE TABLE dbDeviceKey (
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
blob TEXT NOT NULL,
outdated INTEGER AS Int NOT NULL,
PRIMARY KEY (user_id, device_id)
);
CREATE TABLE dbDeviceKeyToMegolmSession (
device_id TEXT NOT NULL,
session_id TEXT NOT NULL,
PRIMARY KEY (device_id, session_id)
);
selectUserDevicesWithSessions:
SELECT user_id, dbDeviceKey.device_id, blob
FROM dbDeviceKey
JOIN dbDeviceKeyToMegolmSession ON dbDeviceKeyToMegolmSession.device_id = dbDeviceKey.device_id
WHERE user_id IN ? AND dbDeviceKeyToMegolmSession.session_id = ?;
selectDevice:
SELECT blob
FROM dbDeviceKey
WHERE device_id = ?;
selectOutdatedUsers:
SELECT user_id
FROM dbDeviceKey
WHERE outdated = 1;
insertDevice:
INSERT OR REPLACE INTO dbDeviceKey(user_id, device_id, blob, outdated)
VALUES (?, ?, ?, 0);
markOutdated:
UPDATE dbDeviceKey
SET outdated = 1
WHERE user_id IN ?;
markIndate:
UPDATE dbDeviceKey
SET outdated = 0
WHERE user_id IN ?;
insertDeviceToMegolmSession:
INSERT OR REPLACE INTO dbDeviceKeyToMegolmSession(device_id, session_id)
VALUES (?, ?);

View File

@ -1,17 +0,0 @@
CREATE TABLE dbInviteState (
room_id TEXT NOT NULL,
blob TEXT NOT NULL,
PRIMARY KEY (room_id)
);
selectAll:
SELECT room_id, blob
FROM dbInviteState;
insert:
INSERT OR REPLACE INTO dbInviteState(room_id, blob)
VALUES (?, ?);
remove:
DELETE FROM dbInviteState
WHERE room_id = ?;

View File

@ -1,18 +0,0 @@
CREATE TABLE IF NOT EXISTS dbLocalEcho (
local_id TEXT NOT NULL,
room_id TEXT NOT NULL,
blob TEXT NOT NULL,
PRIMARY KEY (local_id)
);
selectAll:
SELECT *
FROM dbLocalEcho;
insert:
INSERT OR REPLACE INTO dbLocalEcho(local_id, room_id, blob)
VALUES ?;
delete:
DELETE FROM dbLocalEcho
WHERE local_id = ?;

View File

@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS dbMutedRoom (
room_id TEXT NOT NULL,
PRIMARY KEY (room_id)
);
insertMuted:
INSERT OR REPLACE INTO dbMutedRoom(room_id)
VALUES (?);
removeMuted:
DELETE FROM dbMutedRoom
WHERE room_id = ?;
select:
SELECT room_id
FROM dbMutedRoom;

View File

@ -1,25 +0,0 @@
CREATE TABLE dbOverviewState (
room_id TEXT NOT NULL,
latest_activity_timestamp_utc INTEGER NOT NULL,
read_marker TEXT,
blob TEXT NOT NULL,
PRIMARY KEY (room_id)
);
selectAll:
SELECT *
FROM dbOverviewState
ORDER BY latest_activity_timestamp_utc DESC;
selectRoom:
SELECT blob
FROM dbOverviewState
WHERE room_id = ?;
insert:
INSERT OR REPLACE INTO dbOverviewState(room_id, latest_activity_timestamp_utc, read_marker, blob)
VALUES (?, ?, ?, ?);
remove:
DELETE FROM dbOverviewState
WHERE room_id = ?;

View File

@ -1,53 +0,0 @@
CREATE TABLE IF NOT EXISTS dbRoomEvent (
event_id TEXT NOT NULL,
room_id TEXT NOT NULL,
timestamp_utc INTEGER NOT NULL,
blob TEXT NOT NULL,
PRIMARY KEY (event_id)
);
selectRoom:
SELECT blob
FROM dbRoomEvent
WHERE room_id = ?
ORDER BY timestamp_utc DESC
LIMIT 100;
insert:
INSERT OR REPLACE INTO dbRoomEvent(event_id, room_id, timestamp_utc, blob)
VALUES ?;
selectEvent:
SELECT event_id
FROM dbRoomEvent
WHERE event_id = ?;
selectEventContent:
SELECT blob
FROM dbRoomEvent
WHERE event_id = ?;
selectAllUnread:
SELECT dbRoomEvent.blob, dbRoomEvent.room_id
FROM dbUnreadEvent
INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id
ORDER BY dbRoomEvent.timestamp_utc DESC
LIMIT 100;
selectNotMutedUnread:
SELECT dbRoomEvent.blob, dbRoomEvent.room_id
FROM dbUnreadEvent
INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id
LEFT OUTER JOIN dbMutedRoom
ON dbUnreadEvent.room_id = dbMutedRoom.room_id
WHERE dbMutedRoom.room_id IS NULL
ORDER BY dbRoomEvent.timestamp_utc DESC
LIMIT 100;
remove:
DELETE FROM dbRoomEvent
WHERE room_id = ?;
removeEvent:
DELETE FROM dbRoomEvent
WHERE event_id = ?;

View File

@ -1,21 +0,0 @@
CREATE TABLE dbRoomMember (
user_id TEXT NOT NULL,
room_id TEXT NOT NULL,
blob TEXT NOT NULL,
PRIMARY KEY (user_id, room_id)
);
selectMembersByRoomAndId:
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 (?, ?, ?);

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