Merge pull request #209 from ouchadam/release-candidate

[Auto] Release Candidate
This commit is contained in:
Adam Brown 2022-10-17 23:16:41 +01:00 committed by GitHub
commit fc9a864ed8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
156 changed files with 2345 additions and 1241 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -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<DirectoryState>
fun invites(): Flow<InviteState>
fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow<MessengerState>
fun notificationsMessages(): Flow<UnreadNotifications>
fun notificationsInvites(): Flow<InviteNotification>
suspend fun login(request: LoginRequest): LoginResult
suspend fun me(forceRefresh: Boolean): Me
suspend fun InputStream.importRoomKeys(password: String): Flow<ImportResult>
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<RoomMember>
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<Map<RoomOverview, List<RoomEvent>>, NotificationDiff>
data class NotificationDiff(
val unchanged: Map<RoomId, List<EventId>>,
val changedOrNew: Map<RoomId, List<EventId>>,
val removed: Map<RoomId, List<EventId>>,
val newRooms: Set<RoomId>
)
data class InviteNotification(
val content: String,
val roomId: RoomId
)

View File

@ -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<DirectoryItem>
typealias OverviewState = List<RoomOverview>
typealias InviteState = List<RoomInvite>
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<RoomMember>)
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<RoomId>, 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<RoomEvent>,
)
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<String, String>,
)
}
}
}
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
}

View File

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

View File

@ -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<RoomEvent> = listOf(aRoomMessageEvent()),
) = RoomState(roomOverview, events)

View File

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

View File

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

View File

@ -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 <T> Flow<T>.firstOrNull(count: Int, predicate: suspend (T) -> Boolean): T? {
var counter = 0
@ -18,5 +20,3 @@ suspend fun <T> Flow<T>.firstOrNull(count: Int, predicate: suspend (T) -> Boolea
return result
}
fun <T> Flow<T>.startAndIgnoreEmissions(): Flow<Boolean> = this.map { false }.onStart { emit(true) }.filter { it }

View File

@ -3,6 +3,13 @@ package app.dapk.st.core.extensions
inline fun <T> T?.ifNull(block: () -> T): T = this ?: block()
inline fun <T> ifOrNull(condition: Boolean, block: () -> T): T? = if (condition) block() else null
inline fun <reified T> Any.takeAs(): T? {
return when (this) {
is T -> this
else -> null
}
}
@Suppress("UNCHECKED_CAST")
inline fun <T, T1 : T, T2 : T> Iterable<T>.firstOrNull(predicate: (T) -> Boolean, predicate2: (T) -> Boolean): Pair<T1, T2>? {
var firstValue: T1? = null

View File

@ -1,3 +1,5 @@
package test
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow

View File

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

View File

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

View File

@ -10,22 +10,27 @@ fun <T : Any> Spider(currentPage: SpiderPage<T>, onNavigate: (SpiderPage<out T>?
val pageCache = remember { mutableMapOf<Route<*>, SpiderPage<out T>>() }
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<Route<*>, @Composable (T) -> Unit>().also { computedWeb ->
val scope = object : SpiderScope {
override fun <T> item(route: Route<T>, content: @Composable (T) -> Unit) {
computedWeb[route] = { content(it as T) }
override fun <T> item(route: Route<T>, 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 <T : Any> Spider(currentPage: SpiderPage<T>, onNavigate: (SpiderPage<out T>?
interface SpiderScope {
fun <T> item(route: Route<T>, content: @Composable (T) -> Unit)
fun <T> item(route: Route<T>, content: @Composable SpiderItemScope.(T) -> Unit)
}
interface SpiderItemScope {
fun goBack()
}
data class SpiderPage<T>(

View File

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

View File

@ -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<Preferences>,
private val preferences: Lazy<CachedPreferences>,
) : ProvidableModule {
fun intentFactory() = intentFactory
private val themeStore by unsafeLazy { ThemeStore(preferences.value) }
fun themeStore() = themeStore
fun themeStore() = ThemeStore(preferences.value)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}
testImplementation(testFixtures(project(":core")))
testFixturesImplementation(testFixtures(project(":core")))}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
package app.dapk.st.domain.preference
@Suppress("UNCHECKED_CAST")
class PropertyCache {
private val map = mutableMapOf<String, Any>()
fun <T> getValue(key: String): T? {
return map[key] as? T?
}
fun setValue(key: String, value: Any) {
map[key] = value
}
}

View File

@ -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<LoggingStore>()
fun givenLoggingIsEnabled() = coEvery { instance.isEnabled() }.delegateReturn()
}

View File

@ -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<MessageOptionsStore>()
fun givenReadReceiptsDisabled() = coEvery { instance.isReadReceiptsDisabled() }.delegateReturn()
}

View File

@ -1,4 +1,4 @@
package fixture
package fake
import app.dapk.st.domain.StoreCleaner
import io.mockk.mockk

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

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

@ -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<ShortcutHandler>()
}
class FakeDirectoryUseCase {
val instance = mockk<DirectoryUseCase>()
fun given() = every { instance.state() }.delegateReturn()
}

View File

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

View File

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

View File

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

View File

@ -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<HomeScreenState, HomeEvent>(
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<LoginScreenState, LoginEvent>(
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

View File

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

View File

@ -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<RoomEvent.Image> {
private val mediaDecrypter = MediaDecrypter(base64)
class DecryptingFetcherFactory(
private val context: Context,
private val roomId: RoomId,
private val mediaDecrypter: MediaDecrypter,
) : Fetcher.Factory<RoomEvent.Image> {
override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher {
return DecryptingFetcher(data, context, mediaDecrypter, roomId)

View File

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

View File

@ -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<MessengerState>, replyActions: ReplyActions) {
private fun ColumnScope.Room(roomStateLce: Lce<MessengerState>, 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<MessengerState>, 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<RoomEvent.Image>)
is Message -> TextBubbleContent(it as BubbleContent<RoomEvent.Message>)
is RoomEvent.Message -> TextBubbleContent(it as BubbleContent<RoomEvent.Message>)
is RoomEvent.Reply -> ReplyBubbleContent(it as BubbleContent<RoomEvent.Reply>)
is RoomEvent.Encrypted -> EncryptedBubbleContent(it as BubbleContent<RoomEvent.Encrypted>)
}
}
}
@ -457,6 +446,54 @@ private fun TextBubbleContent(content: BubbleContent<RoomEvent.Message>) {
}
}
@Composable
private fun EncryptedBubbleContent(content: BubbleContent<RoomEvent.Encrypted>) {
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<RoomEvent.Reply>) {
Box(modifier = Modifier.padding(start = 6.dp)) {
@ -494,7 +531,7 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
)
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<RoomEvent.Reply>) {
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<RoomEvent.Reply>) {
)
}
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<RoomEvent.Reply>) {
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(

View File

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

View File

@ -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<MessengerScreenState> = defaultStateFactory(),
) : DapkViewModel<MessengerScreenState, MessengerEvent>(
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<Unit> {
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<RoomEvent.Message>()
.filterNot { it.author.id == self }
.firstOrNull()
?.eventId
sealed interface MessengerAction {
data class ComposerTextUpdate(val newValue: String) : MessengerAction
data class ComposerEnterReplyMode(val replyingTo: RoomEvent) : MessengerAction

View File

@ -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<PermissionResult>>(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<Lce<PermissionResult>>, 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
}

View File

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

View File

@ -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<List<Folder>>) : ImageGalleryPage
data class Files(val content: Lce<List<Media>>) : ImageGalleryPage
data class Files(val content: Lce<List<Media>>, val folder: Folder) : ImageGalleryPage
object Routes {
val folders = Route<Folders>("Folders")

View File

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

View File

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

View File

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

View File

@ -1,2 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="app.dapk.st.notifications"/>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.dapk.st.notifications">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
</manifest>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<RoomEvent>): List<Notifiable> {
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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<NotificationMessageRenderer>()
fun verifyRenders(vararg unreadNotifications: UnreadNotifications) {
fun verifyRenders(vararg unreadNotifications: app.dapk.st.engine.UnreadNotifications) {
unreadNotifications.forEach { unread ->
coVerify {
instance.render(

View File

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

View File

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

View File

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

View File

@ -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<out Page>,
@ -14,12 +13,12 @@ data class ProfileScreenState(
sealed interface Page {
data class Profile(val content: Lce<Content>) : Page {
data class Content(
val me: ProfileService.Me,
val me: Me,
val invitationsCount: Int,
)
}
data class Invitations(val content: Lce<List<RoomInvite>>): Page
data class Invitations(val content: Lce<List<RoomInvite>>) : Page
object Routes {
val profile = Route<Profile>("Profile")

View File

@ -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, ProfileEvent>(
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<Page.Invitations> {
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()
}
}

View File

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

View File

@ -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<SettingItem> {
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"),

View File

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

View File

@ -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<ImportResult.Error.Type.Unknown>()?.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()
}

View File

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

View File

@ -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<SettingsScreenState> = defaultStateFactory(),
) : DapkViewModel<SettingsScreenState, SettingsEvent>(
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<Page.PushProviders> { copy(options = Lce.Loading()) }
@ -135,24 +140,13 @@ internal class SettingsViewModel(
fun importFromFileKeys(file: Uri, passphrase: String) {
updatePageState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Update(0)) }
viewModelScope.launch {
with(cryptoService) {
with(chatEngine) {
runCatching { contentResolver.openInputStream(file)!! }
.fold(
onSuccess = { fileStream ->
fileStream.importRoomKeys(passphrase)
.onEach {
updatePageState<Page.ImportRoomKey> { 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)
},

View File

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

View File

@ -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<List<String>>,

View File

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

View File

@ -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<ThemeStore>()
fun givenMaterialYouIsEnabled() = every { instance.isMaterialYouEnabled() }.delegateReturn()
fun givenMaterialYouIsEnabled() = coEvery { instance.isMaterialYouEnabled() }.delegateReturn()
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Item> {
return syncSyncService.overview().first().map {
suspend fun fetch(): List<Item> {
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 }
)
}
}

View File

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

View File

@ -22,7 +22,7 @@ class ShareEntryViewModel(
fun start() {
syncJob = viewModelScope.launch {
state = DirectoryScreenState.Content(fetchRoomsUseCase.bar())
state = DirectoryScreenState.Content(fetchRoomsUseCase.fetch())
}
}

Some files were not shown because too many files have changed in this diff Show More