From 3c2a22655584b2a05db33dc8870a9613c9e68781 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 20 Sep 2022 20:54:35 +0100 Subject: [PATCH 01/44] extracting a dedicated media decrypter --- .../dapk/st/messenger/DecryptingFetcher.kt | 43 ++--------------- .../app/dapk/st/messenger/MediaDecrypter.kt | 47 +++++++++++++++++++ .../message/internal/SendMessageUseCase.kt | 39 +++++++++------ 3 files changed, 76 insertions(+), 53 deletions(-) create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt 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 0c89d90..4f27103 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,6 @@ package app.dapk.st.messenger import android.content.Context -import android.util.Base64 import app.dapk.st.matrix.sync.RoomEvent import coil.ImageLoader import coil.decode.DataSource @@ -14,15 +13,6 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okio.Buffer -import java.security.MessageDigest -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -private const val CRYPTO_BUFFER_SIZE = 32 * 1024 -private const val CIPHER_ALGORITHM = "AES/CTR/NoPadding" -private const val SECRET_KEY_SPEC_ALGORITHM = "AES" -private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" class DecryptingFetcherFactory(private val context: Context) : Fetcher.Factory { override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher { @@ -34,6 +24,8 @@ private val http = OkHttpClient() class DecryptingFetcher(private val data: RoomEvent.Image, private val context: Context) : Fetcher { + private val mediaDecrypter = MediaDecrypter() + override suspend fun fetch(): FetchResult { val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute() val outputStream = when { @@ -44,32 +36,7 @@ class DecryptingFetcher(private val data: RoomEvent.Image, private val context: } private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { - val key = Base64.decode(keys.k.replace('-', '+').replace('_', '/'), Base64.DEFAULT) - val initVectorBytes = Base64.decode(keys.iv, Base64.DEFAULT) - - val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) - val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) - val ivParameterSpec = IvParameterSpec(initVectorBytes) - decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) - - val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) - - var read: Int - val d = ByteArray(CRYPTO_BUFFER_SIZE) - var decodedBytes: ByteArray - - val outputStream = Buffer() - response.body?.let { - it.byteStream().use { - read = it.read(d) - while (read != -1) { - messageDigest.update(d, 0, read) - decodedBytes = decryptCipher.update(d, 0, read) - outputStream.write(decodedBytes) - read = it.read(d) - } - } - } - return outputStream + return response.body?.byteStream()?.let { mediaDecrypter.decrypt(it, keys) } ?: Buffer() } -} \ No newline at end of file +} + diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt new file mode 100644 index 0000000..9e18c76 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt @@ -0,0 +1,47 @@ +package app.dapk.st.messenger + +import android.util.Base64 +import app.dapk.st.matrix.sync.RoomEvent +import okio.Buffer +import java.io.InputStream +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +private const val CRYPTO_BUFFER_SIZE = 32 * 1024 +private const val CIPHER_ALGORITHM = "AES/CTR/NoPadding" +private const val SECRET_KEY_SPEC_ALGORITHM = "AES" +private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" + +class MediaDecrypter { + + fun decrypt(input: InputStream, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { + val key = Base64.decode(keys.k.replace('-', '+').replace('_', '/'), Base64.DEFAULT) + val initVectorBytes = Base64.decode(keys.iv, Base64.DEFAULT) + + val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) + val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) + val ivParameterSpec = IvParameterSpec(initVectorBytes) + decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) + + val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) + + var read: Int + val d = ByteArray(CRYPTO_BUFFER_SIZE) + var decodedBytes: ByteArray + + val outputStream = Buffer() + input.use { + read = it.read(d) + while (read != -1) { + messageDigest.update(d, 0, read) + decodedBytes = decryptCipher.update(d, 0, read) + outputStream.write(decodedBytes) + read = it.read(d) + } + } + return outputStream + } + +} \ No newline at end of file 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 b094e4f..646b716 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 @@ -38,22 +38,31 @@ internal class SendMessageUseCase( } is MessageService.Message.ImageMessage -> { - val imageContent = imageContentReader.read(message.content.uri) - val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri - val request = sendRequest( - roomId = message.roomId, - eventType = EventType.ROOM_MESSAGE, - txId = message.localId, - content = MessageService.Message.Content.ImageContent( - url = uri, - filename = imageContent.fileName, - MessageService.Message.Content.ImageContent.Info( - height = imageContent.height, - width = imageContent.width, - size = imageContent.size + val request = when (message.sendEncrypted) { + true -> { + throw IllegalStateException() + } + + false -> { + val imageContent = imageContentReader.read(message.content.uri) + val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri + sendRequest( + roomId = message.roomId, + eventType = EventType.ROOM_MESSAGE, + txId = message.localId, + content = MessageService.Message.Content.ImageContent( + url = uri, + filename = imageContent.fileName, + MessageService.Message.Content.ImageContent.Info( + height = imageContent.height, + width = imageContent.width, + size = imageContent.size + ) + ), ) - ), - ) + } + } + httpClient.execute(request).eventId } } From b93058e812e3dc805e28123ddf94f255e68f8934 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 20 Sep 2022 20:56:59 +0100 Subject: [PATCH 02/44] adding dummy tasks to noop module to allow the ide to assemble --- domains/firebase/crashlytics-noop/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/domains/firebase/crashlytics-noop/build.gradle b/domains/firebase/crashlytics-noop/build.gradle index 5ab1042..132fe20 100644 --- a/domains/firebase/crashlytics-noop/build.gradle +++ b/domains/firebase/crashlytics-noop/build.gradle @@ -3,3 +3,7 @@ plugins { id 'kotlin' } dependencies { implementation project(':core') } + + +task generateReleaseSources {} +task compileReleaseSources {} \ No newline at end of file From faf7e647699494373c556d94da0df5eb1e0bdfe3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Sep 2022 05:34:39 +0000 Subject: [PATCH 03/44] Bump junit-jupiter-engine from 5.9.0 to 5.9.1 Bumps [junit-jupiter-engine](https://github.com/junit-team/junit5) from 5.9.0 to 5.9.1. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.9.0...r5.9.1) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-engine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 66dbbba..5209c14 100644 --- a/build.gradle +++ b/build.gradle @@ -136,7 +136,7 @@ ext.kotlinTest = { dependencies -> dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0' - dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0' + dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.1' } ext.kotlinFixtures = { dependencies -> From 40da1e89f8ba733d992e728f188c252d6260d65a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Sep 2022 17:20:53 +0000 Subject: [PATCH 04/44] Bump junit-jupiter-api from 5.9.0 to 5.9.1 Bumps [junit-jupiter-api](https://github.com/junit-team/junit5) from 5.9.0 to 5.9.1. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.9.0...r5.9.1) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-api dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5209c14..a379ec5 100644 --- a/build.gradle +++ b/build.gradle @@ -135,7 +135,7 @@ ext.kotlinTest = { dependencies -> dependencies.testImplementation 'io.mockk:mockk:1.12.8' dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' - dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0' + dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1' dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.1' } From 94ad1cb5958c5251ff5023c9b60d9183c5b0b8f0 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 19:55:41 +0100 Subject: [PATCH 05/44] moving the ApiMessage to the message module --- .../kotlin/app/dapk/st/graph/AppModule.kt | 2 +- .../device/internal/DefaultDeviceService.kt | 21 --------------- .../st/matrix/message/internal/ApiMessage.kt | 26 +++++++++++++++++++ .../src/test/kotlin/test/TestMatrix.kt | 2 +- 4 files changed, 28 insertions(+), 23 deletions(-) create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt 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 9917e86..4bbca82 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -31,7 +31,7 @@ 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.installEncryptionService -import app.dapk.st.matrix.device.internal.ApiMessage +import app.dapk.st.matrix.message.internal.ApiMessage import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.message.MessageEncrypter diff --git a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt index 1bfbe86..ad9fbdf 100644 --- a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt +++ b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt @@ -6,8 +6,6 @@ import app.dapk.st.matrix.device.DeviceService.OneTimeKeys.Key.SignedCurve.Ed255 import app.dapk.st.matrix.device.KnownDeviceStore import app.dapk.st.matrix.device.ToDevicePayload import app.dapk.st.matrix.http.MatrixHttpClient -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import java.util.* @@ -141,22 +139,3 @@ internal class DefaultDeviceService( } } -@Serializable -sealed class ApiMessage { - - @Serializable - @SerialName("text_message") - data class TextMessage( - @SerialName("content") val content: TextContent, - @SerialName("room_id") val roomId: RoomId, - @SerialName("type") val type: String, - ) : ApiMessage() { - - @Serializable - data class TextContent( - @SerialName("body") val body: String, - @SerialName("msgtype") val type: String = MessageType.TEXT.value, - ) - } - -} 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 new file mode 100644 index 0000000..7d4cbc7 --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt @@ -0,0 +1,26 @@ +package app.dapk.st.matrix.message.internal + +import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RoomId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class ApiMessage { + + @Serializable + @SerialName("text_message") + data class TextMessage( + @SerialName("content") val content: TextContent, + @SerialName("room_id") val roomId: RoomId, + @SerialName("type") val type: String, + ) : ApiMessage() { + + @Serializable + data class TextContent( + @SerialName("body") val body: String, + @SerialName("msgtype") val type: String = MessageType.TEXT.value, + ) + } + +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 411f8b6..55206ac 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -16,7 +16,7 @@ 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.installEncryptionService -import app.dapk.st.matrix.device.internal.ApiMessage +import app.dapk.st.matrix.message.internal.ApiMessage import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.message.MessageEncrypter From 06947301acd06d8e79a0ac19236fcd9baa7560d9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 20:07:03 +0100 Subject: [PATCH 06/44] lifting the message contents jsonising to the message usecase --- .../kotlin/app/dapk/st/graph/AppModule.kt | 23 ++----------------- .../st/matrix/message/MessageEncrypter.kt | 14 ++++++----- .../message/internal/SendMessageUseCase.kt | 17 +++++++++++++- .../src/test/kotlin/test/TestMatrix.kt | 23 ++----------------- 4 files changed, 28 insertions(+), 49 deletions(-) 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 4bbca82..b44cf68 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -31,8 +31,6 @@ 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.installEncryptionService -import app.dapk.st.matrix.message.internal.ApiMessage -import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService @@ -277,26 +275,9 @@ internal class MatrixModules( installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader) { serviceProvider -> MessageEncrypter { message -> val result = serviceProvider.cryptoService().encrypt( - roomId = when (message) { - is MessageService.Message.TextMessage -> message.roomId - is MessageService.Message.ImageMessage -> message.roomId - }, + roomId = message.roomId, credentials = credentialsStore.credentials()!!, - when (message) { - is MessageService.Message.TextMessage -> JsonString( - MatrixHttpClient.jsonWithDefaults.encodeToString( - ApiMessage.TextMessage.serializer(), - ApiMessage.TextMessage( - ApiMessage.TextMessage.TextContent( - message.content.body, - message.content.type, - ), message.roomId, type = EventType.ROOM_MESSAGE.value - ) - ) - ) - - is MessageService.Message.ImageMessage -> TODO() - } + messageJson = message.contents, ) MessageEncrypter.EncryptedMessagePayload( diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt index 94300cd..13b67c2 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt @@ -1,15 +1,12 @@ package app.dapk.st.matrix.message -import app.dapk.st.matrix.common.AlgorithmName -import app.dapk.st.matrix.common.CipherText -import app.dapk.st.matrix.common.DeviceId -import app.dapk.st.matrix.common.SessionId +import app.dapk.st.matrix.common.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable fun interface MessageEncrypter { - suspend fun encrypt(message: MessageService.Message): EncryptedMessagePayload + suspend fun encrypt(message: ClearMessagePayload): EncryptedMessagePayload @Serializable data class EncryptedMessagePayload( @@ -19,8 +16,13 @@ fun interface MessageEncrypter { @SerialName("session_id") val sessionId: SessionId, @SerialName("device_id") val deviceId: DeviceId ) + + data class ClearMessagePayload( + val roomId: RoomId, + val contents: JsonString, + ) } internal object MissingMessageEncrypter : MessageEncrypter { - override suspend fun encrypt(message: MessageService.Message) = throw IllegalStateException("No encrypter instance set") + override suspend fun encrypt(message: MessageEncrypter.ClearMessagePayload) = throw IllegalStateException("No encrypter instance set") } \ No newline at end of file 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 646b716..b0a91a2 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 @@ -2,6 +2,7 @@ package app.dapk.st.matrix.message.internal 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.http.MatrixHttpClient import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService @@ -17,11 +18,25 @@ internal class SendMessageUseCase( is MessageService.Message.TextMessage -> { val request = when (message.sendEncrypted) { true -> { + val content = JsonString( + MatrixHttpClient.jsonWithDefaults.encodeToString( + ApiMessage.TextMessage.serializer(), + ApiMessage.TextMessage( + content = ApiMessage.TextMessage.TextContent( + message.content.body, + message.content.type, + ), + roomId = message.roomId, + type = EventType.ROOM_MESSAGE.value + ) + ) + ) + sendRequest( roomId = message.roomId, eventType = EventType.ENCRYPTED, txId = message.localId, - content = messageEncrypter.encrypt(message), + content = messageEncrypter.encrypt(MessageEncrypter.ClearMessagePayload(message.roomId, content)), ) } diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 55206ac..4e89e7b 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -16,8 +16,6 @@ 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.installEncryptionService -import app.dapk.st.matrix.message.internal.ApiMessage -import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService @@ -35,7 +33,6 @@ import app.dapk.st.olm.DeviceKeyFactory import app.dapk.st.olm.OlmPersistenceWrapper import app.dapk.st.olm.OlmWrapper import kotlinx.coroutines.* -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.amshove.kluent.fail import test.impl.InMemoryDatabase @@ -127,25 +124,9 @@ class TestMatrix( installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader()) { serviceProvider -> MessageEncrypter { message -> val result = serviceProvider.cryptoService().encrypt( - roomId = when (message) { - is MessageService.Message.TextMessage -> message.roomId - is MessageService.Message.ImageMessage -> message.roomId - }, + roomId = message.roomId, credentials = storeModule.credentialsStore().credentials()!!, - when (message) { - is MessageService.Message.TextMessage -> JsonString( - MatrixHttpClient.jsonWithDefaults.encodeToString( - ApiMessage.TextMessage( - ApiMessage.TextMessage.TextContent( - message.content.body, - message.content.type, - ), message.roomId, type = EventType.ROOM_MESSAGE.value - ) - ) - ) - - is MessageService.Message.ImageMessage -> TODO() - } + messageJson = message.contents, ) MessageEncrypter.EncryptedMessagePayload( From f6ef073689270b9dcb986d4a54323fad6cf99854 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 20:35:39 +0100 Subject: [PATCH 07/44] adding support for sending encrypted images --- .../kotlin/app/dapk/st/graph/AppModule.kt | 2 +- .../st/matrix/device/internal/ApiMessage.kt | 26 +++ matrix/services/message/build.gradle | 2 + .../dapk/st/matrix/message/MessageService.kt | 31 ++- .../st/matrix/message/internal/ApiMessage.kt | 50 ++++- .../message/internal/DefaultMessageService.kt | 6 +- .../matrix/message/internal/MediaEncrypter.kt | 100 +++++++++ .../message/internal/SendMessageUseCase.kt | 211 ++++++++++++------ .../st/matrix/message/internal/SendRequest.kt | 11 +- test-harness/src/test/kotlin/SmokeTest.kt | 2 +- .../src/test/kotlin/test/TestMatrix.kt | 2 +- 11 files changed, 348 insertions(+), 95 deletions(-) create mode 100644 matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/ApiMessage.kt create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt 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 b44cf68..d80f132 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -272,7 +272,7 @@ internal class MatrixModules( coroutineDispatchers = coroutineDispatchers, ) val imageContentReader = AndroidImageContentReader(contentResolver) - installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader) { serviceProvider -> + installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader, base64) { serviceProvider -> MessageEncrypter { message -> val result = serviceProvider.cryptoService().encrypt( roomId = message.roomId, diff --git a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/ApiMessage.kt b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/ApiMessage.kt new file mode 100644 index 0000000..a2b32ad --- /dev/null +++ b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/ApiMessage.kt @@ -0,0 +1,26 @@ +package app.dapk.st.matrix.device.internal + +import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RoomId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class ApiMessage { + + @Serializable + @SerialName("text_message") + data class TextMessage( + @SerialName("content") val content: TextContent, + @SerialName("room_id") val roomId: RoomId, + @SerialName("type") val type: String, + ) : ApiMessage() { + + @Serializable + data class TextContent( + @SerialName("body") val body: String, + @SerialName("msgtype") val type: String = MessageType.TEXT.value, + ) + } + +} \ No newline at end of file diff --git a/matrix/services/message/build.gradle b/matrix/services/message/build.gradle index 60041a2..9143c32 100644 --- a/matrix/services/message/build.gradle +++ b/matrix/services/message/build.gradle @@ -2,6 +2,8 @@ plugins { id 'java-test-fixtures' } applyMatrixServiceModule(project) dependencies { + implementation project(":core") + kotlinFixtures(it) testFixturesImplementation(testFixtures(project(":core"))) testFixturesImplementation(testFixtures(project(":matrix:common"))) 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 2684194..1fb003a 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,14 @@ 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.common.* +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.message.internal.DefaultMessageService import app.dapk.st.matrix.message.internal.ImageContentReader import kotlinx.coroutines.flow.Flow @@ -67,21 +71,6 @@ interface MessageService : MatrixService { @SerialName("uri") val uri: String, ) : Content() - @Serializable - data class ImageContent( - @SerialName("url") val url: MxUrl, - @SerialName("body") val filename: String, - @SerialName("info") val info: Info, - @SerialName("msgtype") val type: String = MessageType.IMAGE.value, - ) : Content() { - - @Serializable - data class Info( - @SerialName("h") val height: Int, - @SerialName("w") val width: Int, - @SerialName("size") val size: Long, - ) - } } } @@ -141,10 +130,18 @@ fun MatrixServiceInstaller.installMessageService( localEchoStore: LocalEchoStore, backgroundScheduler: BackgroundScheduler, imageContentReader: ImageContentReader, + base64: Base64, messageEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMessageEncrypter }, ) { this.install { (httpClient, _, installedServices) -> - SERVICE_KEY to DefaultMessageService(httpClient, localEchoStore, backgroundScheduler, messageEncrypter.create(installedServices), imageContentReader) + SERVICE_KEY to DefaultMessageService( + httpClient, + localEchoStore, + backgroundScheduler, + base64, + messageEncrypter.create(installedServices), + imageContentReader + ) } } 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 7d4cbc7..9af2353 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 @@ -1,6 +1,7 @@ package app.dapk.st.matrix.message.internal import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.MxUrl import app.dapk.st.matrix.common.RoomId import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -20,7 +21,52 @@ sealed class ApiMessage { data class TextContent( @SerialName("body") val body: String, @SerialName("msgtype") val type: String = MessageType.TEXT.value, - ) + ) : ApiMessageContent } -} \ No newline at end of file + @Serializable + @SerialName("image_message") + data class ImageMessage( + @SerialName("content") val content: ImageContent, + @SerialName("room_id") val roomId: RoomId, + @SerialName("type") val type: String, + ) : ApiMessage() { + + @Serializable + data class ImageContent( + @SerialName("url") val url: MxUrl?, + @SerialName("body") val filename: String, + @SerialName("info") val info: Info, + @SerialName("msgtype") val type: String = MessageType.IMAGE.value, + @SerialName("file") val file: File? = null, + ) : ApiMessageContent { + + @Serializable + data class Info( + @SerialName("h") val height: Int, + @SerialName("w") val width: Int, + @SerialName("size") val size: Long, + ) + + @Serializable + data class File( + @SerialName("url") val url: MxUrl, + @SerialName("key") val key: EncryptionMeta, + @SerialName("iv") val iv: String, + @SerialName("hashes") val hashes: Map, + @SerialName("v") val v: String + ) { + @Serializable + data class EncryptionMeta( + @SerialName("alg") val algorithm: String, + @SerialName("ext") val ext: Boolean, + @SerialName("key_ops") val keyOperations: List, + @SerialName("kty") val kty: String, + @SerialName("k") val k: String + ) + } + } + } +} + +sealed interface ApiMessageContent diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt index 14d4f08..c51dc37 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt @@ -1,5 +1,6 @@ package app.dapk.st.matrix.message.internal +import app.dapk.st.core.Base64 import app.dapk.st.matrix.MatrixTaskRunner import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.http.MatrixHttpClient @@ -19,17 +20,18 @@ internal class DefaultMessageService( httpClient: MatrixHttpClient, private val localEchoStore: LocalEchoStore, private val backgroundScheduler: BackgroundScheduler, + base64: Base64, messageEncrypter: MessageEncrypter, imageContentReader: ImageContentReader, ) : MessageService, MatrixTaskRunner { - private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, imageContentReader) + private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, imageContentReader, base64) private val sendEventMessageUseCase = SendEventMessageUseCase(httpClient) override suspend fun canRun(task: MatrixTaskRunner.MatrixTask) = task.type == MATRIX_MESSAGE_TASK_TYPE || task.type == MATRIX_IMAGE_MESSAGE_TASK_TYPE override suspend fun run(task: MatrixTaskRunner.MatrixTask): MatrixTaskRunner.TaskResult { - val message = when(task.type) { + val message = when (task.type) { MATRIX_MESSAGE_TASK_TYPE -> Json.decodeFromString(MessageService.Message.TextMessage.serializer(), task.jsonPayload) MATRIX_IMAGE_MESSAGE_TASK_TYPE -> Json.decodeFromString(MessageService.Message.ImageMessage.serializer(), task.jsonPayload) else -> throw IllegalStateException("Unhandled task type: ${task.type}") diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt new file mode 100644 index 0000000..181ec2d --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt @@ -0,0 +1,100 @@ +package app.dapk.st.matrix.message.internal + +import app.dapk.st.core.Base64 +import java.io.File +import java.io.InputStream +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +private const val CRYPTO_BUFFER_SIZE = 32 * 1024 +private const val CIPHER_ALGORITHM = "AES/CTR/NoPadding" +private const val SECRET_KEY_SPEC_ALGORITHM = "AES" +private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" + +class MediaEncrypter(private val base64: Base64) { + + fun encrypt(input: InputStream, name: String): Result { + val secureRandom = SecureRandom() + val initVectorBytes = ByteArray(16) { 0.toByte() } + + val ivRandomPart = ByteArray(8) + secureRandom.nextBytes(ivRandomPart) + + System.arraycopy(ivRandomPart, 0, initVectorBytes, 0, ivRandomPart.size) + + val key = ByteArray(32) + secureRandom.nextBytes(key) + + val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) + + val outputFile = File.createTempFile("_encrypt-${name.hashCode()}", ".png") + + val outputStream = outputFile.outputStream() + outputStream.use { s -> + val encryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) + val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) + val ivParameterSpec = IvParameterSpec(initVectorBytes) + encryptCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec) + + val data = ByteArray(CRYPTO_BUFFER_SIZE) + var read: Int + var encodedBytes: ByteArray + + input.use { inputStream -> + read = inputStream.read(data) + var totalRead = read + while (read != -1) { + encodedBytes = encryptCipher.update(data, 0, read) + messageDigest.update(encodedBytes, 0, encodedBytes.size) + s.write(encodedBytes) + read = inputStream.read(data) + totalRead += read + } + } + + encodedBytes = encryptCipher.doFinal() + messageDigest.update(encodedBytes, 0, encodedBytes.size) + s.write(encodedBytes) + } + + return Result( + contents = outputFile.readBytes(), + algorithm = "A256CTR", + ext = true, + keyOperations = listOf("encrypt", "decrypt"), + kty = "oct", + k = base64ToBase64Url(base64.encode(key)), + iv = base64.encode(initVectorBytes).replace("\n", "").replace("=", ""), + hashes = mapOf("sha256" to base64ToUnpaddedBase64(base64.encode(messageDigest.digest()))), + v = "v2" + ) + } + + data class Result( + val contents: ByteArray, + val algorithm: String, + val ext: Boolean, + val keyOperations: List, + val kty: String, + val k: String, + val iv: String, + val hashes: Map, + val v: String, + ) + +} + +private fun base64ToBase64Url(base64: String): String { + return base64.replace("\n".toRegex(), "") + .replace("\\+".toRegex(), "-") + .replace('/', '_') + .replace("=", "") +} + +private fun base64ToUnpaddedBase64(base64: String): String { + return base64.replace("\n".toRegex(), "") + .replace("=", "") +} 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 b0a91a2..d3799e1 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,86 +1,167 @@ package app.dapk.st.matrix.message.internal -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.core.Base64 +import app.dapk.st.matrix.common.* import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest +import app.dapk.st.matrix.message.ApiSendResponse import app.dapk.st.matrix.message.MessageEncrypter -import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.message.MessageService.Message +import java.io.ByteArrayInputStream internal class SendMessageUseCase( private val httpClient: MatrixHttpClient, private val messageEncrypter: MessageEncrypter, private val imageContentReader: ImageContentReader, + private val base64: Base64, ) { - suspend fun sendMessage(message: MessageService.Message): EventId { - return when (message) { - is MessageService.Message.TextMessage -> { - val request = when (message.sendEncrypted) { - true -> { - val content = JsonString( - MatrixHttpClient.jsonWithDefaults.encodeToString( - ApiMessage.TextMessage.serializer(), - ApiMessage.TextMessage( - content = ApiMessage.TextMessage.TextContent( - message.content.body, - message.content.type, - ), - roomId = message.roomId, - type = EventType.ROOM_MESSAGE.value - ) - ) - ) + private val mapper = ApiMessageMapper() - sendRequest( - roomId = message.roomId, - eventType = EventType.ENCRYPTED, - txId = message.localId, - content = messageEncrypter.encrypt(MessageEncrypter.ClearMessagePayload(message.roomId, content)), - ) - } - - false -> { - sendRequest( - roomId = message.roomId, - eventType = EventType.ROOM_MESSAGE, - txId = message.localId, - content = message.content, - ) - } + suspend fun sendMessage(message: Message): EventId { + return with(mapper) { + when (message) { + is Message.TextMessage -> { + val request = textMessageRequest(message) + httpClient.execute(request).eventId } - httpClient.execute(request).eventId + + is Message.ImageMessage -> { + val request = imageMessageRequest(message) + httpClient.execute(request).eventId + } + } + } + } + + private suspend fun ApiMessageMapper.textMessageRequest(message: Message.TextMessage): HttpRequest { + val contents = message.toContents() + return when (message.sendEncrypted) { + true -> sendRequest( + roomId = message.roomId, + eventType = EventType.ENCRYPTED, + txId = message.localId, + content = messageEncrypter.encrypt( + MessageEncrypter.ClearMessagePayload( + message.roomId, + contents.toMessageJson(message.roomId) + ) + ), + ) + + false -> sendRequest( + roomId = message.roomId, + eventType = EventType.ROOM_MESSAGE, + txId = message.localId, + content = contents, + ) + } + } + + private suspend fun ApiMessageMapper.imageMessageRequest(message: Message.ImageMessage): HttpRequest { + val imageContent = imageContentReader.read(message.content.uri) + + return when (message.sendEncrypted) { + true -> { + val result = MediaEncrypter(base64).encrypt( + ByteArrayInputStream(imageContent.content), + imageContent.fileName, + ) + + val uri = httpClient.execute(uploadRequest(result.contents, imageContent.fileName, "application/octet-stream")).contentUri + + val content = ApiMessage.ImageMessage.ImageContent( + url = null, + filename = imageContent.fileName, + file = ApiMessage.ImageMessage.ImageContent.File( + url = uri, + key = ApiMessage.ImageMessage.ImageContent.File.EncryptionMeta( + algorithm = result.algorithm, + ext = result.ext, + keyOperations = result.keyOperations, + kty = result.kty, + k = result.k, + ), + iv = result.iv, + hashes = result.hashes, + v = result.v, + ), + info = ApiMessage.ImageMessage.ImageContent.Info( + height = imageContent.height, + width = imageContent.width, + size = imageContent.size + ) + ) + + + val json = JsonString( + MatrixHttpClient.jsonWithDefaults.encodeToString( + ApiMessage.ImageMessage.serializer(), + ApiMessage.ImageMessage( + content = content, + roomId = message.roomId, + type = EventType.ROOM_MESSAGE.value, + ) + ) + ) + + sendRequest( + roomId = message.roomId, + eventType = EventType.ENCRYPTED, + txId = message.localId, + content = messageEncrypter.encrypt(MessageEncrypter.ClearMessagePayload(message.roomId, json)), + ) } - is MessageService.Message.ImageMessage -> { - val request = when (message.sendEncrypted) { - true -> { - throw IllegalStateException() - } - - false -> { - val imageContent = imageContentReader.read(message.content.uri) - val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri - sendRequest( - roomId = message.roomId, - eventType = EventType.ROOM_MESSAGE, - txId = message.localId, - content = MessageService.Message.Content.ImageContent( - url = uri, - filename = imageContent.fileName, - MessageService.Message.Content.ImageContent.Info( - height = imageContent.height, - width = imageContent.width, - size = imageContent.size - ) - ), + false -> { + val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri + sendRequest( + roomId = message.roomId, + eventType = EventType.ROOM_MESSAGE, + txId = message.localId, + content = ApiMessage.ImageMessage.ImageContent( + url = uri, + filename = imageContent.fileName, + ApiMessage.ImageMessage.ImageContent.Info( + height = imageContent.height, + width = imageContent.width, + size = imageContent.size ) - } - } - - httpClient.execute(request).eventId + ), + ) } } } } + + +class ApiMessageMapper { + + fun Message.TextMessage.toContents() = ApiMessage.TextMessage.TextContent( + this.content.body, + this.content.type, + ) + + fun ApiMessage.TextMessage.TextContent.toMessageJson(roomId: RoomId) = JsonString( + MatrixHttpClient.jsonWithDefaults.encodeToString( + ApiMessage.TextMessage.serializer(), + ApiMessage.TextMessage( + content = this, + roomId = roomId, + type = EventType.ROOM_MESSAGE.value + ) + ) + ) + + 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/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt index 5cf1d64..d3cc3d3 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt @@ -9,18 +9,18 @@ import app.dapk.st.matrix.message.ApiSendResponse import app.dapk.st.matrix.message.ApiUploadResponse import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.EventMessage -import app.dapk.st.matrix.message.MessageService.Message +import app.dapk.st.matrix.message.internal.ApiMessage.ImageMessage +import app.dapk.st.matrix.message.internal.ApiMessage.TextMessage import io.ktor.content.* import io.ktor.http.* import java.util.* -internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: Message.Content) = httpRequest( +internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: ApiMessageContent) = httpRequest( path = "_matrix/client/r0/rooms/${roomId.value}/send/${eventType.value}/${txId}", method = MatrixHttpClient.Method.PUT, body = when (content) { - is Message.Content.TextContent -> jsonBody(Message.Content.TextContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) - is Message.Content.ImageContent -> jsonBody(Message.Content.ImageContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) - is Message.Content.ApiImageContent -> throw IllegalArgumentException() + is TextMessage.TextContent -> jsonBody(TextMessage.TextContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) + is ImageMessage.ImageContent -> jsonBody(ImageMessage.ImageContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) } ) @@ -45,5 +45,4 @@ internal fun uploadRequest(body: ByteArray, filename: String, contentType: Strin body = ByteArrayContent(body, ContentType.parse(contentType)), ) - fun txId() = "local.${UUID.randomUUID()}" \ No newline at end of file diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index d692c96..b610072 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -75,7 +75,7 @@ class SmokeTest { @Order(6) fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> val testImage = loadResourceFile("test-image.png") - alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) + alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = true) bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) } diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 4e89e7b..3da7355 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -121,7 +121,7 @@ class TestMatrix( coroutineDispatchers = coroutineDispatchers, ) - installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader()) { serviceProvider -> + installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader(), base64) { serviceProvider -> MessageEncrypter { message -> val result = serviceProvider.cryptoService().encrypt( roomId = message.roomId, From 9d6e72303a113de4000e8b1454fd76b78cbedfd9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 21:24:45 +0100 Subject: [PATCH 08/44] moving the media encrypting to the crypto module and exposing as part of the service --- .../kotlin/app/dapk/st/graph/AppModule.kt | 60 ++++++++++++------- .../dapk/st/matrix/crypto/CryptoService.kt | 27 +++++++-- .../crypto/internal/DefaultCryptoService.kt | 6 ++ .../matrix/crypto}/internal/MediaEncrypter.kt | 30 +++------- .../dapk/st/matrix/message/MediaEncrypter.kt | 30 ++++++++++ .../st/matrix/message/MessageEncrypter.kt | 2 +- .../dapk/st/matrix/message/MessageService.kt | 4 +- .../message/internal/DefaultMessageService.kt | 10 +--- .../message/internal/ImageContentReader.kt | 6 ++ .../message/internal/SendMessageUseCase.kt | 16 ++--- test-harness/src/test/kotlin/SmokeTest.kt | 2 +- .../src/test/kotlin/test/TestMatrix.kt | 60 ++++++++++++------- 12 files changed, 168 insertions(+), 85 deletions(-) rename matrix/services/{message/src/main/kotlin/app/dapk/st/matrix/message => crypto/src/main/kotlin/app/dapk/st/matrix/crypto}/internal/MediaEncrypter.kt (81%) create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt 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 d80f132..d16eb64 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -32,11 +32,8 @@ import app.dapk.st.matrix.crypto.installCryptoService import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.device.installEncryptionService import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory -import app.dapk.st.matrix.message.MessageEncrypter -import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.message.installMessageService +import app.dapk.st.matrix.message.* import app.dapk.st.matrix.message.internal.ImageContentReader -import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.push.pushService import app.dapk.st.matrix.room.* @@ -272,23 +269,46 @@ internal class MatrixModules( coroutineDispatchers = coroutineDispatchers, ) val imageContentReader = AndroidImageContentReader(contentResolver) - installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader, base64) { serviceProvider -> - MessageEncrypter { message -> - val result = serviceProvider.cryptoService().encrypt( - roomId = message.roomId, - credentials = credentialsStore.credentials()!!, - messageJson = message.contents, - ) + installMessageService( + store.localEchoStore, + BackgroundWorkAdapter(workModule.workScheduler()), + imageContentReader, + messageEncrypter = { + val cryptoService = it.cryptoService() + MessageEncrypter { message -> + val result = cryptoService.encrypt( + roomId = message.roomId, + credentials = credentialsStore.credentials()!!, + messageJson = message.contents, + ) - MessageEncrypter.EncryptedMessagePayload( - result.algorithmName, - result.senderKey, - result.cipherText, - result.sessionId, - result.deviceId, - ) - } - } + MessageEncrypter.EncryptedMessagePayload( + result.algorithmName, + result.senderKey, + result.cipherText, + result.sessionId, + result.deviceId, + ) + } + }, + mediaEncrypter = { + val cryptoService = it.cryptoService() + MediaEncrypter { input -> + val result = cryptoService.encrypt(input) + MediaEncrypter.Result( + uri = result.uri, + algorithm = result.algorithm, + ext = result.ext, + keyOperations = result.keyOperations, + kty = result.kty, + k = result.k, + iv = result.iv, + hashes = result.hashes, + v = result.v, + ) + } + }, + ) val overviewStore = store.overviewStore() installRoomService( 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 adb85c9..405a718 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 @@ -11,10 +11,12 @@ import app.dapk.st.matrix.crypto.internal.* import app.dapk.st.matrix.device.deviceService import kotlinx.coroutines.flow.Flow import java.io.InputStream +import java.net.URI private val SERVICE_KEY = CryptoService::class interface CryptoService : MatrixService { + suspend fun encrypt(input: InputStream): Crypto.MediaEncryptionResult suspend fun encrypt(roomId: RoomId, credentials: DeviceCredentials, messageJson: JsonString): Crypto.EncryptionResult suspend fun decrypt(encryptedPayload: EncryptedMessageContent): DecryptionResult suspend fun importRoomKeys(keys: List) @@ -38,6 +40,18 @@ interface Crypto { val deviceId: DeviceId ) + data class MediaEncryptionResult( + val uri: URI, + val algorithm: String, + val ext: Boolean, + val keyOperations: List, + val kty: String, + val k: String, + val iv: String, + val hashes: Map, + val v: String, + ) + } @@ -151,7 +165,9 @@ fun MatrixServiceInstaller.installCryptoService( ) val verificationHandler = VerificationHandler(deviceService, credentialsStore, logger, JsonCanonicalizer(), olm) val roomKeyImporter = RoomKeyImporter(base64, coroutineDispatchers) - SERVICE_KEY to DefaultCryptoService(olmCrypto, verificationHandler, roomKeyImporter, logger) + val mediaEncrypter = MediaEncrypter(base64) + + SERVICE_KEY to DefaultCryptoService(olmCrypto, verificationHandler, roomKeyImporter, mediaEncrypter, logger) } } @@ -166,12 +182,13 @@ sealed interface ImportResult { data class Error(val cause: Type) : ImportResult { sealed interface Type { - data class Unknown(val cause: Throwable): Type - object NoKeysFound: Type - object UnexpectedDecryptionOutput: Type - object UnableToOpenFile: Type + data class Unknown(val cause: Throwable) : Type + object NoKeysFound : Type + object UnexpectedDecryptionOutput : Type + object UnableToOpenFile : Type } } + data class Update(val importedKeysCount: Long) : ImportResult } diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt index f5a64fe..c23cebc 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt @@ -13,8 +13,14 @@ internal class DefaultCryptoService( private val olmCrypto: OlmCrypto, private val verificationHandler: VerificationHandler, private val roomKeyImporter: RoomKeyImporter, + private val mediaEncrypter: MediaEncrypter, private val logger: MatrixLogger, ) : CryptoService { + + override suspend fun encrypt(input: InputStream): Crypto.MediaEncryptionResult { + return mediaEncrypter.encrypt(input) + } + override suspend fun encrypt(roomId: RoomId, credentials: DeviceCredentials, messageJson: JsonString): Crypto.EncryptionResult { return olmCrypto.encryptMessage(roomId, credentials, messageJson) } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt similarity index 81% rename from matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt rename to matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt index 181ec2d..17ca412 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt @@ -1,10 +1,12 @@ -package app.dapk.st.matrix.message.internal +package app.dapk.st.matrix.crypto.internal import app.dapk.st.core.Base64 +import app.dapk.st.matrix.crypto.Crypto import java.io.File import java.io.InputStream import java.security.MessageDigest import java.security.SecureRandom +import java.util.* import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @@ -16,7 +18,7 @@ private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" class MediaEncrypter(private val base64: Base64) { - fun encrypt(input: InputStream, name: String): Result { + fun encrypt(input: InputStream): Crypto.MediaEncryptionResult { val secureRandom = SecureRandom() val initVectorBytes = ByteArray(16) { 0.toByte() } @@ -30,10 +32,9 @@ class MediaEncrypter(private val base64: Base64) { val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) - val outputFile = File.createTempFile("_encrypt-${name.hashCode()}", ".png") + val outputFile = File.createTempFile("_encrypt-${UUID.randomUUID()}", ".png") - val outputStream = outputFile.outputStream() - outputStream.use { s -> + outputFile.outputStream().use { s -> val encryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) val ivParameterSpec = IvParameterSpec(initVectorBytes) @@ -60,8 +61,8 @@ class MediaEncrypter(private val base64: Base64) { s.write(encodedBytes) } - return Result( - contents = outputFile.readBytes(), + return Crypto.MediaEncryptionResult( + uri = outputFile.toURI(), algorithm = "A256CTR", ext = true, keyOperations = listOf("encrypt", "decrypt"), @@ -72,19 +73,6 @@ class MediaEncrypter(private val base64: Base64) { v = "v2" ) } - - data class Result( - val contents: ByteArray, - val algorithm: String, - val ext: Boolean, - val keyOperations: List, - val kty: String, - val k: String, - val iv: String, - val hashes: Map, - val v: String, - ) - } private fun base64ToBase64Url(base64: String): String { @@ -97,4 +85,4 @@ private fun base64ToBase64Url(base64: String): String { private fun base64ToUnpaddedBase64(base64: String): String { return base64.replace("\n".toRegex(), "") .replace("=", "") -} +} \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt new file mode 100644 index 0000000..2f6711a --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt @@ -0,0 +1,30 @@ +package app.dapk.st.matrix.message + +import java.io.File +import java.io.InputStream +import java.net.URI + +fun interface MediaEncrypter { + + suspend fun encrypt(input: InputStream): Result + + data class Result( + val uri: URI, + val algorithm: String, + val ext: Boolean, + val keyOperations: List, + val kty: String, + val k: String, + val iv: String, + val hashes: Map, + val v: String, + ) { + + fun openStream() = File(uri).outputStream() + } + +} + +internal object MissingMediaEncrypter : MediaEncrypter { + override suspend fun encrypt(input: InputStream) = throw IllegalStateException("No encrypter instance set") +} diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt index 13b67c2..d2387e8 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt @@ -25,4 +25,4 @@ fun interface MessageEncrypter { internal object MissingMessageEncrypter : MessageEncrypter { override suspend fun encrypt(message: MessageEncrypter.ClearMessagePayload) = throw IllegalStateException("No encrypter instance set") -} \ No newline at end of file +} 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 1fb003a..23fdb98 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 @@ -130,16 +130,16 @@ fun MatrixServiceInstaller.installMessageService( localEchoStore: LocalEchoStore, backgroundScheduler: BackgroundScheduler, imageContentReader: ImageContentReader, - base64: Base64, messageEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMessageEncrypter }, + mediaEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMediaEncrypter }, ) { this.install { (httpClient, _, installedServices) -> SERVICE_KEY to DefaultMessageService( httpClient, localEchoStore, backgroundScheduler, - base64, messageEncrypter.create(installedServices), + mediaEncrypter.create(installedServices), imageContentReader ) } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt index c51dc37..c6b7374 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt @@ -1,13 +1,9 @@ package app.dapk.st.matrix.message.internal -import app.dapk.st.core.Base64 import app.dapk.st.matrix.MatrixTaskRunner import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.http.MatrixHttpClient -import app.dapk.st.matrix.message.BackgroundScheduler -import app.dapk.st.matrix.message.LocalEchoStore -import app.dapk.st.matrix.message.MessageEncrypter -import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.message.* import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.Json import java.net.SocketException @@ -20,12 +16,12 @@ internal class DefaultMessageService( httpClient: MatrixHttpClient, private val localEchoStore: LocalEchoStore, private val backgroundScheduler: BackgroundScheduler, - base64: Base64, messageEncrypter: MessageEncrypter, + mediaEncrypter: MediaEncrypter, imageContentReader: ImageContentReader, ) : MessageService, MatrixTaskRunner { - private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, imageContentReader, base64) + private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, mediaEncrypter, imageContentReader) private val sendEventMessageUseCase = SendEventMessageUseCase(httpClient) override suspend fun canRun(task: MatrixTaskRunner.MatrixTask) = task.type == MATRIX_MESSAGE_TASK_TYPE || task.type == MATRIX_IMAGE_MESSAGE_TASK_TYPE diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt index 8395092..d4ff899 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt @@ -1,5 +1,7 @@ package app.dapk.st.matrix.message.internal +import java.io.InputStream + interface ImageContentReader { fun read(uri: String): ImageContent @@ -32,5 +34,9 @@ interface ImageContentReader { result = 31 * result + content.contentHashCode() return result } + + fun stream(): InputStream { + TODO("Not yet implemented") + } } } \ No newline at end of file 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 d3799e1..16d8678 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,19 +1,19 @@ package app.dapk.st.matrix.message.internal -import app.dapk.st.core.Base64 import app.dapk.st.matrix.common.* import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest import app.dapk.st.matrix.message.ApiSendResponse +import app.dapk.st.matrix.message.MediaEncrypter import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.Message -import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream internal class SendMessageUseCase( private val httpClient: MatrixHttpClient, private val messageEncrypter: MessageEncrypter, + private val mediaEncrypter: MediaEncrypter, private val imageContentReader: ImageContentReader, - private val base64: Base64, ) { private val mapper = ApiMessageMapper() @@ -63,12 +63,12 @@ internal class SendMessageUseCase( return when (message.sendEncrypted) { true -> { - val result = MediaEncrypter(base64).encrypt( - ByteArrayInputStream(imageContent.content), - imageContent.fileName, - ) + val result = mediaEncrypter.encrypt(imageContent.stream()) + val bytes = ByteArrayOutputStream().also { + it.writeTo(result.openStream()) + }.toByteArray() - val uri = httpClient.execute(uploadRequest(result.contents, imageContent.fileName, "application/octet-stream")).contentUri + val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, "application/octet-stream")).contentUri val content = ApiMessage.ImageMessage.ImageContent( url = null, diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index b610072..d692c96 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -75,7 +75,7 @@ class SmokeTest { @Order(6) fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> val testImage = loadResourceFile("test-image.png") - alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = true) + alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) } diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 3da7355..797547c 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -17,11 +17,8 @@ import app.dapk.st.matrix.crypto.installCryptoService import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.device.installEncryptionService import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory -import app.dapk.st.matrix.message.MessageEncrypter -import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.message.installMessageService +import app.dapk.st.matrix.message.* import app.dapk.st.matrix.message.internal.ImageContentReader -import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.room.RoomMessenger import app.dapk.st.matrix.room.installRoomService @@ -121,23 +118,46 @@ class TestMatrix( coroutineDispatchers = coroutineDispatchers, ) - installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader(), base64) { serviceProvider -> - MessageEncrypter { message -> - val result = serviceProvider.cryptoService().encrypt( - roomId = message.roomId, - credentials = storeModule.credentialsStore().credentials()!!, - messageJson = message.contents, - ) + installMessageService( + localEchoStore = storeModule.localEchoStore, + backgroundScheduler = InstantScheduler(it), + imageContentReader = JavaImageContentReader(), + messageEncrypter = { + val cryptoService = it.cryptoService() + MessageEncrypter { message -> + val result = cryptoService.encrypt( + roomId = message.roomId, + credentials = storeModule.credentialsStore().credentials()!!, + messageJson = message.contents, + ) - MessageEncrypter.EncryptedMessagePayload( - result.algorithmName, - result.senderKey, - result.cipherText, - result.sessionId, - result.deviceId, - ) - } - } + MessageEncrypter.EncryptedMessagePayload( + result.algorithmName, + result.senderKey, + result.cipherText, + result.sessionId, + result.deviceId, + ) + } + }, + mediaEncrypter = { + val cryptoService = it.cryptoService() + MediaEncrypter { input -> + val result = cryptoService.encrypt(input) + MediaEncrypter.Result( + uri = result.uri, + algorithm = result.algorithm, + ext = result.ext, + keyOperations = result.keyOperations, + kty = result.kty, + k = result.k, + iv = result.iv, + hashes = result.hashes, + v = result.v, + ) + } + }, + ) installRoomService( storeModule.memberStore(), From 2635a5f3e5bd398251663597332ba82ae3648441 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 22:11:35 +0100 Subject: [PATCH 09/44] making the image fectching factory part of the dagger graph --- .../kotlin/app/dapk/st/graph/AppModule.kt | 14 +++++--- .../dapk/st/messenger/DecryptingFetcher.kt | 18 +++++++---- .../app/dapk/st/messenger/MediaDecrypter.kt | 11 +++---- .../dapk/st/messenger/MessengerActivity.kt | 20 ++++++++---- .../app/dapk/st/messenger/MessengerModule.kt | 6 ++++ .../app/dapk/st/messenger/MessengerScreen.kt | 8 ++--- .../message/internal/ImageContentReader.kt | 32 +++---------------- .../message/internal/SendMessageUseCase.kt | 14 +++++--- 8 files changed, 64 insertions(+), 59 deletions(-) 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 d16eb64..677c984 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -59,6 +59,7 @@ import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.WorkModule import com.squareup.sqldelight.android.AndroidSqliteDriver import kotlinx.coroutines.Dispatchers +import java.net.URI import java.time.Clock internal class AppModule(context: Application, logger: MatrixLogger) { @@ -75,6 +76,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) { private val database = DapkDb(driver) private val clock = Clock.systemUTC() val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO) + val base64 = AndroidBase64() val storeModule = unsafeLazy { StoreModule( @@ -89,7 +91,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) { private val workModule = WorkModule(context) private val imageLoaderModule = ImageLoaderModule(context) - private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, context.contentResolver, buildMeta) + private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, context.contentResolver, base64, buildMeta) val domainModules = DomainModules(matrixModules, trackingModule.errorTracker, workModule, storeModule, context, coroutineDispatchers) val coreAndroidModule = CoreAndroidModule( @@ -134,6 +136,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) { deviceMeta, coroutineDispatchers, clock, + base64, ) } @@ -149,6 +152,7 @@ internal class FeatureModules internal constructor( deviceMeta: DeviceMeta, coroutineDispatchers: CoroutineDispatchers, clock: Clock, + base64: Base64, ) { val directoryModule by unsafeLazy { @@ -176,7 +180,9 @@ internal class FeatureModules internal constructor( matrixModules.room, storeModule.value.credentialsStore(), storeModule.value.roomStore(), - clock + clock, + context, + base64, ) } val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile, matrixModules.sync, buildMeta) } @@ -227,6 +233,7 @@ internal class MatrixModules( private val logger: MatrixLogger, private val coroutineDispatchers: CoroutineDispatchers, private val contentResolver: ContentResolver, + private val base64: Base64, private val buildMeta: BuildMeta, ) { @@ -244,7 +251,6 @@ internal class MatrixModules( installAuthService(credentialsStore) installEncryptionService(store.knownDevicesStore()) - val base64 = AndroidBase64() val olmAccountStore = OlmPersistenceWrapper(store.olmStore(), base64) val singletonFlows = SingletonFlows(coroutineDispatchers) val olm = OlmWrapper( @@ -491,7 +497,7 @@ internal class AndroidImageContentReader(private val contentResolver: ContentRes size = output.size.toLong(), mimeType = options.outMimeType, fileName = androidUri.lastPathSegment ?: "file", - content = output + uri = URI.create(uri) ) } ?: throw IllegalArgumentException("Could not process $uri") } 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 4f27103..71cfb4c 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,6 +1,7 @@ package app.dapk.st.messenger import android.content.Context +import app.dapk.st.core.Base64 import app.dapk.st.matrix.sync.RoomEvent import coil.ImageLoader import coil.decode.DataSource @@ -14,17 +15,22 @@ import okhttp3.Request import okhttp3.Response import okio.Buffer -class DecryptingFetcherFactory(private val context: Context) : Fetcher.Factory { +class DecryptingFetcherFactory(private val context: Context, base64: Base64) : Fetcher.Factory { + + private val mediaDecrypter = MediaDecrypter(base64) + override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher { - return DecryptingFetcher(data, context) + return DecryptingFetcher(data, context, mediaDecrypter) } } private val http = OkHttpClient() -class DecryptingFetcher(private val data: RoomEvent.Image, private val context: Context) : Fetcher { - - private val mediaDecrypter = MediaDecrypter() +class DecryptingFetcher( + private val data: RoomEvent.Image, + private val context: Context, + private val mediaDecrypter: MediaDecrypter, +) : Fetcher { override suspend fun fetch(): FetchResult { val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute() @@ -36,7 +42,7 @@ class DecryptingFetcher(private val data: RoomEvent.Image, private val context: } private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { - return response.body?.byteStream()?.let { mediaDecrypter.decrypt(it, keys) } ?: Buffer() + return response.body?.byteStream()?.let { mediaDecrypter.decrypt(it, keys.k, keys.iv) } ?: Buffer() } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt index 9e18c76..230c542 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt @@ -1,7 +1,6 @@ package app.dapk.st.messenger -import android.util.Base64 -import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.core.Base64 import okio.Buffer import java.io.InputStream import java.security.MessageDigest @@ -14,11 +13,11 @@ private const val CIPHER_ALGORITHM = "AES/CTR/NoPadding" private const val SECRET_KEY_SPEC_ALGORITHM = "AES" private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" -class MediaDecrypter { +class MediaDecrypter(private val base64: Base64) { - fun decrypt(input: InputStream, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { - val key = Base64.decode(keys.k.replace('-', '+').replace('_', '/'), Base64.DEFAULT) - val initVectorBytes = Base64.decode(keys.iv, Base64.DEFAULT) + fun decrypt(input: InputStream, k: String, iv: String): Buffer { + val key = base64.decode(k.replace('-', '+').replace('_', '/')) + val initVectorBytes = base64.decode(iv) val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) 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 3697b26..01cb2e6 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,19 +5,25 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Parcelable -import androidx.activity.compose.setContent 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.* -import app.dapk.st.design.components.SmallTalkTheme +import app.dapk.st.core.DapkActivity +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.navigator.MessageAttachment import kotlinx.parcelize.Parcelize +val LocalDecyptingFetcherFactory = staticCompositionLocalOf { throw IllegalAccessError() } + class MessengerActivity : DapkActivity() { - private val viewModel by viewModel { module().messengerViewModel() } + private val module by unsafeLazy { module() } + private val viewModel by viewModel { module.messengerViewModel() } companion object { @@ -44,11 +50,13 @@ class MessengerActivity : DapkActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val payload = readPayload() - log(AppLogTag.ERROR_NON_FATAL, payload) + val factory = module.decryptingFetcherFactory() setContent { - Surface(Modifier.fillMaxSize()) { + Surface(Modifier.fillMaxSize()) { + CompositionLocalProvider(LocalDecyptingFetcherFactory provides factory) { MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator) } + } } } } 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 622019c..f34013f 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 @@ -1,5 +1,7 @@ package app.dapk.st.messenger +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.message.MessageService @@ -15,6 +17,8 @@ class MessengerModule( private val credentialsStore: CredentialsStore, private val roomStore: RoomStore, private val clock: Clock, + private val context: Context, + private val base64: Base64, ) : ProvidableModule { internal fun messengerViewModel(): MessengerViewModel { @@ -25,4 +29,6 @@ class MessengerModule( val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(LocalEchoMapper(MetaMapper())) return TimelineUseCaseImpl(syncService, messageService, roomService, mergeWithLocalEchosUseCase) } + + internal fun decryptingFetcherFactory() = DecryptingFetcherFactory(context, base64) } \ 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 93e0a62..1730b34 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 @@ -228,7 +228,6 @@ private fun LazyItemScope.AlignedBubble( @Composable private fun MessageImage(content: BubbleContent) { val context = LocalContext.current - val fetcherFactory = remember { DecryptingFetcherFactory(context) } Box(modifier = Modifier.padding(start = 6.dp)) { Box( @@ -258,7 +257,7 @@ private fun MessageImage(content: BubbleContent) { modifier = Modifier.size(content.message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) - .fetcherFactory(fetcherFactory) + .fetcherFactory(LocalDecyptingFetcherFactory.current) .data(content.message) .build() ), @@ -407,7 +406,6 @@ private fun ReplyBubbleContent(content: BubbleContent) { .defaultMinSize(minWidth = 50.dp) ) { val context = LocalContext.current - val fetcherFactory = remember { DecryptingFetcherFactory(context) } Column( Modifier .background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground) @@ -438,7 +436,7 @@ private fun ReplyBubbleContent(content: BubbleContent) { modifier = Modifier.size(replyingTo.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) - .fetcherFactory(fetcherFactory) + .fetcherFactory(LocalDecyptingFetcherFactory.current) .data(replyingTo) .build() ), @@ -481,7 +479,7 @@ private fun ReplyBubbleContent(content: BubbleContent) { painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) .data(content.message) - .fetcherFactory(fetcherFactory) + .fetcherFactory(LocalDecyptingFetcherFactory.current) .build() ), contentDescription = null, diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt index d4ff899..dd8ecfa 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt @@ -1,6 +1,7 @@ package app.dapk.st.matrix.message.internal -import java.io.InputStream +import java.io.File +import java.net.URI interface ImageContentReader { fun read(uri: String): ImageContent @@ -11,32 +12,9 @@ interface ImageContentReader { val size: Long, val fileName: String, val mimeType: String, - val content: ByteArray + val uri: URI ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ImageContent - - if (height != other.height) return false - if (width != other.width) return false - if (size != other.size) return false - if (!content.contentEquals(other.content)) return false - - return true - } - - override fun hashCode(): Int { - var result = height - result = 31 * result + width - result = 31 * result + size.hashCode() - result = 31 * result + content.contentHashCode() - return result - } - - fun stream(): InputStream { - TODO("Not yet implemented") - } + fun inputStream() = File(uri).inputStream() + fun outputStream() = File(uri).outputStream() } } \ No newline at end of file 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 16d8678..4dbfa5e 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 @@ -8,6 +8,7 @@ import app.dapk.st.matrix.message.MediaEncrypter import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.Message import java.io.ByteArrayOutputStream +import java.io.File internal class SendMessageUseCase( private val httpClient: MatrixHttpClient, @@ -63,10 +64,9 @@ internal class SendMessageUseCase( return when (message.sendEncrypted) { true -> { - val result = mediaEncrypter.encrypt(imageContent.stream()) - val bytes = ByteArrayOutputStream().also { - it.writeTo(result.openStream()) - }.toByteArray() + val result = mediaEncrypter.encrypt(imageContent.inputStream()) + val bytes = File(result.uri).readBytes() + println("!!! ${bytes.size}") val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, "application/octet-stream")).contentUri @@ -114,7 +114,11 @@ internal class SendMessageUseCase( } false -> { - val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri + val bytes = ByteArrayOutputStream().also { + it.writeTo(imageContent.outputStream()) + }.toByteArray() + + val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, imageContent.mimeType)).contentUri sendRequest( roomId = message.roomId, eventType = EventType.ROOM_MESSAGE, From 3c865179d7a09393fa8887bcda3bc8aa53aed7a5 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 22:42:04 +0100 Subject: [PATCH 10/44] improving image decrypting pipeline to use one less copy and adding smoke test to sending encrypted images --- features/messenger/build.gradle | 1 + .../dapk/st/messenger/DecryptingFetcher.kt | 7 ++++- .../dapk/st/matrix/crypto}/MediaDecrypter.kt | 28 +++++++++++-------- test-harness/src/test/kotlin/SmokeTest.kt | 23 ++++++++++----- test-harness/src/test/kotlin/test/Test.kt | 17 +++++++++-- .../src/test/kotlin/test/TestMatrix.kt | 4 +-- 6 files changed, 56 insertions(+), 24 deletions(-) rename {features/messenger/src/main/kotlin/app/dapk/st/messenger => matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto}/MediaDecrypter.kt (69%) diff --git a/features/messenger/build.gradle b/features/messenger/build.gradle index 4603773..e5686f7 100644 --- a/features/messenger/build.gradle +++ b/features/messenger/build.gradle @@ -4,6 +4,7 @@ apply plugin: 'kotlin-parcelize' dependencies { implementation project(":matrix:services:sync") implementation project(":matrix:services:message") + implementation project(":matrix:services:crypto") implementation project(":matrix:services:room") implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") 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 71cfb4c..d54dc06 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 @@ -2,6 +2,7 @@ package app.dapk.st.messenger import android.content.Context import app.dapk.st.core.Base64 +import app.dapk.st.matrix.crypto.MediaDecrypter import app.dapk.st.matrix.sync.RoomEvent import coil.ImageLoader import coil.decode.DataSource @@ -42,7 +43,11 @@ class DecryptingFetcher( } private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { - return response.body?.byteStream()?.let { mediaDecrypter.decrypt(it, keys.k, keys.iv) } ?: Buffer() + return response.body?.byteStream()?.let { byteStream -> + Buffer().also { buffer -> + mediaDecrypter.decrypt(byteStream, keys.k, keys.iv).collect { buffer.write(it) } + } + } ?: Buffer() } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt similarity index 69% rename from features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt rename to matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt index 230c542..df513d2 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt @@ -1,7 +1,6 @@ -package app.dapk.st.messenger +package app.dapk.st.matrix.crypto import app.dapk.st.core.Base64 -import okio.Buffer import java.io.InputStream import java.security.MessageDigest import javax.crypto.Cipher @@ -15,7 +14,7 @@ private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" class MediaDecrypter(private val base64: Base64) { - fun decrypt(input: InputStream, k: String, iv: String): Buffer { + fun decrypt(input: InputStream, k: String, iv: String): Collector { val key = base64.decode(k.replace('-', '+').replace('_', '/')) val initVectorBytes = base64.decode(iv) @@ -30,17 +29,22 @@ class MediaDecrypter(private val base64: Base64) { val d = ByteArray(CRYPTO_BUFFER_SIZE) var decodedBytes: ByteArray - val outputStream = Buffer() - input.use { - read = it.read(d) - while (read != -1) { - messageDigest.update(d, 0, read) - decodedBytes = decryptCipher.update(d, 0, read) - outputStream.write(decodedBytes) + return Collector { partial -> + input.use { read = it.read(d) + while (read != -1) { + messageDigest.update(d, 0, read) + decodedBytes = decryptCipher.update(d, 0, read) + partial(decodedBytes) + read = it.read(d) + } } } - return outputStream } -} \ No newline at end of file +} + + +fun interface Collector { + fun collect(partial: (ByteArray) -> Unit) +} diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index d692c96..8779851 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -71,16 +71,25 @@ class SmokeTest { @Order(5) fun `can send and receive encrypted text messages`() = testTextMessaging(isEncrypted = true) - @Test - @Order(6) - fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> - val testImage = loadResourceFile("test-image.png") - alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) - bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) - } +// @Test +// @Order(6) +// fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> +// val testImage = loadResourceFile("test-image.png") +// alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) +// bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember, isEncrypted = false) +// } @Test @Order(7) + fun `can send and receive encrypted image messages`() = testAfterInitialSync { alice, bob -> + val testImage = loadResourceFile("test-image.png") + alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = true) + bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) + } + + + @Test + @Order(8) fun `can request and verify devices`() = testAfterInitialSync { alice, bob -> alice.client.cryptoService().verificationAction(Verification.Action.Request(bob.userId(), bob.deviceId())) alice.client.cryptoService().verificationState().automaticVerification(alice).expectAsync { it == Verification.State.Done } diff --git a/test-harness/src/test/kotlin/test/Test.kt b/test-harness/src/test/kotlin/test/Test.kt index 83cdc01..562a863 100644 --- a/test-harness/src/test/kotlin/test/Test.kt +++ b/test-harness/src/test/kotlin/test/Test.kt @@ -7,6 +7,7 @@ import TestUser import app.dapk.st.core.extensions.ifNull import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.crypto.MediaDecrypter import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.sync.RoomEvent @@ -22,6 +23,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.amshove.kluent.fail import org.amshove.kluent.shouldBeEqualTo +import java.io.ByteArrayOutputStream import java.io.File import java.math.BigInteger import java.security.MessageDigest @@ -145,10 +147,21 @@ class MatrixTestScope(private val testScope: TestScope) { this.client.syncService().room(roomId) .map { it.events.filterIsInstance().map { - println("found: ${it.imageMeta.url}") + println("found: ${it}") val output = File(image.parentFile.absolutePath, "output.png") HttpClient().request(it.imageMeta.url).bodyAsChannel().copyAndClose(output.writeChannel()) - output.readBytes().md5Hash() to it.author + val md5Hash = when (val keys = it.imageMeta.keys) { + null -> output.readBytes().md5Hash() + else -> { + val byteStream = ByteArrayOutputStream() + MediaDecrypter(this.base64).decrypt(output.inputStream(), keys.k, keys.iv).collect { + byteStream.write(it) + } + byteStream.toByteArray().md5Hash() + } + } + + md5Hash to it.author }.firstOrNull() } .assert(image.readBytes().md5Hash() to author) diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 797547c..853ac60 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -82,6 +82,7 @@ class TestMatrix( }, coroutineDispatchers = coroutineDispatchers ) + val base64 = JavaBase64() val client = MatrixClient( KtorMatrixHttpClientFactory( @@ -94,7 +95,6 @@ class TestMatrix( installAuthService(storeModule.credentialsStore()) installEncryptionService(storeModule.knownDevicesStore()) - val base64 = JavaBase64() val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore(), base64) val olm = OlmWrapper( olmStore = olmAccountStore, @@ -349,7 +349,7 @@ class JavaImageContentReader : ImageContentReader { size = size, mimeType = "image/${file.extension}", fileName = file.name, - content = file.readBytes() + uri = file.toURI(), ) } From 02e76548e326944dbc1588038e059f70a7452556 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 22:51:00 +0100 Subject: [PATCH 11/44] adding separate image for encrypted image test and re-enabling clear image test --- .../message/internal/SendMessageUseCase.kt | 5 +---- test-harness/src/test/kotlin/SmokeTest.kt | 16 ++++++++-------- test-harness/src/test/kotlin/test/Test.kt | 1 - test-harness/src/test/resources/test-image2.png | Bin 0 -> 59109 bytes 4 files changed, 9 insertions(+), 13 deletions(-) create mode 100644 test-harness/src/test/resources/test-image2.png 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 4dbfa5e..c5e52be 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 @@ -66,7 +66,6 @@ internal class SendMessageUseCase( true -> { val result = mediaEncrypter.encrypt(imageContent.inputStream()) val bytes = File(result.uri).readBytes() - println("!!! ${bytes.size}") val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, "application/octet-stream")).contentUri @@ -114,9 +113,7 @@ internal class SendMessageUseCase( } false -> { - val bytes = ByteArrayOutputStream().also { - it.writeTo(imageContent.outputStream()) - }.toByteArray() + val bytes = File(imageContent.uri).readBytes() val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, imageContent.mimeType)).contentUri sendRequest( diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index 8779851..87be394 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -71,18 +71,18 @@ class SmokeTest { @Order(5) fun `can send and receive encrypted text messages`() = testTextMessaging(isEncrypted = true) -// @Test -// @Order(6) -// fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> -// val testImage = loadResourceFile("test-image.png") -// alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) -// bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember, isEncrypted = false) -// } + @Test + @Order(6) + fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> + val testImage = loadResourceFile("test-image.png") + alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) + bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) + } @Test @Order(7) fun `can send and receive encrypted image messages`() = testAfterInitialSync { alice, bob -> - val testImage = loadResourceFile("test-image.png") + val testImage = loadResourceFile("test-image2.png") alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = true) bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) } diff --git a/test-harness/src/test/kotlin/test/Test.kt b/test-harness/src/test/kotlin/test/Test.kt index 562a863..08b4e4c 100644 --- a/test-harness/src/test/kotlin/test/Test.kt +++ b/test-harness/src/test/kotlin/test/Test.kt @@ -147,7 +147,6 @@ class MatrixTestScope(private val testScope: TestScope) { this.client.syncService().room(roomId) .map { it.events.filterIsInstance().map { - println("found: ${it}") val output = File(image.parentFile.absolutePath, "output.png") HttpClient().request(it.imageMeta.url).bodyAsChannel().copyAndClose(output.writeChannel()) val md5Hash = when (val keys = it.imageMeta.keys) { diff --git a/test-harness/src/test/resources/test-image2.png b/test-harness/src/test/resources/test-image2.png new file mode 100644 index 0000000000000000000000000000000000000000..56b0161429c02e7bcb71779417fc4306088ccfe1 GIT binary patch literal 59109 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 VDk-2=TFwAGH;k=}>aV)T{tw-48|(l8 literal 0 HcmV?d00001 From ccdeed24ef0a8aa7e0dd36fdb6ed61a62bd5ff7c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 23:29:01 +0100 Subject: [PATCH 12/44] using separate content provider query to find image file size rather than collect the content of the image itself into memory, should be much more effecient! --- .../kotlin/app/dapk/st/graph/AppModule.kt | 30 ++++++++----- .../dapk/st/matrix/crypto/CryptoService.kt | 1 + .../matrix/crypto/internal/MediaEncrypter.kt | 1 + .../dapk/st/matrix/message/MediaEncrypter.kt | 3 +- .../message/internal/ImageContentReader.kt | 12 ++--- .../message/internal/SendMessageUseCase.kt | 44 +++++++++++-------- .../st/matrix/message/internal/SendRequest.kt | 12 +++-- test-harness/src/test/kotlin/SmokeTest.kt | 1 - .../src/test/kotlin/test/TestMatrix.kt | 6 ++- 9 files changed, 65 insertions(+), 45 deletions(-) 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 677c984..296ee1f 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -8,6 +8,7 @@ import android.content.Intent import android.graphics.BitmapFactory import android.net.Uri import android.os.Build +import android.provider.OpenableColumns import app.dapk.db.DapkDb import app.dapk.st.BuildConfig import app.dapk.st.SharedPreferencesDelegate @@ -59,7 +60,7 @@ import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.WorkModule import com.squareup.sqldelight.android.AndroidSqliteDriver import kotlinx.coroutines.Dispatchers -import java.net.URI +import java.io.InputStream import java.time.Clock internal class AppModule(context: Application, logger: MatrixLogger) { @@ -303,6 +304,7 @@ internal class MatrixModules( val result = cryptoService.encrypt(input) MediaEncrypter.Result( uri = result.uri, + contentLength = result.contentLength, algorithm = result.algorithm, ext = result.ext, keyOperations = result.keyOperations, @@ -482,23 +484,27 @@ internal class DomainModules( } internal class AndroidImageContentReader(private val contentResolver: ContentResolver) : ImageContentReader { - override fun read(uri: String): ImageContentReader.ImageContent { + override fun meta(uri: String): ImageContentReader.ImageContent { val androidUri = Uri.parse(uri) val fileStream = contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri") val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeStream(fileStream, null, options) - return contentResolver.openInputStream(androidUri)?.use { stream -> - val output = stream.readBytes() - ImageContentReader.ImageContent( - height = options.outHeight, - width = options.outWidth, - size = output.size.toLong(), - mimeType = options.outMimeType, - fileName = androidUri.lastPathSegment ?: "file", - uri = URI.create(uri) - ) + val fileSize = contentResolver.query(androidUri, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + val columnIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.getLong(columnIndex) } ?: throw IllegalArgumentException("Could not process $uri") + + return ImageContentReader.ImageContent( + height = options.outHeight, + width = options.outWidth, + size = fileSize, + mimeType = options.outMimeType, + fileName = androidUri.lastPathSegment ?: "file", + ) } + + override fun inputStream(uri: String): InputStream = contentResolver.openInputStream(Uri.parse(uri))!! } \ No newline at end of file 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 405a718..becaf3b 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 @@ -42,6 +42,7 @@ interface Crypto { data class MediaEncryptionResult( val uri: URI, + val contentLength: Long, val algorithm: String, val ext: Boolean, val keyOperations: List, diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt index 17ca412..f8c2eea 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt @@ -63,6 +63,7 @@ class MediaEncrypter(private val base64: Base64) { return Crypto.MediaEncryptionResult( uri = outputFile.toURI(), + contentLength = outputFile.length(), algorithm = "A256CTR", ext = true, keyOperations = listOf("encrypt", "decrypt"), diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt index 2f6711a..10fa046 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt @@ -10,6 +10,7 @@ fun interface MediaEncrypter { data class Result( val uri: URI, + val contentLength: Long, val algorithm: String, val ext: Boolean, val keyOperations: List, @@ -20,7 +21,7 @@ fun interface MediaEncrypter { val v: String, ) { - fun openStream() = File(uri).outputStream() + fun openStream() = File(uri).inputStream() } } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt index dd8ecfa..ffce257 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt @@ -1,10 +1,10 @@ package app.dapk.st.matrix.message.internal -import java.io.File -import java.net.URI +import java.io.InputStream interface ImageContentReader { - fun read(uri: String): ImageContent + fun meta(uri: String): ImageContent + fun inputStream(uri: String): InputStream data class ImageContent( val height: Int, @@ -12,9 +12,5 @@ interface ImageContentReader { val size: Long, val fileName: String, val mimeType: String, - val uri: URI - ) { - fun inputStream() = File(uri).inputStream() - fun outputStream() = File(uri).outputStream() - } + ) } \ No newline at end of file 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 c5e52be..8398148 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 @@ -7,8 +7,6 @@ import app.dapk.st.matrix.message.ApiSendResponse import app.dapk.st.matrix.message.MediaEncrypter import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.Message -import java.io.ByteArrayOutputStream -import java.io.File internal class SendMessageUseCase( private val httpClient: MatrixHttpClient, @@ -60,18 +58,23 @@ internal class SendMessageUseCase( } private suspend fun ApiMessageMapper.imageMessageRequest(message: Message.ImageMessage): HttpRequest { - val imageContent = imageContentReader.read(message.content.uri) + val imageMeta = imageContentReader.meta(message.content.uri) return when (message.sendEncrypted) { true -> { - val result = mediaEncrypter.encrypt(imageContent.inputStream()) - val bytes = File(result.uri).readBytes() - - val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, "application/octet-stream")).contentUri + val result = mediaEncrypter.encrypt(imageContentReader.inputStream(message.content.uri)) + val uri = httpClient.execute( + uploadRequest( + result.openStream(), + result.contentLength, + imageMeta.fileName, + "application/octet-stream" + ) + ).contentUri val content = ApiMessage.ImageMessage.ImageContent( url = null, - filename = imageContent.fileName, + filename = imageMeta.fileName, file = ApiMessage.ImageMessage.ImageContent.File( url = uri, key = ApiMessage.ImageMessage.ImageContent.File.EncryptionMeta( @@ -86,9 +89,9 @@ internal class SendMessageUseCase( v = result.v, ), info = ApiMessage.ImageMessage.ImageContent.Info( - height = imageContent.height, - width = imageContent.width, - size = imageContent.size + height = imageMeta.height, + width = imageMeta.width, + size = imageMeta.size ) ) @@ -113,20 +116,25 @@ internal class SendMessageUseCase( } false -> { - val bytes = File(imageContent.uri).readBytes() - - val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, imageContent.mimeType)).contentUri + val uri = httpClient.execute( + uploadRequest( + imageContentReader.inputStream(message.content.uri), + imageMeta.size, + imageMeta.fileName, + imageMeta.mimeType + ) + ).contentUri sendRequest( roomId = message.roomId, eventType = EventType.ROOM_MESSAGE, txId = message.localId, content = ApiMessage.ImageMessage.ImageContent( url = uri, - filename = imageContent.fileName, + filename = imageMeta.fileName, ApiMessage.ImageMessage.ImageContent.Info( - height = imageContent.height, - width = imageContent.width, - size = imageContent.size + height = imageMeta.height, + width = imageMeta.width, + size = imageMeta.size ) ), ) diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt index d3cc3d3..2057084 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt @@ -11,8 +11,10 @@ import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.EventMessage import app.dapk.st.matrix.message.internal.ApiMessage.ImageMessage import app.dapk.st.matrix.message.internal.ApiMessage.TextMessage -import io.ktor.content.* import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.utils.io.jvm.javaio.* +import java.io.InputStream import java.util.* internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: ApiMessageContent) = httpRequest( @@ -38,11 +40,15 @@ internal fun sendRequest(roomId: RoomId, eventType: EventType, content: EventMes } ) -internal fun uploadRequest(body: ByteArray, filename: String, contentType: String) = httpRequest( +internal fun uploadRequest(stream: InputStream, contentLength: Long, filename: String, contentType: String) = httpRequest( path = "_matrix/media/r0/upload/?filename=$filename", headers = listOf("Content-Type" to contentType), method = MatrixHttpClient.Method.POST, - body = ByteArrayContent(body, ContentType.parse(contentType)), + body = ChannelWriterContent( + body = { stream.copyTo(this) }, + contentType = ContentType.parse(contentType), + contentLength = contentLength, + ), ) fun txId() = "local.${UUID.randomUUID()}" \ No newline at end of file diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index 87be394..cccb5c8 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -87,7 +87,6 @@ class SmokeTest { bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) } - @Test @Order(8) fun `can request and verify devices`() = testAfterInitialSync { alice, bob -> diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 853ac60..023a3b1 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -146,6 +146,7 @@ class TestMatrix( val result = cryptoService.encrypt(input) MediaEncrypter.Result( uri = result.uri, + contentLength = result.contentLength, algorithm = result.algorithm, ext = result.ext, keyOperations = result.keyOperations, @@ -339,7 +340,7 @@ class JavaBase64 : Base64 { class JavaImageContentReader : ImageContentReader { - override fun read(uri: String): ImageContentReader.ImageContent { + override fun meta(uri: String): ImageContentReader.ImageContent { val file = File(uri) val size = file.length() val image = ImageIO.read(file) @@ -349,8 +350,9 @@ class JavaImageContentReader : ImageContentReader { size = size, mimeType = "image/${file.extension}", fileName = file.name, - uri = file.toURI(), ) } + override fun inputStream(uri: String) = File(uri).inputStream() + } \ No newline at end of file From 379c3ebb2bbf05139e965dc45e721126e9302a69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Sep 2022 05:42:04 +0000 Subject: [PATCH 13/44] Bump turbine from 0.10.0 to 0.11.0 Bumps [turbine](https://github.com/cashapp/turbine) from 0.10.0 to 0.11.0. - [Release notes](https://github.com/cashapp/turbine/releases) - [Changelog](https://github.com/cashapp/turbine/blob/trunk/CHANGELOG.md) - [Commits](https://github.com/cashapp/turbine/compare/0.10.0...0.11.0) --- updated-dependencies: - dependency-name: app.cash.turbine:turbine dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- test-harness/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-harness/build.gradle b/test-harness/build.gradle index e694fdd..04e9e74 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 From fff950b719fa1dff11582c70704e738b0869affd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Sep 2022 05:26:10 +0000 Subject: [PATCH 14/44] Bump mockk from 1.12.8 to 1.13.1 Bumps [mockk](https://github.com/mockk/mockk) from 1.12.8 to 1.13.1. - [Release notes](https://github.com/mockk/mockk/releases) - [Commits](https://github.com/mockk/mockk/commits) --- updated-dependencies: - dependency-name: io.mockk:mockk dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build.gradle | 4 ++-- dependencies.gradle | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index a379ec5..69a51e6 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.1' 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..8755d2c 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -143,7 +143,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.1' matrixOlm = "org.matrix.android:olm-sdk:3.2.12" } From 2b5775aa7f3550fba4727f41fd9930ee57d08bac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Sep 2022 05:38:06 +0000 Subject: [PATCH 15/44] Bump json from 20220320 to 20220924 Bumps [json](https://github.com/douglascrockford/JSON-java) from 20220320 to 20220924. - [Release notes](https://github.com/douglascrockford/JSON-java/releases) - [Changelog](https://github.com/stleary/JSON-java/blob/master/docs/RELEASES.md) - [Commits](https://github.com/douglascrockford/JSON-java/commits) --- updated-dependencies: - dependency-name: org.json:json dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- domains/olm-stub/build.gradle | 2 +- test-harness/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/test-harness/build.gradle b/test-harness/build.gradle index e694fdd..f65e14d 100644 --- a/test-harness/build.gradle +++ b/test-harness/build.gradle @@ -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 From 0a3f1f641a240e8a3ef156d5e913c4a40f5be45b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 26 Sep 2022 19:51:15 +0100 Subject: [PATCH 16/44] avoiding push token registration when signed out --- app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt index 3fb4488..d2e2f97 100644 --- a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt +++ b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt @@ -54,7 +54,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() } From 114aa9f785a993f03ea7e40e7c06eefca5fda68f Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 26 Sep 2022 22:59:35 +0100 Subject: [PATCH 17/44] persisting the decrypted images to the disk and enabling in memory image cache --- .../messenger/src/main/AndroidManifest.xml | 6 +- .../dapk/st/messenger/DecryptingFetcher.kt | 59 ++++++++++++++----- .../dapk/st/messenger/MessengerActivity.kt | 2 +- .../app/dapk/st/messenger/MessengerModule.kt | 3 +- .../app/dapk/st/messenger/MessengerScreen.kt | 5 +- 5 files changed, 54 insertions(+), 21 deletions(-) diff --git a/features/messenger/src/main/AndroidManifest.xml b/features/messenger/src/main/AndroidManifest.xml index e643a26..41b5625 100644 --- a/features/messenger/src/main/AndroidManifest.xml +++ b/features/messenger/src/main/AndroidManifest.xml @@ -1,14 +1,14 @@ + 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..a648dee 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 @@ -50,7 +50,7 @@ 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)) setContent { Surface(Modifier.fillMaxSize()) { CompositionLocalProvider(LocalDecyptingFetcherFactory provides factory) { 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..a1e98f8 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 @@ -258,6 +258,7 @@ private fun MessageImage(content: BubbleContent) { painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) .fetcherFactory(LocalDecyptingFetcherFactory.current) + .memoryCacheKey(content.message.imageMeta.url) .data(content.message) .build() ), @@ -437,6 +438,7 @@ private fun ReplyBubbleContent(content: BubbleContent) { painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) .fetcherFactory(LocalDecyptingFetcherFactory.current) + .memoryCacheKey(replyingTo.imageMeta.url) .data(replyingTo) .build() ), @@ -478,7 +480,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() ), From 894592870bc6e58676685d7f388b49fc0aea1249 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Sep 2022 19:05:07 +0100 Subject: [PATCH 18/44] using correct data param when creating device crypto --- domains/olm/src/main/kotlin/app/dapk/st/olm/OlmWrapper.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 } ) } } From 1c3766748568749c2f300ee60914540384403bd7 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Sep 2022 19:09:21 +0100 Subject: [PATCH 19/44] using a single sync flow instance to reduce memory and risk of concurrent flows --- .../matrix/sync/internal/sync/SyncUseCase.kt | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) 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 + } From ef41f13a7b1224b673d4470694650a1074242ebb Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Sep 2022 20:41:32 +0100 Subject: [PATCH 20/44] adding entry point for extending services --- .../kotlin/app/dapk/st/matrix/MatrixClient.kt | 6 +++- .../app/dapk/st/matrix/ServiceInstaller.kt | 28 +++++++++++++++---- .../app/dapk/st/matrix/auth/AuthService.kt | 5 ++-- .../dapk/st/matrix/crypto/CryptoService.kt | 9 ++---- .../dapk/st/matrix/device/DeviceService.kt | 5 ++-- .../dapk/st/matrix/message/MessageService.kt | 9 ++---- .../app/dapk/st/matrix/room/ProfileService.kt | 5 ++-- .../app/dapk/st/matrix/push/PushService.kt | 5 ++-- .../app/dapk/st/matrix/room/RoomService.kt | 9 ++---- .../app/dapk/st/matrix/sync/SyncService.kt | 9 ++---- 10 files changed, 51 insertions(+), 39 deletions(-) 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/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/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, From 69e7dfd90a81fbcd7667d266bdffda80cd6dcd1a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 27 Sep 2022 21:13:21 +0100 Subject: [PATCH 21/44] using proxy to allow the encryption tests to wait for keys to be uploaded, re-enabling the 2nd device user tests --- test-harness/src/test/kotlin/SmokeTest.kt | 26 +++++++++--------- .../src/test/kotlin/test/TestMatrix.kt | 27 +++++++++++++++++-- 2 files changed, 39 insertions(+), 14 deletions(-) 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 From 846cf66fa1c91c2e813289a5e4d38353df3f9376 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 28 Sep 2022 17:58:48 +0100 Subject: [PATCH 22/44] adding runtime permission granter --- .../kotlin/app/dapk/st/core/DapkActivity.kt | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) 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..d297ca6 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); } @@ -64,4 +67,32 @@ abstract class DapkActivity : ComponentActivity(), EffectScope { } 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 } From debfc5e5f0730aac6bb7c34741ec1cee700bb36e Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 28 Sep 2022 19:51:24 +0100 Subject: [PATCH 23/44] adding first pass at a image gallery component with folder fetching --- app/src/main/AndroidManifest.xml | 12 +- features/home/build.gradle | 1 + .../kotlin/app/dapk/st/home/MainActivity.kt | 134 ++++++++++++++++-- .../home/gallery/FetchMediaFoldersUseCase.kt | 72 ++++++++++ 4 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt 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/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/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt index 609f52d..5170101 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 @@ -1,22 +1,49 @@ package app.dapk.st.home +import android.Manifest import android.os.Bundle -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.AlertDialog import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.lifecycleScope import app.dapk.st.core.DapkActivity +import app.dapk.st.core.PermissionResult import app.dapk.st.core.module import app.dapk.st.core.viewModel +import app.dapk.st.design.components.Toolbar import app.dapk.st.directory.DirectoryModule +import app.dapk.st.home.gallery.FetchMediaFoldersUseCase +import app.dapk.st.home.gallery.Folder import app.dapk.st.login.LoginModule import app.dapk.st.profile.ProfileModule -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import kotlinx.coroutines.launch class MainActivity : DapkActivity() { @@ -27,21 +54,46 @@ class MainActivity : DapkActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - homeViewModel.events.onEach { - when (it) { - HomeEvent.Relaunch -> recreate() - } - }.launchIn(lifecycleScope) - setContent { - if (homeViewModel.hasVersionChanged()) { - BetaUpgradeDialog() - } else { - Surface(Modifier.fillMaxSize()) { - HomeScreen(homeViewModel) + val state = mutableStateOf(emptyList()) + + lifecycleScope.launch { + when (ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE)) { + PermissionResult.Denied -> { + } + + PermissionResult.Granted -> { + state.value = FetchMediaFoldersUseCase(contentResolver).fetchFolders() + } + + PermissionResult.ShowRational -> { + } } + } + + setContent { + Surface { + ImageGallery(state) + } + } + +// homeViewModel.events.onEach { +// when (it) { +// HomeEvent.Relaunch -> recreate() +// } +// }.launchIn(lifecycleScope) +// +// setContent { +// if (homeViewModel.hasVersionChanged()) { +// BetaUpgradeDialog() +// } else { +// Surface(Modifier.fillMaxSize()) { +// HomeScreen(homeViewModel) +// } +// } +// } } @Composable @@ -60,3 +112,57 @@ class MainActivity : DapkActivity() { ) } } + +@Composable +fun ImageGallery(state: State>) { + var boxWidth by remember { mutableStateOf(IntSize.Zero) } + val localDensity = LocalDensity.current + val screenWidth = LocalConfiguration.current.screenWidthDp + + Column { + Toolbar(title = "Send to Awesome Room", onNavigate = {}) + val columns = when { + screenWidth > 600 -> 4 + else -> 2 + } + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier.fillMaxSize(), + ) { + items(state.value, key = { it.bucketId }) { + Box(modifier = Modifier.fillMaxWidth().padding(2.dp).onGloballyPositioned { + boxWidth = it.size + }) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(it.thumbnail.toString()) + .build(), + ), + contentDescription = "123", + modifier = Modifier.fillMaxWidth().height(with(localDensity) { boxWidth.width.toDp() }), + contentScale = ContentScale.Crop + ) + + val gradient = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)), + startY = boxWidth.width.toFloat() * 0.5f, + endY = boxWidth.width.toFloat() + ) + + Box(modifier = Modifier.matchParentSize().background(gradient)) + 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) + } + + } + } + } + } + +} diff --git a/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt b/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt new file mode 100644 index 0000000..4333753 --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt @@ -0,0 +1,72 @@ +package app.dapk.st.home.gallery + +import android.content.ContentResolver +import android.content.ContentUris +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.provider.MediaStore.Images +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + + +// https://github.com/signalapp/Signal-Android/blob/e22ddb8f96f8801f0abe622b5261abc6cb396d94/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java + +class FetchMediaFoldersUseCase( + private val contentResolver: ContentResolver, +) { + + + suspend fun fetchFolders(): List { + 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 + withContext(Dispatchers.IO) { + 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() + +// val folder: FolderData = Util.getOrDefault(folders, bucketId, FolderData(thumbnail, localizeTitle(context, title), bucketId)) +// folder.incrementCount() +// folders.put(bucketId, folder) +// if (cameraBucketId == null && title == "Camera") { +// cameraBucketId = bucketId +// } +// if (timestamp > thumbnailTimestamp) { +// globalThumbnail = thumbnail +// thumbnailTimestamp = timestamp +// } + } + } + } + return folders.values.toList() + } + + private fun isNotPending() = if (Build.VERSION.SDK_INT <= 28) Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1" + +} + +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++ + } + +} \ No newline at end of file From 6f89c7130036b21045ff0a5572124b9d6b2d2798 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 28 Sep 2022 22:37:53 +0100 Subject: [PATCH 24/44] adding skeleton for image gallery folder viewing --- .../kotlin/app/dapk/st/home/MainActivity.kt | 286 ++++++++++++++---- .../home/gallery/FetchMediaFoldersUseCase.kt | 86 ++++-- 2 files changed, 290 insertions(+), 82 deletions(-) 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 5170101..d6c6734 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 @@ -1,9 +1,9 @@ package app.dapk.st.home -import android.Manifest import android.os.Bundle 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 @@ -13,7 +13,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -30,19 +29,23 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.lifecycleScope -import app.dapk.st.core.DapkActivity -import app.dapk.st.core.PermissionResult -import app.dapk.st.core.module -import app.dapk.st.core.viewModel -import app.dapk.st.design.components.Toolbar +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.* +import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.design.components.Route +import app.dapk.st.design.components.Spider +import app.dapk.st.design.components.SpiderPage import app.dapk.st.directory.DirectoryModule import app.dapk.st.home.gallery.FetchMediaFoldersUseCase +import app.dapk.st.home.gallery.FetchMediaUseCase import app.dapk.st.home.gallery.Folder +import app.dapk.st.home.gallery.Media import app.dapk.st.login.LoginModule import app.dapk.st.profile.ProfileModule +import app.dapk.st.viewmodel.DapkViewModel import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest +import kotlinx.coroutines.Job import kotlinx.coroutines.launch class MainActivity : DapkActivity() { @@ -55,27 +58,31 @@ class MainActivity : DapkActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val state = mutableStateOf(emptyList()) + val viewModel = ImageGalleryViewModel( + FetchMediaFoldersUseCase(contentResolver), + FetchMediaUseCase(contentResolver), + ) - lifecycleScope.launch { - when (ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE)) { - PermissionResult.Denied -> { - } - - PermissionResult.Granted -> { - state.value = FetchMediaFoldersUseCase(contentResolver).fetchFolders() - } - - PermissionResult.ShowRational -> { - - } - } - - } +// lifecycleScope.launch { +// when (ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE)) { +// PermissionResult.Denied -> { +// } +// +// PermissionResult.Granted -> { +// state.value = FetchMediaFoldersUseCase(contentResolver).fetchFolders() +// } +// +// PermissionResult.ShowRational -> { +// +// } +// } +// } setContent { Surface { - ImageGallery(state) + ImageGalleryScreen(viewModel) { + finish() + } } } @@ -113,56 +120,215 @@ class MainActivity : DapkActivity() { } } + +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 + +class ImageGalleryViewModel( + private val foldersUseCase: FetchMediaFoldersUseCase, + private val fetchMediaUseCase: FetchMediaUseCase, +) : DapkViewModel( + initialState = ImageGalleryState(page = SpiderPage(route = ImageGalleryPage.Routes.folders, "", null, 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))) } + } + +} + @Composable -fun ImageGallery(state: State>) { +fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> 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) + } + } + +} + +@Composable +fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit) { + var boxWidth by remember { mutableStateOf(IntSize.Zero) } + val localDensity = LocalDensity.current + val screenWidth = LocalConfiguration.current.screenWidthDp + + 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) + .clickable { onClick(it) } + .onGloballyPositioned { + boxWidth = it.size + }) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(it.thumbnail.toString()) + .build(), + ), + contentDescription = "123", + modifier = Modifier.fillMaxWidth().height(with(localDensity) { boxWidth.width.toDp() }), + contentScale = ContentScale.Crop + ) + + val gradient = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)), + startY = boxWidth.width.toFloat() * 0.5f, + endY = boxWidth.width.toFloat() + ) + + Box(modifier = Modifier.matchParentSize().background(gradient)) + 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 -> TODO() + } +} + +@Composable +fun ImageGalleryMedia(state: ImageGalleryPage.Files) { var boxWidth by remember { mutableStateOf(IntSize.Zero) } val localDensity = LocalDensity.current val screenWidth = LocalConfiguration.current.screenWidthDp Column { - Toolbar(title = "Send to Awesome Room", onNavigate = {}) val columns = when { screenWidth > 600 -> 4 else -> 2 } - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - modifier = Modifier.fillMaxSize(), - ) { - items(state.value, key = { it.bucketId }) { - Box(modifier = Modifier.fillMaxWidth().padding(2.dp).onGloballyPositioned { - boxWidth = it.size - }) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalContext.current) - .data(it.thumbnail.toString()) - .build(), - ), - contentDescription = "123", - modifier = Modifier.fillMaxWidth().height(with(localDensity) { boxWidth.width.toDp() }), - contentScale = ContentScale.Crop - ) - val gradient = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)), - startY = boxWidth.width.toFloat() * 0.5f, - endY = boxWidth.width.toFloat() - ) - - Box(modifier = Modifier.matchParentSize().background(gradient)) - 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) + when (val content = state.content) { + is Lce.Loading -> { + CenteredLoading() + } + is Lce.Content -> { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier.fillMaxSize(), + ) { + items(content.value, key = { it.id }) { + Box(modifier = Modifier.fillMaxWidth().padding(2.dp).onGloballyPositioned { + boxWidth = it.size + }) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(it.uri.toString()) + .crossfade(true) + .build(), + ), + contentDescription = "123", + modifier = Modifier.fillMaxWidth().height(with(localDensity) { boxWidth.width.toDp() }), + contentScale = ContentScale.Crop + ) + } } - } } + + is Lce.Error -> TODO() } + } } + + diff --git a/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt b/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt index 4333753..d4d0e66 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt @@ -16,15 +16,14 @@ class FetchMediaFoldersUseCase( private val contentResolver: ContentResolver, ) { - suspend fun fetchFolders(): List { - 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" + return withContext(Dispatchers.IO) { + 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 - withContext(Dispatchers.IO) { + 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])) @@ -32,27 +31,14 @@ class FetchMediaFoldersUseCase( 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() - -// val folder: FolderData = Util.getOrDefault(folders, bucketId, FolderData(thumbnail, localizeTitle(context, title), bucketId)) -// folder.incrementCount() -// folders.put(bucketId, folder) -// if (cameraBucketId == null && title == "Camera") { -// cameraBucketId = bucketId -// } -// if (timestamp > thumbnailTimestamp) { -// globalThumbnail = thumbnail -// thumbnailTimestamp = timestamp -// } } } + folders.values.toList() } - return folders.values.toList() } - private fun isNotPending() = if (Build.VERSION.SDK_INT <= 28) Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1" } @@ -69,4 +55,60 @@ data class Folder( _itemCount++ } -} \ No newline at end of file +} + + +class FetchMediaUseCase(private val contentResolver: ContentResolver) { + + private val projection = arrayOf( + Images.Media._ID, + Images.Media.MIME_TYPE, + Images.Media.DATE_MODIFIED, + Images.Media.ORIENTATION, + Images.Media.WIDTH, + Images.Media.HEIGHT, + Images.Media.SIZE + ) + + suspend fun getMediaInBucket(bucketId: String): List { + return withContext(Dispatchers.IO) { + + val media = mutableListOf() + val selection = Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + Images.Media.MIME_TYPE + " NOT LIKE ?" + val selectionArgs = arrayOf(bucketId, "%image/svg%") + val sortBy = Images.Media.DATE_MODIFIED + " DESC" + val contentUri = 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(Images.Media.MIME_TYPE)) + val date = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED)) + val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(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(Images.Media.SIZE)) + media.add(Media(rowId, uri, mimetype, width, height, size, date)) + } + } + media + } + } + + private fun getWidthColumn(orientation: Int) = if (orientation == 0 || orientation == 180) Images.Media.WIDTH else Images.Media.HEIGHT + + private fun getHeightColumn(orientation: Int) = if (orientation == 0 || orientation == 180) Images.Media.HEIGHT else 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, +) + +private fun isNotPending() = if (Build.VERSION.SDK_INT <= 28) Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1" From fe5717b7c14a1498425312e301c87bfd94d75207 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Sep 2022 05:22:18 +0000 Subject: [PATCH 25/44] Bump mockk from 1.13.1 to 1.13.2 Bumps [mockk](https://github.com/mockk/mockk) from 1.13.1 to 1.13.2. - [Release notes](https://github.com/mockk/mockk/releases) - [Commits](https://github.com/mockk/mockk/compare/1.13.1...1.13.2) --- updated-dependencies: - dependency-name: io.mockk:mockk dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- dependencies.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 69a51e6..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.13.1' + 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' diff --git a/dependencies.gradle b/dependencies.gradle index 8755d2c..6ab6def 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -143,7 +143,7 @@ ext.Dependencies.with { junit = "junit:junit:4.13.2" kluent = "org.amshove.kluent:kluent:1.68" - mockk = 'io.mockk:mockk:1.13.1' + mockk = 'io.mockk:mockk:1.13.2' matrixOlm = "org.matrix.android:olm-sdk:3.2.12" } From 34e0415892ea25390c9148f0e694370a25ef6aae Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 13:11:23 +0100 Subject: [PATCH 26/44] fixing performance issue when loading large lists of images --- .../kotlin/app/dapk/st/home/MainActivity.kt | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) 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 d6c6734..6d150f7 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 @@ -13,25 +13,19 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue 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.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewModelScope import app.dapk.st.core.* import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.design.components.GenericError import app.dapk.st.design.components.Route import app.dapk.st.design.components.Spider import app.dapk.st.design.components.SpiderPage @@ -222,10 +216,12 @@ fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> U @Composable fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit) { - var boxWidth by remember { mutableStateOf(IntSize.Zero) } - val localDensity = LocalDensity.current 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() @@ -242,11 +238,8 @@ fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Un modifier = Modifier.fillMaxSize(), ) { items(content.value, key = { it.bucketId }) { - Box(modifier = Modifier.fillMaxWidth().padding(2.dp) - .clickable { onClick(it) } - .onGloballyPositioned { - boxWidth = it.size - }) { + Box(modifier = Modifier.fillMaxWidth().padding(2.dp).aspectRatio(1f) + .clickable { onClick(it) }) { Image( painter = rememberAsyncImagePainter( model = ImageRequest.Builder(LocalContext.current) @@ -254,17 +247,11 @@ fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Un .build(), ), contentDescription = "123", - modifier = Modifier.fillMaxWidth().height(with(localDensity) { boxWidth.width.toDp() }), + modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) - val gradient = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)), - startY = boxWidth.width.toFloat() * 0.5f, - endY = boxWidth.width.toFloat() - ) - - Box(modifier = Modifier.matchParentSize().background(gradient)) + 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, @@ -279,14 +266,12 @@ fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Un } } - is Lce.Error -> TODO() + is Lce.Error -> GenericError { } } } @Composable fun ImageGalleryMedia(state: ImageGalleryPage.Files) { - var boxWidth by remember { mutableStateOf(IntSize.Zero) } - val localDensity = LocalDensity.current val screenWidth = LocalConfiguration.current.screenWidthDp Column { @@ -299,15 +284,15 @@ fun ImageGalleryMedia(state: ImageGalleryPage.Files) { 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.fillMaxWidth().padding(2.dp).onGloballyPositioned { - boxWidth = it.size - }) { + Box(modifier = modifier) { Image( painter = rememberAsyncImagePainter( model = ImageRequest.Builder(LocalContext.current) @@ -316,7 +301,7 @@ fun ImageGalleryMedia(state: ImageGalleryPage.Files) { .build(), ), contentDescription = "123", - modifier = Modifier.fillMaxWidth().height(with(localDensity) { boxWidth.width.toDp() }), + modifier = Modifier.fillMaxWidth().fillMaxHeight(), contentScale = ContentScale.Crop ) } @@ -324,7 +309,7 @@ fun ImageGalleryMedia(state: ImageGalleryPage.Files) { } } - is Lce.Error -> TODO() + is Lce.Error -> GenericError { } } } From c61646bbd372ec340cf9d843f387c78d931ae3bb Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 13:50:58 +0100 Subject: [PATCH 27/44] creating dedicated activity for image selection --- .../kotlin/app/dapk/st/core/DapkActivity.kt | 4 +- features/home/src/main/AndroidManifest.xml | 7 +- .../kotlin/app/dapk/st/home/MainActivity.kt | 273 ++---------------- .../st/home/gallery/ImageGalleryActivity.kt | 79 +++++ .../st/home/gallery/ImageGalleryScreen.kt | 160 ++++++++++ .../st/home/gallery/ImageGalleryViewModel.kt | 73 +++++ .../kotlin/app/dapk/st/navigator/Navigator.kt | 1 + 7 files changed, 344 insertions(+), 253 deletions(-) create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryActivity.kt create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryScreen.kt create mode 100644 features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryViewModel.kt 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 d297ca6..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 @@ -61,11 +61,13 @@ 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 { diff --git a/features/home/src/main/AndroidManifest.xml b/features/home/src/main/AndroidManifest.xml index 3e51f2c..ccee295 100644 --- a/features/home/src/main/AndroidManifest.xml +++ b/features/home/src/main/AndroidManifest.xml @@ -1,8 +1,9 @@ - - - + + + + \ 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 6d150f7..1ff20c1 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 @@ -1,46 +1,29 @@ package app.dapk.st.home import android.os.Bundle -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 android.widget.Toast +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.AlertDialog import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 androidx.lifecycle.viewModelScope -import app.dapk.st.core.* -import app.dapk.st.core.components.CenteredLoading -import app.dapk.st.design.components.GenericError +import androidx.lifecycle.lifecycleScope +import app.dapk.st.core.DapkActivity +import app.dapk.st.core.Lce +import app.dapk.st.core.module +import app.dapk.st.core.viewModel import app.dapk.st.design.components.Route -import app.dapk.st.design.components.Spider import app.dapk.st.design.components.SpiderPage import app.dapk.st.directory.DirectoryModule -import app.dapk.st.home.gallery.FetchMediaFoldersUseCase -import app.dapk.st.home.gallery.FetchMediaUseCase import app.dapk.st.home.gallery.Folder +import app.dapk.st.home.gallery.GetImageFromGallery import app.dapk.st.home.gallery.Media import app.dapk.st.login.LoginModule import app.dapk.st.profile.ProfileModule -import app.dapk.st.viewmodel.DapkViewModel -import coil.compose.rememberAsyncImagePainter -import coil.request.ImageRequest -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach class MainActivity : DapkActivity() { @@ -52,49 +35,26 @@ class MainActivity : DapkActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val viewModel = ImageGalleryViewModel( - FetchMediaFoldersUseCase(contentResolver), - FetchMediaUseCase(contentResolver), - ) + homeViewModel.events.onEach { + when (it) { + HomeEvent.Relaunch -> recreate() + } + }.launchIn(lifecycleScope) + + registerForActivityResult(GetImageFromGallery()) { + Toast.makeText(this, it.toString(), Toast.LENGTH_SHORT).show() + }.launch(null) -// lifecycleScope.launch { -// when (ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE)) { -// PermissionResult.Denied -> { -// } -// -// PermissionResult.Granted -> { -// state.value = FetchMediaFoldersUseCase(contentResolver).fetchFolders() -// } -// -// PermissionResult.ShowRational -> { -// -// } -// } -// } setContent { - Surface { - ImageGalleryScreen(viewModel) { - finish() + if (homeViewModel.hasVersionChanged()) { + BetaUpgradeDialog() + } else { + Surface(Modifier.fillMaxSize()) { + HomeScreen(homeViewModel) } } } - -// homeViewModel.events.onEach { -// when (it) { -// HomeEvent.Relaunch -> recreate() -// } -// }.launchIn(lifecycleScope) -// -// setContent { -// if (homeViewModel.hasVersionChanged()) { -// BetaUpgradeDialog() -// } else { -// Surface(Modifier.fillMaxSize()) { -// HomeScreen(homeViewModel) -// } -// } -// } } @Composable @@ -132,188 +92,3 @@ sealed interface ImageGalleryPage { sealed interface ImageGalleryEvent - -class ImageGalleryViewModel( - private val foldersUseCase: FetchMediaFoldersUseCase, - private val fetchMediaUseCase: FetchMediaUseCase, -) : DapkViewModel( - initialState = ImageGalleryState(page = SpiderPage(route = ImageGalleryPage.Routes.folders, "", null, 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))) } - } - -} - -@Composable -fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> 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) - } - } - -} - -@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) { - 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) { - 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/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryActivity.kt new file mode 100644 index 0000000..00275ea --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryActivity.kt @@ -0,0 +1,79 @@ +package app.dapk.st.home.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 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.DapkActivity +import app.dapk.st.core.Lce +import app.dapk.st.core.PermissionResult +import app.dapk.st.home.ImageGalleryScreen +import kotlinx.coroutines.launch + +class ImageGalleryActivity : DapkActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val viewModel = ImageGalleryViewModel( + FetchMediaFoldersUseCase(contentResolver), + FetchMediaUseCase(contentResolver), + ) + + 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: Void?): Intent { + return Intent(context, ImageGalleryActivity::class.java) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { + return intent.takeIf { resultCode == Activity.RESULT_OK }?.data + } +} \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryScreen.kt b/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryScreen.kt new file mode 100644 index 0000000..d9fcc2a --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryScreen.kt @@ -0,0 +1,160 @@ +package app.dapk.st.home + +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 app.dapk.st.home.gallery.Folder +import app.dapk.st.home.gallery.ImageGalleryViewModel +import app.dapk.st.home.gallery.Media +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/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryViewModel.kt b/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryViewModel.kt new file mode 100644 index 0000000..a8107b9 --- /dev/null +++ b/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryViewModel.kt @@ -0,0 +1,73 @@ +package app.dapk.st.home.gallery + +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.Lce +import app.dapk.st.design.components.SpiderPage +import app.dapk.st.home.ImageGalleryEvent +import app.dapk.st.home.ImageGalleryPage +import app.dapk.st.home.ImageGalleryState +import app.dapk.st.viewmodel.DapkViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class ImageGalleryViewModel( + private val foldersUseCase: FetchMediaFoldersUseCase, + private val fetchMediaUseCase: FetchMediaUseCase, +) : DapkViewModel( + initialState = ImageGalleryState( + page = SpiderPage( + route = ImageGalleryPage.Routes.folders, + label = "", + 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))) } + } + +} \ No newline at end of file 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..4fb7281 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 @@ -1,6 +1,7 @@ package app.dapk.st.navigator import android.app.Activity +import android.app.Instrumentation.ActivityResult import android.app.PendingIntent import android.content.Context import android.content.Intent From 3f956d1903c053f9221b191088c36460692c9dbb Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 14:32:04 +0100 Subject: [PATCH 28/44] moving the image gallery to the messenger module and attaching to a attachment button --- features/home/src/main/AndroidManifest.xml | 1 - .../kotlin/app/dapk/st/home/MainActivity.kt | 31 ---------------- .../messenger/src/main/AndroidManifest.xml | 1 + .../dapk/st/messenger/MessengerActivity.kt | 23 +++++++++--- .../app/dapk/st/messenger/MessengerScreen.kt | 36 ++++++++++++++----- .../app/dapk/st/messenger/MessengerState.kt | 4 ++- .../dapk/st/messenger/MessengerViewModel.kt | 7 ++++ .../gallery/FetchMediaFoldersUseCase.kt | 2 +- .../gallery/ImageGalleryActivity.kt | 3 +- .../messenger}/gallery/ImageGalleryScreen.kt | 5 +-- .../gallery/ImageGalleryViewModel.kt | 25 ++++++++++--- 11 files changed, 80 insertions(+), 58 deletions(-) rename features/{home/src/main/kotlin/app/dapk/st/home => messenger/src/main/kotlin/app/dapk/st/messenger}/gallery/FetchMediaFoldersUseCase.kt (99%) rename features/{home/src/main/kotlin/app/dapk/st/home => messenger/src/main/kotlin/app/dapk/st/messenger}/gallery/ImageGalleryActivity.kt (96%) rename features/{home/src/main/kotlin/app/dapk/st/home => messenger/src/main/kotlin/app/dapk/st/messenger}/gallery/ImageGalleryScreen.kt (97%) rename features/{home/src/main/kotlin/app/dapk/st/home => messenger/src/main/kotlin/app/dapk/st/messenger}/gallery/ImageGalleryViewModel.kt (80%) diff --git a/features/home/src/main/AndroidManifest.xml b/features/home/src/main/AndroidManifest.xml index ccee295..e9b8247 100644 --- a/features/home/src/main/AndroidManifest.xml +++ b/features/home/src/main/AndroidManifest.xml @@ -3,7 +3,6 @@ - \ 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 1ff20c1..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 @@ -1,7 +1,6 @@ package app.dapk.st.home import android.os.Bundle -import android.widget.Toast import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.AlertDialog import androidx.compose.material3.Surface @@ -11,15 +10,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import app.dapk.st.core.DapkActivity -import app.dapk.st.core.Lce import app.dapk.st.core.module import app.dapk.st.core.viewModel -import app.dapk.st.design.components.Route -import app.dapk.st.design.components.SpiderPage import app.dapk.st.directory.DirectoryModule -import app.dapk.st.home.gallery.Folder -import app.dapk.st.home.gallery.GetImageFromGallery -import app.dapk.st.home.gallery.Media import app.dapk.st.login.LoginModule import app.dapk.st.profile.ProfileModule import kotlinx.coroutines.flow.launchIn @@ -41,11 +34,6 @@ class MainActivity : DapkActivity() { } }.launchIn(lifecycleScope) - registerForActivityResult(GetImageFromGallery()) { - Toast.makeText(this, it.toString(), Toast.LENGTH_SHORT).show() - }.launch(null) - - setContent { if (homeViewModel.hasVersionChanged()) { BetaUpgradeDialog() @@ -73,22 +61,3 @@ class MainActivity : DapkActivity() { ) } } - - -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/AndroidManifest.xml b/features/messenger/src/main/AndroidManifest.xml index 41b5625..d81f786 100644 --- a/features/messenger/src/main/AndroidManifest.xml +++ b/features/messenger/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ android:windowSoftInputMode="adjustResize"/> + 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 a648dee..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 @@ -51,10 +51,25 @@ class MessengerActivity : DapkActivity() { super.onCreate(savedInstanceState) val payload = readPayload() 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/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index a1e98f8..7e9d8ae 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 @@ -11,9 +13,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField 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.Send +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -47,10 +47,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) } @@ -74,6 +80,7 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List) { StartObserving { this@ObserveEvents.events.launch { - // TODO() + when (it) { + MessengerEvent.SelectImageAttachment -> galleryLauncher.launch(null) + } } } } @@ -553,7 +562,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() @@ -579,7 +588,16 @@ 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 = { + Box { + Icon( + modifier = Modifier.align(Alignment.CenterEnd).clickable { onAttach() }, + 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..aead223 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,10 @@ internal class MessengerViewModel( } } + fun startAttachment() { + _events.tryEmit(MessengerEvent.SelectImageAttachment) + } + } private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roomState.events @@ -133,6 +139,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/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCase.kt similarity index 99% rename from features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt rename to features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCase.kt index d4d0e66..8d08c97 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCase.kt @@ -1,4 +1,4 @@ -package app.dapk.st.home.gallery +package app.dapk.st.messenger.gallery import android.content.ContentResolver import android.content.ContentUris diff --git a/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryActivity.kt similarity index 96% rename from features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryActivity.kt rename to features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryActivity.kt index 00275ea..53430dc 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryActivity.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryActivity.kt @@ -1,4 +1,4 @@ -package app.dapk.st.home.gallery +package app.dapk.st.messenger.gallery import android.Manifest import android.app.Activity @@ -15,7 +15,6 @@ import androidx.lifecycle.lifecycleScope import app.dapk.st.core.DapkActivity import app.dapk.st.core.Lce import app.dapk.st.core.PermissionResult -import app.dapk.st.home.ImageGalleryScreen import kotlinx.coroutines.launch class ImageGalleryActivity : DapkActivity() { diff --git a/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt similarity index 97% rename from features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryScreen.kt rename to features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt index d9fcc2a..acb46c1 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt @@ -1,4 +1,4 @@ -package app.dapk.st.home +package app.dapk.st.messenger.gallery import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -24,9 +24,6 @@ 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 app.dapk.st.home.gallery.Folder -import app.dapk.st.home.gallery.ImageGalleryViewModel -import app.dapk.st.home.gallery.Media import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest diff --git a/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryViewModel.kt similarity index 80% rename from features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryViewModel.kt rename to features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryViewModel.kt index a8107b9..1a6d3b1 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/gallery/ImageGalleryViewModel.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryViewModel.kt @@ -1,11 +1,9 @@ -package app.dapk.st.home.gallery +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.home.ImageGalleryEvent -import app.dapk.st.home.ImageGalleryPage -import app.dapk.st.home.ImageGalleryState import app.dapk.st.viewmodel.DapkViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -70,4 +68,21 @@ class ImageGalleryViewModel( updateState { copy(page = (page as SpiderPage).copy(state = block(page.state))) } } -} \ No newline at end of file +} + +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 From ee31dd614b7d891bd3e1ee20064a773be75e18af Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 14:54:38 +0100 Subject: [PATCH 29/44] updating AGP to latest stable - reduces the output apk size a little --- dependencies.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index 6ab6def..8571574 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,7 +104,7 @@ 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}" From 5e939331b7586a678c4b15e3f053ad81782e2b68 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 14:57:09 +0100 Subject: [PATCH 30/44] updating compose compiler to latest stable --- dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index 8571574..9f09a1f 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -111,7 +111,7 @@ ext.Dependencies.with { 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" From bd885823bd17ff5114c8a730d501dd62a69377c1 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 15:12:53 +0100 Subject: [PATCH 31/44] removing unused import --- .../src/main/kotlin/app/dapk/st/navigator/Navigator.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 4fb7281..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 @@ -1,7 +1,6 @@ package app.dapk.st.navigator import android.app.Activity -import android.app.Instrumentation.ActivityResult import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -82,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() } From 415ea4b15087b020a013f8cfdb66ac3f85f5c902 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 16:24:17 +0100 Subject: [PATCH 32/44] providing image gallery via DI graph --- .../app/dapk/st/SmallTalkApplication.kt | 2 + .../kotlin/app/dapk/st/graph/AppModule.kt | 5 ++ .../gallery/FetchMediaFoldersUseCase.kt | 69 ++----------------- .../st/messenger/gallery/FetchMediaUseCase.kt | 64 +++++++++++++++++ .../messenger/gallery/ImageGalleryActivity.kt | 15 ++-- .../messenger/gallery/ImageGalleryModule.kt | 17 +++++ .../messenger/gallery/MediaStoreExtensions.kt | 6 ++ 7 files changed, 104 insertions(+), 74 deletions(-) create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCase.kt create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/MediaStoreExtensions.kt diff --git a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt index d2e2f97..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 @@ -81,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/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 index 8d08c97..1f17102 100644 --- 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 @@ -3,21 +3,17 @@ package app.dapk.st.messenger.gallery import android.content.ContentResolver import android.content.ContentUris import android.net.Uri -import android.os.Build -import android.provider.MediaStore import android.provider.MediaStore.Images -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - - -// https://github.com/signalapp/Signal-Android/blob/e22ddb8f96f8801f0abe622b5261abc6cb396d94/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java +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 withContext(Dispatchers.IO) { + 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" @@ -39,7 +35,6 @@ class FetchMediaFoldersUseCase( } } - } data class Folder( @@ -56,59 +51,3 @@ data class Folder( } } - - -class FetchMediaUseCase(private val contentResolver: ContentResolver) { - - private val projection = arrayOf( - Images.Media._ID, - Images.Media.MIME_TYPE, - Images.Media.DATE_MODIFIED, - Images.Media.ORIENTATION, - Images.Media.WIDTH, - Images.Media.HEIGHT, - Images.Media.SIZE - ) - - suspend fun getMediaInBucket(bucketId: String): List { - return withContext(Dispatchers.IO) { - - val media = mutableListOf() - val selection = Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + Images.Media.MIME_TYPE + " NOT LIKE ?" - val selectionArgs = arrayOf(bucketId, "%image/svg%") - val sortBy = Images.Media.DATE_MODIFIED + " DESC" - val contentUri = 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(Images.Media.MIME_TYPE)) - val date = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED)) - val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(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(Images.Media.SIZE)) - media.add(Media(rowId, uri, mimetype, width, height, size, date)) - } - } - media - } - } - - private fun getWidthColumn(orientation: Int) = if (orientation == 0 || orientation == 180) Images.Media.WIDTH else Images.Media.HEIGHT - - private fun getHeightColumn(orientation: Int) = if (orientation == 0 || orientation == 180) Images.Media.HEIGHT else 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, -) - -private fun isNotPending() = if (Build.VERSION.SDK_INT <= 28) Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1" 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 index 53430dc..0917df0 100644 --- 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 @@ -12,21 +12,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.lifecycleScope -import app.dapk.st.core.DapkActivity -import app.dapk.st.core.Lce -import app.dapk.st.core.PermissionResult +import app.dapk.st.core.* +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.messenger.MessengerModule import kotlinx.coroutines.launch class ImageGalleryActivity : DapkActivity() { + private val module by unsafeLazy { module() } + private val viewModel by viewModel { module.imageGalleryViewModel() } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - val viewModel = ImageGalleryViewModel( - FetchMediaFoldersUseCase(contentResolver), - FetchMediaUseCase(contentResolver), - ) - val permissionState = mutableStateOf>(Lce.Loading()) lifecycleScope.launch { 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..b3076b8 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt @@ -0,0 +1,17 @@ +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() = ImageGalleryViewModel( + FetchMediaFoldersUseCase(contentResolver, dispatchers), + FetchMediaUseCase(contentResolver, dispatchers), + ) + +} \ No newline at end of file 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" From 14d625765c6a1fef4b11b5c997f2f786d723cf8f Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 16:25:33 +0100 Subject: [PATCH 33/44] avoiding try emit --- .../main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 aead223..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 @@ -126,7 +126,9 @@ internal class MessengerViewModel( } fun startAttachment() { - _events.tryEmit(MessengerEvent.SelectImageAttachment) + viewModelScope.launch { + _events.emit(MessengerEvent.SelectImageAttachment) + } } } From ff69156330aaed477583090aa5bc26846cbd62e6 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 16:43:40 +0100 Subject: [PATCH 34/44] including room name in the image gallery toolbar --- .../app/dapk/st/messenger/MessengerScreen.kt | 17 +++++++++++---- .../messenger/gallery/ImageGalleryActivity.kt | 21 ++++++++++++++----- .../messenger/gallery/ImageGalleryModule.kt | 3 ++- .../gallery/ImageGalleryViewModel.kt | 3 ++- 4 files changed, 33 insertions(+), 11 deletions(-) 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 7e9d8ae..6f226fa 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 @@ -13,7 +13,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +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.* import androidx.compose.ui.Alignment @@ -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 @@ -52,7 +57,7 @@ internal fun MessengerScreen( attachments: List?, viewModel: MessengerViewModel, navigator: Navigator, - galleryLauncher: ActivityResultLauncher<*> + galleryLauncher: ActivityResultLauncher ) { val state = viewModel.state @@ -96,11 +101,15 @@ internal fun MessengerScreen( } @Composable -private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher<*>) { +private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher) { StartObserving { this@ObserveEvents.events.launch { when (it) { - MessengerEvent.SelectImageAttachment -> galleryLauncher.launch(null) + MessengerEvent.SelectImageAttachment -> { + state.roomState.takeIfContent()?.let { + galleryLauncher.launch(ImageGalleryActivityPayload(it.roomState.roomOverview.roomName ?: "")) + } + } } } } 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 index 0917df0..06faf13 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -14,13 +15,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.lifecycleScope import app.dapk.st.core.* import app.dapk.st.core.extensions.unsafeLazy -import app.dapk.st.messenger.MessengerModule import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize class ImageGalleryActivity : DapkActivity() { private val module by unsafeLazy { module() } - private val viewModel by viewModel { module.imageGalleryViewModel() } + private val viewModel by viewModel { + val payload = intent.getParcelableExtra("key") as? ImageGalleryActivityPayload + module.imageGalleryViewModel(payload!!.roomName) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -63,13 +67,20 @@ fun Activity.PermissionGuard(state: State>, onGranted: @Co } -class GetImageFromGallery : ActivityResultContract() { +class GetImageFromGallery : ActivityResultContract() { - override fun createIntent(context: Context, input: Void?): Intent { + 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 } -} \ No newline at end of file +} + + +@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 index b3076b8..0e92bdb 100644 --- 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 @@ -9,9 +9,10 @@ class ImageGalleryModule( private val dispatchers: CoroutineDispatchers, ) : ProvidableModule { - fun imageGalleryViewModel() = ImageGalleryViewModel( + 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/ImageGalleryViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryViewModel.kt index 1a6d3b1..59cbbb4 100644 --- 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 @@ -11,11 +11,12 @@ 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 = "", + label = "Send to $roomName", parent = null, state = ImageGalleryPage.Folders(Lce.Loading()) ) From bd9c247ee8ee2925e4fd9af0516c54395977812e Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 17:03:08 +0100 Subject: [PATCH 35/44] fixing bridge images not showing up - caused by a missing mimetype field in the message request --- .../st/matrix/message/internal/ApiMessage.kt | 1 + .../message/internal/SendMessageUseCase.kt | 24 +++++++------------ 2 files changed, 10 insertions(+), 15 deletions(-) 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 - ) - ) - } From e0c1421fd5112afcebbe2610f62c29f5721a3cf5 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 17:42:31 +0100 Subject: [PATCH 36/44] fixing missing input cursor due to decoration box not taking the original text field into account --- .../main/kotlin/app/dapk/st/messenger/MessengerScreen.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 6f226fa..fa45f02 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 @@ -598,10 +598,11 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), textStyle = LocalTextStyle.current.copy(color = SmallTalkTheme.extendedColors.onOthersBubble), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true), - decorationBox = { - Box { + decorationBox = { innerField -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Box(modifier = Modifier.weight(1f).padding(end = 4.dp)) { innerField() } Icon( - modifier = Modifier.align(Alignment.CenterEnd).clickable { onAttach() }, + modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom), imageVector = Icons.Filled.Image, contentDescription = "", ) From 5a67275b89704ee57f9c351922d2b2d4008b5ee4 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 17:58:45 +0100 Subject: [PATCH 37/44] parsing redacted events --- .../matrix/sync/internal/request/ApiTimelineEvent.kt | 10 +++++++++- .../sync/internal/sync/TimelineEventsProcessor.kt | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) 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/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 From 0a0f998785a1ae6653619652ee64d6a4c018d201 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 19:00:40 +0100 Subject: [PATCH 38/44] taking into account redactions and updating the message list --- .../dapk/st/domain/sync/RoomPersistence.kt | 17 +++++--- .../sqldelight/app/dapk/db/model/RoomEvent.sq | 6 ++- .../app/dapk/st/matrix/sync/RoomState.kt | 1 + .../kotlin/app/dapk/st/matrix/sync/Store.kt | 3 +- .../sync/internal/sync/RoomDataSource.kt | 39 ++++++++++++++++--- .../sync/internal/sync/RoomProcessor.kt | 4 ++ .../matrix/sync/internal/sync/SyncReducer.kt | 2 - 7 files changed, 58 insertions(+), 14 deletions(-) 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/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/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(), From 6df204009edb20b773760eeabbe4ef9b3625eeab Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 19:11:18 +0100 Subject: [PATCH 39/44] updating matrix js sdk --- tools/beta-release/package-lock.json | 14 +++++++------- tools/beta-release/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) 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" } } From 40222b5ea31c8393822ae7af4a513d43ba981ee5 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 19:29:33 +0100 Subject: [PATCH 40/44] adding automatic foss apk uploading to the release --- .github/workflows/release-candidate.yml | 6 +++++- tools/beta-release/release.js | 10 ++++++++++ tools/generate-fdroid-release.sh | 2 +- tools/generate-release.sh | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) 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/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 From c39a4747748a4c7fa13363c93b20eeb7df50f1eb Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 19:47:54 +0100 Subject: [PATCH 41/44] fixing reply text being unreadable on some material/theme combinations --- .../src/main/kotlin/app/dapk/st/design/components/Theme.kt | 4 ++-- .../src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index fa45f02..ca123c1 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 @@ -427,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) ) { @@ -436,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, From 5f8f0b87ca36b835d661f20bc3be452e8e0cbbf3 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 19:48:23 +0100 Subject: [PATCH 42/44] renaming work flow to better reflect what it does --- .github/workflows/release-train.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From c2b45d6fa95d541e197593b4ea0011ae60388725 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 19:52:44 +0100 Subject: [PATCH 43/44] temporarily removing no-op room settings toolbar overflow --- .../main/kotlin/app/dapk/st/messenger/MessengerScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 ca123c1..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 @@ -74,9 +74,9 @@ internal fun MessengerScreen( Column { Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = { - OverflowMenu { - DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {}) - } +// OverflowMenu { +// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {}) +// } }) when (state.composerState) { is ComposerState.Text -> { From 616d7c411e481915110be8ec7e76c0e2041f54cd Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 29 Sep 2022 20:04:17 +0100 Subject: [PATCH 44/44] updating version for release --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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