diff --git a/core/src/testFixtures/kotlin/test/ExpectTestScope.kt b/core/src/testFixtures/kotlin/test/ExpectTestScope.kt index f9f3db7..bff2b8f 100644 --- a/core/src/testFixtures/kotlin/test/ExpectTestScope.kt +++ b/core/src/testFixtures/kotlin/test/ExpectTestScope.kt @@ -6,7 +6,11 @@ import kotlinx.coroutines.test.runTest import kotlin.coroutines.CoroutineContext fun runExpectTest(testBody: suspend ExpectTestScope.() -> Unit) { - runTest { testBody(ExpectTest(coroutineContext)) } + runTest { + val expectTest = ExpectTest(coroutineContext) + testBody(expectTest) + } + } class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope { @@ -24,6 +28,11 @@ class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestSc expects.add(times to { block(this@expectUnit) }) } + override fun T.expect(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) { + coJustRun { block(this@expect) } + expects.add(times to { block(this@expect) }) + } + override fun T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) { groups.add { block(this@captureExpects) } } @@ -34,5 +43,6 @@ private fun Any.ignore() = Unit interface ExpectTestScope : CoroutineScope { fun verifyExpects() fun T.expectUnit(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit) + fun T.expect(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit) fun T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) } \ No newline at end of file diff --git a/domains/android/push/build.gradle b/domains/android/push/build.gradle index 0e2d176..e057b0f 100644 --- a/domains/android/push/build.gradle +++ b/domains/android/push/build.gradle @@ -10,4 +10,9 @@ dependencies { implementation Dependencies.mavenCentral.kotlinSerializationJson implementation Dependencies.jitPack.unifiedPush + + kotlinTest(it) + androidImportFixturesWorkaround(project, project(":core")) + androidImportFixturesWorkaround(project, project(":matrix:common")) + androidImportFixturesWorkaround(project, project(":domains:android:stub")) } 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 67d12ae..20963e9 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 @@ -9,6 +9,7 @@ import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.domain.push.PushTokenRegistrarPreferences import app.dapk.st.firebase.messaging.Messaging import app.dapk.st.push.messaging.MessagingPushTokenRegistrar +import app.dapk.st.push.unifiedpush.UnifiedPushImpl import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar class PushModule( @@ -21,15 +22,15 @@ class PushModule( ) : ProvidableModule { private val registrars by unsafeLazy { + val unifiedPush = UnifiedPushImpl(context) PushTokenRegistrars( - context, MessagingPushTokenRegistrar( errorTracker, pushHandler, messaging, ), - UnifiedPushRegistrar(context), - PushTokenRegistrarPreferences(preferences) + UnifiedPushRegistrar(context, unifiedPush), + PushTokenRegistrarPreferences(preferences), ) } 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 index eb29104..b87ef12 100644 --- 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 @@ -1,32 +1,30 @@ package app.dapk.st.push -import android.content.Context import app.dapk.st.domain.push.PushTokenRegistrarPreferences import app.dapk.st.push.messaging.MessagingPushTokenRegistrar 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 messagingPushTokenRegistrar: MessagingPushTokenRegistrar, private val unifiedPushRegistrar: UnifiedPushRegistrar, private val pushTokenStore: PushTokenRegistrarPreferences, + private val state: SelectionState = SelectionState(selection = null), ) : PushTokenRegistrar { - private var selection: Registrar? = null - fun options(): List { val messagingOption = when (messagingPushTokenRegistrar.isAvailable()) { true -> FIREBASE_OPTION else -> null } - return listOfNotNull(NONE, messagingOption) + UnifiedPush.getDistributors(context).map { Registrar(it) } + return listOfNotNull(NONE, messagingOption) + unifiedPushRegistrar.getDistributors() } - suspend fun currentSelection() = selection ?: (pushTokenStore.currentSelection()?.let { Registrar(it) } ?: defaultSelection()).also { selection = it } + suspend fun currentSelection() = state.selection ?: (readStoredSelection() ?: defaultSelection()).also { state.selection = it } + + private suspend fun readStoredSelection() = pushTokenStore.currentSelection()?.let { Registrar(it) }?.takeIf { options().contains(it) } private fun defaultSelection() = when (messagingPushTokenRegistrar.isAvailable()) { true -> FIREBASE_OPTION @@ -34,7 +32,7 @@ class PushTokenRegistrars( } suspend fun makeSelection(option: Registrar) { - selection = option + state.selection = option pushTokenStore.store(option.id) when (option) { NONE -> { @@ -66,7 +64,7 @@ class PushTokenRegistrars( } override fun unregister() { - when (selection) { + when (state.selection) { FIREBASE_OPTION -> messagingPushTokenRegistrar.unregister() NONE -> { runCatching { @@ -86,4 +84,6 @@ class PushTokenRegistrars( } @JvmInline -value class Registrar(val id: String) \ No newline at end of file +value class Registrar(val id: String) + +data class SelectionState(var selection: Registrar?) diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPush.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPush.kt new file mode 100644 index 0000000..55b706d --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPush.kt @@ -0,0 +1,20 @@ +package app.dapk.st.push.unifiedpush + +import android.content.Context +import org.unifiedpush.android.connector.UnifiedPush + +interface UnifiedPush { + fun saveDistributor(distributor: String) + fun getDistributor(): String + fun getDistributors(): List + fun registerApp() + fun unregisterApp() +} + +internal class UnifiedPushImpl(private val context: Context) : app.dapk.st.push.unifiedpush.UnifiedPush { + override fun saveDistributor(distributor: String) = UnifiedPush.saveDistributor(context, distributor) + override fun getDistributor(): String = UnifiedPush.getDistributor(context) + override fun getDistributors(): List = UnifiedPush.getDistributors(context) + override fun registerApp() = UnifiedPush.registerApp(context) + override fun unregisterApp() = UnifiedPush.unregisterApp(context) +} \ No newline at end of file diff --git a/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageDelegate.kt b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageDelegate.kt new file mode 100644 index 0000000..fc9a395 --- /dev/null +++ b/domains/android/push/src/main/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageDelegate.kt @@ -0,0 +1,72 @@ +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 java.net.URL + +private const val FALLBACK_UNIFIED_PUSH_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify" +private val json = Json { ignoreUnknownKeys = true } + +class UnifiedPushMessageDelegate( + private val scope: CoroutineScope = CoroutineScope(SupervisorJob()), + private val pushModuleProvider: (Context) -> PushModule = { it.module() }, + private val endpointReader: suspend (URL) -> String = { + runCatching { it.openStream().use { String(it.readBytes()) } }.getOrNull() ?: "" + } +) { + + fun onMessage(context: Context, message: ByteArray) { + log(AppLogTag.PUSH, "UnifiedPush onMessage, $message") + val module = pushModuleProvider(context) + 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) }) + } + } + } + + fun onNewEndpoint(context: Context, endpoint: String) { + log(AppLogTag.PUSH, "UnifiedPush onNewEndpoint $endpoint") + val module = pushModuleProvider(context) + 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 = endpointReader(matrixEndpoint) + val gatewayUrl = when { + content.contains("\"gateway\":\"matrix\"") -> matrixEndpoint.toString() + else -> FALLBACK_UNIFIED_PUSH_GATEWAY + } + handler.onNewToken(PushTokenPayload(token = endpoint, gatewayUrl = gatewayUrl)) + } + } + } + + @Serializable + private data class UnifiedPushMessagePayload( + @SerialName("notification") val notification: Notification, + ) { + + @Serializable + data class Notification( + @SerialName("event_id") val eventId: String? = null, + @SerialName("room_id") val roomId: String? = null, + ) + } + +} \ 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 index 5a9176a..da8c8bf 100644 --- 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 @@ -3,56 +3,19 @@ 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()) + private val delegate = UnifiedPushMessageDelegate() 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) }) - } - } + delegate.onMessage(context, message) } 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)) - } - } + delegate.onNewEndpoint(context, endpoint) } override fun onRegistrationFailed(context: Context, instance: String) { @@ -63,15 +26,4 @@ class UnifiedPushMessageReceiver : MessagingReceiver() { 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? = null, - @SerialName("room_id") val roomId: String? = null, - ) - } -} \ 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 index cbb5ad5..713b9ad 100644 --- 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 @@ -7,38 +7,41 @@ 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, + private val unifiedPush: UnifiedPush, + private val componentFactory: (Context) -> ComponentName = { ComponentName(it, UnifiedPushMessageReceiver::class.java) } ) : PushTokenRegistrar { + fun getDistributors() = unifiedPush.getDistributors().map { Registrar(it) } + fun registerSelection(registrar: Registrar) { log(AppLogTag.PUSH, "UnifiedPush - register: $registrar") - UnifiedPush.saveDistributor(context, registrar.id) + unifiedPush.saveDistributor(registrar.id) registerApp() } override suspend fun registerCurrentToken() { log(AppLogTag.PUSH, "UnifiedPush - register current token") - if (UnifiedPush.getDistributor(context).isNotEmpty()) { + if (unifiedPush.getDistributor().isNotEmpty()) { registerApp() } } private fun registerApp() { context.packageManager.setComponentEnabledSetting( - ComponentName(context, UnifiedPushMessageReceiver::class.java), + componentFactory(context), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP, ) - UnifiedPush.registerApp(context) + unifiedPush.registerApp() } override fun unregister() { - UnifiedPush.unregisterApp(context) + unifiedPush.unregisterApp() context.packageManager.setComponentEnabledSetting( - ComponentName(context, UnifiedPushMessageReceiver::class.java), + componentFactory(context), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP, ) diff --git a/domains/android/push/src/test/kotlin/app/dapk/st/push/PushTokenRegistrarsTest.kt b/domains/android/push/src/test/kotlin/app/dapk/st/push/PushTokenRegistrarsTest.kt new file mode 100644 index 0000000..3fc1cac --- /dev/null +++ b/domains/android/push/src/test/kotlin/app/dapk/st/push/PushTokenRegistrarsTest.kt @@ -0,0 +1,205 @@ +package app.dapk.st.push + +import app.dapk.st.domain.push.PushTokenRegistrarPreferences +import app.dapk.st.push.messaging.MessagingPushTokenRegistrar +import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.delegateReturn +import test.runExpectTest + +private val UNIFIED_PUSH = Registrar("unified-push option") +private val NONE = Registrar("None") +private val FIREBASE = Registrar("Google - Firebase (FCM)") +private val UNIFIED_PUSH_DISTRIBUTORS = listOf(UNIFIED_PUSH) + +class PushTokenRegistrarsTest { + + private val fakeMessagingPushRegistrar = FakeMessagingPushRegistrar() + private val fakeUnifiedPushRegistrar = FakeUnifiedPushRegistrar() + private val fakePushTokenRegistrarPreferences = FakePushTokenRegistrarPreferences() + private val selectionState = SelectionState(selection = null) + + private val registrars = PushTokenRegistrars( + fakeMessagingPushRegistrar.instance, + fakeUnifiedPushRegistrar.instance, + fakePushTokenRegistrarPreferences.instance, + selectionState, + ) + + @Test + fun `given messaging is available, when reading options, then returns firebase and unified push`() { + fakeMessagingPushRegistrar.givenIsAvailable().returns(true) + fakeUnifiedPushRegistrar.givenDistributors().returns(UNIFIED_PUSH_DISTRIBUTORS) + + val result = registrars.options() + + result shouldBeEqualTo listOf(Registrar("None"), FIREBASE) + UNIFIED_PUSH_DISTRIBUTORS + } + + @Test + fun `given messaging is not available, when reading options, then returns unified push`() { + fakeMessagingPushRegistrar.givenIsAvailable().returns(false) + fakeUnifiedPushRegistrar.givenDistributors().returns(UNIFIED_PUSH_DISTRIBUTORS) + + val result = registrars.options() + + result shouldBeEqualTo listOf(Registrar("None")) + UNIFIED_PUSH_DISTRIBUTORS + } + + @Test + fun `given no saved selection and messaging is not available, when reading default selection, then returns none`() = runTest { + fakePushTokenRegistrarPreferences.givenCurrentSelection().returns(null) + fakeMessagingPushRegistrar.givenIsAvailable().returns(false) + + val result = registrars.currentSelection() + + result shouldBeEqualTo NONE + } + + @Test + fun `given no saved selection and messaging is available, when reading default selection, then returns firebase`() = runTest { + fakePushTokenRegistrarPreferences.givenCurrentSelection().returns(null) + fakeMessagingPushRegistrar.givenIsAvailable().returns(true) + + val result = registrars.currentSelection() + + result shouldBeEqualTo FIREBASE + } + + @Test + fun `given saved selection and is a option, when reading default selection, then returns selection`() = runTest { + fakeUnifiedPushRegistrar.givenDistributors().returns(UNIFIED_PUSH_DISTRIBUTORS) + fakePushTokenRegistrarPreferences.givenCurrentSelection().returns(FIREBASE.id) + fakeMessagingPushRegistrar.givenIsAvailable().returns(true) + + val result = registrars.currentSelection() + + result shouldBeEqualTo FIREBASE + } + + @Test + fun `given saved selection and is not an option, when reading default selection, then returns next default`() = runTest { + fakeUnifiedPushRegistrar.givenDistributors().returns(UNIFIED_PUSH_DISTRIBUTORS) + fakePushTokenRegistrarPreferences.givenCurrentSelection().returns(FIREBASE.id) + fakeMessagingPushRegistrar.givenIsAvailable().returns(false) + + val result = registrars.currentSelection() + + result shouldBeEqualTo NONE + } + + @Test + fun `when selecting none, then stores and unregisters`() = runExpectTest { + fakePushTokenRegistrarPreferences.instance.expect { it.store(NONE.id) } + fakeMessagingPushRegistrar.instance.expect { it.unregister() } + fakeUnifiedPushRegistrar.instance.expect { it.unregister() } + + registrars.makeSelection(NONE) + + verifyExpects() + } + + @Test + fun `when selecting firebase, then stores and unregisters unifiedpush`() = runExpectTest { + fakePushTokenRegistrarPreferences.instance.expect { it.store(FIREBASE.id) } + fakeMessagingPushRegistrar.instance.expect { it.registerCurrentToken() } + fakeUnifiedPushRegistrar.instance.expect { it.unregister() } + + registrars.makeSelection(FIREBASE) + + verifyExpects() + } + + @Test + fun `when selecting unified push, then stores and unregisters firebase`() = runExpectTest { + fakePushTokenRegistrarPreferences.instance.expect { it.store(UNIFIED_PUSH.id) } + fakeMessagingPushRegistrar.instance.expect { it.unregister() } + fakeUnifiedPushRegistrar.instance.expect { it.registerSelection(UNIFIED_PUSH) } + + registrars.makeSelection(UNIFIED_PUSH) + + verifyExpects() + } + + @Test + fun `given unified push selected, when registering current token, then delegates`() = runExpectTest { + selectionState.selection = UNIFIED_PUSH + fakeUnifiedPushRegistrar.instance.expect { it.registerCurrentToken() } + + registrars.registerCurrentToken() + + verifyExpects() + } + + @Test + fun `given firebase selected, when registering current token, then delegates`() = runExpectTest { + selectionState.selection = FIREBASE + fakeMessagingPushRegistrar.instance.expect { it.registerCurrentToken() } + + registrars.registerCurrentToken() + + verifyExpects() + } + + @Test + fun `given none selected, when registering current token, then does nothing`() = runExpectTest { + selectionState.selection = NONE + + registrars.registerCurrentToken() + + verify { fakeMessagingPushRegistrar.instance wasNot Called } + verify { fakeUnifiedPushRegistrar.instance wasNot Called } + } + + @Test + fun `given unified push selected, when unregistering, then delegates`() = runExpectTest { + selectionState.selection = UNIFIED_PUSH + fakeUnifiedPushRegistrar.instance.expect { it.unregister() } + + registrars.unregister() + + verifyExpects() + } + + @Test + fun `given firebase selected, when unregistering, then delegates`() = runExpectTest { + selectionState.selection = FIREBASE + fakeMessagingPushRegistrar.instance.expect { it.unregister() } + + registrars.unregister() + + verifyExpects() + } + + @Test + fun `given none selected, when unregistering, then unregisters all`() = runExpectTest { + selectionState.selection = NONE + fakeUnifiedPushRegistrar.instance.expect { it.unregister() } + fakeMessagingPushRegistrar.instance.expect { it.unregister() } + + registrars.unregister() + + verifyExpects() + } +} + +class FakeMessagingPushRegistrar { + val instance = mockk() + + fun givenIsAvailable() = every { instance.isAvailable() }.delegateReturn() +} + +class FakeUnifiedPushRegistrar { + val instance = mockk() + + fun givenDistributors() = every { instance.getDistributors() }.delegateReturn() +} + +class FakePushTokenRegistrarPreferences { + val instance = mockk() + + fun givenCurrentSelection() = coEvery { instance.currentSelection() }.delegateReturn() +} \ No newline at end of file diff --git a/domains/android/push/src/test/kotlin/app/dapk/st/push/messaging/MessagingPushTokenRegistrarTest.kt b/domains/android/push/src/test/kotlin/app/dapk/st/push/messaging/MessagingPushTokenRegistrarTest.kt new file mode 100644 index 0000000..6f2b4f9 --- /dev/null +++ b/domains/android/push/src/test/kotlin/app/dapk/st/push/messaging/MessagingPushTokenRegistrarTest.kt @@ -0,0 +1,79 @@ +package app.dapk.st.push.messaging + +import app.dapk.st.firebase.messaging.Messaging +import app.dapk.st.push.PushTokenPayload +import app.dapk.st.push.unifiedpush.FakePushHandler +import fake.FakeErrorTracker +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.delegateReturn +import test.runExpectTest + +private const val A_TOKEN = "a-token" +private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify" +private val AN_ERROR = RuntimeException() + +class MessagingPushTokenRegistrarTest { + + private val fakePushHandler = FakePushHandler() + private val fakeErrorTracker = FakeErrorTracker() + private val fakeMessaging = FakeMessaging() + + private val registrar = MessagingPushTokenRegistrar( + fakeErrorTracker, + fakePushHandler, + fakeMessaging.instance, + ) + + @Test + fun `when checking isAvailable, then delegates`() = runExpectTest { + fakeMessaging.givenIsAvailable().returns(true) + + val result = registrar.isAvailable() + + result shouldBeEqualTo true + } + + @Test + fun `when registering current token, then enables and forwards current token to handler`() = runExpectTest { + fakeMessaging.instance.expect { it.enable() } + fakePushHandler.expect { it.onNewToken(PushTokenPayload(A_TOKEN, SYGNAL_GATEWAY)) } + fakeMessaging.givenToken().returns(A_TOKEN) + + registrar.registerCurrentToken() + + verifyExpects() + } + + @Test + fun `given fails to register, when registering current token, then tracks error`() = runExpectTest { + fakeMessaging.instance.expect { it.enable() } + fakeMessaging.givenToken().throws(AN_ERROR) + fakeErrorTracker.expect { it.track(AN_ERROR) } + + registrar.registerCurrentToken() + + verifyExpects() + } + + @Test + fun `when unregistering, then deletes token and disables`() = runExpectTest { + fakeMessaging.instance.expect { it.deleteToken() } + fakeMessaging.instance.expect { it.disable() } + + registrar.unregister() + + verifyExpects() + } + +} + +class FakeMessaging { + val instance = mockk() + + fun givenIsAvailable() = every { instance.isAvailable() }.delegateReturn() + fun givenToken() = coEvery { instance.token() }.delegateReturn() +} \ No newline at end of file diff --git a/domains/android/push/src/test/kotlin/app/dapk/st/push/messaging/MessagingServiceAdapterTest.kt b/domains/android/push/src/test/kotlin/app/dapk/st/push/messaging/MessagingServiceAdapterTest.kt new file mode 100644 index 0000000..26ca2cf --- /dev/null +++ b/domains/android/push/src/test/kotlin/app/dapk/st/push/messaging/MessagingServiceAdapterTest.kt @@ -0,0 +1,41 @@ +package app.dapk.st.push.messaging + +import app.dapk.st.push.PushTokenPayload +import app.dapk.st.push.unifiedpush.FakePushHandler +import fixture.aRoomId +import fixture.anEventId +import org.junit.Test +import test.runExpectTest + +private const val A_TOKEN = "a-push-token" +private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify" +private val A_ROOM_ID = aRoomId() +private val AN_EVENT_ID = anEventId() + +class MessagingServiceAdapterTest { + + private val fakePushHandler = FakePushHandler() + + private val messagingServiceAdapter = MessagingServiceAdapter(fakePushHandler) + + @Test + fun `onNewToken, then delegates to push handler`() = runExpectTest { + fakePushHandler.expect { + it.onNewToken(PushTokenPayload(token = A_TOKEN, gatewayUrl = SYGNAL_GATEWAY)) + } + messagingServiceAdapter.onNewToken(A_TOKEN) + + verifyExpects() + } + + + @Test + fun `onMessageReceived, then delegates to push handler`() = runExpectTest { + fakePushHandler.expect { + it.onMessageReceived(AN_EVENT_ID, A_ROOM_ID) + } + messagingServiceAdapter.onMessageReceived(AN_EVENT_ID, A_ROOM_ID) + + verifyExpects() + } +} diff --git a/domains/android/push/src/test/kotlin/app/dapk/st/push/unifiedpush/FakePushHandler.kt b/domains/android/push/src/test/kotlin/app/dapk/st/push/unifiedpush/FakePushHandler.kt new file mode 100644 index 0000000..64d0243 --- /dev/null +++ b/domains/android/push/src/test/kotlin/app/dapk/st/push/unifiedpush/FakePushHandler.kt @@ -0,0 +1,6 @@ +package app.dapk.st.push.unifiedpush + +import app.dapk.st.push.PushHandler +import io.mockk.mockk + +class FakePushHandler : PushHandler by mockk() \ No newline at end of file diff --git a/domains/android/push/src/test/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageDelegateTest.kt b/domains/android/push/src/test/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageDelegateTest.kt new file mode 100644 index 0000000..c6fd8b1 --- /dev/null +++ b/domains/android/push/src/test/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushMessageDelegateTest.kt @@ -0,0 +1,95 @@ +package app.dapk.st.push.unifiedpush + +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 fake.FakeContext +import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Test +import test.delegateReturn +import test.runExpectTest +import java.net.URL + +private val A_CONTEXT = FakeContext() +private const val A_ROOM_ID = "a room id" +private const val AN_EVENT_ID = "an event id" +private const val AN_ENDPOINT_HOST = "https://aendpointurl.com" +private const val AN_ENDPOINT = "$AN_ENDPOINT_HOST/with/path" +private const val A_GATEWAY_URL = "$AN_ENDPOINT_HOST/_matrix/push/v1/notify" +private const val FALLBACK_GATEWAY_URL = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify" + +class UnifiedPushMessageDelegateTest { + + private val fakePushHandler = FakePushHandler() + private val fakeEndpointReader = FakeEndpointReader() + private val fakePushModule = FakePushModule().also { + it.givenPushHandler().returns(fakePushHandler) + } + + private val unifiedPushReceiver = UnifiedPushMessageDelegate( + CoroutineScope(UnconfinedTestDispatcher()), + pushModuleProvider = { _ -> fakePushModule.instance }, + endpointReader = fakeEndpointReader, + ) + + @Test + fun `parses incoming message payloads`() = runExpectTest { + fakePushHandler.expect { it.onMessageReceived(EventId(AN_EVENT_ID), RoomId(A_ROOM_ID)) } + val messageBytes = createMessage(A_ROOM_ID, AN_EVENT_ID) + + unifiedPushReceiver.onMessage(A_CONTEXT.instance, messageBytes) + + verifyExpects() + } + + @Test + fun `given endpoint is a gateway, then uses original endpoint url`() = runExpectTest { + fakeEndpointReader.given(A_GATEWAY_URL).returns("""{"unifiedpush":{"gateway":"matrix"}}""") + fakePushHandler.expect { it.onNewToken(PushTokenPayload(token = AN_ENDPOINT, gatewayUrl = A_GATEWAY_URL)) } + + unifiedPushReceiver.onNewEndpoint(A_CONTEXT.instance, AN_ENDPOINT) + + verifyExpects() + } + + @Test + fun `given endpoint is not a gateway, then uses fallback endpoint url`() = runExpectTest { + fakeEndpointReader.given(A_GATEWAY_URL).returns("") + fakePushHandler.expect { it.onNewToken(PushTokenPayload(token = AN_ENDPOINT, gatewayUrl = FALLBACK_GATEWAY_URL)) } + + unifiedPushReceiver.onNewEndpoint(A_CONTEXT.instance, AN_ENDPOINT) + + verifyExpects() + } + + private fun createMessage(roomId: String, eventId: String) = """ + { + "notification": { + "room_id": "$roomId", + "event_id": "$eventId" + } + } + """.trimIndent().toByteArray() +} + +class FakePushModule { + val instance = mockk() + + init { + every { instance.dispatcher() }.returns(aCoroutineDispatchers()) + } + + fun givenPushHandler() = every { instance.pushHandler() }.delegateReturn() +} + +class FakeEndpointReader : suspend (URL) -> String by mockk() { + + fun given(url: String) = coEvery { this@FakeEndpointReader.invoke(URL(url)) }.delegateReturn() + +} \ No newline at end of file diff --git a/domains/android/push/src/test/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushRegistrarTest.kt b/domains/android/push/src/test/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushRegistrarTest.kt new file mode 100644 index 0000000..3fb1ec8 --- /dev/null +++ b/domains/android/push/src/test/kotlin/app/dapk/st/push/unifiedpush/UnifiedPushRegistrarTest.kt @@ -0,0 +1,99 @@ +package app.dapk.st.push.unifiedpush + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import app.dapk.st.push.Registrar +import fake.FakeContext +import fake.FakePackageManager +import io.mockk.Called +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.delegateReturn +import test.runExpectTest + +private val A_COMPONENT_NAME = FakeComponentName() +private val A_REGISTRAR_SELECTION = Registrar("a-registrar") +private const val A_SAVED_DISTRIBUTOR = "a distributor" + +class UnifiedPushRegistrarTest { + + private val fakePackageManager = FakePackageManager() + private val fakeContext = FakeContext().also { + it.givenPackageManager().returns(fakePackageManager.instance) + } + private val fakeUnifiedPush = FakeUnifiedPush() + private val fakeComponentFactory = { _: Context -> A_COMPONENT_NAME.instance } + + private val registrar = UnifiedPushRegistrar(fakeContext.instance, fakeUnifiedPush, fakeComponentFactory) + + @Test + fun `when unregistering, then updates unified push and disables component`() = runExpectTest { + fakeUnifiedPush.expect { it.unregisterApp() } + fakePackageManager.instance.expect { + it.setComponentEnabledSetting(A_COMPONENT_NAME.instance, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP) + } + + registrar.unregister() + + verifyExpects() + } + + @Test + fun `when registering selection, then updates unified push and enables component`() = runExpectTest { + fakeUnifiedPush.expect { it.registerApp() } + fakeUnifiedPush.expect { it.saveDistributor(A_REGISTRAR_SELECTION.id) } + fakePackageManager.instance.expect { + it.setComponentEnabledSetting(A_COMPONENT_NAME.instance, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP) + } + + registrar.registerSelection(A_REGISTRAR_SELECTION) + + verifyExpects() + } + + @Test + fun `given saved distributor, when registering current token, then updates unified push and enables component`() = runExpectTest { + fakeUnifiedPush.givenDistributor().returns(A_SAVED_DISTRIBUTOR) + fakeUnifiedPush.expect { it.registerApp() } + fakePackageManager.instance.expect { + it.setComponentEnabledSetting(A_COMPONENT_NAME.instance, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP) + } + + registrar.registerCurrentToken() + + verifyExpects() + } + + @Test + fun `given no distributor, when registering current token, then does nothing`() = runExpectTest { + fakeUnifiedPush.givenDistributor().returns("") + + registrar.registerCurrentToken() + + verify(exactly = 0) { fakeUnifiedPush.registerApp() } + verify { fakePackageManager.instance wasNot Called } + } + + @Test + fun `given distributors, then returns them as Registrars`() { + fakeUnifiedPush.givenDistributors().returns(listOf("a", "b")) + + val result = registrar.getDistributors() + + result shouldBeEqualTo listOf(Registrar("a"), Registrar("b")) + } +} + + +class FakeUnifiedPush : UnifiedPush by mockk() { + fun givenDistributor() = every { getDistributor() }.delegateReturn() + fun givenDistributors() = every { getDistributors() }.delegateReturn() +} + +class FakeComponentName { + val instance = mockk() +} \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt index 623c98c..122191e 100644 --- a/domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt @@ -1,8 +1,16 @@ package fake import android.content.Context +import android.content.pm.PackageManager +import io.mockk.every import io.mockk.mockk +import test.delegateReturn class FakeContext { val instance = mockk() + fun givenPackageManager() = every { instance.packageManager }.delegateReturn() +} + +class FakePackageManager { + val instance = mockk() } \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt index 724aa50..6875205 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt @@ -15,7 +15,6 @@ import io.mockk.every import io.mockk.mockk import org.junit.Test import test.delegateReturn -import test.expect import test.runExpectTest private const val SUMMARY_ID = 101 @@ -45,12 +44,13 @@ class NotificationRendererTest { @Test fun `given removed rooms when rendering then cancels notifications with cancelled room ids`() = runExpectTest { val removedRooms = setOf(aRoomId("id-1"), aRoomId("id-2")) - fakeNotificationFactory.instance.expect { it.mapToNotifications(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet())) } - fakeNotificationManager.instance.expectUnit { - removedRooms.forEach { removedRoom -> it.cancel(removedRoom.value, ROOM_MESSAGE_ID) } - } + val state = aNotificationState(removedRooms = removedRooms) + fakeNotificationFactory.givenNotifications(state).returns(aNotifications()) + fakeNotificationManager.instance.expectUnit { removedRooms.forEach { removedRoom -> it.cancel(removedRoom.value, ROOM_MESSAGE_ID) } } + fakeNotificationManager.instance.expectUnit { it.cancel(SUMMARY_ID) } + + notificationRenderer.render(state) - notificationRenderer.render(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet())) verifyExpects() } diff --git a/tools/coverage.gradle b/tools/coverage.gradle index 30770d4..660bb6c 100644 --- a/tools/coverage.gradle +++ b/tools/coverage.gradle @@ -12,6 +12,7 @@ def excludes = [ '**/*Activity*', '**/*AndroidService*', '**/*Application*', + '**/*Receiver*', // Generated '**/*serializer*',