Merge pull request #82 from ouchadam/feature/unified-push

Feature/unified push
This commit is contained in:
Adam Brown 2022-08-18 21:42:00 +01:00 committed by GitHub
commit da0b1764b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 668 additions and 193 deletions

View File

@ -16,8 +16,9 @@ import app.dapk.st.home.HomeModule
import app.dapk.st.login.LoginModule import app.dapk.st.login.LoginModule
import app.dapk.st.messenger.MessengerModule import app.dapk.st.messenger.MessengerModule
import app.dapk.st.notifications.NotificationsModule import app.dapk.st.notifications.NotificationsModule
import app.dapk.st.notifications.PushAndroidService
import app.dapk.st.profile.ProfileModule import app.dapk.st.profile.ProfileModule
import app.dapk.st.push.firebase.FirebasePushService
import app.dapk.st.push.PushModule
import app.dapk.st.settings.SettingsModule import app.dapk.st.settings.SettingsModule
import app.dapk.st.share.ShareEntryModule import app.dapk.st.share.ShareEntryModule
import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.TaskRunnerModule
@ -54,7 +55,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) { private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) {
applicationScope.launch { applicationScope.launch {
notificationsModule.firebasePushTokenUseCase().registerCurrentToken() featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
storeModule.localEchoStore.preload() storeModule.localEchoStore.preload()
} }
@ -73,6 +74,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
SettingsModule::class -> featureModules.settingsModule SettingsModule::class -> featureModules.settingsModule
ProfileModule::class -> featureModules.profileModule ProfileModule::class -> featureModules.profileModule
NotificationsModule::class -> featureModules.notificationsModule NotificationsModule::class -> featureModules.notificationsModule
PushModule::class -> featureModules.pushModule
MessengerModule::class -> featureModules.messengerModule MessengerModule::class -> featureModules.messengerModule
TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule
CoreAndroidModule::class -> appModule.coreAndroidModule CoreAndroidModule::class -> appModule.coreAndroidModule
@ -82,10 +84,9 @@ class SmallTalkApplication : Application(), ModuleProvider {
} }
override fun reset() { override fun reset() {
featureModules.notificationsModule.firebasePushTokenUseCase().unregister() featureModules.pushModule.pushTokenRegistrar().unregister()
appModule.coroutineDispatchers.io.cancel() appModule.coroutineDispatchers.io.cancel()
applicationScope.cancel() applicationScope.cancel()
stopService(Intent(this, PushAndroidService::class.java))
lazyAppModule.reset() lazyAppModule.reset()
lazyFeatureModules.reset() lazyFeatureModules.reset()

View File

@ -48,6 +48,7 @@ import app.dapk.st.messenger.MessengerActivity
import app.dapk.st.messenger.MessengerModule import app.dapk.st.messenger.MessengerModule
import app.dapk.st.navigator.IntentFactory import app.dapk.st.navigator.IntentFactory
import app.dapk.st.navigator.MessageAttachment import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.notifications.MatrixPushHandler
import app.dapk.st.notifications.NotificationsModule import app.dapk.st.notifications.NotificationsModule
import app.dapk.st.olm.DeviceKeyFactory import app.dapk.st.olm.DeviceKeyFactory
import app.dapk.st.olm.OlmPersistenceWrapper import app.dapk.st.olm.OlmPersistenceWrapper
@ -91,7 +92,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
private val imageLoaderModule = ImageLoaderModule(context) private val imageLoaderModule = ImageLoaderModule(context)
private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, context.contentResolver) private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, context.contentResolver)
val domainModules = DomainModules(matrixModules, trackingModule.errorTracker) val domainModules = DomainModules(matrixModules, trackingModule.errorTracker, workModule, storeModule, context, coroutineDispatchers)
val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory { val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory {
override fun notificationOpenApp(context: Context) = PendingIntent.getActivity( override fun notificationOpenApp(context: Context) = PendingIntent.getActivity(
@ -125,7 +126,6 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
matrixModules, matrixModules,
domainModules, domainModules,
trackingModule, trackingModule,
workModule,
coreAndroidModule, coreAndroidModule,
imageLoaderModule, imageLoaderModule,
context, context,
@ -140,7 +140,6 @@ internal class FeatureModules internal constructor(
private val matrixModules: MatrixModules, private val matrixModules: MatrixModules,
private val domainModules: DomainModules, private val domainModules: DomainModules,
private val trackingModule: TrackingModule, private val trackingModule: TrackingModule,
private val workModule: WorkModule,
private val coreAndroidModule: CoreAndroidModule, private val coreAndroidModule: CoreAndroidModule,
imageLoaderModule: ImageLoaderModule, imageLoaderModule: ImageLoaderModule,
context: Context, context: Context,
@ -181,6 +180,7 @@ internal class FeatureModules internal constructor(
val settingsModule by unsafeLazy { val settingsModule by unsafeLazy {
SettingsModule( SettingsModule(
storeModule.value, storeModule.value,
pushModule,
matrixModules.crypto, matrixModules.crypto,
matrixModules.sync, matrixModules.sync,
context.contentResolver, context.contentResolver,
@ -191,14 +191,9 @@ internal class FeatureModules internal constructor(
val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync, matrixModules.room, trackingModule.errorTracker) } val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync, matrixModules.room, trackingModule.errorTracker) }
val notificationsModule by unsafeLazy { val notificationsModule by unsafeLazy {
NotificationsModule( NotificationsModule(
matrixModules.push,
matrixModules.sync,
storeModule.value.credentialsStore(),
domainModules.pushModule.registerFirebasePushTokenUseCase(),
imageLoaderModule.iconLoader(), imageLoaderModule.iconLoader(),
storeModule.value.roomStore(), storeModule.value.roomStore(),
context, context,
workModule.workScheduler(),
intentFactory = coreAndroidModule.intentFactory(), intentFactory = coreAndroidModule.intentFactory(),
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
deviceMeta = DeviceMeta(Build.VERSION.SDK_INT) deviceMeta = DeviceMeta(Build.VERSION.SDK_INT)
@ -209,6 +204,10 @@ internal class FeatureModules internal constructor(
ShareEntryModule(matrixModules.sync, matrixModules.room) ShareEntryModule(matrixModules.sync, matrixModules.room)
} }
val pushModule by unsafeLazy {
domainModules.pushModule
}
} }
internal class MatrixModules( internal class MatrixModules(
@ -423,9 +422,28 @@ internal class MatrixModules(
internal class DomainModules( internal class DomainModules(
private val matrixModules: MatrixModules, private val matrixModules: MatrixModules,
private val errorTracker: ErrorTracker, private val errorTracker: ErrorTracker,
private val workModule: WorkModule,
private val storeModule: Lazy<StoreModule>,
private val context: Application,
private val dispatchers: CoroutineDispatchers,
) { ) {
val pushModule by unsafeLazy { PushModule(matrixModules.push, errorTracker) } val pushModule by unsafeLazy {
val store = storeModule.value
val pushHandler = MatrixPushHandler(
workScheduler = workModule.workScheduler(),
credentialsStore = store.credentialsStore(),
matrixModules.sync,
store.roomStore(),
)
PushModule(
errorTracker,
pushHandler,
context,
dispatchers,
SharedPreferencesDelegate(context.applicationContext, fileName = "dapk-user-preferences", dispatchers)
)
}
val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run, AppTaskRunner(matrixModules.push))) } val taskRunnerModule by unsafeLazy { TaskRunnerModule(TaskRunnerAdapter(matrixModules.matrix::run, AppTaskRunner(matrixModules.push))) }
} }

View File

@ -1,8 +1,10 @@
package app.dapk.st.graph package app.dapk.st.graph
import app.dapk.st.matrix.push.PushService import app.dapk.st.matrix.push.PushService
import app.dapk.st.push.PushTokenPayload
import app.dapk.st.work.TaskRunner import app.dapk.st.work.TaskRunner
import io.ktor.client.plugins.* import io.ktor.client.plugins.*
import kotlinx.serialization.json.Json
class AppTaskRunner( class AppTaskRunner(
private val pushService: PushService, private val pushService: PushService,
@ -12,7 +14,8 @@ class AppTaskRunner(
return when (val type = workTask.task.type) { return when (val type = workTask.task.type) {
"push_token" -> { "push_token" -> {
runCatching { runCatching {
pushService.registerPush(workTask.task.jsonPayload) val payload = Json.decodeFromString(PushTokenPayload.serializer(), workTask.task.jsonPayload)
pushService.registerPush(payload.token, payload.gatewayUrl)
}.fold( }.fold(
onSuccess = { TaskRunner.TaskResult.Success(workTask.source) }, onSuccess = { TaskRunner.TaskResult.Success(workTask.source) },
onFailure = { onFailure = {
@ -25,6 +28,7 @@ class AppTaskRunner(
} }
) )
} }
else -> throw IllegalArgumentException("Unknown work type: $type") else -> throw IllegalArgumentException("Unknown work type: $type")
} }

View File

@ -10,6 +10,13 @@ ext.Dependencies.with {
} }
} }
repositories.maven {
url 'https://jitpack.io'
content {
includeGroup "com.github.UnifiedPush"
}
}
repositories.mavenCentral { repositories.mavenCentral {
content { content {
includeGroupByRegex "org\\.jetbrains.*" includeGroupByRegex "org\\.jetbrains.*"
@ -140,6 +147,12 @@ ext.Dependencies.with {
matrixOlm = "org.matrix.android:olm-sdk:3.2.12" matrixOlm = "org.matrix.android:olm-sdk:3.2.12"
} }
jitPack = new DependenciesContainer()
jitPack.with {
unifiedPush = "com.github.UnifiedPush:android-connector:2.0.1"
}
} }
class DependenciesContainer extends GroovyObjectSupport { class DependenciesContainer extends GroovyObjectSupport {

View File

@ -2,7 +2,6 @@ package app.dapk.st.core
import android.content.Context import android.content.Context
inline fun <reified T : ProvidableModule> Context.module() = inline fun <reified T : ProvidableModule> Context.module() = (this.applicationContext as ModuleProvider).provide(T::class)
(this.applicationContext as ModuleProvider).provide(T::class)
fun Context.resetModules() = (this.applicationContext as ModuleProvider).reset() fun Context.resetModules() = (this.applicationContext as ModuleProvider).reset()

View File

@ -1,8 +1,13 @@
applyAndroidLibraryModule(project) applyAndroidLibraryModule(project)
apply plugin: "org.jetbrains.kotlin.plugin.serialization"
dependencies { dependencies {
implementation project(':core') implementation project(':core')
implementation project(':domains:android:core')
implementation project(':domains:store')
implementation project(':matrix:services:push') implementation project(':matrix:services:push')
implementation platform('com.google.firebase:firebase-bom:29.0.3') implementation platform('com.google.firebase:firebase-bom:29.0.3')
implementation 'com.google.firebase:firebase-messaging' implementation 'com.google.firebase:firebase-messaging'
implementation Dependencies.mavenCentral.kotlinSerializationJson
implementation Dependencies.jitPack.unifiedPush
} }

View File

@ -1,2 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest package="app.dapk.st.push"/> <manifest package="app.dapk.st.push" xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name=".firebase.FirebasePushService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
<receiver android:exported="true" android:enabled="true" android:name=".unifiedpush.UnifiedPushMessageReceiver">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED"/>
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -0,0 +1,17 @@
package app.dapk.st.push
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
interface PushHandler {
fun onNewToken(payload: PushTokenPayload)
fun onMessageReceived(eventId: EventId?, roomId: RoomId?)
}
@Serializable
data class PushTokenPayload(
@SerialName("token") val token: String,
@SerialName("gateway_url") val gatewayUrl: String,
)

View File

@ -1,16 +1,40 @@
package app.dapk.st.push package app.dapk.st.push
import android.content.Context
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.matrix.push.PushService import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.domain.Preferences
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
import app.dapk.st.push.firebase.FirebasePushTokenRegistrar
import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar
class PushModule( class PushModule(
private val pushService: PushService,
private val errorTracker: ErrorTracker, private val errorTracker: ErrorTracker,
) { private val pushHandler: PushHandler,
private val context: Context,
private val dispatchers: CoroutineDispatchers,
private val preferences: Preferences,
) : ProvidableModule {
fun registerFirebasePushTokenUseCase() = RegisterFirebasePushTokenUseCase( private val registrars by unsafeLazy {
pushService, PushTokenRegistrars(
errorTracker, context,
) FirebasePushTokenRegistrar(
errorTracker,
context,
pushHandler,
),
UnifiedPushRegistrar(context),
PushTokenRegistrarPreferences(preferences)
)
}
fun pushTokenRegistrars() = registrars
fun pushTokenRegistrar(): PushTokenRegistrar = pushTokenRegistrars()
fun pushHandler() = pushHandler
fun dispatcher() = dispatchers
} }

View File

@ -0,0 +1,6 @@
package app.dapk.st.push
interface PushTokenRegistrar {
suspend fun registerCurrentToken()
fun unregister()
}

View File

@ -0,0 +1,76 @@
package app.dapk.st.push
import android.content.Context
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
import app.dapk.st.push.firebase.FirebasePushTokenRegistrar
import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar
import org.unifiedpush.android.connector.UnifiedPush
private val FIREBASE_OPTION = Registrar("Google - Firebase (FCM)")
private val NONE = Registrar("None")
class PushTokenRegistrars(
private val context: Context,
private val firebasePushTokenRegistrar: FirebasePushTokenRegistrar,
private val unifiedPushRegistrar: UnifiedPushRegistrar,
private val pushTokenStore: PushTokenRegistrarPreferences,
) : PushTokenRegistrar {
private var selection: Registrar? = null
fun options(): List<Registrar> {
return listOf(NONE, FIREBASE_OPTION) + UnifiedPush.getDistributors(context).map { Registrar(it) }
}
suspend fun currentSelection() = selection ?: (pushTokenStore.currentSelection()?.let { Registrar(it) } ?: FIREBASE_OPTION).also { selection = it }
suspend fun makeSelection(option: Registrar) {
selection = option
pushTokenStore.store(option.id)
when (option) {
NONE -> {
firebasePushTokenRegistrar.unregister()
unifiedPushRegistrar.unregister()
}
FIREBASE_OPTION -> {
unifiedPushRegistrar.unregister()
firebasePushTokenRegistrar.registerCurrentToken()
}
else -> {
firebasePushTokenRegistrar.unregister()
unifiedPushRegistrar.registerSelection(option)
}
}
}
override suspend fun registerCurrentToken() {
when (selection) {
FIREBASE_OPTION -> firebasePushTokenRegistrar.registerCurrentToken()
NONE -> {
// do nothing
}
else -> unifiedPushRegistrar.registerCurrentToken()
}
}
override fun unregister() {
when (selection) {
FIREBASE_OPTION -> firebasePushTokenRegistrar.unregister()
NONE -> {
runCatching {
firebasePushTokenRegistrar.unregister()
unifiedPushRegistrar.unregister()
}
}
else -> unifiedPushRegistrar.unregister()
}
}
}
@JvmInline
value class Registrar(val id: String)

View File

@ -1,31 +0,0 @@
package app.dapk.st.push
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.extensions.CrashScope
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.log
import app.dapk.st.matrix.push.PushService
import com.google.firebase.messaging.FirebaseMessaging
class RegisterFirebasePushTokenUseCase(
private val pushService: PushService,
override val errorTracker: ErrorTracker,
) : CrashScope {
fun unregister() {
FirebaseMessaging.getInstance().deleteToken()
}
suspend fun registerCurrentToken() {
kotlin.runCatching {
FirebaseMessaging.getInstance().token().also {
pushService.registerPush(it)
}
}
.trackFailure()
.onSuccess {
log(AppLogTag.PUSH, "registered new push token")
}
}
}

View File

@ -1,4 +1,4 @@
package app.dapk.st.push package app.dapk.st.push.firebase
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import kotlin.coroutines.resume import kotlin.coroutines.resume

View File

@ -0,0 +1,36 @@
package app.dapk.st.push.firebase
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.core.log
import app.dapk.st.core.module
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.push.PushModule
import app.dapk.st.push.PushTokenPayload
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify"
class FirebasePushService : FirebaseMessagingService() {
private val handler by unsafeLazy { module<PushModule>().pushHandler() }
override fun onNewToken(token: String) {
log(AppLogTag.PUSH, "FCM onNewToken")
handler.onNewToken(
PushTokenPayload(
token = token,
gatewayUrl = SYGNAL_GATEWAY,
)
)
}
override fun onMessageReceived(message: RemoteMessage) {
log(AppLogTag.PUSH, "FCM onMessage")
val eventId = message.data["event_id"]?.let { EventId(it) }
val roomId = message.data["room_id"]?.let { RoomId(it) }
handler.onMessageReceived(eventId, roomId)
}
}

View File

@ -0,0 +1,61 @@
package app.dapk.st.push.firebase
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.extensions.CrashScope
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.log
import app.dapk.st.push.PushHandler
import app.dapk.st.push.PushTokenPayload
import app.dapk.st.push.PushTokenRegistrar
import app.dapk.st.push.unifiedpush.UnifiedPushMessageReceiver
import com.google.firebase.messaging.FirebaseMessaging
private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify"
class FirebasePushTokenRegistrar(
override val errorTracker: ErrorTracker,
private val context: Context,
private val pushHandler: PushHandler,
) : PushTokenRegistrar, CrashScope {
override suspend fun registerCurrentToken() {
log(AppLogTag.PUSH, "FCM - register current token")
context.packageManager.setComponentEnabledSetting(
ComponentName(context, FirebasePushService::class.java),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP,
)
kotlin.runCatching {
FirebaseMessaging.getInstance().token().also {
pushHandler.onNewToken(
PushTokenPayload(
token = it,
gatewayUrl = SYGNAL_GATEWAY,
)
)
}
}
.trackFailure()
.onSuccess {
log(AppLogTag.PUSH, "registered new push token")
}
}
override fun unregister() {
log(AppLogTag.PUSH, "FCM - unregister")
FirebaseMessaging.getInstance().deleteToken()
context.stopService(Intent(context, FirebasePushService::class.java))
context.packageManager.setComponentEnabledSetting(
ComponentName(context, FirebasePushService::class.java),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP,
)
}
}

View File

@ -0,0 +1,77 @@
package app.dapk.st.push.unifiedpush
import android.content.Context
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.log
import app.dapk.st.core.module
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.push.PushModule
import app.dapk.st.push.PushTokenPayload
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.unifiedpush.android.connector.MessagingReceiver
import java.net.URL
private val json = Json { ignoreUnknownKeys = true }
private const val FALLBACK_UNIFIED_PUSH_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
class UnifiedPushMessageReceiver : MessagingReceiver() {
private val scope = CoroutineScope(SupervisorJob())
override fun onMessage(context: Context, message: ByteArray, instance: String) {
log(AppLogTag.PUSH, "UnifiedPush onMessage, $message")
val module = context.module<PushModule>()
val handler = module.pushHandler()
scope.launch {
withContext(module.dispatcher().io) {
val payload = json.decodeFromString(UnifiedPushMessagePayload.serializer(), String(message))
handler.onMessageReceived(payload.notification.eventId?.let { EventId(it) }, payload.notification.roomId?.let { RoomId(it) })
}
}
}
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
log(AppLogTag.PUSH, "UnifiedPush onNewEndpoint $endpoint")
val module = context.module<PushModule>()
val handler = module.pushHandler()
scope.launch {
withContext(module.dispatcher().io) {
val matrixEndpoint = URL(endpoint).let { URL("${it.protocol}://${it.host}/_matrix/push/v1/notify") }
val content = runCatching { matrixEndpoint.openStream().use { String(it.readBytes()) } }.getOrNull() ?: ""
val gatewayUrl = when {
content.contains("\"gateway\":\"matrix\"") -> matrixEndpoint.toString()
else -> FALLBACK_UNIFIED_PUSH_GATEWAY
}
handler.onNewToken(PushTokenPayload(token = endpoint, gatewayUrl = gatewayUrl))
}
}
}
override fun onRegistrationFailed(context: Context, instance: String) {
log(AppLogTag.PUSH, "UnifiedPush onRegistrationFailed")
}
override fun onUnregistered(context: Context, instance: String) {
log(AppLogTag.PUSH, "UnifiedPush onUnregistered")
}
@Serializable
private data class UnifiedPushMessagePayload(
@SerialName("notification") val notification: Notification,
) {
@Serializable
data class Notification(
@SerialName("event_id") val eventId: String?,
@SerialName("room_id") val roomId: String?,
)
}
}

View File

@ -0,0 +1,47 @@
package app.dapk.st.push.unifiedpush
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.log
import app.dapk.st.push.PushTokenRegistrar
import app.dapk.st.push.Registrar
import org.unifiedpush.android.connector.UnifiedPush
class UnifiedPushRegistrar(
private val context: Context,
) : PushTokenRegistrar {
fun registerSelection(registrar: Registrar) {
log(AppLogTag.PUSH, "UnifiedPush - register: $registrar")
UnifiedPush.saveDistributor(context, registrar.id)
registerApp()
}
override suspend fun registerCurrentToken() {
log(AppLogTag.PUSH, "UnifiedPush - register current token")
if (UnifiedPush.getDistributor(context).isNotEmpty()) {
registerApp()
}
}
private fun registerApp() {
context.packageManager.setComponentEnabledSetting(
ComponentName(context, UnifiedPushMessageReceiver::class.java),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP,
)
UnifiedPush.registerApp(context)
}
override fun unregister() {
UnifiedPush.unregisterApp(context)
context.packageManager.setComponentEnabledSetting(
ComponentName(context, UnifiedPushMessageReceiver::class.java),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP,
)
}
}

View File

@ -7,6 +7,7 @@ import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.domain.eventlog.EventLogPersistence import app.dapk.st.domain.eventlog.EventLogPersistence
import app.dapk.st.domain.localecho.LocalEchoPersistence import app.dapk.st.domain.localecho.LocalEchoPersistence
import app.dapk.st.domain.profile.ProfilePersistence import app.dapk.st.domain.profile.ProfilePersistence
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
import app.dapk.st.domain.sync.OverviewPersistence import app.dapk.st.domain.sync.OverviewPersistence
import app.dapk.st.domain.sync.RoomPersistence import app.dapk.st.domain.sync.RoomPersistence
import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.CredentialsStore
@ -34,6 +35,8 @@ class StoreModule(
fun filterStore(): FilterStore = FilterPreferences(preferences) fun filterStore(): FilterStore = FilterPreferences(preferences)
val localEchoStore: LocalEchoStore by unsafeLazy { LocalEchoPersistence(errorTracker, database) } val localEchoStore: LocalEchoStore by unsafeLazy { LocalEchoPersistence(errorTracker, database) }
fun pushStore() = PushTokenRegistrarPreferences(preferences)
fun applicationStore() = ApplicationPreferences(preferences) fun applicationStore() = ApplicationPreferences(preferences)
fun olmStore() = OlmPersistence(database, credentialsStore()) fun olmStore() = OlmPersistence(database, credentialsStore())

View File

@ -0,0 +1,16 @@
package app.dapk.st.domain.push
import app.dapk.st.domain.Preferences
private const val SELECTION_KEY = "push_token_selection"
class PushTokenRegistrarPreferences(
private val preferences: Preferences,
) {
suspend fun currentSelection() = preferences.readString(SELECTION_KEY)
suspend fun store(registrar: String) {
preferences.store(SELECTION_KEY, registrar)
}
}

View File

@ -3,7 +3,6 @@ package app.dapk.st.login
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.matrix.auth.AuthService import app.dapk.st.matrix.auth.AuthService
import app.dapk.st.matrix.crypto.CryptoService
import app.dapk.st.matrix.room.ProfileService import app.dapk.st.matrix.room.ProfileService
import app.dapk.st.push.PushModule import app.dapk.st.push.PushModule
@ -15,6 +14,6 @@ class LoginModule(
) : ProvidableModule { ) : ProvidableModule {
fun loginViewModel(): LoginViewModel { fun loginViewModel(): LoginViewModel {
return LoginViewModel(authService, pushModule.registerFirebasePushTokenUseCase(), profileService, errorTracker) return LoginViewModel(authService, pushModule.pushTokenRegistrar(), profileService, errorTracker)
} }
} }

View File

@ -7,7 +7,7 @@ import app.dapk.st.login.LoginEvent.LoginComplete
import app.dapk.st.login.LoginScreenState.* import app.dapk.st.login.LoginScreenState.*
import app.dapk.st.matrix.auth.AuthService import app.dapk.st.matrix.auth.AuthService
import app.dapk.st.matrix.room.ProfileService import app.dapk.st.matrix.room.ProfileService
import app.dapk.st.push.RegisterFirebasePushTokenUseCase import app.dapk.st.push.PushTokenRegistrar
import app.dapk.st.viewmodel.DapkViewModel import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
class LoginViewModel( class LoginViewModel(
private val authService: AuthService, private val authService: AuthService,
private val firebasePushTokenUseCase: RegisterFirebasePushTokenUseCase, private val pushTokenRegistrar: PushTokenRegistrar,
private val profileService: ProfileService, private val profileService: ProfileService,
private val errorTracker: ErrorTracker, private val errorTracker: ErrorTracker,
) : DapkViewModel<LoginScreenState, LoginEvent>( ) : DapkViewModel<LoginScreenState, LoginEvent>(
@ -32,7 +32,7 @@ class LoginViewModel(
is AuthService.LoginResult.Success -> { is AuthService.LoginResult.Success -> {
runCatching { runCatching {
listOf( listOf(
async { firebasePushTokenUseCase.registerCurrentToken() }, async { pushTokenRegistrar.registerCurrentToken() },
async { preloadMe() }, async { preloadMe() },
).awaitAll() ).awaitAll()
} }

View File

@ -14,6 +14,7 @@ dependencies {
implementation platform('com.google.firebase:firebase-bom:29.0.3') implementation platform('com.google.firebase:firebase-bom:29.0.3')
implementation 'com.google.firebase:firebase-messaging' implementation 'com.google.firebase:firebase-messaging'
implementation Dependencies.mavenCentral.kotlinSerializationJson
kotlinTest(it) kotlinTest(it)

View File

@ -1,14 +1,2 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.dapk.st.notifications"> <manifest package="app.dapk.st.notifications"/>
<application>
<service
android:name=".PushAndroidService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +1,85 @@
package app.dapk.st.notifications
import app.dapk.st.core.AppLogTag
import app.dapk.st.core.log
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.RoomStore
import app.dapk.st.matrix.sync.SyncService
import app.dapk.st.push.PushHandler
import app.dapk.st.push.PushTokenPayload
import app.dapk.st.work.WorkScheduler
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.serialization.json.Json
private var previousJob: Job? = null
@OptIn(DelicateCoroutinesApi::class)
class MatrixPushHandler(
private val workScheduler: WorkScheduler,
private val credentialsStore: CredentialsStore,
private val syncService: SyncService,
private val roomStore: RoomStore,
) : PushHandler {
override fun onNewToken(payload: PushTokenPayload) {
log(AppLogTag.PUSH, "new push token received")
workScheduler.schedule(
WorkScheduler.WorkTask(
type = "push_token",
jobId = 2,
jsonPayload = Json.encodeToString(PushTokenPayload.serializer(), payload)
)
)
}
override fun onMessageReceived(eventId: EventId?, roomId: RoomId?) {
log(AppLogTag.PUSH, "push received")
previousJob?.cancel()
previousJob = GlobalScope.launch {
when (credentialsStore.credentials()) {
null -> log(AppLogTag.PUSH, "push ignored due to missing api credentials")
else -> doSync(roomId, eventId)
}
}
}
private suspend fun doSync(roomId: RoomId?, eventId: EventId?) {
when (roomId) {
null -> {
log(AppLogTag.PUSH, "empty push payload - keeping sync alive until unread changes")
waitForUnreadChange(60_000) ?: log(AppLogTag.PUSH, "timed out waiting for sync")
}
else -> {
log(AppLogTag.PUSH, "push with eventId payload - keeping sync alive until the event shows up in the sync response")
waitForEvent(
timeout = 60_000,
eventId!!,
) ?: log(AppLogTag.PUSH, "timed out waiting for sync")
}
}
log(AppLogTag.PUSH, "push sync finished")
}
private suspend fun waitForEvent(timeout: Long, eventId: EventId): EventId? {
return withTimeoutOrNull(timeout) {
combine(syncService.startSyncing().startInstantly(), syncService.observeEvent(eventId)) { _, event -> event }
.firstOrNull {
it == eventId
}
}
}
private suspend fun waitForUnreadChange(timeout: Long): String? {
return withTimeoutOrNull(timeout) {
combine(syncService.startSyncing().startInstantly(), roomStore.observeUnread()) { _, unread -> unread }
.first()
"ignored"
}
}
private fun Flow<Unit>.startInstantly() = this.onStart { emit(Unit) }
}

View File

@ -6,33 +6,18 @@ import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.DeviceMeta import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.imageloader.IconLoader import app.dapk.st.imageloader.IconLoader
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.push.PushService
import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.matrix.sync.RoomStore
import app.dapk.st.matrix.sync.SyncService
import app.dapk.st.navigator.IntentFactory import app.dapk.st.navigator.IntentFactory
import app.dapk.st.push.RegisterFirebasePushTokenUseCase
import app.dapk.st.work.WorkScheduler
class NotificationsModule( class NotificationsModule(
private val pushService: PushService,
private val syncService: SyncService,
private val credentialsStore: CredentialsStore,
private val firebasePushTokenUseCase: RegisterFirebasePushTokenUseCase,
private val iconLoader: IconLoader, private val iconLoader: IconLoader,
private val roomStore: RoomStore, private val roomStore: RoomStore,
private val context: Context, private val context: Context,
private val workScheduler: WorkScheduler,
private val intentFactory: IntentFactory, private val intentFactory: IntentFactory,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val deviceMeta: DeviceMeta, private val deviceMeta: DeviceMeta,
) : ProvidableModule { ) : ProvidableModule {
fun pushUseCase() = pushService
fun syncService() = syncService
fun credentialProvider() = credentialsStore
fun firebasePushTokenUseCase() = firebasePushTokenUseCase
fun roomStore() = roomStore
fun notificationsUseCase() = RenderNotificationsUseCase( fun notificationsUseCase() = RenderNotificationsUseCase(
notificationRenderer = NotificationRenderer( notificationRenderer = NotificationRenderer(
notificationManager(), notificationManager(),
@ -55,5 +40,4 @@ class NotificationsModule(
private fun notificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private fun notificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
fun workScheduler() = workScheduler
} }

View File

@ -1,90 +0,0 @@
package app.dapk.st.notifications
import android.content.Context
import app.dapk.st.core.AppLogTag.PUSH
import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.core.log
import app.dapk.st.core.module
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.work.WorkScheduler
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
private var previousJob: Job? = null
@OptIn(DelicateCoroutinesApi::class)
class PushAndroidService : FirebaseMessagingService() {
private val module by unsafeLazy { module<NotificationsModule>() }
private lateinit var context: Context
override fun onCreate() {
super.onCreate()
context = applicationContext
}
override fun onNewToken(token: String) {
log(PUSH, "new push token received")
module.workScheduler().schedule(
WorkScheduler.WorkTask(
type = "push_token",
jobId = 2,
jsonPayload = token
)
)
}
override fun onMessageReceived(message: RemoteMessage) {
val eventId = message.data["event_id"]?.let { EventId(it) }
val roomId = message.data["room_id"]?.let { RoomId(it) }
log(PUSH, "push received")
previousJob?.cancel()
previousJob = GlobalScope.launch {
when (module.credentialProvider().credentials()) {
null -> log(PUSH, "push ignored due to missing api credentials")
else -> doSync(roomId, eventId)
}
}
}
private suspend fun doSync(roomId: RoomId?, eventId: EventId?) {
when (roomId) {
null -> {
log(PUSH, "empty push payload - keeping sync alive until unread changes")
waitForUnreadChange(60_000) ?: log(PUSH, "timed out waiting for sync")
}
else -> {
log(PUSH, "push with eventId payload - keeping sync alive until the event shows up in the sync response")
waitForEvent(
timeout = 60_000,
eventId!!,
) ?: log(PUSH, "timed out waiting for sync")
}
}
log(PUSH, "push sync finished")
}
private suspend fun waitForEvent(timeout: Long, eventId: EventId): EventId? {
return withTimeoutOrNull(timeout) {
combine(module.syncService().startSyncing().startInstantly(), module.syncService().observeEvent(eventId)) { _, event -> event }
.firstOrNull {
it == eventId
}
}
}
private suspend fun waitForUnreadChange(timeout: Long): String? {
return withTimeoutOrNull(timeout) {
combine(module.syncService().startSyncing().startInstantly(), module.roomStore().observeUnread()) { _, unread -> unread }
.first()
"ignored"
}
}
}
private fun Flow<Unit>.startInstantly() = this.onStart { emit(Unit) }

View File

@ -5,6 +5,7 @@ dependencies {
implementation project(":matrix:services:crypto") implementation project(":matrix:services:crypto")
implementation project(":features:navigator") implementation project(":features:navigator")
implementation project(':domains:store') implementation project(':domains:store')
implementation project(':domains:android:push')
implementation project(":domains:android:compose-core") implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel") implementation project(":domains:android:viewmodel")
implementation project(":design-library") implementation project(":design-library")

View File

@ -1,13 +1,18 @@
package app.dapk.st.settings package app.dapk.st.settings
import app.dapk.st.core.BuildMeta import app.dapk.st.core.BuildMeta
import app.dapk.st.push.PushTokenRegistrars
internal class SettingsItemFactory(private val buildMeta: BuildMeta) { internal class SettingsItemFactory(
private val buildMeta: BuildMeta,
private val pushTokenRegistrars: PushTokenRegistrars,
) {
fun root() = listOf( suspend fun root() = listOf(
SettingItem.Header("General"), SettingItem.Header("General"),
SettingItem.Text(SettingItem.Id.Encryption, "Encryption"), SettingItem.Text(SettingItem.Id.Encryption, "Encryption"),
SettingItem.Text(SettingItem.Id.EventLog, "Event log"), SettingItem.Text(SettingItem.Id.EventLog, "Event log"),
SettingItem.Text(SettingItem.Id.PushProvider, "Push provider", pushTokenRegistrars.currentSelection().id),
SettingItem.Header("Data"), SettingItem.Header("Data"),
SettingItem.Text(SettingItem.Id.ClearCache, "Clear cache"), SettingItem.Text(SettingItem.Id.ClearCache, "Clear cache"),
SettingItem.Header("Account"), SettingItem.Header("Account"),

View File

@ -7,10 +7,12 @@ import app.dapk.st.core.ProvidableModule
import app.dapk.st.domain.StoreModule import app.dapk.st.domain.StoreModule
import app.dapk.st.matrix.crypto.CryptoService import app.dapk.st.matrix.crypto.CryptoService
import app.dapk.st.matrix.sync.SyncService import app.dapk.st.matrix.sync.SyncService
import app.dapk.st.push.PushModule
import app.dapk.st.settings.eventlogger.EventLoggerViewModel import app.dapk.st.settings.eventlogger.EventLoggerViewModel
class SettingsModule( class SettingsModule(
private val storeModule: StoreModule, private val storeModule: StoreModule,
private val pushModule: PushModule,
private val cryptoService: CryptoService, private val cryptoService: CryptoService,
private val syncService: SyncService, private val syncService: SyncService,
private val contentResolver: ContentResolver, private val contentResolver: ContentResolver,
@ -24,7 +26,8 @@ class SettingsModule(
cryptoService, cryptoService,
syncService, syncService,
UriFilenameResolver(contentResolver, coroutineDispatchers), UriFilenameResolver(contentResolver, coroutineDispatchers),
SettingsItemFactory(buildMeta), SettingsItemFactory(buildMeta, pushModule.pushTokenRegistrars()),
pushModule.pushTokenRegistrars(),
) )
internal fun eventLogViewModel(): EventLoggerViewModel { internal fun eventLogViewModel(): EventLoggerViewModel {

View File

@ -68,6 +68,9 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit,
item(Page.Routes.encryption) { item(Page.Routes.encryption) {
Encryption(viewModel, it) Encryption(viewModel, it)
} }
item(Page.Routes.pushProviders) {
PushProviders(viewModel, it)
}
item(Page.Routes.importRoomKeys) { item(Page.Routes.importRoomKeys) {
when (it.importProgress) { when (it.importProgress) {
null -> { null -> {
@ -132,6 +135,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit,
} }
} }
} }
is Lce.Content -> { is Lce.Content -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -142,6 +146,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit,
} }
} }
} }
is Lce.Error -> { is Lce.Error -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -152,6 +157,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit,
} }
} }
} }
is Lce.Loading -> CenteredLoading() is Lce.Loading -> CenteredLoading()
} }
} }
@ -176,6 +182,7 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) {
SettingsTextRow(item.content, item.subtitle, itemOnClick) SettingsTextRow(item.content, item.subtitle, itemOnClick)
} }
is SettingItem.AccessToken -> { is SettingItem.AccessToken -> {
Row( Row(
Modifier Modifier
@ -193,6 +200,7 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) {
} }
} }
} }
is SettingItem.Header -> Header(item.label) is SettingItem.Header -> Header(item.label)
} }
} }
@ -203,6 +211,7 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) {
is Lce.Error -> { is Lce.Error -> {
// TODO // TODO
} }
is Lce.Loading -> { is Lce.Loading -> {
// TODO // TODO
} }
@ -216,6 +225,32 @@ private fun Encryption(viewModel: SettingsViewModel, page: Page.Security) {
} }
} }
@Composable
private fun PushProviders(viewModel: SettingsViewModel, state: Page.PushProviders) {
LaunchedEffect(true) {
viewModel.fetchPushProviders()
}
when (val lce = state.options) {
null -> {}
is Lce.Loading -> CenteredLoading()
is Lce.Content -> {
LazyColumn {
items(lce.value) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = it == state.selection, onClick = { viewModel.selectPushProvider(it) })
Text(it.id)
}
}
}
}
is Lce.Error -> TODO()
}
}
@Composable @Composable
private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) { private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
@ -228,10 +263,12 @@ private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) {
clipboard.setPrimaryClip(ClipData.newPlainText("dapk token", it.content)) clipboard.setPrimaryClip(ClipData.newPlainText("dapk token", it.content))
Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show()
} }
is SettingsEvent.Toast -> Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() is SettingsEvent.Toast -> Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show()
OpenEventLog -> { OpenEventLog -> {
context.startActivity(Intent(context, EventLogActivity::class.java)) context.startActivity(Intent(context, EventLogActivity::class.java))
} }
is OpenUrl -> { is OpenUrl -> {
context.startActivity(Intent(Intent.ACTION_VIEW).apply { data = it.url.toUri() }) context.startActivity(Intent(Intent.ACTION_VIEW).apply { data = it.url.toUri() })
} }

View File

@ -4,6 +4,7 @@ import android.net.Uri
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.design.components.Route import app.dapk.st.design.components.Route
import app.dapk.st.design.components.SpiderPage import app.dapk.st.design.components.SpiderPage
import app.dapk.st.push.Registrar
internal data class SettingsScreenState( internal data class SettingsScreenState(
val page: SpiderPage<out Page>, val page: SpiderPage<out Page>,
@ -17,9 +18,15 @@ internal sealed interface Page {
val importProgress: Lce<Unit>? = null, val importProgress: Lce<Unit>? = null,
) : Page ) : Page
data class PushProviders(
val selection: Registrar? = null,
val options: Lce<List<Registrar>>? = Lce.Loading()
) : Page
object Routes { object Routes {
val root = Route<Root>("Settings") val root = Route<Root>("Settings")
val encryption = Route<Page.Security>("Encryption") val encryption = Route<Page.Security>("Encryption")
val pushProviders = Route<Page.PushProviders>("PushProviders")
val importRoomKeys = Route<Page.ImportRoomKey>("ImportRoomKey") val importRoomKeys = Route<Page.ImportRoomKey>("ImportRoomKey")
} }
} }
@ -42,6 +49,7 @@ internal sealed interface SettingItem {
AccessToken, AccessToken,
ClearCache, ClearCache,
EventLog, EventLog,
PushProvider,
Encryption, Encryption,
PrivacyPolicy, PrivacyPolicy,
Ignored, Ignored,

View File

@ -8,6 +8,8 @@ import app.dapk.st.design.components.SpiderPage
import app.dapk.st.domain.StoreCleaner import app.dapk.st.domain.StoreCleaner
import app.dapk.st.matrix.crypto.CryptoService import app.dapk.st.matrix.crypto.CryptoService
import app.dapk.st.matrix.sync.SyncService import app.dapk.st.matrix.sync.SyncService
import app.dapk.st.push.PushTokenRegistrars
import app.dapk.st.push.Registrar
import app.dapk.st.settings.SettingItem.Id.* import app.dapk.st.settings.SettingItem.Id.*
import app.dapk.st.settings.SettingsEvent.* import app.dapk.st.settings.SettingsEvent.*
import app.dapk.st.viewmodel.DapkViewModel import app.dapk.st.viewmodel.DapkViewModel
@ -24,6 +26,7 @@ internal class SettingsViewModel(
private val syncService: SyncService, private val syncService: SyncService,
private val uriFilenameResolver: UriFilenameResolver, private val uriFilenameResolver: UriFilenameResolver,
private val settingsItemFactory: SettingsItemFactory, private val settingsItemFactory: SettingsItemFactory,
private val pushTokenRegistrars: PushTokenRegistrars,
factory: MutableStateFactory<SettingsScreenState> = defaultStateFactory(), factory: MutableStateFactory<SettingsScreenState> = defaultStateFactory(),
) : DapkViewModel<SettingsScreenState, SettingsEvent>( ) : DapkViewModel<SettingsScreenState, SettingsEvent>(
initialState = SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading()))), initialState = SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading()))),
@ -50,39 +53,72 @@ internal class SettingsViewModel(
_events.emit(SignedOut) _events.emit(SignedOut)
} }
} }
AccessToken -> { AccessToken -> {
viewModelScope.launch { viewModelScope.launch {
require(item is SettingItem.AccessToken) require(item is SettingItem.AccessToken)
_events.emit(CopyToClipboard("Token copied", item.accessToken)) _events.emit(CopyToClipboard("Token copied", item.accessToken))
} }
} }
ClearCache -> { ClearCache -> {
viewModelScope.launch { viewModelScope.launch {
cacheCleaner.cleanCache(removeCredentials = false) cacheCleaner.cleanCache(removeCredentials = false)
_events.emit(Toast(message = "Cache deleted")) _events.emit(Toast(message = "Cache deleted"))
} }
} }
EventLog -> { EventLog -> {
viewModelScope.launch { viewModelScope.launch {
_events.emit(OpenEventLog) _events.emit(OpenEventLog)
} }
} }
Encryption -> { Encryption -> {
updateState { updateState {
copy(page = SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security)) copy(page = SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security))
} }
} }
PrivacyPolicy -> { PrivacyPolicy -> {
viewModelScope.launch { viewModelScope.launch {
_events.emit(OpenUrl(PRIVACY_POLICY_URL)) _events.emit(OpenUrl(PRIVACY_POLICY_URL))
} }
} }
PushProvider -> {
updateState {
copy(page = SpiderPage(Page.Routes.pushProviders, "Push providers", Page.Routes.root, Page.PushProviders()))
}
}
Ignored -> { Ignored -> {
// do nothing // do nothing
} }
} }
} }
fun fetchPushProviders() {
updatePageState<Page.PushProviders> { copy(options = Lce.Loading()) }
viewModelScope.launch {
val currentSelection = pushTokenRegistrars.currentSelection()
val options = pushTokenRegistrars.options()
updatePageState<Page.PushProviders> {
copy(
selection = currentSelection,
options = Lce.Content(options)
)
}
}
}
fun selectPushProvider(registrar: Registrar) {
viewModelScope.launch {
pushTokenRegistrars.makeSelection(registrar)
fetchPushProviders()
}
}
fun importFromFileKeys(file: Uri, passphrase: String) { fun importFromFileKeys(file: Uri, passphrase: String) {
updatePageState<Page.ImportRoomKey> { copy(importProgress = Lce.Loading()) } updatePageState<Page.ImportRoomKey> { copy(importProgress = Lce.Loading()) }
viewModelScope.launch { viewModelScope.launch {

View File

@ -1,25 +1,38 @@
package app.dapk.st.settings package app.dapk.st.settings
import app.dapk.st.core.BuildMeta import app.dapk.st.core.BuildMeta
import app.dapk.st.push.PushTokenRegistrars
import app.dapk.st.push.Registrar
import internalfixture.aSettingHeaderItem import internalfixture.aSettingHeaderItem
import internalfixture.aSettingTextItem import internalfixture.aSettingTextItem
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import test.delegateReturn
private val A_SELECTION = Registrar("A_SELECTION")
class SettingsItemFactoryTest { class SettingsItemFactoryTest {
private val buildMeta = BuildMeta(versionName = "a-version-name", versionCode = 100) private val buildMeta = BuildMeta(versionName = "a-version-name", versionCode = 100)
private val fakePushTokenRegistrars = FakePushRegistrars()
private val settingsItemFactory = SettingsItemFactory(buildMeta) private val settingsItemFactory = SettingsItemFactory(buildMeta, fakePushTokenRegistrars.instance)
@Test @Test
fun `when creating root items, then is expected`() { fun `when creating root items, then is expected`() = runTest {
fakePushTokenRegistrars.givenCurrentSelection().returns(A_SELECTION)
val result = settingsItemFactory.root() val result = settingsItemFactory.root()
result shouldBeEqualTo listOf( result shouldBeEqualTo listOf(
aSettingHeaderItem("General"), aSettingHeaderItem("General"),
aSettingTextItem(SettingItem.Id.Encryption, "Encryption"), aSettingTextItem(SettingItem.Id.Encryption, "Encryption"),
aSettingTextItem(SettingItem.Id.EventLog, "Event log"), aSettingTextItem(SettingItem.Id.EventLog, "Event log"),
aSettingTextItem(SettingItem.Id.PushProvider, "Push provider", A_SELECTION.id),
aSettingHeaderItem("Data"), aSettingHeaderItem("Data"),
aSettingTextItem(SettingItem.Id.ClearCache, "Clear cache"), aSettingTextItem(SettingItem.Id.ClearCache, "Clear cache"),
aSettingHeaderItem("Account"), aSettingHeaderItem("Account"),
@ -30,3 +43,11 @@ class SettingsItemFactoryTest {
) )
} }
} }
class FakePushRegistrars {
val instance = mockk<PushTokenRegistrars>()
fun givenCurrentSelection() = coEvery { instance.currentSelection() }.delegateReturn()
}

View File

@ -34,6 +34,7 @@ internal class SettingsViewModelTest {
private val fakeCryptoService = FakeCryptoService() private val fakeCryptoService = FakeCryptoService()
private val fakeSyncService = FakeSyncService() private val fakeSyncService = FakeSyncService()
private val fakeUriFilenameResolver = FakeUriFilenameResolver() private val fakeUriFilenameResolver = FakeUriFilenameResolver()
private val fakePushTokenRegistrars = FakePushRegistrars()
private val fakeSettingsItemFactory = FakeSettingsItemFactory() private val fakeSettingsItemFactory = FakeSettingsItemFactory()
private val viewModel = SettingsViewModel( private val viewModel = SettingsViewModel(
@ -43,6 +44,7 @@ internal class SettingsViewModelTest {
fakeSyncService, fakeSyncService,
fakeUriFilenameResolver.instance, fakeUriFilenameResolver.instance,
fakeSettingsItemFactory.instance, fakeSettingsItemFactory.instance,
fakePushTokenRegistrars.instance,
runViewModelTest.testMutableStateFactory(), runViewModelTest.testMutableStateFactory(),
) )

View File

@ -1,6 +1,7 @@
package internalfake package internalfake
import app.dapk.st.settings.SettingsItemFactory import app.dapk.st.settings.SettingsItemFactory
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import test.delegateReturn import test.delegateReturn
@ -8,5 +9,5 @@ import test.delegateReturn
internal class FakeSettingsItemFactory { internal class FakeSettingsItemFactory {
val instance = mockk<SettingsItemFactory>() val instance = mockk<SettingsItemFactory>()
fun givenRoot() = every { instance.root() }.delegateReturn() fun givenRoot() = coEvery { instance.root() }.delegateReturn()
} }

View File

@ -12,7 +12,7 @@ private val SERVICE_KEY = PushService::class
interface PushService : MatrixService { interface PushService : MatrixService {
suspend fun registerPush(token: String) suspend fun registerPush(token: String, gatewayUrl: String)
@Serializable @Serializable
data class PushRequest( data class PushRequest(

View File

@ -13,8 +13,8 @@ class DefaultPushService(
private val useCase = RegisterPushUseCase(httpClient, credentialsStore, logger) private val useCase = RegisterPushUseCase(httpClient, credentialsStore, logger)
override suspend fun registerPush(token: String) { override suspend fun registerPush(token: String, gatewayUrl: String) {
useCase.registerPushToken(token) useCase.registerPushToken(token, gatewayUrl)
} }
} }

View File

@ -13,7 +13,7 @@ internal class RegisterPushUseCase(
private val logger: MatrixLogger, private val logger: MatrixLogger,
) { ) {
suspend fun registerPushToken(token: String) { suspend fun registerPushToken(token: String, gatewayUrl: String) {
if (credentialsStore.isSignedIn()) { if (credentialsStore.isSignedIn()) {
logger.matrixLog("register push token: $token") logger.matrixLog("register push token: $token")
matrixClient.execute( matrixClient.execute(
@ -29,7 +29,7 @@ internal class RegisterPushUseCase(
append = false, append = false,
data = PushRequest.Payload( data = PushRequest.Payload(
format = "event_id_only", format = "event_id_only",
url = "https://sygnal.dapk.app/_matrix/push/v1/notify", url = gatewayUrl,
), ),
) )
) )