diff --git a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt index 075037b..0590253 100644 --- a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt +++ b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt @@ -16,8 +16,9 @@ import app.dapk.st.home.HomeModule import app.dapk.st.login.LoginModule import app.dapk.st.messenger.MessengerModule import app.dapk.st.notifications.NotificationsModule -import app.dapk.st.notifications.PushAndroidService 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.share.ShareEntryModule import app.dapk.st.work.TaskRunnerModule @@ -54,7 +55,7 @@ class SmallTalkApplication : Application(), ModuleProvider { private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) { applicationScope.launch { - notificationsModule.firebasePushTokenUseCase().registerCurrentToken() + featureModules.pushModule.pushTokenRegistrar().registerCurrentToken() storeModule.localEchoStore.preload() } @@ -73,6 +74,7 @@ class SmallTalkApplication : Application(), ModuleProvider { SettingsModule::class -> featureModules.settingsModule ProfileModule::class -> featureModules.profileModule NotificationsModule::class -> featureModules.notificationsModule + PushModule::class -> featureModules.pushModule MessengerModule::class -> featureModules.messengerModule TaskRunnerModule::class -> appModule.domainModules.taskRunnerModule CoreAndroidModule::class -> appModule.coreAndroidModule @@ -82,10 +84,9 @@ class SmallTalkApplication : Application(), ModuleProvider { } override fun reset() { - featureModules.notificationsModule.firebasePushTokenUseCase().unregister() + featureModules.pushModule.pushTokenRegistrar().unregister() appModule.coroutineDispatchers.io.cancel() applicationScope.cancel() - stopService(Intent(this, PushAndroidService::class.java)) lazyAppModule.reset() lazyFeatureModules.reset() diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 5ae5432..24e0232 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -48,6 +48,7 @@ import app.dapk.st.messenger.MessengerActivity import app.dapk.st.messenger.MessengerModule 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 @@ -91,7 +92,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) { private val imageLoaderModule = ImageLoaderModule(context) 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 { override fun notificationOpenApp(context: Context) = PendingIntent.getActivity( @@ -125,7 +126,6 @@ internal class AppModule(context: Application, logger: MatrixLogger) { matrixModules, domainModules, trackingModule, - workModule, coreAndroidModule, imageLoaderModule, context, @@ -140,7 +140,6 @@ internal class FeatureModules internal constructor( private val matrixModules: MatrixModules, private val domainModules: DomainModules, private val trackingModule: TrackingModule, - private val workModule: WorkModule, private val coreAndroidModule: CoreAndroidModule, imageLoaderModule: ImageLoaderModule, context: Context, @@ -181,6 +180,7 @@ internal class FeatureModules internal constructor( val settingsModule by unsafeLazy { SettingsModule( storeModule.value, + pushModule, matrixModules.crypto, matrixModules.sync, 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 notificationsModule by unsafeLazy { NotificationsModule( - matrixModules.push, - matrixModules.sync, - storeModule.value.credentialsStore(), - domainModules.pushModule.registerFirebasePushTokenUseCase(), imageLoaderModule.iconLoader(), storeModule.value.roomStore(), context, - workModule.workScheduler(), intentFactory = coreAndroidModule.intentFactory(), dispatchers = coroutineDispatchers, deviceMeta = DeviceMeta(Build.VERSION.SDK_INT) @@ -209,6 +204,10 @@ internal class FeatureModules internal constructor( ShareEntryModule(matrixModules.sync, matrixModules.room) } + val pushModule by unsafeLazy { + domainModules.pushModule + } + } internal class MatrixModules( @@ -423,9 +422,28 @@ internal class MatrixModules( internal class DomainModules( private val matrixModules: MatrixModules, private val errorTracker: ErrorTracker, + private val workModule: WorkModule, + private val storeModule: Lazy, + 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))) } } diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt b/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt index 84063b4..ca8bbf9 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppTaskRunner.kt @@ -1,8 +1,10 @@ package app.dapk.st.graph import app.dapk.st.matrix.push.PushService +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, @@ -12,7 +14,8 @@ class AppTaskRunner( return when (val type = workTask.task.type) { "push_token" -> { runCatching { - pushService.registerPush(workTask.task.jsonPayload) + val payload = Json.decodeFromString(PushTokenPayload.serializer(), workTask.task.jsonPayload) + pushService.registerPush(payload.token, payload.gatewayUrl) }.fold( onSuccess = { TaskRunner.TaskResult.Success(workTask.source) }, onFailure = { @@ -25,9 +28,10 @@ class AppTaskRunner( } ) } + else -> throw IllegalArgumentException("Unknown work type: $type") } } -} \ No newline at end of file +} diff --git a/dependencies.gradle b/dependencies.gradle index ef21143..15e8119 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -10,6 +10,13 @@ ext.Dependencies.with { } } + repositories.maven { + url 'https://jitpack.io' + content { + includeGroup "com.github.UnifiedPush" + } + } + repositories.mavenCentral { content { includeGroupByRegex "org\\.jetbrains.*" @@ -140,6 +147,12 @@ ext.Dependencies.with { 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 { diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt index fdfd8cd..c36ac65 100644 --- a/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/ContextExtensions.kt @@ -2,7 +2,6 @@ package app.dapk.st.core import android.content.Context -inline fun Context.module() = - (this.applicationContext as ModuleProvider).provide(T::class) +inline fun Context.module() = (this.applicationContext as ModuleProvider).provide(T::class) fun Context.resetModules() = (this.applicationContext as ModuleProvider).reset() \ No newline at end of file diff --git a/domains/android/push/build.gradle b/domains/android/push/build.gradle index 4f21b7a..dbcfe03 100644 --- a/domains/android/push/build.gradle +++ b/domains/android/push/build.gradle @@ -1,8 +1,13 @@ applyAndroidLibraryModule(project) +apply plugin: "org.jetbrains.kotlin.plugin.serialization" dependencies { implementation project(':core') + implementation project(':domains:android:core') + implementation project(':domains:store') implementation project(':matrix:services:push') implementation platform('com.google.firebase:firebase-bom:29.0.3') implementation 'com.google.firebase:firebase-messaging' + implementation Dependencies.mavenCentral.kotlinSerializationJson + implementation Dependencies.jitPack.unifiedPush } diff --git a/domains/android/push/src/main/AndroidManifest.xml b/domains/android/push/src/main/AndroidManifest.xml index a675a3a..61024b7 100644 --- a/domains/android/push/src/main/AndroidManifest.xml +++ b/domains/android/push/src/main/AndroidManifest.xml @@ -1,2 +1,24 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushHandler.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushHandler.kt new file mode 100644 index 0000000..5b7fa75 --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushHandler.kt @@ -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, +) \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt index 5978b95..5b923c7 100644 --- a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushModule.kt @@ -1,16 +1,40 @@ 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.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( - private val pushService: PushService, private val errorTracker: ErrorTracker, -) { + private val pushHandler: PushHandler, + private val context: Context, + private val dispatchers: CoroutineDispatchers, + private val preferences: Preferences, +) : ProvidableModule { - fun registerFirebasePushTokenUseCase() = RegisterFirebasePushTokenUseCase( - pushService, - errorTracker, - ) + private val registrars by unsafeLazy { + PushTokenRegistrars( + context, + FirebasePushTokenRegistrar( + errorTracker, + context, + pushHandler, + ), + UnifiedPushRegistrar(context), + PushTokenRegistrarPreferences(preferences) + ) + } -} \ No newline at end of file + fun pushTokenRegistrars() = registrars + + fun pushTokenRegistrar(): PushTokenRegistrar = pushTokenRegistrars() + fun pushHandler() = pushHandler + fun dispatcher() = dispatchers + +} diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrar.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrar.kt new file mode 100644 index 0000000..6cd6a1e --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrar.kt @@ -0,0 +1,6 @@ +package app.dapk.st.push + +interface PushTokenRegistrar { + suspend fun registerCurrentToken() + fun unregister() +} diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrars.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrars.kt new file mode 100644 index 0000000..7ce0658 --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/PushTokenRegistrars.kt @@ -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 { + 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) \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/RegisterFirebasePushTokenUseCase.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/RegisterFirebasePushTokenUseCase.kt deleted file mode 100644 index ded8029..0000000 --- a/domains/android/push/src/main/kotlin/app/dapk/st/push/RegisterFirebasePushTokenUseCase.kt +++ /dev/null @@ -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") - } - } - -} \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/FirebaseMessagingExtensions.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebaseMessagingExtensions.kt similarity index 86% rename from domains/android/push/src/main/kotlin/app/dapk/st/push/FirebaseMessagingExtensions.kt rename to domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebaseMessagingExtensions.kt index 78a8033..9ff0ac7 100644 --- a/domains/android/push/src/main/kotlin/app/dapk/st/push/FirebaseMessagingExtensions.kt +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebaseMessagingExtensions.kt @@ -1,4 +1,4 @@ -package app.dapk.st.push +package app.dapk.st.push.firebase import com.google.firebase.messaging.FirebaseMessaging import kotlin.coroutines.resume diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushService.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushService.kt new file mode 100644 index 0000000..e1d79db --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushService.kt @@ -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().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) + } +} diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushTokenRegistrar.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushTokenRegistrar.kt new file mode 100644 index 0000000..2c9321a --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/firebase/FirebasePushTokenRegistrar.kt @@ -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, + ) + } + +} \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageReceiver.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageReceiver.kt new file mode 100644 index 0000000..011b0dc --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageReceiver.kt @@ -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() + 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() + 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?, + ) + } +} \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushRegistrar.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushRegistrar.kt new file mode 100644 index 0000000..cbb5ad5 --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushRegistrar.kt @@ -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, + ) + } + +} diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt index ef7d21f..15cbaf1 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt @@ -7,6 +7,7 @@ import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.domain.eventlog.EventLogPersistence import app.dapk.st.domain.localecho.LocalEchoPersistence 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.RoomPersistence import app.dapk.st.matrix.common.CredentialsStore @@ -34,6 +35,8 @@ class StoreModule( fun filterStore(): FilterStore = FilterPreferences(preferences) val localEchoStore: LocalEchoStore by unsafeLazy { LocalEchoPersistence(errorTracker, database) } + fun pushStore() = PushTokenRegistrarPreferences(preferences) + fun applicationStore() = ApplicationPreferences(preferences) fun olmStore() = OlmPersistence(database, credentialsStore()) diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/push/PushTokenRegistrarPreferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/push/PushTokenRegistrarPreferences.kt new file mode 100644 index 0000000..38110ef --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/push/PushTokenRegistrarPreferences.kt @@ -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) + } +} \ No newline at end of file diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt index 3b50c9b..9180e52 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt @@ -3,7 +3,6 @@ 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.crypto.CryptoService import app.dapk.st.matrix.room.ProfileService import app.dapk.st.push.PushModule @@ -15,6 +14,6 @@ class LoginModule( ) : ProvidableModule { fun loginViewModel(): LoginViewModel { - return LoginViewModel(authService, pushModule.registerFirebasePushTokenUseCase(), profileService, errorTracker) + return LoginViewModel(authService, pushModule.pushTokenRegistrar(), profileService, errorTracker) } } \ No newline at end of file diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt index e5cbf31..f82c80d 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt @@ -7,7 +7,7 @@ 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.RegisterFirebasePushTokenUseCase +import app.dapk.st.push.PushTokenRegistrar import app.dapk.st.viewmodel.DapkViewModel import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -15,7 +15,7 @@ import kotlinx.coroutines.launch class LoginViewModel( private val authService: AuthService, - private val firebasePushTokenUseCase: RegisterFirebasePushTokenUseCase, + private val pushTokenRegistrar: PushTokenRegistrar, private val profileService: ProfileService, private val errorTracker: ErrorTracker, ) : DapkViewModel( @@ -32,7 +32,7 @@ class LoginViewModel( is AuthService.LoginResult.Success -> { runCatching { listOf( - async { firebasePushTokenUseCase.registerCurrentToken() }, + async { pushTokenRegistrar.registerCurrentToken() }, async { preloadMe() }, ).awaitAll() } diff --git a/features/notifications/build.gradle b/features/notifications/build.gradle index 13a9594..377a496 100644 --- a/features/notifications/build.gradle +++ b/features/notifications/build.gradle @@ -14,6 +14,7 @@ dependencies { implementation platform('com.google.firebase:firebase-bom:29.0.3') implementation 'com.google.firebase:firebase-messaging' + implementation Dependencies.mavenCentral.kotlinSerializationJson kotlinTest(it) diff --git a/features/notifications/src/main/AndroidManifest.xml b/features/notifications/src/main/AndroidManifest.xml index 11acb21..e76fe66 100644 --- a/features/notifications/src/main/AndroidManifest.xml +++ b/features/notifications/src/main/AndroidManifest.xml @@ -1,14 +1,2 @@ - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/MatrixPushHandler.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/MatrixPushHandler.kt new file mode 100644 index 0000000..5b39268 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/MatrixPushHandler.kt @@ -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.startInstantly() = this.onStart { emit(Unit) } +} diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt index 0d07a7c..f3501b9 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt @@ -6,33 +6,18 @@ import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.DeviceMeta import app.dapk.st.core.ProvidableModule 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.SyncService import app.dapk.st.navigator.IntentFactory -import app.dapk.st.push.RegisterFirebasePushTokenUseCase -import app.dapk.st.work.WorkScheduler class NotificationsModule( - private val pushService: PushService, - private val syncService: SyncService, - private val credentialsStore: CredentialsStore, - private val firebasePushTokenUseCase: RegisterFirebasePushTokenUseCase, private val iconLoader: IconLoader, private val roomStore: RoomStore, private val context: Context, - private val workScheduler: WorkScheduler, private val intentFactory: IntentFactory, private val dispatchers: CoroutineDispatchers, private val deviceMeta: DeviceMeta, ) : ProvidableModule { - fun pushUseCase() = pushService - fun syncService() = syncService - fun credentialProvider() = credentialsStore - fun firebasePushTokenUseCase() = firebasePushTokenUseCase - fun roomStore() = roomStore fun notificationsUseCase() = RenderNotificationsUseCase( notificationRenderer = NotificationRenderer( notificationManager(), @@ -55,5 +40,4 @@ class NotificationsModule( private fun notificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - fun workScheduler() = workScheduler } diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt deleted file mode 100644 index cd56aa8..0000000 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt +++ /dev/null @@ -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() } - 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.startInstantly() = this.onStart { emit(Unit) } \ No newline at end of file diff --git a/features/settings/build.gradle b/features/settings/build.gradle index c3dac7e..00ef2c0 100644 --- a/features/settings/build.gradle +++ b/features/settings/build.gradle @@ -5,6 +5,7 @@ dependencies { implementation project(":matrix:services:crypto") implementation project(":features:navigator") implementation project(':domains:store') + implementation project(':domains:android:push') implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") implementation project(":design-library") diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt index dd2457f..fbb7547 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsItemFactory.kt @@ -1,13 +1,18 @@ package app.dapk.st.settings 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.Text(SettingItem.Id.Encryption, "Encryption"), SettingItem.Text(SettingItem.Id.EventLog, "Event log"), + SettingItem.Text(SettingItem.Id.PushProvider, "Push provider", pushTokenRegistrars.currentSelection().id), SettingItem.Header("Data"), SettingItem.Text(SettingItem.Id.ClearCache, "Clear cache"), SettingItem.Header("Account"), diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt index 680b72a..0e93856 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsModule.kt @@ -7,10 +7,12 @@ import app.dapk.st.core.ProvidableModule import app.dapk.st.domain.StoreModule import app.dapk.st.matrix.crypto.CryptoService import app.dapk.st.matrix.sync.SyncService +import app.dapk.st.push.PushModule import app.dapk.st.settings.eventlogger.EventLoggerViewModel class SettingsModule( private val storeModule: StoreModule, + private val pushModule: PushModule, private val cryptoService: CryptoService, private val syncService: SyncService, private val contentResolver: ContentResolver, @@ -24,7 +26,8 @@ class SettingsModule( cryptoService, syncService, UriFilenameResolver(contentResolver, coroutineDispatchers), - SettingsItemFactory(buildMeta), + SettingsItemFactory(buildMeta, pushModule.pushTokenRegistrars()), + pushModule.pushTokenRegistrars(), ) internal fun eventLogViewModel(): EventLoggerViewModel { diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt index 8338af8..7b1862d 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt @@ -68,6 +68,9 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, item(Page.Routes.encryption) { Encryption(viewModel, it) } + item(Page.Routes.pushProviders) { + PushProviders(viewModel, it) + } item(Page.Routes.importRoomKeys) { when (it.importProgress) { null -> { @@ -132,6 +135,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, } } } + is Lce.Content -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -142,6 +146,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, } } } + is Lce.Error -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -152,6 +157,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, } } } + is Lce.Loading -> CenteredLoading() } } @@ -176,6 +182,7 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) { SettingsTextRow(item.content, item.subtitle, itemOnClick) } + is SettingItem.AccessToken -> { Row( Modifier @@ -193,6 +200,7 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) { } } } + is SettingItem.Header -> Header(item.label) } } @@ -203,6 +211,7 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) { is Lce.Error -> { // TODO } + is Lce.Loading -> { // 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 private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) { val context = LocalContext.current @@ -228,10 +263,12 @@ private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) { clipboard.setPrimaryClip(ClipData.newPlainText("dapk token", it.content)) Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() } + is SettingsEvent.Toast -> Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() OpenEventLog -> { context.startActivity(Intent(context, EventLogActivity::class.java)) } + is OpenUrl -> { context.startActivity(Intent(Intent.ACTION_VIEW).apply { data = it.url.toUri() }) } diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt index 467627c..fa78752 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsState.kt @@ -4,6 +4,7 @@ import android.net.Uri import app.dapk.st.core.Lce import app.dapk.st.design.components.Route import app.dapk.st.design.components.SpiderPage +import app.dapk.st.push.Registrar internal data class SettingsScreenState( val page: SpiderPage, @@ -17,9 +18,15 @@ internal sealed interface Page { val importProgress: Lce? = null, ) : Page + data class PushProviders( + val selection: Registrar? = null, + val options: Lce>? = Lce.Loading() + ) : Page + object Routes { val root = Route("Settings") val encryption = Route("Encryption") + val pushProviders = Route("PushProviders") val importRoomKeys = Route("ImportRoomKey") } } @@ -42,6 +49,7 @@ internal sealed interface SettingItem { AccessToken, ClearCache, EventLog, + PushProvider, Encryption, PrivacyPolicy, Ignored, diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt index c7063f1..6644542 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsViewModel.kt @@ -8,6 +8,8 @@ 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.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.SettingsEvent.* import app.dapk.st.viewmodel.DapkViewModel @@ -24,6 +26,7 @@ internal class SettingsViewModel( private val syncService: SyncService, private val uriFilenameResolver: UriFilenameResolver, private val settingsItemFactory: SettingsItemFactory, + private val pushTokenRegistrars: PushTokenRegistrars, factory: MutableStateFactory = defaultStateFactory(), ) : DapkViewModel( initialState = SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading()))), @@ -50,39 +53,72 @@ internal class SettingsViewModel( _events.emit(SignedOut) } } + 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")) } } + EventLog -> { viewModelScope.launch { _events.emit(OpenEventLog) } } + Encryption -> { updateState { copy(page = SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security)) } } + PrivacyPolicy -> { viewModelScope.launch { _events.emit(OpenUrl(PRIVACY_POLICY_URL)) } } + + PushProvider -> { + updateState { + copy(page = SpiderPage(Page.Routes.pushProviders, "Push providers", Page.Routes.root, Page.PushProviders())) + } + } + Ignored -> { // do nothing } } } + fun fetchPushProviders() { + updatePageState { copy(options = Lce.Loading()) } + viewModelScope.launch { + val currentSelection = pushTokenRegistrars.currentSelection() + val options = pushTokenRegistrars.options() + updatePageState { + copy( + selection = currentSelection, + options = Lce.Content(options) + ) + } + } + } + + fun selectPushProvider(registrar: Registrar) { + viewModelScope.launch { + pushTokenRegistrars.makeSelection(registrar) + fetchPushProviders() + } + } + fun importFromFileKeys(file: Uri, passphrase: String) { updatePageState { copy(importProgress = Lce.Loading()) } viewModelScope.launch { diff --git a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt index d3368b5..d8885d9 100644 --- a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt +++ b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsItemFactoryTest.kt @@ -1,25 +1,38 @@ package app.dapk.st.settings import app.dapk.st.core.BuildMeta +import app.dapk.st.push.PushTokenRegistrars +import app.dapk.st.push.Registrar import internalfixture.aSettingHeaderItem 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.junit.Test +import test.delegateReturn + +private val A_SELECTION = Registrar("A_SELECTION") class SettingsItemFactoryTest { 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 - 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() 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), aSettingHeaderItem("Data"), aSettingTextItem(SettingItem.Id.ClearCache, "Clear cache"), aSettingHeaderItem("Account"), @@ -29,4 +42,12 @@ class SettingsItemFactoryTest { aSettingTextItem(SettingItem.Id.Ignored, "Version", buildMeta.versionName), ) } +} + +class FakePushRegistrars { + + val instance = mockk() + + fun givenCurrentSelection() = coEvery { instance.currentSelection() }.delegateReturn() + } \ No newline at end of file diff --git a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt index 51f79c8..5fd5279 100644 --- a/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt +++ b/features/settings/src/test/kotlin/app/dapk/st/settings/SettingsViewModelTest.kt @@ -34,6 +34,7 @@ internal class SettingsViewModelTest { private val fakeCryptoService = FakeCryptoService() private val fakeSyncService = FakeSyncService() private val fakeUriFilenameResolver = FakeUriFilenameResolver() + private val fakePushTokenRegistrars = FakePushRegistrars() private val fakeSettingsItemFactory = FakeSettingsItemFactory() private val viewModel = SettingsViewModel( @@ -43,6 +44,7 @@ internal class SettingsViewModelTest { fakeSyncService, fakeUriFilenameResolver.instance, fakeSettingsItemFactory.instance, + fakePushTokenRegistrars.instance, runViewModelTest.testMutableStateFactory(), ) diff --git a/features/settings/src/test/kotlin/internalfake/FakeSettingsItemFactory.kt b/features/settings/src/test/kotlin/internalfake/FakeSettingsItemFactory.kt index 1b90817..226cec9 100644 --- a/features/settings/src/test/kotlin/internalfake/FakeSettingsItemFactory.kt +++ b/features/settings/src/test/kotlin/internalfake/FakeSettingsItemFactory.kt @@ -1,6 +1,7 @@ package internalfake import app.dapk.st.settings.SettingsItemFactory +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import test.delegateReturn @@ -8,5 +9,5 @@ import test.delegateReturn internal class FakeSettingsItemFactory { val instance = mockk() - fun givenRoot() = every { instance.root() }.delegateReturn() + fun givenRoot() = coEvery { instance.root() }.delegateReturn() } \ No newline at end of file diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt index b985c07..5402ed3 100644 --- a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/PushService.kt @@ -12,7 +12,7 @@ private val SERVICE_KEY = PushService::class interface PushService : MatrixService { - suspend fun registerPush(token: String) + suspend fun registerPush(token: String, gatewayUrl: String) @Serializable data class PushRequest( diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt index 7b9945e..92a462a 100644 --- a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/DefaultPushService.kt @@ -13,8 +13,8 @@ class DefaultPushService( private val useCase = RegisterPushUseCase(httpClient, credentialsStore, logger) - override suspend fun registerPush(token: String) { - useCase.registerPushToken(token) + override suspend fun registerPush(token: String, gatewayUrl: String) { + useCase.registerPushToken(token, gatewayUrl) } } \ No newline at end of file diff --git a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt index c1e8587..45711c3 100644 --- a/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt +++ b/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt @@ -13,7 +13,7 @@ internal class RegisterPushUseCase( private val logger: MatrixLogger, ) { - suspend fun registerPushToken(token: String) { + suspend fun registerPushToken(token: String, gatewayUrl: String) { if (credentialsStore.isSignedIn()) { logger.matrixLog("register push token: $token") matrixClient.execute( @@ -29,7 +29,7 @@ internal class RegisterPushUseCase( append = false, data = PushRequest.Payload( format = "event_id_only", - url = "https://sygnal.dapk.app/_matrix/push/v1/notify", + url = gatewayUrl, ), ) )