Merge pull request #209 from ouchadam/release-candidate
[Auto] Release Candidate
This commit is contained in:
commit
fc9a864ed8
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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")))
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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)
|
|
@ -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 {
|
||||
|
|
@ -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())
|
|
@ -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 }
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
package test
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
|
@ -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"
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")))}
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package fixture
|
||||
package fake
|
||||
|
||||
import app.dapk.st.domain.StoreCleaner
|
||||
import io.mockk.mockk
|
|
@ -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"))
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
package app.dapk.st.directory
|
||||
|
||||
import app.dapk.st.engine.DirectoryState
|
||||
|
||||
sealed interface DirectoryScreenState {
|
||||
|
||||
object EmptyLoading : DirectoryScreenState
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"))
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
|
@ -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")
|
||||
}
|
|
@ -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"))
|
||||
}
|
|
@ -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>
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()))
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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"))
|
||||
}
|
|
@ -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"),
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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>>,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue