Merge pull request #103 from ouchadam/release-candidate
[Auto] Release Candidate
This commit is contained in:
commit
c4b3729908
|
@ -0,0 +1,7 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gradle
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 3
|
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
|
@ -17,19 +17,11 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: '11'
|
||||
- uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: Assemble debug variant
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -17,29 +17,26 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: '11'
|
||||
- uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: Create pip requirements
|
||||
run: |
|
||||
echo "matrix-synapse==v1.60.0" > requirements.txt
|
||||
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
cache: 'pip'
|
||||
|
||||
- name: Start synapse server
|
||||
run: |
|
||||
pip install matrix-synapse
|
||||
curl -sL https://raw.githubusercontent.com/matrix-org/synapse/develop/demo/start.sh \
|
||||
pip install -r requirements.txt
|
||||
curl -sL https://gist.githubusercontent.com/ouchadam/e3ad09ec382bd91a66d88ab575ea7c31/raw/run.sh \
|
||||
| bash -s -- --no-rate-limit
|
||||
|
||||
- name: Run all unit tests
|
||||
|
|
|
@ -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
|
|
@ -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>
|
24
README.md
24
README.md
|
@ -1,13 +1,18 @@
|
|||
# SmallTalk [![Assemble](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml/badge.svg)](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml) [![codecov](https://codecov.io/gh/ouchadam/small-talk/branch/main/graph/badge.svg?token=ETFSLZ9FCI)](https://codecov.io/gh/ouchadam/small-talk) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
|
||||
# SmallTalk [![Assemble](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml/badge.svg)](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml) [![codecov](https://codecov.io/gh/ouchadam/small-talk/branch/main/graph/badge.svg?token=ETFSLZ9FCI)](https://codecov.io/gh/ouchadam/small-talk) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ![](https://img.shields.io/github/v/release/ouchadam/small-talk?include_prereleases) ![](https://img.shields.io/badge/%5Bmatrix%5D%20-%23small--talk%3Aiswell.cool-blueviolet)
|
||||
|
||||
`SmallTalk` is a minimal, modern, friends and family focused Android messenger. Heavily inspired by Whatsapp and Signal, powered by Matrix.
|
||||
|
||||
|
||||
![header](https://github.com/ouchadam/small-talk/blob/main/.github/readme/header.png?raw=true)
|
||||
[<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.
|
||||
- Bare-bones feature set.
|
||||
|
||||
|
@ -15,16 +20,17 @@ Project mantra
|
|||
|
||||
---
|
||||
|
||||
#### Feature list
|
||||
### Feature list
|
||||
|
||||
- Login with username/password (home servers must serve `${domain}.well-known/matrix/client`)
|
||||
- Login with Matrix ID/Password
|
||||
- Combined Room and DM interface
|
||||
- End to end encryption
|
||||
- Message bubbles, supporting text, replies and edits
|
||||
- Push notifications (DMs always notify, Rooms notify once)
|
||||
- Importing of E2E room keys from Element clients
|
||||
- [UnifiedPush](https://unifiedpush.org/)
|
||||
|
||||
#### Planned
|
||||
### Planned
|
||||
|
||||
- Device verification (technically supported but has no UI)
|
||||
- Invitations (technically supported but has no UI)
|
||||
|
@ -48,3 +54,7 @@ Project mantra
|
|||
- Heavily optimised build script, clean _cacheless_ builds are sub 10 seconds with a warmed up gradle daemon.
|
||||
- Avoids code generation where possible in favour of build speed, this mainly means manual DI.
|
||||
- A pure kotlin test harness to allow for critical flow assertions [Smoke Tests](https://github.com/ouchadam/small-talk/blob/main/test-harness/src/test/kotlin/SmokeTest.kt), currently Linux x86-64 only.
|
||||
|
||||
---
|
||||
|
||||
#### Join the conversation @ https://matrix.to/#/#small-talk:iswell.cool
|
||||
|
|
|
@ -10,9 +10,20 @@ android {
|
|||
ndkVersion "25.0.8141415"
|
||||
defaultConfig {
|
||||
applicationId "app.dapk.st"
|
||||
versionCode 2
|
||||
versionName "0.0.1-alpha1"
|
||||
resConfigs "en"
|
||||
def versionJson = new groovy.json.JsonSlurper().parseText(rootProject.file('version.json').text)
|
||||
versionCode versionJson.code
|
||||
versionName versionJson.name
|
||||
|
||||
if (isDebugBuild) {
|
||||
resConfigs "en", "xxhdpi"
|
||||
variantFilter { variant ->
|
||||
if (variant.buildType.name == "release") {
|
||||
setIgnore(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resConfigs "en"
|
||||
}
|
||||
}
|
||||
|
||||
bundle {
|
||||
|
@ -23,6 +34,7 @@ android {
|
|||
|
||||
buildTypes {
|
||||
debug {
|
||||
versionNameSuffix = " [debug]"
|
||||
matchingFallbacks = ['release']
|
||||
signingConfig.storeFile rootProject.file("tools/debug.keystore")
|
||||
}
|
||||
|
@ -33,16 +45,24 @@ android {
|
|||
'proguard/app.pro',
|
||||
"proguard/serializationx.pro",
|
||||
"proguard/olm.pro"
|
||||
|
||||
// actual releases are signed with a different config
|
||||
signingConfig = buildTypes.debug.signingConfig
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources.excludes += "DebugProbesKt.bin"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring Dependencies.google.jdkLibs
|
||||
|
||||
implementation project(":features:home")
|
||||
implementation project(":features:directory")
|
||||
implementation project(":features:login")
|
||||
|
@ -51,8 +71,10 @@ dependencies {
|
|||
implementation project(":features:messenger")
|
||||
implementation project(":features:profile")
|
||||
implementation project(":features:navigator")
|
||||
implementation project(":features:share-entry")
|
||||
|
||||
implementation project(':domains:store')
|
||||
implementation project(":domains:android:compose-core")
|
||||
implementation project(":domains:android:core")
|
||||
implementation project(":domains:android:tracking")
|
||||
implementation project(":domains:android:push")
|
||||
|
@ -79,5 +101,5 @@ dependencies {
|
|||
implementation Dependencies.mavenCentral.matrixOlm
|
||||
|
||||
implementation Dependencies.mavenCentral.kotlinSerializationJson
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
|
||||
debugImplementation Dependencies.mavenCentral.leakCanary
|
||||
}
|
||||
|
|
|
@ -20,6 +20,11 @@
|
|||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
</application>
|
||||
|
|
|
@ -33,7 +33,7 @@ internal class SharedPreferencesDelegate(
|
|||
|
||||
override suspend fun clear() {
|
||||
coroutineDispatchers.withIoContext {
|
||||
preferences.edit().clear().apply()
|
||||
preferences.edit().clear().commit()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,26 +1,29 @@
|
|||
package app.dapk.st
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import app.dapk.st.core.CoreAndroidModule
|
||||
import app.dapk.st.core.ModuleProvider
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.core.attachAppLogger
|
||||
import app.dapk.st.core.extensions.ResettableUnsafeLazy
|
||||
import app.dapk.st.core.extensions.Scope
|
||||
import app.dapk.st.core.extensions.unsafeLazy
|
||||
import app.dapk.st.directory.DirectoryModule
|
||||
import app.dapk.st.messenger.MessengerModule
|
||||
import app.dapk.st.domain.StoreModule
|
||||
import app.dapk.st.graph.AppModule
|
||||
import app.dapk.st.graph.FeatureModules
|
||||
import app.dapk.st.home.HomeModule
|
||||
import app.dapk.st.login.LoginModule
|
||||
import app.dapk.st.messenger.MessengerModule
|
||||
import app.dapk.st.notifications.NotificationsModule
|
||||
import app.dapk.st.profile.ProfileModule
|
||||
import app.dapk.st.push.firebase.FirebasePushService
|
||||
import app.dapk.st.push.PushModule
|
||||
import app.dapk.st.settings.SettingsModule
|
||||
import app.dapk.st.share.ShareEntryModule
|
||||
import app.dapk.st.work.TaskRunnerModule
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class SmallTalkApplication : Application(), ModuleProvider {
|
||||
|
@ -28,8 +31,10 @@ class SmallTalkApplication : Application(), ModuleProvider {
|
|||
private val appLogger: (String, String) -> Unit = { tag, message -> _appLogger?.invoke(tag, message) }
|
||||
private var _appLogger: ((String, String) -> Unit)? = null
|
||||
|
||||
private val appModule: AppModule by unsafeLazy { AppModule(this, appLogger) }
|
||||
private val featureModules: FeatureModules by unsafeLazy { appModule.featureModules }
|
||||
private val lazyAppModule = ResettableUnsafeLazy { AppModule(this, appLogger) }
|
||||
private val lazyFeatureModules = ResettableUnsafeLazy { appModule.featureModules }
|
||||
private val appModule by lazyAppModule
|
||||
private val featureModules by lazyFeatureModules
|
||||
private val applicationScope = Scope(Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
|
@ -40,15 +45,17 @@ class SmallTalkApplication : Application(), ModuleProvider {
|
|||
|
||||
val logger: (String, String) -> Unit = { tag, message ->
|
||||
Log.e(tag, message)
|
||||
GlobalScope.launch {
|
||||
eventLogStore.insert(tag, message)
|
||||
}
|
||||
applicationScope.launch { eventLogStore.insert(tag, message) }
|
||||
}
|
||||
attachAppLogger(logger)
|
||||
_appLogger = logger
|
||||
|
||||
onApplicationLaunch(notificationsModule, storeModule)
|
||||
}
|
||||
|
||||
private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) {
|
||||
applicationScope.launch {
|
||||
notificationsModule.firebasePushTokenUseCase().registerCurrentToken()
|
||||
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
|
||||
storeModule.localEchoStore.preload()
|
||||
}
|
||||
|
||||
|
@ -67,10 +74,24 @@ class SmallTalkApplication : Application(), ModuleProvider {
|
|||
SettingsModule::class -> featureModules.settingsModule
|
||||
ProfileModule::class -> featureModules.profileModule
|
||||
NotificationsModule::class -> featureModules.notificationsModule
|
||||
PushModule::class -> featureModules.pushModule
|
||||
MessengerModule::class -> featureModules.messengerModule
|
||||
TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule
|
||||
CoreAndroidModule::class -> appModule.coreAndroidModule
|
||||
ShareEntryModule::class -> featureModules.shareEntryModule
|
||||
else -> throw IllegalArgumentException("Unknown: $klass")
|
||||
} as T
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
featureModules.pushModule.pushTokenRegistrar().unregister()
|
||||
appModule.coroutineDispatchers.io.cancel()
|
||||
applicationScope.cancel()
|
||||
lazyAppModule.reset()
|
||||
lazyFeatureModules.reset()
|
||||
|
||||
val notificationsModule = featureModules.notificationsModule
|
||||
val storeModule = appModule.storeModule.value
|
||||
onApplicationLaunch(notificationsModule, storeModule)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package app.dapk.st.graph
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.app.PendingIntent
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import app.dapk.db.DapkDb
|
||||
import app.dapk.st.BuildConfig
|
||||
import app.dapk.st.SharedPreferencesDelegate
|
||||
import app.dapk.st.core.BuildMeta
|
||||
import app.dapk.st.core.CoreAndroidModule
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.SingletonFlows
|
||||
import app.dapk.st.core.*
|
||||
import app.dapk.st.core.extensions.ErrorTracker
|
||||
import app.dapk.st.core.extensions.unsafeLazy
|
||||
import app.dapk.st.directory.DirectoryModule
|
||||
|
@ -20,8 +21,6 @@ import app.dapk.st.home.MainActivity
|
|||
import app.dapk.st.imageloader.ImageLoaderModule
|
||||
import app.dapk.st.login.LoginModule
|
||||
import app.dapk.st.matrix.MatrixClient
|
||||
import app.dapk.st.matrix.MatrixTaskRunner
|
||||
import app.dapk.st.matrix.MatrixTaskRunner.MatrixTask
|
||||
import app.dapk.st.matrix.auth.authService
|
||||
import app.dapk.st.matrix.auth.installAuthService
|
||||
import app.dapk.st.matrix.common.*
|
||||
|
@ -34,7 +33,11 @@ import app.dapk.st.matrix.device.installEncryptionService
|
|||
import app.dapk.st.matrix.device.internal.ApiMessage
|
||||
import app.dapk.st.matrix.http.MatrixHttpClient
|
||||
import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory
|
||||
import app.dapk.st.matrix.message.*
|
||||
import app.dapk.st.matrix.message.MessageEncrypter
|
||||
import app.dapk.st.matrix.message.MessageService
|
||||
import app.dapk.st.matrix.message.installMessageService
|
||||
import app.dapk.st.matrix.message.internal.ImageContentReader
|
||||
import app.dapk.st.matrix.message.messageService
|
||||
import app.dapk.st.matrix.push.installPushService
|
||||
import app.dapk.st.matrix.push.pushService
|
||||
import app.dapk.st.matrix.room.*
|
||||
|
@ -44,6 +47,8 @@ import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
|
|||
import app.dapk.st.messenger.MessengerActivity
|
||||
import app.dapk.st.messenger.MessengerModule
|
||||
import app.dapk.st.navigator.IntentFactory
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import app.dapk.st.notifications.MatrixPushHandler
|
||||
import app.dapk.st.notifications.NotificationsModule
|
||||
import app.dapk.st.olm.DeviceKeyFactory
|
||||
import app.dapk.st.olm.OlmPersistenceWrapper
|
||||
|
@ -51,18 +56,17 @@ import app.dapk.st.olm.OlmWrapper
|
|||
import app.dapk.st.profile.ProfileModule
|
||||
import app.dapk.st.push.PushModule
|
||||
import app.dapk.st.settings.SettingsModule
|
||||
import app.dapk.st.share.ShareEntryModule
|
||||
import app.dapk.st.tracking.TrackingModule
|
||||
import app.dapk.st.work.TaskRunner
|
||||
import app.dapk.st.work.TaskRunnerModule
|
||||
import app.dapk.st.work.WorkModule
|
||||
import app.dapk.st.work.WorkScheduler
|
||||
import com.squareup.sqldelight.android.AndroidSqliteDriver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.time.Clock
|
||||
|
||||
internal class AppModule(context: Application, logger: MatrixLogger) {
|
||||
|
||||
private val buildMeta = BuildMeta(BuildConfig.VERSION_NAME)
|
||||
private val buildMeta = BuildMeta(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)
|
||||
private val trackingModule by unsafeLazy {
|
||||
TrackingModule(
|
||||
isCrashTrackingEnabled = !BuildConfig.DEBUG
|
||||
|
@ -71,8 +75,8 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
|
|||
|
||||
private val driver = AndroidSqliteDriver(DapkDb.Schema, context, "dapk.db")
|
||||
private val database = DapkDb(driver)
|
||||
|
||||
private val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO)
|
||||
private val clock = Clock.systemUTC()
|
||||
val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO)
|
||||
|
||||
val storeModule = unsafeLazy {
|
||||
StoreModule(
|
||||
|
@ -80,31 +84,41 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
|
|||
preferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-user-preferences", coroutineDispatchers),
|
||||
errorTracker = trackingModule.errorTracker,
|
||||
credentialPreferences = SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-credentials-preferences", coroutineDispatchers),
|
||||
databaseDropper = { includeCryptoAccount ->
|
||||
val cursor = driver.executeQuery(
|
||||
identifier = null,
|
||||
sql = "SELECT name FROM sqlite_master WHERE type = 'table' ${if (includeCryptoAccount) "" else "AND name != 'dbCryptoAccount'"}",
|
||||
parameters = 0
|
||||
)
|
||||
while (cursor.next()) {
|
||||
cursor.getString(0)?.let {
|
||||
driver.execute(null, "DELETE FROM $it", 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
databaseDropper = DefaultDatabaseDropper(coroutineDispatchers, driver),
|
||||
coroutineDispatchers = coroutineDispatchers
|
||||
)
|
||||
}
|
||||
private val workModule = WorkModule(context)
|
||||
private val imageLoaderModule = ImageLoaderModule(context)
|
||||
|
||||
private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers)
|
||||
val domainModules = DomainModules(matrixModules, trackingModule.errorTracker)
|
||||
private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, context.contentResolver)
|
||||
val domainModules = DomainModules(matrixModules, trackingModule.errorTracker, workModule, storeModule, context, coroutineDispatchers)
|
||||
|
||||
val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory {
|
||||
override fun home(activity: Activity) = Intent(activity, MainActivity::class.java)
|
||||
override fun messenger(activity: Activity, roomId: RoomId) = MessengerActivity.newInstance(activity, roomId)
|
||||
override fun messengerShortcut(activity: Activity, roomId: RoomId) = MessengerActivity.newShortcutInstance(activity, roomId)
|
||||
override fun notificationOpenApp(context: Context) = PendingIntent.getActivity(
|
||||
context,
|
||||
1000,
|
||||
home(context)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
override fun notificationOpenMessage(context: Context, roomId: RoomId) = PendingIntent.getActivity(
|
||||
context,
|
||||
roomId.hashCode(),
|
||||
messenger(context, roomId)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
override fun home(context: Context) = Intent(context, MainActivity::class.java)
|
||||
override fun messenger(context: Context, roomId: RoomId) = MessengerActivity.newInstance(context, roomId)
|
||||
override fun messengerShortcut(context: Context, roomId: RoomId) = MessengerActivity.newShortcutInstance(context, roomId)
|
||||
override fun messengerAttachments(context: Context, roomId: RoomId, attachments: List<MessageAttachment>) = MessengerActivity.newMessageAttachment(
|
||||
context,
|
||||
roomId,
|
||||
attachments
|
||||
)
|
||||
})
|
||||
|
||||
val featureModules = FeatureModules(
|
||||
|
@ -112,10 +126,12 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
|
|||
matrixModules,
|
||||
domainModules,
|
||||
trackingModule,
|
||||
coreAndroidModule,
|
||||
imageLoaderModule,
|
||||
context,
|
||||
buildMeta,
|
||||
coroutineDispatchers,
|
||||
clock,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -124,10 +140,12 @@ internal class FeatureModules internal constructor(
|
|||
private val matrixModules: MatrixModules,
|
||||
private val domainModules: DomainModules,
|
||||
private val trackingModule: TrackingModule,
|
||||
private val coreAndroidModule: CoreAndroidModule,
|
||||
imageLoaderModule: ImageLoaderModule,
|
||||
context: Context,
|
||||
buildMeta: BuildMeta,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
clock: Clock,
|
||||
) {
|
||||
|
||||
val directoryModule by unsafeLazy {
|
||||
|
@ -155,12 +173,14 @@ internal class FeatureModules internal constructor(
|
|||
matrixModules.room,
|
||||
storeModule.value.credentialsStore(),
|
||||
storeModule.value.roomStore(),
|
||||
clock
|
||||
)
|
||||
}
|
||||
val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile) }
|
||||
val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile, buildMeta) }
|
||||
val settingsModule by unsafeLazy {
|
||||
SettingsModule(
|
||||
storeModule.value,
|
||||
pushModule,
|
||||
matrixModules.crypto,
|
||||
matrixModules.sync,
|
||||
context.contentResolver,
|
||||
|
@ -168,19 +188,26 @@ internal class FeatureModules internal constructor(
|
|||
coroutineDispatchers
|
||||
)
|
||||
}
|
||||
val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync) }
|
||||
val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync, matrixModules.room, trackingModule.errorTracker) }
|
||||
val notificationsModule by unsafeLazy {
|
||||
NotificationsModule(
|
||||
matrixModules.push,
|
||||
matrixModules.sync,
|
||||
storeModule.value.credentialsStore(),
|
||||
domainModules.pushModule.registerFirebasePushTokenUseCase(),
|
||||
imageLoaderModule.iconLoader(),
|
||||
storeModule.value.roomStore(),
|
||||
context,
|
||||
intentFactory = coreAndroidModule.intentFactory(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
deviceMeta = DeviceMeta(Build.VERSION.SDK_INT)
|
||||
)
|
||||
}
|
||||
|
||||
val shareEntryModule by unsafeLazy {
|
||||
ShareEntryModule(matrixModules.sync, matrixModules.room)
|
||||
}
|
||||
|
||||
val pushModule by unsafeLazy {
|
||||
domainModules.pushModule
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal class MatrixModules(
|
||||
|
@ -189,6 +216,7 @@ internal class MatrixModules(
|
|||
private val workModule: WorkModule,
|
||||
private val logger: MatrixLogger,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val contentResolver: ContentResolver,
|
||||
) {
|
||||
|
||||
val matrix by unsafeLazy {
|
||||
|
@ -205,7 +233,8 @@ internal class MatrixModules(
|
|||
installAuthService(credentialsStore)
|
||||
installEncryptionService(store.knownDevicesStore())
|
||||
|
||||
val olmAccountStore = OlmPersistenceWrapper(store.olmStore())
|
||||
val base64 = AndroidBase64()
|
||||
val olmAccountStore = OlmPersistenceWrapper(store.olmStore(), base64)
|
||||
val singletonFlows = SingletonFlows(coroutineDispatchers)
|
||||
val olm = OlmWrapper(
|
||||
olmStore = olmAccountStore,
|
||||
|
@ -225,13 +254,16 @@ internal class MatrixModules(
|
|||
services.roomService().joinedMembers(it).map { it.userId }
|
||||
}
|
||||
},
|
||||
base64 = base64,
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
)
|
||||
installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler())) { serviceProvider ->
|
||||
val imageContentReader = AndroidImageContentReader(contentResolver)
|
||||
installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader) { serviceProvider ->
|
||||
MessageEncrypter { message ->
|
||||
val result = serviceProvider.cryptoService().encrypt(
|
||||
roomId = when (message) {
|
||||
is MessageService.Message.TextMessage -> message.roomId
|
||||
is MessageService.Message.ImageMessage -> message.roomId
|
||||
},
|
||||
credentials = credentialsStore.credentials()!!,
|
||||
when (message) {
|
||||
|
@ -246,6 +278,8 @@ internal class MatrixModules(
|
|||
)
|
||||
)
|
||||
)
|
||||
|
||||
is MessageService.Message.ImageMessage -> TODO()
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -283,6 +317,14 @@ internal class MatrixModules(
|
|||
store.roomStore(),
|
||||
store.syncStore(),
|
||||
store.filterStore(),
|
||||
deviceNotifier = { services ->
|
||||
val encryption = services.deviceService()
|
||||
val crypto = services.cryptoService()
|
||||
DeviceNotifier { userIds, syncToken ->
|
||||
encryption.updateStaleDevices(userIds)
|
||||
crypto.updateOlmSession(userIds, syncToken)
|
||||
}
|
||||
},
|
||||
messageDecrypter = { serviceProvider ->
|
||||
val cryptoService = serviceProvider.cryptoService()
|
||||
MessageDecrypter {
|
||||
|
@ -296,9 +338,9 @@ internal class MatrixModules(
|
|||
}
|
||||
},
|
||||
verificationHandler = { services ->
|
||||
logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it")
|
||||
val cryptoService = services.cryptoService()
|
||||
VerificationHandler { apiEvent ->
|
||||
logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it")
|
||||
cryptoService.onVerificationEvent(
|
||||
when (apiEvent) {
|
||||
is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested(
|
||||
|
@ -308,12 +350,14 @@ internal class MatrixModules(
|
|||
apiEvent.content.methods,
|
||||
apiEvent.content.timestampPosix,
|
||||
)
|
||||
|
||||
is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready(
|
||||
apiEvent.sender,
|
||||
apiEvent.content.fromDevice,
|
||||
apiEvent.content.transactionId,
|
||||
apiEvent.content.methods,
|
||||
)
|
||||
|
||||
is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started(
|
||||
apiEvent.sender,
|
||||
apiEvent.content.fromDevice,
|
||||
|
@ -324,6 +368,7 @@ internal class MatrixModules(
|
|||
apiEvent.content.short,
|
||||
apiEvent.content.transactionId,
|
||||
)
|
||||
|
||||
is ApiToDeviceEvent.VerificationCancel -> TODO()
|
||||
is ApiToDeviceEvent.VerificationAccept -> TODO()
|
||||
is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key(
|
||||
|
@ -331,6 +376,7 @@ internal class MatrixModules(
|
|||
apiEvent.content.transactionId,
|
||||
apiEvent.content.key
|
||||
)
|
||||
|
||||
is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac(
|
||||
apiEvent.sender,
|
||||
apiEvent.content.transactionId,
|
||||
|
@ -341,14 +387,6 @@ internal class MatrixModules(
|
|||
)
|
||||
}
|
||||
},
|
||||
deviceNotifier = { services ->
|
||||
val encryption = services.deviceService()
|
||||
val crypto = services.cryptoService()
|
||||
DeviceNotifier { userIds, syncToken ->
|
||||
encryption.updateStaleDevices(userIds)
|
||||
crypto.updateOlmSession(userIds, syncToken)
|
||||
}
|
||||
},
|
||||
oneTimeKeyProducer = { services ->
|
||||
val cryptoService = services.cryptoService()
|
||||
MaybeCreateMoreKeys {
|
||||
|
@ -359,6 +397,7 @@ internal class MatrixModules(
|
|||
val roomService = services.roomService()
|
||||
object : RoomMembersService {
|
||||
override suspend fun find(roomId: RoomId, userIds: List<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)
|
||||
}
|
||||
},
|
||||
|
@ -367,8 +406,6 @@ internal class MatrixModules(
|
|||
)
|
||||
|
||||
installPushService(credentialsStore)
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -385,32 +422,49 @@ internal class MatrixModules(
|
|||
internal class DomainModules(
|
||||
private val matrixModules: MatrixModules,
|
||||
private val errorTracker: ErrorTracker,
|
||||
private val workModule: WorkModule,
|
||||
private val storeModule: Lazy<StoreModule>,
|
||||
private val context: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
|
||||
val pushModule by unsafeLazy { PushModule(matrixModules.push, errorTracker) }
|
||||
val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run)) }
|
||||
}
|
||||
|
||||
class BackgroundWorkAdapter(private val workScheduler: WorkScheduler) : BackgroundScheduler {
|
||||
override fun schedule(key: String, task: BackgroundScheduler.Task) {
|
||||
workScheduler.schedule(
|
||||
WorkScheduler.WorkTask(
|
||||
jobId = 1,
|
||||
type = task.type,
|
||||
jsonPayload = task.jsonPayload,
|
||||
)
|
||||
val pushModule by unsafeLazy {
|
||||
val store = storeModule.value
|
||||
val pushHandler = MatrixPushHandler(
|
||||
workScheduler = workModule.workScheduler(),
|
||||
credentialsStore = store.credentialsStore(),
|
||||
matrixModules.sync,
|
||||
store.roomStore(),
|
||||
)
|
||||
PushModule(
|
||||
errorTracker,
|
||||
pushHandler,
|
||||
context,
|
||||
dispatchers,
|
||||
SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-user-preferences", dispatchers)
|
||||
)
|
||||
}
|
||||
val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run, AppTaskRunner(matrixModules.push))) }
|
||||
}
|
||||
|
||||
class TaskRunnerAdapter(private val matrixTaskRunner: suspend (MatrixTask) -> MatrixTaskRunner.TaskResult) : TaskRunner {
|
||||
internal class AndroidImageContentReader(private val contentResolver: ContentResolver) : ImageContentReader {
|
||||
override fun read(uri: String): ImageContentReader.ImageContent {
|
||||
val androidUri = Uri.parse(uri)
|
||||
val fileStream = contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")
|
||||
|
||||
override suspend fun run(tasks: List<TaskRunner.RunnableWorkTask>): List<TaskRunner.TaskResult> {
|
||||
return tasks.map {
|
||||
when (val result = matrixTaskRunner(MatrixTask(it.task.type, it.task.jsonPayload))) {
|
||||
is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(it.source, canRetry = result.canRetry)
|
||||
MatrixTaskRunner.TaskResult.Success -> TaskRunner.TaskResult.Success(it.source)
|
||||
}
|
||||
}
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeStream(fileStream, null, options)
|
||||
|
||||
return contentResolver.openInputStream(androidUri)?.use { stream ->
|
||||
val output = stream.readBytes()
|
||||
ImageContentReader.ImageContent(
|
||||
height = options.outHeight,
|
||||
width = options.outWidth,
|
||||
size = output.size.toLong(),
|
||||
mimeType = options.outMimeType,
|
||||
fileName = androidUri.lastPathSegment ?: "file",
|
||||
content = output
|
||||
)
|
||||
} ?: throw IllegalArgumentException("Could not process $uri")
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
41
build.gradle
41
build.gradle
|
@ -9,7 +9,7 @@ buildscript {
|
|||
classpath Dependencies.mavenCentral.kotlinGradlePlugin
|
||||
classpath Dependencies.mavenCentral.sqldelightGradlePlugin
|
||||
classpath Dependencies.mavenCentral.kotlinSerializationGradlePlugin
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1'
|
||||
classpath Dependencies.google.firebaseCrashlyticsPlugin
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -18,14 +18,16 @@ def launchTask = getGradle()
|
|||
.getTaskRequests()
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
def isReleaseBuild = launchTask.contains("release")
|
||||
ext.isDebugBuild = !isReleaseBuild
|
||||
|
||||
subprojects {
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
freeCompilerArgs = [
|
||||
'-Xopt-in=kotlin.contracts.ExperimentalContracts',
|
||||
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -52,7 +54,7 @@ ext.applyLibraryPlugins = { project ->
|
|||
project.apply plugin: 'kotlin-android'
|
||||
}
|
||||
|
||||
ext.androidSdkVersion = 31
|
||||
ext.androidSdkVersion = 32
|
||||
|
||||
ext.applyCommonAndroidParameters = { project ->
|
||||
def android = project.android
|
||||
|
@ -63,14 +65,9 @@ ext.applyCommonAndroidParameters = { project ->
|
|||
incremental = true
|
||||
}
|
||||
android.defaultConfig {
|
||||
minSdkVersion 29
|
||||
minSdkVersion 24
|
||||
targetSdkVersion androidSdkVersion
|
||||
}
|
||||
|
||||
android.buildFeatures.compose = true
|
||||
android.composeOptions {
|
||||
kotlinCompilerExtensionVersion = Dependencies.google.kotlinCompilerExtensionVersion
|
||||
}
|
||||
}
|
||||
|
||||
ext.applyLibraryModuleOptimisations = { project ->
|
||||
|
@ -101,17 +98,26 @@ ext.applyCompose = { project ->
|
|||
dependencies.implementation Dependencies.google.androidxComposeMaterial
|
||||
dependencies.implementation Dependencies.google.androidxComposeIconsExtended
|
||||
dependencies.implementation Dependencies.google.androidxActivityCompose
|
||||
|
||||
def android = project.android
|
||||
android.buildFeatures.compose = true
|
||||
android.composeOptions {
|
||||
kotlinCompilerExtensionVersion = Dependencies.google.kotlinCompilerExtensionVersion
|
||||
}
|
||||
}
|
||||
|
||||
ext.applyAndroidComposeLibraryModule = { project ->
|
||||
applyAndroidLibraryModule(project)
|
||||
applyCompose(project)
|
||||
}
|
||||
|
||||
ext.applyAndroidLibraryModule = { project ->
|
||||
applyLibraryPlugins(project)
|
||||
applyCommonAndroidParameters(project)
|
||||
applyLibraryModuleOptimisations(project)
|
||||
applyCompose(project)
|
||||
}
|
||||
|
||||
ext.applyCrashlyticsIfRelease = { project ->
|
||||
def isReleaseBuild = launchTask.contains("release")
|
||||
if (isReleaseBuild) {
|
||||
project.apply plugin: 'com.google.firebase.crashlytics'
|
||||
project.afterEvaluate {
|
||||
|
@ -126,16 +132,17 @@ ext.kotlinTest = { dependencies ->
|
|||
dependencies.testImplementation Dependencies.mavenCentral.kluent
|
||||
dependencies.testImplementation Dependencies.mavenCentral.kotlinTest
|
||||
dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10"
|
||||
dependencies.testImplementation 'io.mockk:mockk:1.12.2'
|
||||
dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
||||
dependencies.testImplementation 'io.mockk:mockk:1.12.7'
|
||||
dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
|
||||
|
||||
dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
|
||||
dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
|
||||
dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0'
|
||||
dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0'
|
||||
}
|
||||
|
||||
ext.kotlinFixtures = { dependencies ->
|
||||
dependencies.testFixturesImplementation 'io.mockk:mockk:1.12.2'
|
||||
dependencies.testFixturesImplementation 'io.mockk:mockk:1.12.7'
|
||||
dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent
|
||||
dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
}
|
||||
|
||||
ext.androidImportFixturesWorkaround = { project, fixtures ->
|
||||
|
|
|
@ -4,9 +4,9 @@ plugins {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
api Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
testFixturesImplementation Dependencies.mavenCentral.kluent
|
||||
testFixturesImplementation 'io.mockk:mockk:1.12.2'
|
||||
testFixturesImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
||||
testFixturesImplementation Dependencies.mavenCentral.mockk
|
||||
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package app.dapk.st.core
|
||||
|
||||
@JvmInline
|
||||
value class AndroidUri(val value: String)
|
|
@ -0,0 +1,6 @@
|
|||
package app.dapk.st.core
|
||||
|
||||
interface Base64 {
|
||||
fun encode(input: ByteArray): String
|
||||
fun decode(input: String): ByteArray
|
||||
}
|
|
@ -2,4 +2,5 @@ package app.dapk.st.core
|
|||
|
||||
data class BuildMeta(
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
)
|
|
@ -2,7 +2,11 @@ package app.dapk.st.core
|
|||
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
data class CoroutineDispatchers(val io: CoroutineDispatcher = Dispatchers.IO, val global: CoroutineScope = GlobalScope)
|
||||
data class CoroutineDispatchers(
|
||||
val io: CoroutineDispatcher = Dispatchers.IO,
|
||||
val main: CoroutineDispatcher = Dispatchers.Main,
|
||||
val global: CoroutineScope = GlobalScope,
|
||||
)
|
||||
|
||||
suspend fun <T> CoroutineDispatchers.withIoContext(
|
||||
block: suspend CoroutineScope.() -> T
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
package app.dapk.st.core
|
||||
|
||||
data class DeviceMeta(
|
||||
val apiVersion: Int
|
||||
)
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
package app.dapk.st.core
|
||||
|
||||
sealed interface MimeType {
|
||||
object Image: MimeType
|
||||
}
|
|
@ -4,7 +4,8 @@ import kotlin.reflect.KClass
|
|||
|
||||
interface ModuleProvider {
|
||||
|
||||
fun <T: ProvidableModule> provide(klass: KClass<T>): T
|
||||
fun <T : ProvidableModule> provide(klass: KClass<T>): T
|
||||
fun reset()
|
||||
}
|
||||
|
||||
interface ProvidableModule
|
|
@ -31,10 +31,12 @@ class SingletonFlows(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> get(key: String): Flow<T> {
|
||||
return cache[key]!! as Flow<T>
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
suspend fun <T> update(key: String, value: T) {
|
||||
(cache[key] as? MutableSharedFlow<T>)?.emit(value)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -1,9 +1,6 @@
|
|||
package test
|
||||
|
||||
import io.mockk.MockKMatcherScope
|
||||
import io.mockk.MockKVerificationScope
|
||||
import io.mockk.coJustRun
|
||||
import io.mockk.coVerifyAll
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
@ -12,23 +9,30 @@ fun runExpectTest(testBody: suspend ExpectTestScope.() -> Unit) {
|
|||
runTest { testBody(ExpectTest(coroutineContext)) }
|
||||
}
|
||||
|
||||
|
||||
class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope {
|
||||
|
||||
private val expects = mutableListOf<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 <T> T.expectUnit(block: suspend MockKMatcherScope.(T) -> Unit) {
|
||||
coJustRun { block(this@expectUnit) }.ignore()
|
||||
expects.add { block(this@expectUnit) }
|
||||
override fun verifyExpects() {
|
||||
expects.forEach { (times, block) -> coVerify(exactly = times) { block.invoke(this) } }
|
||||
groups.forEach { coVerifyOrder { it.invoke(this) } }
|
||||
}
|
||||
|
||||
override fun <T> 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
|
||||
|
||||
interface ExpectTestScope : CoroutineScope {
|
||||
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)
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
package test
|
||||
|
||||
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) {
|
||||
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> {
|
||||
override fun returns(value: T) = block(value)
|
||||
override fun throws(value: Throwable) = throw value
|
||||
}
|
||||
|
||||
interface Emits<T> {
|
||||
fun emits(vararg values: T)
|
||||
}
|
||||
|
||||
interface Returns<T> {
|
||||
fun returns(value: T)
|
||||
fun throws(value: Throwable)
|
||||
|
|
|
@ -10,6 +10,13 @@ ext.Dependencies.with {
|
|||
}
|
||||
}
|
||||
|
||||
repositories.maven {
|
||||
url 'https://jitpack.io'
|
||||
content {
|
||||
includeGroup "com.github.UnifiedPush"
|
||||
}
|
||||
}
|
||||
|
||||
repositories.mavenCentral {
|
||||
content {
|
||||
includeGroupByRegex "org\\.jetbrains.*"
|
||||
|
@ -88,45 +95,64 @@ ext.Dependencies.with {
|
|||
}
|
||||
}
|
||||
|
||||
def kotlinVer = "1.6.10"
|
||||
def kotlinVer = "1.7.10"
|
||||
def sqldelightVer = "1.5.3"
|
||||
def composeVer = "1.1.0"
|
||||
def composeVer = "1.2.1"
|
||||
def ktorVer = "2.1.0"
|
||||
|
||||
google = new DependenciesContainer()
|
||||
google.with {
|
||||
androidGradlePlugin = "com.android.tools.build:gradle:7.1.2"
|
||||
androidGradlePlugin = "com.android.tools.build:gradle:7.2.1"
|
||||
|
||||
androidxComposeUi = "androidx.compose.ui:ui:${composeVer}"
|
||||
androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}"
|
||||
androidxComposeMaterial = "androidx.compose.material:material:${composeVer}"
|
||||
androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}"
|
||||
androidxActivityCompose = "androidx.activity:activity-compose:1.4.0"
|
||||
kotlinCompilerExtensionVersion = "1.1.0-rc02"
|
||||
kotlinCompilerExtensionVersion = "1.3.0"
|
||||
|
||||
firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.1"
|
||||
jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"
|
||||
}
|
||||
|
||||
mavenCentral = new DependenciesContainer()
|
||||
mavenCentral.with {
|
||||
kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVer}"
|
||||
kotlinSerializationGradlePlugin = "org.jetbrains.kotlin:kotlin-serialization:${kotlinVer}"
|
||||
kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0"
|
||||
kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-RC2"
|
||||
kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0"
|
||||
kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
|
||||
kotlinCoroutinesTest = 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
|
||||
kotlinTest = "org.jetbrains.kotlin:kotlin-test-junit:${kotlinVer}"
|
||||
|
||||
sqldelightGradlePlugin = "com.squareup.sqldelight:gradle-plugin:${sqldelightVer}"
|
||||
sqldelightAndroid = "com.squareup.sqldelight:android-driver:${sqldelightVer}"
|
||||
sqldelightInMemory = "com.squareup.sqldelight:sqlite-driver:${sqldelightVer}"
|
||||
|
||||
ktorAndroid = "io.ktor:ktor-client-android:1.6.4"
|
||||
ktorCore = "io.ktor:ktor-client-core:1.6.2"
|
||||
ktorSerialization = "io.ktor:ktor-client-serialization:1.5.0"
|
||||
ktorLogging = "io.ktor:ktor-client-logging-jvm:1.6.2"
|
||||
ktorJava = "io.ktor:ktor-client-java:1.6.2"
|
||||
leakCanary = 'com.squareup.leakcanary:leakcanary-android:2.9.1'
|
||||
|
||||
ktorAndroid = "io.ktor:ktor-client-android:${ktorVer}"
|
||||
ktorCore = "io.ktor:ktor-client-core:${ktorVer}"
|
||||
ktorSerialization = "io.ktor:ktor-client-serialization:${ktorVer}"
|
||||
ktorJson = "io.ktor:ktor-serialization-kotlinx-json:${ktorVer}"
|
||||
ktorLogging = "io.ktor:ktor-client-logging-jvm:${ktorVer}"
|
||||
ktorJava = "io.ktor:ktor-client-java:${ktorVer}"
|
||||
ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}"
|
||||
|
||||
coil = "io.coil-kt:coil-compose:2.2.0"
|
||||
accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
|
||||
|
||||
junit = "junit:junit:4.13.2"
|
||||
kluent = "org.amshove.kluent:kluent:1.68"
|
||||
mockk = 'io.mockk:mockk:1.12.7'
|
||||
|
||||
matrixOlm = "org.matrix.android:olm-sdk:3.2.10"
|
||||
matrixOlm = "org.matrix.android:olm-sdk:3.2.12"
|
||||
}
|
||||
|
||||
jitPack = new DependenciesContainer()
|
||||
jitPack.with {
|
||||
unifiedPush = "com.github.UnifiedPush:android-connector:2.0.1"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class DependenciesContainer extends GroovyObjectSupport {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
applyAndroidLibraryModule(project)
|
||||
applyAndroidComposeLibraryModule(project)
|
||||
|
||||
dependencies {
|
||||
implementation project(":core")
|
||||
implementation("io.coil-kt:coil-compose:1.4.0")
|
||||
implementation "com.google.accompanist:accompanist-systemuicontroller:0.24.1-alpha"
|
||||
implementation Dependencies.mavenCentral.coil
|
||||
implementation Dependencies.mavenCentral.accompanistSystemuicontroller
|
||||
}
|
|
@ -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...")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,12 +10,14 @@ import androidx.compose.material.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.ExperimentalUnitApi
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import coil.compose.rememberImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import coil.transform.CircleCropTransformation
|
||||
|
||||
@OptIn(ExperimentalUnitApi::class)
|
||||
|
@ -25,7 +27,8 @@ fun BoxScope.CircleishAvatar(avatarUrl: String?, fallbackLabel: String, size: Dp
|
|||
null -> {
|
||||
val colors = SmallTalkTheme.extendedColors.getMissingImageColor(fallbackLabel)
|
||||
Box(
|
||||
Modifier.align(Alignment.Center)
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.background(color = colors.first, shape = CircleShape)
|
||||
.size(size),
|
||||
contentAlignment = Alignment.Center
|
||||
|
@ -40,14 +43,16 @@ fun BoxScope.CircleishAvatar(avatarUrl: String?, fallbackLabel: String, size: Dp
|
|||
}
|
||||
else -> {
|
||||
Image(
|
||||
painter = rememberImagePainter(
|
||||
data = avatarUrl,
|
||||
builder = {
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(avatarUrl)
|
||||
.transformations(CircleCropTransformation())
|
||||
.build()
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size).align(Alignment.Center)
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -56,7 +61,11 @@ fun BoxScope.CircleishAvatar(avatarUrl: String?, fallbackLabel: String, size: Dp
|
|||
@Composable
|
||||
fun MissingAvatarIcon(displayName: String, displayImageSize: Dp) {
|
||||
val colors = SmallTalkTheme.extendedColors.getMissingImageColor(displayName)
|
||||
Box(Modifier.background(color = colors.first, shape = CircleShape).size(displayImageSize), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier
|
||||
.background(color = colors.first, shape = CircleShape)
|
||||
.size(displayImageSize), contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = (displayName).first().toString().uppercase(),
|
||||
color = colors.second
|
||||
|
@ -67,11 +76,11 @@ fun MissingAvatarIcon(displayName: String, displayImageSize: Dp) {
|
|||
@Composable
|
||||
fun MessengerUrlIcon(avatarUrl: String, displayImageSize: Dp) {
|
||||
Image(
|
||||
painter = rememberImagePainter(
|
||||
data = avatarUrl,
|
||||
builder = {
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(avatarUrl)
|
||||
.transformations(CircleCropTransformation())
|
||||
.build()
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(displayImageSize)
|
||||
|
|
|
@ -27,14 +27,13 @@ fun <T : Any> Spider(currentPage: SpiderPage<T>, onNavigate: (SpiderPage<out T>?
|
|||
}
|
||||
|
||||
Column {
|
||||
Toolbar(
|
||||
onNavigate = navigateAndPopStack,
|
||||
title = currentPage.label
|
||||
)
|
||||
|
||||
currentPage.parent?.let {
|
||||
BackHandler(onBack = navigateAndPopStack)
|
||||
if (currentPage.hasToolbar) {
|
||||
Toolbar(
|
||||
onNavigate = navigateAndPopStack,
|
||||
title = currentPage.label
|
||||
)
|
||||
}
|
||||
BackHandler(onBack = navigateAndPopStack)
|
||||
computedWeb[currentPage.route]!!.invoke(currentPage.state)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,27 +1,38 @@
|
|||
package app.dapk.st.design.components
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ColumnScope.Toolbar(onNavigate: () -> Unit, title: String? = null, actions: @Composable RowScope.() -> Unit = {}) {
|
||||
fun Toolbar(
|
||||
onNavigate: (() -> Unit)? = null,
|
||||
title: String? = null,
|
||||
offset: (Density.() -> IntOffset)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
val navigationIcon = foo(onNavigate)
|
||||
|
||||
TopAppBar(
|
||||
modifier = Modifier.height(72.dp),
|
||||
backgroundColor = Color.Transparent,
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onNavigate() }) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = null)
|
||||
modifier = Modifier.height(72.dp).run {
|
||||
if (offset == null) {
|
||||
this
|
||||
} else {
|
||||
this.offset(offset)
|
||||
}
|
||||
},
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
navigationIcon = navigationIcon,
|
||||
title = title?.let {
|
||||
{ Text(it, maxLines = 2) }
|
||||
} ?: {},
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun TextRow(title: String, content: String? = null, includeDivider: Boolean = true, onClick: (() -> Unit)? = null) {
|
||||
fun TextRow(title: String, content: String? = null, includeDivider: Boolean = true, onClick: (() -> Unit)? = null, body: @Composable () -> Unit = {}) {
|
||||
val modifier = Modifier.padding(horizontal = 24.dp)
|
||||
Column(
|
||||
Modifier
|
||||
|
@ -31,6 +31,7 @@ fun TextRow(title: String, content: String? = null, includeDivider: Boolean = tr
|
|||
Text(text = content, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
body()
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
if (includeDivider) {
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
applyAndroidComposeLibraryModule(project)
|
||||
|
||||
dependencies {
|
||||
implementation project(":core")
|
||||
implementation project(":features:navigator")
|
||||
api project(":domains:android:core")
|
||||
}
|
|
@ -41,6 +41,9 @@ fun LifecycleEffect(onStart: () -> Unit = {}, onStop: () -> Unit = {}) {
|
|||
when (event) {
|
||||
Lifecycle.Event.ON_START -> onStart()
|
||||
Lifecycle.Event.ON_STOP -> onStop()
|
||||
else -> {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
applyAndroidLibraryModule(project)
|
||||
plugins { id 'kotlin' }
|
||||
|
||||
dependencies {
|
||||
compileOnly project(":domains:android:stub")
|
||||
implementation project(":core")
|
||||
implementation project(":features:navigator")
|
||||
}
|
||||
|
|
|
@ -2,5 +2,6 @@ package app.dapk.st.core
|
|||
|
||||
import android.content.Context
|
||||
|
||||
inline fun <reified T : ProvidableModule> Context.module() =
|
||||
(this.applicationContext as ModuleProvider).provide(T::class)
|
||||
inline fun <reified T : ProvidableModule> Context.module() = (this.applicationContext as ModuleProvider).provide(T::class)
|
||||
|
||||
fun Context.resetModules() = (this.applicationContext as ModuleProvider).reset()
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -2,5 +2,5 @@ applyAndroidLibraryModule(project)
|
|||
|
||||
dependencies {
|
||||
implementation project(":core")
|
||||
implementation "io.coil-kt:coil:1.4.0"
|
||||
implementation Dependencies.mavenCentral.coil
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
package app.dapk.st.imageloader
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.Icon
|
||||
import android.widget.ImageView
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.ImageResult
|
||||
import coil.transform.CircleCropTransformation
|
||||
import coil.transform.Transformation
|
||||
import coil.load as coilLoad
|
||||
|
@ -14,7 +15,6 @@ import coil.load as coilLoad
|
|||
interface ImageLoader {
|
||||
|
||||
suspend fun load(url: String, transformation: Transformation? = null): Drawable?
|
||||
|
||||
}
|
||||
|
||||
interface IconLoader {
|
||||
|
@ -31,19 +31,24 @@ class CachedIcons(private val imageLoader: ImageLoader) : IconLoader {
|
|||
|
||||
override suspend fun load(url: String): Icon? {
|
||||
return cache.getOrPut(url) {
|
||||
imageLoader.load(url, transformation = circleCrop)?.toBitmap()?.let {
|
||||
imageLoader.load(url, transformation = circleCrop)?.asBitmap()?.let {
|
||||
Icon.createWithBitmap(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Drawable.asBitmap() = (this as? BitmapDrawable)?.bitmap
|
||||
|
||||
internal class CoilImageLoader(private val context: Context) : ImageLoader {
|
||||
|
||||
private val coil = context.imageLoader
|
||||
|
||||
override suspend fun load(url: String, transformation: Transformation?): Drawable? {
|
||||
return internalLoad(url, transformation).drawable
|
||||
}
|
||||
|
||||
private suspend fun internalLoad(url: String, transformation: Transformation?): ImageResult {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.let {
|
||||
|
@ -53,7 +58,7 @@ internal class CoilImageLoader(private val context: Context) : ImageLoader {
|
|||
}
|
||||
}
|
||||
.build()
|
||||
return coil.execute(request).drawable
|
||||
return coil.execute(request)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
applyAndroidLibraryModule(project)
|
||||
apply plugin: "org.jetbrains.kotlin.plugin.serialization"
|
||||
|
||||
dependencies {
|
||||
implementation project(':core')
|
||||
implementation project(':domains:android:core')
|
||||
implementation project(':domains:store')
|
||||
implementation project(':matrix:services:push')
|
||||
implementation platform('com.google.firebase:firebase-bom:29.0.3')
|
||||
implementation 'com.google.firebase:firebase-messaging'
|
||||
implementation Dependencies.mavenCentral.kotlinSerializationJson
|
||||
implementation Dependencies.jitPack.unifiedPush
|
||||
}
|
||||
|
|
|
@ -1,2 +1,24 @@
|
|||
<?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>
|
|
@ -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,
|
||||
)
|
|
@ -1,16 +1,40 @@
|
|||
package app.dapk.st.push
|
||||
|
||||
import android.content.Context
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.core.extensions.ErrorTracker
|
||||
import app.dapk.st.matrix.push.PushService
|
||||
import app.dapk.st.core.extensions.unsafeLazy
|
||||
import app.dapk.st.domain.Preferences
|
||||
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
|
||||
import app.dapk.st.push.firebase.FirebasePushTokenRegistrar
|
||||
import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar
|
||||
|
||||
class PushModule(
|
||||
private val pushService: PushService,
|
||||
private val errorTracker: ErrorTracker,
|
||||
) {
|
||||
private val pushHandler: PushHandler,
|
||||
private val context: Context,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val preferences: Preferences,
|
||||
) : ProvidableModule {
|
||||
|
||||
fun registerFirebasePushTokenUseCase() = RegisterFirebasePushTokenUseCase(
|
||||
pushService,
|
||||
errorTracker,
|
||||
)
|
||||
private val registrars by unsafeLazy {
|
||||
PushTokenRegistrars(
|
||||
context,
|
||||
FirebasePushTokenRegistrar(
|
||||
errorTracker,
|
||||
context,
|
||||
pushHandler,
|
||||
),
|
||||
UnifiedPushRegistrar(context),
|
||||
PushTokenRegistrarPreferences(preferences)
|
||||
)
|
||||
}
|
||||
|
||||
fun pushTokenRegistrars() = registrars
|
||||
|
||||
fun pushTokenRegistrar(): PushTokenRegistrar = pushTokenRegistrars()
|
||||
fun pushHandler() = pushHandler
|
||||
fun dispatcher() = dispatchers
|
||||
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package app.dapk.st.push
|
||||
|
||||
interface PushTokenRegistrar {
|
||||
suspend fun registerCurrentToken()
|
||||
fun unregister()
|
||||
}
|
|
@ -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)
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package app.dapk.st.push
|
||||
package app.dapk.st.push.firebase
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import kotlin.coroutines.resume
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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?,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -13,6 +13,8 @@ if (localProperties.exists()) {
|
|||
|
||||
dependencies {
|
||||
def androidVer = androidSdkVersion
|
||||
api files("${properties.getProperty("sdk.dir")}/platforms/android-${androidVer}/android.jar")
|
||||
|
||||
kotlinFixtures(it)
|
||||
testFixturesImplementation testFixtures(project(":core"))
|
||||
testFixturesImplementation files("${properties.getProperty("sdk.dir")}/platforms/android-${androidVer}/android.jar")
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
package fake
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeContext {
|
||||
val instance = mockk<Context>()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,12 @@
|
|||
package fake
|
||||
|
||||
import android.app.Notification
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeNotification {
|
||||
|
||||
val instance = mockk<Notification>()
|
||||
|
||||
}
|
||||
|
||||
fun aFakeNotification() = FakeNotification().instance
|
|
@ -0,0 +1,8 @@
|
|||
package fake
|
||||
|
||||
import android.app.Notification
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeNotificationBuilder {
|
||||
val instance = mockk<Notification.Builder>(relaxed = true)
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package fake
|
||||
|
||||
import android.app.Person
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakePersonBuilder {
|
||||
val instance = mockk<Person.Builder>(relaxed = true)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
@file:JvmName("SnapshotStateKt")
|
||||
|
||||
@file:Suppress("UNUSED")
|
||||
package androidx.compose.runtime
|
||||
|
||||
import kotlin.reflect.KProperty
|
||||
|
|
|
@ -9,7 +9,7 @@ dependencies {
|
|||
|
||||
kotlinFixtures(it)
|
||||
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
testFixturesImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
||||
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest
|
||||
testFixturesImplementation testFixtures(project(":core"))
|
||||
testFixturesCompileOnly project(":domains:android:viewmodel-stub")
|
||||
}
|
|
@ -6,6 +6,7 @@ import kotlinx.coroutines.test.runTest
|
|||
import kotlinx.coroutines.test.setMain
|
||||
import test.ExpectTest
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class ViewModelTest {
|
||||
|
||||
var instance: TestMutableState<Any>? = null
|
||||
|
|
|
@ -8,15 +8,15 @@ interface TaskRunner {
|
|||
suspend fun run(tasks: List<RunnableWorkTask>): List<TaskResult>
|
||||
|
||||
data class RunnableWorkTask(
|
||||
val source: JobWorkItem,
|
||||
val source: JobWorkItem?,
|
||||
val task: WorkTask
|
||||
)
|
||||
|
||||
sealed interface TaskResult {
|
||||
val source: JobWorkItem
|
||||
val source: JobWorkItem?
|
||||
|
||||
data class Success(override val source: JobWorkItem) : TaskResult
|
||||
data class Failure(override val source: JobWorkItem, val canRetry: Boolean) : TaskResult
|
||||
data class Success(override val source: JobWorkItem?) : TaskResult
|
||||
data class Failure(override val source: JobWorkItem?, val canRetry: Boolean) : TaskResult
|
||||
}
|
||||
|
||||
}
|
|
@ -3,6 +3,7 @@ package app.dapk.st.work
|
|||
import android.app.job.JobParameters
|
||||
import android.app.job.JobService
|
||||
import android.app.job.JobWorkItem
|
||||
import android.os.Build
|
||||
import app.dapk.st.core.extensions.Scope
|
||||
import app.dapk.st.core.extensions.unsafeLazy
|
||||
import app.dapk.st.core.module
|
||||
|
@ -24,11 +25,15 @@ class WorkAndroidService : JobService() {
|
|||
when (it) {
|
||||
is TaskRunner.TaskResult.Failure -> {
|
||||
if (!it.canRetry) {
|
||||
params.completeWork(it.source)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
params.completeWork(it.source!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
is TaskRunner.TaskResult.Success -> {
|
||||
params.completeWork(it.source)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
params.completeWork(it.source!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -40,24 +45,37 @@ class WorkAndroidService : JobService() {
|
|||
}
|
||||
|
||||
private fun JobParameters.collectAllTasks(): List<RunnableWorkTask> {
|
||||
var work: JobWorkItem?
|
||||
val tasks = mutableListOf<RunnableWorkTask>()
|
||||
do {
|
||||
work = this.dequeueWork()
|
||||
work?.intent?.also { intent ->
|
||||
tasks.add(
|
||||
RunnableWorkTask(
|
||||
source = work,
|
||||
task = WorkTask(
|
||||
jobId = this.jobId,
|
||||
type = intent.getStringExtra("task-type")!!,
|
||||
jsonPayload = intent.getStringExtra("task-payload")!!,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
var work: JobWorkItem?
|
||||
val tasks = mutableListOf<RunnableWorkTask>()
|
||||
do {
|
||||
work = this.dequeueWork()
|
||||
work?.intent?.also { intent ->
|
||||
tasks.add(
|
||||
RunnableWorkTask(
|
||||
source = work,
|
||||
task = WorkTask(
|
||||
jobId = this.jobId,
|
||||
type = intent.getStringExtra("task-type")!!,
|
||||
jsonPayload = intent.getStringExtra("task-payload")!!,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
} while (work != null)
|
||||
return tasks
|
||||
} else {
|
||||
return listOf(
|
||||
RunnableWorkTask(
|
||||
source = null,
|
||||
task = WorkTask(
|
||||
jobId = this.jobId,
|
||||
type = this.extras.getString("task-type")!!,
|
||||
jsonPayload = this.extras.getString("task-payload")!!,
|
||||
)
|
||||
)
|
||||
}
|
||||
} while (work != null)
|
||||
return tasks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopJob(params: JobParameters): Boolean {
|
||||
|
|
|
@ -6,6 +6,7 @@ import android.app.job.JobWorkItem
|
|||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
|
||||
internal class WorkSchedulingJobScheduler(
|
||||
private val context: Context,
|
||||
|
@ -23,12 +24,17 @@ internal class WorkSchedulingJobScheduler(
|
|||
.setRequiresDeviceIdle(false)
|
||||
.build()
|
||||
|
||||
val item = JobWorkItem(
|
||||
Intent()
|
||||
.putExtra("task-type", task.type)
|
||||
.putExtra("task-payload", task.jsonPayload)
|
||||
)
|
||||
|
||||
jobScheduler.enqueue(job, item)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val item = JobWorkItem(
|
||||
Intent()
|
||||
.putExtra("task-type", task.type)
|
||||
.putExtra("task-payload", task.jsonPayload)
|
||||
)
|
||||
jobScheduler.enqueue(job, item)
|
||||
} else {
|
||||
job.extras.putString("task-type", task.type)
|
||||
job.extras.putString("task-payload", task.jsonPayload)
|
||||
jobScheduler.schedule(job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,5 +3,5 @@ plugins {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly 'org.json:json:20211205'
|
||||
compileOnly 'org.json:json:20220320'
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package app.dapk.st.olm
|
||||
|
||||
import app.dapk.st.core.Base64
|
||||
import app.dapk.st.domain.OlmPersistence
|
||||
import app.dapk.st.domain.SerializedObject
|
||||
import app.dapk.st.matrix.common.Curve25519
|
||||
|
@ -10,10 +11,10 @@ import org.matrix.olm.OlmInboundGroupSession
|
|||
import org.matrix.olm.OlmOutboundGroupSession
|
||||
import org.matrix.olm.OlmSession
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
|
||||
class OlmPersistenceWrapper(
|
||||
private val olmPersistence: OlmPersistence,
|
||||
private val base64: Base64,
|
||||
) : OlmStore {
|
||||
|
||||
override suspend fun read(): OlmAccount? {
|
||||
|
@ -49,21 +50,21 @@ class OlmPersistenceWrapper(
|
|||
override suspend fun readInbound(sessionId: SessionId): OlmInboundGroupSession? {
|
||||
return olmPersistence.readInbound(sessionId)?.value?.deserialize()
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Serializable> T.serialize(): String {
|
||||
val baos = ByteArrayOutputStream()
|
||||
ObjectOutputStream(baos).use {
|
||||
it.writeObject(this)
|
||||
private fun <T : Serializable> T.serialize(): String {
|
||||
val baos = ByteArrayOutputStream()
|
||||
ObjectOutputStream(baos).use {
|
||||
it.writeObject(this)
|
||||
}
|
||||
return base64.encode(baos.toByteArray())
|
||||
}
|
||||
return Base64.getEncoder().encode(baos.toByteArray()).toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Serializable> String.deserialize(): T {
|
||||
val decoded = Base64.getDecoder().decode(this)
|
||||
val baos = ByteArrayInputStream(decoded)
|
||||
return ObjectInputStream(baos).use {
|
||||
it.readObject() as T
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Serializable> String.deserialize(): T {
|
||||
val decoded = base64.decode(this)
|
||||
val baos = ByteArrayInputStream(decoded)
|
||||
return ObjectInputStream(baos).use {
|
||||
it.readObject() as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -254,7 +254,7 @@ class OlmWrapper(
|
|||
|
||||
return readSession.firstNotNullOfOrNull { (_, session) ->
|
||||
kotlin.runCatching {
|
||||
when (type.toInt()) {
|
||||
when (type) {
|
||||
OlmMessage.MESSAGE_TYPE_PRE_KEY -> {
|
||||
if (session.matchesInboundSession(body.value)) {
|
||||
logger.matrixLog(CRYPTO, "matched inbound session, attempting decrypt")
|
||||
|
@ -270,6 +270,8 @@ class OlmWrapper(
|
|||
session.decryptMessage(olmMessage)?.let { JsonString(it) }?.also {
|
||||
logger.crypto("alt flow identity: $senderKey : ${session.sessionIdentifier()}")
|
||||
olmStore.persistSession(senderKey, SessionId(session.sessionIdentifier()), session)
|
||||
}.also {
|
||||
session.releaseSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -287,6 +289,8 @@ class OlmWrapper(
|
|||
}.ifNull {
|
||||
logger.matrixLog(CRYPTO, "failed to decrypt olm session")
|
||||
DecryptionResult.Failed(errors.joinToString { it.message ?: "N/A" })
|
||||
}.also {
|
||||
readSession.forEach { it.second.releaseSession() }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -310,7 +314,9 @@ class OlmWrapper(
|
|||
errorTracker.track(it)
|
||||
DecryptionResult.Failed(it.message ?: "Unknown")
|
||||
}
|
||||
)
|
||||
).also {
|
||||
megolmSession.releaseSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
@ -35,4 +35,12 @@ class MemberPersistence(
|
|||
.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) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ import app.dapk.st.core.extensions.unsafeLazy
|
|||
import app.dapk.st.domain.eventlog.EventLogPersistence
|
||||
import app.dapk.st.domain.localecho.LocalEchoPersistence
|
||||
import app.dapk.st.domain.profile.ProfilePersistence
|
||||
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
|
||||
import app.dapk.st.domain.sync.OverviewPersistence
|
||||
import app.dapk.st.domain.sync.RoomPersistence
|
||||
import app.dapk.st.matrix.common.CredentialsStore
|
||||
|
@ -34,6 +35,10 @@ class StoreModule(
|
|||
fun filterStore(): FilterStore = FilterPreferences(preferences)
|
||||
val localEchoStore: LocalEchoStore by unsafeLazy { LocalEchoPersistence(errorTracker, database) }
|
||||
|
||||
fun pushStore() = PushTokenRegistrarPreferences(preferences)
|
||||
|
||||
fun applicationStore() = ApplicationPreferences(preferences)
|
||||
|
||||
fun olmStore() = OlmPersistence(database, credentialsStore())
|
||||
fun knownDevicesStore() = DevicePersistence(database, KnownDevicesCache(), coroutineDispatchers)
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package app.dapk.st.domain
|
||||
|
||||
import app.dapk.st.core.AppLogTag
|
||||
import app.dapk.st.core.log
|
||||
import app.dapk.st.matrix.common.SyncToken
|
||||
import app.dapk.st.matrix.sync.SyncStore
|
||||
import app.dapk.st.matrix.sync.SyncStore.SyncKey
|
||||
|
|
|
@ -33,11 +33,13 @@ class LocalEchoPersistence(
|
|||
inMemoryEchos.value = echos.groupBy {
|
||||
when (val message = it.message) {
|
||||
is MessageService.Message.TextMessage -> message.roomId
|
||||
is MessageService.Message.ImageMessage -> message.roomId
|
||||
}
|
||||
}.mapValues {
|
||||
it.value.associateBy {
|
||||
when (val message = it.message) {
|
||||
is MessageService.Message.TextMessage -> message.localId
|
||||
is MessageService.Message.ImageMessage -> message.localId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -56,6 +58,7 @@ class LocalEchoPersistence(
|
|||
database.transaction {
|
||||
when (message) {
|
||||
is MessageService.Message.TextMessage -> database.localEchoQueries.delete(message.localId)
|
||||
is MessageService.Message.ImageMessage -> database.localEchoQueries.delete(message.localId)
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
|
@ -84,6 +87,14 @@ class LocalEchoPersistence(
|
|||
Json.encodeToString(MessageService.LocalEcho.serializer(), localEcho)
|
||||
)
|
||||
)
|
||||
|
||||
is MessageService.Message.ImageMessage -> database.localEchoQueries.insert(
|
||||
DbLocalEcho(
|
||||
message.localId,
|
||||
message.roomId.value,
|
||||
Json.encodeToString(MessageService.LocalEcho.serializer(), localEcho)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -11,10 +11,8 @@ import app.dapk.st.matrix.sync.RoomInvite
|
|||
import app.dapk.st.matrix.sync.RoomOverview
|
||||
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
||||
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private val json = Json
|
||||
|
@ -31,11 +29,22 @@ internal class OverviewPersistence(
|
|||
.map { it.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } }
|
||||
}
|
||||
|
||||
override suspend fun removeRooms(roomsToRemove: List<RoomId>) {
|
||||
dispatchers.withIoContext {
|
||||
database.transaction {
|
||||
roomsToRemove.forEach {
|
||||
database.inviteStateQueries.remove(it.value)
|
||||
database.overviewStateQueries.remove(it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun persistInvites(invites: List<RoomInvite>) {
|
||||
dispatchers.withIoContext {
|
||||
database.inviteStateQueries.transaction {
|
||||
invites.forEach {
|
||||
database.inviteStateQueries.insert(it.roomId.value)
|
||||
database.inviteStateQueries.insert(it.roomId.value, json.encodeToString(RoomInvite.serializer(), it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -45,7 +54,15 @@ internal class OverviewPersistence(
|
|||
return database.inviteStateQueries.selectAll()
|
||||
.asFlow()
|
||||
.mapToList()
|
||||
.map { it.map { RoomInvite(RoomId(it)) } }
|
||||
.map { it.map { json.decodeFromString(RoomInvite.serializer(), it.blob) } }
|
||||
}
|
||||
|
||||
override suspend fun removeInvites(invites: List<RoomId>) {
|
||||
dispatchers.withIoContext {
|
||||
database.inviteStateQueries.transaction {
|
||||
invites.forEach { database.inviteStateQueries.remove(it.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun persist(overviewState: OverviewState) {
|
||||
|
@ -59,7 +76,7 @@ internal class OverviewPersistence(
|
|||
}
|
||||
|
||||
override suspend fun retrieve(): OverviewState {
|
||||
return withContext(Dispatchers.IO) {
|
||||
return dispatchers.withIoContext {
|
||||
val overviews = database.overviewStateQueries.selectAll().executeAsList()
|
||||
overviews.map { json.decodeFromString(RoomOverview.serializer(), it.blob) }
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
val overviewFlow = database.overviewStateQueries.selectRoom(roomId.value).asFlow().mapToOneNotNull().map {
|
||||
json.decodeFromString(RoomOverview.serializer(), it)
|
||||
|
@ -75,7 +82,7 @@ internal class RoomPersistence(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
|
||||
override fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
|
||||
return database.roomEventQueries.selectAllUnread()
|
||||
.asFlow()
|
||||
.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()
|
||||
.asFlow()
|
||||
.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)
|
||||
.asFlow()
|
||||
.mapToOneNotNull()
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
CREATE TABLE dbInviteState (
|
||||
room_id TEXT NOT NULL,
|
||||
blob TEXT NOT NULL,
|
||||
PRIMARY KEY (room_id)
|
||||
);
|
||||
|
||||
selectAll:
|
||||
SELECT room_id
|
||||
SELECT room_id, blob
|
||||
FROM dbInviteState;
|
||||
|
||||
insert:
|
||||
INSERT OR REPLACE INTO dbInviteState(room_id)
|
||||
VALUES (?);
|
||||
INSERT OR REPLACE INTO dbInviteState(room_id, blob)
|
||||
VALUES (?, ?);
|
||||
|
||||
remove:
|
||||
DELETE FROM dbInviteState
|
||||
WHERE room_id = ?;
|
|
@ -19,3 +19,7 @@ WHERE room_id = ?;
|
|||
insert:
|
||||
INSERT OR REPLACE INTO dbOverviewState(room_id, latest_activity_timestamp_utc, read_marker, blob)
|
||||
VALUES (?, ?, ?, ?);
|
||||
|
||||
remove:
|
||||
DELETE FROM dbOverviewState
|
||||
WHERE room_id = ?;
|
|
@ -30,4 +30,10 @@ WHERE event_id = ?;
|
|||
selectAllUnread:
|
||||
SELECT dbRoomEvent.blob, dbRoomEvent.room_id
|
||||
FROM dbUnreadEvent
|
||||
INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id;
|
||||
INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id
|
||||
ORDER BY dbRoomEvent.timestamp_utc DESC
|
||||
LIMIT 100;
|
||||
|
||||
remove:
|
||||
DELETE FROM dbRoomEvent
|
||||
WHERE room_id = ?;
|
|
@ -10,6 +10,13 @@ SELECT blob
|
|||
FROM dbRoomMember
|
||||
WHERE room_id = ? AND user_id IN ?;
|
||||
|
||||
selectMembersByRoom:
|
||||
SELECT blob
|
||||
FROM dbRoomMember
|
||||
WHERE room_id = ?
|
||||
LIMIT ?;
|
||||
|
||||
|
||||
insert:
|
||||
INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob)
|
||||
VALUES (?, ?, ?);
|
|
@ -1,13 +1,23 @@
|
|||
applyAndroidLibraryModule(project)
|
||||
applyAndroidComposeLibraryModule(project)
|
||||
|
||||
dependencies {
|
||||
implementation project(":matrix:services:sync")
|
||||
implementation project(":matrix:services:message")
|
||||
implementation project(":matrix:services:room")
|
||||
implementation project(":domains:android:core")
|
||||
implementation project(":domains:android:compose-core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(":features:messenger")
|
||||
implementation project(":core")
|
||||
implementation project(":design-library")
|
||||
implementation("io.coil-kt:coil-compose:1.4.0")
|
||||
implementation Dependencies.mavenCentral.coil
|
||||
|
||||
kotlinTest(it)
|
||||
|
||||
androidImportFixturesWorkaround(project, project(":matrix:services:sync"))
|
||||
androidImportFixturesWorkaround(project, project(":matrix:services:message"))
|
||||
androidImportFixturesWorkaround(project, project(":matrix:common"))
|
||||
androidImportFixturesWorkaround(project, project(":core"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:store"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
|
||||
}
|
|
@ -10,29 +10,34 @@ import androidx.compose.foundation.lazy.LazyListState
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.dapk.st.core.LifecycleEffect
|
||||
import app.dapk.st.core.StartObserving
|
||||
import app.dapk.st.core.components.CenteredLoading
|
||||
import app.dapk.st.design.components.CircleishAvatar
|
||||
import app.dapk.st.design.components.GenericEmpty
|
||||
import app.dapk.st.design.components.GenericError
|
||||
import app.dapk.st.design.components.Toolbar
|
||||
import app.dapk.st.directory.DirectoryEvent.OpenDownloadUrl
|
||||
import app.dapk.st.directory.DirectoryScreenState.Content
|
||||
import app.dapk.st.directory.DirectoryScreenState.EmptyLoading
|
||||
import app.dapk.st.design.components.CircleishAvatar
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.sync.RoomOverview
|
||||
import app.dapk.st.matrix.sync.SyncService
|
||||
|
@ -44,36 +49,56 @@ import java.time.ZoneId
|
|||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun DirectoryScreen(directoryViewModel: DirectoryViewModel) {
|
||||
val state = directoryViewModel.state
|
||||
directoryViewModel.ObserveEvents()
|
||||
|
||||
val listState: LazyListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = 0,
|
||||
)
|
||||
|
||||
val toolbarHeight = 72.dp
|
||||
val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() }
|
||||
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
|
||||
|
||||
directoryViewModel.ObserveEvents(listState, toolbarOffsetHeightPx)
|
||||
|
||||
LifecycleEffect(
|
||||
onStart = { directoryViewModel.start() },
|
||||
onStop = { directoryViewModel.stop() }
|
||||
)
|
||||
|
||||
when (state) {
|
||||
is Content -> {
|
||||
Content(state)
|
||||
}
|
||||
EmptyLoading -> CenteredLoading()
|
||||
is Error -> {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Something went wrong...")
|
||||
Button(onClick = {}) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.y
|
||||
val newOffset = toolbarOffsetHeightPx.value + delta
|
||||
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
) {
|
||||
when (state) {
|
||||
EmptyLoading -> CenteredLoading()
|
||||
DirectoryScreenState.Empty -> GenericEmpty()
|
||||
is Error -> GenericError {
|
||||
// TODO
|
||||
}
|
||||
is Content -> Content(listState, state)
|
||||
}
|
||||
Toolbar(title = "Messages", offset = { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DirectoryViewModel.ObserveEvents() {
|
||||
private fun DirectoryViewModel.ObserveEvents(listState: LazyListState, toolbarPosition: MutableState<Float>) {
|
||||
val context = LocalContext.current
|
||||
StartObserving {
|
||||
this@ObserveEvents.events.launch {
|
||||
|
@ -81,21 +106,24 @@ private fun DirectoryViewModel.ObserveEvents() {
|
|||
is OpenDownloadUrl -> {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.url)))
|
||||
}
|
||||
DirectoryEvent.ScrollToTop -> {
|
||||
toolbarPosition.value = 0f
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val clock = Clock.systemUTC()
|
||||
|
||||
@Composable
|
||||
private fun Content(state: Content) {
|
||||
private fun Content(listState: LazyListState, state: Content) {
|
||||
val context = LocalContext.current
|
||||
val navigateToRoom = { roomId: RoomId ->
|
||||
context.startActivity(MessengerActivity.newInstance(context, roomId))
|
||||
}
|
||||
val clock = Clock.systemUTC()
|
||||
val listState: LazyListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = 0,
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = state.overviewState) {
|
||||
|
@ -103,7 +131,7 @@ private fun Content(state: Content) {
|
|||
scope.launch { listState.scrollToItem(0) }
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(top = 72.dp)) {
|
||||
items(
|
||||
items = state.overviewState,
|
||||
key = { it.overview.roomId.value },
|
||||
|
@ -119,9 +147,13 @@ private fun DirectoryItem(room: RoomFoo, onClick: (RoomId) -> Unit, clock: Clock
|
|||
val roomName = overview.roomName ?: "Empty room"
|
||||
val hasUnread = room.unreadCount.value > 0
|
||||
|
||||
Box(Modifier.height(IntrinsicSize.Min).fillMaxWidth().clickable {
|
||||
onClick(overview.roomId)
|
||||
}) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onClick(overview.roomId)
|
||||
}) {
|
||||
Row(Modifier.padding(20.dp)) {
|
||||
val secondaryText = MaterialTheme.colors.onBackground.copy(alpha = 0.5f)
|
||||
|
||||
|
@ -164,13 +196,24 @@ private fun DirectoryItem(room: RoomFoo, onClick: (RoomId) -> Unit, clock: Clock
|
|||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Box(Modifier.align(Alignment.CenterVertically)) {
|
||||
Box(
|
||||
Modifier.align(Alignment.Center).background(color = MaterialTheme.colors.primary, shape = CircleShape).size(22.dp),
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.background(color = MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.size(22.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val unreadTextSize = when (room.unreadCount.value > 99) {
|
||||
true -> 9.sp
|
||||
false -> 10.sp
|
||||
}
|
||||
val unreadLabelContent = when {
|
||||
room.unreadCount.value > 99 -> "99+"
|
||||
else -> room.unreadCount.value.toString()
|
||||
}
|
||||
Text(
|
||||
fontSize = 10.sp,
|
||||
fontSize = unreadTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
text = room.unreadCount.value.toString(),
|
||||
text = unreadLabelContent,
|
||||
color = MaterialTheme.colors.onPrimary
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package app.dapk.st.directory
|
|||
sealed interface DirectoryScreenState {
|
||||
|
||||
object EmptyLoading : DirectoryScreenState
|
||||
object Empty : DirectoryScreenState
|
||||
data class Content(
|
||||
val overviewState: DirectoryState,
|
||||
) : DirectoryScreenState
|
||||
|
@ -10,5 +11,6 @@ sealed interface DirectoryScreenState {
|
|||
|
||||
sealed interface DirectoryEvent {
|
||||
data class OpenDownloadUrl(val url: String) : DirectoryEvent
|
||||
object ScrollToTop : DirectoryEvent
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue