starting engine migration

- add a chat-engine abstraction
- create a matrix-chat-engine implementation
- starts by porting the initial creation and directory listing
This commit is contained in:
Adam Brown 2022-10-09 16:52:33 +01:00
parent 94731d006e
commit 128c2db432
15 changed files with 483 additions and 50 deletions

View File

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

View File

@ -17,6 +17,7 @@ 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
@ -164,12 +165,8 @@ internal class FeatureModules internal constructor(
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 = matrixModules.engine,
)
}
val loginModule by unsafeLazy {
@ -252,6 +249,30 @@ internal class MatrixModules(
private val buildMeta: BuildMeta,
) {
val engine by unsafeLazy {
val store = storeModule.value
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 matrix by unsafeLazy {
val store = storeModule.value
val credentialsStore = store.credentialsStore()

12
chat-engine/build.gradle Normal file
View File

@ -0,0 +1,12 @@
plugins {
id 'kotlin'
}
dependencies {
api Dependencies.mavenCentral.kotlinCoroutinesCore
api project(":matrix:common")
implementation project(":matrix:services:sync")
implementation project(":matrix:services:message")
implementation project(":matrix:services:room")
}

View File

@ -0,0 +1,46 @@
package app.dapk.st.engine
import app.dapk.st.matrix.common.AvatarUrl
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.RoomMember
import kotlinx.coroutines.flow.Flow
interface ChatEngine {
fun directory(): Flow<DirectoryState>
}
typealias DirectoryState = List<DirectoryItem>
typealias OverviewState = List<RoomOverview>
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,
)
}
@JvmInline
value class UnreadCount(val value: Int)
data class Typing(val roomId: RoomId, val members: List<RoomMember>)

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
package app.dapk.st.directory
import app.dapk.st.engine.DirectoryState
sealed interface DirectoryScreenState {
object EmptyLoading : DirectoryScreenState

View File

@ -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<DirectoryScreenState> = defaultStateFactory(),
) : DapkViewModel<DirectoryScreenState, DirectoryEvent>(
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

View File

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

View File

@ -0,0 +1,23 @@
plugins {
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")
}

View File

@ -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<RoomFoo>
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<Typing>().firstOrNull { it.roomId == roomOverview.roomId }
typing = events.filterIsInstance<Typing>().firstOrNull { it.roomId == roomOverview.roomId }?.engine()
)
}
}
@ -50,7 +40,7 @@ class DirectoryUseCase(
private fun overviewDatasource() = combine(
syncService.startSyncing().map { false }.onStart { emit(true) },
syncService.overview()
syncService.overview().map { it.map { it.engine() } }
) { isFirstLoad, overview ->
when {
isFirstLoad && overview.isEmpty() -> null
@ -81,7 +71,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 +86,6 @@ class DirectoryUseCase(
}
}

View File

@ -0,0 +1,27 @@
package app.dapk.st.engine
import app.dapk.st.matrix.sync.LastMessage as MatrixLastMessage
import app.dapk.st.matrix.sync.RoomOverview as MatrixRoomOverview
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,
)

View File

@ -0,0 +1,317 @@
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.auth.DeviceDisplayNameGenerator
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.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.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 java.time.Clock
class MatrixEngine internal constructor(
private val directoryUseCase: Lazy<DirectoryUseCase>,
) : ChatEngine {
override fun directory() = directoryUseCase.value.state()
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 matrix = MatrixFactory.createMatrix(
base64,
buildMeta,
logger,
nameGenerator,
coroutineDispatchers,
errorTracker,
imageContentReader,
backgroundScheduler,
memberStore,
roomStore,
profileStore,
syncStore,
overviewStore,
filterStore,
localEchoStore,
credentialsStore,
knownDeviceStore,
olmStore
)
val directoryUseCase = unsafeLazy {
DirectoryUseCase(
matrix.syncService(),
matrix.messageService(),
matrix.roomService(),
credentialsStore,
roomStore
)
}
return MatrixEngine(directoryUseCase)
}
}
}
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<UserId>) = roomService.findMembers(roomId, userIds)
override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId)
override suspend fun insert(roomId: RoomId, members: List<RoomMember>) = roomService.insertMembers(roomId, members)
}
},
errorTracker = errorTracker,
coroutineDispatchers = coroutineDispatchers,
)
installPushService(credentialsStore)
}
}
}
fun <T> unsafeLazy(initializer: () -> T): Lazy<T> = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer)

View File

@ -55,3 +55,6 @@ include ':matrix:services:profile'
include ':core'
include ':test-harness'
include ':chat-engine'
include ':matrix-chat-engine'