Merge pull request #169 from ouchadam/release-candidate

[Auto] Release Candidate
This commit is contained in:
Adam Brown 2022-09-29 20:08:08 +01:00 committed by GitHub
commit 0a6c4df6c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 881 additions and 171 deletions

View File

@ -34,11 +34,14 @@ jobs:
touch .secrets/service-account.json touch .secrets/service-account.json
touch .secrets/matrix.json touch .secrets/matrix.json
echo -n '${{ secrets.UPLOAD_KEY }}' | base64 --decode >> .secrets/upload-key.jks echo -n '${{ secrets.UPLOAD_KEY }}' | base64 --decode >> .secrets/upload-key.jks
echo -n '${{ secrets.FDROID_KEY }}' | base64 --decode >> .secrets/fdroid.keystore
echo -n '${{ secrets.SERVICE_ACCOUNT }}' | base64 --decode >> .secrets/service-account.json echo -n '${{ secrets.SERVICE_ACCOUNT }}' | base64 --decode >> .secrets/service-account.json
echo -n '${{ secrets.MATRIX }}' | base64 --decode >> .secrets/matrix.json echo -n '${{ secrets.MATRIX }}' | base64 --decode >> .secrets/matrix.json
- name: Assemble release variant - name: Assemble release variant
run: ./tools/generate-release.sh ${{ secrets.STORE_PASS }} run: |
./tools/generate-release.sh ${{ secrets.STORE_PASS }}
./tools/generate-fdroid-release.sh ${{ secrets.FDROID_STORE_PASS }}
- uses: actions/github-script@v6 - uses: actions/github-script@v6
with: with:
@ -48,6 +51,7 @@ jobs:
const artifacts = { const artifacts = {
bundle: '${{ github.workspace }}/app/build/outputs/bundle/release/app-release.aab', bundle: '${{ github.workspace }}/app/build/outputs/bundle/release/app-release.aab',
mapping: '${{ github.workspace }}/app/build/outputs/mapping/release/mapping.txt', mapping: '${{ github.workspace }}/app/build/outputs/mapping/release/mapping.txt',
fossApkPath: '${{ github.workspace }}/app/build/outputs/apk/release/app-foss-release-signed.apk',
} }
await publishRelease(github, artifacts) await publishRelease(github, artifacts)

View File

@ -1,4 +1,4 @@
name: Nightly name: Release Train
on: on:
workflow_dispatch: workflow_dispatch:

View File

@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.dapk.st"> package="app.dapk.st">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application <application
android:name="app.dapk.st.SmallTalkApplication" android:name="app.dapk.st.SmallTalkApplication"
@ -17,13 +19,13 @@
android:exported="true" android:exported="true"
android:targetActivity="app.dapk.st.home.MainActivity"> android:targetActivity="app.dapk.st.home.MainActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts"/>
</activity-alias> </activity-alias>

View File

@ -15,6 +15,7 @@ import app.dapk.st.graph.AppModule
import app.dapk.st.home.HomeModule import app.dapk.st.home.HomeModule
import app.dapk.st.login.LoginModule import app.dapk.st.login.LoginModule
import app.dapk.st.messenger.MessengerModule import app.dapk.st.messenger.MessengerModule
import app.dapk.st.messenger.gallery.ImageGalleryModule
import app.dapk.st.notifications.NotificationsModule import app.dapk.st.notifications.NotificationsModule
import app.dapk.st.profile.ProfileModule import app.dapk.st.profile.ProfileModule
import app.dapk.st.push.PushModule import app.dapk.st.push.PushModule
@ -54,7 +55,9 @@ class SmallTalkApplication : Application(), ModuleProvider {
private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) { private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) {
applicationScope.launch { applicationScope.launch {
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken() storeModule.credentialsStore().credentials()?.let {
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
}
storeModule.localEchoStore.preload() storeModule.localEchoStore.preload()
} }
@ -79,6 +82,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule
CoreAndroidModule::class -> appModule.coreAndroidModule CoreAndroidModule::class -> appModule.coreAndroidModule
ShareEntryModule::class -> featureModules.shareEntryModule ShareEntryModule::class -> featureModules.shareEntryModule
ImageGalleryModule::class -> featureModules.imageGalleryModule
else -> throw IllegalArgumentException("Unknown: $klass") else -> throw IllegalArgumentException("Unknown: $klass")
} as T } as T
} }

View File

@ -43,6 +43,7 @@ import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent
import app.dapk.st.matrix.sync.internal.room.MessageDecrypter import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
import app.dapk.st.messenger.MessengerActivity import app.dapk.st.messenger.MessengerActivity
import app.dapk.st.messenger.MessengerModule import app.dapk.st.messenger.MessengerModule
import app.dapk.st.messenger.gallery.ImageGalleryModule
import app.dapk.st.navigator.IntentFactory import app.dapk.st.navigator.IntentFactory
import app.dapk.st.navigator.MessageAttachment import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.notifications.MatrixPushHandler import app.dapk.st.notifications.MatrixPushHandler
@ -217,6 +218,10 @@ internal class FeatureModules internal constructor(
ShareEntryModule(matrixModules.sync, matrixModules.room) ShareEntryModule(matrixModules.sync, matrixModules.room)
} }
val imageGalleryModule by unsafeLazy {
ImageGalleryModule(context.contentResolver, coroutineDispatchers)
}
val pushModule by unsafeLazy { val pushModule by unsafeLazy {
domainModules.pushModule domainModules.pushModule
} }

View File

@ -132,7 +132,7 @@ ext.kotlinTest = { dependencies ->
dependencies.testImplementation Dependencies.mavenCentral.kluent dependencies.testImplementation Dependencies.mavenCentral.kluent
dependencies.testImplementation Dependencies.mavenCentral.kotlinTest dependencies.testImplementation Dependencies.mavenCentral.kotlinTest
dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10" dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10"
dependencies.testImplementation 'io.mockk:mockk:1.12.8' dependencies.testImplementation 'io.mockk:mockk:1.13.2'
dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1' dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1'
@ -140,7 +140,7 @@ ext.kotlinTest = { dependencies ->
} }
ext.kotlinFixtures = { dependencies -> ext.kotlinFixtures = { dependencies ->
dependencies.testFixturesImplementation 'io.mockk:mockk:1.12.7' dependencies.testFixturesImplementation 'io.mockk:mockk:1.13.1'
dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent
dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
} }

View File

@ -82,10 +82,12 @@ ext.Dependencies.with {
includeGroup "io.ktor" includeGroup "io.ktor"
includeGroup "io.coil-kt" includeGroup "io.coil-kt"
includeGroup "io.mockk" includeGroup "io.mockk"
includeGroup "io.perfmark"
includeGroup "info.picocli" includeGroup "info.picocli"
includeGroup "us.fatehi" includeGroup "us.fatehi"
includeGroup "jakarta.xml.bind" includeGroup "jakarta.xml.bind"
includeGroup "jakarta.activation" includeGroup "jakarta.activation"
includeGroup "javax.annotation"
includeGroup "javax.inject" includeGroup "javax.inject"
includeGroup "junit" includeGroup "junit"
includeGroup "jline" includeGroup "jline"
@ -102,14 +104,14 @@ ext.Dependencies.with {
google = new DependenciesContainer() google = new DependenciesContainer()
google.with { google.with {
androidGradlePlugin = "com.android.tools.build:gradle:7.2.2" androidGradlePlugin = "com.android.tools.build:gradle:7.3.0"
androidxComposeUi = "androidx.compose.ui:ui:${composeVer}" androidxComposeUi = "androidx.compose.ui:ui:${composeVer}"
androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}" androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}"
androidxComposeMaterial = "androidx.compose.material3:material3:1.0.0-beta01" androidxComposeMaterial = "androidx.compose.material3:material3:1.0.0-beta01"
androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}" androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}"
androidxActivityCompose = "androidx.activity:activity-compose:1.4.0" androidxActivityCompose = "androidx.activity:activity-compose:1.4.0"
kotlinCompilerExtensionVersion = "1.3.0" kotlinCompilerExtensionVersion = "1.3.1"
firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.1" firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.1"
jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5" jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"
@ -143,7 +145,7 @@ ext.Dependencies.with {
junit = "junit:junit:4.13.2" junit = "junit:junit:4.13.2"
kluent = "org.amshove.kluent:kluent:1.68" kluent = "org.amshove.kluent:kluent:1.68"
mockk = 'io.mockk:mockk:1.12.8' mockk = 'io.mockk:mockk:1.13.2'
matrixOlm = "org.matrix.android:olm-sdk:3.2.12" matrixOlm = "org.matrix.android:olm-sdk:3.2.12"
} }

View File

@ -31,8 +31,8 @@ private fun createExtended(scheme: ColorScheme) = ExtendedColors(
onSelfBubble = scheme.onPrimary, onSelfBubble = scheme.onPrimary,
othersBubble = scheme.secondaryContainer, othersBubble = scheme.secondaryContainer,
onOthersBubble = scheme.onSecondaryContainer, onOthersBubble = scheme.onSecondaryContainer,
selfBubbleReplyBackground = Color(0x40EAEAEA), selfBubbleReplyBackground = scheme.primary.copy(alpha = 0.2f),
otherBubbleReplyBackground = Color(0x20EAEAEA), otherBubbleReplyBackground = scheme.primary.copy(alpha = 0.2f),
missingImageColors = listOf( missingImageColors = listOf(
Color(0xFFf7c7f7) to Color(0xFFdf20de), Color(0xFFf7c7f7) to Color(0xFFdf20de),
Color(0xFFe5d7f6) to Color(0xFF7b30cf), Color(0xFFe5d7f6) to Color(0xFF7b30cf),

View File

@ -1,15 +1,19 @@
package app.dapk.st.core package app.dapk.st.core
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.design.components.SmallTalkTheme import app.dapk.st.design.components.SmallTalkTheme
import app.dapk.st.design.components.ThemeConfig import app.dapk.st.design.components.ThemeConfig
import app.dapk.st.navigator.navigator import app.dapk.st.navigator.navigator
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import androidx.activity.compose.setContent as _setContent import androidx.activity.compose.setContent as _setContent
abstract class DapkActivity : ComponentActivity(), EffectScope { abstract class DapkActivity : ComponentActivity(), EffectScope {
@ -27,7 +31,6 @@ abstract class DapkActivity : ComponentActivity(), EffectScope {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
this.themeConfig = ThemeConfig(themeStore.isMaterialYouEnabled()) this.themeConfig = ThemeConfig(themeStore.isMaterialYouEnabled())
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
} }
@ -58,10 +61,40 @@ abstract class DapkActivity : ComponentActivity(), EffectScope {
} }
} }
@Suppress("OVERRIDE_DEPRECATION")
override fun onBackPressed() { override fun onBackPressed() {
if (needsBackLeakWorkaround && !onBackPressedDispatcher.hasEnabledCallbacks()) { if (needsBackLeakWorkaround && !onBackPressedDispatcher.hasEnabledCallbacks()) {
finishAfterTransition() finishAfterTransition()
} else } else {
super.onBackPressed() super.onBackPressed()
}
}
protected suspend fun ensurePermission(permission: String): PermissionResult {
return when {
checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED -> PermissionResult.Granted
shouldShowRequestPermissionRationale(permission) -> PermissionResult.ShowRational
else -> {
val isGranted = suspendCancellableCoroutine { continuation ->
val callback: (result: Boolean) -> Unit = { result -> continuation.resume(result) }
val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission(), callback)
launcher.launch(permission)
continuation.invokeOnCancellation { launcher.unregister() }
}
when (isGranted) {
true -> PermissionResult.Granted
false -> PermissionResult.Denied
}
}
}
} }
} }
sealed interface PermissionResult {
object Granted : PermissionResult
object ShowRational : PermissionResult
object Denied : PermissionResult
}

View File

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

View File

@ -170,6 +170,8 @@ class OlmWrapper(
val inBound = OlmInboundGroupSession(roomCryptoSession.key) val inBound = OlmInboundGroupSession(roomCryptoSession.key)
olmStore.persist(roomCryptoSession.id, inBound) olmStore.persist(roomCryptoSession.id, inBound)
logger.crypto("Creating megolm: ${roomCryptoSession.id}")
return roomCryptoSession return roomCryptoSession
} }
@ -181,7 +183,7 @@ class OlmWrapper(
private suspend fun deviceCrypto(input: OlmSessionInput): DeviceCryptoSession? { private suspend fun deviceCrypto(input: OlmSessionInput): DeviceCryptoSession? {
return olmStore.readSessions(listOf(input.identity))?.let { return olmStore.readSessions(listOf(input.identity))?.let {
DeviceCryptoSession( DeviceCryptoSession(
input.deviceId, input.userId, input.identity, input.fingerprint, it input.deviceId, input.userId, input.identity, input.fingerprint, it.map { it.second }
) )
} }
} }

View File

@ -27,10 +27,10 @@ internal class RoomPersistence(
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
) : RoomStore { ) : RoomStore {
override suspend fun persist(roomId: RoomId, state: RoomState) { override suspend fun persist(roomId: RoomId, events: List<RoomEvent>) {
coroutineDispatchers.withIoContext { coroutineDispatchers.withIoContext {
database.transaction { database.transaction {
state.events.forEach { events.forEach {
database.roomEventQueries.insertRoomEvent(roomId, it) database.roomEventQueries.insertRoomEvent(roomId, it)
} }
} }
@ -38,9 +38,16 @@ internal class RoomPersistence(
} }
override suspend fun remove(rooms: List<RoomId>) { override suspend fun remove(rooms: List<RoomId>) {
coroutineDispatchers coroutineDispatchers.withIoContext {
database.roomEventQueries.transaction { database.roomEventQueries.transaction {
rooms.forEach { database.roomEventQueries.remove(it.value) } rooms.forEach { database.roomEventQueries.remove(it.value) }
}
}
}
override suspend fun remove(eventId: EventId) {
coroutineDispatchers.withIoContext {
database.roomEventQueries.removeEvent(eventId.value)
} }
} }

View File

@ -36,4 +36,8 @@ LIMIT 100;
remove: remove:
DELETE FROM dbRoomEvent DELETE FROM dbRoomEvent
WHERE room_id = ?; WHERE room_id = ?;
removeEvent:
DELETE FROM dbRoomEvent
WHERE event_id = ?;

View File

@ -13,4 +13,5 @@ dependencies {
implementation project(':domains:store') implementation project(':domains:store')
implementation project(":core") implementation project(":core")
implementation project(":design-library") implementation project(":design-library")
implementation Dependencies.mavenCentral.coil
} }

View File

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

View File

@ -27,6 +27,7 @@ class MainActivity : DapkActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
homeViewModel.events.onEach { homeViewModel.events.onEach {
when (it) { when (it) {
HomeEvent.Relaunch -> recreate() HomeEvent.Relaunch -> recreate()

View File

@ -1,14 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.dapk.st.messenger"> package="app.dapk.st.messenger">
<application> <application>
<activity <activity
android:name=".MessengerActivity" android:name=".MessengerActivity"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize"/>
<activity android:name=".roomsettings.RoomSettingsActivity" /> <activity android:name=".roomsettings.RoomSettingsActivity"/>
<activity android:name=".gallery.ImageGalleryActivity"/>
</application> </application>

View File

@ -1,7 +1,9 @@
package app.dapk.st.messenger package app.dapk.st.messenger
import android.content.Context import android.content.Context
import android.os.Environment
import app.dapk.st.core.Base64 import app.dapk.st.core.Base64
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.crypto.MediaDecrypter import app.dapk.st.matrix.crypto.MediaDecrypter
import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomEvent
import coil.ImageLoader import coil.ImageLoader
@ -14,14 +16,16 @@ import coil.request.Options
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okio.Buffer import okio.BufferedSource
import okio.Path.Companion.toOkioPath
import java.io.File
class DecryptingFetcherFactory(private val context: Context, base64: Base64) : Fetcher.Factory<RoomEvent.Image> { class DecryptingFetcherFactory(private val context: Context, base64: Base64, private val roomId: RoomId) : Fetcher.Factory<RoomEvent.Image> {
private val mediaDecrypter = MediaDecrypter(base64) private val mediaDecrypter = MediaDecrypter(base64)
override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher { override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher {
return DecryptingFetcher(data, context, mediaDecrypter) return DecryptingFetcher(data, context, mediaDecrypter, roomId)
} }
} }
@ -31,23 +35,48 @@ class DecryptingFetcher(
private val data: RoomEvent.Image, private val data: RoomEvent.Image,
private val context: Context, private val context: Context,
private val mediaDecrypter: MediaDecrypter, private val mediaDecrypter: MediaDecrypter,
roomId: RoomId,
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult { private val directory by lazy {
val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute() context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!.resolve("SmallTalk/${roomId.value}").also { it.mkdirs() }
val outputStream = when {
data.imageMeta.keys != null -> handleEncrypted(response, data.imageMeta.keys!!)
else -> response.body?.source() ?: throw IllegalArgumentException("No bitmap response found")
}
return SourceResult(ImageSource(outputStream, context), null, DataSource.NETWORK)
} }
private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { override suspend fun fetch(): FetchResult {
return response.body?.byteStream()?.let { byteStream -> val diskCacheKey = data.imageMeta.url.hashCode().toString()
Buffer().also { buffer -> val diskCachedFile = directory.resolve(diskCacheKey)
mediaDecrypter.decrypt(byteStream, keys.k, keys.iv).collect { buffer.write(it) } val path = diskCachedFile.toOkioPath()
return when {
diskCachedFile.exists() -> SourceResult(ImageSource(path), null, DataSource.DISK)
else -> {
diskCachedFile.createNewFile()
val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute()
when {
data.imageMeta.keys != null -> response.writeDecrypted(diskCachedFile, data.imageMeta.keys!!)
else -> response.body?.source()?.writeToFile(diskCachedFile) ?: throw IllegalArgumentException("No bitmap response found")
}
SourceResult(ImageSource(path), null, DataSource.NETWORK)
} }
} ?: Buffer() }
}
private fun Response.writeDecrypted(file: File, keys: RoomEvent.Image.ImageMeta.Keys) {
this.body?.byteStream()?.let { byteStream ->
file.outputStream().use { output ->
mediaDecrypter.decrypt(byteStream, keys.k, keys.iv).collect { output.write(it) }
}
}
}
}
private fun BufferedSource.writeToFile(file: File) {
this.inputStream().use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
} }
} }

View File

@ -5,16 +5,16 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import app.dapk.st.core.DapkActivity import app.dapk.st.core.*
import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.core.module
import app.dapk.st.core.viewModel
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.messenger.gallery.GetImageFromGallery
import app.dapk.st.navigator.MessageAttachment import app.dapk.st.navigator.MessageAttachment
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -50,11 +50,26 @@ class MessengerActivity : DapkActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val payload = readPayload<MessagerActivityPayload>() val payload = readPayload<MessagerActivityPayload>()
val factory = module.decryptingFetcherFactory() val factory = module.decryptingFetcherFactory(RoomId(payload.roomId))
val galleryLauncher = registerForActivityResult(GetImageFromGallery()) {
it?.let { uri ->
viewModel.post(
MessengerAction.ComposerImageUpdate(
MessageAttachment(
AndroidUri(it.toString()),
MimeType.Image,
)
)
)
}
}
setContent { setContent {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize()) {
CompositionLocalProvider(LocalDecyptingFetcherFactory provides factory) { CompositionLocalProvider(LocalDecyptingFetcherFactory provides factory) {
MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator) MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator, galleryLauncher)
} }
} }
} }

View File

@ -4,6 +4,7 @@ import android.content.Context
import app.dapk.st.core.Base64 import app.dapk.st.core.Base64
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.MessageService
import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.room.RoomService
import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.matrix.sync.RoomStore
@ -30,5 +31,5 @@ class MessengerModule(
return TimelineUseCaseImpl(syncService, messageService, roomService, mergeWithLocalEchosUseCase) return TimelineUseCaseImpl(syncService, messageService, roomService, mergeWithLocalEchosUseCase)
} }
internal fun decryptingFetcherFactory() = DecryptingFetcherFactory(context, base64) internal fun decryptingFetcherFactory(roomId: RoomId) = DecryptingFetcherFactory(context, base64, roomId)
} }

View File

@ -1,9 +1,11 @@
package app.dapk.st.messenger package app.dapk.st.messenger
import android.content.res.Configuration import android.content.res.Configuration
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.* import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -13,6 +15,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -33,6 +36,7 @@ import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.StartObserving import app.dapk.st.core.StartObserving
import app.dapk.st.core.components.CenteredLoading import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.core.extensions.takeIfContent
import app.dapk.st.design.components.* import app.dapk.st.design.components.*
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.common.UserId
@ -40,6 +44,7 @@ import app.dapk.st.matrix.sync.MessageMeta
import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomEvent.Message import app.dapk.st.matrix.sync.RoomEvent.Message
import app.dapk.st.matrix.sync.RoomState import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload
import app.dapk.st.navigator.MessageAttachment import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.navigator.Navigator import app.dapk.st.navigator.Navigator
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
@ -47,10 +52,16 @@ import coil.request.ImageRequest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment>?, viewModel: MessengerViewModel, navigator: Navigator) { internal fun MessengerScreen(
roomId: RoomId,
attachments: List<MessageAttachment>?,
viewModel: MessengerViewModel,
navigator: Navigator,
galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>
) {
val state = viewModel.state val state = viewModel.state
viewModel.ObserveEvents() viewModel.ObserveEvents(galleryLauncher)
LifecycleEffect( LifecycleEffect(
onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }, onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) },
onStop = { viewModel.post(MessengerAction.OnMessengerGone) } onStop = { viewModel.post(MessengerAction.OnMessengerGone) }
@ -63,9 +74,9 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment
Column { Column {
Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = { Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = {
OverflowMenu { // OverflowMenu {
DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {}) // DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
} // }
}) })
when (state.composerState) { when (state.composerState) {
is ComposerState.Text -> { is ComposerState.Text -> {
@ -74,6 +85,7 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment
state.composerState, state.composerState,
onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) }, onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) },
onSend = { viewModel.post(MessengerAction.ComposerSendText) }, onSend = { viewModel.post(MessengerAction.ComposerSendText) },
onAttach = { viewModel.startAttachment() }
) )
} }
@ -89,10 +101,16 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment
} }
@Composable @Composable
private fun MessengerViewModel.ObserveEvents() { private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>) {
StartObserving { StartObserving {
this@ObserveEvents.events.launch { this@ObserveEvents.events.launch {
// TODO() when (it) {
MessengerEvent.SelectImageAttachment -> {
state.roomState.takeIfContent()?.let {
galleryLauncher.launch(ImageGalleryActivityPayload(it.roomState.roomOverview.roomName ?: ""))
}
}
}
} }
} }
} }
@ -258,6 +276,7 @@ private fun MessageImage(content: BubbleContent<RoomEvent.Image>) {
painter = rememberAsyncImagePainter( painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context) model = ImageRequest.Builder(context)
.fetcherFactory(LocalDecyptingFetcherFactory.current) .fetcherFactory(LocalDecyptingFetcherFactory.current)
.memoryCacheKey(content.message.imageMeta.url)
.data(content.message) .data(content.message)
.build() .build()
), ),
@ -408,6 +427,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
val context = LocalContext.current val context = LocalContext.current
Column( Column(
Modifier Modifier
.fillMaxWidth()
.background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground) .background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground)
.padding(4.dp) .padding(4.dp)
) { ) {
@ -417,13 +437,13 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
fontSize = 11.sp, fontSize = 11.sp,
text = replyName, text = replyName,
maxLines = 1, maxLines = 1,
color = MaterialTheme.colorScheme.onPrimary color = content.textColor()
) )
when (val replyingTo = content.message.replyingTo) { when (val replyingTo = content.message.replyingTo) {
is Message -> { is Message -> {
Text( Text(
text = replyingTo.content, text = replyingTo.content,
color = MaterialTheme.colorScheme.onPrimary, color = content.textColor(),
fontSize = 15.sp, fontSize = 15.sp,
modifier = Modifier.wrapContentSize(), modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
@ -437,6 +457,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
painter = rememberAsyncImagePainter( painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context) model = ImageRequest.Builder(context)
.fetcherFactory(LocalDecyptingFetcherFactory.current) .fetcherFactory(LocalDecyptingFetcherFactory.current)
.memoryCacheKey(replyingTo.imageMeta.url)
.data(replyingTo) .data(replyingTo)
.build() .build()
), ),
@ -478,7 +499,8 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
modifier = Modifier.size(message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), modifier = Modifier.size(message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter( painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context) model = ImageRequest.Builder(context)
.data(content.message) .data(message)
.memoryCacheKey(message.imageMeta.url)
.fetcherFactory(LocalDecyptingFetcherFactory.current) .fetcherFactory(LocalDecyptingFetcherFactory.current)
.build() .build()
), ),
@ -550,7 +572,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
} }
@Composable @Composable
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit) { private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -576,7 +598,17 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
onValueChange = { onTextChange(it) }, onValueChange = { onTextChange(it) },
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
textStyle = LocalTextStyle.current.copy(color = SmallTalkTheme.extendedColors.onOthersBubble), textStyle = LocalTextStyle.current.copy(color = SmallTalkTheme.extendedColors.onOthersBubble),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true) keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true),
decorationBox = { innerField ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Box(modifier = Modifier.weight(1f).padding(end = 4.dp)) { innerField() }
Icon(
modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom),
imageVector = Icons.Filled.Image,
contentDescription = "",
)
}
}
) )
} }
} }

View File

@ -10,7 +10,9 @@ data class MessengerScreenState(
val composerState: ComposerState, val composerState: ComposerState,
) )
sealed interface MessengerEvent sealed interface MessengerEvent {
object SelectImageAttachment : MessengerEvent
}
sealed interface ComposerState { sealed interface ComposerState {

View File

@ -48,6 +48,7 @@ internal class MessengerViewModel(
is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue)) } is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue)) }
MessengerAction.ComposerSendText -> sendMessage() MessengerAction.ComposerSendText -> sendMessage()
MessengerAction.ComposerClear -> updateState { copy(composerState = ComposerState.Text("")) } MessengerAction.ComposerClear -> updateState { copy(composerState = ComposerState.Text("")) }
is MessengerAction.ComposerImageUpdate -> updateState { copy(composerState = ComposerState.Attachments(listOf(action.newValue))) }
} }
} }
@ -100,6 +101,7 @@ internal class MessengerViewModel(
} }
} }
} }
is ComposerState.Attachments -> { is ComposerState.Attachments -> {
val copy = composerState.copy() val copy = composerState.copy()
updateState { copy(composerState = ComposerState.Text("")) } updateState { copy(composerState = ComposerState.Text("")) }
@ -123,6 +125,12 @@ internal class MessengerViewModel(
} }
} }
fun startAttachment() {
viewModelScope.launch {
_events.emit(MessengerEvent.SelectImageAttachment)
}
}
} }
private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roomState.events private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roomState.events
@ -133,6 +141,7 @@ private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roo
sealed interface MessengerAction { sealed interface MessengerAction {
data class ComposerTextUpdate(val newValue: String) : MessengerAction data class ComposerTextUpdate(val newValue: String) : MessengerAction
data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction
object ComposerSendText : MessengerAction object ComposerSendText : MessengerAction
object ComposerClear : MessengerAction object ComposerClear : MessengerAction
data class OnMessengerVisible(val roomId: RoomId, val attachments: List<MessageAttachment>?) : MessengerAction data class OnMessengerVisible(val roomId: RoomId, val attachments: List<MessageAttachment>?) : MessengerAction

View File

@ -0,0 +1,53 @@
package app.dapk.st.messenger.gallery
import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri
import android.provider.MediaStore.Images
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
class FetchMediaFoldersUseCase(
private val contentResolver: ContentResolver,
private val dispatchers: CoroutineDispatchers,
) {
suspend fun fetchFolders(): List<Folder> {
return dispatchers.withIoContext {
val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED)
val selection = "${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?"
val sortBy = "${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC"
val folders = mutableMapOf<String, Folder>()
val contentUri = Images.Media.EXTERNAL_CONTENT_URI
contentResolver.query(contentUri, projection, selection, arrayOf("%image/svg%"), sortBy).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
val thumbnail = ContentUris.withAppendedId(contentUri, rowId)
val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1]))
val title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])) ?: ""
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3]))
val folder = folders.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
folder.incrementItemCount()
}
}
folders.values.toList()
}
}
}
data class Folder(
val bucketId: String,
val title: String,
val thumbnail: Uri,
) {
private var _itemCount: Long = 0L
val itemCount: Long
get() = _itemCount
fun incrementItemCount() {
_itemCount++
}
}

View File

@ -0,0 +1,64 @@
package app.dapk.st.messenger.gallery
import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri
import android.provider.MediaStore
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
class FetchMediaUseCase(private val contentResolver: ContentResolver, private val dispatchers: CoroutineDispatchers) {
private val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED,
MediaStore.Images.Media.ORIENTATION,
MediaStore.Images.Media.WIDTH,
MediaStore.Images.Media.HEIGHT,
MediaStore.Images.Media.SIZE
)
private val selection = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + MediaStore.Images.Media.MIME_TYPE + " NOT LIKE ?"
suspend fun getMediaInBucket(bucketId: String): List<Media> {
return dispatchers.withIoContext {
val media = mutableListOf<Media>()
val selectionArgs = arrayOf(bucketId, "%image/svg%")
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC"
val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
val uri = ContentUris.withAppendedId(contentUri, rowId)
val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)))
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
media.add(Media(rowId, uri, mimetype, width, height, size, date))
}
}
media
}
}
private fun getWidthColumn(orientation: Int) = if (orientation == 0 || orientation == 180) MediaStore.Images.Media.WIDTH else MediaStore.Images.Media.HEIGHT
private fun getHeightColumn(orientation: Int) =
if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH
}
data class Media(
val id: Long,
val uri: Uri,
val mimeType: String,
val width: Int,
val height: Int,
val size: Long,
val dateModifiedEpochMillis: Long,
)

View File

@ -0,0 +1,86 @@
package app.dapk.st.messenger.gallery
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.lifecycleScope
import app.dapk.st.core.*
import app.dapk.st.core.extensions.unsafeLazy
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
class ImageGalleryActivity : DapkActivity() {
private val module by unsafeLazy { module<ImageGalleryModule>() }
private val viewModel by viewModel {
val payload = intent.getParcelableExtra("key") as? ImageGalleryActivityPayload
module.imageGalleryViewModel(payload!!.roomName)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val permissionState = mutableStateOf<Lce<PermissionResult>>(Lce.Loading())
lifecycleScope.launch {
permissionState.value = runCatching { ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE) }.fold(
onSuccess = { Lce.Content(it) },
onFailure = { Lce.Error(it) }
)
}
setContent {
Surface {
PermissionGuard(permissionState) {
ImageGalleryScreen(viewModel, onTopLevelBack = { finish() }) { media ->
setResult(RESULT_OK, Intent().setData(media.uri))
finish()
}
}
}
}
}
}
@Composable
fun Activity.PermissionGuard(state: State<Lce<PermissionResult>>, onGranted: @Composable () -> Unit) {
when (val content = state.value) {
is Lce.Content -> when (content.value) {
PermissionResult.Granted -> onGranted()
PermissionResult.Denied -> finish()
PermissionResult.ShowRational -> finish()
}
is Lce.Error -> finish()
is Lce.Loading -> {
// loading should be quick, let's avoid displaying anything
}
}
}
class GetImageFromGallery : ActivityResultContract<ImageGalleryActivityPayload, Uri?>() {
override fun createIntent(context: Context, input: ImageGalleryActivityPayload): Intent {
return Intent(context, ImageGalleryActivity::class.java)
.putExtra("key", input)
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
}
}
@Parcelize
data class ImageGalleryActivityPayload(
val roomName: String,
) : Parcelable

View File

@ -0,0 +1,18 @@
package app.dapk.st.messenger.gallery
import android.content.ContentResolver
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.ProvidableModule
class ImageGalleryModule(
private val contentResolver: ContentResolver,
private val dispatchers: CoroutineDispatchers,
) : ProvidableModule {
fun imageGalleryViewModel(roomName: String) = ImageGalleryViewModel(
FetchMediaFoldersUseCase(contentResolver, dispatchers),
FetchMediaUseCase(contentResolver, dispatchers),
roomName = roomName,
)
}

View File

@ -0,0 +1,157 @@
package app.dapk.st.messenger.gallery
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.GenericError
import app.dapk.st.design.components.Spider
import app.dapk.st.design.components.SpiderPage
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
@Composable
fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) {
LifecycleEffect(onStart = {
viewModel.start()
})
val onNavigate: (SpiderPage<out ImageGalleryPage>?) -> Unit = {
when (it) {
null -> onTopLevelBack()
else -> viewModel.goTo(it)
}
}
Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) {
item(ImageGalleryPage.Routes.folders) {
ImageGalleryFolders(it) { folder ->
viewModel.selectFolder(folder)
}
}
item(ImageGalleryPage.Routes.files) {
ImageGalleryMedia(it, onImageSelected)
}
}
}
@Composable
fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit) {
val screenWidth = LocalConfiguration.current.screenWidthDp
val gradient = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)),
)
when (val content = state.content) {
is Lce.Loading -> {
CenteredLoading()
}
is Lce.Content -> {
Column {
val columns = when {
screenWidth > 600 -> 4
else -> 2
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = Modifier.fillMaxSize(),
) {
items(content.value, key = { it.bucketId }) {
Box(modifier = Modifier.fillMaxWidth().padding(2.dp).aspectRatio(1f)
.clickable { onClick(it) }) {
Image(
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data(it.thumbnail.toString())
.build(),
),
contentDescription = "123",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.6f).background(gradient).align(Alignment.BottomStart))
Row(
modifier = Modifier.fillMaxWidth().align(Alignment.BottomStart).padding(4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(it.title, fontSize = 13.sp, color = Color.White)
Text(it.itemCount.toString(), fontSize = 11.sp, color = Color.White)
}
}
}
}
}
}
is Lce.Error -> GenericError { }
}
}
@Composable
fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit) {
val screenWidth = LocalConfiguration.current.screenWidthDp
Column {
val columns = when {
screenWidth > 600 -> 4
else -> 2
}
when (val content = state.content) {
is Lce.Loading -> {
CenteredLoading()
}
is Lce.Content -> {
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = Modifier.fillMaxSize(),
) {
val modifier = Modifier.fillMaxWidth().padding(2.dp).aspectRatio(1f)
items(content.value, key = { it.id }) {
Box(modifier = modifier.clickable { onFileSelected(it) }) {
Image(
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data(it.uri.toString())
.crossfade(true)
.build(),
),
contentDescription = "123",
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
contentScale = ContentScale.Crop
)
}
}
}
}
is Lce.Error -> GenericError { }
}
}
}

View File

@ -0,0 +1,89 @@
package app.dapk.st.messenger.gallery
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.Lce
import app.dapk.st.design.components.Route
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class ImageGalleryViewModel(
private val foldersUseCase: FetchMediaFoldersUseCase,
private val fetchMediaUseCase: FetchMediaUseCase,
roomName: String,
) : DapkViewModel<ImageGalleryState, ImageGalleryEvent>(
initialState = ImageGalleryState(
page = SpiderPage(
route = ImageGalleryPage.Routes.folders,
label = "Send to $roomName",
parent = null,
state = ImageGalleryPage.Folders(Lce.Loading())
)
)
) {
private var currentPageJob: Job? = null
fun start() {
currentPageJob?.cancel()
currentPageJob = viewModelScope.launch {
val folders = foldersUseCase.fetchFolders()
updatePageState<ImageGalleryPage.Folders> { copy(content = Lce.Content(folders)) }
}
}
fun goTo(page: SpiderPage<out ImageGalleryPage>) {
currentPageJob?.cancel()
updateState { copy(page = page) }
}
fun selectFolder(folder: Folder) {
currentPageJob?.cancel()
updateState {
copy(
page = SpiderPage(
route = ImageGalleryPage.Routes.files,
label = page.label,
parent = ImageGalleryPage.Routes.folders,
state = ImageGalleryPage.Files(Lce.Loading())
)
)
}
currentPageJob = viewModelScope.launch {
val media = fetchMediaUseCase.getMediaInBucket(folder.bucketId)
updatePageState<ImageGalleryPage.Files> {
copy(content = Lce.Content(media))
}
}
}
@Suppress("UNCHECKED_CAST")
private inline fun <reified S : ImageGalleryPage> updatePageState(crossinline block: S.() -> S) {
val page = state.page
val currentState = page.state
require(currentState is S)
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
}
}
data class ImageGalleryState(
val page: SpiderPage<out ImageGalleryPage>,
)
sealed interface ImageGalleryPage {
data class Folders(val content: Lce<List<Folder>>) : ImageGalleryPage
data class Files(val content: Lce<List<Media>>) : ImageGalleryPage
object Routes {
val folders = Route<Folders>("Folders")
val files = Route<Files>("Files")
}
}
sealed interface ImageGalleryEvent

View File

@ -0,0 +1,6 @@
package app.dapk.st.messenger.gallery
import android.os.Build
import android.provider.MediaStore
fun isNotPending() = if (Build.VERSION.SDK_INT <= 28) MediaStore.Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1"

View File

@ -81,7 +81,7 @@ data class MessageAttachment(val uri: AndroidUri, val type: MimeType) : Parcelab
private companion object : Parceler<MessageAttachment> { private companion object : Parceler<MessageAttachment> {
override fun create(parcel: Parcel): MessageAttachment { override fun create(parcel: Parcel): MessageAttachment {
val uri = AndroidUri(parcel.readString()!!) val uri = AndroidUri(parcel.readString()!!)
val type = when(parcel.readString()!!) { val type = when (parcel.readString()!!) {
"mimetype-image" -> MimeType.Image "mimetype-image" -> MimeType.Image
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }

View File

@ -43,7 +43,11 @@ data class ServiceDependencies(
interface MatrixServiceInstaller { interface MatrixServiceInstaller {
fun serializers(builder: SerializersModuleBuilder.() -> Unit) fun serializers(builder: SerializersModuleBuilder.() -> Unit)
fun install(factory: MatrixService.Factory) fun <T : MatrixService> install(factory: MatrixService.Factory): InstallExtender<T>
}
interface InstallExtender<T : MatrixService> {
fun proxy(proxy: (T) -> T)
} }
interface MatrixServiceProvider { interface MatrixServiceProvider {

View File

@ -11,15 +11,22 @@ internal class ServiceInstaller {
private val services = mutableMapOf<Any, MatrixService>() private val services = mutableMapOf<Any, MatrixService>()
private val serviceInstaller = object : MatrixServiceInstaller { private val serviceInstaller = object : MatrixServiceInstaller {
val serviceCollector = mutableListOf<MatrixService.Factory>() val serviceCollector = mutableListOf<Pair<MatrixService.Factory, (MatrixService) -> MatrixService>>()
val serializers = mutableListOf<SerializersModuleBuilder.() -> Unit>() val serializers = mutableListOf<SerializersModuleBuilder.() -> Unit>()
override fun serializers(builder: SerializersModuleBuilder.() -> Unit) { override fun serializers(builder: SerializersModuleBuilder.() -> Unit) {
serializers.add(builder) serializers.add(builder)
} }
override fun install(factory: MatrixService.Factory) { override fun <T : MatrixService> install(factory: MatrixService.Factory): InstallExtender<T> {
serviceCollector.add(factory) val mutableProxy = MutableProxy<T>()
return object : InstallExtender<T> {
override fun proxy(proxy: (T) -> T) {
mutableProxy.value = proxy
}
}.also {
serviceCollector.add(factory to mutableProxy)
}
} }
} }
@ -39,9 +46,9 @@ internal class ServiceInstaller {
val serviceProvider = object : MatrixServiceProvider { val serviceProvider = object : MatrixServiceProvider {
override fun <T : MatrixService> getService(key: ServiceKey) = this@ServiceInstaller.getService<T>(key) override fun <T : MatrixService> getService(key: ServiceKey) = this@ServiceInstaller.getService<T>(key)
} }
serviceInstaller.serviceCollector.forEach { serviceInstaller.serviceCollector.forEach { (factory, extender) ->
val (key, service) = it.create(ServiceDependencies(httpClient, json, serviceProvider, logger)) val (key, service) = factory.create(ServiceDependencies(httpClient, json, serviceProvider, logger))
services[key] = service services[key] = extender(service)
} }
} }
@ -57,4 +64,13 @@ internal class ServiceInstaller {
?: throw IllegalArgumentException("No service available to handle ${task.type}") ?: throw IllegalArgumentException("No service available to handle ${task.type}")
} }
}
internal class MutableProxy<T : MatrixService> : (MatrixService) -> MatrixService {
var value: (T) -> T = { it }
@Suppress("UNCHECKED_CAST")
override fun invoke(service: MatrixService) = value(service as T)
} }

View File

@ -1,5 +1,6 @@
package app.dapk.st.matrix.auth package app.dapk.st.matrix.auth
import app.dapk.st.matrix.InstallExtender
import app.dapk.st.matrix.MatrixClient import app.dapk.st.matrix.MatrixClient
import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller import app.dapk.st.matrix.MatrixServiceInstaller
@ -25,8 +26,8 @@ interface AuthService : MatrixService {
fun MatrixServiceInstaller.installAuthService( fun MatrixServiceInstaller.installAuthService(
credentialsStore: CredentialsStore, credentialsStore: CredentialsStore,
) { ): InstallExtender<AuthService> {
this.install { (httpClient, json) -> return this.install { (httpClient, json) ->
SERVICE_KEY to DefaultAuthService(httpClient, credentialsStore, json) SERVICE_KEY to DefaultAuthService(httpClient, credentialsStore, json)
} }
} }

View File

@ -2,10 +2,7 @@ package app.dapk.st.matrix.crypto
import app.dapk.st.core.Base64 import app.dapk.st.core.Base64
import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.*
import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider
import app.dapk.st.matrix.ServiceDepFactory
import app.dapk.st.matrix.common.* import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.crypto.internal.* import app.dapk.st.matrix.crypto.internal.*
import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.device.deviceService
@ -136,8 +133,8 @@ fun MatrixServiceInstaller.installCryptoService(
roomMembersProvider: ServiceDepFactory<RoomMembersProvider>, roomMembersProvider: ServiceDepFactory<RoomMembersProvider>,
base64: Base64, base64: Base64,
coroutineDispatchers: CoroutineDispatchers, coroutineDispatchers: CoroutineDispatchers,
) { ): InstallExtender<CryptoService> {
this.install { (_, _, services, logger) -> return this.install { (_, _, services, logger) ->
val deviceService = services.deviceService() val deviceService = services.deviceService()
val accountCryptoUseCase = FetchAccountCryptoUseCaseImpl(credentialsStore, olm, deviceService) val accountCryptoUseCase = FetchAccountCryptoUseCaseImpl(credentialsStore, olm, deviceService)

View File

@ -1,5 +1,6 @@
package app.dapk.st.matrix.device package app.dapk.st.matrix.device
import app.dapk.st.matrix.InstallExtender
import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider import app.dapk.st.matrix.MatrixServiceProvider
@ -122,8 +123,8 @@ sealed class ToDevicePayload {
sealed interface VerificationPayload sealed interface VerificationPayload
} }
fun MatrixServiceInstaller.installEncryptionService(knownDeviceStore: KnownDeviceStore) { fun MatrixServiceInstaller.installEncryptionService(knownDeviceStore: KnownDeviceStore): InstallExtender<DeviceService> {
this.install { (httpClient, _, _, logger) -> return this.install { (httpClient, _, _, logger) ->
SERVICE_KEY to DefaultDeviceService(httpClient, logger, knownDeviceStore) SERVICE_KEY to DefaultDeviceService(httpClient, logger, knownDeviceStore)
} }
} }

View File

@ -1,10 +1,7 @@
package app.dapk.st.matrix.message package app.dapk.st.matrix.message
import app.dapk.st.core.Base64 import app.dapk.st.core.Base64
import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.*
import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider
import app.dapk.st.matrix.ServiceDepFactory
import app.dapk.st.matrix.common.AlgorithmName import app.dapk.st.matrix.common.AlgorithmName
import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.MessageType import app.dapk.st.matrix.common.MessageType
@ -132,8 +129,8 @@ fun MatrixServiceInstaller.installMessageService(
imageContentReader: ImageContentReader, imageContentReader: ImageContentReader,
messageEncrypter: ServiceDepFactory<MessageEncrypter> = ServiceDepFactory { MissingMessageEncrypter }, messageEncrypter: ServiceDepFactory<MessageEncrypter> = ServiceDepFactory { MissingMessageEncrypter },
mediaEncrypter: ServiceDepFactory<MediaEncrypter> = ServiceDepFactory { MissingMediaEncrypter }, mediaEncrypter: ServiceDepFactory<MediaEncrypter> = ServiceDepFactory { MissingMediaEncrypter },
) { ): InstallExtender<MessageService> {
this.install { (httpClient, _, installedServices) -> return this.install { (httpClient, _, installedServices) ->
SERVICE_KEY to DefaultMessageService( SERVICE_KEY to DefaultMessageService(
httpClient, httpClient,
localEchoStore, localEchoStore,

View File

@ -45,6 +45,7 @@ sealed class ApiMessage {
data class Info( data class Info(
@SerialName("h") val height: Int, @SerialName("h") val height: Int,
@SerialName("w") val width: Int, @SerialName("w") val width: Int,
@SerialName("mimetype") val mimeType: String,
@SerialName("size") val size: Long, @SerialName("size") val size: Long,
) )

View File

@ -1,6 +1,9 @@
package app.dapk.st.matrix.message.internal package app.dapk.st.matrix.message.internal
import app.dapk.st.matrix.common.* import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.EventType
import app.dapk.st.matrix.common.JsonString
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.MatrixHttpClient
import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest
import app.dapk.st.matrix.message.ApiSendResponse import app.dapk.st.matrix.message.ApiSendResponse
@ -57,7 +60,7 @@ internal class SendMessageUseCase(
} }
} }
private suspend fun ApiMessageMapper.imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> { private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> {
val imageMeta = imageContentReader.meta(message.content.uri) val imageMeta = imageContentReader.meta(message.content.uri)
return when (message.sendEncrypted) { return when (message.sendEncrypted) {
@ -91,11 +94,11 @@ internal class SendMessageUseCase(
info = ApiMessage.ImageMessage.ImageContent.Info( info = ApiMessage.ImageMessage.ImageContent.Info(
height = imageMeta.height, height = imageMeta.height,
width = imageMeta.width, width = imageMeta.width,
size = imageMeta.size size = imageMeta.size,
mimeType = imageMeta.mimeType,
) )
) )
val json = JsonString( val json = JsonString(
MatrixHttpClient.jsonWithDefaults.encodeToString( MatrixHttpClient.jsonWithDefaults.encodeToString(
ApiMessage.ImageMessage.serializer(), ApiMessage.ImageMessage.serializer(),
@ -134,7 +137,8 @@ internal class SendMessageUseCase(
ApiMessage.ImageMessage.ImageContent.Info( ApiMessage.ImageMessage.ImageContent.Info(
height = imageMeta.height, height = imageMeta.height,
width = imageMeta.width, width = imageMeta.width,
size = imageMeta.size size = imageMeta.size,
mimeType = imageMeta.mimeType,
) )
), ),
) )
@ -163,14 +167,4 @@ class ApiMessageMapper {
) )
) )
fun Message.ImageMessage.toContents(uri: MxUrl, image: ImageContentReader.ImageContent) = ApiMessage.ImageMessage.ImageContent(
url = uri,
filename = image.fileName,
ApiMessage.ImageMessage.ImageContent.Info(
height = image.height,
width = image.width,
size = image.size
)
)
} }

View File

@ -1,6 +1,7 @@
package app.dapk.st.matrix.room package app.dapk.st.matrix.room
import app.dapk.st.core.SingletonFlows import app.dapk.st.core.SingletonFlows
import app.dapk.st.matrix.InstallExtender
import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider import app.dapk.st.matrix.MatrixServiceProvider
@ -29,8 +30,8 @@ fun MatrixServiceInstaller.installProfileService(
profileStore: ProfileStore, profileStore: ProfileStore,
singletonFlows: SingletonFlows, singletonFlows: SingletonFlows,
credentialsStore: CredentialsStore, credentialsStore: CredentialsStore,
) { ): InstallExtender<ProfileService> {
this.install { (httpClient, _, _, logger) -> return this.install { (httpClient, _, _, logger) ->
SERVICE_KEY to DefaultProfileService(httpClient, logger, profileStore, singletonFlows, credentialsStore) SERVICE_KEY to DefaultProfileService(httpClient, logger, profileStore, singletonFlows, credentialsStore)
} }
} }

View File

@ -1,5 +1,6 @@
package app.dapk.st.matrix.push package app.dapk.st.matrix.push
import app.dapk.st.matrix.InstallExtender
import app.dapk.st.matrix.MatrixClient import app.dapk.st.matrix.MatrixClient
import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller import app.dapk.st.matrix.MatrixServiceInstaller
@ -38,8 +39,8 @@ interface PushService : MatrixService {
fun MatrixServiceInstaller.installPushService( fun MatrixServiceInstaller.installPushService(
credentialsStore: CredentialsStore, credentialsStore: CredentialsStore,
) { ): InstallExtender<PushService> {
this.install { (httpClient, _, _, logger) -> return this.install { (httpClient, _, _, logger) ->
SERVICE_KEY to DefaultPushService(httpClient, credentialsStore, logger) SERVICE_KEY to DefaultPushService(httpClient, credentialsStore, logger)
} }
} }

View File

@ -1,9 +1,6 @@
package app.dapk.st.matrix.room package app.dapk.st.matrix.room
import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.*
import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.MatrixServiceProvider
import app.dapk.st.matrix.ServiceDepFactory
import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.common.RoomMember
@ -42,8 +39,8 @@ fun MatrixServiceInstaller.installRoomService(
memberStore: MemberStore, memberStore: MemberStore,
roomMessenger: ServiceDepFactory<RoomMessenger>, roomMessenger: ServiceDepFactory<RoomMessenger>,
roomInviteRemover: RoomInviteRemover, roomInviteRemover: RoomInviteRemover,
) { ): InstallExtender<RoomService> {
this.install { (httpClient, _, services, logger) -> return this.install { (httpClient, _, services, logger) ->
SERVICE_KEY to DefaultRoomService( SERVICE_KEY to DefaultRoomService(
httpClient, httpClient,
logger, logger,

View File

@ -35,6 +35,7 @@ sealed class RoomEvent {
@SerialName("meta") override val meta: MessageMeta, @SerialName("meta") override val meta: MessageMeta,
@SerialName("encrypted_content") val encryptedContent: MegOlmV1? = null, @SerialName("encrypted_content") val encryptedContent: MegOlmV1? = null,
@SerialName("edited") val edited: Boolean = false, @SerialName("edited") val edited: Boolean = false,
@SerialName("redacted") val redacted: Boolean = false,
) : RoomEvent() { ) : RoomEvent() {
@Serializable @Serializable

View File

@ -7,8 +7,9 @@ import kotlinx.coroutines.flow.Flow
interface RoomStore { interface RoomStore {
suspend fun persist(roomId: RoomId, state: RoomState) suspend fun persist(roomId: RoomId, events: List<RoomEvent>)
suspend fun remove(rooms: List<RoomId>) suspend fun remove(rooms: List<RoomId>)
suspend fun remove(eventId: EventId)
suspend fun retrieve(roomId: RoomId): RoomState? suspend fun retrieve(roomId: RoomId): RoomState?
fun latest(roomId: RoomId): Flow<RoomState> fun latest(roomId: RoomId): Flow<RoomState>
suspend fun insertUnread(roomId: RoomId, eventIds: List<EventId>) suspend fun insertUnread(roomId: RoomId, eventIds: List<EventId>)

View File

@ -2,10 +2,7 @@ package app.dapk.st.matrix.sync
import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.matrix.MatrixClient import app.dapk.st.matrix.*
import app.dapk.st.matrix.MatrixService
import app.dapk.st.matrix.MatrixServiceInstaller
import app.dapk.st.matrix.ServiceDepFactory
import app.dapk.st.matrix.common.* import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.sync.internal.DefaultSyncService import app.dapk.st.matrix.sync.internal.DefaultSyncService
import app.dapk.st.matrix.sync.internal.request.* import app.dapk.st.matrix.sync.internal.request.*
@ -49,7 +46,7 @@ fun MatrixServiceInstaller.installSyncService(
errorTracker: ErrorTracker, errorTracker: ErrorTracker,
coroutineDispatchers: CoroutineDispatchers, coroutineDispatchers: CoroutineDispatchers,
syncConfig: SyncConfig = SyncConfig(), syncConfig: SyncConfig = SyncConfig(),
) { ): InstallExtender<SyncService> {
this.serializers { this.serializers {
polymorphicDefault(ApiTimelineEvent::class) { polymorphicDefault(ApiTimelineEvent::class) {
ApiTimelineEvent.Ignored.serializer() ApiTimelineEvent.Ignored.serializer()
@ -71,7 +68,7 @@ fun MatrixServiceInstaller.installSyncService(
} }
} }
this.install { (httpClient, json, services, logger) -> return this.install { (httpClient, json, services, logger) ->
SERVICE_KEY to DefaultSyncService( SERVICE_KEY to DefaultSyncService(
httpClient = httpClient, httpClient = httpClient,
syncStore = syncStore, syncStore = syncStore,

View File

@ -82,7 +82,6 @@ internal sealed class ApiTimelineEvent {
) )
} }
@Serializable @Serializable
@SerialName("m.room.member") @SerialName("m.room.member")
internal data class RoomMember( internal data class RoomMember(
@ -109,6 +108,15 @@ internal sealed class ApiTimelineEvent {
} }
} }
@Serializable
@SerialName("m.room.redaction")
internal data class RoomRedcation(
@SerialName("event_id") val id: EventId,
@SerialName("redacts") val redactedId: EventId,
@SerialName("origin_server_ts") val utcTimestamp: Long,
@SerialName("sender") val senderId: UserId,
) : ApiTimelineEvent()
@Serializable @Serializable
internal data class DecryptionStatus( internal data class DecryptionStatus(
@SerialName("is_verified") val isVerified: Boolean @SerialName("is_verified") val isVerified: Boolean

View File

@ -1,9 +1,7 @@
package app.dapk.st.matrix.sync.internal.sync package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.MatrixLogTag import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.common.MatrixLogger import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.matrixLog
import app.dapk.st.matrix.sync.RoomState import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.matrix.sync.RoomStore
@ -26,7 +24,7 @@ class RoomDataSource(
logger.matrixLog(MatrixLogTag.SYNC, "no changes, not persisting") logger.matrixLog(MatrixLogTag.SYNC, "no changes, not persisting")
} else { } else {
roomCache[roomId] = newState roomCache[roomId] = newState
roomStore.persist(roomId, newState) roomStore.persist(roomId, newState.events)
} }
} }
@ -34,4 +32,35 @@ class RoomDataSource(
roomsLeft.forEach { roomCache.remove(it) } roomsLeft.forEach { roomCache.remove(it) }
roomStore.remove(roomsLeft) roomStore.remove(roomsLeft)
} }
suspend fun redact(roomId: RoomId, event: EventId) {
val eventToRedactFromCache = roomCache[roomId]?.events?.find { it.eventId == event }
val redactedEvent = when {
eventToRedactFromCache != null -> {
eventToRedactFromCache.redact().also { redacted ->
val cachedRoomState = roomCache[roomId]
requireNotNull(cachedRoomState)
roomCache[roomId] = cachedRoomState.replaceEvent(eventToRedactFromCache, redacted)
}
}
else -> roomStore.findEvent(event)?.redact()
}
redactedEvent?.let { roomStore.persist(roomId, listOf(it)) }
}
}
private fun RoomEvent.redact() = when (this) {
is RoomEvent.Image -> RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
is RoomEvent.Message -> RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
is RoomEvent.Reply -> RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
}
private fun RoomState.replaceEvent(old: RoomEvent, new: RoomEvent): RoomState {
val updatedEvents = this.events.toMutableList().apply {
remove(old)
add(new)
}
return this.copy(events = updatedEvents)
} }

View File

@ -21,6 +21,10 @@ internal class RoomProcessor(
val members = roomToProcess.apiSyncRoom.collectMembers(roomToProcess.userCredentials) val members = roomToProcess.apiSyncRoom.collectMembers(roomToProcess.userCredentials)
roomMembersService.insert(roomToProcess.roomId, members) roomMembersService.insert(roomToProcess.roomId, members)
roomToProcess.apiSyncRoom.timeline.apiTimelineEvents.filterIsInstance<ApiTimelineEvent.RoomRedcation>().forEach {
roomDataSource.redact(roomToProcess.roomId, it.redactedId)
}
val previousState = roomDataSource.read(roomToProcess.roomId) val previousState = roomDataSource.read(roomToProcess.roomId)
val (newEvents, distinctEvents) = timelineEventsProcessor.process( val (newEvents, distinctEvents) = timelineEventsProcessor.process(

View File

@ -61,8 +61,6 @@ internal class SyncReducer(
} }
} }
roomDataSource.remove(roomsLeft)
return ReducerResult( return ReducerResult(
newRooms, newRooms,
(apiRoomsToProcess + roomsWithSideEffects).awaitAll().filterNotNull(), (apiRoomsToProcess + roomsWithSideEffects).awaitAll().filterNotNull(),

View File

@ -8,7 +8,6 @@ import app.dapk.st.matrix.sync.internal.SideEffectFlowIterator
import app.dapk.st.matrix.sync.internal.overview.ReducedSyncFilterUseCase import app.dapk.st.matrix.sync.internal.overview.ReducedSyncFilterUseCase
import app.dapk.st.matrix.sync.internal.request.syncRequest import app.dapk.st.matrix.sync.internal.request.syncRequest
import app.dapk.st.matrix.sync.internal.room.SyncSideEffects import app.dapk.st.matrix.sync.internal.room.SyncSideEffects
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -25,19 +24,17 @@ internal class SyncUseCase(
private val syncConfig: SyncConfig, private val syncConfig: SyncConfig,
) { ) {
fun sync(): Flow<Unit> { private val _flow = flow {
return flow { val credentials = credentialsStore.credentials()!!
val credentials = credentialsStore.credentials()!! val filterId = filterUseCase.reducedFilter(credentials.userId)
val filterId = filterUseCase.reducedFilter(credentials.userId) with(flowIterator) {
with(flowIterator) { loop<OverviewState>(
loop<OverviewState>( initial = null,
initial = null, onPost = { emit(Unit) },
onPost = { emit(Unit) }, onIteration = { onEachSyncIteration(filterId, credentials, previousState = it) }
onIteration = { onEachSyncIteration(filterId, credentials, previousState = it) } )
) }
} }.cancellable()
}.cancellable()
}
private suspend fun onEachSyncIteration(filterId: SyncService.FilterId, credentials: UserCredentials, previousState: OverviewState?): OverviewState? { private suspend fun onEachSyncIteration(filterId: SyncService.FilterId, credentials: UserCredentials, previousState: OverviewState?): OverviewState? {
val syncToken = syncStore.read(key = SyncStore.SyncKey.Overview) val syncToken = syncStore.read(key = SyncStore.SyncKey.Overview)
@ -85,4 +82,6 @@ internal class SyncUseCase(
) )
} }
fun sync() = _flow
} }

View File

@ -32,6 +32,7 @@ internal class TimelineEventsProcessor(
is ApiTimelineEvent.TimelineMessage -> event.toRoomEvent(roomToProcess.userCredentials, roomToProcess.roomId) { eventId -> is ApiTimelineEvent.TimelineMessage -> event.toRoomEvent(roomToProcess.userCredentials, roomToProcess.roomId) { eventId ->
eventLookupUseCase.lookup(eventId, decryptedTimeline, decryptedPreviousEvents) eventLookupUseCase.lookup(eventId, decryptedTimeline, decryptedPreviousEvents)
} }
is ApiTimelineEvent.RoomRedcation -> null
is ApiTimelineEvent.Encryption -> null is ApiTimelineEvent.Encryption -> null
is ApiTimelineEvent.RoomAvatar -> null is ApiTimelineEvent.RoomAvatar -> null
is ApiTimelineEvent.RoomCreate -> null is ApiTimelineEvent.RoomCreate -> null

View File

@ -9,7 +9,7 @@ test {
dependencies { dependencies {
kotlinTest(it) kotlinTest(it)
testImplementation 'app.cash.turbine:turbine:0.10.0' testImplementation 'app.cash.turbine:turbine:0.11.0'
testImplementation Dependencies.mavenCentral.kotlinSerializationJson testImplementation Dependencies.mavenCentral.kotlinSerializationJson
@ -28,7 +28,7 @@ dependencies {
testImplementation project(":matrix:services:crypto") testImplementation project(":matrix:services:crypto")
testImplementation rootProject.files("external/jolm.jar") testImplementation rootProject.files("external/jolm.jar")
testImplementation 'org.json:json:20220320' testImplementation 'org.json:json:20220924'
testImplementation Dependencies.mavenCentral.ktorJava testImplementation Dependencies.mavenCentral.ktorJava
testImplementation Dependencies.mavenCentral.sqldelightInMemory testImplementation Dependencies.mavenCentral.sqldelightInMemory

View File

@ -123,18 +123,20 @@ class SmokeTest {
alice.expectTextMessage(SharedState.sharedRoom, message2) alice.expectTextMessage(SharedState.sharedRoom, message2)
// Needs investigation // Needs investigation
// val aliceSecondDevice = testMatrix(SharedState.alice, isTemp = true, withLogging = true).also { it.newlogin() } val aliceSecondDevice = testMatrix(SharedState.alice, isTemp = true, withLogging = true).also { it.newlogin() }
// aliceSecondDevice.client.syncService().startSyncing().collectAsync { aliceSecondDevice.client.syncService().startSyncing().collectAsync {
// val message3 = "from alice to bob and alice's second device".from(SharedState.alice.roomMember) aliceSecondDevice.client.proxyDeviceService().waitForOneTimeKeysToBeUploaded()
// alice.sendTextMessage(SharedState.sharedRoom, message3.content, isEncrypted)
// aliceSecondDevice.expectTextMessage(SharedState.sharedRoom, message3) val message3 = "from alice to bob and alice's second device".from(SharedState.alice.roomMember)
// bob.expectTextMessage(SharedState.sharedRoom, message3) alice.sendTextMessage(SharedState.sharedRoom, message3.content, isEncrypted)
// aliceSecondDevice.expectTextMessage(SharedState.sharedRoom, message3)
// val message4 = "from alice's second device to bob and alice's first device".from(SharedState.alice.roomMember) bob.expectTextMessage(SharedState.sharedRoom, message3)
// aliceSecondDevice.sendTextMessage(SharedState.sharedRoom, message4.content, isEncrypted)
// alice.expectTextMessage(SharedState.sharedRoom, message4) val message4 = "from alice's second device to bob and alice's first device".from(SharedState.alice.roomMember)
// bob.expectTextMessage(SharedState.sharedRoom, message4) aliceSecondDevice.sendTextMessage(SharedState.sharedRoom, message4.content, isEncrypted)
// } alice.expectTextMessage(SharedState.sharedRoom, message4)
bob.expectTextMessage(SharedState.sharedRoom, message4)
}
} }
} }

View File

@ -14,6 +14,7 @@ import app.dapk.st.matrix.crypto.RoomMembersProvider
import app.dapk.st.matrix.crypto.Verification import app.dapk.st.matrix.crypto.Verification
import app.dapk.st.matrix.crypto.cryptoService import app.dapk.st.matrix.crypto.cryptoService
import app.dapk.st.matrix.crypto.installCryptoService import app.dapk.st.matrix.crypto.installCryptoService
import app.dapk.st.matrix.device.DeviceService
import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.device.deviceService
import app.dapk.st.matrix.device.installEncryptionService import app.dapk.st.matrix.device.installEncryptionService
import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory
@ -39,6 +40,7 @@ import test.impl.PrintingErrorTracking
import java.io.File import java.io.File
import java.time.Clock import java.time.Clock
import javax.imageio.ImageIO import javax.imageio.ImageIO
import kotlin.coroutines.resume
object TestUsers { object TestUsers {
@ -93,7 +95,9 @@ class TestMatrix(
).also { ).also {
it.install { it.install {
installAuthService(storeModule.credentialsStore()) installAuthService(storeModule.credentialsStore())
installEncryptionService(storeModule.knownDevicesStore()) installEncryptionService(storeModule.knownDevicesStore()).proxy {
ProxyDeviceService(it)
}
val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore(), base64) val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore(), base64)
val olm = OlmWrapper( val olm = OlmWrapper(
@ -355,4 +359,23 @@ class JavaImageContentReader : ImageContentReader {
override fun inputStream(uri: String) = File(uri).inputStream() override fun inputStream(uri: String) = File(uri).inputStream()
} }
class ProxyDeviceService(private val deviceService: DeviceService) : DeviceService by deviceService {
private var oneTimeKeysContinuation: (() -> Unit)? = null
override suspend fun uploadOneTimeKeys(oneTimeKeys: DeviceService.OneTimeKeys) {
deviceService.uploadOneTimeKeys(oneTimeKeys)
oneTimeKeysContinuation?.invoke()?.also { oneTimeKeysContinuation = null }
}
suspend fun waitForOneTimeKeysToBeUploaded() {
suspendCancellableCoroutine { continuation ->
oneTimeKeysContinuation = { continuation.resume(Unit) }
}
}
}
fun MatrixClient.proxyDeviceService() = this.deviceService() as ProxyDeviceService

View File

@ -10,7 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@googleapis/androidpublisher": "^3.0.0", "@googleapis/androidpublisher": "^3.0.0",
"matrix-js-sdk": "^19.4.0", "matrix-js-sdk": "^19.7.0",
"request": "^2.88.2" "request": "^2.88.2"
} }
}, },
@ -631,9 +631,9 @@
"integrity": "sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA==" "integrity": "sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA=="
}, },
"node_modules/matrix-js-sdk": { "node_modules/matrix-js-sdk": {
"version": "19.4.0", "version": "19.7.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.4.0.tgz", "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz",
"integrity": "sha512-B8Mm4jCsCHaMaChcdM3VhZDVKrn0nMSDtYvHmS15Iu8Pe0G4qmIpk2AoADBAL9U9yN3pCqvs3TDXaQhM8UxRRA==", "integrity": "sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"another-json": "^0.2.0", "another-json": "^0.2.0",
@ -1448,9 +1448,9 @@
"integrity": "sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA==" "integrity": "sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA=="
}, },
"matrix-js-sdk": { "matrix-js-sdk": {
"version": "19.4.0", "version": "19.7.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.4.0.tgz", "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz",
"integrity": "sha512-B8Mm4jCsCHaMaChcdM3VhZDVKrn0nMSDtYvHmS15Iu8Pe0G4qmIpk2AoADBAL9U9yN3pCqvs3TDXaQhM8UxRRA==", "integrity": "sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg==",
"requires": { "requires": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"another-json": "^0.2.0", "another-json": "^0.2.0",

View File

@ -7,7 +7,7 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@googleapis/androidpublisher": "^3.0.0", "@googleapis/androidpublisher": "^3.0.0",
"matrix-js-sdk": "^19.4.0", "matrix-js-sdk": "^19.7.0",
"request": "^2.88.2" "request": "^2.88.2"
} }
} }

View File

@ -56,6 +56,7 @@ export const release = async (github, version, applicationId, artifacts, config)
console.log(releaseResult.data.id) console.log(releaseResult.data.id)
console.log("Uploading universal apk...")
await github.rest.repos.uploadReleaseAsset({ await github.rest.repos.uploadReleaseAsset({
owner: config.owner, owner: config.owner,
repo: config.repo, repo: config.repo,
@ -64,6 +65,15 @@ export const release = async (github, version, applicationId, artifacts, config)
data: fs.readFileSync(universalApkPath) data: fs.readFileSync(universalApkPath)
}) })
console.log("Uploading foss apk...")
await github.rest.repos.uploadReleaseAsset({
owner: config.owner,
repo: config.repo,
release_id: releaseResult.data.id,
name: `foss-signed-${version.name}.apk`,
data: fs.readFileSync(artifacts.fossApkPath)
})
console.log("Promoting beta draft release to live...") console.log("Promoting beta draft release to live...")
await promoteDraftToLive(applicationId) await promoteDraftToLive(applicationId)

View File

@ -9,7 +9,7 @@ SIGNED=$WORKING_DIR/app-foss-release-signed.apk
ZIPALIGN=$(find "$ANDROID_HOME" -iname zipalign -print -quit) ZIPALIGN=$(find "$ANDROID_HOME" -iname zipalign -print -quit)
APKSIGNER=$(find "$ANDROID_HOME" -iname apksigner -print -quit) APKSIGNER=$(find "$ANDROID_HOME" -iname apksigner -print -quit)
./gradlew clean assembleRelease -Pfoss -Punsigned --no-daemon --no-configuration-cache --no-build-cache ./gradlew assembleRelease -Pfoss -Punsigned --no-daemon --no-configuration-cache --no-build-cache
$ZIPALIGN -v -p 4 $UNSIGNED $ALIGNED_UNSIGNED $ZIPALIGN -v -p 4 $UNSIGNED $ALIGNED_UNSIGNED

View File

@ -1,6 +1,6 @@
#! /bin/bash #! /bin/bash
./gradlew clean bundleRelease -Punsigned --no-daemon --no-configuration-cache --no-build-cache ./gradlew bundleRelease -Punsigned --no-daemon --no-configuration-cache --no-build-cache
WORKING_DIR=app/build/outputs/bundle/release WORKING_DIR=app/build/outputs/bundle/release
RELEASE_AAB=$WORKING_DIR/app-release.aab RELEASE_AAB=$WORKING_DIR/app-release.aab

View File

@ -1,4 +1,4 @@
{ {
"code": 18, "code": 19,
"name": "22/09/2022-V1" "name": "29/09/2022-V1"
} }