Merge pull request #103 from ouchadam/release-candidate

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -17,19 +17,11 @@ jobs:
steps:
- 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

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

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

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

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

View File

@ -17,29 +17,26 @@ jobs:
steps:
- 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

View File

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

View File

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

View File

@ -1,13 +1,18 @@
# SmallTalk [![Assemble](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml/badge.svg)](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml) [![codecov](https://codecov.io/gh/ouchadam/small-talk/branch/main/graph/badge.svg?token=ETFSLZ9FCI)](https://codecov.io/gh/ouchadam/small-talk) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
# SmallTalk [![Assemble](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml/badge.svg)](https://github.com/ouchadam/small-talk/actions/workflows/assemble.yml) [![codecov](https://codecov.io/gh/ouchadam/small-talk/branch/main/graph/badge.svg?token=ETFSLZ9FCI)](https://codecov.io/gh/ouchadam/small-talk) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ![](https://img.shields.io/github/v/release/ouchadam/small-talk?include_prereleases) ![](https://img.shields.io/badge/%5Bmatrix%5D%20-%23small--talk%3Aiswell.cool-blueviolet)
`SmallTalk` is a minimal, modern, friends and family focused Android messenger. Heavily inspired by Whatsapp and Signal, powered by Matrix.
![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

View File

@ -10,10 +10,21 @@ android {
ndkVersion "25.0.8141415"
defaultConfig {
applicationId "app.dapk.st"
versionCode 2
versionName "0.0.1-alpha1"
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 {
abi.enableSplit true
@ -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
}

View File

@ -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>

View File

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

View File

@ -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)
}
}

View File

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

View File

@ -1,16 +1,17 @@
package app.dapk.st.graph
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")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ buildscript {
classpath Dependencies.mavenCentral.kotlinGradlePlugin
classpath Dependencies.mavenCentral.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 ->

View File

@ -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
}

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}

View File

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

View File

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

View File

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

View File

@ -1,9 +1,6 @@
package test
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)
}

View File

@ -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)

View File

@ -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 {

View File

@ -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
}

View File

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

View File

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

View File

@ -10,12 +10,14 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.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)

View File

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

View File

@ -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)
}
}

View File

@ -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) {

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

@ -1,12 +1,13 @@
package app.dapk.st.imageloader
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)
}
}

View File

@ -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
}

View File

@ -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>

View File

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

View File

@ -1,16 +1,40 @@
package app.dapk.st.push
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,
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,6 +13,8 @@ if (localProperties.exists()) {
dependencies {
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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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")
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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,6 +45,7 @@ class WorkAndroidService : JobService() {
}
private fun JobParameters.collectAllTasks(): List<RunnableWorkTask> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
var work: JobWorkItem?
val tasks = mutableListOf<RunnableWorkTask>()
do {
@ -58,6 +64,18 @@ class WorkAndroidService : JobService() {
}
} 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")!!,
)
)
)
}
}
override fun onStopJob(params: JobParameters): Boolean {

View File

@ -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()
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)
}
}
}

View File

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

View File

@ -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)
}
return Base64.getEncoder().encode(baos.toByteArray()).toString(Charsets.UTF_8)
return base64.encode(baos.toByteArray())
}
@Suppress("UNCHECKED_CAST")
private fun <T : Serializable> String.deserialize(): T {
val decoded = Base64.getDecoder().decode(this)
val decoded = base64.decode(this)
val baos = ByteArrayInputStream(decoded)
return ObjectInputStream(baos).use {
it.readObject() as T
}
}
}

View File

@ -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()
}
}
}
}

View File

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

View File

@ -35,4 +35,12 @@ class MemberPersistence(
.map { Json.decodeFromString(RoomMember.serializer(), it) }
}
}
override suspend fun query(roomId: RoomId, limit: Int): List<RoomMember> {
return coroutineDispatchers.withIoContext {
database.roomMemberQueries.selectMembersByRoom(roomId.value, limit.toLong())
.executeAsList()
.map { Json.decodeFromString(RoomMember.serializer(), it) }
}
}
}

View File

@ -7,6 +7,7 @@ import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.domain.eventlog.EventLogPersistence
import app.dapk.st.domain.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)

View File

@ -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

View File

@ -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)
)
)
}
}
}

View File

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

View File

@ -11,10 +11,8 @@ import app.dapk.st.matrix.sync.RoomInvite
import app.dapk.st.matrix.sync.RoomOverview
import 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) }
}

View File

@ -37,6 +37,13 @@ internal class RoomPersistence(
}
}
override suspend fun remove(rooms: List<RoomId>) {
coroutineDispatchers
database.roomEventQueries.transaction {
rooms.forEach { database.roomEventQueries.remove(it.value) }
}
}
override fun latest(roomId: RoomId): Flow<RoomState> {
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()

View File

@ -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 = ?;

View File

@ -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 = ?;

View File

@ -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 = ?;

View File

@ -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 (?, ?, ?);

View File

@ -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"))
}

View File

@ -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() }
)
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) {
is Content -> {
Content(state)
}
EmptyLoading -> CenteredLoading()
is Error -> {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Something went wrong...")
Button(onClick = {}) {
Text("Retry")
}
}
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,7 +147,11 @@ 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 {
Box(
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth()
.clickable {
onClick(overview.roomId)
}) {
Row(Modifier.padding(20.dp)) {
@ -164,13 +196,24 @@ private fun DirectoryItem(room: RoomFoo, onClick: (RoomId) -> Unit, clock: Clock
Spacer(modifier = Modifier.width(6.dp))
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
)
}

View File

@ -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