Merge pull request #103 from ouchadam/release-candidate

[Auto] Release Candidate
This commit is contained in:
Adam Brown 2022-08-24 14:11:56 +01:00 committed by GitHub
commit c4b3729908
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
280 changed files with 7257 additions and 1776 deletions

7
.github/dependabot.yml vendored Normal file
View File

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

BIN
.github/readme/google-play-badge.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -17,19 +17,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/cache@v2
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- uses: actions/setup-java@v2 - uses: actions/setup-java@v2
with: with:
distribution: 'adopt' distribution: 'adopt'
java-version: '11' java-version: '11'
- uses: gradle/gradle-build-action@v2
- name: Assemble debug variant - name: Assemble debug variant
run: ./gradlew assembleDebug --no-daemon run: ./gradlew assembleDebug --no-daemon

45
.github/workflows/check_size.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: Check Size
on:
pull_request:
jobs:
check-size:
name: Check Size
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v2
with:
distribution: 'adopt'
java-version: '11'
- uses: gradle/gradle-build-action@v2
- name: Fetch bundletool
run: |
curl -s -L https://github.com/google/bundletool/releases/download/1.9.0/bundletool-all-1.9.0.jar --create-dirs -o bin/bundletool.jar
chmod +x bin/bundletool.jar
echo "#!/bin/bash" >> bin/bundletool
echo 'java -jar $(dirname "$0")/bundletool.jar "$@"' >> bin/bundletool
chmod +x bin/bundletool
echo "$(pwd)/bin" >> $GITHUB_PATH
- name: Save Size
env:
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
run: |
mkdir -p ./apk_size
echo $(./tools/check-size.sh | tail -1 | cut -d ',' -f2-) > ./apk_size/size.txt
echo $PULL_REQUEST_NUMBER > ./apk_size/pr_number.txt
- uses: actions/upload-artifact@v3
with:
name: apk-size
path: |
apk_size/size.txt
apk_size/pr_number.txt
retention-days: 5

44
.github/workflows/comment_size.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: Comment APK Size
on:
workflow_run:
workflows: [ "Check Size" ]
types:
- completed
jobs:
comment-size:
name: Comment Size
runs-on: ubuntu-latest
if: >
${{ github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: dawidd6/action-download-artifact@v2
with:
name: apk-size
workflow: ${{ github.event.workflow_run.workflow_id }}
- name: Check release size
run: |
ls -R
echo "::set-output name=APK_SIZE::$(cat size.txt)"
echo "::set-output name=PR_NUMBER::$(cat pr_number.txt)"
id: size
- name: Find Comment
uses: peter-evans/find-comment@v1
id: fc
with:
issue-number: ${{ steps.size.outputs.PR_NUMBER }}
comment-author: 'github-actions[bot]'
body-includes: APK Size
- name: Publish size to PR
uses: peter-evans/create-or-update-comment@v1
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ steps.size.outputs.PR_NUMBER }}
body: |
APK Size: ${{ steps.size.outputs.APK_SIZE }}
edit-mode: replace

View File

@ -17,29 +17,26 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/cache@v2
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- uses: actions/setup-java@v2 - uses: actions/setup-java@v2
with: with:
distribution: 'adopt' distribution: 'adopt'
java-version: '11' java-version: '11'
- uses: gradle/gradle-build-action@v2
- name: Create pip requirements
run: |
echo "matrix-synapse==v1.60.0" > requirements.txt
- name: Set up Python 3.8 - name: Set up Python 3.8
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: 3.8 python-version: 3.8
cache: 'pip'
- name: Start synapse server - name: Start synapse server
run: | run: |
pip install matrix-synapse pip install -r requirements.txt
curl -sL https://raw.githubusercontent.com/matrix-org/synapse/develop/demo/start.sh \ curl -sL https://gist.githubusercontent.com/ouchadam/e3ad09ec382bd91a66d88ab575ea7c31/raw/run.sh \
| bash -s -- --no-rate-limit | bash -s -- --no-rate-limit
- name: Run all unit tests - name: Run all unit tests

View File

@ -0,0 +1,15 @@
name: Update Gradle Wrapper
on:
schedule:
- cron: "0 0 * * *"
jobs:
update-gradle-wrapper:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@v1

View File

@ -0,0 +1,29 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>

View File

@ -1,13 +1,18 @@
# SmallTalk [![Assemble](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml/badge.svg)](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml) [![codecov](https://codecov.io/gh/ouchadam/small-talk/branch/main/graph/badge.svg?token=ETFSLZ9FCI)](https://codecov.io/gh/ouchadam/small-talk) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) # SmallTalk [![Assemble](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml/badge.svg)](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml) [![codecov](https://codecov.io/gh/ouchadam/small-talk/branch/main/graph/badge.svg?token=ETFSLZ9FCI)](https://codecov.io/gh/ouchadam/small-talk) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ![](https://img.shields.io/github/v/release/ouchadam/small-talk?include_prereleases) ![](https://img.shields.io/badge/%5Bmatrix%5D%20-%23small--talk%3Aiswell.cool-blueviolet)
`SmallTalk` is a minimal, modern, friends and family focused Android messenger. Heavily inspired by Whatsapp and Signal, powered by Matrix. `SmallTalk` is a minimal, modern, friends and family focused Android messenger. Heavily inspired by Whatsapp and Signal, powered by Matrix.
![header](https://github.com/ouchadam/small-talk/blob/main/.github/readme/header.png?raw=true) ![header](https://github.com/ouchadam/small-talk/blob/main/.github/readme/header.png?raw=true)
[<img align="right" height="70" src="https://github.com/ouchadam/small-talk/blob/main/.github/readme/google-play-badge.png?raw=tru"></a>](https://play.google.com/store/apps/details?id=app.dapk.st)
--- <br>
<br>
<br>
Project mantra
- Tiny app size - currently 1.72mb~ when provided via app bundle. ### Project mantra
- Tiny app size - currently 1.80mb~ when provided via app bundle.
- Focused on reliability and stability. - Focused on reliability and stability.
- Bare-bones feature set. - Bare-bones feature set.
@ -15,16 +20,17 @@ Project mantra
--- ---
#### Feature list ### Feature list
- Login with username/password (home servers must serve `${domain}.well-known/matrix/client`) - Login with Matrix ID/Password
- Combined Room and DM interface - Combined Room and DM interface
- End to end encryption - End to end encryption
- Message bubbles, supporting text, replies and edits - Message bubbles, supporting text, replies and edits
- Push notifications (DMs always notify, Rooms notify once) - Push notifications (DMs always notify, Rooms notify once)
- Importing of E2E room keys from Element clients - Importing of E2E room keys from Element clients
- [UnifiedPush](https://unifiedpush.org/)
#### Planned ### Planned
- Device verification (technically supported but has no UI) - Device verification (technically supported but has no UI)
- Invitations (technically supported but has no UI) - Invitations (technically supported but has no UI)
@ -48,3 +54,7 @@ Project mantra
- Heavily optimised build script, clean _cacheless_ builds are sub 10 seconds with a warmed up gradle daemon. - Heavily optimised build script, clean _cacheless_ builds are sub 10 seconds with a warmed up gradle daemon.
- Avoids code generation where possible in favour of build speed, this mainly means manual DI. - Avoids code generation where possible in favour of build speed, this mainly means manual DI.
- A pure kotlin test harness to allow for critical flow assertions [Smoke Tests](https://github.com/ouchadam/small-talk/blob/main/test-harness/src/test/kotlin/SmokeTest.kt), currently Linux x86-64 only. - A pure kotlin test harness to allow for critical flow assertions [Smoke Tests](https://github.com/ouchadam/small-talk/blob/main/test-harness/src/test/kotlin/SmokeTest.kt), currently Linux x86-64 only.
---
#### Join the conversation @ https://matrix.to/#/#small-talk:iswell.cool

View File

@ -10,10 +10,21 @@ android {
ndkVersion "25.0.8141415" ndkVersion "25.0.8141415"
defaultConfig { defaultConfig {
applicationId "app.dapk.st" applicationId "app.dapk.st"
versionCode 2 def versionJson = new groovy.json.JsonSlurper().parseText(rootProject.file('version.json').text)
versionName "0.0.1-alpha1" versionCode versionJson.code
versionName versionJson.name
if (isDebugBuild) {
resConfigs "en", "xxhdpi"
variantFilter { variant ->
if (variant.buildType.name == "release") {
setIgnore(true)
}
}
} else {
resConfigs "en" resConfigs "en"
} }
}
bundle { bundle {
abi.enableSplit true abi.enableSplit true
@ -23,6 +34,7 @@ android {
buildTypes { buildTypes {
debug { debug {
versionNameSuffix = " [debug]"
matchingFallbacks = ['release'] matchingFallbacks = ['release']
signingConfig.storeFile rootProject.file("tools/debug.keystore") signingConfig.storeFile rootProject.file("tools/debug.keystore")
} }
@ -33,16 +45,24 @@ android {
'proguard/app.pro', 'proguard/app.pro',
"proguard/serializationx.pro", "proguard/serializationx.pro",
"proguard/olm.pro" "proguard/olm.pro"
// actual releases are signed with a different config
signingConfig = buildTypes.debug.signingConfig signingConfig = buildTypes.debug.signingConfig
} }
} }
compileOptions {
coreLibraryDesugaringEnabled true
}
packagingOptions { packagingOptions {
resources.excludes += "DebugProbesKt.bin" resources.excludes += "DebugProbesKt.bin"
} }
} }
dependencies { dependencies {
coreLibraryDesugaring Dependencies.google.jdkLibs
implementation project(":features:home") implementation project(":features:home")
implementation project(":features:directory") implementation project(":features:directory")
implementation project(":features:login") implementation project(":features:login")
@ -51,8 +71,10 @@ dependencies {
implementation project(":features:messenger") implementation project(":features:messenger")
implementation project(":features:profile") implementation project(":features:profile")
implementation project(":features:navigator") implementation project(":features:navigator")
implementation project(":features:share-entry")
implementation project(':domains:store') implementation project(':domains:store')
implementation project(":domains:android:compose-core")
implementation project(":domains:android:core") implementation project(":domains:android:core")
implementation project(":domains:android:tracking") implementation project(":domains:android:tracking")
implementation project(":domains:android:push") implementation project(":domains:android:push")
@ -79,5 +101,5 @@ dependencies {
implementation Dependencies.mavenCentral.matrixOlm implementation Dependencies.mavenCentral.matrixOlm
implementation Dependencies.mavenCentral.kotlinSerializationJson implementation Dependencies.mavenCentral.kotlinSerializationJson
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' debugImplementation Dependencies.mavenCentral.leakCanary
} }

View File

@ -20,6 +20,11 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias> </activity-alias>
</application> </application>

View File

@ -33,7 +33,7 @@ internal class SharedPreferencesDelegate(
override suspend fun clear() { override suspend fun clear() {
coroutineDispatchers.withIoContext { coroutineDispatchers.withIoContext {
preferences.edit().clear().apply() preferences.edit().clear().commit()
} }
} }
} }

View File

@ -1,26 +1,29 @@
package app.dapk.st package app.dapk.st
import android.app.Application import android.app.Application
import android.content.Intent
import android.util.Log import android.util.Log
import app.dapk.st.core.CoreAndroidModule import app.dapk.st.core.CoreAndroidModule
import app.dapk.st.core.ModuleProvider import app.dapk.st.core.ModuleProvider
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.attachAppLogger import app.dapk.st.core.attachAppLogger
import app.dapk.st.core.extensions.ResettableUnsafeLazy
import app.dapk.st.core.extensions.Scope import app.dapk.st.core.extensions.Scope
import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.directory.DirectoryModule import app.dapk.st.directory.DirectoryModule
import app.dapk.st.messenger.MessengerModule import app.dapk.st.domain.StoreModule
import app.dapk.st.graph.AppModule import app.dapk.st.graph.AppModule
import app.dapk.st.graph.FeatureModules
import app.dapk.st.home.HomeModule import app.dapk.st.home.HomeModule
import app.dapk.st.login.LoginModule import app.dapk.st.login.LoginModule
import app.dapk.st.messenger.MessengerModule
import app.dapk.st.notifications.NotificationsModule import app.dapk.st.notifications.NotificationsModule
import app.dapk.st.profile.ProfileModule import app.dapk.st.profile.ProfileModule
import app.dapk.st.push.firebase.FirebasePushService
import app.dapk.st.push.PushModule
import app.dapk.st.settings.SettingsModule import app.dapk.st.settings.SettingsModule
import app.dapk.st.share.ShareEntryModule
import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.TaskRunnerModule
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlin.reflect.KClass import kotlin.reflect.KClass
class SmallTalkApplication : Application(), ModuleProvider { class SmallTalkApplication : Application(), ModuleProvider {
@ -28,8 +31,10 @@ class SmallTalkApplication : Application(), ModuleProvider {
private val appLogger: (String, String) -> Unit = { tag, message -> _appLogger?.invoke(tag, message) } private val appLogger: (String, String) -> Unit = { tag, message -> _appLogger?.invoke(tag, message) }
private var _appLogger: ((String, String) -> Unit)? = null private var _appLogger: ((String, String) -> Unit)? = null
private val appModule: AppModule by unsafeLazy { AppModule(this, appLogger) } private val lazyAppModule = ResettableUnsafeLazy { AppModule(this, appLogger) }
private val featureModules: FeatureModules by unsafeLazy { appModule.featureModules } private val lazyFeatureModules = ResettableUnsafeLazy { appModule.featureModules }
private val appModule by lazyAppModule
private val featureModules by lazyFeatureModules
private val applicationScope = Scope(Dispatchers.IO) private val applicationScope = Scope(Dispatchers.IO)
override fun onCreate() { override fun onCreate() {
@ -40,15 +45,17 @@ class SmallTalkApplication : Application(), ModuleProvider {
val logger: (String, String) -> Unit = { tag, message -> val logger: (String, String) -> Unit = { tag, message ->
Log.e(tag, message) Log.e(tag, message)
GlobalScope.launch { applicationScope.launch { eventLogStore.insert(tag, message) }
eventLogStore.insert(tag, message)
}
} }
attachAppLogger(logger) attachAppLogger(logger)
_appLogger = logger _appLogger = logger
onApplicationLaunch(notificationsModule, storeModule)
}
private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) {
applicationScope.launch { applicationScope.launch {
notificationsModule.firebasePushTokenUseCase().registerCurrentToken() featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
storeModule.localEchoStore.preload() storeModule.localEchoStore.preload()
} }
@ -67,10 +74,24 @@ class SmallTalkApplication : Application(), ModuleProvider {
SettingsModule::class -> featureModules.settingsModule SettingsModule::class -> featureModules.settingsModule
ProfileModule::class -> featureModules.profileModule ProfileModule::class -> featureModules.profileModule
NotificationsModule::class -> featureModules.notificationsModule NotificationsModule::class -> featureModules.notificationsModule
PushModule::class -> featureModules.pushModule
MessengerModule::class -> featureModules.messengerModule MessengerModule::class -> featureModules.messengerModule
TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule
CoreAndroidModule::class -> appModule.coreAndroidModule CoreAndroidModule::class -> appModule.coreAndroidModule
ShareEntryModule::class -> featureModules.shareEntryModule
else -> throw IllegalArgumentException("Unknown: $klass") else -> throw IllegalArgumentException("Unknown: $klass")
} as T } as T
} }
override fun reset() {
featureModules.pushModule.pushTokenRegistrar().unregister()
appModule.coroutineDispatchers.io.cancel()
applicationScope.cancel()
lazyAppModule.reset()
lazyFeatureModules.reset()
val notificationsModule = featureModules.notificationsModule
val storeModule = appModule.storeModule.value
onApplicationLaunch(notificationsModule, storeModule)
}
} }

View File

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

View File

@ -1,16 +1,17 @@
package app.dapk.st.graph package app.dapk.st.graph
import android.app.Activity
import android.app.Application import android.app.Application
import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import app.dapk.db.DapkDb import app.dapk.db.DapkDb
import app.dapk.st.BuildConfig import app.dapk.st.BuildConfig
import app.dapk.st.SharedPreferencesDelegate import app.dapk.st.SharedPreferencesDelegate
import app.dapk.st.core.BuildMeta import app.dapk.st.core.*
import app.dapk.st.core.CoreAndroidModule
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.ErrorTracker
import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.directory.DirectoryModule import app.dapk.st.directory.DirectoryModule
@ -20,8 +21,6 @@ import app.dapk.st.home.MainActivity
import app.dapk.st.imageloader.ImageLoaderModule import app.dapk.st.imageloader.ImageLoaderModule
import app.dapk.st.login.LoginModule import app.dapk.st.login.LoginModule
import app.dapk.st.matrix.MatrixClient import app.dapk.st.matrix.MatrixClient
import app.dapk.st.matrix.MatrixTaskRunner
import app.dapk.st.matrix.MatrixTaskRunner.MatrixTask
import app.dapk.st.matrix.auth.authService import app.dapk.st.matrix.auth.authService
import app.dapk.st.matrix.auth.installAuthService import app.dapk.st.matrix.auth.installAuthService
import app.dapk.st.matrix.common.* import app.dapk.st.matrix.common.*
@ -34,7 +33,11 @@ import app.dapk.st.matrix.device.installEncryptionService
import app.dapk.st.matrix.device.internal.ApiMessage import app.dapk.st.matrix.device.internal.ApiMessage
import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.MatrixHttpClient
import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory
import app.dapk.st.matrix.message.* import app.dapk.st.matrix.message.MessageEncrypter
import app.dapk.st.matrix.message.MessageService
import app.dapk.st.matrix.message.installMessageService
import app.dapk.st.matrix.message.internal.ImageContentReader
import app.dapk.st.matrix.message.messageService
import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.push.installPushService
import app.dapk.st.matrix.push.pushService import app.dapk.st.matrix.push.pushService
import app.dapk.st.matrix.room.* import app.dapk.st.matrix.room.*
@ -44,6 +47,8 @@ import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
import app.dapk.st.messenger.MessengerActivity import app.dapk.st.messenger.MessengerActivity
import app.dapk.st.messenger.MessengerModule import app.dapk.st.messenger.MessengerModule
import app.dapk.st.navigator.IntentFactory import app.dapk.st.navigator.IntentFactory
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.notifications.MatrixPushHandler
import app.dapk.st.notifications.NotificationsModule import app.dapk.st.notifications.NotificationsModule
import app.dapk.st.olm.DeviceKeyFactory import app.dapk.st.olm.DeviceKeyFactory
import app.dapk.st.olm.OlmPersistenceWrapper import app.dapk.st.olm.OlmPersistenceWrapper
@ -51,18 +56,17 @@ import app.dapk.st.olm.OlmWrapper
import app.dapk.st.profile.ProfileModule import app.dapk.st.profile.ProfileModule
import app.dapk.st.push.PushModule import app.dapk.st.push.PushModule
import app.dapk.st.settings.SettingsModule import app.dapk.st.settings.SettingsModule
import app.dapk.st.share.ShareEntryModule
import app.dapk.st.tracking.TrackingModule import app.dapk.st.tracking.TrackingModule
import app.dapk.st.work.TaskRunner
import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.TaskRunnerModule
import app.dapk.st.work.WorkModule import app.dapk.st.work.WorkModule
import app.dapk.st.work.WorkScheduler
import com.squareup.sqldelight.android.AndroidSqliteDriver import com.squareup.sqldelight.android.AndroidSqliteDriver
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import java.time.Clock import java.time.Clock
internal class AppModule(context: Application, logger: MatrixLogger) { internal class AppModule(context: Application, logger: MatrixLogger) {
private val buildMeta = BuildMeta(BuildConfig.VERSION_NAME) private val buildMeta = BuildMeta(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)
private val trackingModule by unsafeLazy { private val trackingModule by unsafeLazy {
TrackingModule( TrackingModule(
isCrashTrackingEnabled = !BuildConfig.DEBUG isCrashTrackingEnabled = !BuildConfig.DEBUG
@ -71,8 +75,8 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
private val driver = AndroidSqliteDriver(DapkDb.Schema, context, "dapk.db") private val driver = AndroidSqliteDriver(DapkDb.Schema, context, "dapk.db")
private val database = DapkDb(driver) private val database = DapkDb(driver)
private val clock = Clock.systemUTC()
private val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO) val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO)
val storeModule = unsafeLazy { val storeModule = unsafeLazy {
StoreModule( StoreModule(
@ -80,31 +84,41 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
preferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-user-preferences", coroutineDispatchers), preferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-user-preferences", coroutineDispatchers),
errorTracker = trackingModule.errorTracker, errorTracker = trackingModule.errorTracker,
credentialPreferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-credentials-preferences", coroutineDispatchers), credentialPreferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-credentials-preferences", coroutineDispatchers),
databaseDropper = { includeCryptoAccount -> databaseDropper = DefaultDatabaseDropper(coroutineDispatchers, driver),
val cursor = driver.executeQuery(
identifier = null,
sql = "SELECT name FROM sqlite_master WHERE type = 'table' ${if (includeCryptoAccount) "" else "AND name != 'dbCryptoAccount'"}",
parameters = 0
)
while (cursor.next()) {
cursor.getString(0)?.let {
driver.execute(null, "DELETE FROM $it", 0)
}
}
},
coroutineDispatchers = coroutineDispatchers coroutineDispatchers = coroutineDispatchers
) )
} }
private val workModule = WorkModule(context) private val workModule = WorkModule(context)
private val imageLoaderModule = ImageLoaderModule(context) private val imageLoaderModule = ImageLoaderModule(context)
private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers) private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, context.contentResolver)
val domainModules = DomainModules(matrixModules, trackingModule.errorTracker) val domainModules = DomainModules(matrixModules, trackingModule.errorTracker, workModule, storeModule, context, coroutineDispatchers)
val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory { val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory {
override fun home(activity: Activity) = Intent(activity, MainActivity::class.java) override fun notificationOpenApp(context: Context) = PendingIntent.getActivity(
override fun messenger(activity: Activity, roomId: RoomId) = MessengerActivity.newInstance(activity, roomId) context,
override fun messengerShortcut(activity: Activity, roomId: RoomId) = MessengerActivity.newShortcutInstance(activity, roomId) 1000,
home(context)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
override fun notificationOpenMessage(context: Context, roomId: RoomId) = PendingIntent.getActivity(
context,
roomId.hashCode(),
messenger(context, roomId)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
override fun home(context: Context) = Intent(context, MainActivity::class.java)
override fun messenger(context: Context, roomId: RoomId) = MessengerActivity.newInstance(context, roomId)
override fun messengerShortcut(context: Context, roomId: RoomId) = MessengerActivity.newShortcutInstance(context, roomId)
override fun messengerAttachments(context: Context, roomId: RoomId, attachments: List<MessageAttachment>) = MessengerActivity.newMessageAttachment(
context,
roomId,
attachments
)
}) })
val featureModules = FeatureModules( val featureModules = FeatureModules(
@ -112,10 +126,12 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
matrixModules, matrixModules,
domainModules, domainModules,
trackingModule, trackingModule,
coreAndroidModule,
imageLoaderModule, imageLoaderModule,
context, context,
buildMeta, buildMeta,
coroutineDispatchers, coroutineDispatchers,
clock,
) )
} }
@ -124,10 +140,12 @@ internal class FeatureModules internal constructor(
private val matrixModules: MatrixModules, private val matrixModules: MatrixModules,
private val domainModules: DomainModules, private val domainModules: DomainModules,
private val trackingModule: TrackingModule, private val trackingModule: TrackingModule,
private val coreAndroidModule: CoreAndroidModule,
imageLoaderModule: ImageLoaderModule, imageLoaderModule: ImageLoaderModule,
context: Context, context: Context,
buildMeta: BuildMeta, buildMeta: BuildMeta,
coroutineDispatchers: CoroutineDispatchers, coroutineDispatchers: CoroutineDispatchers,
clock: Clock,
) { ) {
val directoryModule by unsafeLazy { val directoryModule by unsafeLazy {
@ -155,12 +173,14 @@ internal class FeatureModules internal constructor(
matrixModules.room, matrixModules.room,
storeModule.value.credentialsStore(), storeModule.value.credentialsStore(),
storeModule.value.roomStore(), storeModule.value.roomStore(),
clock
) )
} }
val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile) } val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile, buildMeta) }
val settingsModule by unsafeLazy { val settingsModule by unsafeLazy {
SettingsModule( SettingsModule(
storeModule.value, storeModule.value,
pushModule,
matrixModules.crypto, matrixModules.crypto,
matrixModules.sync, matrixModules.sync,
context.contentResolver, context.contentResolver,
@ -168,19 +188,26 @@ internal class FeatureModules internal constructor(
coroutineDispatchers coroutineDispatchers
) )
} }
val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync) } val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync, matrixModules.room, trackingModule.errorTracker) }
val notificationsModule by unsafeLazy { val notificationsModule by unsafeLazy {
NotificationsModule( NotificationsModule(
matrixModules.push,
matrixModules.sync,
storeModule.value.credentialsStore(),
domainModules.pushModule.registerFirebasePushTokenUseCase(),
imageLoaderModule.iconLoader(), imageLoaderModule.iconLoader(),
storeModule.value.roomStore(), storeModule.value.roomStore(),
context, context,
intentFactory = coreAndroidModule.intentFactory(),
dispatchers = coroutineDispatchers,
deviceMeta = DeviceMeta(Build.VERSION.SDK_INT)
) )
} }
val shareEntryModule by unsafeLazy {
ShareEntryModule(matrixModules.sync, matrixModules.room)
}
val pushModule by unsafeLazy {
domainModules.pushModule
}
} }
internal class MatrixModules( internal class MatrixModules(
@ -189,6 +216,7 @@ internal class MatrixModules(
private val workModule: WorkModule, private val workModule: WorkModule,
private val logger: MatrixLogger, private val logger: MatrixLogger,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val contentResolver: ContentResolver,
) { ) {
val matrix by unsafeLazy { val matrix by unsafeLazy {
@ -205,7 +233,8 @@ internal class MatrixModules(
installAuthService(credentialsStore) installAuthService(credentialsStore)
installEncryptionService(store.knownDevicesStore()) installEncryptionService(store.knownDevicesStore())
val olmAccountStore = OlmPersistenceWrapper(store.olmStore()) val base64 = AndroidBase64()
val olmAccountStore = OlmPersistenceWrapper(store.olmStore(), base64)
val singletonFlows = SingletonFlows(coroutineDispatchers) val singletonFlows = SingletonFlows(coroutineDispatchers)
val olm = OlmWrapper( val olm = OlmWrapper(
olmStore = olmAccountStore, olmStore = olmAccountStore,
@ -225,13 +254,16 @@ internal class MatrixModules(
services.roomService().joinedMembers(it).map { it.userId } services.roomService().joinedMembers(it).map { it.userId }
} }
}, },
base64 = base64,
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
) )
installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler())) { serviceProvider -> val imageContentReader = AndroidImageContentReader(contentResolver)
installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader) { serviceProvider ->
MessageEncrypter { message -> MessageEncrypter { message ->
val result = serviceProvider.cryptoService().encrypt( val result = serviceProvider.cryptoService().encrypt(
roomId = when (message) { roomId = when (message) {
is MessageService.Message.TextMessage -> message.roomId is MessageService.Message.TextMessage -> message.roomId
is MessageService.Message.ImageMessage -> message.roomId
}, },
credentials = credentialsStore.credentials()!!, credentials = credentialsStore.credentials()!!,
when (message) { when (message) {
@ -246,6 +278,8 @@ internal class MatrixModules(
) )
) )
) )
is MessageService.Message.ImageMessage -> TODO()
} }
) )
@ -283,6 +317,14 @@ internal class MatrixModules(
store.roomStore(), store.roomStore(),
store.syncStore(), store.syncStore(),
store.filterStore(), store.filterStore(),
deviceNotifier = { services ->
val encryption = services.deviceService()
val crypto = services.cryptoService()
DeviceNotifier { userIds, syncToken ->
encryption.updateStaleDevices(userIds)
crypto.updateOlmSession(userIds, syncToken)
}
},
messageDecrypter = { serviceProvider -> messageDecrypter = { serviceProvider ->
val cryptoService = serviceProvider.cryptoService() val cryptoService = serviceProvider.cryptoService()
MessageDecrypter { MessageDecrypter {
@ -296,9 +338,9 @@ internal class MatrixModules(
} }
}, },
verificationHandler = { services -> verificationHandler = { services ->
logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it")
val cryptoService = services.cryptoService() val cryptoService = services.cryptoService()
VerificationHandler { apiEvent -> VerificationHandler { apiEvent ->
logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it")
cryptoService.onVerificationEvent( cryptoService.onVerificationEvent(
when (apiEvent) { when (apiEvent) {
is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested( is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested(
@ -308,12 +350,14 @@ internal class MatrixModules(
apiEvent.content.methods, apiEvent.content.methods,
apiEvent.content.timestampPosix, apiEvent.content.timestampPosix,
) )
is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready(
apiEvent.sender, apiEvent.sender,
apiEvent.content.fromDevice, apiEvent.content.fromDevice,
apiEvent.content.transactionId, apiEvent.content.transactionId,
apiEvent.content.methods, apiEvent.content.methods,
) )
is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started(
apiEvent.sender, apiEvent.sender,
apiEvent.content.fromDevice, apiEvent.content.fromDevice,
@ -324,6 +368,7 @@ internal class MatrixModules(
apiEvent.content.short, apiEvent.content.short,
apiEvent.content.transactionId, apiEvent.content.transactionId,
) )
is ApiToDeviceEvent.VerificationCancel -> TODO() is ApiToDeviceEvent.VerificationCancel -> TODO()
is ApiToDeviceEvent.VerificationAccept -> TODO() is ApiToDeviceEvent.VerificationAccept -> TODO()
is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key(
@ -331,6 +376,7 @@ internal class MatrixModules(
apiEvent.content.transactionId, apiEvent.content.transactionId,
apiEvent.content.key apiEvent.content.key
) )
is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac(
apiEvent.sender, apiEvent.sender,
apiEvent.content.transactionId, apiEvent.content.transactionId,
@ -341,14 +387,6 @@ internal class MatrixModules(
) )
} }
}, },
deviceNotifier = { services ->
val encryption = services.deviceService()
val crypto = services.cryptoService()
DeviceNotifier { userIds, syncToken ->
encryption.updateStaleDevices(userIds)
crypto.updateOlmSession(userIds, syncToken)
}
},
oneTimeKeyProducer = { services -> oneTimeKeyProducer = { services ->
val cryptoService = services.cryptoService() val cryptoService = services.cryptoService()
MaybeCreateMoreKeys { MaybeCreateMoreKeys {
@ -359,6 +397,7 @@ internal class MatrixModules(
val roomService = services.roomService() val roomService = services.roomService()
object : RoomMembersService { object : RoomMembersService {
override suspend fun find(roomId: RoomId, userIds: List<UserId>) = roomService.findMembers(roomId, userIds) override suspend fun find(roomId: RoomId, userIds: List<UserId>) = roomService.findMembers(roomId, userIds)
override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId)
override suspend fun insert(roomId: RoomId, members: List<RoomMember>) = roomService.insertMembers(roomId, members) override suspend fun insert(roomId: RoomId, members: List<RoomMember>) = roomService.insertMembers(roomId, members)
} }
}, },
@ -367,8 +406,6 @@ internal class MatrixModules(
) )
installPushService(credentialsStore) installPushService(credentialsStore)
} }
} }
} }
@ -385,32 +422,49 @@ internal class MatrixModules(
internal class DomainModules( internal class DomainModules(
private val matrixModules: MatrixModules, private val matrixModules: MatrixModules,
private val errorTracker: ErrorTracker, private val errorTracker: ErrorTracker,
private val workModule: WorkModule,
private val storeModule: Lazy<StoreModule>,
private val context: Application,
private val dispatchers: CoroutineDispatchers,
) { ) {
val pushModule by unsafeLazy { PushModule(matrixModules.push, errorTracker) } val pushModule by unsafeLazy {
val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run)) } val store = storeModule.value
} val pushHandler = MatrixPushHandler(
workScheduler = workModule.workScheduler(),
class BackgroundWorkAdapter(private val workScheduler: WorkScheduler) : BackgroundScheduler { credentialsStore = store.credentialsStore(),
override fun schedule(key: String, task: BackgroundScheduler.Task) { matrixModules.sync,
workScheduler.schedule( store.roomStore(),
WorkScheduler.WorkTask(
jobId = 1,
type = task.type,
jsonPayload = task.jsonPayload,
) )
PushModule(
errorTracker,
pushHandler,
context,
dispatchers,
SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-user-preferences", dispatchers)
) )
} }
val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run, AppTaskRunner(matrixModules.push))) }
} }
class TaskRunnerAdapter(private val matrixTaskRunner: suspend (MatrixTask) -> MatrixTaskRunner.TaskResult) : TaskRunner { internal class AndroidImageContentReader(private val contentResolver: ContentResolver) : ImageContentReader {
override fun read(uri: String): ImageContentReader.ImageContent {
val androidUri = Uri.parse(uri)
val fileStream = contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")
override suspend fun run(tasks: List<TaskRunner.RunnableWorkTask>): List<TaskRunner.TaskResult> { val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
return tasks.map { BitmapFactory.decodeStream(fileStream, null, options)
when (val result = matrixTaskRunner(MatrixTask(it.task.type, it.task.jsonPayload))) {
is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(it.source, canRetry = result.canRetry) return contentResolver.openInputStream(androidUri)?.use { stream ->
MatrixTaskRunner.TaskResult.Success -> TaskRunner.TaskResult.Success(it.source) val output = stream.readBytes()
} ImageContentReader.ImageContent(
} height = options.outHeight,
width = options.outWidth,
size = output.size.toLong(),
mimeType = options.outMimeType,
fileName = androidUri.lastPathSegment ?: "file",
content = output
)
} ?: throw IllegalArgumentException("Could not process $uri")
} }
} }

View File

@ -0,0 +1,37 @@
package app.dapk.st.graph
import app.dapk.st.matrix.push.PushService
import app.dapk.st.push.PushTokenPayload
import app.dapk.st.work.TaskRunner
import io.ktor.client.plugins.*
import kotlinx.serialization.json.Json
class AppTaskRunner(
private val pushService: PushService,
) {
suspend fun run(workTask: TaskRunner.RunnableWorkTask): TaskRunner.TaskResult {
return when (val type = workTask.task.type) {
"push_token" -> {
runCatching {
val payload = Json.decodeFromString(PushTokenPayload.serializer(), workTask.task.jsonPayload)
pushService.registerPush(payload.token, payload.gatewayUrl)
}.fold(
onSuccess = { TaskRunner.TaskResult.Success(workTask.source) },
onFailure = {
val canRetry = if (it is ClientRequestException) {
it.response.status.value !in (400 until 500)
} else {
true
}
TaskRunner.TaskResult.Failure(workTask.source, canRetry = canRetry)
}
)
}
else -> throw IllegalArgumentException("Unknown work type: $type")
}
}
}

View File

@ -0,0 +1,16 @@
package app.dapk.st.graph
import app.dapk.st.matrix.message.BackgroundScheduler
import app.dapk.st.work.WorkScheduler
class BackgroundWorkAdapter(private val workScheduler: WorkScheduler) : BackgroundScheduler {
override fun schedule(key: String, task: BackgroundScheduler.Task) {
workScheduler.schedule(
WorkScheduler.WorkTask(
jobId = 1,
type = task.type,
jsonPayload = task.jsonPayload,
)
)
}
}

View File

@ -0,0 +1,33 @@
package app.dapk.st.graph
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
import app.dapk.st.domain.DatabaseDropper
import com.squareup.sqldelight.android.AndroidSqliteDriver
class DefaultDatabaseDropper(
private val coroutineDispatchers: CoroutineDispatchers,
private val driver: AndroidSqliteDriver,
) : DatabaseDropper {
override suspend fun dropAllTables(deleteCrypto: Boolean) {
coroutineDispatchers.withIoContext {
val cursor = driver.executeQuery(
identifier = null,
sql = "SELECT name FROM sqlite_master WHERE type = 'table'",
parameters = 0
)
cursor.use {
while (cursor.next()) {
cursor.getString(0)?.let {
if (!deleteCrypto && it.startsWith("dbCrypto")) {
// skip
} else {
driver.execute(null, "DELETE FROM $it", 0)
}
}
}
}
}
}
}

View File

@ -0,0 +1,24 @@
package app.dapk.st.graph
import app.dapk.st.matrix.MatrixTaskRunner
import app.dapk.st.work.TaskRunner
class TaskRunnerAdapter(
private val matrixTaskRunner: suspend (MatrixTaskRunner.MatrixTask) -> MatrixTaskRunner.TaskResult,
private val appTaskRunner: AppTaskRunner,
) : TaskRunner {
override suspend fun run(tasks: List<TaskRunner.RunnableWorkTask>): List<TaskRunner.TaskResult> {
return tasks.map {
when {
it.task.type.startsWith("matrix") -> {
when (val result = matrixTaskRunner(MatrixTaskRunner.MatrixTask(it.task.type, it.task.jsonPayload))) {
is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(it.source, canRetry = result.canRetry)
MatrixTaskRunner.TaskResult.Success -> TaskRunner.TaskResult.Success(it.source)
}
}
else -> appTaskRunner.run(it)
}
}
}
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<share-target android:targetClass="app.dapk.st.share.ShareEntryActivity">
<data android:mimeType="image/*" />
<category android:name="android.shortcut.conversation" />
</share-target>
</shortcuts>

View File

@ -9,7 +9,7 @@ buildscript {
classpath Dependencies.mavenCentral.kotlinGradlePlugin classpath Dependencies.mavenCentral.kotlinGradlePlugin
classpath Dependencies.mavenCentral.sqldelightGradlePlugin classpath Dependencies.mavenCentral.sqldelightGradlePlugin
classpath Dependencies.mavenCentral.kotlinSerializationGradlePlugin classpath Dependencies.mavenCentral.kotlinSerializationGradlePlugin
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1' classpath Dependencies.google.firebaseCrashlyticsPlugin
} }
} }
@ -18,14 +18,16 @@ def launchTask = getGradle()
.getTaskRequests() .getTaskRequests()
.toString() .toString()
.toLowerCase() .toLowerCase()
def isReleaseBuild = launchTask.contains("release")
ext.isDebugBuild = !isReleaseBuild
subprojects { subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "1.8"
freeCompilerArgs = [ freeCompilerArgs = [
'-Xopt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
] ]
} }
} }
@ -52,7 +54,7 @@ ext.applyLibraryPlugins = { project ->
project.apply plugin: 'kotlin-android' project.apply plugin: 'kotlin-android'
} }
ext.androidSdkVersion = 31 ext.androidSdkVersion = 32
ext.applyCommonAndroidParameters = { project -> ext.applyCommonAndroidParameters = { project ->
def android = project.android def android = project.android
@ -63,14 +65,9 @@ ext.applyCommonAndroidParameters = { project ->
incremental = true incremental = true
} }
android.defaultConfig { android.defaultConfig {
minSdkVersion 29 minSdkVersion 24
targetSdkVersion androidSdkVersion targetSdkVersion androidSdkVersion
} }
android.buildFeatures.compose = true
android.composeOptions {
kotlinCompilerExtensionVersion = Dependencies.google.kotlinCompilerExtensionVersion
}
} }
ext.applyLibraryModuleOptimisations = { project -> ext.applyLibraryModuleOptimisations = { project ->
@ -101,17 +98,26 @@ ext.applyCompose = { project ->
dependencies.implementation Dependencies.google.androidxComposeMaterial dependencies.implementation Dependencies.google.androidxComposeMaterial
dependencies.implementation Dependencies.google.androidxComposeIconsExtended dependencies.implementation Dependencies.google.androidxComposeIconsExtended
dependencies.implementation Dependencies.google.androidxActivityCompose 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 -> ext.applyAndroidLibraryModule = { project ->
applyLibraryPlugins(project) applyLibraryPlugins(project)
applyCommonAndroidParameters(project) applyCommonAndroidParameters(project)
applyLibraryModuleOptimisations(project) applyLibraryModuleOptimisations(project)
applyCompose(project)
} }
ext.applyCrashlyticsIfRelease = { project -> ext.applyCrashlyticsIfRelease = { project ->
def isReleaseBuild = launchTask.contains("release")
if (isReleaseBuild) { if (isReleaseBuild) {
project.apply plugin: 'com.google.firebase.crashlytics' project.apply plugin: 'com.google.firebase.crashlytics'
project.afterEvaluate { project.afterEvaluate {
@ -126,16 +132,17 @@ ext.kotlinTest = { dependencies ->
dependencies.testImplementation Dependencies.mavenCentral.kluent dependencies.testImplementation Dependencies.mavenCentral.kluent
dependencies.testImplementation Dependencies.mavenCentral.kotlinTest dependencies.testImplementation Dependencies.mavenCentral.kotlinTest
dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10" dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10"
dependencies.testImplementation 'io.mockk:mockk:1.12.2' dependencies.testImplementation 'io.mockk:mockk:1.12.7'
dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0' dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0'
dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0'
} }
ext.kotlinFixtures = { dependencies -> ext.kotlinFixtures = { dependencies ->
dependencies.testFixturesImplementation 'io.mockk:mockk:1.12.2' dependencies.testFixturesImplementation 'io.mockk:mockk:1.12.7'
dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent
dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
} }
ext.androidImportFixturesWorkaround = { project, fixtures -> ext.androidImportFixturesWorkaround = { project, fixtures ->

View File

@ -4,9 +4,9 @@ plugins {
} }
dependencies { dependencies {
implementation Dependencies.mavenCentral.kotlinCoroutinesCore api Dependencies.mavenCentral.kotlinCoroutinesCore
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
testFixturesImplementation Dependencies.mavenCentral.kluent testFixturesImplementation Dependencies.mavenCentral.kluent
testFixturesImplementation 'io.mockk:mockk:1.12.2' testFixturesImplementation Dependencies.mavenCentral.mockk
testFixturesImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0' testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest
} }

View File

@ -0,0 +1,4 @@
package app.dapk.st.core
@JvmInline
value class AndroidUri(val value: String)

View File

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

View File

@ -2,4 +2,5 @@ package app.dapk.st.core
data class BuildMeta( data class BuildMeta(
val versionName: String, val versionName: String,
val versionCode: Int,
) )

View File

@ -2,7 +2,11 @@ package app.dapk.st.core
import kotlinx.coroutines.* import kotlinx.coroutines.*
data class CoroutineDispatchers(val io: CoroutineDispatcher = Dispatchers.IO, val global: CoroutineScope = GlobalScope) data class CoroutineDispatchers(
val io: CoroutineDispatcher = Dispatchers.IO,
val main: CoroutineDispatcher = Dispatchers.Main,
val global: CoroutineScope = GlobalScope,
)
suspend fun <T> CoroutineDispatchers.withIoContext( suspend fun <T> CoroutineDispatchers.withIoContext(
block: suspend CoroutineScope.() -> T block: suspend CoroutineScope.() -> T

View File

@ -0,0 +1,5 @@
package app.dapk.st.core
data class DeviceMeta(
val apiVersion: Int
)

View File

@ -0,0 +1,27 @@
package app.dapk.st.core
class LRUCache<K, V>(val maxSize: Int) {
private val internalCache = object : LinkedHashMap<K, V>(0, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, V>?): Boolean {
return size > maxSize
}
}
fun put(key: K, value: V) {
internalCache[key] = value
}
fun get(key: K): V? {
return internalCache[key]
}
fun getOrPut(key: K, value: () -> V): V {
return get(key) ?: value().also { put(key, it) }
}
fun size() = internalCache.size
}
fun LRUCache<*, *>?.isNullOrEmpty() = this == null || this.size() == 0

View File

@ -0,0 +1,5 @@
package app.dapk.st.core
sealed interface MimeType {
object Image: MimeType
}

View File

@ -5,6 +5,7 @@ import kotlin.reflect.KClass
interface ModuleProvider { interface ModuleProvider {
fun <T : ProvidableModule> provide(klass: KClass<T>): T fun <T : ProvidableModule> provide(klass: KClass<T>): T
fun reset()
} }
interface ProvidableModule interface ProvidableModule

View File

@ -31,10 +31,12 @@ class SingletonFlows(
} }
} }
@Suppress("UNCHECKED_CAST")
fun <T> get(key: String): Flow<T> { fun <T> get(key: String): Flow<T> {
return cache[key]!! as Flow<T> return cache[key]!! as Flow<T>
} }
@Suppress("UNCHECKED_CAST")
suspend fun <T> update(key: String, value: T) { suspend fun <T> update(key: String, value: T) {
(cache[key] as? MutableSharedFlow<T>)?.emit(value) (cache[key] as? MutableSharedFlow<T>)?.emit(value)
} }

View File

@ -21,3 +21,25 @@ inline fun <T, T1 : T, T2 : T> Iterable<T>.firstOrNull(predicate: (T) -> Boolean
} }
fun <T> unsafeLazy(initializer: () -> T): Lazy<T> = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer) fun <T> unsafeLazy(initializer: () -> T): Lazy<T> = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer)
class ResettableUnsafeLazy<T>(private val initializer: () -> T) : Lazy<T> {
private var _value: T? = null
override val value: T
get() {
return if (_value == null) {
initializer().also { _value = it }
} else {
_value!!
}
}
override fun isInitialized(): Boolean {
return _value != null
}
fun reset() {
_value = null
}
}

View File

@ -0,0 +1,8 @@
package app.dapk.st.core.extensions
fun <K, V> Map<K, V>?.containsKey(key: K) = this?.containsKey(key) ?: false
fun <K, V> MutableMap<K,V>.clearAndPutAll(input: Map<K, V>) {
this.clear()
this.putAll(input)
}

View File

@ -0,0 +1,14 @@
package fixture
import app.dapk.st.core.CoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
object CoroutineDispatchersFixture {
fun aCoroutineDispatchers() = CoroutineDispatchers(
Dispatchers.Unconfined,
main = Dispatchers.Unconfined,
global = CoroutineScope(Dispatchers.Unconfined)
)
}

View File

@ -1,9 +1,6 @@
package test package test
import io.mockk.MockKMatcherScope import io.mockk.*
import io.mockk.MockKVerificationScope
import io.mockk.coJustRun
import io.mockk.coVerifyAll
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@ -12,23 +9,30 @@ fun runExpectTest(testBody: suspend ExpectTestScope.() -> Unit) {
runTest { testBody(ExpectTest(coroutineContext)) } runTest { testBody(ExpectTest(coroutineContext)) }
} }
class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope { class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope {
private val expects = mutableListOf<suspend MockKVerificationScope.() -> Unit>() private val expects = mutableListOf<Pair<Int, suspend MockKVerificationScope.() -> Unit>>()
private val groups = mutableListOf<suspend MockKVerificationScope.() -> Unit>()
override fun verifyExpects() = coVerifyAll { expects.forEach { it.invoke(this@coVerifyAll) } } override fun verifyExpects() {
expects.forEach { (times, block) -> coVerify(exactly = times) { block.invoke(this) } }
override fun <T> T.expectUnit(block: suspend MockKMatcherScope.(T) -> Unit) { groups.forEach { coVerifyOrder { it.invoke(this) } }
coJustRun { block(this@expectUnit) }.ignore()
expects.add { block(this@expectUnit) }
} }
override fun <T> T.expectUnit(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) {
coJustRun { block(this@expectUnit) }.ignore()
expects.add(times to { block(this@expectUnit) })
}
override fun <T> T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) {
groups.add { block(this@captureExpects) }
}
} }
private fun Any.ignore() = Unit private fun Any.ignore() = Unit
interface ExpectTestScope : CoroutineScope { interface ExpectTestScope : CoroutineScope {
fun verifyExpects() fun verifyExpects()
fun <T> T.expectUnit(block: suspend MockKMatcherScope.(T) -> Unit) fun <T> T.expectUnit(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit)
fun <T> T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit)
} }

View File

@ -1,6 +1,8 @@
package test package test
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
inline fun <T : Any, reified R> T.expect(crossinline block: suspend MockKMatcherScope.(T) -> R) { inline fun <T : Any, reified R> T.expect(crossinline block: suspend MockKMatcherScope.(T) -> R) {
coEvery { block(this@expect) } returns mockk(relaxed = true) coEvery { block(this@expect) } returns mockk(relaxed = true)
@ -16,11 +18,22 @@ fun <T, B> MockKStubScope<T, B>.delegateReturn() = object : Returns<T> {
} }
} }
fun <T, B> MockKStubScope<Flow<T>, B>.delegateEmit() = object : Emits<T> {
override fun emits(vararg values: T) {
answers(ConstantAnswer(flowOf(*values)))
}
}
fun <T> returns(block: (T) -> Unit) = object : Returns<T> { fun <T> returns(block: (T) -> Unit) = object : Returns<T> {
override fun returns(value: T) = block(value) override fun returns(value: T) = block(value)
override fun throws(value: Throwable) = throw value override fun throws(value: Throwable) = throw value
} }
interface Emits<T> {
fun emits(vararg values: T)
}
interface Returns<T> { interface Returns<T> {
fun returns(value: T) fun returns(value: T)
fun throws(value: Throwable) fun throws(value: Throwable)

View File

@ -10,6 +10,13 @@ ext.Dependencies.with {
} }
} }
repositories.maven {
url 'https://jitpack.io'
content {
includeGroup "com.github.UnifiedPush"
}
}
repositories.mavenCentral { repositories.mavenCentral {
content { content {
includeGroupByRegex "org\\.jetbrains.*" includeGroupByRegex "org\\.jetbrains.*"
@ -88,45 +95,64 @@ ext.Dependencies.with {
} }
} }
def kotlinVer = "1.6.10" def kotlinVer = "1.7.10"
def sqldelightVer = "1.5.3" def sqldelightVer = "1.5.3"
def composeVer = "1.1.0" def composeVer = "1.2.1"
def ktorVer = "2.1.0"
google = new DependenciesContainer() google = new DependenciesContainer()
google.with { google.with {
androidGradlePlugin = "com.android.tools.build:gradle:7.1.2" androidGradlePlugin = "com.android.tools.build:gradle:7.2.1"
androidxComposeUi = "androidx.compose.ui:ui:${composeVer}" androidxComposeUi = "androidx.compose.ui:ui:${composeVer}"
androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}" androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}"
androidxComposeMaterial = "androidx.compose.material:material:${composeVer}" androidxComposeMaterial = "androidx.compose.material:material:${composeVer}"
androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}" androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}"
androidxActivityCompose = "androidx.activity:activity-compose:1.4.0" androidxActivityCompose = "androidx.activity:activity-compose:1.4.0"
kotlinCompilerExtensionVersion = "1.1.0-rc02" kotlinCompilerExtensionVersion = "1.3.0"
firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.1"
jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"
} }
mavenCentral = new DependenciesContainer() mavenCentral = new DependenciesContainer()
mavenCentral.with { mavenCentral.with {
kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVer}" kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVer}"
kotlinSerializationGradlePlugin = "org.jetbrains.kotlin:kotlin-serialization:${kotlinVer}" kotlinSerializationGradlePlugin = "org.jetbrains.kotlin:kotlin-serialization:${kotlinVer}"
kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0" kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0"
kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-RC2" kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
kotlinCoroutinesTest = 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
kotlinTest = "org.jetbrains.kotlin:kotlin-test-junit:${kotlinVer}" kotlinTest = "org.jetbrains.kotlin:kotlin-test-junit:${kotlinVer}"
sqldelightGradlePlugin = "com.squareup.sqldelight:gradle-plugin:${sqldelightVer}" sqldelightGradlePlugin = "com.squareup.sqldelight:gradle-plugin:${sqldelightVer}"
sqldelightAndroid = "com.squareup.sqldelight:android-driver:${sqldelightVer}" sqldelightAndroid = "com.squareup.sqldelight:android-driver:${sqldelightVer}"
sqldelightInMemory = "com.squareup.sqldelight:sqlite-driver:${sqldelightVer}" sqldelightInMemory = "com.squareup.sqldelight:sqlite-driver:${sqldelightVer}"
ktorAndroid = "io.ktor:ktor-client-android:1.6.4" leakCanary = 'com.squareup.leakcanary:leakcanary-android:2.9.1'
ktorCore = "io.ktor:ktor-client-core:1.6.2"
ktorSerialization = "io.ktor:ktor-client-serialization:1.5.0" ktorAndroid = "io.ktor:ktor-client-android:${ktorVer}"
ktorLogging = "io.ktor:ktor-client-logging-jvm:1.6.2" ktorCore = "io.ktor:ktor-client-core:${ktorVer}"
ktorJava = "io.ktor:ktor-client-java:1.6.2" ktorSerialization = "io.ktor:ktor-client-serialization:${ktorVer}"
ktorJson = "io.ktor:ktor-serialization-kotlinx-json:${ktorVer}"
ktorLogging = "io.ktor:ktor-client-logging-jvm:${ktorVer}"
ktorJava = "io.ktor:ktor-client-java:${ktorVer}"
ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}"
coil = "io.coil-kt:coil-compose:2.2.0"
accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
junit = "junit:junit:4.13.2" junit = "junit:junit:4.13.2"
kluent = "org.amshove.kluent:kluent:1.68" kluent = "org.amshove.kluent:kluent:1.68"
mockk = 'io.mockk:mockk:1.12.7'
matrixOlm = "org.matrix.android:olm-sdk:3.2.10" matrixOlm = "org.matrix.android:olm-sdk:3.2.12"
} }
jitPack = new DependenciesContainer()
jitPack.with {
unifiedPush = "com.github.UnifiedPush:android-connector:2.0.1"
}
} }
class DependenciesContainer extends GroovyObjectSupport { class DependenciesContainer extends GroovyObjectSupport {

View File

@ -1,7 +1,7 @@
applyAndroidLibraryModule(project) applyAndroidComposeLibraryModule(project)
dependencies { dependencies {
implementation project(":core") implementation project(":core")
implementation("io.coil-kt:coil-compose:1.4.0") implementation Dependencies.mavenCentral.coil
implementation "com.google.accompanist:accompanist-systemuicontroller:0.24.1-alpha" implementation Dependencies.mavenCentral.accompanistSystemuicontroller
} }

View File

@ -0,0 +1,18 @@
package app.dapk.st.design.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun GenericEmpty() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Nothing to see here...")
}
}
}

View File

@ -0,0 +1,22 @@
package app.dapk.st.design.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun GenericError(retryAction: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Something went wrong...")
Button(onClick = { retryAction() }) {
Text("Retry")
}
}
}
}

View File

@ -10,12 +10,14 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.ExperimentalUnitApi import androidx.compose.ui.unit.ExperimentalUnitApi
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.TextUnitType
import coil.compose.rememberImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
@OptIn(ExperimentalUnitApi::class) @OptIn(ExperimentalUnitApi::class)
@ -25,7 +27,8 @@ fun BoxScope.CircleishAvatar(avatarUrl: String?, fallbackLabel: String, size: Dp
null -> { null -> {
val colors = SmallTalkTheme.extendedColors.getMissingImageColor(fallbackLabel) val colors = SmallTalkTheme.extendedColors.getMissingImageColor(fallbackLabel)
Box( Box(
Modifier.align(Alignment.Center) Modifier
.align(Alignment.Center)
.background(color = colors.first, shape = CircleShape) .background(color = colors.first, shape = CircleShape)
.size(size), .size(size),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@ -40,14 +43,16 @@ fun BoxScope.CircleishAvatar(avatarUrl: String?, fallbackLabel: String, size: Dp
} }
else -> { else -> {
Image( Image(
painter = rememberImagePainter( painter = rememberAsyncImagePainter(
data = avatarUrl, model = ImageRequest.Builder(LocalContext.current)
builder = { .data(avatarUrl)
transformations(CircleCropTransformation()) .transformations(CircleCropTransformation())
} .build()
), ),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(size).align(Alignment.Center) modifier = Modifier
.size(size)
.align(Alignment.Center)
) )
} }
} }
@ -56,7 +61,11 @@ fun BoxScope.CircleishAvatar(avatarUrl: String?, fallbackLabel: String, size: Dp
@Composable @Composable
fun MissingAvatarIcon(displayName: String, displayImageSize: Dp) { fun MissingAvatarIcon(displayName: String, displayImageSize: Dp) {
val colors = SmallTalkTheme.extendedColors.getMissingImageColor(displayName) val colors = SmallTalkTheme.extendedColors.getMissingImageColor(displayName)
Box(Modifier.background(color = colors.first, shape = CircleShape).size(displayImageSize), contentAlignment = Alignment.Center) { Box(
Modifier
.background(color = colors.first, shape = CircleShape)
.size(displayImageSize), contentAlignment = Alignment.Center
) {
Text( Text(
text = (displayName).first().toString().uppercase(), text = (displayName).first().toString().uppercase(),
color = colors.second color = colors.second
@ -67,11 +76,11 @@ fun MissingAvatarIcon(displayName: String, displayImageSize: Dp) {
@Composable @Composable
fun MessengerUrlIcon(avatarUrl: String, displayImageSize: Dp) { fun MessengerUrlIcon(avatarUrl: String, displayImageSize: Dp) {
Image( Image(
painter = rememberImagePainter( painter = rememberAsyncImagePainter(
data = avatarUrl, model = ImageRequest.Builder(LocalContext.current)
builder = { .data(avatarUrl)
transformations(CircleCropTransformation()) .transformations(CircleCropTransformation())
} .build()
), ),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(displayImageSize) modifier = Modifier.size(displayImageSize)

View File

@ -27,14 +27,13 @@ fun <T : Any> Spider(currentPage: SpiderPage<T>, onNavigate: (SpiderPage<out T>?
} }
Column { Column {
if (currentPage.hasToolbar) {
Toolbar( Toolbar(
onNavigate = navigateAndPopStack, onNavigate = navigateAndPopStack,
title = currentPage.label title = currentPage.label
) )
currentPage.parent?.let {
BackHandler(onBack = navigateAndPopStack)
} }
BackHandler(onBack = navigateAndPopStack)
computedWeb[currentPage.route]!!.invoke(currentPage.state) computedWeb[currentPage.route]!!.invoke(currentPage.state)
} }
} }

View File

@ -1,27 +1,38 @@
package app.dapk.st.design.components package app.dapk.st.design.components
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
fun ColumnScope.Toolbar(onNavigate: () -> Unit, title: String? = null, actions: @Composable RowScope.() -> Unit = {}) { fun Toolbar(
onNavigate: (() -> Unit)? = null,
title: String? = null,
offset: (Density.() -> IntOffset)? = null,
actions: @Composable RowScope.() -> Unit = {}
) {
val navigationIcon = foo(onNavigate)
TopAppBar( TopAppBar(
modifier = Modifier.height(72.dp), modifier = Modifier.height(72.dp).run {
backgroundColor = Color.Transparent, if (offset == null) {
navigationIcon = { this
IconButton(onClick = { onNavigate() }) { } else {
Icon(Icons.Default.ArrowBack, contentDescription = null) this.offset(offset)
} }
}, },
backgroundColor = MaterialTheme.colors.background,
navigationIcon = navigationIcon,
title = title?.let { title = title?.let {
{ Text(it, maxLines = 2) } { Text(it, maxLines = 2) }
} ?: {}, } ?: {},
@ -30,3 +41,17 @@ fun ColumnScope.Toolbar(onNavigate: () -> Unit, title: String? = null, actions:
) )
Divider(modifier = Modifier.fillMaxWidth(), color = Color.Black.copy(alpha = 0.2f), thickness = 0.5.dp) Divider(modifier = Modifier.fillMaxWidth(), color = Color.Black.copy(alpha = 0.2f), thickness = 0.5.dp)
} }
private fun foo(onNavigate: (() -> Unit)?): (@Composable () -> Unit)? {
return onNavigate?.let {
{ NavigationIcon(it) }
}
}
@Composable
private fun NavigationIcon(onNavigate: () -> Unit) {
IconButton(onClick = { onNavigate.invoke() }) {
Icon(Icons.Default.ArrowBack, contentDescription = null)
}
}

View File

@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@Composable @Composable
fun TextRow(title: String, content: String? = null, includeDivider: Boolean = true, onClick: (() -> Unit)? = null) { fun TextRow(title: String, content: String? = null, includeDivider: Boolean = true, onClick: (() -> Unit)? = null, body: @Composable () -> Unit = {}) {
val modifier = Modifier.padding(horizontal = 24.dp) val modifier = Modifier.padding(horizontal = 24.dp)
Column( Column(
Modifier Modifier
@ -31,6 +31,7 @@ fun TextRow(title: String, content: String? = null, includeDivider: Boolean = tr
Text(text = content, fontSize = 18.sp) Text(text = content, fontSize = 18.sp)
} }
} }
body()
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
} }
if (includeDivider) { if (includeDivider) {

View File

@ -0,0 +1,7 @@
applyAndroidComposeLibraryModule(project)
dependencies {
implementation project(":core")
implementation project(":features:navigator")
api project(":domains:android:core")
}

View File

@ -41,6 +41,9 @@ fun LifecycleEffect(onStart: () -> Unit = {}, onStop: () -> Unit = {}) {
when (event) { when (event) {
Lifecycle.Event.ON_START -> onStart() Lifecycle.Event.ON_START -> onStart()
Lifecycle.Event.ON_STOP -> onStop() Lifecycle.Event.ON_STOP -> onStop()
else -> {
// ignored
}
} }
} }

View File

@ -1,6 +1,6 @@
applyAndroidLibraryModule(project) plugins { id 'kotlin' }
dependencies { dependencies {
compileOnly project(":domains:android:stub")
implementation project(":core") implementation project(":core")
implementation project(":features:navigator")
} }

View File

@ -2,5 +2,6 @@ package app.dapk.st.core
import android.content.Context import android.content.Context
inline fun <reified T : ProvidableModule> Context.module() = inline fun <reified T : ProvidableModule> Context.module() = (this.applicationContext as ModuleProvider).provide(T::class)
(this.applicationContext as ModuleProvider).provide(T::class)
fun Context.resetModules() = (this.applicationContext as ModuleProvider).reset()

View File

@ -0,0 +1,19 @@
package app.dapk.st.core
import android.os.Build
fun <T> DeviceMeta.isAtLeastO(block: () -> T, fallback: () -> T = { throw IllegalStateException("not handled") }): T {
return if (this.apiVersion >= Build.VERSION_CODES.O) block() else fallback()
}
fun DeviceMeta.onAtLeastO(block: () -> Unit) {
if (this.apiVersion >= Build.VERSION_CODES.O) block()
}
inline fun <T> DeviceMeta.whenPOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.P, block, fallback)
inline fun <T> DeviceMeta.whenOOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.O, block, fallback)
inline fun <T> DeviceMeta.whenXOrHigher(version: Int, block: () -> T, fallback: () -> T): T {
return if (this.apiVersion >= version) block() else fallback()
}

View File

@ -2,5 +2,5 @@ applyAndroidLibraryModule(project)
dependencies { dependencies {
implementation project(":core") implementation project(":core")
implementation "io.coil-kt:coil:1.4.0" implementation Dependencies.mavenCentral.coil
} }

View File

@ -1,12 +1,13 @@
package app.dapk.st.imageloader package app.dapk.st.imageloader
import android.content.Context import android.content.Context
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.widget.ImageView import android.widget.ImageView
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.request.ImageResult
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import coil.transform.Transformation import coil.transform.Transformation
import coil.load as coilLoad import coil.load as coilLoad
@ -14,7 +15,6 @@ import coil.load as coilLoad
interface ImageLoader { interface ImageLoader {
suspend fun load(url: String, transformation: Transformation? = null): Drawable? suspend fun load(url: String, transformation: Transformation? = null): Drawable?
} }
interface IconLoader { interface IconLoader {
@ -31,19 +31,24 @@ class CachedIcons(private val imageLoader: ImageLoader) : IconLoader {
override suspend fun load(url: String): Icon? { override suspend fun load(url: String): Icon? {
return cache.getOrPut(url) { return cache.getOrPut(url) {
imageLoader.load(url, transformation = circleCrop)?.toBitmap()?.let { imageLoader.load(url, transformation = circleCrop)?.asBitmap()?.let {
Icon.createWithBitmap(it) Icon.createWithBitmap(it)
} }
} }
} }
} }
private fun Drawable.asBitmap() = (this as? BitmapDrawable)?.bitmap
internal class CoilImageLoader(private val context: Context) : ImageLoader { internal class CoilImageLoader(private val context: Context) : ImageLoader {
private val coil = context.imageLoader private val coil = context.imageLoader
override suspend fun load(url: String, transformation: Transformation?): Drawable? { override suspend fun load(url: String, transformation: Transformation?): Drawable? {
return internalLoad(url, transformation).drawable
}
private suspend fun internalLoad(url: String, transformation: Transformation?): ImageResult {
val request = ImageRequest.Builder(context) val request = ImageRequest.Builder(context)
.data(url) .data(url)
.let { .let {
@ -53,7 +58,7 @@ internal class CoilImageLoader(private val context: Context) : ImageLoader {
} }
} }
.build() .build()
return coil.execute(request).drawable return coil.execute(request)
} }
} }

View File

@ -1,8 +1,13 @@
applyAndroidLibraryModule(project) applyAndroidLibraryModule(project)
apply plugin: "org.jetbrains.kotlin.plugin.serialization"
dependencies { dependencies {
implementation project(':core') implementation project(':core')
implementation project(':domains:android:core')
implementation project(':domains:store')
implementation project(':matrix:services:push') implementation project(':matrix:services:push')
implementation platform('com.google.firebase:firebase-bom:29.0.3') implementation platform('com.google.firebase:firebase-bom:29.0.3')
implementation 'com.google.firebase:firebase-messaging' implementation 'com.google.firebase:firebase-messaging'
implementation Dependencies.mavenCentral.kotlinSerializationJson
implementation Dependencies.jitPack.unifiedPush
} }

View File

@ -1,2 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest package="app.dapk.st.push"/> <manifest package="app.dapk.st.push" xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name=".firebase.FirebasePushService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
<receiver android:exported="true" android:enabled="true" android:name=".unifiedpush.UnifiedPushMessageReceiver">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED"/>
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -0,0 +1,17 @@
package app.dapk.st.push
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
interface PushHandler {
fun onNewToken(payload: PushTokenPayload)
fun onMessageReceived(eventId: EventId?, roomId: RoomId?)
}
@Serializable
data class PushTokenPayload(
@SerialName("token") val token: String,
@SerialName("gateway_url") val gatewayUrl: String,
)

View File

@ -1,16 +1,40 @@
package app.dapk.st.push package app.dapk.st.push
import android.content.Context
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.matrix.push.PushService import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.domain.Preferences
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
import app.dapk.st.push.firebase.FirebasePushTokenRegistrar
import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar
class PushModule( class PushModule(
private val pushService: PushService,
private val errorTracker: ErrorTracker, private val errorTracker: ErrorTracker,
) { private val pushHandler: PushHandler,
private val context: Context,
private val dispatchers: CoroutineDispatchers,
private val preferences: Preferences,
) : ProvidableModule {
fun registerFirebasePushTokenUseCase() = RegisterFirebasePushTokenUseCase( private val registrars by unsafeLazy {
pushService, PushTokenRegistrars(
context,
FirebasePushTokenRegistrar(
errorTracker, errorTracker,
context,
pushHandler,
),
UnifiedPushRegistrar(context),
PushTokenRegistrarPreferences(preferences)
) )
}
fun pushTokenRegistrars() = registrars
fun pushTokenRegistrar(): PushTokenRegistrar = pushTokenRegistrars()
fun pushHandler() = pushHandler
fun dispatcher() = dispatchers
} }

View File

@ -0,0 +1,6 @@
package app.dapk.st.push
interface PushTokenRegistrar {
suspend fun registerCurrentToken()
fun unregister()
}

View File

@ -0,0 +1,76 @@
package app.dapk.st.push
import android.content.Context
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
import app.dapk.st.push.firebase.FirebasePushTokenRegistrar
import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar
import org.unifiedpush.android.connector.UnifiedPush
private val FIREBASE_OPTION = Registrar("Google - Firebase (FCM)")
private val NONE = Registrar("None")
class PushTokenRegistrars(
private val context: Context,
private val firebasePushTokenRegistrar: FirebasePushTokenRegistrar,
private val unifiedPushRegistrar: UnifiedPushRegistrar,
private val pushTokenStore: PushTokenRegistrarPreferences,
) : PushTokenRegistrar {
private var selection: Registrar? = null
fun options(): List<Registrar> {
return listOf(NONE, FIREBASE_OPTION) + UnifiedPush.getDistributors(context).map { Registrar(it) }
}
suspend fun currentSelection() = selection ?: (pushTokenStore.currentSelection()?.let { Registrar(it) } ?: FIREBASE_OPTION).also { selection = it }
suspend fun makeSelection(option: Registrar) {
selection = option
pushTokenStore.store(option.id)
when (option) {
NONE -> {
firebasePushTokenRegistrar.unregister()
unifiedPushRegistrar.unregister()
}
FIREBASE_OPTION -> {
unifiedPushRegistrar.unregister()
firebasePushTokenRegistrar.registerCurrentToken()
}
else -> {
firebasePushTokenRegistrar.unregister()
unifiedPushRegistrar.registerSelection(option)
}
}
}
override suspend fun registerCurrentToken() {
when (selection) {
FIREBASE_OPTION -> firebasePushTokenRegistrar.registerCurrentToken()
NONE -> {
// do nothing
}
else -> unifiedPushRegistrar.registerCurrentToken()
}
}
override fun unregister() {
when (selection) {
FIREBASE_OPTION -> firebasePushTokenRegistrar.unregister()
NONE -> {
runCatching {
firebasePushTokenRegistrar.unregister()
unifiedPushRegistrar.unregister()
}
}
else -> unifiedPushRegistrar.unregister()
}
}
}
@JvmInline
value class Registrar(val id: String)

View File

@ -1,27 +0,0 @@
package app.dapk.st.push
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.extensions.CrashScope
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.log
import app.dapk.st.matrix.push.PushService
import com.google.firebase.messaging.FirebaseMessaging
class RegisterFirebasePushTokenUseCase(
private val pushService: PushService,
override val errorTracker: ErrorTracker,
) : CrashScope {
suspend fun registerCurrentToken() {
kotlin.runCatching {
FirebaseMessaging.getInstance().token().also {
pushService.registerPush(it)
}
}
.trackFailure()
.onSuccess {
log(AppLogTag.PUSH, "registered new push token")
}
}
}

View File

@ -1,4 +1,4 @@
package app.dapk.st.push package app.dapk.st.push.firebase
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import kotlin.coroutines.resume import kotlin.coroutines.resume

View File

@ -0,0 +1,36 @@
package app.dapk.st.push.firebase
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.core.log
import app.dapk.st.core.module
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.push.PushModule
import app.dapk.st.push.PushTokenPayload
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify"
class FirebasePushService : FirebaseMessagingService() {
private val handler by unsafeLazy { module<PushModule>().pushHandler() }
override fun onNewToken(token: String) {
log(AppLogTag.PUSH, "FCM onNewToken")
handler.onNewToken(
PushTokenPayload(
token = token,
gatewayUrl = SYGNAL_GATEWAY,
)
)
}
override fun onMessageReceived(message: RemoteMessage) {
log(AppLogTag.PUSH, "FCM onMessage")
val eventId = message.data["event_id"]?.let { EventId(it) }
val roomId = message.data["room_id"]?.let { RoomId(it) }
handler.onMessageReceived(eventId, roomId)
}
}

View File

@ -0,0 +1,61 @@
package app.dapk.st.push.firebase
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.extensions.CrashScope
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.log
import app.dapk.st.push.PushHandler
import app.dapk.st.push.PushTokenPayload
import app.dapk.st.push.PushTokenRegistrar
import app.dapk.st.push.unifiedpush.UnifiedPushMessageReceiver
import com.google.firebase.messaging.FirebaseMessaging
private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify"
class FirebasePushTokenRegistrar(
override val errorTracker: ErrorTracker,
private val context: Context,
private val pushHandler: PushHandler,
) : PushTokenRegistrar, CrashScope {
override suspend fun registerCurrentToken() {
log(AppLogTag.PUSH, "FCM - register current token")
context.packageManager.setComponentEnabledSetting(
ComponentName(context, FirebasePushService::class.java),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP,
)
kotlin.runCatching {
FirebaseMessaging.getInstance().token().also {
pushHandler.onNewToken(
PushTokenPayload(
token = it,
gatewayUrl = SYGNAL_GATEWAY,
)
)
}
}
.trackFailure()
.onSuccess {
log(AppLogTag.PUSH, "registered new push token")
}
}
override fun unregister() {
log(AppLogTag.PUSH, "FCM - unregister")
FirebaseMessaging.getInstance().deleteToken()
context.stopService(Intent(context, FirebasePushService::class.java))
context.packageManager.setComponentEnabledSetting(
ComponentName(context, FirebasePushService::class.java),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP,
)
}
}

View File

@ -0,0 +1,77 @@
package app.dapk.st.push.unifiedpush
import android.content.Context
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.log
import app.dapk.st.core.module
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.push.PushModule
import app.dapk.st.push.PushTokenPayload
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.unifiedpush.android.connector.MessagingReceiver
import java.net.URL
private val json = Json { ignoreUnknownKeys = true }
private const val FALLBACK_UNIFIED_PUSH_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
class UnifiedPushMessageReceiver : MessagingReceiver() {
private val scope = CoroutineScope(SupervisorJob())
override fun onMessage(context: Context, message: ByteArray, instance: String) {
log(AppLogTag.PUSH, "UnifiedPush onMessage, $message")
val module = context.module<PushModule>()
val handler = module.pushHandler()
scope.launch {
withContext(module.dispatcher().io) {
val payload = json.decodeFromString(UnifiedPushMessagePayload.serializer(), String(message))
handler.onMessageReceived(payload.notification.eventId?.let { EventId(it) }, payload.notification.roomId?.let { RoomId(it) })
}
}
}
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
log(AppLogTag.PUSH, "UnifiedPush onNewEndpoint $endpoint")
val module = context.module<PushModule>()
val handler = module.pushHandler()
scope.launch {
withContext(module.dispatcher().io) {
val matrixEndpoint = URL(endpoint).let { URL("${it.protocol}://${it.host}/_matrix/push/v1/notify") }
val content = runCatching { matrixEndpoint.openStream().use { String(it.readBytes()) } }.getOrNull() ?: ""
val gatewayUrl = when {
content.contains("\"gateway\":\"matrix\"") -> matrixEndpoint.toString()
else -> FALLBACK_UNIFIED_PUSH_GATEWAY
}
handler.onNewToken(PushTokenPayload(token = endpoint, gatewayUrl = gatewayUrl))
}
}
}
override fun onRegistrationFailed(context: Context, instance: String) {
log(AppLogTag.PUSH, "UnifiedPush onRegistrationFailed")
}
override fun onUnregistered(context: Context, instance: String) {
log(AppLogTag.PUSH, "UnifiedPush onUnregistered")
}
@Serializable
private data class UnifiedPushMessagePayload(
@SerialName("notification") val notification: Notification,
) {
@Serializable
data class Notification(
@SerialName("event_id") val eventId: String?,
@SerialName("room_id") val roomId: String?,
)
}
}

View File

@ -0,0 +1,47 @@
package app.dapk.st.push.unifiedpush
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.log
import app.dapk.st.push.PushTokenRegistrar
import app.dapk.st.push.Registrar
import org.unifiedpush.android.connector.UnifiedPush
class UnifiedPushRegistrar(
private val context: Context,
) : PushTokenRegistrar {
fun registerSelection(registrar: Registrar) {
log(AppLogTag.PUSH, "UnifiedPush - register: $registrar")
UnifiedPush.saveDistributor(context, registrar.id)
registerApp()
}
override suspend fun registerCurrentToken() {
log(AppLogTag.PUSH, "UnifiedPush - register current token")
if (UnifiedPush.getDistributor(context).isNotEmpty()) {
registerApp()
}
}
private fun registerApp() {
context.packageManager.setComponentEnabledSetting(
ComponentName(context, UnifiedPushMessageReceiver::class.java),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP,
)
UnifiedPush.registerApp(context)
}
override fun unregister() {
UnifiedPush.unregisterApp(context)
context.packageManager.setComponentEnabledSetting(
ComponentName(context, UnifiedPushMessageReceiver::class.java),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP,
)
}
}

View File

@ -13,6 +13,8 @@ if (localProperties.exists()) {
dependencies { dependencies {
def androidVer = androidSdkVersion def androidVer = androidSdkVersion
api files("${properties.getProperty("sdk.dir")}/platforms/android-${androidVer}/android.jar")
kotlinFixtures(it) kotlinFixtures(it)
testFixturesImplementation testFixtures(project(":core")) testFixturesImplementation testFixtures(project(":core"))
testFixturesImplementation files("${properties.getProperty("sdk.dir")}/platforms/android-${androidVer}/android.jar") testFixturesImplementation files("${properties.getProperty("sdk.dir")}/platforms/android-${androidVer}/android.jar")

View File

@ -0,0 +1,8 @@
package fake
import android.content.Context
import io.mockk.mockk
class FakeContext {
val instance = mockk<Context>()
}

View File

@ -0,0 +1,22 @@
package fake
import android.app.Notification
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
class FakeInboxStyle {
private val _summary = slot<String>()
val instance = mockk<Notification.InboxStyle>()
val lines = mutableListOf<String>()
val summary: String
get() = _summary.captured
fun captureInteractions() {
every { instance.addLine(capture(lines)) } returns instance
every { instance.setSummaryText(capture(_summary)) } returns instance
}
}

View File

@ -0,0 +1,14 @@
package fake
import android.app.Notification
import android.app.Person
import io.mockk.every
import io.mockk.mockk
class FakeMessagingStyle {
var user: Person? = null
val instance = mockk<Notification.MessagingStyle>()
}
fun aFakeMessagingStyle() = FakeMessagingStyle().instance

View File

@ -0,0 +1,12 @@
package fake
import android.app.Notification
import io.mockk.mockk
class FakeNotification {
val instance = mockk<Notification>()
}
fun aFakeNotification() = FakeNotification().instance

View File

@ -0,0 +1,8 @@
package fake
import android.app.Notification
import io.mockk.mockk
class FakeNotificationBuilder {
val instance = mockk<Notification.Builder>(relaxed = true)
}

View File

@ -0,0 +1,14 @@
package fake
import android.app.NotificationManager
import io.mockk.mockk
import io.mockk.verify
class FakeNotificationManager {
val instance = mockk<NotificationManager>()
fun verifyCancelled(tag: String, id: Int) {
verify { instance.cancel(tag, id) }
}
}

View File

@ -0,0 +1,8 @@
package fake
import android.app.Person
import io.mockk.mockk
class FakePersonBuilder {
val instance = mockk<Person.Builder>(relaxed = true)
}

View File

@ -1,5 +1,5 @@
@file:JvmName("SnapshotStateKt") @file:JvmName("SnapshotStateKt")
@file:Suppress("UNUSED")
package androidx.compose.runtime package androidx.compose.runtime
import kotlin.reflect.KProperty import kotlin.reflect.KProperty

View File

@ -9,7 +9,7 @@ dependencies {
kotlinFixtures(it) kotlinFixtures(it)
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
testFixturesImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0' testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest
testFixturesImplementation testFixtures(project(":core")) testFixturesImplementation testFixtures(project(":core"))
testFixturesCompileOnly project(":domains:android:viewmodel-stub") testFixturesCompileOnly project(":domains:android:viewmodel-stub")
} }

View File

@ -6,6 +6,7 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import test.ExpectTest import test.ExpectTest
@Suppress("UNCHECKED_CAST")
class ViewModelTest { class ViewModelTest {
var instance: TestMutableState<Any>? = null var instance: TestMutableState<Any>? = null

View File

@ -8,15 +8,15 @@ interface TaskRunner {
suspend fun run(tasks: List<RunnableWorkTask>): List<TaskResult> suspend fun run(tasks: List<RunnableWorkTask>): List<TaskResult>
data class RunnableWorkTask( data class RunnableWorkTask(
val source: JobWorkItem, val source: JobWorkItem?,
val task: WorkTask val task: WorkTask
) )
sealed interface TaskResult { sealed interface TaskResult {
val source: JobWorkItem val source: JobWorkItem?
data class Success(override val source: JobWorkItem) : TaskResult data class Success(override val source: JobWorkItem?) : TaskResult
data class Failure(override val source: JobWorkItem, val canRetry: Boolean) : TaskResult data class Failure(override val source: JobWorkItem?, val canRetry: Boolean) : TaskResult
} }
} }

View File

@ -3,6 +3,7 @@ package app.dapk.st.work
import android.app.job.JobParameters import android.app.job.JobParameters
import android.app.job.JobService import android.app.job.JobService
import android.app.job.JobWorkItem import android.app.job.JobWorkItem
import android.os.Build
import app.dapk.st.core.extensions.Scope import app.dapk.st.core.extensions.Scope
import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.core.module import app.dapk.st.core.module
@ -24,11 +25,15 @@ class WorkAndroidService : JobService() {
when (it) { when (it) {
is TaskRunner.TaskResult.Failure -> { is TaskRunner.TaskResult.Failure -> {
if (!it.canRetry) { if (!it.canRetry) {
params.completeWork(it.source) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
params.completeWork(it.source!!)
}
} }
} }
is TaskRunner.TaskResult.Success -> { is TaskRunner.TaskResult.Success -> {
params.completeWork(it.source) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
params.completeWork(it.source!!)
}
} }
} }
} }
@ -40,6 +45,7 @@ class WorkAndroidService : JobService() {
} }
private fun JobParameters.collectAllTasks(): List<RunnableWorkTask> { private fun JobParameters.collectAllTasks(): List<RunnableWorkTask> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
var work: JobWorkItem? var work: JobWorkItem?
val tasks = mutableListOf<RunnableWorkTask>() val tasks = mutableListOf<RunnableWorkTask>()
do { do {
@ -58,6 +64,18 @@ class WorkAndroidService : JobService() {
} }
} while (work != null) } while (work != null)
return tasks return tasks
} else {
return listOf(
RunnableWorkTask(
source = null,
task = WorkTask(
jobId = this.jobId,
type = this.extras.getString("task-type")!!,
jsonPayload = this.extras.getString("task-payload")!!,
)
)
)
}
} }
override fun onStopJob(params: JobParameters): Boolean { override fun onStopJob(params: JobParameters): Boolean {

View File

@ -6,6 +6,7 @@ import android.app.job.JobWorkItem
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
internal class WorkSchedulingJobScheduler( internal class WorkSchedulingJobScheduler(
private val context: Context, private val context: Context,
@ -23,12 +24,17 @@ internal class WorkSchedulingJobScheduler(
.setRequiresDeviceIdle(false) .setRequiresDeviceIdle(false)
.build() .build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val item = JobWorkItem( val item = JobWorkItem(
Intent() Intent()
.putExtra("task-type", task.type) .putExtra("task-type", task.type)
.putExtra("task-payload", task.jsonPayload) .putExtra("task-payload", task.jsonPayload)
) )
jobScheduler.enqueue(job, item) jobScheduler.enqueue(job, item)
} else {
job.extras.putString("task-type", task.type)
job.extras.putString("task-payload", task.jsonPayload)
jobScheduler.schedule(job)
}
} }
} }

View File

@ -3,5 +3,5 @@ plugins {
} }
dependencies { dependencies {
compileOnly 'org.json:json:20211205' compileOnly 'org.json:json:20220320'
} }

View File

@ -1,5 +1,6 @@
package app.dapk.st.olm package app.dapk.st.olm
import app.dapk.st.core.Base64
import app.dapk.st.domain.OlmPersistence import app.dapk.st.domain.OlmPersistence
import app.dapk.st.domain.SerializedObject import app.dapk.st.domain.SerializedObject
import app.dapk.st.matrix.common.Curve25519 import app.dapk.st.matrix.common.Curve25519
@ -10,10 +11,10 @@ import org.matrix.olm.OlmInboundGroupSession
import org.matrix.olm.OlmOutboundGroupSession import org.matrix.olm.OlmOutboundGroupSession
import org.matrix.olm.OlmSession import org.matrix.olm.OlmSession
import java.io.* import java.io.*
import java.util.*
class OlmPersistenceWrapper( class OlmPersistenceWrapper(
private val olmPersistence: OlmPersistence, private val olmPersistence: OlmPersistence,
private val base64: Base64,
) : OlmStore { ) : OlmStore {
override suspend fun read(): OlmAccount? { override suspend fun read(): OlmAccount? {
@ -49,21 +50,21 @@ class OlmPersistenceWrapper(
override suspend fun readInbound(sessionId: SessionId): OlmInboundGroupSession? { override suspend fun readInbound(sessionId: SessionId): OlmInboundGroupSession? {
return olmPersistence.readInbound(sessionId)?.value?.deserialize() return olmPersistence.readInbound(sessionId)?.value?.deserialize()
} }
}
private fun <T : Serializable> T.serialize(): String { private fun <T : Serializable> T.serialize(): String {
val baos = ByteArrayOutputStream() val baos = ByteArrayOutputStream()
ObjectOutputStream(baos).use { ObjectOutputStream(baos).use {
it.writeObject(this) it.writeObject(this)
} }
return Base64.getEncoder().encode(baos.toByteArray()).toString(Charsets.UTF_8) return base64.encode(baos.toByteArray())
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun <T : Serializable> String.deserialize(): T { private fun <T : Serializable> String.deserialize(): T {
val decoded = Base64.getDecoder().decode(this) val decoded = base64.decode(this)
val baos = ByteArrayInputStream(decoded) val baos = ByteArrayInputStream(decoded)
return ObjectInputStream(baos).use { return ObjectInputStream(baos).use {
it.readObject() as T it.readObject() as T
} }
} }
}

View File

@ -254,7 +254,7 @@ class OlmWrapper(
return readSession.firstNotNullOfOrNull { (_, session) -> return readSession.firstNotNullOfOrNull { (_, session) ->
kotlin.runCatching { kotlin.runCatching {
when (type.toInt()) { when (type) {
OlmMessage.MESSAGE_TYPE_PRE_KEY -> { OlmMessage.MESSAGE_TYPE_PRE_KEY -> {
if (session.matchesInboundSession(body.value)) { if (session.matchesInboundSession(body.value)) {
logger.matrixLog(CRYPTO, "matched inbound session, attempting decrypt") logger.matrixLog(CRYPTO, "matched inbound session, attempting decrypt")
@ -270,6 +270,8 @@ class OlmWrapper(
session.decryptMessage(olmMessage)?.let { JsonString(it) }?.also { session.decryptMessage(olmMessage)?.let { JsonString(it) }?.also {
logger.crypto("alt flow identity: $senderKey : ${session.sessionIdentifier()}") logger.crypto("alt flow identity: $senderKey : ${session.sessionIdentifier()}")
olmStore.persistSession(senderKey, SessionId(session.sessionIdentifier()), session) olmStore.persistSession(senderKey, SessionId(session.sessionIdentifier()), session)
}.also {
session.releaseSession()
} }
} }
} }
@ -287,6 +289,8 @@ class OlmWrapper(
}.ifNull { }.ifNull {
logger.matrixLog(CRYPTO, "failed to decrypt olm session") logger.matrixLog(CRYPTO, "failed to decrypt olm session")
DecryptionResult.Failed(errors.joinToString { it.message ?: "N/A" }) DecryptionResult.Failed(errors.joinToString { it.message ?: "N/A" })
}.also {
readSession.forEach { it.second.releaseSession() }
} }
} }
@ -310,7 +314,9 @@ class OlmWrapper(
errorTracker.track(it) errorTracker.track(it)
DecryptionResult.Failed(it.message ?: "Unknown") DecryptionResult.Failed(it.message ?: "Unknown")
} }
) ).also {
megolmSession.releaseSession()
}
} }
} }
} }

View File

@ -0,0 +1,19 @@
package app.dapk.st.domain
class ApplicationPreferences(
private val preferences: Preferences,
) {
suspend fun readVersion(): ApplicationVersion? {
return preferences.readString("version")?.let { ApplicationVersion(it.toInt()) }
}
suspend fun setVersion(version: ApplicationVersion) {
return preferences.store("version", version.value.toString())
}
}
@JvmInline
value class ApplicationVersion(val value: Int)

View File

@ -35,4 +35,12 @@ class MemberPersistence(
.map { Json.decodeFromString(RoomMember.serializer(), it) } .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

@ -7,6 +7,7 @@ import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.domain.eventlog.EventLogPersistence import app.dapk.st.domain.eventlog.EventLogPersistence
import app.dapk.st.domain.localecho.LocalEchoPersistence import app.dapk.st.domain.localecho.LocalEchoPersistence
import app.dapk.st.domain.profile.ProfilePersistence import app.dapk.st.domain.profile.ProfilePersistence
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
import app.dapk.st.domain.sync.OverviewPersistence import app.dapk.st.domain.sync.OverviewPersistence
import app.dapk.st.domain.sync.RoomPersistence import app.dapk.st.domain.sync.RoomPersistence
import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.CredentialsStore
@ -34,6 +35,10 @@ class StoreModule(
fun filterStore(): FilterStore = FilterPreferences(preferences) fun filterStore(): FilterStore = FilterPreferences(preferences)
val localEchoStore: LocalEchoStore by unsafeLazy { LocalEchoPersistence(errorTracker, database) } val localEchoStore: LocalEchoStore by unsafeLazy { LocalEchoPersistence(errorTracker, database) }
fun pushStore() = PushTokenRegistrarPreferences(preferences)
fun applicationStore() = ApplicationPreferences(preferences)
fun olmStore() = OlmPersistence(database, credentialsStore()) fun olmStore() = OlmPersistence(database, credentialsStore())
fun knownDevicesStore() = DevicePersistence(database, KnownDevicesCache(), coroutineDispatchers) fun knownDevicesStore() = DevicePersistence(database, KnownDevicesCache(), coroutineDispatchers)

View File

@ -1,5 +1,7 @@
package app.dapk.st.domain package app.dapk.st.domain
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.log
import app.dapk.st.matrix.common.SyncToken import app.dapk.st.matrix.common.SyncToken
import app.dapk.st.matrix.sync.SyncStore import app.dapk.st.matrix.sync.SyncStore
import app.dapk.st.matrix.sync.SyncStore.SyncKey import app.dapk.st.matrix.sync.SyncStore.SyncKey

View File

@ -33,11 +33,13 @@ class LocalEchoPersistence(
inMemoryEchos.value = echos.groupBy { inMemoryEchos.value = echos.groupBy {
when (val message = it.message) { when (val message = it.message) {
is MessageService.Message.TextMessage -> message.roomId is MessageService.Message.TextMessage -> message.roomId
is MessageService.Message.ImageMessage -> message.roomId
} }
}.mapValues { }.mapValues {
it.value.associateBy { it.value.associateBy {
when (val message = it.message) { when (val message = it.message) {
is MessageService.Message.TextMessage -> message.localId is MessageService.Message.TextMessage -> message.localId
is MessageService.Message.ImageMessage -> message.localId
} }
} }
} }
@ -56,6 +58,7 @@ class LocalEchoPersistence(
database.transaction { database.transaction {
when (message) { when (message) {
is MessageService.Message.TextMessage -> database.localEchoQueries.delete(message.localId) is MessageService.Message.TextMessage -> database.localEchoQueries.delete(message.localId)
is MessageService.Message.ImageMessage -> database.localEchoQueries.delete(message.localId)
} }
} }
} catch (error: Exception) { } catch (error: Exception) {
@ -84,6 +87,14 @@ class LocalEchoPersistence(
Json.encodeToString(MessageService.LocalEcho.serializer(), localEcho) 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)
)
)
} }
} }
} }

View File

@ -0,0 +1,16 @@
package app.dapk.st.domain.push
import app.dapk.st.domain.Preferences
private const val SELECTION_KEY = "push_token_selection"
class PushTokenRegistrarPreferences(
private val preferences: Preferences,
) {
suspend fun currentSelection() = preferences.readString(SELECTION_KEY)
suspend fun store(registrar: String) {
preferences.store(SELECTION_KEY, registrar)
}
}

View File

@ -11,10 +11,8 @@ import app.dapk.st.matrix.sync.RoomInvite
import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomOverview
import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.asFlow
import com.squareup.sqldelight.runtime.coroutines.mapToList import com.squareup.sqldelight.runtime.coroutines.mapToList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
private val json = Json private val json = Json
@ -31,11 +29,22 @@ internal class OverviewPersistence(
.map { it.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } } .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>) { override suspend fun persistInvites(invites: List<RoomInvite>) {
dispatchers.withIoContext { dispatchers.withIoContext {
database.inviteStateQueries.transaction { database.inviteStateQueries.transaction {
invites.forEach { invites.forEach {
database.inviteStateQueries.insert(it.roomId.value) database.inviteStateQueries.insert(it.roomId.value, json.encodeToString(RoomInvite.serializer(), it))
} }
} }
} }
@ -45,7 +54,15 @@ internal class OverviewPersistence(
return database.inviteStateQueries.selectAll() return database.inviteStateQueries.selectAll()
.asFlow() .asFlow()
.mapToList() .mapToList()
.map { it.map { RoomInvite(RoomId(it)) } } .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) { override suspend fun persist(overviewState: OverviewState) {
@ -59,7 +76,7 @@ internal class OverviewPersistence(
} }
override suspend fun retrieve(): OverviewState { override suspend fun retrieve(): OverviewState {
return withContext(Dispatchers.IO) { return dispatchers.withIoContext {
val overviews = database.overviewStateQueries.selectAll().executeAsList() val overviews = database.overviewStateQueries.selectAll().executeAsList()
overviews.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } overviews.map { json.decodeFromString(RoomOverview.serializer(), it.blob) }
} }

View File

@ -37,6 +37,13 @@ internal class RoomPersistence(
} }
} }
override suspend fun remove(rooms: List<RoomId>) {
coroutineDispatchers
database.roomEventQueries.transaction {
rooms.forEach { database.roomEventQueries.remove(it.value) }
}
}
override fun latest(roomId: RoomId): Flow<RoomState> { override fun latest(roomId: RoomId): Flow<RoomState> {
val overviewFlow = database.overviewStateQueries.selectRoom(roomId.value).asFlow().mapToOneNotNull().map { val overviewFlow = database.overviewStateQueries.selectRoom(roomId.value).asFlow().mapToOneNotNull().map {
json.decodeFromString(RoomOverview.serializer(), it) json.decodeFromString(RoomOverview.serializer(), it)
@ -75,7 +82,7 @@ internal class RoomPersistence(
} }
} }
override suspend fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> { override fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
return database.roomEventQueries.selectAllUnread() return database.roomEventQueries.selectAllUnread()
.asFlow() .asFlow()
.mapToList() .mapToList()
@ -91,7 +98,7 @@ internal class RoomPersistence(
} }
} }
override suspend fun observeUnreadCountById(): Flow<Map<RoomId, Int>> { override fun observeUnreadCountById(): Flow<Map<RoomId, Int>> {
return database.roomEventQueries.selectAllUnread() return database.roomEventQueries.selectAllUnread()
.asFlow() .asFlow()
.mapToList() .mapToList()
@ -107,7 +114,7 @@ internal class RoomPersistence(
} }
} }
override suspend fun observeEvent(eventId: EventId): Flow<EventId> { override fun observeEvent(eventId: EventId): Flow<EventId> {
return database.roomEventQueries.selectEvent(event_id = eventId.value) return database.roomEventQueries.selectEvent(event_id = eventId.value)
.asFlow() .asFlow()
.mapToOneNotNull() .mapToOneNotNull()

View File

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

View File

@ -19,3 +19,7 @@ WHERE room_id = ?;
insert: insert:
INSERT OR REPLACE INTO dbOverviewState(room_id, latest_activity_timestamp_utc, read_marker, blob) INSERT OR REPLACE INTO dbOverviewState(room_id, latest_activity_timestamp_utc, read_marker, blob)
VALUES (?, ?, ?, ?); VALUES (?, ?, ?, ?);
remove:
DELETE FROM dbOverviewState
WHERE room_id = ?;

View File

@ -30,4 +30,10 @@ WHERE event_id = ?;
selectAllUnread: selectAllUnread:
SELECT dbRoomEvent.blob, dbRoomEvent.room_id SELECT dbRoomEvent.blob, dbRoomEvent.room_id
FROM dbUnreadEvent FROM dbUnreadEvent
INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id; INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id
ORDER BY dbRoomEvent.timestamp_utc DESC
LIMIT 100;
remove:
DELETE FROM dbRoomEvent
WHERE room_id = ?;

View File

@ -10,6 +10,13 @@ SELECT blob
FROM dbRoomMember FROM dbRoomMember
WHERE room_id = ? AND user_id IN ?; WHERE room_id = ? AND user_id IN ?;
selectMembersByRoom:
SELECT blob
FROM dbRoomMember
WHERE room_id = ?
LIMIT ?;
insert: insert:
INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob) INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob)
VALUES (?, ?, ?); VALUES (?, ?, ?);

View File

@ -1,13 +1,23 @@
applyAndroidLibraryModule(project) applyAndroidComposeLibraryModule(project)
dependencies { dependencies {
implementation project(":matrix:services:sync") implementation project(":matrix:services:sync")
implementation project(":matrix:services:message") implementation project(":matrix:services:message")
implementation project(":matrix:services:room") implementation project(":matrix:services:room")
implementation project(":domains:android:core") implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel") implementation project(":domains:android:viewmodel")
implementation project(":features:messenger") implementation project(":features:messenger")
implementation project(":core") implementation project(":core")
implementation project(":design-library") implementation project(":design-library")
implementation("io.coil-kt:coil-compose:1.4.0") implementation Dependencies.mavenCentral.coil
kotlinTest(it)
androidImportFixturesWorkaround(project, project(":matrix:services:sync"))
androidImportFixturesWorkaround(project, project(":matrix:services:message"))
androidImportFixturesWorkaround(project, project(":matrix:common"))
androidImportFixturesWorkaround(project, project(":core"))
androidImportFixturesWorkaround(project, project(":domains:store"))
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
} }

View File

@ -10,29 +10,34 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import app.dapk.st.core.LifecycleEffect import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.StartObserving import app.dapk.st.core.StartObserving
import app.dapk.st.core.components.CenteredLoading import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.CircleishAvatar
import app.dapk.st.design.components.GenericEmpty
import app.dapk.st.design.components.GenericError
import app.dapk.st.design.components.Toolbar
import app.dapk.st.directory.DirectoryEvent.OpenDownloadUrl import app.dapk.st.directory.DirectoryEvent.OpenDownloadUrl
import app.dapk.st.directory.DirectoryScreenState.Content import app.dapk.st.directory.DirectoryScreenState.Content
import app.dapk.st.directory.DirectoryScreenState.EmptyLoading import app.dapk.st.directory.DirectoryScreenState.EmptyLoading
import app.dapk.st.design.components.CircleishAvatar
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomOverview
import app.dapk.st.matrix.sync.SyncService import app.dapk.st.matrix.sync.SyncService
@ -44,36 +49,56 @@ import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt
@Composable @Composable
fun DirectoryScreen(directoryViewModel: DirectoryViewModel) { fun DirectoryScreen(directoryViewModel: DirectoryViewModel) {
val state = directoryViewModel.state val state = directoryViewModel.state
directoryViewModel.ObserveEvents()
val listState: LazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = 0,
)
val toolbarHeight = 72.dp
val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() }
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
directoryViewModel.ObserveEvents(listState, toolbarOffsetHeightPx)
LifecycleEffect( LifecycleEffect(
onStart = { directoryViewModel.start() }, onStart = { directoryViewModel.start() },
onStop = { directoryViewModel.stop() } onStop = { directoryViewModel.stop() }
) )
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
return Offset.Zero
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
when (state) { when (state) {
is Content -> {
Content(state)
}
EmptyLoading -> CenteredLoading() EmptyLoading -> CenteredLoading()
is Error -> { DirectoryScreenState.Empty -> GenericEmpty()
Box(contentAlignment = Alignment.Center) { is Error -> GenericError {
Column(horizontalAlignment = Alignment.CenterHorizontally) { // TODO
Text("Something went wrong...")
Button(onClick = {}) {
Text("Retry")
}
}
} }
is Content -> Content(listState, state)
} }
Toolbar(title = "Messages", offset = { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) })
} }
} }
@Composable @Composable
private fun DirectoryViewModel.ObserveEvents() { private fun DirectoryViewModel.ObserveEvents(listState: LazyListState, toolbarPosition: MutableState<Float>) {
val context = LocalContext.current val context = LocalContext.current
StartObserving { StartObserving {
this@ObserveEvents.events.launch { this@ObserveEvents.events.launch {
@ -81,21 +106,24 @@ private fun DirectoryViewModel.ObserveEvents() {
is OpenDownloadUrl -> { is OpenDownloadUrl -> {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.url))) context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.url)))
} }
DirectoryEvent.ScrollToTop -> {
toolbarPosition.value = 0f
listState.scrollToItem(0)
}
} }
} }
} }
} }
val clock = Clock.systemUTC()
@Composable @Composable
private fun Content(state: Content) { private fun Content(listState: LazyListState, state: Content) {
val context = LocalContext.current val context = LocalContext.current
val navigateToRoom = { roomId: RoomId -> val navigateToRoom = { roomId: RoomId ->
context.startActivity(MessengerActivity.newInstance(context, roomId)) context.startActivity(MessengerActivity.newInstance(context, roomId))
} }
val clock = Clock.systemUTC()
val listState: LazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = 0,
)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(key1 = state.overviewState) { LaunchedEffect(key1 = state.overviewState) {
@ -103,7 +131,7 @@ private fun Content(state: Content) {
scope.launch { listState.scrollToItem(0) } scope.launch { listState.scrollToItem(0) }
} }
} }
LazyColumn(Modifier.fillMaxSize(), state = listState) { LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(top = 72.dp)) {
items( items(
items = state.overviewState, items = state.overviewState,
key = { it.overview.roomId.value }, key = { it.overview.roomId.value },
@ -119,7 +147,11 @@ private fun DirectoryItem(room: RoomFoo, onClick: (RoomId) -> Unit, clock: Clock
val roomName = overview.roomName ?: "Empty room" val roomName = overview.roomName ?: "Empty room"
val hasUnread = room.unreadCount.value > 0 val hasUnread = room.unreadCount.value > 0
Box(Modifier.height(IntrinsicSize.Min).fillMaxWidth().clickable { Box(
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth()
.clickable {
onClick(overview.roomId) onClick(overview.roomId)
}) { }) {
Row(Modifier.padding(20.dp)) { Row(Modifier.padding(20.dp)) {
@ -164,13 +196,24 @@ private fun DirectoryItem(room: RoomFoo, onClick: (RoomId) -> Unit, clock: Clock
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Box(Modifier.align(Alignment.CenterVertically)) { Box(Modifier.align(Alignment.CenterVertically)) {
Box( Box(
Modifier.align(Alignment.Center).background(color = MaterialTheme.colors.primary, shape = CircleShape).size(22.dp), Modifier
.align(Alignment.Center)
.background(color = MaterialTheme.colors.primary, shape = CircleShape)
.size(22.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val unreadTextSize = when (room.unreadCount.value > 99) {
true -> 9.sp
false -> 10.sp
}
val unreadLabelContent = when {
room.unreadCount.value > 99 -> "99+"
else -> room.unreadCount.value.toString()
}
Text( Text(
fontSize = 10.sp, fontSize = unreadTextSize,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
text = room.unreadCount.value.toString(), text = unreadLabelContent,
color = MaterialTheme.colors.onPrimary color = MaterialTheme.colors.onPrimary
) )
} }

View File

@ -3,6 +3,7 @@ package app.dapk.st.directory
sealed interface DirectoryScreenState { sealed interface DirectoryScreenState {
object EmptyLoading : DirectoryScreenState object EmptyLoading : DirectoryScreenState
object Empty : DirectoryScreenState
data class Content( data class Content(
val overviewState: DirectoryState, val overviewState: DirectoryState,
) : DirectoryScreenState ) : DirectoryScreenState
@ -10,5 +11,6 @@ sealed interface DirectoryScreenState {
sealed interface DirectoryEvent { sealed interface DirectoryEvent {
data class OpenDownloadUrl(val url: String) : DirectoryEvent data class OpenDownloadUrl(val url: String) : DirectoryEvent
object ScrollToTop : DirectoryEvent
} }

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