diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index a20d425..91f174d 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -34,11 +34,14 @@ jobs: touch .secrets/service-account.json touch .secrets/matrix.json 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.MATRIX }}' | base64 --decode >> .secrets/matrix.json - 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 with: @@ -48,6 +51,7 @@ jobs: const artifacts = { bundle: '${{ github.workspace }}/app/build/outputs/bundle/release/app-release.aab', 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) diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml index 78ba14c..f9f2a85 100644 --- a/.github/workflows/release-train.yml +++ b/.github/workflows/release-train.yml @@ -1,4 +1,4 @@ -name: Nightly +name: Release Train on: workflow_dispatch: diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6e493a2..b478f91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,8 +1,10 @@ + package="app.dapk.st"> - + + + - - + + + android:resource="@xml/shortcuts"/> diff --git a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt index 3fb4488..1b28443 100644 --- a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt +++ b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt @@ -15,6 +15,7 @@ import app.dapk.st.graph.AppModule import app.dapk.st.home.HomeModule import app.dapk.st.login.LoginModule import app.dapk.st.messenger.MessengerModule +import app.dapk.st.messenger.gallery.ImageGalleryModule import app.dapk.st.notifications.NotificationsModule import app.dapk.st.profile.ProfileModule import app.dapk.st.push.PushModule @@ -54,7 +55,9 @@ class SmallTalkApplication : Application(), ModuleProvider { private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) { applicationScope.launch { - featureModules.pushModule.pushTokenRegistrar().registerCurrentToken() + storeModule.credentialsStore().credentials()?.let { + featureModules.pushModule.pushTokenRegistrar().registerCurrentToken() + } storeModule.localEchoStore.preload() } @@ -79,6 +82,7 @@ class SmallTalkApplication : Application(), ModuleProvider { TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule CoreAndroidModule::class -> appModule.coreAndroidModule ShareEntryModule::class -> featureModules.shareEntryModule + ImageGalleryModule::class -> featureModules.imageGalleryModule else -> throw IllegalArgumentException("Unknown: $klass") } as T } diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 296ee1f..f424c2b 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -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.messenger.MessengerActivity import app.dapk.st.messenger.MessengerModule +import app.dapk.st.messenger.gallery.ImageGalleryModule import app.dapk.st.navigator.IntentFactory import app.dapk.st.navigator.MessageAttachment import app.dapk.st.notifications.MatrixPushHandler @@ -217,6 +218,10 @@ internal class FeatureModules internal constructor( ShareEntryModule(matrixModules.sync, matrixModules.room) } + val imageGalleryModule by unsafeLazy { + ImageGalleryModule(context.contentResolver, coroutineDispatchers) + } + val pushModule by unsafeLazy { domainModules.pushModule } diff --git a/build.gradle b/build.gradle index a379ec5..5ac2108 100644 --- a/build.gradle +++ b/build.gradle @@ -132,7 +132,7 @@ 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.8' + dependencies.testImplementation 'io.mockk:mockk:1.13.2' dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1' @@ -140,7 +140,7 @@ ext.kotlinTest = { 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.kotlinCoroutinesCore } diff --git a/dependencies.gradle b/dependencies.gradle index 4be9fcd..9f09a1f 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -82,10 +82,12 @@ ext.Dependencies.with { includeGroup "io.ktor" includeGroup "io.coil-kt" includeGroup "io.mockk" + includeGroup "io.perfmark" includeGroup "info.picocli" includeGroup "us.fatehi" includeGroup "jakarta.xml.bind" includeGroup "jakarta.activation" + includeGroup "javax.annotation" includeGroup "javax.inject" includeGroup "junit" includeGroup "jline" @@ -102,14 +104,14 @@ ext.Dependencies.with { google = new DependenciesContainer() 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}" androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}" androidxComposeMaterial = "androidx.compose.material3:material3:1.0.0-beta01" androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}" 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" jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5" @@ -143,7 +145,7 @@ ext.Dependencies.with { junit = "junit:junit:4.13.2" 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" } diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Theme.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Theme.kt index c1e37c2..95f2e98 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Theme.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Theme.kt @@ -31,8 +31,8 @@ private fun createExtended(scheme: ColorScheme) = ExtendedColors( onSelfBubble = scheme.onPrimary, othersBubble = scheme.secondaryContainer, onOthersBubble = scheme.onSecondaryContainer, - selfBubbleReplyBackground = Color(0x40EAEAEA), - otherBubbleReplyBackground = Color(0x20EAEAEA), + selfBubbleReplyBackground = scheme.primary.copy(alpha = 0.2f), + otherBubbleReplyBackground = scheme.primary.copy(alpha = 0.2f), missingImageColors = listOf( Color(0xFFf7c7f7) to Color(0xFFdf20de), Color(0xFFe5d7f6) to Color(0xFF7b30cf), diff --git a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt index 29ee41b..fbcf0ed 100644 --- a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt +++ b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt @@ -1,15 +1,19 @@ package app.dapk.st.core +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.WindowManager import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.design.components.SmallTalkTheme import app.dapk.st.design.components.ThemeConfig import app.dapk.st.navigator.navigator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume import androidx.activity.compose.setContent as _setContent abstract class DapkActivity : ComponentActivity(), EffectScope { @@ -27,7 +31,6 @@ abstract class DapkActivity : ComponentActivity(), EffectScope { super.onCreate(savedInstanceState) this.themeConfig = ThemeConfig(themeStore.isMaterialYouEnabled()) - window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); } @@ -58,10 +61,40 @@ abstract class DapkActivity : ComponentActivity(), EffectScope { } } + @Suppress("OVERRIDE_DEPRECATION") override fun onBackPressed() { if (needsBackLeakWorkaround && !onBackPressedDispatcher.hasEnabledCallbacks()) { finishAfterTransition() - } else + } else { 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 +} diff --git a/domains/olm-stub/build.gradle b/domains/olm-stub/build.gradle index b35101f..d946f5a 100644 --- a/domains/olm-stub/build.gradle +++ b/domains/olm-stub/build.gradle @@ -3,5 +3,5 @@ plugins { } dependencies { - compileOnly 'org.json:json:20220320' + compileOnly 'org.json:json:20220924' } \ No newline at end of file diff --git a/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt index ad6a6e6..50c6548 100644 --- a/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt +++ b/domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt @@ -170,6 +170,8 @@ class OlmWrapper( val inBound = OlmInboundGroupSession(roomCryptoSession.key) olmStore.persist(roomCryptoSession.id, inBound) + logger.crypto("Creating megolm: ${roomCryptoSession.id}") + return roomCryptoSession } @@ -181,7 +183,7 @@ class OlmWrapper( private suspend fun deviceCrypto(input: OlmSessionInput): DeviceCryptoSession? { return olmStore.readSessions(listOf(input.identity))?.let { DeviceCryptoSession( - input.deviceId, input.userId, input.identity, input.fingerprint, it + input.deviceId, input.userId, input.identity, input.fingerprint, it.map { it.second } ) } } diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt index 451effd..7523e1d 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt @@ -27,10 +27,10 @@ internal class RoomPersistence( private val coroutineDispatchers: CoroutineDispatchers, ) : RoomStore { - override suspend fun persist(roomId: RoomId, state: RoomState) { + override suspend fun persist(roomId: RoomId, events: List) { coroutineDispatchers.withIoContext { database.transaction { - state.events.forEach { + events.forEach { database.roomEventQueries.insertRoomEvent(roomId, it) } } @@ -38,9 +38,16 @@ internal class RoomPersistence( } override suspend fun remove(rooms: List) { - coroutineDispatchers - database.roomEventQueries.transaction { - rooms.forEach { database.roomEventQueries.remove(it.value) } + coroutineDispatchers.withIoContext { + database.roomEventQueries.transaction { + rooms.forEach { database.roomEventQueries.remove(it.value) } + } + } + } + + override suspend fun remove(eventId: EventId) { + coroutineDispatchers.withIoContext { + database.roomEventQueries.removeEvent(eventId.value) } } diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq index 46883cf..d6067ed 100644 --- a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq @@ -36,4 +36,8 @@ LIMIT 100; remove: DELETE FROM dbRoomEvent -WHERE room_id = ?; \ No newline at end of file +WHERE room_id = ?; + +removeEvent: +DELETE FROM dbRoomEvent +WHERE event_id = ?; \ No newline at end of file diff --git a/features/home/build.gradle b/features/home/build.gradle index 3265a1f..0422dfa 100644 --- a/features/home/build.gradle +++ b/features/home/build.gradle @@ -13,4 +13,5 @@ dependencies { implementation project(':domains:store') implementation project(":core") implementation project(":design-library") + implementation Dependencies.mavenCentral.coil } \ No newline at end of file diff --git a/features/home/src/main/AndroidManifest.xml b/features/home/src/main/AndroidManifest.xml index 3e51f2c..e9b8247 100644 --- a/features/home/src/main/AndroidManifest.xml +++ b/features/home/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ - - - + + + \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt index 609f52d..c308346 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt @@ -27,6 +27,7 @@ class MainActivity : DapkActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + homeViewModel.events.onEach { when (it) { HomeEvent.Relaunch -> recreate() diff --git a/features/messenger/src/main/AndroidManifest.xml b/features/messenger/src/main/AndroidManifest.xml index e643a26..d81f786 100644 --- a/features/messenger/src/main/AndroidManifest.xml +++ b/features/messenger/src/main/AndroidManifest.xml @@ -1,14 +1,15 @@ + package="app.dapk.st.messenger"> + android:windowSoftInputMode="adjustResize"/> - + + diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt index d54dc06..a345ffc 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt @@ -1,7 +1,9 @@ package app.dapk.st.messenger import android.content.Context +import android.os.Environment 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.sync.RoomEvent import coil.ImageLoader @@ -14,14 +16,16 @@ import coil.request.Options import okhttp3.OkHttpClient import okhttp3.Request 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 { +class DecryptingFetcherFactory(private val context: Context, base64: Base64, private val roomId: RoomId) : Fetcher.Factory { private val mediaDecrypter = MediaDecrypter(base64) 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 context: Context, private val mediaDecrypter: MediaDecrypter, + roomId: RoomId, ) : Fetcher { - override suspend fun fetch(): FetchResult { - val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute() - 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 val directory by lazy { + context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!.resolve("SmallTalk/${roomId.value}").also { it.mkdirs() } } - private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { - return response.body?.byteStream()?.let { byteStream -> - Buffer().also { buffer -> - mediaDecrypter.decrypt(byteStream, keys.k, keys.iv).collect { buffer.write(it) } + override suspend fun fetch(): FetchResult { + val diskCacheKey = data.imageMeta.url.hashCode().toString() + val diskCachedFile = directory.resolve(diskCacheKey) + 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) + } } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt index 01cb2e6..6849d35 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt @@ -5,16 +5,16 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Parcelable +import android.widget.Toast import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf 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.module -import app.dapk.st.core.viewModel import app.dapk.st.matrix.common.RoomId +import app.dapk.st.messenger.gallery.GetImageFromGallery import app.dapk.st.navigator.MessageAttachment import kotlinx.parcelize.Parcelize @@ -50,11 +50,26 @@ class MessengerActivity : DapkActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val payload = readPayload() - 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 { Surface(Modifier.fillMaxSize()) { CompositionLocalProvider(LocalDecyptingFetcherFactory provides factory) { - MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator) + MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator, galleryLauncher) } } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt index f34013f..90446f9 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt @@ -4,6 +4,7 @@ import android.content.Context import app.dapk.st.core.Base64 import app.dapk.st.core.ProvidableModule 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.room.RoomService import app.dapk.st.matrix.sync.RoomStore @@ -30,5 +31,5 @@ class MessengerModule( return TimelineUseCaseImpl(syncService, messageService, roomService, mergeWithLocalEchosUseCase) } - internal fun decryptingFetcherFactory() = DecryptingFetcherFactory(context, base64) + internal fun decryptingFetcherFactory(roomId: RoomId) = DecryptingFetcherFactory(context, base64, roomId) } \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 1730b34..ee891ff 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -1,9 +1,11 @@ package app.dapk.st.messenger import android.content.res.Configuration +import androidx.activity.result.ActivityResultLauncher import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* 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.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Send import androidx.compose.material3.* 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.StartObserving import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.core.extensions.takeIfContent import app.dapk.st.design.components.* import app.dapk.st.matrix.common.RoomId 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.Message 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.Navigator import coil.compose.rememberAsyncImagePainter @@ -47,10 +52,16 @@ import coil.request.ImageRequest import kotlinx.coroutines.launch @Composable -internal fun MessengerScreen(roomId: RoomId, attachments: List?, viewModel: MessengerViewModel, navigator: Navigator) { +internal fun MessengerScreen( + roomId: RoomId, + attachments: List?, + viewModel: MessengerViewModel, + navigator: Navigator, + galleryLauncher: ActivityResultLauncher +) { val state = viewModel.state - viewModel.ObserveEvents() + viewModel.ObserveEvents(galleryLauncher) LifecycleEffect( onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }, onStop = { viewModel.post(MessengerAction.OnMessengerGone) } @@ -63,9 +74,9 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List { @@ -74,6 +85,7 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List) { StartObserving { 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) { painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) .fetcherFactory(LocalDecyptingFetcherFactory.current) + .memoryCacheKey(content.message.imageMeta.url) .data(content.message) .build() ), @@ -408,6 +427,7 @@ private fun ReplyBubbleContent(content: BubbleContent) { val context = LocalContext.current Column( Modifier + .fillMaxWidth() .background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground) .padding(4.dp) ) { @@ -417,13 +437,13 @@ private fun ReplyBubbleContent(content: BubbleContent) { fontSize = 11.sp, text = replyName, maxLines = 1, - color = MaterialTheme.colorScheme.onPrimary + color = content.textColor() ) when (val replyingTo = content.message.replyingTo) { is Message -> { Text( text = replyingTo.content, - color = MaterialTheme.colorScheme.onPrimary, + color = content.textColor(), fontSize = 15.sp, modifier = Modifier.wrapContentSize(), textAlign = TextAlign.Start, @@ -437,6 +457,7 @@ private fun ReplyBubbleContent(content: BubbleContent) { painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) .fetcherFactory(LocalDecyptingFetcherFactory.current) + .memoryCacheKey(replyingTo.imageMeta.url) .data(replyingTo) .build() ), @@ -478,7 +499,8 @@ private fun ReplyBubbleContent(content: BubbleContent) { modifier = Modifier.size(message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) - .data(content.message) + .data(message) + .memoryCacheKey(message.imageMeta.url) .fetcherFactory(LocalDecyptingFetcherFactory.current) .build() ), @@ -550,7 +572,7 @@ private fun RowScope.SendStatus(message: RoomEvent) { } @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( Modifier .fillMaxWidth() @@ -576,7 +598,17 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un onValueChange = { onTextChange(it) }, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), 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 = "", + ) + } + } ) } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt index 17bcd11..cf335e6 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt @@ -10,7 +10,9 @@ data class MessengerScreenState( val composerState: ComposerState, ) -sealed interface MessengerEvent +sealed interface MessengerEvent { + object SelectImageAttachment : MessengerEvent +} sealed interface ComposerState { diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt index 86930b0..94a59f1 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt @@ -48,6 +48,7 @@ internal class MessengerViewModel( is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue)) } MessengerAction.ComposerSendText -> sendMessage() 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 -> { val copy = composerState.copy() 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 @@ -133,6 +141,7 @@ private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roo sealed interface MessengerAction { data class ComposerTextUpdate(val newValue: String) : MessengerAction + data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction object ComposerSendText : MessengerAction object ComposerClear : MessengerAction data class OnMessengerVisible(val roomId: RoomId, val attachments: List?) : MessengerAction diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCase.kt new file mode 100644 index 0000000..1f17102 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCase.kt @@ -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 { + 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() + 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++ + } + +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCase.kt new file mode 100644 index 0000000..7ea73f7 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCase.kt @@ -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 { + + return dispatchers.withIoContext { + val media = mutableListOf() + 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, +) + diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryActivity.kt new file mode 100644 index 0000000..06faf13 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryActivity.kt @@ -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() } + 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.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>, 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() { + + 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 \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt new file mode 100644 index 0000000..0e92bdb --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt @@ -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, + ) + +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt new file mode 100644 index 0000000..acb46c1 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt @@ -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?) -> 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 { } + } + + } + +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryViewModel.kt new file mode 100644 index 0000000..59cbbb4 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryViewModel.kt @@ -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( + 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 { copy(content = Lce.Content(folders)) } + } + + } + + fun goTo(page: SpiderPage) { + 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 { + copy(content = Lce.Content(media)) + } + } + } + + @Suppress("UNCHECKED_CAST") + private inline fun updatePageState(crossinline block: S.() -> S) { + val page = state.page + val currentState = page.state + require(currentState is S) + updateState { copy(page = (page as SpiderPage).copy(state = block(page.state))) } + } + +} + +data class ImageGalleryState( + val page: SpiderPage, +) + + +sealed interface ImageGalleryPage { + data class Folders(val content: Lce>) : ImageGalleryPage + data class Files(val content: Lce>) : ImageGalleryPage + + object Routes { + val folders = Route("Folders") + val files = Route("Files") + } +} + +sealed interface ImageGalleryEvent diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/MediaStoreExtensions.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/MediaStoreExtensions.kt new file mode 100644 index 0000000..dd0679f --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/MediaStoreExtensions.kt @@ -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" diff --git a/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt b/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt index 1e6efea..e52d34c 100644 --- a/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt +++ b/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt @@ -81,7 +81,7 @@ data class MessageAttachment(val uri: AndroidUri, val type: MimeType) : Parcelab private companion object : Parceler { override fun create(parcel: Parcel): MessageAttachment { val uri = AndroidUri(parcel.readString()!!) - val type = when(parcel.readString()!!) { + val type = when (parcel.readString()!!) { "mimetype-image" -> MimeType.Image else -> throw IllegalStateException() } diff --git a/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/MatrixClient.kt b/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/MatrixClient.kt index 24294eb..695dff4 100644 --- a/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/MatrixClient.kt +++ b/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/MatrixClient.kt @@ -43,7 +43,11 @@ data class ServiceDependencies( interface MatrixServiceInstaller { fun serializers(builder: SerializersModuleBuilder.() -> Unit) - fun install(factory: MatrixService.Factory) + fun install(factory: MatrixService.Factory): InstallExtender +} + +interface InstallExtender { + fun proxy(proxy: (T) -> T) } interface MatrixServiceProvider { diff --git a/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/ServiceInstaller.kt b/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/ServiceInstaller.kt index 2e59c9c..a2d6a1c 100644 --- a/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/ServiceInstaller.kt +++ b/matrix/matrix/src/main/kotlin/app/dapk/st/matrix/ServiceInstaller.kt @@ -11,15 +11,22 @@ internal class ServiceInstaller { private val services = mutableMapOf() private val serviceInstaller = object : MatrixServiceInstaller { - val serviceCollector = mutableListOf() + val serviceCollector = mutableListOf MatrixService>>() val serializers = mutableListOf Unit>() override fun serializers(builder: SerializersModuleBuilder.() -> Unit) { serializers.add(builder) } - override fun install(factory: MatrixService.Factory) { - serviceCollector.add(factory) + override fun install(factory: MatrixService.Factory): InstallExtender { + val mutableProxy = MutableProxy() + return object : InstallExtender { + 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 { override fun getService(key: ServiceKey) = this@ServiceInstaller.getService(key) } - serviceInstaller.serviceCollector.forEach { - val (key, service) = it.create(ServiceDependencies(httpClient, json, serviceProvider, logger)) - services[key] = service + serviceInstaller.serviceCollector.forEach { (factory, extender) -> + val (key, service) = factory.create(ServiceDependencies(httpClient, json, serviceProvider, logger)) + services[key] = extender(service) } } @@ -57,4 +64,13 @@ internal class ServiceInstaller { ?: throw IllegalArgumentException("No service available to handle ${task.type}") } +} + +internal class MutableProxy : (MatrixService) -> MatrixService { + + var value: (T) -> T = { it } + + @Suppress("UNCHECKED_CAST") + override fun invoke(service: MatrixService) = value(service as T) + } \ No newline at end of file diff --git a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt index 95db7f4..348df96 100644 --- a/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt +++ b/matrix/services/auth/src/main/kotlin/app/dapk/st/matrix/auth/AuthService.kt @@ -1,5 +1,6 @@ package app.dapk.st.matrix.auth +import app.dapk.st.matrix.InstallExtender import app.dapk.st.matrix.MatrixClient import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixServiceInstaller @@ -25,8 +26,8 @@ interface AuthService : MatrixService { fun MatrixServiceInstaller.installAuthService( credentialsStore: CredentialsStore, -) { - this.install { (httpClient, json) -> +): InstallExtender { + return this.install { (httpClient, json) -> SERVICE_KEY to DefaultAuthService(httpClient, credentialsStore, json) } } diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt index becaf3b..ba5936b 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt @@ -2,10 +2,7 @@ package app.dapk.st.matrix.crypto import app.dapk.st.core.Base64 import app.dapk.st.core.CoroutineDispatchers -import app.dapk.st.matrix.MatrixService -import app.dapk.st.matrix.MatrixServiceInstaller -import app.dapk.st.matrix.MatrixServiceProvider -import app.dapk.st.matrix.ServiceDepFactory +import app.dapk.st.matrix.* import app.dapk.st.matrix.common.* import app.dapk.st.matrix.crypto.internal.* import app.dapk.st.matrix.device.deviceService @@ -136,8 +133,8 @@ fun MatrixServiceInstaller.installCryptoService( roomMembersProvider: ServiceDepFactory, base64: Base64, coroutineDispatchers: CoroutineDispatchers, -) { - this.install { (_, _, services, logger) -> +): InstallExtender { + return this.install { (_, _, services, logger) -> val deviceService = services.deviceService() val accountCryptoUseCase = FetchAccountCryptoUseCaseImpl(credentialsStore, olm, deviceService) diff --git a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/DeviceService.kt b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/DeviceService.kt index c69d8d4..1244c65 100644 --- a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/DeviceService.kt +++ b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/DeviceService.kt @@ -1,5 +1,6 @@ package app.dapk.st.matrix.device +import app.dapk.st.matrix.InstallExtender import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixServiceInstaller import app.dapk.st.matrix.MatrixServiceProvider @@ -122,8 +123,8 @@ sealed class ToDevicePayload { sealed interface VerificationPayload } -fun MatrixServiceInstaller.installEncryptionService(knownDeviceStore: KnownDeviceStore) { - this.install { (httpClient, _, _, logger) -> +fun MatrixServiceInstaller.installEncryptionService(knownDeviceStore: KnownDeviceStore): InstallExtender { + return this.install { (httpClient, _, _, logger) -> SERVICE_KEY to DefaultDeviceService(httpClient, logger, knownDeviceStore) } } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt index 23fdb98..35b6297 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt @@ -1,10 +1,7 @@ package app.dapk.st.matrix.message import app.dapk.st.core.Base64 -import app.dapk.st.matrix.MatrixService -import app.dapk.st.matrix.MatrixServiceInstaller -import app.dapk.st.matrix.MatrixServiceProvider -import app.dapk.st.matrix.ServiceDepFactory +import app.dapk.st.matrix.* import app.dapk.st.matrix.common.AlgorithmName import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.MessageType @@ -132,8 +129,8 @@ fun MatrixServiceInstaller.installMessageService( imageContentReader: ImageContentReader, messageEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMessageEncrypter }, mediaEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMediaEncrypter }, -) { - this.install { (httpClient, _, installedServices) -> +): InstallExtender { + return this.install { (httpClient, _, installedServices) -> SERVICE_KEY to DefaultMessageService( httpClient, localEchoStore, diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt index 9af2353..84bc9f7 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt @@ -45,6 +45,7 @@ sealed class ApiMessage { data class Info( @SerialName("h") val height: Int, @SerialName("w") val width: Int, + @SerialName("mimetype") val mimeType: String, @SerialName("size") val size: Long, ) diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index 8398148..a75e088 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -1,6 +1,9 @@ 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.HttpRequest import app.dapk.st.matrix.message.ApiSendResponse @@ -57,7 +60,7 @@ internal class SendMessageUseCase( } } - private suspend fun ApiMessageMapper.imageMessageRequest(message: Message.ImageMessage): HttpRequest { + private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest { val imageMeta = imageContentReader.meta(message.content.uri) return when (message.sendEncrypted) { @@ -91,11 +94,11 @@ internal class SendMessageUseCase( info = ApiMessage.ImageMessage.ImageContent.Info( height = imageMeta.height, width = imageMeta.width, - size = imageMeta.size + size = imageMeta.size, + mimeType = imageMeta.mimeType, ) ) - val json = JsonString( MatrixHttpClient.jsonWithDefaults.encodeToString( ApiMessage.ImageMessage.serializer(), @@ -134,7 +137,8 @@ internal class SendMessageUseCase( ApiMessage.ImageMessage.ImageContent.Info( height = imageMeta.height, 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 - ) - ) - } diff --git a/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileService.kt b/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileService.kt index 28ba329..73e4768 100644 --- a/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileService.kt +++ b/matrix/services/profile/src/main/kotlin/app/dapk/st/matrix/room/ProfileService.kt @@ -1,6 +1,7 @@ package app.dapk.st.matrix.room import app.dapk.st.core.SingletonFlows +import app.dapk.st.matrix.InstallExtender import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixServiceInstaller import app.dapk.st.matrix.MatrixServiceProvider @@ -29,8 +30,8 @@ fun MatrixServiceInstaller.installProfileService( profileStore: ProfileStore, singletonFlows: SingletonFlows, credentialsStore: CredentialsStore, -) { - this.install { (httpClient, _, _, logger) -> +): InstallExtender { + return this.install { (httpClient, _, _, logger) -> SERVICE_KEY to DefaultProfileService(httpClient, logger, profileStore, singletonFlows, credentialsStore) } } diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt index 5402ed3..34026a6 100644 --- a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt @@ -1,5 +1,6 @@ package app.dapk.st.matrix.push +import app.dapk.st.matrix.InstallExtender import app.dapk.st.matrix.MatrixClient import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixServiceInstaller @@ -38,8 +39,8 @@ interface PushService : MatrixService { fun MatrixServiceInstaller.installPushService( credentialsStore: CredentialsStore, -) { - this.install { (httpClient, _, _, logger) -> +): InstallExtender { + return this.install { (httpClient, _, _, logger) -> SERVICE_KEY to DefaultPushService(httpClient, credentialsStore, logger) } } diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt index 1f933a9..56ba0a1 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt @@ -1,9 +1,6 @@ package app.dapk.st.matrix.room -import app.dapk.st.matrix.MatrixService -import app.dapk.st.matrix.MatrixServiceInstaller -import app.dapk.st.matrix.MatrixServiceProvider -import app.dapk.st.matrix.ServiceDepFactory +import app.dapk.st.matrix.* import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomMember @@ -42,8 +39,8 @@ fun MatrixServiceInstaller.installRoomService( memberStore: MemberStore, roomMessenger: ServiceDepFactory, roomInviteRemover: RoomInviteRemover, -) { - this.install { (httpClient, _, services, logger) -> +): InstallExtender { + return this.install { (httpClient, _, services, logger) -> SERVICE_KEY to DefaultRoomService( httpClient, logger, diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt index 139c7cc..68dcd8c 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt @@ -35,6 +35,7 @@ sealed class RoomEvent { @SerialName("meta") override val meta: MessageMeta, @SerialName("encrypted_content") val encryptedContent: MegOlmV1? = null, @SerialName("edited") val edited: Boolean = false, + @SerialName("redacted") val redacted: Boolean = false, ) : RoomEvent() { @Serializable diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt index 6964ff4..2d43ad7 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt @@ -7,8 +7,9 @@ import kotlinx.coroutines.flow.Flow interface RoomStore { - suspend fun persist(roomId: RoomId, state: RoomState) + suspend fun persist(roomId: RoomId, events: List) suspend fun remove(rooms: List) + suspend fun remove(eventId: EventId) suspend fun retrieve(roomId: RoomId): RoomState? fun latest(roomId: RoomId): Flow suspend fun insertUnread(roomId: RoomId, eventIds: List) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt index f0c8530..d0487d3 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt @@ -2,10 +2,7 @@ package app.dapk.st.matrix.sync import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.extensions.ErrorTracker -import app.dapk.st.matrix.MatrixClient -import app.dapk.st.matrix.MatrixService -import app.dapk.st.matrix.MatrixServiceInstaller -import app.dapk.st.matrix.ServiceDepFactory +import app.dapk.st.matrix.* import app.dapk.st.matrix.common.* import app.dapk.st.matrix.sync.internal.DefaultSyncService import app.dapk.st.matrix.sync.internal.request.* @@ -49,7 +46,7 @@ fun MatrixServiceInstaller.installSyncService( errorTracker: ErrorTracker, coroutineDispatchers: CoroutineDispatchers, syncConfig: SyncConfig = SyncConfig(), -) { +): InstallExtender { this.serializers { polymorphicDefault(ApiTimelineEvent::class) { 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( httpClient = httpClient, syncStore = syncStore, diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiTimelineEvent.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiTimelineEvent.kt index 6a26b02..f4ecf19 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiTimelineEvent.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiTimelineEvent.kt @@ -82,7 +82,6 @@ internal sealed class ApiTimelineEvent { ) } - @Serializable @SerialName("m.room.member") 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 internal data class DecryptionStatus( @SerialName("is_verified") val isVerified: Boolean diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt index df4bb80..7ab8f17 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt @@ -1,9 +1,7 @@ package app.dapk.st.matrix.sync.internal.sync -import app.dapk.st.matrix.common.MatrixLogTag -import app.dapk.st.matrix.common.MatrixLogger -import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.common.matrixLog +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomState import app.dapk.st.matrix.sync.RoomStore @@ -26,7 +24,7 @@ class RoomDataSource( logger.matrixLog(MatrixLogTag.SYNC, "no changes, not persisting") } else { roomCache[roomId] = newState - roomStore.persist(roomId, newState) + roomStore.persist(roomId, newState.events) } } @@ -34,4 +32,35 @@ class RoomDataSource( roomsLeft.forEach { roomCache.remove(it) } 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) } \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt index 5eb0353..d73f9f7 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt @@ -21,6 +21,10 @@ internal class RoomProcessor( val members = roomToProcess.apiSyncRoom.collectMembers(roomToProcess.userCredentials) roomMembersService.insert(roomToProcess.roomId, members) + roomToProcess.apiSyncRoom.timeline.apiTimelineEvents.filterIsInstance().forEach { + roomDataSource.redact(roomToProcess.roomId, it.redactedId) + } + val previousState = roomDataSource.read(roomToProcess.roomId) val (newEvents, distinctEvents) = timelineEventsProcessor.process( diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt index 5875854..9bba338 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt @@ -61,8 +61,6 @@ internal class SyncReducer( } } - roomDataSource.remove(roomsLeft) - return ReducerResult( newRooms, (apiRoomsToProcess + roomsWithSideEffects).awaitAll().filterNotNull(), diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt index dd4301b..42a3b69 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt @@ -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.request.syncRequest import app.dapk.st.matrix.sync.internal.room.SyncSideEffects -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.flow @@ -25,19 +24,17 @@ internal class SyncUseCase( private val syncConfig: SyncConfig, ) { - fun sync(): Flow { - return flow { - val credentials = credentialsStore.credentials()!! - val filterId = filterUseCase.reducedFilter(credentials.userId) - with(flowIterator) { - loop( - initial = null, - onPost = { emit(Unit) }, - onIteration = { onEachSyncIteration(filterId, credentials, previousState = it) } - ) - } - }.cancellable() - } + private val _flow = flow { + val credentials = credentialsStore.credentials()!! + val filterId = filterUseCase.reducedFilter(credentials.userId) + with(flowIterator) { + loop( + initial = null, + onPost = { emit(Unit) }, + onIteration = { onEachSyncIteration(filterId, credentials, previousState = it) } + ) + } + }.cancellable() private suspend fun onEachSyncIteration(filterId: SyncService.FilterId, credentials: UserCredentials, previousState: OverviewState?): OverviewState? { val syncToken = syncStore.read(key = SyncStore.SyncKey.Overview) @@ -85,4 +82,6 @@ internal class SyncUseCase( ) } + fun sync() = _flow + } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt index 0ac76c1..419bd9c 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessor.kt @@ -32,6 +32,7 @@ internal class TimelineEventsProcessor( is ApiTimelineEvent.TimelineMessage -> event.toRoomEvent(roomToProcess.userCredentials, roomToProcess.roomId) { eventId -> eventLookupUseCase.lookup(eventId, decryptedTimeline, decryptedPreviousEvents) } + is ApiTimelineEvent.RoomRedcation -> null is ApiTimelineEvent.Encryption -> null is ApiTimelineEvent.RoomAvatar -> null is ApiTimelineEvent.RoomCreate -> null diff --git a/test-harness/build.gradle b/test-harness/build.gradle index e694fdd..fa5f378 100644 --- a/test-harness/build.gradle +++ b/test-harness/build.gradle @@ -9,7 +9,7 @@ test { dependencies { kotlinTest(it) - testImplementation 'app.cash.turbine:turbine:0.10.0' + testImplementation 'app.cash.turbine:turbine:0.11.0' testImplementation Dependencies.mavenCentral.kotlinSerializationJson @@ -28,7 +28,7 @@ dependencies { testImplementation project(":matrix:services:crypto") testImplementation rootProject.files("external/jolm.jar") - testImplementation 'org.json:json:20220320' + testImplementation 'org.json:json:20220924' testImplementation Dependencies.mavenCentral.ktorJava testImplementation Dependencies.mavenCentral.sqldelightInMemory diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index cccb5c8..488680c 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -123,18 +123,20 @@ class SmokeTest { alice.expectTextMessage(SharedState.sharedRoom, message2) // Needs investigation -// val aliceSecondDevice = testMatrix(SharedState.alice, isTemp = true, withLogging = true).also { it.newlogin() } -// aliceSecondDevice.client.syncService().startSyncing().collectAsync { -// val message3 = "from alice to bob and alice's second device".from(SharedState.alice.roomMember) -// alice.sendTextMessage(SharedState.sharedRoom, message3.content, isEncrypted) -// aliceSecondDevice.expectTextMessage(SharedState.sharedRoom, message3) -// bob.expectTextMessage(SharedState.sharedRoom, message3) -// -// val message4 = "from alice's second device to bob and alice's first device".from(SharedState.alice.roomMember) -// aliceSecondDevice.sendTextMessage(SharedState.sharedRoom, message4.content, isEncrypted) -// alice.expectTextMessage(SharedState.sharedRoom, message4) -// bob.expectTextMessage(SharedState.sharedRoom, message4) -// } + val aliceSecondDevice = testMatrix(SharedState.alice, isTemp = true, withLogging = true).also { it.newlogin() } + aliceSecondDevice.client.syncService().startSyncing().collectAsync { + aliceSecondDevice.client.proxyDeviceService().waitForOneTimeKeysToBeUploaded() + + val message3 = "from alice to bob and alice's second device".from(SharedState.alice.roomMember) + alice.sendTextMessage(SharedState.sharedRoom, message3.content, isEncrypted) + aliceSecondDevice.expectTextMessage(SharedState.sharedRoom, message3) + bob.expectTextMessage(SharedState.sharedRoom, message3) + + val message4 = "from alice's second device to bob and alice's first device".from(SharedState.alice.roomMember) + aliceSecondDevice.sendTextMessage(SharedState.sharedRoom, message4.content, isEncrypted) + alice.expectTextMessage(SharedState.sharedRoom, message4) + bob.expectTextMessage(SharedState.sharedRoom, message4) + } } } diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 023a3b1..fce9c4f 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -14,6 +14,7 @@ import app.dapk.st.matrix.crypto.RoomMembersProvider import app.dapk.st.matrix.crypto.Verification import app.dapk.st.matrix.crypto.cryptoService 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.installEncryptionService import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory @@ -39,6 +40,7 @@ import test.impl.PrintingErrorTracking import java.io.File import java.time.Clock import javax.imageio.ImageIO +import kotlin.coroutines.resume object TestUsers { @@ -93,7 +95,9 @@ class TestMatrix( ).also { it.install { installAuthService(storeModule.credentialsStore()) - installEncryptionService(storeModule.knownDevicesStore()) + installEncryptionService(storeModule.knownDevicesStore()).proxy { + ProxyDeviceService(it) + } val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore(), base64) val olm = OlmWrapper( @@ -355,4 +359,23 @@ class JavaImageContentReader : ImageContentReader { override fun inputStream(uri: String) = File(uri).inputStream() -} \ No newline at end of file +} + +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 \ No newline at end of file diff --git a/tools/beta-release/package-lock.json b/tools/beta-release/package-lock.json index adae758..efeda28 100644 --- a/tools/beta-release/package-lock.json +++ b/tools/beta-release/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@googleapis/androidpublisher": "^3.0.0", - "matrix-js-sdk": "^19.4.0", + "matrix-js-sdk": "^19.7.0", "request": "^2.88.2" } }, @@ -631,9 +631,9 @@ "integrity": "sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA==" }, "node_modules/matrix-js-sdk": { - "version": "19.4.0", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.4.0.tgz", - "integrity": "sha512-B8Mm4jCsCHaMaChcdM3VhZDVKrn0nMSDtYvHmS15Iu8Pe0G4qmIpk2AoADBAL9U9yN3pCqvs3TDXaQhM8UxRRA==", + "version": "19.7.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz", + "integrity": "sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg==", "dependencies": { "@babel/runtime": "^7.12.5", "another-json": "^0.2.0", @@ -1448,9 +1448,9 @@ "integrity": "sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA==" }, "matrix-js-sdk": { - "version": "19.4.0", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.4.0.tgz", - "integrity": "sha512-B8Mm4jCsCHaMaChcdM3VhZDVKrn0nMSDtYvHmS15Iu8Pe0G4qmIpk2AoADBAL9U9yN3pCqvs3TDXaQhM8UxRRA==", + "version": "19.7.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz", + "integrity": "sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg==", "requires": { "@babel/runtime": "^7.12.5", "another-json": "^0.2.0", diff --git a/tools/beta-release/package.json b/tools/beta-release/package.json index c10bfa0..b826e45 100644 --- a/tools/beta-release/package.json +++ b/tools/beta-release/package.json @@ -7,7 +7,7 @@ "private": true, "dependencies": { "@googleapis/androidpublisher": "^3.0.0", - "matrix-js-sdk": "^19.4.0", + "matrix-js-sdk": "^19.7.0", "request": "^2.88.2" } } diff --git a/tools/beta-release/release.js b/tools/beta-release/release.js index dfe83c7..358147c 100644 --- a/tools/beta-release/release.js +++ b/tools/beta-release/release.js @@ -56,6 +56,7 @@ export const release = async (github, version, applicationId, artifacts, config) console.log(releaseResult.data.id) + console.log("Uploading universal apk...") await github.rest.repos.uploadReleaseAsset({ owner: config.owner, repo: config.repo, @@ -64,6 +65,15 @@ export const release = async (github, version, applicationId, artifacts, config) 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...") await promoteDraftToLive(applicationId) diff --git a/tools/generate-fdroid-release.sh b/tools/generate-fdroid-release.sh index bf4d074..e1ff974 100755 --- a/tools/generate-fdroid-release.sh +++ b/tools/generate-fdroid-release.sh @@ -9,7 +9,7 @@ SIGNED=$WORKING_DIR/app-foss-release-signed.apk ZIPALIGN=$(find "$ANDROID_HOME" -iname zipalign -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 diff --git a/tools/generate-release.sh b/tools/generate-release.sh index 16ae11f..ea757a4 100755 --- a/tools/generate-release.sh +++ b/tools/generate-release.sh @@ -1,6 +1,6 @@ #! /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 RELEASE_AAB=$WORKING_DIR/app-release.aab diff --git a/version.json b/version.json index 9e572b7..b8a10c3 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "code": 18, - "name": "22/09/2022-V1" + "code": 19, + "name": "29/09/2022-V1" } \ No newline at end of file