diff --git a/app/build.gradle b/app/build.gradle index a52087c7..0b091ca4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -106,6 +106,9 @@ dependencies { implementation project(":core") + implementation project(":chat-engine") + implementation project(":matrix-chat-engine") + implementation Dependencies.google.androidxComposeUi implementation Dependencies.mavenCentral.ktorAndroid implementation Dependencies.mavenCentral.sqldelightAndroid diff --git a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt index 4997d902..8d906ef4 100644 --- a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt +++ b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt @@ -42,10 +42,15 @@ class SmallTalkApplication : Application(), ModuleProvider { val notificationsModule = featureModules.notificationsModule val storeModule = appModule.storeModule.value val eventLogStore = storeModule.eventLogStore() + val loggingStore = storeModule.loggingStore() val logger: (String, String) -> Unit = { tag, message -> Log.e(tag, message) - applicationScope.launch { eventLogStore.insert(tag, message) } + applicationScope.launch { + if (loggingStore.isEnabled()) { + eventLogStore.insert(tag, message) + } + } } attachAppLogger(logger) _appLogger = logger 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 4dc68901..a77fc820 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -17,43 +17,29 @@ import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.directory.DirectoryModule import app.dapk.st.domain.StoreModule +import app.dapk.st.engine.MatrixEngine import app.dapk.st.firebase.messaging.MessagingModule import app.dapk.st.home.HomeModule import app.dapk.st.home.MainActivity import app.dapk.st.imageloader.ImageLoaderModule import app.dapk.st.login.LoginModule -import app.dapk.st.matrix.MatrixClient import app.dapk.st.matrix.auth.DeviceDisplayNameGenerator -import app.dapk.st.matrix.auth.authService -import app.dapk.st.matrix.auth.installAuthService -import app.dapk.st.matrix.common.* -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.installEncryptionService -import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory -import app.dapk.st.matrix.message.* +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.JsonString +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.message.internal.ImageContentReader -import app.dapk.st.matrix.push.installPushService -import app.dapk.st.matrix.push.pushService -import app.dapk.st.matrix.room.* -import app.dapk.st.matrix.sync.* -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 import app.dapk.st.notifications.NotificationsModule -import app.dapk.st.olm.DeviceKeyFactory import app.dapk.st.olm.OlmPersistenceWrapper -import app.dapk.st.olm.OlmWrapper import app.dapk.st.profile.ProfileModule +import app.dapk.st.push.PushHandler import app.dapk.st.push.PushModule +import app.dapk.st.push.PushTokenPayload import app.dapk.st.push.messaging.MessagingServiceAdapter import app.dapk.st.settings.SettingsModule import app.dapk.st.share.ShareEntryModule @@ -62,8 +48,8 @@ import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.WorkModule import com.squareup.sqldelight.android.AndroidSqliteDriver import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.json.Json import java.io.InputStream -import java.time.Clock internal class AppModule(context: Application, logger: MatrixLogger) { @@ -77,9 +63,8 @@ internal class AppModule(context: Application, logger: MatrixLogger) { private val driver = AndroidSqliteDriver(DapkDb.Schema, context, "dapk.db") private val database = DapkDb(driver) - private val clock = Clock.systemUTC() val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO) - val base64 = AndroidBase64() + private val base64 = AndroidBase64() val storeModule = unsafeLazy { StoreModule( @@ -95,9 +80,10 @@ internal class AppModule(context: Application, logger: MatrixLogger) { private val imageLoaderModule = ImageLoaderModule(context) private val imageContentReader by unsafeLazy { AndroidImageContentReader(context.contentResolver) } - private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, imageContentReader, base64, buildMeta) + private val chatEngineModule = + ChatEngineModule(storeModule, trackingModule, workModule, logger, coroutineDispatchers, imageContentReader, base64, buildMeta) - val domainModules = DomainModules(matrixModules, trackingModule.errorTracker, workModule, storeModule, context, coroutineDispatchers) + val domainModules = DomainModules(chatEngineModule, trackingModule.errorTracker, context, coroutineDispatchers) val coreAndroidModule = CoreAndroidModule( intentFactory = object : IntentFactory { @@ -126,93 +112,76 @@ internal class AppModule(context: Application, logger: MatrixLogger) { attachments ) }, - unsafeLazy { storeModule.value.preferences } + unsafeLazy { storeModule.value.cachingPreferences }, ) val featureModules = FeatureModules( storeModule, - matrixModules, + chatEngineModule, domainModules, trackingModule, coreAndroidModule, imageLoaderModule, - imageContentReader, context, buildMeta, deviceMeta, coroutineDispatchers, - clock, - base64, ) } internal class FeatureModules internal constructor( private val storeModule: Lazy, - private val matrixModules: MatrixModules, + private val chatEngineModule: ChatEngineModule, private val domainModules: DomainModules, private val trackingModule: TrackingModule, private val coreAndroidModule: CoreAndroidModule, imageLoaderModule: ImageLoaderModule, - imageContentReader: ImageContentReader, context: Context, buildMeta: BuildMeta, deviceMeta: DeviceMeta, coroutineDispatchers: CoroutineDispatchers, - clock: Clock, - base64: Base64, ) { val directoryModule by unsafeLazy { DirectoryModule( - syncService = matrixModules.sync, - messageService = matrixModules.message, context = context, - credentialsStore = storeModule.value.credentialsStore(), - roomStore = storeModule.value.roomStore(), - roomService = matrixModules.room, + chatEngine = chatEngineModule.engine, ) } val loginModule by unsafeLazy { LoginModule( - matrixModules.auth, + chatEngineModule.engine, domainModules.pushModule, - matrixModules.profile, trackingModule.errorTracker ) } val messengerModule by unsafeLazy { MessengerModule( - matrixModules.sync, - matrixModules.message, - matrixModules.room, - storeModule.value.credentialsStore(), - storeModule.value.roomStore(), - clock, + chatEngineModule.engine, context, - base64, - imageContentReader, + storeModule.value.messageStore(), ) } - val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile, matrixModules.sync, buildMeta) } + val homeModule by unsafeLazy { HomeModule(chatEngineModule.engine, storeModule.value, buildMeta) } val settingsModule by unsafeLazy { SettingsModule( + chatEngineModule.engine, storeModule.value, pushModule, - matrixModules.crypto, - matrixModules.sync, context.contentResolver, buildMeta, deviceMeta, coroutineDispatchers, coreAndroidModule.themeStore(), + storeModule.value.loggingStore(), + storeModule.value.messageStore(), ) } - val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync, matrixModules.room, trackingModule.errorTracker) } + val profileModule by unsafeLazy { ProfileModule(chatEngineModule.engine, trackingModule.errorTracker) } val notificationsModule by unsafeLazy { NotificationsModule( + chatEngineModule.engine, imageLoaderModule.iconLoader(), - storeModule.value.roomStore(), - storeModule.value.overviewStore(), context, intentFactory = coreAndroidModule.intentFactory(), dispatchers = coroutineDispatchers, @@ -221,7 +190,7 @@ internal class FeatureModules internal constructor( } val shareEntryModule by unsafeLazy { - ShareEntryModule(matrixModules.sync, matrixModules.room) + ShareEntryModule(chatEngineModule.engine) } val imageGalleryModule by unsafeLazy { @@ -238,7 +207,7 @@ internal class FeatureModules internal constructor( } -internal class MatrixModules( +internal class ChatEngineModule( private val storeModule: Lazy, private val trackingModule: TrackingModule, private val workModule: WorkModule, @@ -249,232 +218,48 @@ internal class MatrixModules( private val buildMeta: BuildMeta, ) { - val matrix by unsafeLazy { + val engine by unsafeLazy { val store = storeModule.value - val credentialsStore = store.credentialsStore() - MatrixClient( - KtorMatrixHttpClientFactory( - credentialsStore, - includeLogging = buildMeta.isDebug, - ), - logger - ).also { - it.install { - installAuthService(credentialsStore, SmallTalkDeviceNameGenerator()) - installEncryptionService(store.knownDevicesStore()) - - val olmAccountStore = OlmPersistenceWrapper(store.olmStore(), base64) - val singletonFlows = SingletonFlows(coroutineDispatchers) - val olm = OlmWrapper( - olmStore = olmAccountStore, - singletonFlows = singletonFlows, - jsonCanonicalizer = JsonCanonicalizer(), - deviceKeyFactory = DeviceKeyFactory(JsonCanonicalizer()), - errorTracker = trackingModule.errorTracker, - logger = logger, - clock = Clock.systemUTC(), - coroutineDispatchers = coroutineDispatchers, - ) - installCryptoService( - credentialsStore, - olm, - roomMembersProvider = { services -> - RoomMembersProvider { - services.roomService().joinedMembers(it).map { it.userId } - } - }, - base64 = base64, - coroutineDispatchers = coroutineDispatchers, - ) - 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, - ) - } - }, - mediaEncrypter = { - val cryptoService = it.cryptoService() - MediaEncrypter { input -> - val result = cryptoService.encrypt(input) - MediaEncrypter.Result( - uri = result.uri, - contentLength = result.contentLength, - 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( - storeModule.value.memberStore(), - roomMessenger = { - val messageService = it.messageService() - object : RoomMessenger { - override suspend fun enableEncryption(roomId: RoomId) { - messageService.sendEventMessage( - roomId, MessageService.EventMessage.Encryption( - algorithm = AlgorithmName("m.megolm.v1.aes-sha2") - ) - ) - } - } - }, - roomInviteRemover = { - overviewStore.removeInvites(listOf(it)) - } - ) - - installProfileService(storeModule.value.profileStore(), singletonFlows, credentialsStore) - - installSyncService( - credentialsStore, - overviewStore, - store.roomStore(), - store.syncStore(), - store.filterStore(), - deviceNotifier = { services -> - val encryption = services.deviceService() - val crypto = services.cryptoService() - DeviceNotifier { userIds, syncToken -> - encryption.updateStaleDevices(userIds) - crypto.updateOlmSession(userIds, syncToken) - } - }, - messageDecrypter = { serviceProvider -> - val cryptoService = serviceProvider.cryptoService() - MessageDecrypter { - cryptoService.decrypt(it) - } - }, - keySharer = { serviceProvider -> - val cryptoService = serviceProvider.cryptoService() - KeySharer { sharedRoomKeys -> - cryptoService.importRoomKeys(sharedRoomKeys) - } - }, - verificationHandler = { services -> - val cryptoService = services.cryptoService() - VerificationHandler { apiEvent -> - logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it") - cryptoService.onVerificationEvent( - when (apiEvent) { - is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested( - apiEvent.sender, - apiEvent.content.fromDevice, - apiEvent.content.transactionId, - apiEvent.content.methods, - apiEvent.content.timestampPosix, - ) - - is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( - apiEvent.sender, - apiEvent.content.fromDevice, - apiEvent.content.transactionId, - apiEvent.content.methods, - ) - - is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( - apiEvent.sender, - apiEvent.content.fromDevice, - apiEvent.content.method, - apiEvent.content.protocols, - apiEvent.content.hashes, - apiEvent.content.codes, - apiEvent.content.short, - apiEvent.content.transactionId, - ) - - is ApiToDeviceEvent.VerificationCancel -> TODO() - is ApiToDeviceEvent.VerificationAccept -> TODO() - is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( - apiEvent.sender, - apiEvent.content.transactionId, - apiEvent.content.key - ) - - is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( - apiEvent.sender, - apiEvent.content.transactionId, - apiEvent.content.keys, - apiEvent.content.mac, - ) - } - ) - } - }, - oneTimeKeyProducer = { services -> - val cryptoService = services.cryptoService() - MaybeCreateMoreKeys { - cryptoService.maybeCreateMoreKeys(it) - } - }, - roomMembersService = { services -> - val roomService = services.roomService() - object : RoomMembersService { - override suspend fun find(roomId: RoomId, userIds: List) = roomService.findMembers(roomId, userIds) - override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId) - override suspend fun insert(roomId: RoomId, members: List) = roomService.insertMembers(roomId, members) - } - }, - errorTracker = trackingModule.errorTracker, - coroutineDispatchers = coroutineDispatchers, - ) - - installPushService(credentialsStore) - } - } + MatrixEngine.Factory().create( + base64, + buildMeta, + logger, + SmallTalkDeviceNameGenerator(), + coroutineDispatchers, + trackingModule.errorTracker, + imageContentReader, + BackgroundWorkAdapter(workModule.workScheduler()), + store.memberStore(), + store.roomStore(), + store.profileStore(), + store.syncStore(), + store.overviewStore(), + store.filterStore(), + store.localEchoStore, + store.credentialsStore(), + store.knownDevicesStore(), + OlmPersistenceWrapper(store.olmStore(), base64), + ) } - val auth by unsafeLazy { matrix.authService() } - val push by unsafeLazy { matrix.pushService() } - val sync by unsafeLazy { matrix.syncService() } - val message by unsafeLazy { matrix.messageService() } - val room by unsafeLazy { matrix.roomService() } - val profile by unsafeLazy { matrix.profileService() } - val crypto by unsafeLazy { matrix.cryptoService() } } internal class DomainModules( - private val matrixModules: MatrixModules, + private val chatEngineModule: ChatEngineModule, private val errorTracker: ErrorTracker, - private val workModule: WorkModule, - private val storeModule: Lazy, private val context: Application, private val dispatchers: CoroutineDispatchers, ) { - val pushHandler by unsafeLazy { - val store = storeModule.value - MatrixPushHandler( - workScheduler = workModule.workScheduler(), - credentialsStore = store.credentialsStore(), - matrixModules.sync, - store.roomStore(), - ) + private val pushHandler by unsafeLazy { + val enginePushHandler = chatEngineModule.engine.pushHandler() + object : PushHandler { + override fun onNewToken(payload: PushTokenPayload) { + enginePushHandler.onNewToken(JsonString(Json.encodeToString(PushTokenPayload.serializer(), payload))) + } + + override fun onMessageReceived(eventId: EventId?, roomId: RoomId?) = enginePushHandler.onMessageReceived(eventId, roomId) + } } val messaging by unsafeLazy { MessagingModule(MessagingServiceAdapter(pushHandler), context) } @@ -489,7 +274,9 @@ internal class DomainModules( messaging.messaging, ) } - val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run, AppTaskRunner(matrixModules.push))) } + val taskRunnerModule by unsafeLazy { + TaskRunnerModule(TaskRunnerAdapter(chatEngineModule.engine, AppTaskRunner(chatEngineModule.engine))) + } } diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt b/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt index ca8bbf9c..c2c6890f 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt @@ -1,13 +1,13 @@ package app.dapk.st.graph -import app.dapk.st.matrix.push.PushService +import app.dapk.st.engine.ChatEngine import app.dapk.st.push.PushTokenPayload import app.dapk.st.work.TaskRunner import io.ktor.client.plugins.* import kotlinx.serialization.json.Json class AppTaskRunner( - private val pushService: PushService, + private val chatEngine: ChatEngine, ) { suspend fun run(workTask: TaskRunner.RunnableWorkTask): TaskRunner.TaskResult { @@ -15,7 +15,7 @@ class AppTaskRunner( "push_token" -> { runCatching { val payload = Json.decodeFromString(PushTokenPayload.serializer(), workTask.task.jsonPayload) - pushService.registerPush(payload.token, payload.gatewayUrl) + chatEngine.registerPushToken(payload.token, payload.gatewayUrl) }.fold( onSuccess = { TaskRunner.TaskResult.Success(workTask.source) }, onFailure = { diff --git a/app/src/main/kotlin/app/dapk/st/graph/BackgroundWorkAdapter.kt b/app/src/main/kotlin/app/dapk/st/graph/BackgroundWorkAdapter.kt index c35db37a..4e9de878 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/BackgroundWorkAdapter.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/BackgroundWorkAdapter.kt @@ -9,7 +9,7 @@ class BackgroundWorkAdapter(private val workScheduler: WorkScheduler) : Backgrou WorkScheduler.WorkTask( jobId = 1, type = task.type, - jsonPayload = task.jsonPayload, + jsonPayload = task.jsonPayload.value, ) ) } diff --git a/app/src/main/kotlin/app/dapk/st/graph/TaskRunnerAdapter.kt b/app/src/main/kotlin/app/dapk/st/graph/TaskRunnerAdapter.kt index 5f9f717a..e914fb44 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/TaskRunnerAdapter.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/TaskRunnerAdapter.kt @@ -1,10 +1,11 @@ package app.dapk.st.graph -import app.dapk.st.matrix.MatrixTaskRunner +import app.dapk.st.engine.ChatEngine +import app.dapk.st.engine.ChatEngineTask import app.dapk.st.work.TaskRunner class TaskRunnerAdapter( - private val matrixTaskRunner: suspend (MatrixTaskRunner.MatrixTask) -> MatrixTaskRunner.TaskResult, + private val chatEngine: ChatEngine, private val appTaskRunner: AppTaskRunner, ) : TaskRunner { @@ -12,11 +13,12 @@ class TaskRunnerAdapter( return tasks.map { when { it.task.type.startsWith("matrix") -> { - when (val result = matrixTaskRunner(MatrixTaskRunner.MatrixTask(it.task.type, it.task.jsonPayload))) { - is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(it.source, canRetry = result.canRetry) - MatrixTaskRunner.TaskResult.Success -> TaskRunner.TaskResult.Success(it.source) + when (val result = chatEngine.runTask(ChatEngineTask(it.task.type, it.task.jsonPayload))) { + is app.dapk.st.engine.TaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(it.source, canRetry = result.canRetry) + app.dapk.st.engine.TaskRunner.TaskResult.Success -> TaskRunner.TaskResult.Success(it.source) } } + else -> appTaskRunner.run(it) } } diff --git a/build.gradle b/build.gradle index 5ac21086..cc4e8352 100644 --- a/build.gradle +++ b/build.gradle @@ -131,7 +131,7 @@ ext.applyCrashlyticsIfRelease = { project -> 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 "org.jetbrains.kotlin:kotlin-test-junit:1.7.20" dependencies.testImplementation 'io.mockk:mockk:1.13.2' dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' diff --git a/chat-engine/build.gradle b/chat-engine/build.gradle new file mode 100644 index 00000000..beab4c8c --- /dev/null +++ b/chat-engine/build.gradle @@ -0,0 +1,13 @@ +plugins { + id 'kotlin' + id 'java-test-fixtures' +} + +dependencies { + api Dependencies.mavenCentral.kotlinCoroutinesCore + api project(":matrix:common") + + kotlinFixtures(it) + testFixturesImplementation(testFixtures(project(":matrix:common"))) + testFixturesImplementation(testFixtures(project(":core"))) +} \ No newline at end of file diff --git a/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt b/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt new file mode 100644 index 00000000..fd88d3d0 --- /dev/null +++ b/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt @@ -0,0 +1,82 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.JsonString +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import kotlinx.coroutines.flow.Flow +import java.io.InputStream + +interface ChatEngine : TaskRunner { + + fun directory(): Flow + fun invites(): Flow + fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow + + fun notificationsMessages(): Flow + fun notificationsInvites(): Flow + + suspend fun login(request: LoginRequest): LoginResult + + suspend fun me(forceRefresh: Boolean): Me + + suspend fun InputStream.importRoomKeys(password: String): Flow + + suspend fun send(message: SendMessage, room: RoomOverview) + + suspend fun registerPushToken(token: String, gatewayUrl: String) + + suspend fun joinRoom(roomId: RoomId) + + suspend fun rejectJoinRoom(roomId: RoomId) + + suspend fun findMembersSummary(roomId: RoomId): List + + fun mediaDecrypter(): MediaDecrypter + + fun pushHandler(): PushHandler + +} + +interface TaskRunner { + + suspend fun runTask(task: ChatEngineTask): TaskResult + + sealed interface TaskResult { + object Success : TaskResult + data class Failure(val canRetry: Boolean) : TaskResult + } + +} + + +data class ChatEngineTask(val type: String, val jsonPayload: String) + +interface MediaDecrypter { + + fun decrypt(input: InputStream, k: String, iv: String): Collector + + fun interface Collector { + fun collect(partial: (ByteArray) -> Unit) + } + +} + +interface PushHandler { + fun onNewToken(payload: JsonString) + fun onMessageReceived(eventId: EventId?, roomId: RoomId?) +} + +typealias UnreadNotifications = Pair>, NotificationDiff> + +data class NotificationDiff( + val unchanged: Map>, + val changedOrNew: Map>, + val removed: Map>, + val newRooms: Set +) + +data class InviteNotification( + val content: String, + val roomId: RoomId +) \ No newline at end of file diff --git a/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt b/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt new file mode 100644 index 00000000..b823b03c --- /dev/null +++ b/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt @@ -0,0 +1,234 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.common.* +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +typealias DirectoryState = List +typealias OverviewState = List +typealias InviteState = List + +data class DirectoryItem( + val overview: RoomOverview, + val unreadCount: UnreadCount, + val typing: Typing? +) + +data class RoomOverview( + val roomId: RoomId, + val roomCreationUtc: Long, + val roomName: String?, + val roomAvatarUrl: AvatarUrl?, + val lastMessage: LastMessage?, + val isGroup: Boolean, + val readMarker: EventId?, + val isEncrypted: Boolean, +) { + + data class LastMessage( + val content: String, + val utcTimestamp: Long, + val author: RoomMember, + ) + +} + +data class RoomInvite( + val from: RoomMember, + val roomId: RoomId, + val inviteMeta: InviteMeta, +) { + sealed class InviteMeta { + object DirectMessage : InviteMeta() + data class Room(val roomName: String? = null) : InviteMeta() + } + +} + +@JvmInline +value class UnreadCount(val value: Int) + +data class Typing(val roomId: RoomId, val members: List) + +data class LoginRequest(val userName: String, val password: String, val serverUrl: String?) + +sealed interface LoginResult { + data class Success(val userCredentials: UserCredentials) : LoginResult + object MissingWellKnown : LoginResult + data class Error(val cause: Throwable) : LoginResult +} + +data class Me( + val userId: UserId, + val displayName: String?, + val avatarUrl: AvatarUrl?, + val homeServerUrl: HomeServerUrl, +) + +sealed interface ImportResult { + data class Success(val roomIds: Set, val totalImportedKeysCount: Long) : 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 + object InvalidFile : Type + } + + } + + data class Update(val importedKeysCount: Long) : ImportResult +} + +data class MessengerState( + val self: UserId, + val roomState: RoomState, + val typing: Typing? +) + +data class RoomState( + val roomOverview: RoomOverview, + val events: List, +) + +internal val DEFAULT_ZONE = ZoneId.systemDefault() +internal val MESSAGE_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm") + +sealed class RoomEvent { + + abstract val eventId: EventId + abstract val utcTimestamp: Long + abstract val author: RoomMember + abstract val meta: MessageMeta + + data class Encrypted( + override val eventId: EventId, + override val utcTimestamp: Long, + override val author: RoomMember, + override val meta: MessageMeta, + ) : RoomEvent() { + + val time: String by lazy(mode = LazyThreadSafetyMode.NONE) { + val instant = Instant.ofEpochMilli(utcTimestamp) + ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) + } + } + + data class Message( + override val eventId: EventId, + override val utcTimestamp: Long, + val content: String, + override val author: RoomMember, + override val meta: MessageMeta, + val edited: Boolean = false, + val redacted: Boolean = false, + ) : RoomEvent() { + + val time: String by lazy(mode = LazyThreadSafetyMode.NONE) { + val instant = Instant.ofEpochMilli(utcTimestamp) + ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) + } + } + + data class Reply( + val message: RoomEvent, + val replyingTo: RoomEvent, + ) : RoomEvent() { + + override val eventId: EventId = message.eventId + override val utcTimestamp: Long = message.utcTimestamp + override val author: RoomMember = message.author + override val meta: MessageMeta = message.meta + + val replyingToSelf = replyingTo.author == message.author + + val time: String by lazy(mode = LazyThreadSafetyMode.NONE) { + val instant = Instant.ofEpochMilli(utcTimestamp) + ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) + } + } + + data class Image( + override val eventId: EventId, + override val utcTimestamp: Long, + val imageMeta: ImageMeta, + override val author: RoomMember, + override val meta: MessageMeta, + val edited: Boolean = false, + ) : RoomEvent() { + + val time: String by lazy(mode = LazyThreadSafetyMode.NONE) { + val instant = Instant.ofEpochMilli(utcTimestamp) + ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) + } + + data class ImageMeta( + val width: Int?, + val height: Int?, + val url: String, + val keys: Keys?, + ) { + + data class Keys( + val k: String, + val iv: String, + val v: String, + val hashes: Map, + ) + + } + } + +} + +sealed class MessageMeta { + + object FromServer : MessageMeta() + + data class LocalEcho( + val echoId: String, + val state: State + ) : MessageMeta() { + + sealed class State { + object Sending : State() + + object Sent : State() + + data class Error( + val message: String, + val type: Type, + ) : State() { + + enum class Type { + UNKNOWN + } + } + } + } +} + +sealed interface SendMessage { + + data class TextMessage( + val content: String, + val reply: Reply? = null, + ) : SendMessage { + + data class Reply( + val author: RoomMember, + val originalMessage: String, + val eventId: EventId, + val timestampUtc: Long, + ) + } + + data class ImageMessage( + val uri: String, + ) : SendMessage + +} \ No newline at end of file diff --git a/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt b/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt new file mode 100644 index 00000000..bf5ff6fd --- /dev/null +++ b/chat-engine/src/testFixtures/kotlin/fake/FakeChatEngine.kt @@ -0,0 +1,24 @@ +package fake + +import app.dapk.st.engine.ChatEngine +import app.dapk.st.matrix.common.RoomId +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import test.delegateEmit +import test.delegateReturn +import java.io.InputStream + +class FakeChatEngine : ChatEngine by mockk() { + + fun givenMessages(roomId: RoomId, disableReadReceipts: Boolean) = every { messages(roomId, disableReadReceipts) }.delegateReturn() + + fun givenDirectory() = every { directory() }.delegateReturn() + + fun givenImportKeys(inputStream: InputStream, passphrase: String) = coEvery { inputStream.importRoomKeys(passphrase) }.delegateReturn() + + fun givenNotificationsInvites() = every { notificationsInvites() }.delegateEmit() + + fun givenNotificationsMessages() = every { notificationsMessages() }.delegateEmit() + +} \ No newline at end of file diff --git a/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt b/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt new file mode 100644 index 00000000..68f252b1 --- /dev/null +++ b/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt @@ -0,0 +1,66 @@ +package fixture + +import app.dapk.st.engine.* +import app.dapk.st.matrix.common.* + +fun aMessengerState( + self: UserId = aUserId(), + roomState: RoomState, + typing: Typing? = null +) = MessengerState(self, roomState, typing) + +fun aRoomOverview( + roomId: RoomId = aRoomId(), + roomCreationUtc: Long = 0L, + roomName: String? = null, + roomAvatarUrl: AvatarUrl? = null, + lastMessage: RoomOverview.LastMessage? = null, + isGroup: Boolean = false, + readMarker: EventId? = null, + isEncrypted: Boolean = false, +) = RoomOverview(roomId, roomCreationUtc, roomName, roomAvatarUrl, lastMessage, isGroup, readMarker, isEncrypted) + +fun anEncryptedRoomMessageEvent( + eventId: EventId = anEventId(), + utcTimestamp: Long = 0L, + content: String = "encrypted-content", + author: RoomMember = aRoomMember(), + meta: MessageMeta = MessageMeta.FromServer, + edited: Boolean = false, + redacted: Boolean = false, +) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, edited, redacted) + +fun aRoomImageMessageEvent( + eventId: EventId = anEventId(), + utcTimestamp: Long = 0L, + content: RoomEvent.Image.ImageMeta = anImageMeta(), + author: RoomMember = aRoomMember(), + meta: MessageMeta = MessageMeta.FromServer, + edited: Boolean = false, +) = RoomEvent.Image(eventId, utcTimestamp, content, author, meta, edited) + +fun aRoomReplyMessageEvent( + message: RoomEvent = aRoomMessageEvent(), + replyingTo: RoomEvent = aRoomMessageEvent(eventId = anEventId("in-reply-to-id")), +) = RoomEvent.Reply(message, replyingTo) + +fun aRoomMessageEvent( + eventId: EventId = anEventId(), + utcTimestamp: Long = 0L, + content: String = "message-content", + author: RoomMember = aRoomMember(), + meta: MessageMeta = MessageMeta.FromServer, + edited: Boolean = false, +) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, edited) + +fun anImageMeta( + width: Int? = 100, + height: Int? = 100, + url: String = "https://a-url.com", + keys: RoomEvent.Image.ImageMeta.Keys? = null +) = RoomEvent.Image.ImageMeta(width, height, url, keys) + +fun aRoomState( + roomOverview: RoomOverview = aRoomOverview(), + events: List = listOf(aRoomMessageEvent()), +) = RoomState(roomOverview, events) \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fixture/NotificationDiffFixtures.kt b/chat-engine/src/testFixtures/kotlin/fixture/NotificationDiffFixtures.kt similarity index 90% rename from features/notifications/src/test/kotlin/fixture/NotificationDiffFixtures.kt rename to chat-engine/src/testFixtures/kotlin/fixture/NotificationDiffFixtures.kt index 7b9f0e99..bd507235 100644 --- a/features/notifications/src/test/kotlin/fixture/NotificationDiffFixtures.kt +++ b/chat-engine/src/testFixtures/kotlin/fixture/NotificationDiffFixtures.kt @@ -2,7 +2,7 @@ package fixture import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId -import app.dapk.st.notifications.NotificationDiff +import app.dapk.st.engine.NotificationDiff object NotificationDiffFixtures { diff --git a/core/src/main/kotlin/app/dapk/st/core/Preferences.kt b/core/src/main/kotlin/app/dapk/st/core/Preferences.kt index 4d1cc3f7..bac20a12 100644 --- a/core/src/main/kotlin/app/dapk/st/core/Preferences.kt +++ b/core/src/main/kotlin/app/dapk/st/core/Preferences.kt @@ -8,5 +8,13 @@ interface Preferences { suspend fun remove(key: String) } +interface CachedPreferences : Preferences { + suspend fun readString(key: String, defaultValue: String): String +} + +suspend fun CachedPreferences.readBoolean(key: String, defaultValue: Boolean) = this + .readString(key, defaultValue.toString()) + .toBooleanStrict() + suspend fun Preferences.readBoolean(key: String) = this.readString(key)?.toBooleanStrict() suspend fun Preferences.store(key: String, value: Boolean) = this.store(key, value.toString()) \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt index 92be2fbd..967c5b54 100644 --- a/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt @@ -1,6 +1,8 @@ package app.dapk.st.core.extensions -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.takeWhile suspend fun Flow.firstOrNull(count: Int, predicate: suspend (T) -> Boolean): T? { var counter = 0 @@ -18,5 +20,3 @@ suspend fun Flow.firstOrNull(count: Int, predicate: suspend (T) -> Boolea return result } - -fun Flow.startAndIgnoreEmissions(): Flow = this.map { false }.onStart { emit(true) }.filter { it } \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt index 1c947a75..fee8de74 100644 --- a/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/GlobalExtensions.kt @@ -3,6 +3,13 @@ package app.dapk.st.core.extensions inline fun T?.ifNull(block: () -> T): T = this ?: block() inline fun ifOrNull(condition: Boolean, block: () -> T): T? = if (condition) block() else null +inline fun Any.takeAs(): T? { + return when (this) { + is T -> this + else -> null + } +} + @Suppress("UNCHECKED_CAST") inline fun Iterable.firstOrNull(predicate: (T) -> Boolean, predicate2: (T) -> Boolean): Pair? { var firstValue: T1? = null diff --git a/domains/android/viewmodel/src/testFixtures/kotlin/FlowTestObserver.kt b/core/src/testFixtures/kotlin/test/FlowTestObserver.kt similarity index 97% rename from domains/android/viewmodel/src/testFixtures/kotlin/FlowTestObserver.kt rename to core/src/testFixtures/kotlin/test/FlowTestObserver.kt index 657f258f..3ab82cf8 100644 --- a/domains/android/viewmodel/src/testFixtures/kotlin/FlowTestObserver.kt +++ b/core/src/testFixtures/kotlin/test/FlowTestObserver.kt @@ -1,3 +1,5 @@ +package test + import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow diff --git a/dependencies.gradle b/dependencies.gradle index ec82c32d..183c92f9 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -97,8 +97,8 @@ ext.Dependencies.with { } } - def kotlinVer = "1.7.10" - def sqldelightVer = "1.5.3" + def kotlinVer = "1.7.20" + def sqldelightVer = "1.5.4" def composeVer = "1.2.1" def ktorVer = "2.1.2" @@ -111,7 +111,7 @@ ext.Dependencies.with { androidxComposeMaterial = "androidx.compose.material3:material3:1.0.0-beta03" androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}" androidxActivityCompose = "androidx.activity:activity-compose:1.4.0" - kotlinCompilerExtensionVersion = "1.3.1" + kotlinCompilerExtensionVersion = "1.3.2" firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.1" jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5" @@ -121,7 +121,7 @@ ext.Dependencies.with { mavenCentral.with { kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVer}" kotlinSerializationGradlePlugin = "org.jetbrains.kotlin:kotlin-serialization:${kotlinVer}" - kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0" + kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" kotlinCoroutinesTest = 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' kotlinTest = "org.jetbrains.kotlin:kotlin-test-junit:${kotlinVer}" @@ -144,7 +144,7 @@ ext.Dependencies.with { accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.25.1" junit = "junit:junit:4.13.2" - kluent = "org.amshove.kluent:kluent:1.68" + kluent = "org.amshove.kluent:kluent:1.70" mockk = 'io.mockk:mockk:1.13.2' matrixOlm = "org.matrix.android:olm-sdk:3.2.12" diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Error.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Error.kt index 722ff2c0..ef06b5f4 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Error.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Error.kt @@ -1,21 +1,46 @@ package app.dapk.st.design.components -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp @Composable -fun GenericError(retryAction: () -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { +fun GenericError(message: String = "Something went wrong...", label: String = "Retry", cause: Throwable? = null, action: () -> Unit) { + val moreDetails = cause?.let { "${it::class.java.simpleName}: ${it.message}" } + + val openDetailsDialog = remember { mutableStateOf(false) } + if (openDetailsDialog.value) { + AlertDialog( + onDismissRequest = { openDetailsDialog.value = false }, + confirmButton = { + Button(onClick = { openDetailsDialog.value = false }) { + Text("OK") + } + }, + title = { Text("Details") }, + text = { + Text(moreDetails!!) + } + ) + } + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Something went wrong...") - Button(onClick = { retryAction() }) { - Text("Retry") + Text(message) + if (moreDetails != null) { + Text("Tap for more details".uppercase(), fontSize = 12.sp, modifier = Modifier.clickable { openDetailsDialog.value = true }.padding(12.dp)) + } + Spacer(modifier = Modifier.height(12.dp)) + Button(onClick = { action() }) { + Text(label.uppercase()) } } } diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt index 826694b4..55b8a12a 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt @@ -10,22 +10,27 @@ fun Spider(currentPage: SpiderPage, onNavigate: (SpiderPage? val pageCache = remember { mutableMapOf, SpiderPage>() } pageCache[currentPage.route] = currentPage + val navigateAndPopStack = { + pageCache.remove(currentPage.route) + onNavigate(pageCache[currentPage.parent]) + } + val itemScope = object : SpiderItemScope { + override fun goBack() { + navigateAndPopStack() + } + } + val computedWeb = remember(true) { mutableMapOf, @Composable (T) -> Unit>().also { computedWeb -> val scope = object : SpiderScope { - override fun item(route: Route, content: @Composable (T) -> Unit) { - computedWeb[route] = { content(it as T) } + override fun item(route: Route, content: @Composable SpiderItemScope.(T) -> Unit) { + computedWeb[route] = { content(itemScope, it as T) } } } graph.invoke(scope) } } - val navigateAndPopStack = { - pageCache.remove(currentPage.route) - onNavigate(pageCache[currentPage.parent]) - } - Column { if (currentPage.hasToolbar) { Toolbar( @@ -40,7 +45,11 @@ fun Spider(currentPage: SpiderPage, onNavigate: (SpiderPage? interface SpiderScope { - fun item(route: Route, content: @Composable (T) -> Unit) + fun item(route: Route, content: @Composable SpiderItemScope.(T) -> Unit) +} + +interface SpiderItemScope { + fun goBack() } data class SpiderPage( diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt index afe21695..15384dad 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt @@ -2,37 +2,49 @@ package app.dapk.st.design.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.material3.Divider -import androidx.compose.material3.Icon -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable -fun TextRow(title: String, content: String? = null, includeDivider: Boolean = true, onClick: (() -> Unit)? = null, body: @Composable () -> Unit = {}) { +fun TextRow( + title: String, + content: String? = null, + includeDivider: Boolean = true, + onClick: (() -> Unit)? = null, + enabled: Boolean = true, + body: @Composable () -> Unit = {} +) { + val verticalPadding = 24.dp val modifier = Modifier.padding(horizontal = 24.dp) Column( Modifier .fillMaxWidth() .clickable(enabled = onClick != null) { onClick?.invoke() }) { - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(verticalPadding)) Column(modifier) { + val textModifier = when (enabled) { + true -> Modifier + false -> Modifier.alpha(0.5f) + } when (content) { null -> { - Text(text = title, fontSize = 18.sp) + Text(text = title, fontSize = 18.sp, modifier = textModifier) } + else -> { - Text(text = title, fontSize = 12.sp) + Text(text = title, fontSize = 12.sp, modifier = textModifier) Spacer(modifier = Modifier.height(2.dp)) - Text(text = content, fontSize = 18.sp) + Text(text = content, fontSize = 18.sp, modifier = textModifier) } } body() - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(verticalPadding)) } if (includeDivider) { Divider(modifier = Modifier.fillMaxWidth()) @@ -56,6 +68,39 @@ fun IconRow(icon: ImageVector, title: String, onClick: (() -> Unit)? = null) { } @Composable -fun SettingsTextRow(title: String, subtitle: String?, onClick: (() -> Unit)?) { - TextRow(title = title, subtitle, includeDivider = false, onClick) +fun SettingsTextRow(title: String, subtitle: String?, onClick: (() -> Unit)?, enabled: Boolean) { + TextRow(title = title, subtitle, includeDivider = false, onClick, enabled = enabled) +} + +@Composable +fun SettingsToggleRow(title: String, subtitle: String?, state: Boolean, onToggle: () -> Unit) { + Toggle(title, subtitle, state, onToggle) } + +@Composable +private fun Toggle(title: String, subtitle: String?, state: Boolean, onToggle: () -> Unit) { + val verticalPadding = 16.dp + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = verticalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (subtitle == null) { + Text(text = title) + } else { + Column(modifier = Modifier.weight(1f)) { + Text(text = title) + Spacer(Modifier.height(4.dp)) + Text(text = subtitle, fontSize = 12.sp, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)) + } + } + Switch( + modifier = Modifier.wrapContentWidth(), + checked = state, + onCheckedChange = { onToggle() } + ) + } +} + diff --git a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt index b84e2c7e..f9bc464c 100644 --- a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt +++ b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/CoreAndroidModule.kt @@ -1,17 +1,14 @@ package app.dapk.st.core -import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.navigator.IntentFactory class CoreAndroidModule( private val intentFactory: IntentFactory, - private val preferences: Lazy, + private val preferences: Lazy, ) : ProvidableModule { fun intentFactory() = intentFactory - private val themeStore by unsafeLazy { ThemeStore(preferences.value) } - - fun themeStore() = themeStore + fun themeStore() = ThemeStore(preferences.value) } \ No newline at end of file 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 fbcf0ed0..4c75c4f7 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 @@ -8,10 +8,13 @@ import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.lifecycle.lifecycleScope 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.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import androidx.activity.compose.setContent as _setContent @@ -29,7 +32,7 @@ abstract class DapkActivity : ComponentActivity(), EffectScope { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - this.themeConfig = ThemeConfig(themeStore.isMaterialYouEnabled()) + this.themeConfig = runBlocking { ThemeConfig(themeStore.isMaterialYouEnabled()) } window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); @@ -45,8 +48,10 @@ abstract class DapkActivity : ComponentActivity(), EffectScope { override fun onResume() { super.onResume() - if (themeConfig.useDynamicTheme != themeStore.isMaterialYouEnabled()) { - recreate() + lifecycleScope.launch { + if (themeConfig.useDynamicTheme != themeStore.isMaterialYouEnabled()) { + recreate() + } } } @@ -70,6 +75,16 @@ abstract class DapkActivity : ComponentActivity(), EffectScope { } } + protected fun registerForPermission(permission: String, callback: () -> Unit = {}): () -> Unit { + val resultCallback: (result: Boolean) -> Unit = { result -> + if (result) { + callback() + } + } + val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission(), resultCallback) + return { launcher.launch(permission) } + } + protected suspend fun ensurePermission(permission: String): PermissionResult { return when { checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED -> PermissionResult.Granted diff --git a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ThemeStore.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ThemeStore.kt index ff3d2f55..687a0728 100644 --- a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ThemeStore.kt +++ b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ThemeStore.kt @@ -1,26 +1,15 @@ package app.dapk.st.core -import kotlinx.coroutines.runBlocking - private const val KEY_MATERIAL_YOU_ENABLED = "material_you_enabled" class ThemeStore( - private val preferences: Preferences + private val preferences: CachedPreferences ) { - private var _isMaterialYouEnabled: Boolean? = null - - fun isMaterialYouEnabled() = _isMaterialYouEnabled ?: blockingInitialRead() - - private fun blockingInitialRead(): Boolean { - return runBlocking { - (preferences.readBoolean(KEY_MATERIAL_YOU_ENABLED) ?: false).also { _isMaterialYouEnabled = it } - } - } + suspend fun isMaterialYouEnabled() = preferences.readBoolean(KEY_MATERIAL_YOU_ENABLED, defaultValue = false) suspend fun storeMaterialYouEnabled(isEnabled: Boolean) { - _isMaterialYouEnabled = isEnabled preferences.store(KEY_MATERIAL_YOU_ENABLED, isEnabled) } -} \ No newline at end of file +} diff --git a/domains/android/push/build.gradle b/domains/android/push/build.gradle index deafddc2..0e2d1762 100644 --- a/domains/android/push/build.gradle +++ b/domains/android/push/build.gradle @@ -5,7 +5,6 @@ dependencies { implementation project(':core') implementation project(':domains:android:core') implementation project(':domains:store') - implementation project(':matrix:services:push') firebase(it, "messaging") diff --git a/domains/android/viewmodel/src/testFixtures/kotlin/ViewModelTestScopeImpl.kt b/domains/android/viewmodel/src/testFixtures/kotlin/ViewModelTestScopeImpl.kt index 83c2e24f..d2f0b0f5 100644 --- a/domains/android/viewmodel/src/testFixtures/kotlin/ViewModelTestScopeImpl.kt +++ b/domains/android/viewmodel/src/testFixtures/kotlin/ViewModelTestScopeImpl.kt @@ -3,6 +3,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import org.amshove.kluent.internal.assertEquals import test.ExpectTestScope +import test.FlowTestObserver @Suppress("UNCHECKED_CAST") internal class ViewModelTestScopeImpl( diff --git a/domains/store/build.gradle b/domains/store/build.gradle index 7b6db9ad..90b79fae 100644 --- a/domains/store/build.gradle +++ b/domains/store/build.gradle @@ -22,7 +22,8 @@ dependencies { implementation project(":core") implementation Dependencies.mavenCentral.kotlinSerializationJson implementation Dependencies.mavenCentral.kotlinCoroutinesCore - implementation "com.squareup.sqldelight:coroutines-extensions:1.5.3" + implementation "com.squareup.sqldelight:coroutines-extensions:1.5.4" kotlinFixtures(it) -} \ No newline at end of file + testImplementation(testFixtures(project(":core"))) + testFixturesImplementation(testFixtures(project(":core")))} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/OlmPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/OlmPersistence.kt index c01d5355..df3a63c1 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/OlmPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/OlmPersistence.kt @@ -14,6 +14,8 @@ import com.squareup.sqldelight.TransactionWithoutReturn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine class OlmPersistence( private val database: DapkDb, @@ -83,10 +85,12 @@ class OlmPersistence( } suspend fun startTransaction(action: suspend TransactionWithoutReturn.() -> Unit) { - val scope = CoroutineScope(dispatchers.io) - database.cryptoQueries.transaction { - scope.launch { action() } + val transaction = suspendCoroutine { continuation -> + database.cryptoQueries.transaction { + continuation.resume(this) + } } + action(transaction) } suspend fun persist(sessionId: SessionId, inboundGroupSession: SerializedObject) { diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt index 874b8f57..aa8b1ef3 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt @@ -5,8 +5,12 @@ import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.Preferences import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.unsafeLazy -import app.dapk.st.domain.eventlog.EventLogPersistence +import app.dapk.st.domain.application.eventlog.EventLogPersistence +import app.dapk.st.domain.application.eventlog.LoggingStore +import app.dapk.st.domain.application.message.MessageOptionsStore import app.dapk.st.domain.localecho.LocalEchoPersistence +import app.dapk.st.domain.preference.CachingPreferences +import app.dapk.st.domain.preference.PropertyCache import app.dapk.st.domain.profile.ProfilePersistence import app.dapk.st.domain.push.PushTokenRegistrarPreferences import app.dapk.st.domain.sync.OverviewPersistence @@ -36,6 +40,9 @@ class StoreModule( fun filterStore(): FilterStore = FilterPreferences(preferences) val localEchoStore: LocalEchoStore by unsafeLazy { LocalEchoPersistence(errorTracker, database) } + private val cache = PropertyCache() + val cachingPreferences = CachingPreferences(cache, preferences) + fun pushStore() = PushTokenRegistrarPreferences(preferences) fun applicationStore() = ApplicationPreferences(preferences) @@ -57,7 +64,12 @@ class StoreModule( return EventLogPersistence(database, coroutineDispatchers) } + fun loggingStore(): LoggingStore = LoggingStore(cachingPreferences) + + fun messageStore(): MessageOptionsStore = MessageOptionsStore(cachingPreferences) + fun memberStore(): MemberStore { return MemberPersistence(database, coroutineDispatchers) } + } diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/eventlog/EventLogPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/application/eventlog/EventLogPersistence.kt similarity index 97% rename from domains/store/src/main/kotlin/app/dapk/st/domain/eventlog/EventLogPersistence.kt rename to domains/store/src/main/kotlin/app/dapk/st/domain/application/eventlog/EventLogPersistence.kt index ff65dcaf..718e34ad 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/eventlog/EventLogPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/application/eventlog/EventLogPersistence.kt @@ -1,8 +1,8 @@ -package app.dapk.st.domain.eventlog +package app.dapk.st.domain.application.eventlog +import app.dapk.db.DapkDb import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.withIoContext -import app.dapk.db.DapkDb import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.mapToList import kotlinx.coroutines.flow.Flow @@ -42,6 +42,7 @@ class EventLogPersistence( ) } } + else -> database.eventLoggerQueries.selectLatestByLogFiltered(logKey, filter) .asFlow() .mapToList(context = coroutineDispatchers.io) diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/application/eventlog/LoggingStore.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/application/eventlog/LoggingStore.kt new file mode 100644 index 00000000..a4b4f22c --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/application/eventlog/LoggingStore.kt @@ -0,0 +1,17 @@ +package app.dapk.st.domain.application.eventlog + +import app.dapk.st.core.CachedPreferences +import app.dapk.st.core.readBoolean +import app.dapk.st.core.store + +private const val KEY_LOGGING_ENABLED = "key_logging_enabled" + +class LoggingStore(private val cachedPreferences: CachedPreferences) { + + suspend fun isEnabled() = cachedPreferences.readBoolean(KEY_LOGGING_ENABLED, defaultValue = false) + + suspend fun setEnabled(isEnabled: Boolean) { + cachedPreferences.store(KEY_LOGGING_ENABLED, isEnabled) + } + +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/application/message/MessageOptionsStore.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/application/message/MessageOptionsStore.kt new file mode 100644 index 00000000..bf4584d9 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/application/message/MessageOptionsStore.kt @@ -0,0 +1,17 @@ +package app.dapk.st.domain.application.message + +import app.dapk.st.core.CachedPreferences +import app.dapk.st.core.readBoolean +import app.dapk.st.core.store + +private const val KEY_READ_RECEIPTS_DISABLED = "key_read_receipts_disabled" + +class MessageOptionsStore(private val cachedPreferences: CachedPreferences) { + + suspend fun isReadReceiptsDisabled() = cachedPreferences.readBoolean(KEY_READ_RECEIPTS_DISABLED, defaultValue = true) + + suspend fun setReadReceiptsDisabled(isDisabled: Boolean) { + cachedPreferences.store(KEY_READ_RECEIPTS_DISABLED, isDisabled) + } + +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/preference/CachingPreferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/preference/CachingPreferences.kt new file mode 100644 index 00000000..1d891b41 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/preference/CachingPreferences.kt @@ -0,0 +1,29 @@ +package app.dapk.st.domain.preference + +import app.dapk.st.core.CachedPreferences +import app.dapk.st.core.Preferences + +class CachingPreferences(private val cache: PropertyCache, private val preferences: Preferences) : CachedPreferences { + override suspend fun store(key: String, value: String) { + cache.setValue(key, value) + preferences.store(key, value) + } + + override suspend fun readString(key: String): String? { + return cache.getValue(key) ?: preferences.readString(key)?.also { + cache.setValue(key, it) + } + } + + override suspend fun readString(key: String, defaultValue: String): String { + return readString(key) ?: (defaultValue.also { cache.setValue(key, it) }) + } + + override suspend fun remove(key: String) { + preferences.remove(key) + } + + override suspend fun clear() { + preferences.clear() + } +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/preference/PropertyCache.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/preference/PropertyCache.kt new file mode 100644 index 00000000..41adfda0 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/preference/PropertyCache.kt @@ -0,0 +1,16 @@ +package app.dapk.st.domain.preference + +@Suppress("UNCHECKED_CAST") +class PropertyCache { + + private val map = mutableMapOf() + + fun getValue(key: String): T? { + return map[key] as? T? + } + + fun setValue(key: String, value: Any) { + map[key] = value + } + +} \ No newline at end of file diff --git a/domains/store/src/testFixtures/kotlin/fake/FakeLoggingStore.kt b/domains/store/src/testFixtures/kotlin/fake/FakeLoggingStore.kt new file mode 100644 index 00000000..78cc176a --- /dev/null +++ b/domains/store/src/testFixtures/kotlin/fake/FakeLoggingStore.kt @@ -0,0 +1,12 @@ +package fake + +import app.dapk.st.domain.application.eventlog.LoggingStore +import io.mockk.coEvery +import io.mockk.mockk +import test.delegateReturn + +class FakeLoggingStore { + val instance = mockk() + + fun givenLoggingIsEnabled() = coEvery { instance.isEnabled() }.delegateReturn() +} diff --git a/domains/store/src/testFixtures/kotlin/fake/FakeMessageOptionsStore.kt b/domains/store/src/testFixtures/kotlin/fake/FakeMessageOptionsStore.kt new file mode 100644 index 00000000..d9ad43ec --- /dev/null +++ b/domains/store/src/testFixtures/kotlin/fake/FakeMessageOptionsStore.kt @@ -0,0 +1,12 @@ +package fake + +import app.dapk.st.domain.application.message.MessageOptionsStore +import io.mockk.coEvery +import io.mockk.mockk +import test.delegateReturn + +class FakeMessageOptionsStore { + val instance = mockk() + + fun givenReadReceiptsDisabled() = coEvery { instance.isReadReceiptsDisabled() }.delegateReturn() +} \ No newline at end of file diff --git a/domains/store/src/testFixtures/kotlin/fixture/FakeStoreCleaner.kt b/domains/store/src/testFixtures/kotlin/fake/FakeStoreCleaner.kt similarity index 87% rename from domains/store/src/testFixtures/kotlin/fixture/FakeStoreCleaner.kt rename to domains/store/src/testFixtures/kotlin/fake/FakeStoreCleaner.kt index 19ac519b..ea2c635f 100644 --- a/domains/store/src/testFixtures/kotlin/fixture/FakeStoreCleaner.kt +++ b/domains/store/src/testFixtures/kotlin/fake/FakeStoreCleaner.kt @@ -1,4 +1,4 @@ -package fixture +package fake import app.dapk.st.domain.StoreCleaner import io.mockk.mockk diff --git a/features/directory/build.gradle b/features/directory/build.gradle index b0058814..67b48070 100644 --- a/features/directory/build.gradle +++ b/features/directory/build.gradle @@ -1,9 +1,7 @@ applyAndroidComposeLibraryModule(project) dependencies { - implementation project(":matrix:services:sync") - implementation project(":matrix:services:message") - implementation project(":matrix:services:room") + implementation project(":chat-engine") implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") implementation project(":features:messenger") @@ -13,11 +11,10 @@ dependencies { kotlinTest(it) - androidImportFixturesWorkaround(project, project(":matrix:services:sync")) - androidImportFixturesWorkaround(project, project(":matrix:services:message")) androidImportFixturesWorkaround(project, project(":matrix:common")) androidImportFixturesWorkaround(project, project(":core")) androidImportFixturesWorkaround(project, project(":domains:store")) androidImportFixturesWorkaround(project, project(":domains:android:viewmodel")) androidImportFixturesWorkaround(project, project(":domains:android:stub")) + androidImportFixturesWorkaround(project, project(":chat-engine")) } \ No newline at end of file diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt index 284f7288..1c14523f 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt @@ -38,9 +38,10 @@ import app.dapk.st.design.components.Toolbar import app.dapk.st.directory.DirectoryEvent.OpenDownloadUrl import app.dapk.st.directory.DirectoryScreenState.Content import app.dapk.st.directory.DirectoryScreenState.EmptyLoading +import app.dapk.st.engine.DirectoryItem +import app.dapk.st.engine.RoomOverview +import app.dapk.st.engine.Typing import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.sync.RoomOverview -import app.dapk.st.matrix.sync.SyncService import app.dapk.st.messenger.MessengerActivity import kotlinx.coroutines.launch import java.time.Clock @@ -147,7 +148,7 @@ private fun Content(listState: LazyListState, state: Content) { } @Composable -private fun DirectoryItem(room: RoomFoo, onClick: (RoomId) -> Unit, clock: Clock) { +private fun DirectoryItem(room: DirectoryItem, onClick: (RoomId) -> Unit, clock: Clock) { val overview = room.overview val roomName = overview.roomName ?: "Empty room" val hasUnread = room.unreadCount.value > 0 @@ -233,7 +234,7 @@ private fun DirectoryItem(room: RoomFoo, onClick: (RoomId) -> Unit, clock: Clock } @Composable -private fun body(overview: RoomOverview, secondaryText: Color, typing: SyncService.SyncEvent.Typing?) { +private fun body(overview: RoomOverview, secondaryText: Color, typing: Typing?) { val bodySize = 14.sp when { diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt index 5ef40d9e..a21ede30 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt @@ -2,31 +2,17 @@ package app.dapk.st.directory import android.content.Context import app.dapk.st.core.ProvidableModule -import app.dapk.st.matrix.common.CredentialsStore -import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.RoomStore -import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.engine.ChatEngine class DirectoryModule( - private val syncService: SyncService, - private val messageService: MessageService, - private val roomService: RoomService, private val context: Context, - private val credentialsStore: CredentialsStore, - private val roomStore: RoomStore, + private val chatEngine: ChatEngine, ) : ProvidableModule { fun directoryViewModel(): DirectoryViewModel { return DirectoryViewModel( ShortcutHandler(context), - DirectoryUseCase( - syncService, - messageService, - roomService, - credentialsStore, - roomStore, - ) + chatEngine, ) } } \ No newline at end of file diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt index 2cf51fad..0dd1a419 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryState.kt @@ -1,5 +1,7 @@ package app.dapk.st.directory +import app.dapk.st.engine.DirectoryState + sealed interface DirectoryScreenState { object EmptyLoading : DirectoryScreenState diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt index d8c4fdc1..92757f92 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryViewModel.kt @@ -2,6 +2,7 @@ package app.dapk.st.directory import androidx.lifecycle.viewModelScope import app.dapk.st.directory.DirectoryScreenState.* +import app.dapk.st.engine.ChatEngine import app.dapk.st.viewmodel.DapkViewModel import app.dapk.st.viewmodel.MutableStateFactory import app.dapk.st.viewmodel.defaultStateFactory @@ -12,7 +13,7 @@ import kotlinx.coroutines.launch class DirectoryViewModel( private val shortcutHandler: ShortcutHandler, - private val directoryUseCase: DirectoryUseCase, + private val chatEngine: ChatEngine, factory: MutableStateFactory = defaultStateFactory(), ) : DapkViewModel( initialState = EmptyLoading, @@ -23,7 +24,7 @@ class DirectoryViewModel( fun start() { syncJob = viewModelScope.launch { - directoryUseCase.state().onEach { + chatEngine.directory().onEach { shortcutHandler.onDirectoryUpdate(it.map { it.overview }) state = when (it.isEmpty()) { true -> Empty diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt index 87b4980f..276a8d3c 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt @@ -5,8 +5,8 @@ import android.content.pm.ShortcutInfo import androidx.core.app.Person import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat +import app.dapk.st.engine.RoomOverview import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.messenger.MessengerActivity class ShortcutHandler(private val context: Context) { diff --git a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryViewModelTest.kt b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryViewModelTest.kt index 752805a0..4b0b5a85 100644 --- a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryViewModelTest.kt +++ b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryViewModelTest.kt @@ -1,25 +1,26 @@ package app.dapk.st.directory import ViewModelTest +import app.dapk.st.engine.DirectoryItem +import app.dapk.st.engine.UnreadCount +import fake.FakeChatEngine import fixture.aRoomOverview -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.flowOf import org.junit.Test -import test.delegateReturn private val AN_OVERVIEW = aRoomOverview() -private val AN_OVERVIEW_STATE = RoomFoo(AN_OVERVIEW, UnreadCount(1), null) +private val AN_OVERVIEW_STATE = DirectoryItem(AN_OVERVIEW, UnreadCount(1), null) class DirectoryViewModelTest { private val runViewModelTest = ViewModelTest() - private val fakeDirectoryUseCase = FakeDirectoryUseCase() private val fakeShortcutHandler = FakeShortcutHandler() + private val fakeChatEngine = FakeChatEngine() private val viewModel = DirectoryViewModel( fakeShortcutHandler.instance, - fakeDirectoryUseCase.instance, + fakeChatEngine, runViewModelTest.testMutableStateFactory(), ) @@ -33,7 +34,7 @@ class DirectoryViewModelTest { @Test fun `when starting, then updates shortcuts and emits room state`() = runViewModelTest { fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) } - fakeDirectoryUseCase.given().returns(flowOf(listOf(AN_OVERVIEW_STATE))) + fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE))) viewModel.test().start() @@ -44,9 +45,4 @@ class DirectoryViewModelTest { class FakeShortcutHandler { val instance = mockk() -} - -class FakeDirectoryUseCase { - val instance = mockk() - fun given() = every { instance.state() }.delegateReturn() } \ No newline at end of file diff --git a/features/home/build.gradle b/features/home/build.gradle index 0422dfad..41343e5c 100644 --- a/features/home/build.gradle +++ b/features/home/build.gradle @@ -1,9 +1,7 @@ applyAndroidComposeLibraryModule(project) dependencies { - implementation project(":matrix:services:profile") - implementation project(":matrix:services:crypto") - implementation project(":matrix:services:sync") + implementation project(":chat-engine") implementation project(":features:directory") implementation project(":features:login") implementation project(":features:settings") diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt index b49c5cc6..210b5aa8 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt @@ -4,31 +4,28 @@ import app.dapk.st.core.BuildMeta import app.dapk.st.core.ProvidableModule import app.dapk.st.directory.DirectoryViewModel import app.dapk.st.domain.StoreModule +import app.dapk.st.engine.ChatEngine import app.dapk.st.login.LoginViewModel -import app.dapk.st.matrix.room.ProfileService -import app.dapk.st.matrix.sync.SyncService import app.dapk.st.profile.ProfileViewModel class HomeModule( + private val chatEngine: ChatEngine, private val storeModule: StoreModule, - private val profileService: ProfileService, - private val syncService: SyncService, private val buildMeta: BuildMeta, ) : ProvidableModule { fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel { return HomeViewModel( + chatEngine, storeModule.credentialsStore(), directory, login, profileViewModel, - profileService, storeModule.cacheCleaner(), BetaVersionUpgradeUseCase( storeModule.applicationStore(), buildMeta, ), - syncService, ) } diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt index 7bf01149..22ec6aa6 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt @@ -4,13 +4,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Settings import androidx.compose.ui.graphics.vector.ImageVector -import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.engine.Me sealed interface HomeScreenState { object Loading : HomeScreenState object SignedOut : HomeScreenState - data class SignedIn(val page: Page, val me: ProfileService.Me, val invites: Int) : HomeScreenState + data class SignedIn(val page: Page, val me: Me, val invites: Int) : HomeScreenState enum class Page(val icon: ImageVector) { Directory(Icons.Filled.Menu), @@ -21,5 +21,6 @@ sealed interface HomeScreenState { sealed interface HomeEvent { object Relaunch : HomeEvent + object OnShowContent : HomeEvent } diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt index 728c6c00..a18e8e87 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt @@ -3,12 +3,11 @@ package app.dapk.st.home import androidx.lifecycle.viewModelScope import app.dapk.st.directory.DirectoryViewModel import app.dapk.st.domain.StoreCleaner +import app.dapk.st.engine.ChatEngine import app.dapk.st.home.HomeScreenState.* import app.dapk.st.login.LoginViewModel import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.isSignedIn -import app.dapk.st.matrix.room.ProfileService -import app.dapk.st.matrix.sync.SyncService import app.dapk.st.profile.ProfileViewModel import app.dapk.st.viewmodel.DapkViewModel import kotlinx.coroutines.CoroutineScope @@ -20,14 +19,13 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch class HomeViewModel( + private val chatEngine: ChatEngine, private val credentialsProvider: CredentialsStore, private val directoryViewModel: DirectoryViewModel, private val loginViewModel: LoginViewModel, private val profileViewModel: ProfileViewModel, - private val profileService: ProfileService, private val cacheCleaner: StoreCleaner, private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, - private val syncService: SyncService, ) : DapkViewModel( initialState = Loading ) { @@ -41,6 +39,7 @@ class HomeViewModel( fun start() { viewModelScope.launch { state = if (credentialsProvider.isSignedIn()) { + _events.emit(HomeEvent.OnShowContent) initialHomeContent() } else { SignedOut @@ -56,21 +55,22 @@ class HomeViewModel( } private suspend fun initialHomeContent(): SignedIn { - val me = profileService.me(forceRefresh = false) - val initialInvites = syncService.invites().first().size + val me = chatEngine.me(forceRefresh = false) + val initialInvites = chatEngine.invites().first().size return SignedIn(Page.Directory, me, invites = initialInvites) } fun loggedIn() { viewModelScope.launch { state = initialHomeContent() + _events.emit(HomeEvent.OnShowContent) listenForInviteChanges() } } private fun CoroutineScope.listenForInviteChanges() { listenForInvitesJob?.cancel() - listenForInvitesJob = syncService.invites() + listenForInvitesJob = chatEngine.invites() .onEach { invites -> when (val currentState = state) { is SignedIn -> updateState { currentState.copy(invites = invites.size) } @@ -122,6 +122,6 @@ class HomeViewModel( } fun stop() { - viewModelScope.cancel() + // do nothing } } 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 c308346e..22567c49 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,5 +1,7 @@ package app.dapk.st.home +import android.Manifest +import android.os.Build import android.os.Bundle import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.AlertDialog @@ -27,10 +29,11 @@ class MainActivity : DapkActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + val pushPermissionLauncher = registerPushPermission() homeViewModel.events.onEach { when (it) { HomeEvent.Relaunch -> recreate() + HomeEvent.OnShowContent -> pushPermissionLauncher?.invoke() } }.launchIn(lifecycleScope) @@ -45,6 +48,14 @@ class MainActivity : DapkActivity() { } } + private fun registerPushPermission(): (() -> Unit)? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerForPermission(Manifest.permission.POST_NOTIFICATIONS) + } else { + null + } + } + @Composable private fun BetaUpgradeDialog() { AlertDialog( diff --git a/features/login/build.gradle b/features/login/build.gradle index 3a3af957..ac3340bb 100644 --- a/features/login/build.gradle +++ b/features/login/build.gradle @@ -1,11 +1,10 @@ applyAndroidComposeLibraryModule(project) dependencies { + implementation project(":chat-engine") implementation project(":domains:android:compose-core") implementation project(":domains:android:push") implementation project(":domains:android:viewmodel") - implementation project(":matrix:services:auth") - implementation project(":matrix:services:profile") - implementation project(":matrix:services:crypto") + implementation project(":design-library") implementation project(":core") } \ No newline at end of file diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt index 9180e524..c745f9fa 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt @@ -2,18 +2,16 @@ package app.dapk.st.login import app.dapk.st.core.ProvidableModule import app.dapk.st.core.extensions.ErrorTracker -import app.dapk.st.matrix.auth.AuthService -import app.dapk.st.matrix.room.ProfileService +import app.dapk.st.engine.ChatEngine import app.dapk.st.push.PushModule class LoginModule( - private val authService: AuthService, + private val chatEngine: ChatEngine, private val pushModule: PushModule, - private val profileService: ProfileService, private val errorTracker: ErrorTracker, ) : ProvidableModule { fun loginViewModel(): LoginViewModel { - return LoginViewModel(authService, pushModule.pushTokenRegistrar(), profileService, errorTracker) + return LoginViewModel(chatEngine, pushModule.pushTokenRegistrar(), errorTracker) } } \ No newline at end of file diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt index 2271c9ae..fd56d73f 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt @@ -1,7 +1,6 @@ package app.dapk.st.login import android.widget.Toast -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -32,6 +31,8 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.dapk.st.core.StartObserving +import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.design.components.GenericError import app.dapk.st.login.LoginEvent.LoginComplete import app.dapk.st.login.LoginScreenState.* @@ -49,42 +50,8 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { val keyboardController = LocalSoftwareKeyboardController.current when (val state = loginViewModel.state) { - is Error -> { - val openDetailsDialog = remember { mutableStateOf(false) } - - if (openDetailsDialog.value) { - AlertDialog( - onDismissRequest = { openDetailsDialog.value = false }, - confirmButton = { - Button(onClick = { openDetailsDialog.value = false }) { - Text("OK") - } - }, - title = { Text("Details") }, - text = { - Text(state.cause.message ?: "Unknown") - } - ) - } - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Something went wrong") - Text("Tap for more details".uppercase(), fontSize = 12.sp, modifier = Modifier.clickable { openDetailsDialog.value = true }.padding(12.dp)) - Spacer(modifier = Modifier.height(12.dp)) - Button(onClick = { - loginViewModel.start() - }) { - Text("Retry".uppercase()) - } - } - } - } - - Loading -> { - Box(contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } + is Error -> GenericError(cause = state.cause, action = { loginViewModel.start() }) + Loading -> CenteredLoading() is Content -> Row { diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt index f82c80d8..c8efb13c 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt @@ -3,10 +3,11 @@ package app.dapk.st.login import androidx.lifecycle.viewModelScope import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.logP +import app.dapk.st.engine.ChatEngine +import app.dapk.st.engine.LoginRequest +import app.dapk.st.engine.LoginResult import app.dapk.st.login.LoginEvent.LoginComplete import app.dapk.st.login.LoginScreenState.* -import app.dapk.st.matrix.auth.AuthService -import app.dapk.st.matrix.room.ProfileService import app.dapk.st.push.PushTokenRegistrar import app.dapk.st.viewmodel.DapkViewModel import kotlinx.coroutines.async @@ -14,9 +15,8 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch class LoginViewModel( - private val authService: AuthService, + private val chatEngine: ChatEngine, private val pushTokenRegistrar: PushTokenRegistrar, - private val profileService: ProfileService, private val errorTracker: ErrorTracker, ) : DapkViewModel( initialState = Content(showServerUrl = false) @@ -28,8 +28,8 @@ class LoginViewModel( state = Loading viewModelScope.launch { logP("login") { - when (val result = authService.login(AuthService.LoginRequest(userName, password, serverUrl.takeIfNotEmpty()))) { - is AuthService.LoginResult.Success -> { + when (val result = chatEngine.login(LoginRequest(userName, password, serverUrl.takeIfNotEmpty()))) { + is LoginResult.Success -> { runCatching { listOf( async { pushTokenRegistrar.registerCurrentToken() }, @@ -38,11 +38,13 @@ class LoginViewModel( } _events.tryEmit(LoginComplete) } - is AuthService.LoginResult.Error -> { + + is LoginResult.Error -> { errorTracker.track(result.cause) state = Error(result.cause) } - AuthService.LoginResult.MissingWellKnown -> { + + LoginResult.MissingWellKnown -> { _events.tryEmit(LoginEvent.WellKnownMissing) state = Content(showServerUrl = true) } @@ -51,7 +53,7 @@ class LoginViewModel( } } - private suspend fun preloadMe() = profileService.me(forceRefresh = false) + private suspend fun preloadMe() = chatEngine.me(forceRefresh = false) fun start() { val showServerUrl = previousState?.let { it is Content && it.showServerUrl } ?: false diff --git a/features/messenger/build.gradle b/features/messenger/build.gradle index e5686f71..f9edf4e3 100644 --- a/features/messenger/build.gradle +++ b/features/messenger/build.gradle @@ -2,12 +2,10 @@ applyAndroidComposeLibraryModule(project) 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(":chat-engine") implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") + implementation project(":domains:store") implementation project(":core") implementation project(":features:navigator") implementation project(":design-library") @@ -15,11 +13,10 @@ dependencies { kotlinTest(it) - androidImportFixturesWorkaround(project, project(":matrix:services:sync")) - androidImportFixturesWorkaround(project, project(":matrix:services:message")) androidImportFixturesWorkaround(project, project(":matrix:common")) androidImportFixturesWorkaround(project, project(":core")) androidImportFixturesWorkaround(project, project(":domains:store")) androidImportFixturesWorkaround(project, project(":domains:android:viewmodel")) androidImportFixturesWorkaround(project, project(":domains:android:stub")) + androidImportFixturesWorkaround(project, project(":chat-engine")) } \ No newline at end of file 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 a345ffc4..d77e319e 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,10 +2,9 @@ package app.dapk.st.messenger import android.content.Context import android.os.Environment -import app.dapk.st.core.Base64 +import app.dapk.st.engine.MediaDecrypter +import app.dapk.st.engine.RoomEvent import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.crypto.MediaDecrypter -import app.dapk.st.matrix.sync.RoomEvent import coil.ImageLoader import coil.decode.DataSource import coil.decode.ImageSource @@ -20,9 +19,11 @@ import okio.BufferedSource import okio.Path.Companion.toOkioPath import java.io.File -class DecryptingFetcherFactory(private val context: Context, base64: Base64, private val roomId: RoomId) : Fetcher.Factory { - - private val mediaDecrypter = MediaDecrypter(base64) +class DecryptingFetcherFactory( + private val context: Context, + private val roomId: RoomId, + private val mediaDecrypter: MediaDecrypter, +) : Fetcher.Factory { override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher { return DecryptingFetcher(data, context, mediaDecrypter, roomId) 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 4caff564..e46f37a6 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,37 +1,23 @@ 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.domain.application.message.MessageOptionsStore +import app.dapk.st.engine.ChatEngine import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.message.internal.ImageContentReader -import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.RoomStore -import app.dapk.st.matrix.sync.SyncService -import java.time.Clock class MessengerModule( - private val syncService: SyncService, - private val messageService: MessageService, - private val roomService: RoomService, - private val credentialsStore: CredentialsStore, - private val roomStore: RoomStore, - private val clock: Clock, + private val chatEngine: ChatEngine, private val context: Context, - private val base64: Base64, - private val imageMetaReader: ImageContentReader, + private val messageOptionsStore: MessageOptionsStore, ) : ProvidableModule { internal fun messengerViewModel(): MessengerViewModel { - return MessengerViewModel(messageService, roomService, roomStore, credentialsStore, timelineUseCase(), LocalIdFactory(), imageMetaReader, clock) + return MessengerViewModel( + chatEngine, + messageOptionsStore, + ) } - private fun timelineUseCase(): TimelineUseCaseImpl { - val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(LocalEchoMapper(MetaMapper())) - return TimelineUseCaseImpl(syncService, messageService, roomService, mergeWithLocalEchosUseCase) - } - - internal fun decryptingFetcherFactory(roomId: RoomId) = DecryptingFetcherFactory(context, base64, roomId) + internal fun decryptingFetcherFactory(roomId: RoomId) = DecryptingFetcherFactory(context, roomId, chatEngine.mediaDecrypter()) } \ 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 d130dd3c..8662e701 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 @@ -43,16 +43,13 @@ 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.MessengerUrlIcon -import app.dapk.st.design.components.MissingAvatarIcon -import app.dapk.st.design.components.SmallTalkTheme -import app.dapk.st.design.components.Toolbar +import app.dapk.st.design.components.* +import app.dapk.st.engine.MessageMeta +import app.dapk.st.engine.MessengerState +import app.dapk.st.engine.RoomEvent +import app.dapk.st.engine.RoomState import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.UserId -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 @@ -95,7 +92,7 @@ internal fun MessengerScreen( }) when (state.composerState) { is ComposerState.Text -> { - Room(state.roomState, replyActions) + Room(state.roomState, replyActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }) TextComposer( state.composerState, onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) }, @@ -132,7 +129,7 @@ private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLaun } @Composable -private fun ColumnScope.Room(roomStateLce: Lce, replyActions: ReplyActions) { +private fun ColumnScope.Room(roomStateLce: Lce, replyActions: ReplyActions, onRetry: () -> Unit) { when (val state = roomStateLce) { is Lce.Loading -> CenteredLoading() is Lce.Content -> { @@ -165,16 +162,7 @@ private fun ColumnScope.Room(roomStateLce: Lce, replyActions: Re } } - is Lce.Error -> { - Box(contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Something went wrong...") - Button(onClick = {}) { - Text("Retry") - } - } - } - } + is Lce.Error -> GenericError(cause = state.cause, action = onRetry) } } @@ -208,8 +196,9 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, replyActions AlignedBubble(item, self, wasPreviousMessageSameSender, replyActions) { when (item) { is RoomEvent.Image -> MessageImage(it as BubbleContent) - is Message -> TextBubbleContent(it as BubbleContent) + is RoomEvent.Message -> TextBubbleContent(it as BubbleContent) is RoomEvent.Reply -> ReplyBubbleContent(it as BubbleContent) + is RoomEvent.Encrypted -> EncryptedBubbleContent(it as BubbleContent) } } } @@ -457,6 +446,54 @@ private fun TextBubbleContent(content: BubbleContent) { } } +@Composable +private fun EncryptedBubbleContent(content: BubbleContent) { + Box(modifier = Modifier.padding(start = 6.dp)) { + Box( + Modifier + .padding(4.dp) + .clip(content.shape) + .background(content.background) + .height(IntrinsicSize.Max), + ) { + Column( + Modifier + .padding(8.dp) + .width(IntrinsicSize.Max) + .defaultMinSize(minWidth = 50.dp) + ) { + if (content.isNotSelf) { + Text( + fontSize = 11.sp, + text = content.message.author.displayName ?: content.message.author.id.value, + maxLines = 1, + color = content.textColor() + ) + } + Text( + text = "Encrypted message", + color = content.textColor(), + fontSize = 15.sp, + modifier = Modifier.wrapContentSize(), + textAlign = TextAlign.Start, + ) + + Spacer(modifier = Modifier.height(2.dp)) + Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + fontSize = 9.sp, + text = "${content.message.time}", + textAlign = TextAlign.End, + color = content.textColor(), + modifier = Modifier.wrapContentSize() + ) + SendStatus(content.message) + } + } + } + } +} + @Composable private fun ReplyBubbleContent(content: BubbleContent) { Box(modifier = Modifier.padding(start = 6.dp)) { @@ -494,7 +531,7 @@ private fun ReplyBubbleContent(content: BubbleContent) { ) Spacer(modifier = Modifier.height(2.dp)) when (val replyingTo = content.message.replyingTo) { - is Message -> { + is RoomEvent.Message -> { Text( text = replyingTo.content, color = content.textColor().copy(alpha = 0.8f), @@ -523,6 +560,16 @@ private fun ReplyBubbleContent(content: BubbleContent) { is RoomEvent.Reply -> { // TODO - a reply to a reply } + + is RoomEvent.Encrypted -> { + Text( + text = "Encrypted message", + color = content.textColor().copy(alpha = 0.8f), + fontSize = 14.sp, + modifier = Modifier.wrapContentSize(), + textAlign = TextAlign.Start, + ) + } } } @@ -537,7 +584,7 @@ private fun ReplyBubbleContent(content: BubbleContent) { ) } when (val message = content.message.message) { - is Message -> { + is RoomEvent.Message -> { Text( text = message.content, color = content.textColor(), @@ -566,6 +613,16 @@ private fun ReplyBubbleContent(content: BubbleContent) { is RoomEvent.Reply -> { // TODO - a reply to a reply } + + is RoomEvent.Encrypted -> { + Text( + text = "Encrypted message", + color = content.textColor(), + fontSize = 15.sp, + modifier = Modifier.wrapContentSize(), + textAlign = TextAlign.Start, + ) + } } Spacer(modifier = Modifier.height(2.dp)) @@ -654,7 +711,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un ) } ) { - if (it is Message) { + if (it is RoomEvent.Message) { Box(Modifier.padding(12.dp)) { Box(Modifier.padding(8.dp).clickable { replyActions.onDismiss() }.wrapContentWidth().align(Alignment.TopEnd)) { Icon( 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 7c0259c8..ce4d8292 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 @@ -1,8 +1,9 @@ package app.dapk.st.messenger import app.dapk.st.core.Lce +import app.dapk.st.engine.MessengerState +import app.dapk.st.engine.RoomEvent import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.navigator.MessageAttachment data class MessengerScreenState( 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 4ba6163a..6c87dc0c 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 @@ -3,34 +3,23 @@ package app.dapk.st.messenger import androidx.lifecycle.viewModelScope import app.dapk.st.core.Lce import app.dapk.st.core.extensions.takeIfContent -import app.dapk.st.matrix.common.CredentialsStore -import app.dapk.st.matrix.common.EventId +import app.dapk.st.domain.application.message.MessageOptionsStore +import app.dapk.st.engine.ChatEngine +import app.dapk.st.engine.RoomEvent +import app.dapk.st.engine.SendMessage import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.common.UserId -import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.message.internal.ImageContentReader -import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.RoomEvent -import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.navigator.MessageAttachment import app.dapk.st.viewmodel.DapkViewModel import app.dapk.st.viewmodel.MutableStateFactory import app.dapk.st.viewmodel.defaultStateFactory -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import java.time.Clock +import kotlinx.coroutines.launch internal class MessengerViewModel( - private val messageService: MessageService, - private val roomService: RoomService, - private val roomStore: RoomStore, - private val credentialsStore: CredentialsStore, - private val observeTimeline: ObserveTimelineUseCase, - private val localIdFactory: LocalIdFactory, - private val imageContentReader: ImageContentReader, - private val clock: Clock, + private val chatEngine: ChatEngine, + private val messageOptionsStore: MessageOptionsStore, factory: MutableStateFactory = defaultStateFactory(), ) : DapkViewModel( initialState = MessengerScreenState( @@ -81,31 +70,13 @@ internal class MessengerViewModel( private fun start(action: MessengerAction.OnMessengerVisible) { updateState { copy(roomId = action.roomId, composerState = action.attachments?.let { ComposerState.Attachments(it, null) } ?: composerState) } - syncJob = viewModelScope.launch { - roomStore.markRead(action.roomId) - - val credentials = credentialsStore.credentials()!! - var lastKnownReadEvent: EventId? = null - observeTimeline.invoke(action.roomId, credentials.userId).distinctUntilChanged().onEach { state -> - state.latestMessageEventFromOthers(self = credentials.userId)?.let { - if (lastKnownReadEvent != it) { - updateRoomReadStateAsync(latestReadEvent = it, state) - lastKnownReadEvent = it - } - } - updateState { copy(roomState = Lce.Content(state)) } - }.collect() + viewModelScope.launch { + syncJob = chatEngine.messages(action.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled()) + .onEach { updateState { copy(roomState = Lce.Content(it)) } } + .launchIn(this) } } - private fun CoroutineScope.updateRoomReadStateAsync(latestReadEvent: EventId, state: MessengerState): Deferred { - return async { - runCatching { - roomService.markFullyRead(state.roomState.roomOverview.roomId, latestReadEvent) - roomStore.markRead(state.roomState.roomOverview.roomId) - } - } - } private fun sendMessage() { when (val composerState = state.composerState) { @@ -116,27 +87,24 @@ internal class MessengerViewModel( state.roomState.takeIfContent()?.let { content -> val roomState = content.roomState viewModelScope.launch { - messageService.scheduleMessage( - MessageService.Message.TextMessage( - MessageService.Message.Content.TextContent(body = copy.value), - roomId = roomState.roomOverview.roomId, - sendEncrypted = roomState.roomOverview.isEncrypted, - localId = localIdFactory.create(), - timestampUtc = clock.millis(), + chatEngine.send( + message = SendMessage.TextMessage( + content = copy.value, reply = copy.reply?.let { - MessageService.Message.TextMessage.Reply( + SendMessage.TextMessage.Reply( author = it.author, originalMessage = when (it) { is RoomEvent.Image -> TODO() is RoomEvent.Reply -> TODO() is RoomEvent.Message -> it.content + is RoomEvent.Encrypted -> error("Should never happen") }, - replyContent = copy.value, eventId = it.eventId, timestampUtc = it.utcTimestamp, ) } - ) + ), + room = roomState.roomOverview, ) } } @@ -149,26 +117,7 @@ internal class MessengerViewModel( state.roomState.takeIfContent()?.let { content -> val roomState = content.roomState viewModelScope.launch { - val imageUri = copy.values.first().uri.value - val meta = imageContentReader.meta(imageUri) - - messageService.scheduleMessage( - MessageService.Message.ImageMessage( - MessageService.Message.Content.ImageContent( - uri = imageUri, MessageService.Message.Content.ImageContent.Meta( - height = meta.height, - width = meta.width, - size = meta.size, - fileName = meta.fileName, - mimeType = meta.mimeType, - ) - ), - roomId = roomState.roomOverview.roomId, - sendEncrypted = roomState.roomOverview.isEncrypted, - localId = localIdFactory.create(), - timestampUtc = clock.millis(), - ) - ) + chatEngine.send(SendMessage.ImageMessage(uri = copy.values.first().uri.value), roomState.roomOverview) } } @@ -188,12 +137,6 @@ internal class MessengerViewModel( } -private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roomState.events - .filterIsInstance() - .filterNot { it.author.id == self } - .firstOrNull() - ?.eventId - sealed interface MessengerAction { data class ComposerTextUpdate(val newValue: String) : MessengerAction data class ComposerEnterReplyMode(val replyingTo: RoomEvent) : MessengerAction 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 06faf139..1b4a23e9 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 @@ -5,6 +5,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Parcelable import androidx.activity.result.contract.ActivityResultContract @@ -15,6 +16,7 @@ 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.design.components.GenericError import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -31,7 +33,7 @@ class ImageGalleryActivity : DapkActivity() { val permissionState = mutableStateOf>(Lce.Loading()) lifecycleScope.launch { - permissionState.value = runCatching { ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE) }.fold( + permissionState.value = runCatching { ensurePermission(mediaPermission()) }.fold( onSuccess = { Lce.Content(it) }, onFailure = { Lce.Error(it) } ) @@ -48,6 +50,12 @@ class ImageGalleryActivity : DapkActivity() { } } } + + private fun mediaPermission() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } } @Composable @@ -59,7 +67,10 @@ fun Activity.PermissionGuard(state: State>, onGranted: @Co PermissionResult.ShowRational -> finish() } - is Lce.Error -> finish() + is Lce.Error -> GenericError(message = "Store permission required", label = "Close") { + finish() + } + is Lce.Loading -> { // loading should be quick, let's avoid displaying anything } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt index acb46c18..daae82d4 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt @@ -42,20 +42,17 @@ fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> U Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) { item(ImageGalleryPage.Routes.folders) { - ImageGalleryFolders(it) { folder -> - viewModel.selectFolder(folder) - } + ImageGalleryFolders(it, onClick = { viewModel.selectFolder(it) }, onRetry = { viewModel.start() }) } item(ImageGalleryPage.Routes.files) { - ImageGalleryMedia(it, onImageSelected) + ImageGalleryMedia(it, onImageSelected, onRetry = { viewModel.selectFolder(it.folder) }) } } } - @Composable -fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit) { +fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit, onRetry: () -> Unit) { val screenWidth = LocalConfiguration.current.screenWidthDp val gradient = Brush.verticalGradient( @@ -106,12 +103,12 @@ fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Un } } - is Lce.Error -> GenericError { } + is Lce.Error -> GenericError(cause = content.cause, action = onRetry) } } @Composable -fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit) { +fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit, onRetry: () -> Unit) { val screenWidth = LocalConfiguration.current.screenWidthDp Column { @@ -149,7 +146,7 @@ fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> } } - is Lce.Error -> GenericError { } + is Lce.Error -> GenericError(cause = content.cause, action = onRetry) } } 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 59cbbb4f..06203156 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 @@ -48,7 +48,7 @@ class ImageGalleryViewModel( route = ImageGalleryPage.Routes.files, label = page.label, parent = ImageGalleryPage.Routes.folders, - state = ImageGalleryPage.Files(Lce.Loading()) + state = ImageGalleryPage.Files(Lce.Loading(), folder) ) ) } @@ -78,7 +78,7 @@ data class ImageGalleryState( sealed interface ImageGalleryPage { data class Folders(val content: Lce>) : ImageGalleryPage - data class Files(val content: Lce>) : ImageGalleryPage + data class Files(val content: Lce>, val folder: Folder) : ImageGalleryPage object Routes { val folders = Route("Folders") diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt index 2ce75672..fdaaadce 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt @@ -2,32 +2,22 @@ package app.dapk.st.messenger import ViewModelTest import app.dapk.st.core.Lce +import app.dapk.st.core.extensions.takeIfContent +import app.dapk.st.engine.MessengerState +import app.dapk.st.engine.RoomState +import app.dapk.st.engine.SendMessage import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.UserId -import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.message.internal.ImageContentReader -import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.RoomState -import app.dapk.st.matrix.sync.SyncService -import fake.FakeCredentialsStore -import fake.FakeRoomStore +import fake.FakeChatEngine +import fake.FakeMessageOptionsStore import fixture.* -import internalfake.FakeLocalIdFactory -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk import kotlinx.coroutines.flow.flowOf import org.junit.Test -import test.delegateReturn -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset -private const val A_CURRENT_TIMESTAMP = 10000L +private const val READ_RECEIPTS_ARE_DISABLED = true private val A_ROOM_ID = aRoomId("messenger state room id") private const val A_MESSAGE_CONTENT = "message content" -private const val A_LOCAL_ID = "local.1111-2222-3333" private val AN_EVENT_ID = anEventId("state event") private val A_SELF_ID = aUserId("self") @@ -35,21 +25,12 @@ class MessengerViewModelTest { private val runViewModelTest = ViewModelTest() - private val fakeMessageService = FakeMessageService() - private val fakeRoomService = FakeRoomService() - private val fakeRoomStore = FakeRoomStore() - private val fakeCredentialsStore = FakeCredentialsStore().also { it.givenCredentials().returns(aUserCredentials(userId = A_SELF_ID)) } - private val fakeObserveTimelineUseCase = FakeObserveTimelineUseCase() + private val fakeMessageOptionsStore = FakeMessageOptionsStore() + private val fakeChatEngine = FakeChatEngine() private val viewModel = MessengerViewModel( - fakeMessageService, - fakeRoomService, - fakeRoomStore, - fakeCredentialsStore, - fakeObserveTimelineUseCase, - localIdFactory = FakeLocalIdFactory().also { it.givenCreate().returns(A_LOCAL_ID) }.instance, - imageContentReader = FakeImageContentReader(), - clock = fixedClock(A_CURRENT_TIMESTAMP), + fakeChatEngine, + fakeMessageOptionsStore.instance, factory = runViewModelTest.testMutableStateFactory(), ) @@ -68,10 +49,9 @@ class MessengerViewModelTest { @Test fun `given timeline emits state, when starting, then updates state and marks room and events as read`() = runViewModelTest { - fakeRoomStore.expectUnit(times = 2) { it.markRead(A_ROOM_ID) } - fakeRoomService.expectUnit { it.markFullyRead(A_ROOM_ID, AN_EVENT_ID) } + fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED) val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID) - fakeObserveTimelineUseCase.given(A_ROOM_ID, A_SELF_ID).returns(flowOf(state)) + fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state)) viewModel.test().post(MessengerAction.OnMessengerVisible(A_ROOM_ID, attachments = null)) @@ -93,9 +73,10 @@ class MessengerViewModelTest { @Test fun `given composer message state when posting send text, then resets composer state and sends message`() = runViewModelTest { - fakeMessageService.expectUnit { it.scheduleMessage(expectEncryptedMessage(A_ROOM_ID, A_LOCAL_ID, A_CURRENT_TIMESTAMP, A_MESSAGE_CONTENT)) } + val initialState = initialStateWithComposerMessage(A_ROOM_ID, A_MESSAGE_CONTENT) + fakeChatEngine.expectUnit { it.send(expectTextMessage(A_MESSAGE_CONTENT), initialState.roomState.takeIfContent()!!.roomState.roomOverview) } - viewModel.test(initialState = initialStateWithComposerMessage(A_ROOM_ID, A_MESSAGE_CONTENT)).post(MessengerAction.ComposerSendText) + viewModel.test(initialState = initialState).post(MessengerAction.ComposerSendText) assertStates({ copy(composerState = ComposerState.Text("", reply = null)) }) verifyExpects() @@ -109,9 +90,8 @@ class MessengerViewModelTest { return aMessageScreenState(roomId, aMessengerState(roomState = roomState), messageContent) } - private fun expectEncryptedMessage(roomId: RoomId, localId: String, timestamp: Long, messageContent: String): MessageService.Message.TextMessage { - val content = MessageService.Message.Content.TextContent(body = messageContent) - return MessageService.Message.TextMessage(content, sendEncrypted = true, roomId, localId, timestamp) + private fun expectTextMessage(messageContent: String): SendMessage.TextMessage { + return SendMessage.TextMessage(messageContent, reply = null) } private fun aMessengerStateWithEvent(eventId: EventId, selfId: UserId) = aRoomStateWithEventId(eventId).toMessengerState(selfId) @@ -130,27 +110,3 @@ fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, m roomState = Lce.Content(roomState), composerState = ComposerState.Text(value = messageContent ?: "", reply = null) ) - -fun aMessengerState( - self: UserId = aUserId(), - roomState: RoomState, - typing: SyncService.SyncEvent.Typing? = null -) = MessengerState(self, roomState, typing) - -class FakeObserveTimelineUseCase : ObserveTimelineUseCase by mockk() { - fun given(roomId: RoomId, selfId: UserId) = coEvery { this@FakeObserveTimelineUseCase.invoke(roomId, selfId) }.delegateReturn() -} - -class FakeMessageService : MessageService by mockk() { - - fun givenEchos(roomId: RoomId) = every { localEchos(roomId) }.delegateReturn() - -} - -class FakeRoomService : RoomService by mockk() { - fun givenFindMember(roomId: RoomId, userId: UserId) = coEvery { findMember(roomId, userId) }.delegateReturn() -} - -fun fixedClock(timestamp: Long = 0) = Clock.fixed(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) - -class FakeImageContentReader: ImageContentReader by mockk() \ No newline at end of file diff --git a/features/navigator/build.gradle b/features/navigator/build.gradle index 6c97c966..f3bacd74 100644 --- a/features/navigator/build.gradle +++ b/features/navigator/build.gradle @@ -4,5 +4,5 @@ apply plugin: 'kotlin-parcelize' dependencies { compileOnly project(":domains:android:stub") implementation project(":core") - implementation project(":matrix:common") + implementation project(":chat-engine") } \ No newline at end of file diff --git a/features/notifications/build.gradle b/features/notifications/build.gradle index 85ca262d..b7d1171c 100644 --- a/features/notifications/build.gradle +++ b/features/notifications/build.gradle @@ -1,8 +1,7 @@ applyAndroidLibraryModule(project) dependencies { - implementation project(":matrix:services:push") - implementation project(":matrix:services:sync") + implementation project(":chat-engine") implementation project(':domains:store') implementation project(":domains:android:work") implementation project(':domains:android:push') @@ -12,12 +11,13 @@ dependencies { implementation project(":features:messenger") implementation project(":features:navigator") + implementation Dependencies.mavenCentral.kotlinSerializationJson kotlinTest(it) androidImportFixturesWorkaround(project, project(":core")) androidImportFixturesWorkaround(project, project(":matrix:common")) - androidImportFixturesWorkaround(project, project(":matrix:services:sync")) + androidImportFixturesWorkaround(project, project(":chat-engine")) androidImportFixturesWorkaround(project, project(":domains:android:stub")) } \ No newline at end of file diff --git a/features/notifications/src/main/AndroidManifest.xml b/features/notifications/src/main/AndroidManifest.xml index e76fe661..ebdd9bb5 100644 --- a/features/notifications/src/main/AndroidManifest.xml +++ b/features/notifications/src/main/AndroidManifest.xml @@ -1,2 +1,4 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt index 503e0746..43b7a9fc 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt @@ -4,9 +4,9 @@ import android.app.Notification import android.content.Context import app.dapk.st.core.DeviceMeta import app.dapk.st.core.whenPOrHigher +import app.dapk.st.engine.RoomOverview import app.dapk.st.imageloader.IconLoader import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.navigator.IntentFactory import java.time.Clock @@ -87,7 +87,7 @@ class NotificationFactory( ) } - fun createInvite(inviteNotification: InviteNotification): AndroidNotification { + fun createInvite(inviteNotification: app.dapk.st.engine.InviteNotification): AndroidNotification { val openAppIntent = intentFactory.notificationOpenApp(context) return AndroidNotification( channelId = INVITE_CHANNEL_ID, diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationInviteRenderer.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationInviteRenderer.kt index 8987ea92..63fe6d18 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationInviteRenderer.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationInviteRenderer.kt @@ -10,7 +10,7 @@ class NotificationInviteRenderer( private val androidNotificationBuilder: AndroidNotificationBuilder, ) { - fun render(inviteNotification: InviteNotification) { + fun render(inviteNotification: app.dapk.st.engine.InviteNotification) { notificationManager.notify( inviteNotification.roomId.value, INVITE_NOTIFICATION_ID, @@ -18,7 +18,7 @@ class NotificationInviteRenderer( ) } - private fun InviteNotification.toAndroidNotification() = androidNotificationBuilder.build( + private fun app.dapk.st.engine.InviteNotification.toAndroidNotification() = androidNotificationBuilder.build( notificationFactory.createInvite(this) ) diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationMessageRenderer.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationMessageRenderer.kt index 1a12ddb8..ab979782 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationMessageRenderer.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationMessageRenderer.kt @@ -5,9 +5,9 @@ import app.dapk.st.core.AppLogTag import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.extensions.ifNull import app.dapk.st.core.log +import app.dapk.st.engine.RoomEvent +import app.dapk.st.engine.RoomOverview import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.sync.RoomEvent -import app.dapk.st.matrix.sync.RoomOverview import kotlinx.coroutines.withContext private const val SUMMARY_NOTIFICATION_ID = 101 diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStyleFactory.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStyleFactory.kt index 232c2733..4cfb12b5 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStyleFactory.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStyleFactory.kt @@ -3,8 +3,8 @@ package app.dapk.st.notifications import android.annotation.SuppressLint import app.dapk.st.core.DeviceMeta import app.dapk.st.core.whenPOrHigher +import app.dapk.st.engine.RoomOverview import app.dapk.st.imageloader.IconLoader -import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.notifications.AndroidNotificationStyle.Inbox import app.dapk.st.notifications.AndroidNotificationStyle.Messaging diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt index 3368110b..5c87c936 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt @@ -5,16 +5,14 @@ import android.content.Context import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.DeviceMeta import app.dapk.st.core.ProvidableModule +import app.dapk.st.engine.ChatEngine import app.dapk.st.imageloader.IconLoader -import app.dapk.st.matrix.sync.OverviewStore -import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.navigator.IntentFactory import java.time.Clock class NotificationsModule( + private val chatEngine: ChatEngine, private val iconLoader: IconLoader, - private val roomStore: RoomStore, - private val overviewStore: OverviewStore, private val context: Context, private val intentFactory: IntentFactory, private val dispatchers: CoroutineDispatchers, @@ -40,10 +38,9 @@ class NotificationsModule( ) return RenderNotificationsUseCase( notificationRenderer = notificationMessageRenderer, - observeRenderableUnreadEventsUseCase = ObserveUnreadNotificationsUseCaseImpl(roomStore), notificationChannels = NotificationChannels(notificationManager), - observeInviteNotificationsUseCase = ObserveInviteNotificationsUseCaseImpl(overviewStore), - inviteRenderer = NotificationInviteRenderer(notificationManager, notificationFactory, androidNotificationBuilder) + inviteRenderer = NotificationInviteRenderer(notificationManager, notificationFactory, androidNotificationBuilder), + chatEngine = chatEngine, ) } diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt index d51f6e86..097b0c66 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt @@ -1,7 +1,9 @@ package app.dapk.st.notifications -import app.dapk.st.matrix.sync.RoomEvent -import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.engine.ChatEngine +import app.dapk.st.engine.NotificationDiff +import app.dapk.st.engine.RoomEvent +import app.dapk.st.engine.RoomOverview import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -9,18 +11,17 @@ import kotlinx.coroutines.flow.onEach class RenderNotificationsUseCase( private val notificationRenderer: NotificationMessageRenderer, private val inviteRenderer: NotificationInviteRenderer, - private val observeRenderableUnreadEventsUseCase: ObserveUnreadNotificationsUseCase, - private val observeInviteNotificationsUseCase: ObserveInviteNotificationsUseCase, + private val chatEngine: ChatEngine, private val notificationChannels: NotificationChannels, ) { suspend fun listenForNotificationChanges(scope: CoroutineScope) { notificationChannels.initChannels() - observeRenderableUnreadEventsUseCase() + chatEngine.notificationsMessages() .onEach { (each, diff) -> renderUnreadChange(each, diff) } .launchIn(scope) - observeInviteNotificationsUseCase() + chatEngine.notificationsInvites() .onEach { inviteRenderer.render(it) } .launchIn(scope) } diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt index 9f5c05d7..ef649cdd 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt @@ -1,24 +1,19 @@ package app.dapk.st.notifications +import app.dapk.st.engine.RoomEvent import app.dapk.st.matrix.common.RoomMember -import app.dapk.st.matrix.sync.RoomEvent class RoomEventsToNotifiableMapper { fun map(events: List): List { - return events.map { - when (it) { - is RoomEvent.Image -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) - is RoomEvent.Message -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) - is RoomEvent.Reply -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) - } - } + return events.map { Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) } } private fun RoomEvent.toNotifiableContent(): String = when (this) { is RoomEvent.Image -> "\uD83D\uDCF7" is RoomEvent.Message -> this.content is RoomEvent.Reply -> this.message.toNotifiableContent() + is RoomEvent.Encrypted -> "Encrypted message" } } diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt index 014cce66..5c26faab 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt @@ -4,8 +4,8 @@ import android.app.Notification import android.app.PendingIntent import android.os.Build import app.dapk.st.core.DeviceMeta +import app.dapk.st.engine.RoomOverview import app.dapk.st.matrix.common.AvatarUrl -import app.dapk.st.matrix.sync.RoomOverview import fake.FakeContext import fixture.NotificationDelegateFixtures.anAndroidNotification import fixture.NotificationDelegateFixtures.anInboxStyle @@ -137,7 +137,7 @@ class NotificationFactoryTest { fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT) val content = "Content message" val result = notificationFactory.createInvite( - InviteNotification( + app.dapk.st.engine.InviteNotification( content = content, A_ROOM_ID, ) diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt index 0be9e2f5..724aa500 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt @@ -1,8 +1,8 @@ package app.dapk.st.notifications +import app.dapk.st.engine.RoomEvent +import app.dapk.st.engine.RoomOverview import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.sync.RoomEvent -import app.dapk.st.matrix.sync.RoomOverview import fake.FakeNotificationFactory import fake.FakeNotificationManager import fake.aFakeNotification diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStateMapperTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStateMapperTest.kt index 045ed36c..2711f43c 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStateMapperTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStateMapperTest.kt @@ -1,9 +1,9 @@ package app.dapk.st.notifications import android.content.Context +import app.dapk.st.engine.RoomEvent +import app.dapk.st.engine.RoomOverview import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.sync.RoomEvent -import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.navigator.IntentFactory import fixture.NotificationDelegateFixtures.anAndroidNotification import fixture.NotificationFixtures.aDismissRoomNotification diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt index 51759567..e99921ae 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt @@ -1,5 +1,6 @@ package app.dapk.st.notifications +import app.dapk.st.engine.UnreadNotifications import fake.* import fixture.NotificationDiffFixtures.aNotificationDiff import kotlinx.coroutines.test.TestScope @@ -14,25 +15,23 @@ class RenderNotificationsUseCaseTest { private val fakeNotificationMessageRenderer = FakeNotificationMessageRenderer() private val fakeNotificationInviteRenderer = FakeNotificationInviteRenderer() - private val fakeObserveUnreadNotificationsUseCase = FakeObserveUnreadNotificationsUseCase() - private val fakeObserveInviteNotificationsUseCase = FakeObserveInviteNotificationsUseCase() private val fakeNotificationChannels = FakeNotificationChannels().also { it.instance.expect { it.initChannels() } } + private val fakeChatEngine = FakeChatEngine() private val renderNotificationsUseCase = RenderNotificationsUseCase( fakeNotificationMessageRenderer.instance, fakeNotificationInviteRenderer.instance, - fakeObserveUnreadNotificationsUseCase, - fakeObserveInviteNotificationsUseCase, + fakeChatEngine, fakeNotificationChannels.instance, ) @Test fun `given events, when listening for changes then initiates channels once`() = runTest { fakeNotificationMessageRenderer.instance.expect { it.render(any()) } - fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS) - fakeObserveInviteNotificationsUseCase.given().emits() + fakeChatEngine.givenNotificationsMessages().emits(AN_UNREAD_NOTIFICATIONS) + fakeChatEngine.givenNotificationsInvites().emits() renderNotificationsUseCase.listenForNotificationChanges(TestScope(UnconfinedTestDispatcher())) @@ -42,8 +41,8 @@ class RenderNotificationsUseCaseTest { @Test fun `given renderable unread events, when listening for changes, then renders change`() = runTest { fakeNotificationMessageRenderer.instance.expect { it.render(any()) } - fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS) - fakeObserveInviteNotificationsUseCase.given().emits() + fakeChatEngine.givenNotificationsMessages().emits(AN_UNREAD_NOTIFICATIONS) + fakeChatEngine.givenNotificationsInvites().emits() renderNotificationsUseCase.listenForNotificationChanges(TestScope(UnconfinedTestDispatcher())) diff --git a/features/notifications/src/test/kotlin/fake/FakeNotificationMessageRenderer.kt b/features/notifications/src/test/kotlin/fake/FakeNotificationMessageRenderer.kt index 288d6940..c5fbab49 100644 --- a/features/notifications/src/test/kotlin/fake/FakeNotificationMessageRenderer.kt +++ b/features/notifications/src/test/kotlin/fake/FakeNotificationMessageRenderer.kt @@ -2,14 +2,14 @@ package fake import app.dapk.st.notifications.NotificationMessageRenderer import app.dapk.st.notifications.NotificationState -import app.dapk.st.notifications.UnreadNotifications +import app.dapk.st.engine.UnreadNotifications import io.mockk.coVerify import io.mockk.mockk class FakeNotificationMessageRenderer { val instance = mockk() - fun verifyRenders(vararg unreadNotifications: UnreadNotifications) { + fun verifyRenders(vararg unreadNotifications: app.dapk.st.engine.UnreadNotifications) { unreadNotifications.forEach { unread -> coVerify { instance.render( diff --git a/features/profile/build.gradle b/features/profile/build.gradle index 6a2f7b02..89e50f1b 100644 --- a/features/profile/build.gradle +++ b/features/profile/build.gradle @@ -1,9 +1,7 @@ applyAndroidComposeLibraryModule(project) dependencies { - implementation project(":matrix:services:sync") - implementation project(":matrix:services:room") - implementation project(":matrix:services:profile") + implementation project(":chat-engine") implementation project(":features:settings") implementation project(':domains:store') implementation project(":domains:android:compose-core") diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt index 43d7d44a..4766144d 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt @@ -2,19 +2,15 @@ package app.dapk.st.profile import app.dapk.st.core.ProvidableModule import app.dapk.st.core.extensions.ErrorTracker -import app.dapk.st.matrix.room.ProfileService -import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.engine.ChatEngine class ProfileModule( - private val profileService: ProfileService, - private val syncService: SyncService, - private val roomService: RoomService, + private val chatEngine: ChatEngine, private val errorTracker: ErrorTracker, ) : ProvidableModule { fun profileViewModel(): ProfileViewModel { - return ProfileViewModel(profileService, syncService, roomService, errorTracker) + return ProfileViewModel(chatEngine, errorTracker) } } \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt index 6d574707..d08d2b48 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt @@ -21,8 +21,8 @@ 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.* -import app.dapk.st.matrix.sync.InviteMeta -import app.dapk.st.matrix.sync.RoomInvite +import app.dapk.st.engine.RoomInvite +import app.dapk.st.engine.RoomInvite.InviteMeta import app.dapk.st.settings.SettingsActivity @Composable @@ -119,7 +119,7 @@ private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile: } @Composable -private fun Invitations(viewModel: ProfileViewModel, invitations: Page.Invitations) { +private fun SpiderItemScope.Invitations(viewModel: ProfileViewModel, invitations: Page.Invitations) { when (val state = invitations.content) { is Lce.Loading -> CenteredLoading() is Lce.Content -> { @@ -147,7 +147,7 @@ private fun Invitations(viewModel: ProfileViewModel, invitations: Page.Invitatio } } - is Lce.Error -> TODO() + is Lce.Error -> GenericError(label = "Go back", cause = state.cause) { goBack() } } } diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt index 5e0b6e2a..b7754df7 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt @@ -3,9 +3,8 @@ package app.dapk.st.profile import app.dapk.st.core.Lce import app.dapk.st.design.components.Route import app.dapk.st.design.components.SpiderPage -import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.room.ProfileService -import app.dapk.st.matrix.sync.RoomInvite +import app.dapk.st.engine.Me +import app.dapk.st.engine.RoomInvite data class ProfileScreenState( val page: SpiderPage, @@ -14,12 +13,12 @@ data class ProfileScreenState( sealed interface Page { data class Profile(val content: Lce) : Page { data class Content( - val me: ProfileService.Me, + val me: Me, val invitationsCount: Int, ) } - data class Invitations(val content: Lce>): Page + data class Invitations(val content: Lce>) : Page object Routes { val profile = Route("Profile") diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt index bec102ad..bb3cfb6f 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt @@ -5,25 +5,20 @@ import androidx.lifecycle.viewModelScope import app.dapk.st.core.Lce import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.design.components.SpiderPage +import app.dapk.st.engine.ChatEngine import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.room.ProfileService -import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.SyncService import app.dapk.st.viewmodel.DapkViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch class ProfileViewModel( - private val profileService: ProfileService, - private val syncService: SyncService, - private val roomService: RoomService, + private val chatEngine: ChatEngine, private val errorTracker: ErrorTracker, ) : DapkViewModel( ProfileScreenState(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false)) ) { - private var syncingJob: Job? = null private var currentPageJob: Job? = null fun start() { @@ -31,15 +26,13 @@ class ProfileViewModel( } private fun goToProfile() { - syncingJob = syncService.startSyncing().launchIn(viewModelScope) - combine( flow { - val result = runCatching { profileService.me(forceRefresh = true) } + val result = runCatching { chatEngine.me(forceRefresh = true) } .onFailure { errorTracker.track(it, "Loading profile") } emit(result) }, - syncService.invites(), + chatEngine.invites(), transform = { me, invites -> me to invites } ) .onEach { (me, invites) -> @@ -57,7 +50,7 @@ class ProfileViewModel( fun goToInvitations() { updateState { copy(page = SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()))) } - syncService.invites() + chatEngine.invites() .onEach { updatePageState { copy(content = Lce.Content(it)) @@ -89,13 +82,13 @@ class ProfileViewModel( } fun acceptRoomInvite(roomId: RoomId) { - launchCatching { roomService.joinRoom(roomId) }.fold( + launchCatching { chatEngine.joinRoom(roomId) }.fold( onError = {} ) } fun rejectRoomInvite(roomId: RoomId) { - launchCatching { roomService.rejectJoinRoom(roomId) }.fold( + launchCatching { chatEngine.rejectJoinRoom(roomId) }.fold( onError = { Log.e("!!!", it.message, it) } @@ -115,7 +108,7 @@ class ProfileViewModel( } fun stop() { - syncingJob?.cancel() + currentPageJob?.cancel() } } diff --git a/features/settings/build.gradle b/features/settings/build.gradle index 00ef2c06..7eb18c8a 100644 --- a/features/settings/build.gradle +++ b/features/settings/build.gradle @@ -1,8 +1,7 @@ applyAndroidComposeLibraryModule(project) dependencies { - implementation project(":matrix:services:sync") - implementation project(":matrix:services:crypto") + implementation project(":chat-engine") implementation project(":features:navigator") implementation project(':domains:store') implementation project(':domains:android:push') @@ -13,11 +12,10 @@ dependencies { kotlinTest(it) - androidImportFixturesWorkaround(project, project(":matrix:services:sync")) - androidImportFixturesWorkaround(project, project(":matrix:services:crypto")) androidImportFixturesWorkaround(project, project(":matrix:common")) androidImportFixturesWorkaround(project, project(":core")) androidImportFixturesWorkaround(project, project(":domains:store")) androidImportFixturesWorkaround(project, project(":domains:android:viewmodel")) androidImportFixturesWorkaround(project, project(":domains:android:stub")) + androidImportFixturesWorkaround(project, project(":chat-engine")) } \ No newline at end of file diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt index a36e2f1f..d054e64f 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt @@ -1,6 +1,11 @@ package app.dapk.st.settings -import app.dapk.st.core.* +import app.dapk.st.core.BuildMeta +import app.dapk.st.core.DeviceMeta +import app.dapk.st.core.ThemeStore +import app.dapk.st.core.isAtLeastS +import app.dapk.st.domain.application.eventlog.LoggingStore +import app.dapk.st.domain.application.message.MessageOptionsStore import app.dapk.st.push.PushTokenRegistrars internal class SettingsItemFactory( @@ -8,18 +13,19 @@ internal class SettingsItemFactory( private val deviceMeta: DeviceMeta, private val pushTokenRegistrars: PushTokenRegistrars, private val themeStore: ThemeStore, + private val loggingStore: LoggingStore, + private val messageOptionsStore: MessageOptionsStore, ) { - suspend fun root() = general() + theme() + data() + account() + about() + suspend fun root() = general() + theme() + data() + account() + advanced() + about() private suspend fun general() = listOf( SettingItem.Header("General"), SettingItem.Text(SettingItem.Id.Encryption, "Encryption"), - SettingItem.Text(SettingItem.Id.EventLog, "Event log"), SettingItem.Text(SettingItem.Id.PushProvider, "Push provider", pushTokenRegistrars.currentSelection().id) ) - private fun theme() = listOfNotNull( + private suspend fun theme() = listOfNotNull( SettingItem.Header("Theme"), SettingItem.Toggle(SettingItem.Id.ToggleDynamicTheme, "Enable Material You", state = themeStore.isMaterialYouEnabled()).takeIf { deviceMeta.isAtLeastS() @@ -36,6 +42,21 @@ internal class SettingsItemFactory( SettingItem.Text(SettingItem.Id.SignOut, "Sign out"), ) + private suspend fun advanced(): List { + val loggingIsEnabled = loggingStore.isEnabled() + return listOf( + SettingItem.Header("Advanced"), + SettingItem.Toggle( + SettingItem.Id.ToggleSendReadReceipts, + "Don't send message read receipts", + subtitle = "Requires the Homeserver to be running Synapse 1.65+", + state = messageOptionsStore.isReadReceiptsDisabled() + ), + SettingItem.Toggle(SettingItem.Id.ToggleEnableLogs, "Enable local logging", state = loggingIsEnabled), + SettingItem.Text(SettingItem.Id.EventLog, "Event log", enabled = loggingIsEnabled), + ) + } + private fun about() = listOf( SettingItem.Header("About"), SettingItem.Text(SettingItem.Id.PrivacyPolicy, "Privacy policy"), diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt index 4e24052c..b498f95b 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt @@ -3,33 +3,36 @@ package app.dapk.st.settings import android.content.ContentResolver import app.dapk.st.core.* import app.dapk.st.domain.StoreModule -import app.dapk.st.matrix.crypto.CryptoService -import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.domain.application.eventlog.LoggingStore +import app.dapk.st.domain.application.message.MessageOptionsStore +import app.dapk.st.engine.ChatEngine import app.dapk.st.push.PushModule import app.dapk.st.settings.eventlogger.EventLoggerViewModel class SettingsModule( + private val chatEngine: ChatEngine, private val storeModule: StoreModule, private val pushModule: PushModule, - private val cryptoService: CryptoService, - private val syncService: SyncService, private val contentResolver: ContentResolver, private val buildMeta: BuildMeta, private val deviceMeta: DeviceMeta, private val coroutineDispatchers: CoroutineDispatchers, private val themeStore: ThemeStore, + private val loggingStore: LoggingStore, + private val messageOptionsStore: MessageOptionsStore, ) : ProvidableModule { internal fun settingsViewModel(): SettingsViewModel { return SettingsViewModel( + chatEngine, storeModule.cacheCleaner(), contentResolver, - cryptoService, - syncService, UriFilenameResolver(contentResolver, coroutineDispatchers), - SettingsItemFactory(buildMeta, deviceMeta, pushModule.pushTokenRegistrars(), themeStore), + SettingsItemFactory(buildMeta, deviceMeta, pushModule.pushTokenRegistrars(), themeStore, loggingStore, messageOptionsStore), pushModule.pushTokenRegistrars(), themeStore, + loggingStore, + messageOptionsStore, ) } diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt index b10d9fa4..09522f25 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt @@ -5,7 +5,6 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.widget.Toast -import androidx.activity.compose.LocalActivityResultRegistryOwner import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable @@ -32,7 +31,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -41,12 +39,10 @@ import app.dapk.st.core.Lce import app.dapk.st.core.StartObserving import app.dapk.st.core.components.CenteredLoading import app.dapk.st.core.components.Header +import app.dapk.st.core.extensions.takeAs import app.dapk.st.core.getActivity -import app.dapk.st.design.components.SettingsTextRow -import app.dapk.st.design.components.Spider -import app.dapk.st.design.components.SpiderPage -import app.dapk.st.design.components.TextRow -import app.dapk.st.matrix.crypto.ImportResult +import app.dapk.st.design.components.* +import app.dapk.st.engine.ImportResult import app.dapk.st.navigator.Navigator import app.dapk.st.settings.SettingsEvent.* import app.dapk.st.settings.eventlogger.EventLogActivity @@ -67,7 +63,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, } Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) { item(Page.Routes.root) { - RootSettings(it) { viewModel.onClick(it) } + RootSettings(it, onClick = { viewModel.onClick(it) }, onRetry = { viewModel.start() }) } item(Page.Routes.encryption) { Encryption(viewModel, it) @@ -153,21 +149,19 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, } is ImportResult.Error -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - val message = when (val type = result.cause) { - ImportResult.Error.Type.NoKeysFound -> "No keys found in the file" - ImportResult.Error.Type.UnexpectedDecryptionOutput -> "Unable to decrypt file, double check your passphrase" - is ImportResult.Error.Type.Unknown -> "${type.cause::class.java.simpleName}: ${type.cause.message}" - ImportResult.Error.Type.UnableToOpenFile -> "Unable to open file" - } - - Text(text = "Import failed\n$message", textAlign = TextAlign.Center) - Spacer(modifier = Modifier.height(12.dp)) - Button(onClick = { navigator.navigate.upToHome() }) { - Text(text = "Close".uppercase()) - } - } + val message = when (result.cause) { + ImportResult.Error.Type.NoKeysFound -> "No keys found in the file" + ImportResult.Error.Type.UnexpectedDecryptionOutput -> "Unable to decrypt file, double check your passphrase" + is ImportResult.Error.Type.Unknown -> "Unknown error" + ImportResult.Error.Type.UnableToOpenFile -> "Unable to open file" + ImportResult.Error.Type.InvalidFile -> "Unable to process file" + } + GenericError( + message = message, + label = "Close", + cause = result.cause.takeAs()?.cause + ) { + navigator.navigate.upToHome() } } @@ -186,7 +180,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, } @Composable -private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) { +private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit, onRetry: () -> Unit) { when (val content = page.content) { is Lce.Content -> { LazyColumn( @@ -197,11 +191,11 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) { items(content.value) { item -> when (item) { is SettingItem.Text -> { - val itemOnClick = onClick.takeIf { item.id != SettingItem.Id.Ignored }?.let { - { it.invoke(item) } - } + val itemOnClick = onClick.takeIf { + item.id != SettingItem.Id.Ignored && item.enabled + }?.let { { it.invoke(item) } } - SettingsTextRow(item.content, item.subtitle, itemOnClick) + SettingsTextRow(item.content, item.subtitle, itemOnClick, enabled = item.enabled) } is SettingItem.AccessToken -> { @@ -223,7 +217,7 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) { } is SettingItem.Header -> Header(item.label) - is SettingItem.Toggle -> Toggle(item, onToggle = { + is SettingItem.Toggle -> SettingsToggleRow(item.content, item.subtitle, item.state, onToggle = { onClick(item) }) } @@ -232,33 +226,14 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) { } } - is Lce.Error -> { - // TODO - } + is Lce.Error -> GenericError(cause = content.cause, action = onRetry) is Lce.Loading -> { - // TODO + // Should be quick enough to avoid needing a loading state } } } -@Composable -private fun Toggle(item: SettingItem.Toggle, onToggle: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = item.content) - Switch( - checked = item.state, - onCheckedChange = { onToggle() } - ) - } -} - @Composable private fun Encryption(viewModel: SettingsViewModel, page: Page.Security) { Column { @@ -287,7 +262,7 @@ private fun PushProviders(viewModel: SettingsViewModel, state: Page.PushProvider } } - is Lce.Error -> TODO() + is Lce.Error -> GenericError(cause = lce.cause) { viewModel.fetchPushProviders() } } } @@ -313,6 +288,7 @@ private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) { is OpenUrl -> { context.startActivity(Intent(Intent.ACTION_VIEW).apply { data = it.url.toUri() }) } + RecreateActivity -> { context.getActivity()?.recreate() } diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt index ddf7c82d..ff796c71 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt @@ -2,10 +2,9 @@ package app.dapk.st.settings import android.net.Uri import app.dapk.st.core.Lce -import app.dapk.st.core.LceWithProgress import app.dapk.st.design.components.Route import app.dapk.st.design.components.SpiderPage -import app.dapk.st.matrix.crypto.ImportResult +import app.dapk.st.engine.ImportResult import app.dapk.st.push.Registrar internal data class SettingsScreenState( @@ -43,8 +42,8 @@ internal sealed interface SettingItem { val id: Id data class Header(val label: String, override val id: Id = Id.Ignored) : SettingItem - data class Text(override val id: Id, val content: String, val subtitle: String? = null) : SettingItem - data class Toggle(override val id: Id, val content: String, val state: Boolean) : SettingItem + data class Text(override val id: Id, val content: String, val subtitle: String? = null, val enabled: Boolean = true) : SettingItem + data class Toggle(override val id: Id, val content: String, val subtitle: String? = null, val state: Boolean) : SettingItem data class AccessToken(override val id: Id, val content: String, val accessToken: String) : SettingItem enum class Id { @@ -57,6 +56,8 @@ internal sealed interface SettingItem { PrivacyPolicy, Ignored, ToggleDynamicTheme, + ToggleEnableLogs, + ToggleSendReadReceipts, } } diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt index eed096ec..b74daf83 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt @@ -7,9 +7,10 @@ import app.dapk.st.core.Lce import app.dapk.st.core.ThemeStore import app.dapk.st.design.components.SpiderPage import app.dapk.st.domain.StoreCleaner -import app.dapk.st.matrix.crypto.CryptoService -import app.dapk.st.matrix.crypto.ImportResult -import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.domain.application.eventlog.LoggingStore +import app.dapk.st.domain.application.message.MessageOptionsStore +import app.dapk.st.engine.ChatEngine +import app.dapk.st.engine.ImportResult import app.dapk.st.push.PushTokenRegistrars import app.dapk.st.push.Registrar import app.dapk.st.settings.SettingItem.Id.* @@ -24,14 +25,15 @@ import kotlinx.coroutines.launch private const val PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/" internal class SettingsViewModel( + private val chatEngine: ChatEngine, private val cacheCleaner: StoreCleaner, private val contentResolver: ContentResolver, - private val cryptoService: CryptoService, - private val syncService: SyncService, private val uriFilenameResolver: UriFilenameResolver, private val settingsItemFactory: SettingsItemFactory, private val pushTokenRegistrars: PushTokenRegistrars, private val themeStore: ThemeStore, + private val loggingStore: LoggingStore, + private val messageOptionsStore: MessageOptionsStore, factory: MutableStateFactory = defaultStateFactory(), ) : DapkViewModel( initialState = SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading()))), @@ -52,31 +54,23 @@ internal class SettingsViewModel( fun onClick(item: SettingItem) { when (item.id) { - SignOut -> { - viewModelScope.launch { - cacheCleaner.cleanCache(removeCredentials = true) - _events.emit(SignedOut) - } + SignOut -> viewModelScope.launch { + cacheCleaner.cleanCache(removeCredentials = true) + _events.emit(SignedOut) } - AccessToken -> { - viewModelScope.launch { - require(item is SettingItem.AccessToken) - _events.emit(CopyToClipboard("Token copied", item.accessToken)) - } + AccessToken -> viewModelScope.launch { + require(item is SettingItem.AccessToken) + _events.emit(CopyToClipboard("Token copied", item.accessToken)) } - ClearCache -> { - viewModelScope.launch { - cacheCleaner.cleanCache(removeCredentials = false) - _events.emit(Toast(message = "Cache deleted")) - } + ClearCache -> viewModelScope.launch { + cacheCleaner.cleanCache(removeCredentials = false) + _events.emit(Toast(message = "Cache deleted")) } - EventLog -> { - viewModelScope.launch { - _events.emit(OpenEventLog) - } + EventLog -> viewModelScope.launch { + _events.emit(OpenEventLog) } Encryption -> { @@ -85,10 +79,8 @@ internal class SettingsViewModel( } } - PrivacyPolicy -> { - viewModelScope.launch { - _events.emit(OpenUrl(PRIVACY_POLICY_URL)) - } + PrivacyPolicy -> viewModelScope.launch { + _events.emit(OpenUrl(PRIVACY_POLICY_URL)) } PushProvider -> { @@ -100,16 +92,29 @@ internal class SettingsViewModel( Ignored -> { // do nothing } - ToggleDynamicTheme -> { - viewModelScope.launch { - themeStore.storeMaterialYouEnabled(!themeStore.isMaterialYouEnabled()) - start() - _events.emit(RecreateActivity) - } + + ToggleDynamicTheme -> viewModelScope.launch { + themeStore.storeMaterialYouEnabled(!themeStore.isMaterialYouEnabled()) + refreshRoot() + _events.emit(RecreateActivity) + + } + + ToggleEnableLogs -> viewModelScope.launch { + loggingStore.setEnabled(!loggingStore.isEnabled()) + refreshRoot() + } + + ToggleSendReadReceipts -> viewModelScope.launch { + messageOptionsStore.setReadReceiptsDisabled(!messageOptionsStore.isReadReceiptsDisabled()) + refreshRoot() } } } + private fun refreshRoot() { + start() + } fun fetchPushProviders() { updatePageState { copy(options = Lce.Loading()) } @@ -135,24 +140,13 @@ internal class SettingsViewModel( fun importFromFileKeys(file: Uri, passphrase: String) { updatePageState { copy(importProgress = ImportResult.Update(0)) } viewModelScope.launch { - with(cryptoService) { + with(chatEngine) { runCatching { contentResolver.openInputStream(file)!! } .fold( onSuccess = { fileStream -> fileStream.importRoomKeys(passphrase) .onEach { updatePageState { copy(importProgress = it) } - when (it) { - is ImportResult.Error -> { - // do nothing - } - is ImportResult.Update -> { - // do nothing - } - is ImportResult.Success -> { - syncService.forceManualRefresh(it.roomIds.toList()) - } - } } .launchIn(viewModelScope) }, diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt index 8a7d76ea..50cbfeea 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLogScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.dapk.st.core.AppLogTag import app.dapk.st.core.Lce +import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.design.components.GenericError import app.dapk.st.matrix.common.MatrixLogTag private val filterItems = listOf(null) + (MatrixLogTag.values().map { it.key } + AppLogTag.values().map { it.key }).distinct() @@ -33,11 +35,13 @@ fun EventLogScreen(viewModel: EventLoggerViewModel) { viewModel.selectLog(it, filter = null) } } + else -> { Events( selectedPageContent = state.selectedState, onExit = { viewModel.exitLog() }, - onSelectTag = { viewModel.selectLog(state.selectedState.selectedPage, it) } + onSelectTag = { viewModel.selectLog(state.selectedState.selectedPage, it) }, + onRetry = { viewModel.start() }, ) } } @@ -46,6 +50,7 @@ fun EventLogScreen(viewModel: EventLoggerViewModel) { is Lce.Error -> { // TODO } + is Lce.Loading -> { // TODO } @@ -69,7 +74,7 @@ private fun LogKeysList(keys: List, onSelected: (String) -> Unit) { } @Composable -private fun Events(selectedPageContent: SelectedState, onExit: () -> Unit, onSelectTag: (String?) -> Unit) { +private fun Events(selectedPageContent: SelectedState, onExit: () -> Unit, onSelectTag: (String?) -> Unit, onRetry: () -> Unit) { BackHandler(onBack = onExit) when (val content = selectedPageContent.content) { is Lce.Content -> { @@ -112,9 +117,8 @@ private fun Events(selectedPageContent: SelectedState, onExit: () -> Unit, onSel } } } - is Lce.Error -> TODO() - is Lce.Loading -> { - // TODO - } + + is Lce.Error -> GenericError(cause = content.cause, action = onRetry) + is Lce.Loading -> CenteredLoading() } } \ No newline at end of file diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerState.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerState.kt index 60bf4499..08a28eff 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerState.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerState.kt @@ -1,7 +1,7 @@ package app.dapk.st.settings.eventlogger import app.dapk.st.core.Lce -import app.dapk.st.domain.eventlog.LogLine +import app.dapk.st.domain.application.eventlog.LogLine data class EventLoggerState( val logs: Lce>, diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerViewModel.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerViewModel.kt index b85e0576..aa9d6afd 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerViewModel.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/eventlogger/EventLoggerViewModel.kt @@ -2,7 +2,7 @@ package app.dapk.st.settings.eventlogger import androidx.lifecycle.viewModelScope import app.dapk.st.core.Lce -import app.dapk.st.domain.eventlog.EventLogPersistence +import app.dapk.st.domain.application.eventlog.EventLogPersistence import app.dapk.st.viewmodel.DapkViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collect diff --git a/features/settings/src/test/kotlin/app/dapk/st/settings/FakeThemeStore.kt b/features/settings/src/test/kotlin/app/dapk/st/settings/FakeThemeStore.kt index f41b68d4..9fe4b7e6 100644 --- a/features/settings/src/test/kotlin/app/dapk/st/settings/FakeThemeStore.kt +++ b/features/settings/src/test/kotlin/app/dapk/st/settings/FakeThemeStore.kt @@ -1,6 +1,7 @@ package app.dapk.st.settings import app.dapk.st.core.ThemeStore +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import test.delegateReturn @@ -8,5 +9,5 @@ import test.delegateReturn class FakeThemeStore { val instance = mockk() - fun givenMaterialYouIsEnabled() = every { instance.isMaterialYouEnabled() }.delegateReturn() + fun givenMaterialYouIsEnabled() = coEvery { instance.isMaterialYouEnabled() }.delegateReturn() } \ No newline at end of file diff --git a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt index b34c3625..46a1620c 100644 --- a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt +++ b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt @@ -4,6 +4,8 @@ import app.dapk.st.core.BuildMeta import app.dapk.st.core.DeviceMeta import app.dapk.st.push.PushTokenRegistrars import app.dapk.st.push.Registrar +import fake.FakeLoggingStore +import fake.FakeMessageOptionsStore import internalfixture.aSettingHeaderItem import internalfixture.aSettingTextItem import io.mockk.coEvery @@ -15,6 +17,8 @@ import test.delegateReturn private val A_SELECTION = Registrar("A_SELECTION") private const val ENABLED_MATERIAL_YOU = true +private const val DISABLED_LOGGING = false +private const val DISABLED_READ_RECEIPTS = true class SettingsItemFactoryTest { @@ -22,20 +26,30 @@ class SettingsItemFactoryTest { private val deviceMeta = DeviceMeta(apiVersion = 31) private val fakePushTokenRegistrars = FakePushRegistrars() private val fakeThemeStore = FakeThemeStore() + private val fakeLoggingStore = FakeLoggingStore() + private val fakeMessageOptionsStore = FakeMessageOptionsStore() - private val settingsItemFactory = SettingsItemFactory(buildMeta, deviceMeta, fakePushTokenRegistrars.instance, fakeThemeStore.instance) + private val settingsItemFactory = SettingsItemFactory( + buildMeta, + deviceMeta, + fakePushTokenRegistrars.instance, + fakeThemeStore.instance, + fakeLoggingStore.instance, + fakeMessageOptionsStore.instance, + ) @Test fun `when creating root items, then is expected`() = runTest { fakePushTokenRegistrars.givenCurrentSelection().returns(A_SELECTION) fakeThemeStore.givenMaterialYouIsEnabled().returns(ENABLED_MATERIAL_YOU) + fakeLoggingStore.givenLoggingIsEnabled().returns(DISABLED_LOGGING) + fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(DISABLED_READ_RECEIPTS) val result = settingsItemFactory.root() result shouldBeEqualTo listOf( aSettingHeaderItem("General"), aSettingTextItem(SettingItem.Id.Encryption, "Encryption"), - aSettingTextItem(SettingItem.Id.EventLog, "Event log"), aSettingTextItem(SettingItem.Id.PushProvider, "Push provider", A_SELECTION.id), SettingItem.Header("Theme"), SettingItem.Toggle(SettingItem.Id.ToggleDynamicTheme, "Enable Material You", state = ENABLED_MATERIAL_YOU), @@ -43,6 +57,15 @@ class SettingsItemFactoryTest { aSettingTextItem(SettingItem.Id.ClearCache, "Clear cache"), aSettingHeaderItem("Account"), aSettingTextItem(SettingItem.Id.SignOut, "Sign out"), + aSettingHeaderItem("Advanced"), + SettingItem.Toggle( + SettingItem.Id.ToggleSendReadReceipts, + "Don't send message read receipts", + subtitle = "Requires the Homeserver to be running Synapse 1.65+", + state = DISABLED_READ_RECEIPTS + ), + SettingItem.Toggle(SettingItem.Id.ToggleEnableLogs, "Enable local logging", state = DISABLED_LOGGING), + aSettingTextItem(SettingItem.Id.EventLog, "Event log", enabled = DISABLED_LOGGING), aSettingHeaderItem("About"), aSettingTextItem(SettingItem.Id.PrivacyPolicy, "Privacy policy"), aSettingTextItem(SettingItem.Id.Ignored, "Version", buildMeta.versionName), diff --git a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt index 6a6481e1..98c4f121 100644 --- a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt +++ b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt @@ -3,9 +3,8 @@ package app.dapk.st.settings import ViewModelTest import app.dapk.st.core.Lce import app.dapk.st.design.components.SpiderPage -import app.dapk.st.matrix.crypto.ImportResult +import app.dapk.st.engine.ImportResult import fake.* -import fixture.FakeStoreCleaner import fixture.aRoomId import internalfake.FakeSettingsItemFactory import internalfake.FakeUriFilenameResolver @@ -35,22 +34,24 @@ internal class SettingsViewModelTest { private val fakeStoreCleaner = FakeStoreCleaner() private val fakeContentResolver = FakeContentResolver() - private val fakeCryptoService = FakeCryptoService() - private val fakeSyncService = FakeSyncService() private val fakeUriFilenameResolver = FakeUriFilenameResolver() private val fakePushTokenRegistrars = FakePushRegistrars() private val fakeSettingsItemFactory = FakeSettingsItemFactory() private val fakeThemeStore = FakeThemeStore() + private val fakeLoggingStore = FakeLoggingStore() + private val fakeMessageOptionsStore = FakeMessageOptionsStore() + private val fakeChatEngine = FakeChatEngine() private val viewModel = SettingsViewModel( + fakeChatEngine, fakeStoreCleaner, fakeContentResolver.instance, - fakeCryptoService, - fakeSyncService, fakeUriFilenameResolver.instance, fakeSettingsItemFactory.instance, fakePushTokenRegistrars.instance, fakeThemeStore.instance, + fakeLoggingStore.instance, + fakeMessageOptionsStore.instance, runViewModelTest.testMutableStateFactory(), ) @@ -170,9 +171,8 @@ internal class SettingsViewModelTest { @Test fun `given success when importing room keys, then emits progress`() = runViewModelTest { - fakeSyncService.expectUnit { it.forceManualRefresh(A_LIST_OF_ROOM_IDS) } fakeContentResolver.givenFile(A_URI.instance).returns(AN_INPUT_STREAM.instance) - fakeCryptoService.givenImportKeys(AN_INPUT_STREAM.instance, A_PASSPHRASE).returns(flowOf(AN_IMPORT_SUCCESS)) + fakeChatEngine.givenImportKeys(AN_INPUT_STREAM.instance, A_PASSPHRASE).returns(flowOf(AN_IMPORT_SUCCESS)) viewModel .test(initialState = SettingsScreenState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION)) diff --git a/features/settings/src/test/kotlin/internalfixture/SettingItemFixture.kt b/features/settings/src/test/kotlin/internalfixture/SettingItemFixture.kt index 5625fe71..3d01eb3a 100644 --- a/features/settings/src/test/kotlin/internalfixture/SettingItemFixture.kt +++ b/features/settings/src/test/kotlin/internalfixture/SettingItemFixture.kt @@ -5,8 +5,9 @@ import app.dapk.st.settings.SettingItem internal fun aSettingTextItem( id: SettingItem.Id = SettingItem.Id.Ignored, content: String = "text-content", - subtitle: String? = null -) = SettingItem.Text(id, content, subtitle) + subtitle: String? = null, + enabled: Boolean = true, +) = SettingItem.Text(id, content, subtitle, enabled) internal fun aSettingHeaderItem( label: String = "header-label", diff --git a/features/share-entry/build.gradle b/features/share-entry/build.gradle index 814f902c..176afb2e 100644 --- a/features/share-entry/build.gradle +++ b/features/share-entry/build.gradle @@ -4,9 +4,7 @@ dependencies { implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") implementation project(':domains:store') - implementation project(':matrix:services:sync') - implementation project(':matrix:services:room') - implementation project(':matrix:services:message') + implementation project(':chat-engine') implementation project(":core") implementation project(":design-library") implementation project(":features:navigator") diff --git a/features/share-entry/src/main/kotlin/app/dapk/st/share/FetchRoomsUseCase.kt b/features/share-entry/src/main/kotlin/app/dapk/st/share/FetchRoomsUseCase.kt index 18aca0c5..2b681ea5 100644 --- a/features/share-entry/src/main/kotlin/app/dapk/st/share/FetchRoomsUseCase.kt +++ b/features/share-entry/src/main/kotlin/app/dapk/st/share/FetchRoomsUseCase.kt @@ -1,21 +1,20 @@ package app.dapk.st.share -import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.engine.ChatEngine import kotlinx.coroutines.flow.first class FetchRoomsUseCase( - private val syncSyncService: SyncService, - private val roomService: RoomService, + private val chatEngine: ChatEngine, ) { - suspend fun bar(): List { - return syncSyncService.overview().first().map { + suspend fun fetch(): List { + return chatEngine.directory().first().map { + val overview = it.overview Item( - it.roomId, - it.roomAvatarUrl, - it.roomName ?: "", - roomService.findMembersSummary(it.roomId).map { it.displayName ?: it.id.value } + overview.roomId, + overview.roomAvatarUrl, + overview.roomName ?: "", + chatEngine.findMembersSummary(overview.roomId).map { it.displayName ?: it.id.value } ) } } diff --git a/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryModule.kt b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryModule.kt index ac0f61b8..0697547f 100644 --- a/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryModule.kt +++ b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryModule.kt @@ -1,15 +1,13 @@ package app.dapk.st.share import app.dapk.st.core.ProvidableModule -import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.engine.ChatEngine class ShareEntryModule( - private val syncService: SyncService, - private val roomService: RoomService, + private val chatEngine: ChatEngine, ) : ProvidableModule { fun shareEntryViewModel(): ShareEntryViewModel { - return ShareEntryViewModel(FetchRoomsUseCase(syncService, roomService)) + return ShareEntryViewModel(FetchRoomsUseCase(chatEngine)) } } \ No newline at end of file diff --git a/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryViewModel.kt b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryViewModel.kt index ddd6fbe7..4e7d6bfc 100644 --- a/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryViewModel.kt +++ b/features/share-entry/src/main/kotlin/app/dapk/st/share/ShareEntryViewModel.kt @@ -22,7 +22,7 @@ class ShareEntryViewModel( fun start() { syncJob = viewModelScope.launch { - state = DirectoryScreenState.Content(fetchRoomsUseCase.bar()) + state = DirectoryScreenState.Content(fetchRoomsUseCase.fetch()) } } diff --git a/matrix-chat-engine/build.gradle b/matrix-chat-engine/build.gradle new file mode 100644 index 00000000..7d6b2214 --- /dev/null +++ b/matrix-chat-engine/build.gradle @@ -0,0 +1,34 @@ +plugins { + id 'java-test-fixtures' + id 'kotlin' +} + +dependencies { + api Dependencies.mavenCentral.kotlinCoroutinesCore + + implementation project(":core") + implementation project(":chat-engine") + + implementation project(":domains:olm") + + implementation project(":matrix:matrix") + implementation project(":matrix:matrix-http-ktor") + implementation project(":matrix:services:auth") + implementation project(":matrix:services:sync") + implementation project(":matrix:services:room") + implementation project(":matrix:services:push") + implementation project(":matrix:services:message") + implementation project(":matrix:services:device") + implementation project(":matrix:services:crypto") + implementation project(":matrix:services:profile") + + kotlinTest(it) + kotlinFixtures(it) + + testImplementation(testFixtures(project(":matrix:services:sync"))) + testImplementation(testFixtures(project(":matrix:services:message"))) + testImplementation(testFixtures(project(":matrix:common"))) + testImplementation(testFixtures(project(":core"))) + testImplementation(testFixtures(project(":domains:store"))) + testImplementation(testFixtures(project(":chat-engine"))) +} \ No newline at end of file diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt similarity index 80% rename from features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt rename to matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt index b6a7bf61..e21596fa 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt @@ -1,4 +1,4 @@ -package app.dapk.st.directory +package app.dapk.st.engine import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.RoomId @@ -6,22 +6,12 @@ import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.* +import app.dapk.st.matrix.sync.RoomStore +import app.dapk.st.matrix.sync.SyncService import app.dapk.st.matrix.sync.SyncService.SyncEvent.Typing import kotlinx.coroutines.flow.* -@JvmInline -value class UnreadCount(val value: Int) - -typealias DirectoryState = List - -data class RoomFoo( - val overview: RoomOverview, - val unreadCount: UnreadCount, - val typing: Typing? -) - -class DirectoryUseCase( +internal class DirectoryUseCase( private val syncService: SyncService, private val messageService: MessageService, private val roomService: RoomService, @@ -38,10 +28,10 @@ class DirectoryUseCase( syncService.events() ) { overviewState, localEchos, unread, events -> overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview -> - RoomFoo( + DirectoryItem( overview = roomOverview, unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0), - typing = events.filterIsInstance().firstOrNull { it.roomId == roomOverview.roomId } + typing = events.filterIsInstance().firstOrNull { it.roomId == roomOverview.roomId }?.engine() ) } } @@ -49,14 +39,9 @@ class DirectoryUseCase( } private fun overviewDatasource() = combine( - syncService.startSyncing().map { false }.onStart { emit(true) }, - syncService.overview() - ) { isFirstLoad, overview -> - when { - isFirstLoad && overview.isEmpty() -> null - else -> overview - } - }.filterNotNull() + syncService.startSyncing(), + syncService.overview().map { it.map { it.engine() } } + ) { _, overview -> overview }.filterNotNull() private suspend fun OverviewState.mergeWithLocalEchos(localEchos: Map>, userId: UserId): OverviewState { return when { @@ -81,7 +66,7 @@ class DirectoryUseCase( val latestEcho = echos.maxByOrNull { it.timestampUtc } return if (latestEcho != null && latestEcho.timestampUtc > (this.lastMessage?.utcTimestamp ?: 0)) { this.copy( - lastMessage = LastMessage( + lastMessage = RoomOverview.LastMessage( content = when (val message = latestEcho.message) { is MessageService.Message.TextMessage -> message.content.body is MessageService.Message.ImageMessage -> "\uD83D\uDCF7" @@ -96,3 +81,6 @@ class DirectoryUseCase( } } + + + diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/InviteUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/InviteUseCase.kt new file mode 100644 index 00000000..d688bdfe --- /dev/null +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/InviteUseCase.kt @@ -0,0 +1,19 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.sync.SyncService +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map + +class InviteUseCase( + private val syncService: SyncService +) { + + fun invites() = invitesDatasource() + + private fun invitesDatasource() = combine( + syncService.startSyncing(), + syncService.invites().map { it.map { it.engine() } } + ) { _, invites -> invites }.filterNotNull() + +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalEchoMapper.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/LocalEchoMapper.kt similarity index 95% rename from features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalEchoMapper.kt rename to matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/LocalEchoMapper.kt index 8fc2fa71..93e3740b 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalEchoMapper.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/LocalEchoMapper.kt @@ -1,10 +1,8 @@ -package app.dapk.st.messenger +package app.dapk.st.engine import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.sync.MessageMeta -import app.dapk.st.matrix.sync.RoomEvent internal class LocalEchoMapper(private val metaMapper: MetaMapper) { @@ -49,6 +47,7 @@ internal class LocalEchoMapper(private val metaMapper: MetaMapper) { is RoomEvent.Message -> this.copy(meta = metaMapper.toMeta(echo)) is RoomEvent.Reply -> this.copy(message = this.message.mergeWith(echo)) is RoomEvent.Image -> this.copy(meta = metaMapper.toMeta(echo)) + is RoomEvent.Encrypted -> this.copy(meta = metaMapper.toMeta(echo)) } } diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/LocalIdFactory.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/LocalIdFactory.kt new file mode 100644 index 00000000..57b37ebb --- /dev/null +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/LocalIdFactory.kt @@ -0,0 +1,7 @@ +package app.dapk.st.engine + +import java.util.* + +internal class LocalIdFactory { + fun create() = "local.${UUID.randomUUID()}" +} \ No newline at end of file diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MappingExtensions.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MappingExtensions.kt new file mode 100644 index 00000000..94a7587c --- /dev/null +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MappingExtensions.kt @@ -0,0 +1,117 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.auth.AuthService +import app.dapk.st.matrix.sync.InviteMeta +import app.dapk.st.matrix.auth.AuthService.LoginRequest as MatrixLoginRequest +import app.dapk.st.matrix.auth.AuthService.LoginResult as MatrixLoginResult +import app.dapk.st.matrix.crypto.ImportResult as MatrixImportResult +import app.dapk.st.matrix.room.ProfileService.Me as MatrixMe +import app.dapk.st.matrix.sync.LastMessage as MatrixLastMessage +import app.dapk.st.matrix.sync.MessageMeta as MatrixMessageMeta +import app.dapk.st.matrix.sync.RoomEvent as MatrixRoomEvent +import app.dapk.st.matrix.sync.RoomInvite as MatrixRoomInvite +import app.dapk.st.matrix.sync.RoomOverview as MatrixRoomOverview +import app.dapk.st.matrix.sync.RoomState as MatrixRoomState +import app.dapk.st.matrix.sync.SyncService.SyncEvent.Typing as MatrixTyping + +fun MatrixRoomOverview.engine() = RoomOverview( + this.roomId, + this.roomCreationUtc, + this.roomName, + this.roomAvatarUrl, + this.lastMessage?.engine(), + this.isGroup, + this.readMarker, + this.isEncrypted +) + +fun MatrixLastMessage.engine() = RoomOverview.LastMessage( + this.content, + this.utcTimestamp, + this.author, +) + +fun MatrixTyping.engine() = Typing( + this.roomId, + this.members, +) + +fun LoginRequest.engine() = MatrixLoginRequest( + this.userName, + this.password, + this.serverUrl +) + +fun MatrixLoginResult.engine() = when (this) { + is AuthService.LoginResult.Error -> LoginResult.Error(this.cause) + AuthService.LoginResult.MissingWellKnown -> LoginResult.MissingWellKnown + is AuthService.LoginResult.Success -> LoginResult.Success(this.userCredentials) +} + +fun MatrixMe.engine() = Me( + this.userId, + this.displayName, + this.avatarUrl, + this.homeServerUrl, +) + +fun MatrixRoomInvite.engine() = RoomInvite( + this.from, + this.roomId, + this.inviteMeta.engine(), +) + +fun InviteMeta.engine() = when (this) { + InviteMeta.DirectMessage -> RoomInvite.InviteMeta.DirectMessage + is InviteMeta.Room -> RoomInvite.InviteMeta.Room(this.roomName) +} + +fun MatrixImportResult.engine() = when (this) { + is MatrixImportResult.Error -> ImportResult.Error( + when (val error = this.cause) { + MatrixImportResult.Error.Type.InvalidFile -> ImportResult.Error.Type.InvalidFile + MatrixImportResult.Error.Type.NoKeysFound -> ImportResult.Error.Type.NoKeysFound + MatrixImportResult.Error.Type.UnableToOpenFile -> ImportResult.Error.Type.UnableToOpenFile + MatrixImportResult.Error.Type.UnexpectedDecryptionOutput -> ImportResult.Error.Type.UnexpectedDecryptionOutput + is MatrixImportResult.Error.Type.Unknown -> ImportResult.Error.Type.Unknown(error.cause) + } + ) + + is MatrixImportResult.Success -> ImportResult.Success(this.roomIds, this.totalImportedKeysCount) + is MatrixImportResult.Update -> ImportResult.Update(this.importedKeysCount) +} + +fun MatrixRoomState.engine() = RoomState( + this.roomOverview.engine(), + this.events.map { it.engine() } +) + +fun MatrixRoomEvent.engine(): RoomEvent = when (this) { + is MatrixRoomEvent.Image -> RoomEvent.Image(this.eventId, this.utcTimestamp, this.imageMeta.engine(), this.author, this.meta.engine(), this.edited) + is MatrixRoomEvent.Message -> RoomEvent.Message(this.eventId, this.utcTimestamp, this.content, this.author, this.meta.engine(), this.edited, this.redacted) + is MatrixRoomEvent.Reply -> RoomEvent.Reply(this.message.engine(), this.replyingTo.engine()) + is MatrixRoomEvent.Encrypted -> RoomEvent.Encrypted(this.eventId, this.utcTimestamp, this.author, this.meta.engine()) +} + +fun MatrixRoomEvent.Image.ImageMeta.engine() = RoomEvent.Image.ImageMeta( + this.width, + this.height, + this.url, + this.keys?.let { RoomEvent.Image.ImageMeta.Keys(it.k, it.iv, it.v, it.hashes) } +) + +fun MatrixMessageMeta.engine() = when (this) { + MatrixMessageMeta.FromServer -> MessageMeta.FromServer + is MatrixMessageMeta.LocalEcho -> MessageMeta.LocalEcho( + this.echoId, when (val echo = this.state) { + is MatrixMessageMeta.LocalEcho.State.Error -> MessageMeta.LocalEcho.State.Error( + echo.message, when (echo.type) { + MatrixMessageMeta.LocalEcho.State.Error.Type.UNKNOWN -> MessageMeta.LocalEcho.State.Error.Type.UNKNOWN + } + ) + + MatrixMessageMeta.LocalEcho.State.Sending -> MessageMeta.LocalEcho.State.Sending + MatrixMessageMeta.LocalEcho.State.Sent -> MessageMeta.LocalEcho.State.Sent + } + ) +} \ No newline at end of file diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt new file mode 100644 index 00000000..71981fcb --- /dev/null +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt @@ -0,0 +1,430 @@ +package app.dapk.st.engine + +import app.dapk.st.core.Base64 +import app.dapk.st.core.BuildMeta +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.SingletonFlows +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.matrix.MatrixClient +import app.dapk.st.matrix.MatrixTaskRunner +import app.dapk.st.matrix.auth.DeviceDisplayNameGenerator +import app.dapk.st.matrix.auth.authService +import app.dapk.st.matrix.auth.installAuthService +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.crypto.* +import app.dapk.st.matrix.device.KnownDeviceStore +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.* +import app.dapk.st.matrix.message.internal.ImageContentReader +import app.dapk.st.matrix.push.installPushService +import app.dapk.st.matrix.push.pushService +import app.dapk.st.matrix.room.* +import app.dapk.st.matrix.sync.* +import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent +import app.dapk.st.matrix.sync.internal.room.MessageDecrypter +import app.dapk.st.olm.DeviceKeyFactory +import app.dapk.st.olm.OlmStore +import app.dapk.st.olm.OlmWrapper +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import java.io.InputStream +import java.time.Clock + +class MatrixEngine internal constructor( + private val directoryUseCase: Lazy, + private val matrix: Lazy, + private val timelineUseCase: Lazy, + private val sendMessageUseCase: Lazy, + private val matrixMediaDecrypter: Lazy, + private val matrixPushHandler: Lazy, + private val inviteUseCase: Lazy, + private val notificationMessagesUseCase: Lazy, + private val notificationInvitesUseCase: Lazy, +) : ChatEngine { + + override fun directory() = directoryUseCase.value.state() + override fun invites() = inviteUseCase.value.invites() + + override fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow { + return timelineUseCase.value.fetch(roomId, isReadReceiptsDisabled = disableReadReceipts) + } + + override fun notificationsMessages(): Flow { + return notificationMessagesUseCase.value.invoke() + } + + override fun notificationsInvites(): Flow { + return notificationInvitesUseCase.value.invoke() + } + + override suspend fun login(request: LoginRequest): LoginResult { + return matrix.value.authService().login(request.engine()).engine() + } + + override suspend fun me(forceRefresh: Boolean): Me { + return matrix.value.profileService().me(forceRefresh).engine() + } + + override suspend fun InputStream.importRoomKeys(password: String): Flow { + return with(matrix.value.cryptoService()) { + importRoomKeys(password).map { it.engine() }.onEach { + when (it) { + is ImportResult.Error, + is ImportResult.Update -> { + // do nothing + } + + is ImportResult.Success -> matrix.value.syncService().forceManualRefresh(it.roomIds) + } + } + } + } + + override suspend fun send(message: SendMessage, room: RoomOverview) { + sendMessageUseCase.value.send(message, room) + } + + override suspend fun registerPushToken(token: String, gatewayUrl: String) { + matrix.value.pushService().registerPush(token, gatewayUrl) + } + + override suspend fun joinRoom(roomId: RoomId) { + matrix.value.roomService().joinRoom(roomId) + } + + override suspend fun rejectJoinRoom(roomId: RoomId) { + matrix.value.roomService().rejectJoinRoom(roomId) + } + + override suspend fun findMembersSummary(roomId: RoomId) = matrix.value.roomService().findMembersSummary(roomId) + + override fun mediaDecrypter(): MediaDecrypter { + val mediaDecrypter = matrixMediaDecrypter.value + return object : MediaDecrypter { + override fun decrypt(input: InputStream, k: String, iv: String): MediaDecrypter.Collector { + return MediaDecrypter.Collector { + mediaDecrypter.decrypt(input, k, iv).collect(it) + } + } + } + } + + override fun pushHandler() = matrixPushHandler.value + + override suspend fun runTask(task: ChatEngineTask): TaskRunner.TaskResult { + return when (val result = matrix.value.run(MatrixTaskRunner.MatrixTask(task.type, task.jsonPayload))) { + is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(result.canRetry) + MatrixTaskRunner.TaskResult.Success -> TaskRunner.TaskResult.Success + } + } + + class Factory { + + fun create( + base64: Base64, + buildMeta: BuildMeta, + logger: MatrixLogger, + nameGenerator: DeviceDisplayNameGenerator, + coroutineDispatchers: CoroutineDispatchers, + errorTracker: ErrorTracker, + imageContentReader: ImageContentReader, + backgroundScheduler: BackgroundScheduler, + memberStore: MemberStore, + roomStore: RoomStore, + profileStore: ProfileStore, + syncStore: SyncStore, + overviewStore: OverviewStore, + filterStore: FilterStore, + localEchoStore: LocalEchoStore, + credentialsStore: CredentialsStore, + knownDeviceStore: KnownDeviceStore, + olmStore: OlmStore, + ): ChatEngine { + val lazyMatrix = lazy { + MatrixFactory.createMatrix( + base64, + buildMeta, + logger, + nameGenerator, + coroutineDispatchers, + errorTracker, + imageContentReader, + backgroundScheduler, + memberStore, + roomStore, + profileStore, + syncStore, + overviewStore, + filterStore, + localEchoStore, + credentialsStore, + knownDeviceStore, + olmStore + ) + } + val directoryUseCase = unsafeLazy { + val matrix = lazyMatrix.value + DirectoryUseCase( + matrix.syncService(), + matrix.messageService(), + matrix.roomService(), + credentialsStore, + roomStore + ) + } + val timelineUseCase = unsafeLazy { + val matrix = lazyMatrix.value + val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(LocalEchoMapper(MetaMapper())) + val timeline = TimelineUseCaseImpl(matrix.syncService(), matrix.messageService(), matrix.roomService(), mergeWithLocalEchosUseCase) + ReadMarkingTimeline(roomStore, credentialsStore, timeline, matrix.roomService()) + } + + val sendMessageUseCase = unsafeLazy { + val matrix = lazyMatrix.value + SendMessageUseCase(matrix.messageService(), LocalIdFactory(), imageContentReader, Clock.systemUTC()) + } + + val mediaDecrypter = unsafeLazy { MatrixMediaDecrypter(base64) } + val pushHandler = unsafeLazy { MatrixPushHandler(backgroundScheduler, credentialsStore, lazyMatrix.value.syncService(), roomStore) } + + val invitesUseCase = unsafeLazy { InviteUseCase(lazyMatrix.value.syncService()) } + + return MatrixEngine( + directoryUseCase, + lazyMatrix, + timelineUseCase, + sendMessageUseCase, + mediaDecrypter, + pushHandler, + invitesUseCase, + unsafeLazy { ObserveUnreadNotificationsUseCaseImpl(roomStore) }, + unsafeLazy { ObserveInviteNotificationsUseCaseImpl(overviewStore) }, + ) + } + + } + +} + + +object MatrixFactory { + + fun createMatrix( + base64: Base64, + buildMeta: BuildMeta, + logger: MatrixLogger, + nameGenerator: DeviceDisplayNameGenerator, + coroutineDispatchers: CoroutineDispatchers, + errorTracker: ErrorTracker, + imageContentReader: ImageContentReader, + backgroundScheduler: BackgroundScheduler, + memberStore: MemberStore, + roomStore: RoomStore, + profileStore: ProfileStore, + syncStore: SyncStore, + overviewStore: OverviewStore, + filterStore: FilterStore, + localEchoStore: LocalEchoStore, + credentialsStore: CredentialsStore, + knownDeviceStore: KnownDeviceStore, + olmStore: OlmStore, + ) = MatrixClient( + KtorMatrixHttpClientFactory( + credentialsStore, + includeLogging = buildMeta.isDebug, + ), + logger + ).also { + it.install { + installAuthService(credentialsStore, nameGenerator) + installEncryptionService(knownDeviceStore) + + val singletonFlows = SingletonFlows(coroutineDispatchers) + val olm = OlmWrapper( + olmStore = olmStore, + singletonFlows = singletonFlows, + jsonCanonicalizer = JsonCanonicalizer(), + deviceKeyFactory = DeviceKeyFactory(JsonCanonicalizer()), + errorTracker = errorTracker, + logger = logger, + clock = Clock.systemUTC(), + coroutineDispatchers = coroutineDispatchers, + ) + installCryptoService( + credentialsStore, + olm, + roomMembersProvider = { services -> + RoomMembersProvider { + services.roomService().joinedMembers(it).map { it.userId } + } + }, + base64 = base64, + coroutineDispatchers = coroutineDispatchers, + ) + installMessageService( + localEchoStore, + backgroundScheduler, + 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, + ) + } + }, + mediaEncrypter = { + val cryptoService = it.cryptoService() + MediaEncrypter { input -> + val result = cryptoService.encrypt(input) + MediaEncrypter.Result( + uri = result.uri, + contentLength = result.contentLength, + 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( + memberStore, + roomMessenger = { + val messageService = it.messageService() + object : RoomMessenger { + override suspend fun enableEncryption(roomId: RoomId) { + messageService.sendEventMessage( + roomId, MessageService.EventMessage.Encryption( + algorithm = AlgorithmName("m.megolm.v1.aes-sha2") + ) + ) + } + } + }, + roomInviteRemover = { + overviewStore.removeInvites(listOf(it)) + } + ) + + installProfileService(profileStore, singletonFlows, credentialsStore) + + installSyncService( + credentialsStore, + overviewStore, + roomStore, + syncStore, + filterStore, + deviceNotifier = { services -> + val encryption = services.deviceService() + val crypto = services.cryptoService() + DeviceNotifier { userIds, syncToken -> + encryption.updateStaleDevices(userIds) + crypto.updateOlmSession(userIds, syncToken) + } + }, + messageDecrypter = { serviceProvider -> + val cryptoService = serviceProvider.cryptoService() + MessageDecrypter { + cryptoService.decrypt(it) + } + }, + keySharer = { serviceProvider -> + val cryptoService = serviceProvider.cryptoService() + KeySharer { sharedRoomKeys -> + cryptoService.importRoomKeys(sharedRoomKeys) + } + }, + verificationHandler = { services -> + val cryptoService = services.cryptoService() + VerificationHandler { apiEvent -> + logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it") + cryptoService.onVerificationEvent( + when (apiEvent) { + is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.transactionId, + apiEvent.content.methods, + apiEvent.content.timestampPosix, + ) + + is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.transactionId, + apiEvent.content.methods, + ) + + is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.method, + apiEvent.content.protocols, + apiEvent.content.hashes, + apiEvent.content.codes, + apiEvent.content.short, + apiEvent.content.transactionId, + ) + + is ApiToDeviceEvent.VerificationCancel -> TODO() + is ApiToDeviceEvent.VerificationAccept -> TODO() + is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( + apiEvent.sender, + apiEvent.content.transactionId, + apiEvent.content.key + ) + + is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( + apiEvent.sender, + apiEvent.content.transactionId, + apiEvent.content.keys, + apiEvent.content.mac, + ) + } + ) + } + }, + oneTimeKeyProducer = { services -> + val cryptoService = services.cryptoService() + MaybeCreateMoreKeys { + cryptoService.maybeCreateMoreKeys(it) + } + }, + roomMembersService = { services -> + val roomService = services.roomService() + object : RoomMembersService { + override suspend fun find(roomId: RoomId, userIds: List) = roomService.findMembers(roomId, userIds) + override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId) + override suspend fun insert(roomId: RoomId, members: List) = roomService.insertMembers(roomId, members) + } + }, + errorTracker = errorTracker, + coroutineDispatchers = coroutineDispatchers, + ) + + installPushService(credentialsStore) + } + } + +} + +fun unsafeLazy(initializer: () -> T): Lazy = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer) diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/MatrixPushHandler.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixPushHandler.kt similarity index 74% rename from features/notifications/src/main/kotlin/app/dapk/st/notifications/MatrixPushHandler.kt rename to matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixPushHandler.kt index 5b39268a..f9cac913 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/MatrixPushHandler.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixPushHandler.kt @@ -1,36 +1,34 @@ -package app.dapk.st.notifications +package app.dapk.st.engine import app.dapk.st.core.AppLogTag import app.dapk.st.core.log import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.JsonString import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.message.BackgroundScheduler import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.matrix.sync.SyncService -import app.dapk.st.push.PushHandler -import app.dapk.st.push.PushTokenPayload -import app.dapk.st.work.WorkScheduler import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.serialization.json.Json private var previousJob: Job? = null @OptIn(DelicateCoroutinesApi::class) class MatrixPushHandler( - private val workScheduler: WorkScheduler, + private val backgroundScheduler: BackgroundScheduler, private val credentialsStore: CredentialsStore, private val syncService: SyncService, private val roomStore: RoomStore, ) : PushHandler { - override fun onNewToken(payload: PushTokenPayload) { + override fun onNewToken(payload: JsonString) { log(AppLogTag.PUSH, "new push token received") - workScheduler.schedule( - WorkScheduler.WorkTask( + backgroundScheduler.schedule( + key = "2", + task = BackgroundScheduler.Task( type = "push_token", - jobId = 2, - jsonPayload = Json.encodeToString(PushTokenPayload.serializer(), payload) + jsonPayload = payload ) ) } @@ -66,7 +64,7 @@ class MatrixPushHandler( private suspend fun waitForEvent(timeout: Long, eventId: EventId): EventId? { return withTimeoutOrNull(timeout) { - combine(syncService.startSyncing().startInstantly(), syncService.observeEvent(eventId)) { _, event -> event } + combine(syncService.startSyncing(), syncService.observeEvent(eventId)) { _, event -> event } .firstOrNull { it == eventId } @@ -75,11 +73,9 @@ class MatrixPushHandler( private suspend fun waitForUnreadChange(timeout: Long): String? { return withTimeoutOrNull(timeout) { - combine(syncService.startSyncing().startInstantly(), roomStore.observeUnread()) { _, unread -> unread } + combine(syncService.startSyncing(), roomStore.observeUnread()) { _, unread -> unread } .first() "ignored" } } - - private fun Flow.startInstantly() = this.onStart { emit(Unit) } -} +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCase.kt similarity index 94% rename from features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt rename to matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCase.kt index ba23d340..8f918481 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCase.kt @@ -1,10 +1,8 @@ -package app.dapk.st.messenger +package app.dapk.st.engine import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.sync.RoomEvent -import app.dapk.st.matrix.sync.RoomState internal typealias MergeWithLocalEchosUseCase = (RoomState, RoomMember, List) -> RoomState diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveInviteNotificationsUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ObserveInviteNotificationsUseCase.kt similarity index 82% rename from features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveInviteNotificationsUseCase.kt rename to matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ObserveInviteNotificationsUseCase.kt index 67d88c5c..b926f750 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveInviteNotificationsUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ObserveInviteNotificationsUseCase.kt @@ -1,17 +1,16 @@ -package app.dapk.st.notifications +package app.dapk.st.engine -import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.sync.InviteMeta import app.dapk.st.matrix.sync.OverviewStore import app.dapk.st.matrix.sync.RoomInvite import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* -internal typealias ObserveInviteNotificationsUseCase = suspend () -> Flow +internal typealias ObserveInviteNotificationsUseCase = () -> Flow class ObserveInviteNotificationsUseCaseImpl(private val overviewStore: OverviewStore) : ObserveInviteNotificationsUseCase { - override suspend fun invoke(): Flow { + override fun invoke(): Flow { return overviewStore.latestInvites() .diff() .drop(1) @@ -43,8 +42,3 @@ class ObserveInviteNotificationsUseCaseImpl(private val overviewStore: OverviewS private fun Flow>.flatten() = this.flatMapConcat { items -> flow { items.forEach { this.emit(it) } } } - -data class InviteNotification( - val content: String, - val roomId: RoomId -) \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseImpl.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ObserveUnreadNotificationsUseCaseImpl.kt similarity index 77% rename from features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseImpl.kt rename to matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ObserveUnreadNotificationsUseCaseImpl.kt index 8a3860d8..83f29812 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseImpl.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ObserveUnreadNotificationsUseCaseImpl.kt @@ -1,4 +1,4 @@ -package app.dapk.st.notifications +package app.dapk.st.engine import app.dapk.st.core.AppLogTag import app.dapk.st.core.extensions.clearAndPutAll @@ -6,17 +6,16 @@ import app.dapk.st.core.extensions.containsKey import app.dapk.st.core.log import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.sync.RoomEvent -import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomStore import kotlinx.coroutines.flow.* +import app.dapk.st.matrix.sync.RoomEvent as MatrixRoomEvent +import app.dapk.st.matrix.sync.RoomOverview as MatrixRoomOverview -typealias UnreadNotifications = Pair>, NotificationDiff> -internal typealias ObserveUnreadNotificationsUseCase = suspend () -> Flow +internal typealias ObserveUnreadNotificationsUseCase = () -> Flow class ObserveUnreadNotificationsUseCaseImpl(private val roomStore: RoomStore) : ObserveUnreadNotificationsUseCase { - override suspend fun invoke(): Flow { + override fun invoke(): Flow { return roomStore.observeUnread() .mapWithDiff() .avoidShowingPreviousNotificationsOnLaunch() @@ -25,28 +24,7 @@ class ObserveUnreadNotificationsUseCaseImpl(private val roomStore: RoomStore) : } -private fun Flow.onlyRenderableChanges(): Flow { - val inferredCurrentNotifications = mutableMapOf>() - return this - .filter { (_, diff) -> - when { - diff.changedOrNew.isEmpty() && diff.removed.isEmpty() -> { - log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no renderable changes") - false - } - - inferredCurrentNotifications.isEmpty() && diff.removed.isNotEmpty() -> { - log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no currently showing messages and changes are all messages marked as read") - false - } - - else -> true - } - } - .onEach { (allUnread, _) -> inferredCurrentNotifications.clearAndPutAll(allUnread.mapKeys { it.key.roomId }) } -} - -private fun Flow>>.mapWithDiff(): Flow>, NotificationDiff>> { +private fun Flow>>.mapWithDiff(): Flow>, NotificationDiff>> { val previousUnreadEvents = mutableMapOf>() return this.map { each -> val allUnreadIds = each.toTimestampedIds() @@ -83,19 +61,39 @@ private fun Map>?.toLatestTimestamps() = this?. private fun Map>.toEventIds() = this.mapValues { it.value.map { it.first } } -private fun Map>.toTimestampedIds() = this +private fun Map>.toTimestampedIds() = this .mapValues { it.value.toEventIds() } .mapKeys { it.key.roomId } -private fun List.toEventIds() = this.map { it.eventId to it.utcTimestamp } +private fun List.toEventIds() = this.map { it.eventId to it.utcTimestamp } private fun Flow.avoidShowingPreviousNotificationsOnLaunch() = drop(1) -data class NotificationDiff( - val unchanged: Map>, - val changedOrNew: Map>, - val removed: Map>, - val newRooms: Set -) +private fun Flow>, NotificationDiff>>.onlyRenderableChanges(): Flow { + val inferredCurrentNotifications = mutableMapOf>() + return this + .filter { (_, diff) -> + when { + diff.changedOrNew.isEmpty() && diff.removed.isEmpty() -> { + log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no renderable changes") + false + } + + inferredCurrentNotifications.isEmpty() && diff.removed.isNotEmpty() -> { + log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no currently showing messages and changes are all messages marked as read") + false + } + + else -> true + } + } + .onEach { (allUnread, _) -> inferredCurrentNotifications.clearAndPutAll(allUnread.mapKeys { it.key.roomId }) } + .map { + val engineModels = it.first + .mapKeys { it.key.engine() } + .mapValues { it.value.map { it.engine() } } + engineModels to it.second + } +} typealias TimestampedEventId = Pair \ No newline at end of file diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt new file mode 100644 index 00000000..0e0d5f80 --- /dev/null +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt @@ -0,0 +1,57 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.room.RoomService +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomStore +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* + +class ReadMarkingTimeline( + private val roomStore: RoomStore, + private val credentialsStore: CredentialsStore, + private val observeTimelineUseCase: ObserveTimelineUseCase, + private val roomService: RoomService, +) { + + fun fetch(roomId: RoomId, isReadReceiptsDisabled: Boolean): Flow { + return flow { + val credentials = credentialsStore.credentials()!! + roomStore.markRead(roomId) + emit(credentials) + }.flatMapMerge { credentials -> + var lastKnownReadEvent: EventId? = null + observeTimelineUseCase.invoke(roomId, credentials.userId).distinctUntilChanged().onEach { state -> + state.latestMessageEventFromOthers(self = credentials.userId)?.let { + if (lastKnownReadEvent != it) { + updateRoomReadStateAsync(latestReadEvent = it, state, isReadReceiptsDisabled) + lastKnownReadEvent = it + } + } + } + } + } + + private suspend fun updateRoomReadStateAsync(latestReadEvent: EventId, state: MessengerState, isReadReceiptsDisabled: Boolean): Deferred<*> { + return coroutineScope { + async { + runCatching { + roomService.markFullyRead(state.roomState.roomOverview.roomId, latestReadEvent, isPrivate = isReadReceiptsDisabled) + roomStore.markRead(state.roomState.roomOverview.roomId) + } + } + } + } + +} + +private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roomState.events + .filterIsInstance() + .filterNot { it.author.id == self } + .firstOrNull() + ?.eventId diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/SendMessageUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/SendMessageUseCase.kt new file mode 100644 index 00000000..4c4054df --- /dev/null +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/SendMessageUseCase.kt @@ -0,0 +1,60 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.message.internal.ImageContentReader +import java.time.Clock + +internal class SendMessageUseCase( + private val messageService: MessageService, + private val localIdFactory: LocalIdFactory, + private val imageContentReader: ImageContentReader, + private val clock: Clock, +) { + + suspend fun send(message: SendMessage, room: RoomOverview) { + when (message) { + is SendMessage.ImageMessage -> createImageMessage(message, room) + is SendMessage.TextMessage -> messageService.scheduleMessage(createTextMessage(message, room)) + } + } + + private suspend fun createImageMessage(message: SendMessage.ImageMessage, room: RoomOverview) { + val meta = imageContentReader.meta(message.uri) + messageService.scheduleMessage( + MessageService.Message.ImageMessage( + MessageService.Message.Content.ImageContent( + uri = message.uri, + MessageService.Message.Content.ImageContent.Meta( + height = meta.height, + width = meta.width, + size = meta.size, + fileName = meta.fileName, + mimeType = meta.mimeType, + ) + ), + roomId = room.roomId, + sendEncrypted = room.isEncrypted, + localId = localIdFactory.create(), + timestampUtc = clock.millis(), + ) + ) + } + + private fun createTextMessage(message: SendMessage.TextMessage, room: RoomOverview) = MessageService.Message.TextMessage( + content = MessageService.Message.Content.TextContent(message.content), + roomId = room.roomId, + sendEncrypted = room.isEncrypted, + localId = localIdFactory.create(), + timestampUtc = clock.millis(), + reply = message.reply?.let { + MessageService.Message.TextMessage.Reply( + author = it.author, + originalMessage = it.originalMessage, + replyContent = message.content, + eventId = it.eventId, + timestampUtc = it.timestampUtc, + ) + } + ) + +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt similarity index 80% rename from features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt rename to matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt index c1d9538b..b25cbe45 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt @@ -1,15 +1,14 @@ -package app.dapk.st.messenger +package app.dapk.st.engine -import app.dapk.st.core.extensions.startAndIgnoreEmissions import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.RoomState import app.dapk.st.matrix.sync.SyncService import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map internal typealias ObserveTimelineUseCase = (RoomId, UserId) -> Flow @@ -37,22 +36,16 @@ internal class TimelineUseCaseImpl( ) } }, - typing = events.filterIsInstance().firstOrNull { it.roomId == roomId }, + typing = events.filterIsInstance().firstOrNull { it.roomId == roomId }?.engine(), self = userId, ) } } private fun roomDatasource(roomId: RoomId) = combine( - syncService.startSyncing().startAndIgnoreEmissions(), - syncService.room(roomId) + syncService.startSyncing(), + syncService.room(roomId).map { it.engine() } ) { _, room -> room } } private fun UserId.toFallbackMember() = RoomMember(this, displayName = null, avatarUrl = null) - -data class MessengerState( - val self: UserId, - val roomState: RoomState, - val typing: SyncService.SyncEvent.Typing? -) diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/LocalEchoMapperTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/LocalEchoMapperTest.kt similarity index 81% rename from features/messenger/src/test/kotlin/app/dapk/st/messenger/LocalEchoMapperTest.kt rename to matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/LocalEchoMapperTest.kt index 29b8e596..a2f794d0 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/LocalEchoMapperTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/LocalEchoMapperTest.kt @@ -1,10 +1,10 @@ -package app.dapk.st.messenger +package app.dapk.st.engine import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.sync.MessageMeta +import fake.FakeMetaMapper import fixture.* -import internalfake.FakeMetaMapper import org.amshove.kluent.shouldBeEqualTo import org.junit.Test @@ -27,7 +27,7 @@ class LocalEchoMapperTest { result shouldBeEqualTo aRoomMessageEvent( eventId = echo.eventId!!, content = AN_ECHO_CONTENT.content.body, - meta = A_META + meta = A_META.engine() ) } @@ -40,25 +40,25 @@ class LocalEchoMapperTest { result shouldBeEqualTo aRoomMessageEvent( eventId = anEventId(echo.localId), content = AN_ECHO_CONTENT.content.body, - meta = A_META + meta = A_META.engine() ) } @Test fun `when merging with echo then updates meta with the echos meta`() = runWith(localEchoMapper) { - val previousMeta = MessageMeta.LocalEcho("previous", MessageMeta.LocalEcho.State.Sending) + val previousMeta = MessageMeta.LocalEcho("previous", MessageMeta.LocalEcho.State.Sending).engine() val event = aRoomMessageEvent(meta = previousMeta) val echo = aLocalEcho() - fakeMetaMapper.given(echo).returns(A_META) + fakeMetaMapper.given(echo).returns(A_META.engine() as app.dapk.st.engine.MessageMeta.LocalEcho) val result = event.mergeWith(echo) - result shouldBeEqualTo aRoomMessageEvent(meta = A_META) + result shouldBeEqualTo aRoomMessageEvent(meta = A_META.engine()) } private fun givenEcho(eventId: EventId? = null, localId: String = "", meta: MessageMeta.LocalEcho = A_META): MessageService.LocalEcho { return aLocalEcho(eventId = eventId, message = aTextMessage(localId = localId)).also { - fakeMetaMapper.given(it).returns(meta) + fakeMetaMapper.given(it).returns(meta.engine() as app.dapk.st.engine.MessageMeta.LocalEcho) } } } diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt similarity index 91% rename from features/messenger/src/test/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCaseTest.kt rename to matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt index f5bcdafb..56031930 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt @@ -1,11 +1,8 @@ -package app.dapk.st.messenger +package app.dapk.st.engine 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.MessageService import fixture.* -import internalfake.FakeLocalEventMapper import org.amshove.kluent.shouldBeEqualTo import org.junit.Test @@ -18,7 +15,7 @@ private val ANOTHER_ROOM_MESSAGE_EVENT = A_ROOM_MESSAGE_EVENT.copy(eventId = anE class MergeWithLocalEchosUseCaseTest { - private val fakeLocalEchoMapper = FakeLocalEventMapper() + private val fakeLocalEchoMapper = fake.FakeLocalEventMapper() private val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(fakeLocalEchoMapper.instance) @Test @@ -60,4 +57,4 @@ class MergeWithLocalEchosUseCaseTest { aTextMessage(aTextContent(body)), state, ) -} +} \ No newline at end of file diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MetaMapperTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MetaMapperTest.kt similarity index 96% rename from features/messenger/src/test/kotlin/app/dapk/st/messenger/MetaMapperTest.kt rename to matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MetaMapperTest.kt index 27419cea..eb2cda17 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MetaMapperTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MetaMapperTest.kt @@ -1,7 +1,6 @@ -package app.dapk.st.messenger +package app.dapk.st.engine import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.sync.MessageMeta import fixture.aLocalEcho import fixture.aTextMessage import org.amshove.kluent.shouldBeEqualTo diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadRenderNotificationsUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt similarity index 64% rename from features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadRenderNotificationsUseCaseTest.kt rename to matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt index 1a932fe3..50bd2b9f 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadRenderNotificationsUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt @@ -1,24 +1,27 @@ -package app.dapk.st.notifications +package app.dapk.st.engine -import app.dapk.st.matrix.sync.RoomEvent -import app.dapk.st.matrix.sync.RoomOverview import fake.FakeRoomStore import fixture.NotificationDiffFixtures.aNotificationDiff +import fixture.aMatrixRoomMessageEvent +import fixture.aMatrixRoomOverview import fixture.aRoomId -import fixture.aRoomMessageEvent -import fixture.aRoomOverview import fixture.anEventId import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.Test +import app.dapk.st.matrix.sync.RoomEvent as MatrixRoomEvent +import app.dapk.st.matrix.sync.RoomOverview as MatrixRoomOverview -private val NO_UNREADS = emptyMap>() -private val A_MESSAGE = aRoomMessageEvent(eventId = anEventId("1"), content = "hello", utcTimestamp = 1000) -private val A_MESSAGE_2 = aRoomMessageEvent(eventId = anEventId("2"), content = "world", utcTimestamp = 2000) -private val A_ROOM_OVERVIEW = aRoomOverview(roomId = aRoomId("1")) -private val A_ROOM_OVERVIEW_2 = aRoomOverview(roomId = aRoomId("2")) +private val NO_UNREADS = emptyMap>() +private val A_MESSAGE = aMatrixRoomMessageEvent(eventId = anEventId("1"), content = "hello", utcTimestamp = 1000) +private val A_MESSAGE_2 = aMatrixRoomMessageEvent(eventId = anEventId("2"), content = "world", utcTimestamp = 2000) +private val A_ROOM_OVERVIEW = aMatrixRoomOverview(roomId = aRoomId("1")) +private val A_ROOM_OVERVIEW_2 = aMatrixRoomOverview(roomId = aRoomId("2")) + +private fun MatrixRoomOverview.withUnreads(vararg events: MatrixRoomEvent) = mapOf(this to events.toList()) +private fun MatrixRoomOverview.toDiff(vararg events: MatrixRoomEvent) = mapOf(this.roomId to events.map { it.eventId }) class ObserveUnreadRenderNotificationsUseCaseTest { @@ -33,7 +36,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest { val result = useCase.invoke().toList() result shouldBeEqualTo listOf( - A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) to aNotificationDiff( + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE).engine() to aNotificationDiff( changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE), newRooms = setOf(A_ROOM_OVERVIEW.roomId) ) @@ -47,11 +50,11 @@ class ObserveUnreadRenderNotificationsUseCaseTest { val result = useCase.invoke().toList() result shouldBeEqualTo listOf( - A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) to aNotificationDiff( + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE).engine() to aNotificationDiff( changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE), newRooms = setOf(A_ROOM_OVERVIEW.roomId) ), - A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE_2)) + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2).engine() to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE_2)) ) } @@ -64,7 +67,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest { val result = useCase.invoke().toList() result shouldBeEqualTo listOf( - A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE_2)) + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2).engine() to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE_2)) ) } @@ -92,7 +95,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest { val result = useCase.invoke().toList() result shouldBeEqualTo listOf( - A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) to aNotificationDiff( + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE).engine() to aNotificationDiff( changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE), newRooms = setOf(A_ROOM_OVERVIEW.roomId) ), @@ -110,8 +113,10 @@ class ObserveUnreadRenderNotificationsUseCaseTest { result shouldBeEqualTo emptyList() } - private fun givenNoInitialUnreads(vararg unreads: Map>) = fakeRoomStore.givenUnreadEvents(flowOf(NO_UNREADS, *unreads)) + private fun givenNoInitialUnreads(vararg unreads: Map>) = + fakeRoomStore.givenUnreadEvents(flowOf(NO_UNREADS, *unreads)) } -private fun RoomOverview.withUnreads(vararg events: RoomEvent) = mapOf(this to events.toList()) -private fun RoomOverview.toDiff(vararg events: RoomEvent) = mapOf(this.roomId to events.map { it.eventId }) +private fun Map>.engine() = this + .mapKeys { it.key.engine() } + .mapValues { it.value.map { it.engine() } } diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/TimelineUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt similarity index 75% rename from features/messenger/src/test/kotlin/app/dapk/st/messenger/TimelineUseCaseTest.kt rename to matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt index 8f5447ff..c2edd7d1 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/TimelineUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt @@ -1,13 +1,15 @@ -package app.dapk.st.messenger +package app.dapk.st.engine -import FlowTestObserver import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.sync.RoomState import app.dapk.st.matrix.sync.SyncService import fake.FakeSyncService import fixture.* +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope @@ -16,12 +18,13 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Test +import test.FlowTestObserver import test.delegateReturn private val A_ROOM_ID = aRoomId() private val AN_USER_ID = aUserId() -private val A_ROOM_STATE = aRoomState() -private val A_MERGED_ROOM_STATE = A_ROOM_STATE.copy(events = listOf(aRoomMessageEvent(content = "a merged event"))) +private val A_ROOM_STATE = aMatrixRoomState() +private val A_MERGED_ROOM_STATE = A_ROOM_STATE.copy(events = listOf(aMatrixRoomMessageEvent(content = "a merged event"))) private val A_LOCAL_ECHOS_LIST = listOf(aLocalEcho()) private val A_ROOM_MEMBER = aRoomMember() @@ -47,7 +50,7 @@ class TimelineUseCaseTest { .test(this) .assertValues( listOf( - aMessengerState(self = AN_USER_ID, roomState = A_ROOM_STATE) + aMessengerState(self = AN_USER_ID, roomState = A_ROOM_STATE.engine()) ) ) } @@ -57,13 +60,13 @@ class TimelineUseCaseTest { givenSyncEmission(roomState = A_ROOM_STATE, echos = A_LOCAL_ECHOS_LIST) fakeRoomService.givenFindMember(A_ROOM_ID, AN_USER_ID).returns(A_ROOM_MEMBER) - fakeMergeWithLocalEchosUseCase.givenMerging(A_ROOM_STATE, A_ROOM_MEMBER, A_LOCAL_ECHOS_LIST).returns(A_MERGED_ROOM_STATE) + fakeMergeWithLocalEchosUseCase.givenMerging(A_ROOM_STATE, A_ROOM_MEMBER, A_LOCAL_ECHOS_LIST).returns(A_MERGED_ROOM_STATE.engine()) timelineUseCase.invoke(A_ROOM_ID, AN_USER_ID) .test(this) .assertValues( listOf( - aMessengerState(self = AN_USER_ID, roomState = A_MERGED_ROOM_STATE) + aMessengerState(self = AN_USER_ID, roomState = A_MERGED_ROOM_STATE.engine()) ) ) } @@ -81,7 +84,11 @@ class TimelineUseCaseTest { .test(this) .assertValues( listOf( - aMessengerState(self = AN_USER_ID, roomState = A_ROOM_STATE, typing = aTypingSyncEvent(A_ROOM_ID, members = listOf(A_ROOM_MEMBER))) + aMessengerState( + self = AN_USER_ID, + roomState = A_ROOM_STATE.engine(), + typing = aTypingSyncEvent(A_ROOM_ID, members = listOf(A_ROOM_MEMBER)).engine() + ) ) ) } @@ -104,11 +111,27 @@ suspend fun Flow.test(scope: CoroutineScope) = FlowTestObserver(scope, th class FakeMergeWithLocalEchosUseCase : MergeWithLocalEchosUseCase by mockk() { fun givenMerging(roomState: RoomState, roomMember: RoomMember, echos: List) = every { - this@FakeMergeWithLocalEchosUseCase.invoke(roomState, roomMember, echos) + this@FakeMergeWithLocalEchosUseCase.invoke(roomState.engine(), roomMember, echos) }.delegateReturn() } fun aTypingSyncEvent( roomId: RoomId = aRoomId(), members: List = listOf(aRoomMember()) -) = SyncService.SyncEvent.Typing(roomId, members) \ No newline at end of file +) = SyncService.SyncEvent.Typing(roomId, members) + +class FakeMessageService : MessageService by mockk() { + + fun givenEchos(roomId: RoomId) = every { localEchos(roomId) }.delegateReturn() + +} + +class FakeRoomService : RoomService by mockk() { + fun givenFindMember(roomId: RoomId, userId: UserId) = coEvery { findMember(roomId, userId) }.delegateReturn() +} + +fun aMessengerState( + self: UserId = aUserId(), + roomState: app.dapk.st.engine.RoomState, + typing: Typing? = null +) = MessengerState(self, roomState, typing) \ No newline at end of file diff --git a/matrix-chat-engine/src/test/kotlin/fake/FakeDirectoryUseCase.kt b/matrix-chat-engine/src/test/kotlin/fake/FakeDirectoryUseCase.kt new file mode 100644 index 00000000..434e0db0 --- /dev/null +++ b/matrix-chat-engine/src/test/kotlin/fake/FakeDirectoryUseCase.kt @@ -0,0 +1,11 @@ +package fake + +import app.dapk.st.engine.DirectoryUseCase +import io.mockk.every +import io.mockk.mockk +import test.delegateReturn + +internal class FakeDirectoryUseCase { + val instance = mockk() + fun given() = every { instance.state() }.delegateReturn() +} \ No newline at end of file diff --git a/features/messenger/src/test/kotlin/internalfake/FakeLocalEventMapper.kt b/matrix-chat-engine/src/test/kotlin/fake/FakeLocalEventMapper.kt similarity index 84% rename from features/messenger/src/test/kotlin/internalfake/FakeLocalEventMapper.kt rename to matrix-chat-engine/src/test/kotlin/fake/FakeLocalEventMapper.kt index 7e3d6e10..2365e9dd 100644 --- a/features/messenger/src/test/kotlin/internalfake/FakeLocalEventMapper.kt +++ b/matrix-chat-engine/src/test/kotlin/fake/FakeLocalEventMapper.kt @@ -1,8 +1,8 @@ -package internalfake +package fake +import app.dapk.st.engine.LocalEchoMapper import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.message.MessageService -import app.dapk.st.messenger.LocalEchoMapper import io.mockk.every import io.mockk.mockk diff --git a/features/messenger/src/test/kotlin/internalfake/FakeLocalIdFactory.kt b/matrix-chat-engine/src/test/kotlin/fake/FakeLocalIdFactory.kt similarity index 77% rename from features/messenger/src/test/kotlin/internalfake/FakeLocalIdFactory.kt rename to matrix-chat-engine/src/test/kotlin/fake/FakeLocalIdFactory.kt index 6b70cfb5..994f22b0 100644 --- a/features/messenger/src/test/kotlin/internalfake/FakeLocalIdFactory.kt +++ b/matrix-chat-engine/src/test/kotlin/fake/FakeLocalIdFactory.kt @@ -1,6 +1,6 @@ -package internalfake +package fake -import app.dapk.st.messenger.LocalIdFactory +import app.dapk.st.engine.LocalIdFactory import io.mockk.every import io.mockk.mockk import test.delegateReturn diff --git a/features/messenger/src/test/kotlin/internalfake/FakeMetaMapper.kt b/matrix-chat-engine/src/test/kotlin/fake/FakeMetaMapper.kt similarity index 82% rename from features/messenger/src/test/kotlin/internalfake/FakeMetaMapper.kt rename to matrix-chat-engine/src/test/kotlin/fake/FakeMetaMapper.kt index 6762d994..8722d9ad 100644 --- a/features/messenger/src/test/kotlin/internalfake/FakeMetaMapper.kt +++ b/matrix-chat-engine/src/test/kotlin/fake/FakeMetaMapper.kt @@ -1,7 +1,7 @@ -package internalfake +package fake +import app.dapk.st.engine.MetaMapper import app.dapk.st.matrix.message.MessageService -import app.dapk.st.messenger.MetaMapper import io.mockk.every import io.mockk.mockk import test.delegateReturn diff --git a/features/notifications/src/test/kotlin/fake/FakeObserveInviteNotificationsUseCase.kt b/matrix-chat-engine/src/test/kotlin/fake/FakeObserveInviteNotificationsUseCase.kt similarity index 80% rename from features/notifications/src/test/kotlin/fake/FakeObserveInviteNotificationsUseCase.kt rename to matrix-chat-engine/src/test/kotlin/fake/FakeObserveInviteNotificationsUseCase.kt index fba079f5..886fe27c 100644 --- a/features/notifications/src/test/kotlin/fake/FakeObserveInviteNotificationsUseCase.kt +++ b/matrix-chat-engine/src/test/kotlin/fake/FakeObserveInviteNotificationsUseCase.kt @@ -1,6 +1,6 @@ package fake -import app.dapk.st.notifications.ObserveInviteNotificationsUseCase +import app.dapk.st.engine.ObserveInviteNotificationsUseCase import io.mockk.coEvery import io.mockk.mockk import test.delegateEmit diff --git a/features/notifications/src/test/kotlin/fake/FakeObserveUnreadNotificationsUseCase.kt b/matrix-chat-engine/src/test/kotlin/fake/FakeObserveUnreadNotificationsUseCase.kt similarity index 80% rename from features/notifications/src/test/kotlin/fake/FakeObserveUnreadNotificationsUseCase.kt rename to matrix-chat-engine/src/test/kotlin/fake/FakeObserveUnreadNotificationsUseCase.kt index dd881d2e..d04b7823 100644 --- a/features/notifications/src/test/kotlin/fake/FakeObserveUnreadNotificationsUseCase.kt +++ b/matrix-chat-engine/src/test/kotlin/fake/FakeObserveUnreadNotificationsUseCase.kt @@ -1,6 +1,6 @@ package fake -import app.dapk.st.notifications.ObserveUnreadNotificationsUseCase +import app.dapk.st.engine.ObserveUnreadNotificationsUseCase import io.mockk.coEvery import io.mockk.mockk import test.delegateEmit 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 ba5936bc..d4ac8d89 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 @@ -184,6 +184,7 @@ sealed interface ImportResult { object NoKeysFound : Type object UnexpectedDecryptionOutput : Type object UnableToOpenFile : Type + object InvalidFile : Type } } diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MatrixMediaDecrypter.kt similarity index 96% rename from matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt rename to matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MatrixMediaDecrypter.kt index df513d2b..65dde9e2 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MatrixMediaDecrypter.kt @@ -12,7 +12,7 @@ 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(private val base64: Base64) { +class MatrixMediaDecrypter(private val base64: Base64) { fun decrypt(input: InputStream, k: String, iv: String): Collector { val key = base64.decode(k.replace('-', '+').replace('_', '/')) diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt index 24311a94..7adcc3c6 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/RoomKeyImporter.kt @@ -86,21 +86,27 @@ class RoomKeyImporter( val line = it.joinToString(separator = "").replace("\n", "") val toByteArray = base64.decode(line) if (index == 0) { - decryptCipher.initialize(toByteArray, password) - toByteArray - .copyOfRange(37, toByteArray.size) - .decrypt(decryptCipher) - .also { - if (!it.startsWith("[{")) { - throw ImportException(ImportResult.Error.Type.UnexpectedDecryptionOutput) - } + toByteArray.ensureHasCipherPayloadOrThrow() + val initializer = toByteArray.copyOfRange(0, 37) + decryptCipher.initialize(initializer, password) + val content = toByteArray.copyOfRange(37, toByteArray.size) + content.decrypt(decryptCipher).also { + if (!it.startsWith("[{")) { + throw ImportException(ImportResult.Error.Type.UnexpectedDecryptionOutput) } + } } else { toByteArray.decrypt(decryptCipher) } } } + private fun ByteArray.ensureHasCipherPayloadOrThrow() { + if (this.size < 37) { + throw ImportException(ImportResult.Error.Type.InvalidFile) + } + } + private fun Cipher.initialize(payload: ByteArray, passphrase: String) { val salt = payload.copyOfRange(1, 1 + 16) val iv = payload.copyOfRange(17, 17 + 16) @@ -176,6 +182,7 @@ private class JsonAccumulator { jsonSegment = withLatest null } + else -> { val string = withLatest.substring(objectRange) importJson.decodeFromString(ElementMegolmExportObject.serializer(), string).also { @@ -200,6 +207,7 @@ private class JsonAccumulator { } opens++ } + c == '}' -> { opens-- if (opens == 0) { diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/BackgroundScheduler.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/BackgroundScheduler.kt index e740ade3..04241e02 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/BackgroundScheduler.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/BackgroundScheduler.kt @@ -1,9 +1,11 @@ package app.dapk.st.matrix.message +import app.dapk.st.matrix.common.JsonString + interface BackgroundScheduler { fun schedule(key: String, task: Task) - data class Task(val type: String, val jsonPayload: String) + data class Task(val type: String, val jsonPayload: JsonString) } 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 94ee5a99..3a39d8b4 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 @@ -77,13 +77,12 @@ interface MessageService : MatrixService { @Serializable data class Meta( - val height: Int, - val width: Int, - val size: Long, - val fileName: String, - val mimeType: String, + @SerialName("height") val height: Int, + @SerialName("width") val width: Int, + @SerialName("size") val size: Long, + @SerialName("file_name") val fileName: String, + @SerialName("mime_type") val mimeType: String, ) - } } 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 c6b73745..7dd056fe 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,6 +1,7 @@ package app.dapk.st.matrix.message.internal import app.dapk.st.matrix.MatrixTaskRunner +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.message.* @@ -69,13 +70,13 @@ internal class DefaultMessageService( is MessageService.Message.TextMessage -> { BackgroundScheduler.Task( type = MATRIX_MESSAGE_TASK_TYPE, - Json.encodeToString(MessageService.Message.TextMessage.serializer(), this) + JsonString(Json.encodeToString(MessageService.Message.TextMessage.serializer(), this)) ) } is MessageService.Message.ImageMessage -> BackgroundScheduler.Task( type = MATRIX_IMAGE_MESSAGE_TASK_TYPE, - Json.encodeToString(MessageService.Message.ImageMessage.serializer(), this) + JsonString(Json.encodeToString(MessageService.Message.ImageMessage.serializer(), this)) ) } } 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 56ba0a1b..a51817b4 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 @@ -15,7 +15,7 @@ private val SERVICE_KEY = RoomService::class interface RoomService : MatrixService { suspend fun joinedMembers(roomId: RoomId): List - suspend fun markFullyRead(roomId: RoomId, eventId: EventId) + suspend fun markFullyRead(roomId: RoomId, eventId: EventId, isPrivate: Boolean) suspend fun findMember(roomId: RoomId, userId: UserId): RoomMember? suspend fun findMembers(roomId: RoomId, userIds: List): List diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt index 2448e4cf..22e15513 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt @@ -30,9 +30,9 @@ class DefaultRoomService( } } - override suspend fun markFullyRead(roomId: RoomId, eventId: EventId) { + override suspend fun markFullyRead(roomId: RoomId, eventId: EventId, isPrivate: Boolean) { logger.matrixLog(ROOM, "marking room fully read ${roomId.value}") - httpClient.execute(markFullyReadRequest(roomId, eventId)) + httpClient.execute(markFullyReadRequest(roomId, eventId, isPrivate)) } override suspend fun findMember(roomId: RoomId, userId: UserId): RoomMember? { @@ -97,10 +97,10 @@ internal fun joinedMembersRequest(roomId: RoomId) = httpRequest( +internal fun markFullyReadRequest(roomId: RoomId, eventId: EventId, isPrivate: Boolean) = httpRequest( path = "_matrix/client/r0/rooms/${roomId.value}/read_markers", method = MatrixHttpClient.Method.POST, - body = jsonBody(MarkFullyReadRequest(eventId, eventId, hidden = true)) + body = jsonBody(MarkFullyReadRequest(eventId, eventId, hidden = isPrivate)) ) internal fun createRoomRequest(invites: List, isDM: Boolean, visibility: RoomVisibility, name: String? = null) = httpRequest( 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 68dcd8c8..837066d2 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 @@ -1,22 +1,14 @@ package app.dapk.st.matrix.sync -import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.matrix.common.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.time.Instant -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter data class RoomState( val roomOverview: RoomOverview, val events: List, ) -internal val DEFAULT_ZONE = ZoneId.systemDefault() -internal val MESSAGE_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm") - @Serializable sealed class RoomEvent { @@ -24,18 +16,18 @@ sealed class RoomEvent { abstract val utcTimestamp: Long abstract val author: RoomMember abstract val meta: MessageMeta + abstract val redacted: Boolean @Serializable - @SerialName("message") - data class Message( + @SerialName("encrypted") + data class Encrypted( @SerialName("event_id") override val eventId: EventId, @SerialName("timestamp") override val utcTimestamp: Long, - @SerialName("content") val content: String, @SerialName("author") override val author: RoomMember, @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, + @SerialName("redacted") override val redacted: Boolean = false, + @SerialName("encrypted_content") val encryptedContent: MegOlmV1, ) : RoomEvent() { @Serializable @@ -46,12 +38,20 @@ sealed class RoomEvent { @SerialName("session_id") val sessionId: SessionId, ) - val time: String by unsafeLazy { - val instant = Instant.ofEpochMilli(utcTimestamp) - ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) - } } + @Serializable + @SerialName("message") + data class Message( + @SerialName("event_id") override val eventId: EventId, + @SerialName("timestamp") override val utcTimestamp: Long, + @SerialName("content") val content: String, + @SerialName("author") override val author: RoomMember, + @SerialName("meta") override val meta: MessageMeta, + @SerialName("edited") val edited: Boolean = false, + @SerialName("redacted") override val redacted: Boolean = false, + ) : RoomEvent() + @Serializable @SerialName("reply") data class Reply( @@ -63,13 +63,8 @@ sealed class RoomEvent { override val utcTimestamp: Long = message.utcTimestamp override val author: RoomMember = message.author override val meta: MessageMeta = message.meta + override val redacted: Boolean = message.redacted - val replyingToSelf = replyingTo.author == message.author - - val time: String by unsafeLazy { - val instant = Instant.ofEpochMilli(utcTimestamp) - ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) - } } @Serializable @@ -80,15 +75,10 @@ sealed class RoomEvent { @SerialName("image_meta") val imageMeta: ImageMeta, @SerialName("author") override val author: RoomMember, @SerialName("meta") override val meta: MessageMeta, - @SerialName("encrypted_content") val encryptedContent: Message.MegOlmV1? = null, @SerialName("edited") val edited: Boolean = false, + @SerialName("redacted") override val redacted: Boolean = false, ) : RoomEvent() { - val time: String by unsafeLazy { - val instant = Instant.ofEpochMilli(utcTimestamp) - ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT) - } - @Serializable data class ImageMeta( @SerialName("width") val width: Int?, 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 20ddf80f..6cba46eb 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 @@ -18,10 +18,15 @@ interface SyncService : MatrixService { fun invites(): Flow fun overview(): Flow fun room(roomId: RoomId): Flow + + /** + * Subscribe to keep the background syncing alive + * Emits once, either when the initial sync completes or immediately if has already sync'd once + */ fun startSyncing(): Flow fun events(roomId: RoomId? = null): Flow> suspend fun observeEvent(eventId: EventId): Flow - suspend fun forceManualRefresh(roomIds: List) + suspend fun forceManualRefresh(roomIds: Set) @JvmInline value class FilterId(val value: String) @@ -31,6 +36,7 @@ interface SyncService : MatrixService { data class Typing(override val roomId: RoomId, val members: List) : SyncEvent } + } fun MatrixServiceInstaller.installSyncService( diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt index 779c2f0a..fa7e04f6 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt @@ -24,7 +24,7 @@ private val syncSubscriptionCount = AtomicInteger() internal class DefaultSyncService( httpClient: MatrixHttpClient, - syncStore: SyncStore, + private val syncStore: SyncStore, private val overviewStore: OverviewStore, private val roomStore: RoomStore, filterStore: FilterStore, @@ -104,13 +104,24 @@ internal class DefaultSyncService( } } - override fun startSyncing() = syncFlow + override fun startSyncing(): Flow { + return flow { emit(syncStore.read(SyncStore.SyncKey.Overview) != null) }.flatMapMerge { hasSynced -> + when (hasSynced) { + true -> syncFlow.filter { false }.onStart { emit(Unit) } + false -> { + var counter = 0 + syncFlow.filter { counter < 1 }.onEach { counter++ } + } + } + } + } + override fun invites() = overviewStore.latestInvites() override fun overview() = overviewStore.latest() override fun room(roomId: RoomId) = roomStore.latest(roomId) override fun events(roomId: RoomId?) = roomId?.let { syncEventsFlow.map { it.filter { it.roomId == roomId } }.distinctUntilChanged() } ?: syncEventsFlow override suspend fun observeEvent(eventId: EventId) = roomStore.observeEvent(eventId) - override suspend fun forceManualRefresh(roomIds: List) { + override suspend fun forceManualRefresh(roomIds: Set) { coroutineDispatchers.withIoContext { roomIds.map { async { diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt index b14f5b4e..1194b746 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt @@ -3,6 +3,8 @@ package app.dapk.st.matrix.sync.internal.room import app.dapk.st.matrix.common.* import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent.TimelineMessage.Content.Image +import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent.TimelineMessage.Content.Text import app.dapk.st.matrix.sync.internal.request.DecryptedContent import kotlinx.serialization.json.Json @@ -17,56 +19,60 @@ internal class RoomEventsDecrypter( } private suspend fun decryptEvent(event: RoomEvent, userCredentials: UserCredentials): RoomEvent = when (event) { - is RoomEvent.Message -> event.decrypt() + is RoomEvent.Encrypted -> event.decrypt(userCredentials) + is RoomEvent.Message -> event is RoomEvent.Reply -> RoomEvent.Reply( message = decryptEvent(event.message, userCredentials), replyingTo = decryptEvent(event.replyingTo, userCredentials), ) - is RoomEvent.Image -> event.decrypt(userCredentials) - } - private suspend fun RoomEvent.Image.decrypt(userCredentials: UserCredentials) = when (this.encryptedContent) { - null -> this - else -> when (val result = messageDecrypter.decrypt(this.encryptedContent.toModel())) { - is DecryptionResult.Failed -> this.also { logger.crypto("Failed to decrypt ${it.eventId}") } - is DecryptionResult.Success -> when (val model = result.payload.toModel()) { - DecryptedContent.Ignored -> this - is DecryptedContent.TimelineText -> { - val content = model.content as ApiTimelineEvent.TimelineMessage.Content.Image - this.copy( - imageMeta = RoomEvent.Image.ImageMeta( - width = content.info?.width, - height = content.info?.height, - url = content.file?.url?.convertMxUrToUrl(userCredentials.homeServer) ?: content.url!!.convertMxUrToUrl(userCredentials.homeServer), - keys = content.file?.let { RoomEvent.Image.ImageMeta.Keys(it.key.k, it.iv, it.v, it.hashes) } - ), - encryptedContent = null, - ) - } - } - } + is RoomEvent.Image -> event } - private suspend fun RoomEvent.Message.decrypt() = when (this.encryptedContent) { - null -> this - else -> when (val result = messageDecrypter.decrypt(this.encryptedContent.toModel())) { - is DecryptionResult.Failed -> this.also { logger.crypto("Failed to decrypt ${it.eventId}") } - is DecryptionResult.Success -> when (val model = result.payload.toModel()) { - DecryptedContent.Ignored -> this - is DecryptedContent.TimelineText -> this.copyWithDecryptedContent(model) + private suspend fun RoomEvent.Encrypted.decrypt(userCredentials: UserCredentials) = when (val result = this.decryptContent()) { + is DecryptionResult.Failed -> this.also { logger.crypto("Failed to decrypt ${it.eventId}") } + is DecryptionResult.Success -> when (val model = result.payload.toModel()) { + DecryptedContent.Ignored -> this + is DecryptedContent.TimelineText -> when (val content = model.content) { + ApiTimelineEvent.TimelineMessage.Content.Ignored -> this + is Image -> createImageEvent(content, userCredentials) + is Text -> createMessageEvent(content) } } } - private fun JsonString.toModel() = json.decodeFromString(DecryptedContent.serializer(), this.value) + private suspend fun RoomEvent.Encrypted.decryptContent() = messageDecrypter.decrypt(this.encryptedContent.toModel()) - private fun RoomEvent.Message.copyWithDecryptedContent(decryptedContent: DecryptedContent.TimelineText) = this.copy( - content = (decryptedContent.content as ApiTimelineEvent.TimelineMessage.Content.Text).body ?: "", - encryptedContent = null + private fun RoomEvent.Encrypted.createMessageEvent(content: Text) = RoomEvent.Message( + eventId = this.eventId, + utcTimestamp = this.utcTimestamp, + author = this.author, + meta = this.meta, + edited = this.edited, + redacted = this.redacted, + content = content.body ?: "" ) + + private fun RoomEvent.Encrypted.createImageEvent(content: Image, userCredentials: UserCredentials) = RoomEvent.Image( + eventId = this.eventId, + utcTimestamp = this.utcTimestamp, + author = this.author, + meta = this.meta, + edited = this.edited, + redacted = this.redacted, + imageMeta = RoomEvent.Image.ImageMeta( + width = content.info?.width, + height = content.info?.height, + url = content.file?.url?.convertMxUrToUrl(userCredentials.homeServer) ?: content.url!!.convertMxUrToUrl(userCredentials.homeServer), + keys = content.file?.let { RoomEvent.Image.ImageMeta.Keys(it.key.k, it.iv, it.v, it.hashes) } + ), + ) + + private fun JsonString.toModel() = json.decodeFromString(DecryptedContent.serializer(), this.value) + } -private fun RoomEvent.Message.MegOlmV1.toModel() = EncryptedMessageContent.MegOlmV1( +private fun RoomEvent.Encrypted.MegOlmV1.toModel() = EncryptedMessageContent.MegOlmV1( this.cipherText, this.deviceId, this.senderKey, 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 7ab8f17b..f3d7e6f8 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 @@ -51,11 +51,7 @@ class RoomDataSource( } } -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 RoomEvent.redact() = 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 { diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt index 73639899..2bb35af5 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt @@ -24,13 +24,12 @@ internal class RoomEventCreator( suspend fun ApiTimelineEvent.Encrypted.toRoomEvent(roomId: RoomId): RoomEvent? { return when (this.encryptedContent) { is ApiEncryptedContent.MegOlmV1 -> { - RoomEvent.Message( + RoomEvent.Encrypted( eventId = this.eventId, author = roomMembersService.find(roomId, this.senderId)!!, utcTimestamp = this.utcTimestamp, meta = MessageMeta.FromServer, - content = "Encrypted message", - encryptedContent = RoomEvent.Message.MegOlmV1( + encryptedContent = RoomEvent.Encrypted.MegOlmV1( this.encryptedContent.cipherText, this.encryptedContent.deviceId, this.encryptedContent.senderKey, @@ -38,6 +37,7 @@ internal class RoomEventCreator( ) ) } + is ApiEncryptedContent.OlmV1 -> errorTracker.nullAndTrack(IllegalStateException("unexpected encryption, got OlmV1 for a room event")) ApiEncryptedContent.Unknown -> errorTracker.nullAndTrack(IllegalStateException("unknown room event encryption")) } @@ -79,6 +79,7 @@ internal class TimelineEventMapper( is RoomEvent.Message -> relationEvent is RoomEvent.Reply -> relationEvent.message is RoomEvent.Image -> relationEvent + is RoomEvent.Encrypted -> relationEvent } ) } @@ -110,12 +111,19 @@ internal class TimelineEventMapper( is RoomEvent.Image -> original.message is RoomEvent.Message -> original.message.edited(incomingEdit) is RoomEvent.Reply -> original.message + is RoomEvent.Encrypted -> original.message } ) + is RoomEvent.Image -> { // can't edit images null } + + is RoomEvent.Encrypted -> { + // can't edit encrypted messages + null + } } } } @@ -127,11 +135,13 @@ internal class TimelineEventMapper( utcTimestamp = incomingEdit.utcTimestamp, edited = true, ) + is ApiTimelineEvent.TimelineMessage.Content.Text -> original.toTextMessage( utcTimestamp = incomingEdit.utcTimestamp, content = incomingEdit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted", edited = true, ) + ApiTimelineEvent.TimelineMessage.Content.Ignored -> null } } 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 d73f9f7f..eca9c77c 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 @@ -81,4 +81,5 @@ private fun RoomEvent.toTextContent(): String = when (this) { is RoomEvent.Image -> "\uD83D\uDCF7" is RoomEvent.Message -> this.content is RoomEvent.Reply -> this.message.toTextContent() + is RoomEvent.Encrypted -> "Encrypted message" } \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt index b4f82a72..71781548 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt @@ -43,6 +43,7 @@ internal class UnreadEventsProcessor( is RoomEvent.Message -> it.author.id == selfId is RoomEvent.Reply -> it.message.author.id == selfId is RoomEvent.Image -> it.author.id == selfId + is RoomEvent.Encrypted -> it.author.id == selfId } }.map { it.eventId } roomStore.insertUnread(overview.roomId, eventsFromOthers) diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt index 3178ae93..42831463 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt @@ -15,12 +15,12 @@ import org.junit.Test private const val A_DECRYPTED_MESSAGE_CONTENT = "decrypted - content" private val AN_ENCRYPTED_ROOM_CONTENT = aMegolmV1() -private val AN_ENCRYPTED_ROOM_MESSAGE = aRoomMessageEvent(encryptedContent = AN_ENCRYPTED_ROOM_CONTENT) +private val AN_ENCRYPTED_ROOM_MESSAGE = anEncryptedRoomMessageEvent(encryptedContent = AN_ENCRYPTED_ROOM_CONTENT) private val AN_ENCRYPTED_ROOM_REPLY = aRoomReplyMessageEvent( message = AN_ENCRYPTED_ROOM_MESSAGE, replyingTo = AN_ENCRYPTED_ROOM_MESSAGE.copy(eventId = anEventId("other-event")) ) -private val A_DECRYPTED_CONTENT = DecryptedContent.TimelineText(aTimelineTextEventContent(body = A_DECRYPTED_MESSAGE_CONTENT)) +private val A_DECRYPTED_TEXT_CONTENT = DecryptedContent.TimelineText(aTimelineTextEventContent(body = A_DECRYPTED_MESSAGE_CONTENT)) private val A_USER_CREDENTIALS = aUserCredentials() private val json = Json { encodeDefaults = true } @@ -37,7 +37,7 @@ class RoomEventsDecrypterTest { @Test fun `given clear message event, when decrypting, then does nothing`() = runTest { - val aClearMessageEvent = aRoomMessageEvent(encryptedContent = null) + val aClearMessageEvent = aMatrixRoomMessageEvent() val result = roomEventsDecrypter.decryptRoomEvents(A_USER_CREDENTIALS, listOf(aClearMessageEvent)) result shouldBeEqualTo listOf(aClearMessageEvent) @@ -45,42 +45,52 @@ class RoomEventsDecrypterTest { @Test fun `given encrypted message event, when decrypting, then applies decrypted body and removes encrypted content`() = runTest { - givenEncryptedMessage(AN_ENCRYPTED_ROOM_MESSAGE, decryptsTo = A_DECRYPTED_CONTENT) + givenEncryptedMessage(AN_ENCRYPTED_ROOM_MESSAGE, decryptsTo = A_DECRYPTED_TEXT_CONTENT) val result = roomEventsDecrypter.decryptRoomEvents(A_USER_CREDENTIALS, listOf(AN_ENCRYPTED_ROOM_MESSAGE)) - result shouldBeEqualTo listOf(AN_ENCRYPTED_ROOM_MESSAGE.copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null)) + result shouldBeEqualTo listOf(AN_ENCRYPTED_ROOM_MESSAGE.toText(A_DECRYPTED_MESSAGE_CONTENT)) } @Test fun `given encrypted reply event, when decrypting, then decrypts message and replyTo`() = runTest { - givenEncryptedReply(AN_ENCRYPTED_ROOM_REPLY, decryptsTo = A_DECRYPTED_CONTENT) + givenEncryptedReply(AN_ENCRYPTED_ROOM_REPLY, decryptsTo = A_DECRYPTED_TEXT_CONTENT) val result = roomEventsDecrypter.decryptRoomEvents(A_USER_CREDENTIALS, listOf(AN_ENCRYPTED_ROOM_REPLY)) result shouldBeEqualTo listOf( AN_ENCRYPTED_ROOM_REPLY.copy( - message = (AN_ENCRYPTED_ROOM_REPLY.message as RoomEvent.Message).copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null), - replyingTo = (AN_ENCRYPTED_ROOM_REPLY.replyingTo as RoomEvent.Message).copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null), + message = (AN_ENCRYPTED_ROOM_REPLY.message as RoomEvent.Encrypted).toText(A_DECRYPTED_MESSAGE_CONTENT), + replyingTo = (AN_ENCRYPTED_ROOM_REPLY.replyingTo as RoomEvent.Encrypted).toText(A_DECRYPTED_MESSAGE_CONTENT), ) ) } - private fun givenEncryptedMessage(roomMessage: RoomEvent.Message, decryptsTo: DecryptedContent) { - val model = roomMessage.encryptedContent!!.toModel() + private fun givenEncryptedMessage(roomMessage: RoomEvent.Encrypted, decryptsTo: DecryptedContent) { + val model = roomMessage.encryptedContent.toModel() fakeMessageDecrypter.givenDecrypt(model) .returns(aDecryptionSuccessResult(payload = JsonString(json.encodeToString(DecryptedContent.serializer(), decryptsTo)))) } private fun givenEncryptedReply(roomReply: RoomEvent.Reply, decryptsTo: DecryptedContent) { - givenEncryptedMessage(roomReply.message as RoomEvent.Message, decryptsTo) - givenEncryptedMessage(roomReply.replyingTo as RoomEvent.Message, decryptsTo) + givenEncryptedMessage(roomReply.message as RoomEvent.Encrypted, decryptsTo) + givenEncryptedMessage(roomReply.replyingTo as RoomEvent.Encrypted, decryptsTo) } } -private fun RoomEvent.Message.MegOlmV1.toModel() = EncryptedMessageContent.MegOlmV1( +private fun RoomEvent.Encrypted.MegOlmV1.toModel() = EncryptedMessageContent.MegOlmV1( this.cipherText, this.deviceId, this.senderKey, this.sessionId, ) + +private fun RoomEvent.Encrypted.toText(text: String) = RoomEvent.Message( + this.eventId, + this.utcTimestamp, + content = text, + this.author, + this.meta, + this.edited, + this.redacted, +) \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt index b837fb6c..5bc47b68 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt @@ -1,7 +1,7 @@ package app.dapk.st.matrix.sync.internal.sync import fake.FakeRoomStore -import fixture.aRoomMessageEvent +import fixture.aMatrixRoomMessageEvent import fixture.anEventId import internalfixture.aTimelineTextEventContent import internalfixture.anApiTimelineTextEvent @@ -11,8 +11,8 @@ import org.junit.Test private val AN_EVENT_ID = anEventId() private val A_TIMELINE_EVENT = anApiTimelineTextEvent(AN_EVENT_ID, content = aTimelineTextEventContent(body = "timeline event")) -private val A_ROOM_EVENT = aRoomMessageEvent(AN_EVENT_ID, content = "previous room event") -private val A_PERSISTED_EVENT = aRoomMessageEvent(AN_EVENT_ID, content = "persisted event") +private val A_ROOM_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = "previous room event") +private val A_PERSISTED_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = "persisted event") class EventLookupUseCaseTest { diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt index f0b9a94e..38f1ed11 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt @@ -5,7 +5,6 @@ import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent import fake.FakeErrorTracker -import fake.FakeMatrixLogger import fake.FakeRoomMembersService import fixture.* import internalfixture.* @@ -41,10 +40,9 @@ internal class RoomEventCreatorTest { val result = with(roomEventCreator) { megolmEvent.toRoomEvent(A_ROOM_ID) } - result shouldBeEqualTo aRoomMessageEvent( + result shouldBeEqualTo anEncryptedRoomMessageEvent( eventId = megolmEvent.eventId, utcTimestamp = megolmEvent.utcTimestamp, - content = "Encrypted message", author = A_SENDER, encryptedContent = megolmEvent.encryptedContent.toMegolm(), ) @@ -74,7 +72,7 @@ internal class RoomEventCreatorTest { val result = with(roomEventCreator) { A_TEXT_EVENT.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) } - result shouldBeEqualTo aRoomMessageEvent( + result shouldBeEqualTo aMatrixRoomMessageEvent( eventId = A_TEXT_EVENT.id, utcTimestamp = A_TEXT_EVENT.utcTimestamp, content = A_TEXT_EVENT_MESSAGE, @@ -88,7 +86,7 @@ internal class RoomEventCreatorTest { val result = with(roomEventCreator) { A_TEXT_EVENT_WITHOUT_CONTENT.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) } - result shouldBeEqualTo aRoomMessageEvent( + result shouldBeEqualTo aMatrixRoomMessageEvent( eventId = A_TEXT_EVENT_WITHOUT_CONTENT.id, utcTimestamp = A_TEXT_EVENT_WITHOUT_CONTENT.utcTimestamp, content = "redacted", @@ -103,7 +101,7 @@ internal class RoomEventCreatorTest { val result = with(roomEventCreator) { editEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) } - result shouldBeEqualTo aRoomMessageEvent( + result shouldBeEqualTo aMatrixRoomMessageEvent( eventId = editEvent.id, utcTimestamp = editEvent.utcTimestamp, content = editEvent.asTextContent().body!!, @@ -121,7 +119,7 @@ internal class RoomEventCreatorTest { val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } - result shouldBeEqualTo aRoomMessageEvent( + result shouldBeEqualTo aMatrixRoomMessageEvent( eventId = originalMessage.id, utcTimestamp = editedMessage.utcTimestamp, content = A_TEXT_EVENT_MESSAGE, @@ -133,13 +131,13 @@ internal class RoomEventCreatorTest { @Test fun `given edited event which relates to a room event then updates existing message`() = runTest { fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) - val originalMessage = aRoomMessageEvent() + val originalMessage = aMatrixRoomMessageEvent() val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } - result shouldBeEqualTo aRoomMessageEvent( + result shouldBeEqualTo aMatrixRoomMessageEvent( eventId = originalMessage.eventId, utcTimestamp = editedMessage.utcTimestamp, content = A_TEXT_EVENT_MESSAGE, @@ -151,7 +149,7 @@ internal class RoomEventCreatorTest { @Test fun `given edited event which relates to a room reply event then only updates message`() = runTest { fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) - val originalMessage = aRoomReplyMessageEvent(message = aRoomMessageEvent()) + val originalMessage = aRoomReplyMessageEvent(message = aMatrixRoomMessageEvent()) val editedMessage = (originalMessage.message as RoomEvent.Message).toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) @@ -159,7 +157,7 @@ internal class RoomEventCreatorTest { result shouldBeEqualTo aRoomReplyMessageEvent( replyingTo = originalMessage.replyingTo, - message = aRoomMessageEvent( + message = aMatrixRoomMessageEvent( eventId = originalMessage.eventId, utcTimestamp = editedMessage.utcTimestamp, content = A_TEXT_EVENT_MESSAGE, @@ -182,7 +180,7 @@ internal class RoomEventCreatorTest { @Test fun `given edited event is older than related room event then ignores edit`() = runTest { - val originalMessage = aRoomMessageEvent(utcTimestamp = 1000) + val originalMessage = aMatrixRoomMessageEvent(utcTimestamp = 1000) val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) @@ -199,7 +197,7 @@ internal class RoomEventCreatorTest { println(replyEvent.content) val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) } - result shouldBeEqualTo aRoomMessageEvent( + result shouldBeEqualTo aMatrixRoomMessageEvent( eventId = replyEvent.id, utcTimestamp = replyEvent.utcTimestamp, content = replyEvent.asTextContent().body!!, @@ -217,13 +215,13 @@ internal class RoomEventCreatorTest { val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } result shouldBeEqualTo aRoomReplyMessageEvent( - replyingTo = aRoomMessageEvent( + replyingTo = aMatrixRoomMessageEvent( eventId = originalMessage.id, utcTimestamp = originalMessage.utcTimestamp, content = originalMessage.asTextContent().body!!, author = A_SENDER, ), - message = aRoomMessageEvent( + message = aMatrixRoomMessageEvent( eventId = replyMessage.id, utcTimestamp = replyMessage.utcTimestamp, content = A_REPLY_EVENT_MESSAGE, @@ -235,7 +233,7 @@ internal class RoomEventCreatorTest { @Test fun `given reply event which relates to a room event then maps to reply`() = runTest { fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) - val originalMessage = aRoomMessageEvent() + val originalMessage = aMatrixRoomMessageEvent() val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) val lookup = givenLookup(originalMessage) @@ -243,7 +241,7 @@ internal class RoomEventCreatorTest { result shouldBeEqualTo aRoomReplyMessageEvent( replyingTo = originalMessage, - message = aRoomMessageEvent( + message = aMatrixRoomMessageEvent( eventId = replyMessage.id, utcTimestamp = replyMessage.utcTimestamp, content = A_REPLY_EVENT_MESSAGE, @@ -263,7 +261,7 @@ internal class RoomEventCreatorTest { result shouldBeEqualTo aRoomReplyMessageEvent( replyingTo = originalMessage.message, - message = aRoomMessageEvent( + message = aMatrixRoomMessageEvent( eventId = replyMessage.id, utcTimestamp = replyMessage.utcTimestamp, content = A_REPLY_EVENT_MESSAGE, @@ -321,7 +319,7 @@ private fun RoomEvent.Message.toReplyEvent(messageContent: String) = anApiTimeli ) ) -private fun ApiEncryptedContent.toMegolm(): RoomEvent.Message.MegOlmV1 { +private fun ApiEncryptedContent.toMegolm(): RoomEvent.Encrypted.MegOlmV1 { require(this is ApiEncryptedContent.MegOlmV1) return aMegolmV1(this.cipherText, this.deviceId, this.senderKey, this.sessionId) } diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt index 60a76fbf..b0b1ef3e 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt @@ -5,8 +5,8 @@ import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomState import fake.FakeMatrixLogger import fake.FakeRoomDataSource -import internalfake.FakeRoomEventsDecrypter import fixture.* +import internalfake.FakeRoomEventsDecrypter import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.Test @@ -15,13 +15,14 @@ import test.expect private val A_ROOM_ID = aRoomId() private object ARoom { - val MESSAGE_EVENT = aRoomMessageEvent(utcTimestamp = 0) + val MESSAGE_EVENT = aMatrixRoomMessageEvent(utcTimestamp = 0) val ENCRYPTED_EVENT = anEncryptedRoomMessageEvent(utcTimestamp = 1) - val DECRYPTED_EVENT = aRoomMessageEvent(utcTimestamp = 2) - val PREVIOUS_STATE = RoomState(aRoomOverview(), listOf(MESSAGE_EVENT, ENCRYPTED_EVENT)) + val DECRYPTED_EVENT = aMatrixRoomMessageEvent(utcTimestamp = 2) + val PREVIOUS_STATE = RoomState(aMatrixRoomOverview(), listOf(MESSAGE_EVENT, ENCRYPTED_EVENT)) val DECRYPTED_EVENTS = listOf(MESSAGE_EVENT, DECRYPTED_EVENT) - val NEW_STATE = RoomState(aRoomOverview(lastMessage = DECRYPTED_EVENT.asLastMessage()), DECRYPTED_EVENTS) + val NEW_STATE = RoomState(aMatrixRoomOverview(lastMessage = DECRYPTED_EVENT.asLastMessage()), DECRYPTED_EVENTS) } + private val A_USER_CREDENTIALS = aUserCredentials() internal class RoomRefresherTest { diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt index 31ecef5c..d8a1d4a6 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/TimelineEventsProcessorTest.kt @@ -19,7 +19,7 @@ private val A_ROOM_ID = aRoomId() private val ANY_LOOKUP_RESULT = LookupResult(anApiTimelineTextEvent(), roomEvent = null) private val AN_ENCRYPTED_TIMELINE_EVENT = anEncryptedApiTimelineEvent() private val A_TEXT_TIMELINE_EVENT = anApiTimelineTextEvent() -private val A_MESSAGE_ROOM_EVENT = aRoomMessageEvent(anEventId("a-message")) +private val A_MESSAGE_ROOM_EVENT = aMatrixRoomMessageEvent(anEventId("a-message")) private val AN_ENCRYPTED_ROOM_EVENT = anEncryptedRoomMessageEvent(anEventId("encrypted-message")) private val A_LOOKUP_EVENT_ID = anEventId("lookup-id") private val A_USER_CREDENTIALS = aUserCredentials() @@ -52,7 +52,7 @@ class TimelineEventsProcessorTest { @Test fun `given encrypted and text timeline events when processing then maps to room events`() = runTest { - val previousEvents = listOf(aRoomMessageEvent(eventId = anEventId("previous-event"))) + val previousEvents = listOf(aMatrixRoomMessageEvent(eventId = anEventId("previous-event"))) val newTimelineEvents = listOf(AN_ENCRYPTED_TIMELINE_EVENT, A_TEXT_TIMELINE_EVENT) val roomToProcess = aRoomToProcess(apiSyncRoom = anApiSyncRoom(anApiSyncRoomTimeline(newTimelineEvents))) fakeRoomEventsDecrypter.givenDecrypts(A_USER_CREDENTIALS, previousEvents) diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessorTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessorTest.kt index 9fd79c2f..28d2be16 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessorTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessorTest.kt @@ -8,8 +8,8 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import test.expect -private val A_ROOM_OVERVIEW = aRoomOverview() -private val A_ROOM_MESSAGE_FROM_OTHER = aRoomMessageEvent( +private val A_ROOM_OVERVIEW = aMatrixRoomOverview() +private val A_ROOM_MESSAGE_FROM_OTHER = aMatrixRoomMessageEvent( eventId = anEventId("a-new-message-event"), author = aRoomMember(id = aUserId("a-different-user")) ) @@ -27,7 +27,7 @@ internal class UnreadEventsProcessorTest { fun `given initial sync when processing unread then does mark any events as unread`() = runTest { unreadEventsProcessor.processUnreadState( isInitialSync = true, - overview = aRoomOverview(), + overview = aMatrixRoomOverview(), previousState = null, newEvents = emptyList(), selfId = aUserId() diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt index 3909f95d..2c1b0bf8 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt @@ -5,11 +5,12 @@ import app.dapk.st.matrix.sync.SyncService import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf import test.delegateReturn class FakeSyncService : SyncService by mockk() { fun givenStartsSyncing() { - every { startSyncing() }.returns(emptyFlow()) + every { startSyncing() }.returns(flowOf(Unit)) } fun givenRoom(roomId: RoomId) = every { room(roomId) }.delegateReturn() diff --git a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt index 65b1c096..36bdf0ba 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt @@ -4,15 +4,14 @@ import app.dapk.st.matrix.common.* import app.dapk.st.matrix.sync.MessageMeta import app.dapk.st.matrix.sync.RoomEvent -fun aRoomMessageEvent( +fun aMatrixRoomMessageEvent( eventId: EventId = anEventId(), utcTimestamp: Long = 0L, content: String = "message-content", author: RoomMember = aRoomMember(), meta: MessageMeta = MessageMeta.FromServer, - encryptedContent: RoomEvent.Message.MegOlmV1? = null, edited: Boolean = false, -) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, encryptedContent, edited) +) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, edited) fun aRoomImageMessageEvent( eventId: EventId = anEventId(), @@ -20,31 +19,30 @@ fun aRoomImageMessageEvent( content: RoomEvent.Image.ImageMeta = anImageMeta(), author: RoomMember = aRoomMember(), meta: MessageMeta = MessageMeta.FromServer, - encryptedContent: RoomEvent.Message.MegOlmV1? = null, edited: Boolean = false, -) = RoomEvent.Image(eventId, utcTimestamp, content, author, meta, encryptedContent, edited) +) = RoomEvent.Image(eventId, utcTimestamp, content, author, meta, edited) fun aRoomReplyMessageEvent( - message: RoomEvent = aRoomMessageEvent(), - replyingTo: RoomEvent = aRoomMessageEvent(eventId = anEventId("in-reply-to-id")), + message: RoomEvent = aMatrixRoomMessageEvent(), + replyingTo: RoomEvent = aMatrixRoomMessageEvent(eventId = anEventId("in-reply-to-id")), ) = RoomEvent.Reply(message, replyingTo) fun anEncryptedRoomMessageEvent( eventId: EventId = anEventId(), utcTimestamp: Long = 0L, - content: String = "encrypted-content", author: RoomMember = aRoomMember(), meta: MessageMeta = MessageMeta.FromServer, - encryptedContent: RoomEvent.Message.MegOlmV1? = aMegolmV1(), + encryptedContent: RoomEvent.Encrypted.MegOlmV1 = aMegolmV1(), edited: Boolean = false, -) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, encryptedContent, edited) + redacted: Boolean = false, +) = RoomEvent.Encrypted(eventId, utcTimestamp, author, meta, edited, redacted, encryptedContent) fun aMegolmV1( cipherText: CipherText = CipherText("a-cipher"), deviceId: DeviceId = aDeviceId(), senderKey: String = "a-sender-key", sessionId: SessionId = aSessionId(), -) = RoomEvent.Message.MegOlmV1(cipherText, deviceId, senderKey, sessionId) +) = RoomEvent.Encrypted.MegOlmV1(cipherText, deviceId, senderKey, sessionId) fun anImageMeta( width: Int? = 100, diff --git a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomOverviewFixture.kt b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomOverviewFixture.kt index ecc363c6..03c019fc 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomOverviewFixture.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomOverviewFixture.kt @@ -7,7 +7,7 @@ import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.sync.LastMessage import app.dapk.st.matrix.sync.RoomOverview -fun aRoomOverview( +fun aMatrixRoomOverview( roomId: RoomId = aRoomId(), roomCreationUtc: Long = 0L, roomName: String? = null, diff --git a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomStateFixture.kt b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomStateFixture.kt index 260e46dc..511bcf49 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomStateFixture.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomStateFixture.kt @@ -4,7 +4,7 @@ import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomState -fun aRoomState( - roomOverview: RoomOverview = aRoomOverview(), - events: List = listOf(aRoomMessageEvent()), +fun aMatrixRoomState( + roomOverview: RoomOverview = aMatrixRoomOverview(), + events: List = listOf(aMatrixRoomMessageEvent()), ) = RoomState(roomOverview, events) \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 4b8bbf49..bb28daba 100644 --- a/settings.gradle +++ b/settings.gradle @@ -55,3 +55,6 @@ include ':matrix:services:profile' include ':core' include ':test-harness' + +include ':chat-engine' +include ':matrix-chat-engine' \ No newline at end of file diff --git a/test-harness/build.gradle b/test-harness/build.gradle index fa5f378a..5d445a7d 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.11.0' + testImplementation 'app.cash.turbine:turbine:0.12.0' testImplementation Dependencies.mavenCentral.kotlinSerializationJson diff --git a/test-harness/src/test/kotlin/test/Test.kt b/test-harness/src/test/kotlin/test/Test.kt index 7ff56046..e0403a22 100644 --- a/test-harness/src/test/kotlin/test/Test.kt +++ b/test-harness/src/test/kotlin/test/Test.kt @@ -7,7 +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.crypto.MatrixMediaDecrypter import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.sync.RoomEvent @@ -153,7 +153,7 @@ class MatrixTestScope(private val testScope: TestScope) { null -> output.readBytes().md5Hash() else -> { val byteStream = ByteArrayOutputStream() - MediaDecrypter(this.base64).decrypt(output.inputStream(), keys.k, keys.iv).collect { + MatrixMediaDecrypter(this.base64).decrypt(output.inputStream(), keys.k, keys.iv).collect { byteStream.write(it) } byteStream.toByteArray().md5Hash() diff --git a/test-harness/src/test/kotlin/test/impl/InstantScheduler.kt b/test-harness/src/test/kotlin/test/impl/InstantScheduler.kt index f0d7788c..2cdf443a 100644 --- a/test-harness/src/test/kotlin/test/impl/InstantScheduler.kt +++ b/test-harness/src/test/kotlin/test/impl/InstantScheduler.kt @@ -12,7 +12,7 @@ class InstantScheduler(private val matrixClient: MatrixClient) : BackgroundSched matrixClient.run( MatrixTaskRunner.MatrixTask( task.type, - task.jsonPayload + task.jsonPayload.value, ) ) } diff --git a/version.json b/version.json index a52f12b1..06414aff 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "code": 21, - "name": "06/10/2022-V1" + "code": 22, + "name": "17/10/2022-V1" } \ No newline at end of file