From 582cb0a67a11dc28e0251cac3343b3f74e09018e Mon Sep 17 00:00:00 2001 From: Artem Bazhanov Date: Tue, 21 Oct 2025 22:53:36 +0300 Subject: [PATCH 1/5] Rename Firebase messaging module to Firebase Cloud Messaging --- gradle/libs.versions.toml | 10 + .../kick/core/data/ModuleDescription.kt | 4 + .../build.gradle.kts | 101 +++++ .../FirebaseCloudMessagingAccessor.kt | 11 + .../FirebaseCloudMessagingModule.kt | 32 ++ .../module/firebase/cloudmessaging/Kick.kt | 6 + .../actions/FirebaseCloudMessagingDelegate.kt | 12 + .../data/FirebaseLocalNotificationRequest.kt | 8 + .../core/data/FirebaseMessage.kt | 26 ++ .../core/data/FirebaseNotificationStatus.kt | 25 ++ .../firebase-cloud-messaging/build.gradle.kts | 94 ++++ .../AndroidFirebaseCloudMessagingDelegate.kt | 138 ++++++ .../core/actions/RemoteMessageExtensions.kt | 37 ++ .../extension/PlatformContext.android.kt | 13 + .../FirebaseCloudMessagingAccessor.kt | 23 + .../FirebaseCloudMessagingModule.kt | 57 +++ .../module/firebase/cloudmessaging/Kick.kt | 6 + .../actions/FirebaseCloudMessagingActions.kt | 67 +++ .../child/FirebaseCloudMessagingChild.kt | 8 + .../config/FirebaseCloudMessagingConfig.kt | 9 + .../data/FirebaseLocalNotificationRequest.kt | 11 + .../core/data/FirebaseMessage.kt | 28 ++ .../core/data/FirebaseNotificationStatus.kt | 28 ++ .../feature/extension/PlatformContext.kt | 5 + .../DefaultFirebaseCloudMessagingComponent.kt | 291 +++++++++++++ .../FirebaseCloudMessagingComponent.kt | 27 ++ .../FirebaseCloudMessagingContent.kt | 403 ++++++++++++++++++ .../FirebaseCloudMessagingState.kt | 30 ++ .../core/actions/PushPayloadExtensions.kt | 80 ++++ .../feature/extension/PlatformContext.ios.kt | 10 + settings.gradle.kts | 5 + 31 files changed, 1605 insertions(+) create mode 100644 module/firebase/firebase-cloud-messaging-stub/build.gradle.kts create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/Kick.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingDelegate.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseMessage.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationStatus.kt create mode 100644 module/firebase/firebase-cloud-messaging/build.gradle.kts create mode 100644 module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.android.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/Kick.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/component/child/FirebaseCloudMessagingChild.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/component/config/FirebaseCloudMessagingConfig.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseMessage.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationStatus.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingComponent.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingContent.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingState.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/PushPayloadExtensions.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.ios.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 64d051f..8ce719c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,11 @@ ktor = "3.2.2" publish-plugin = "0.34.0" sqljs = "1.8.0" copyWebpackPlugin = "9.1.0" +firebase-messaging = "24.0.0" +firebase-installations = "18.0.0" +play-services-base = "18.5.0" +datetime = "0.6.1" +androidx-core-ktx = "1.15.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -58,6 +63,11 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "a androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose-ui" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-ui" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx", version.ref = "firebase-messaging" } +firebase-installations = { module = "com.google.firebase:firebase-installations-ktx", version.ref = "firebase-installations" } +play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "play-services-base" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/main-core/src/commonMain/kotlin/ru/bartwell/kick/core/data/ModuleDescription.kt b/main-core/src/commonMain/kotlin/ru/bartwell/kick/core/data/ModuleDescription.kt index 5cdea8b..975bda2 100644 --- a/main-core/src/commonMain/kotlin/ru/bartwell/kick/core/data/ModuleDescription.kt +++ b/main-core/src/commonMain/kotlin/ru/bartwell/kick/core/data/ModuleDescription.kt @@ -40,4 +40,8 @@ public enum class ModuleDescription( title = "Overlay", description = "Floating debug panel over the app that updates in real time. Shows live key/value metrics." ), + FIREBASE_CLOUD_MESSAGING( + title = "Firebase Cloud Messaging", + description = "Inspect FCM tokens, installation id, delivered pushes and emulate notifications." + ), } diff --git a/module/firebase/firebase-cloud-messaging-stub/build.gradle.kts b/module/firebase/firebase-cloud-messaging-stub/build.gradle.kts new file mode 100644 index 0000000..e6e8f54 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/build.gradle.kts @@ -0,0 +1,101 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.publish.plugin) + id("publish-convention") +} + +group = "ru.bartwell.kick" +version = extra["libraryVersionName"] as String + +kotlin { + androidTarget { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } + } + } + + @Suppress("OPT_IN_USAGE") + wasmJs { + browser() + } + + jvm() + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "firebase-cloud-messaging-stub" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(projects.mainCore) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation(libs.decompose) + implementation(libs.decompose.extensions.compose) + implementation(libs.decompose.essenty.lifecycle.coroutines) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain.dependencies { + implementation(libs.androidx.activity.compose) + } + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + } + wasmJsMain.dependencies { + implementation(libs.kotlinx.serialization.json) + } + iosTest.dependencies { + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + implementation(libs.kotlin.test) + } + } + + explicitApi() +} + +android { + namespace = "ru.bartwell.kick" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildFeatures { + compose = true + } +} + +dependencies { + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt new file mode 100644 index 0000000..83a67d7 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt @@ -0,0 +1,11 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging + +import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingDelegate +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage + +public class FirebaseCloudMessagingAccessor internal constructor() { + public fun registerDelegate(delegate: FirebaseCloudMessagingDelegate) {} + public fun unregisterDelegate(delegate: FirebaseCloudMessagingDelegate? = null) {} + public fun log(message: FirebaseMessage) {} + public fun clearLoggedMessages() {} +} diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt new file mode 100644 index 0000000..02d6fad --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt @@ -0,0 +1,32 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging + +import androidx.compose.runtime.Composable +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.StackNavigation +import kotlinx.serialization.modules.PolymorphicModuleBuilder +import ru.bartwell.kick.core.component.Child +import ru.bartwell.kick.core.component.Config +import ru.bartwell.kick.core.component.StubConfig +import ru.bartwell.kick.core.data.Module +import ru.bartwell.kick.core.data.ModuleDescription +import ru.bartwell.kick.core.data.PlatformContext + +@Suppress("UNUSED_PARAMETER") +public class FirebaseCloudMessagingModule( + context: PlatformContext, +) : Module { + + override val description: ModuleDescription = ModuleDescription.FIREBASE_CLOUD_MESSAGING + override val startConfig: Config = StubConfig(description) + + override fun getComponent( + componentContext: ComponentContext, + nav: StackNavigation, + config: Config, + ): Child<*>? = null + + @Composable + override fun Content(instance: Child<*>) {} + + override fun registerSubclasses(builder: PolymorphicModuleBuilder) {} +} diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/Kick.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/Kick.kt new file mode 100644 index 0000000..f2d6949 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/Kick.kt @@ -0,0 +1,6 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging + +import ru.bartwell.kick.Kick + +public val Kick.Companion.firebaseCloudMessaging: FirebaseCloudMessagingAccessor + get() = FirebaseCloudMessagingAccessor() diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingDelegate.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingDelegate.kt new file mode 100644 index 0000000..1da8d9c --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingDelegate.kt @@ -0,0 +1,12 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions + +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus + +public interface FirebaseCloudMessagingDelegate { + public val isFirebaseInitialized: Boolean + public suspend fun getRegistrationToken(forceRefresh: Boolean): Result + public suspend fun getFirebaseInstallationId(): Result + public suspend fun sendLocalNotification(request: FirebaseLocalNotificationRequest): Result + public suspend fun getNotificationStatus(): Result +} diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt new file mode 100644 index 0000000..cac8e8f --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt @@ -0,0 +1,8 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +public data class FirebaseLocalNotificationRequest( + val title: String?, + val body: String?, + val data: Map, + val channelId: String?, +) diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseMessage.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseMessage.kt new file mode 100644 index 0000000..8fd7d32 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseMessage.kt @@ -0,0 +1,26 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +/** + * Lightweight representation of a Firebase Cloud Messaging push notification. + */ +public data class FirebaseMessage( + val title: String? = null, + val body: String? = null, + val data: Map = emptyMap(), + val from: String? = null, + val to: String? = null, + val messageId: String? = null, + val sentTimeMillis: Long? = null, + val collapseKey: String? = null, + val channelId: String? = null, + val category: String? = null, + val threadId: String? = null, + val badge: String? = null, + val sound: String? = null, + val tag: String? = null, + val imageUrl: String? = null, + val priority: String? = null, + val ttlSeconds: Long? = null, + val raw: Map = emptyMap(), + val receivedAtMillis: Long = 0L, +) diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationStatus.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationStatus.kt new file mode 100644 index 0000000..6072044 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationStatus.kt @@ -0,0 +1,25 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +public data class FirebaseNotificationStatus( + val iosPermission: IosNotificationPermissionStatus? = null, + val androidChannel: AndroidNotificationChannelStatus? = null, + val isGooglePlayServicesAvailable: Boolean? = null, +) + +public enum class IosNotificationPermissionStatus(public val description: String) { + NotDetermined("Not determined"), + Denied("Denied"), + Authorized("Authorized"), + Provisional("Provisional"), + Ephemeral("Ephemeral"), + Unknown("Unknown"), +} + +public data class AndroidNotificationChannelStatus( + val id: String, + val name: String? = null, + val description: String? = null, + val importance: String? = null, + val isEnabled: Boolean? = null, + val isAppNotificationsEnabled: Boolean? = null, +) diff --git a/module/firebase/firebase-cloud-messaging/build.gradle.kts b/module/firebase/firebase-cloud-messaging/build.gradle.kts new file mode 100644 index 0000000..1cfc430 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/build.gradle.kts @@ -0,0 +1,94 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.publish.plugin) + id("publish-convention") +} + +group = "ru.bartwell.kick" +version = extra["libraryVersionName"] as String + +kotlin { + androidTarget { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "firebase-cloud-messaging" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(projects.mainCore) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation(libs.decompose) + implementation(libs.decompose.extensions.compose) + implementation(libs.decompose.essenty.lifecycle.coroutines) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain.dependencies { + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.ktx) + implementation(libs.firebase.messaging) + implementation(libs.firebase.installations) + implementation(libs.play.services.base) + } + appleMain.dependencies { + } + iosTest.dependencies { + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + implementation(libs.kotlin.test) + } + } + + explicitApi() +} + +android { + namespace = "ru.bartwell.kick" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildFeatures { + compose = true + } +} + +dependencies { + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt new file mode 100644 index 0000000..6ffb48a --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt @@ -0,0 +1,138 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.tasks.Task +import com.google.firebase.FirebaseApp +import com.google.firebase.installations.FirebaseInstallations +import com.google.firebase.messaging.FirebaseMessaging +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.AndroidNotificationChannelStatus +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus + +public class AndroidFirebaseCloudMessagingDelegate( + private val context: Context, + private val fallbackChannelId: String = DEFAULT_CHANNEL_ID, + private val fallbackChannelName: String = DEFAULT_CHANNEL_NAME, +) : FirebaseCloudMessagingDelegate { + + private val applicationContext: Context = context.applicationContext + + override val isFirebaseInitialized: Boolean + get() = FirebaseApp.getApps(applicationContext).isNotEmpty() + + override suspend fun getRegistrationToken(forceRefresh: Boolean): Result = runCatching { + ensureFirebase() + val messaging = FirebaseMessaging.getInstance() + if (forceRefresh) { + messaging.deleteToken().await() + } + messaging.token.await() + } + + override suspend fun getFirebaseInstallationId(): Result = runCatching { + ensureFirebase() + FirebaseInstallations.getInstance().id.await() + } + + override suspend fun sendLocalNotification(request: FirebaseLocalNotificationRequest): Result = runCatching { + ensureFirebase() + val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelId = ensureChannel(manager, request.channelId) + val contentText = request.body ?: request.data.entries.joinToString { "${it.key}: ${it.value}" } + val notification = NotificationCompat.Builder(applicationContext, channelId) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(request.title ?: "Firebase push") + .setContentText(contentText) + .setStyle(NotificationCompat.BigTextStyle().bigText(contentText)) + .setAutoCancel(true) + .build() + manager.notify(System.currentTimeMillis().toInt(), notification) + } + + override suspend fun getNotificationStatus(): Result = runCatching { + ensureFirebase() + val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelInfo = buildChannelStatus(manager) + val playServices = GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(applicationContext) == ConnectionResult.SUCCESS + FirebaseNotificationStatus( + androidChannel = channelInfo, + isGooglePlayServicesAvailable = playServices, + ) + } + + private fun buildChannelStatus(manager: NotificationManager): AndroidNotificationChannelStatus { + val notificationsEnabled = manager.areNotificationsEnabled() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = manager.getNotificationChannel(fallbackChannelId) + AndroidNotificationChannelStatus( + id = channel?.id ?: fallbackChannelId, + name = channel?.name?.toString(), + description = channel?.description, + importance = channel?.importance?.toImportanceDescription(), + isEnabled = channel != null && notificationsEnabled, + isAppNotificationsEnabled = notificationsEnabled, + ) + } else { + AndroidNotificationChannelStatus( + id = fallbackChannelId, + name = null, + description = null, + importance = null, + isEnabled = notificationsEnabled, + isAppNotificationsEnabled = notificationsEnabled, + ) + } + } + + private fun ensureChannel(manager: NotificationManager, requested: String?): String { + val id = requested ?: fallbackChannelId + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val existing = manager.getNotificationChannel(id) + if (existing == null) { + val channel = NotificationChannel(id, fallbackChannelName, NotificationManager.IMPORTANCE_DEFAULT) + manager.createNotificationChannel(channel) + } + } + return id + } + + private fun ensureFirebase() { + require(isFirebaseInitialized) { "Firebase is not initialised" } + } + + private fun Int.toImportanceDescription(): String = when (this) { + NotificationManager.IMPORTANCE_NONE -> "none" + NotificationManager.IMPORTANCE_MIN -> "min" + NotificationManager.IMPORTANCE_LOW -> "low" + NotificationManager.IMPORTANCE_DEFAULT -> "default" + NotificationManager.IMPORTANCE_HIGH -> "high" + NotificationManager.IMPORTANCE_MAX -> "max" + else -> toString() + } + + private suspend fun Task.await(): T = suspendCancellableCoroutine { continuation -> + addOnCompleteListener { task -> + if (task.isSuccessful) { + continuation.resume(task.result) + } else { + val exception = task.exception ?: IllegalStateException("Firebase task failed") + continuation.resumeWithException(exception) + } + } + } + + private companion object { + private const val DEFAULT_CHANNEL_ID: String = "kick_firebase_debug" + private const val DEFAULT_CHANNEL_NAME: String = "Firebase Debug Pushes" + } +} diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt new file mode 100644 index 0000000..5250360 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt @@ -0,0 +1,37 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions + +import com.google.firebase.messaging.RemoteMessage +import ru.bartwell.kick.Kick +import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage + +public fun Kick.Companion.logFirebaseMessage(message: RemoteMessage) { + firebaseCloudMessaging.log(message.toFirebaseMessage()) +} + +private fun RemoteMessage.toFirebaseMessage(): FirebaseMessage { + val notification = notification + val channelId = notification?.androidChannelId ?: notification?.channelId + val ttl = if (ttl == 0) null else ttl.toLong() + val priority = when (priority) { + RemoteMessage.PRIORITY_HIGH -> "high" + RemoteMessage.PRIORITY_NORMAL -> "normal" + else -> priority.toString() + } + return FirebaseMessage( + title = notification?.title, + body = notification?.body, + data = data, + from = from, + to = to, + messageId = messageId, + sentTimeMillis = sentTime.takeIf { it != 0L }, + collapseKey = collapseKey, + channelId = channelId, + tag = notification?.tag, + imageUrl = notification?.imageUrl?.toString(), + priority = priority, + ttlSeconds = ttl, + raw = data, + ) +} diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.android.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.android.kt new file mode 100644 index 0000000..b36e0ff --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.android.kt @@ -0,0 +1,13 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.feature.extension + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.core.data.get + +internal actual fun PlatformContext.copyToClipboard(label: String, text: String) { + val context: Context = get() + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText(label, text)) +} diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt new file mode 100644 index 0000000..c210ba2 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt @@ -0,0 +1,23 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging + +import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingActions +import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingDelegate +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage + +public class FirebaseCloudMessagingAccessor internal constructor() { + public fun registerDelegate(delegate: FirebaseCloudMessagingDelegate) { + FirebaseCloudMessagingActions.registerDelegate(delegate) + } + + public fun unregisterDelegate(delegate: FirebaseCloudMessagingDelegate? = null) { + FirebaseCloudMessagingActions.unregisterDelegate(delegate ?: FirebaseCloudMessagingActions.currentDelegate()) + } + + public fun log(message: FirebaseMessage) { + FirebaseCloudMessagingActions.emitMessage(message) + } + + public fun clearLoggedMessages() { + FirebaseCloudMessagingActions.clearMessages() + } +} diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt new file mode 100644 index 0000000..bff29df --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt @@ -0,0 +1,57 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.pop +import kotlinx.serialization.modules.PolymorphicModuleBuilder +import ru.bartwell.kick.core.component.Child +import ru.bartwell.kick.core.component.Config +import ru.bartwell.kick.core.data.Module +import ru.bartwell.kick.core.data.ModuleDescription +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.module.firebase.cloudmessaging.core.component.child.FirebaseCloudMessagingChild +import ru.bartwell.kick.module.firebase.cloudmessaging.core.component.config.FirebaseCloudMessagingConfig +import ru.bartwell.kick.module.firebase.cloudmessaging.feature.presentation.DefaultFirebaseCloudMessagingComponent +import ru.bartwell.kick.module.firebase.cloudmessaging.feature.presentation.FirebaseCloudMessagingComponent +import ru.bartwell.kick.module.firebase.cloudmessaging.feature.presentation.FirebaseCloudMessagingContent + +public class FirebaseCloudMessagingModule( + @Suppress("UNUSED_PARAMETER") + context: PlatformContext, +) : Module { + + override val description: ModuleDescription = ModuleDescription.FIREBASE_CLOUD_MESSAGING + override val startConfig: Config = FirebaseCloudMessagingConfig + + override fun getComponent( + componentContext: ComponentContext, + nav: StackNavigation, + config: Config, + ): Child<*>? = if (config == FirebaseCloudMessagingConfig) { + FirebaseCloudMessagingChild( + DefaultFirebaseCloudMessagingComponent( + componentContext = componentContext, + onFinished = { nav.pop() }, + ) + ) + } else { + null + } + + @Composable + override fun Content(instance: Child<*>) { + when (val child = instance) { + is FirebaseCloudMessagingChild -> FirebaseCloudMessagingContent( + component = child.component, + modifier = Modifier.fillMaxSize(), + ) + } + } + + override fun registerSubclasses(builder: PolymorphicModuleBuilder) { + builder.subclass(FirebaseCloudMessagingConfig::class, FirebaseCloudMessagingConfig.serializer()) + } +} diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/Kick.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/Kick.kt new file mode 100644 index 0000000..f2d6949 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/Kick.kt @@ -0,0 +1,6 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging + +import ru.bartwell.kick.Kick + +public val Kick.Companion.firebaseCloudMessaging: FirebaseCloudMessagingAccessor + get() = FirebaseCloudMessagingAccessor() diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt new file mode 100644 index 0000000..367632f --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt @@ -0,0 +1,67 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus + +/** + * API that the host application needs to implement so the module can talk back to Firebase SDK. + */ +public interface FirebaseCloudMessagingDelegate { + /** + * Indicates that Firebase SDK was fully initialised and can be queried safely. + */ + public val isFirebaseInitialized: Boolean + + /** + * Returns current FCM registration token. + */ + public suspend fun getRegistrationToken(forceRefresh: Boolean): Result + + /** + * Returns Firebase installation identifier used by the current device. + */ + public suspend fun getFirebaseInstallationId(): Result + + /** + * Triggers a local notification emulating an incoming FCM push. + */ + public suspend fun sendLocalNotification(request: FirebaseLocalNotificationRequest): Result + + /** + * Collects platform specific notification status information. + */ + public suspend fun getNotificationStatus(): Result +} + +internal object FirebaseCloudMessagingActions { + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _delegate = MutableStateFlow(null) + val delegateFlow: StateFlow = _delegate.asStateFlow() + + fun currentDelegate(): FirebaseCloudMessagingDelegate? = _delegate.value + + fun registerDelegate(delegate: FirebaseCloudMessagingDelegate) { + _delegate.value = delegate + } + + fun unregisterDelegate(delegate: FirebaseCloudMessagingDelegate?) { + if (_delegate.value == delegate || delegate == null) { + _delegate.value = null + } + } + + fun emitMessage(message: FirebaseMessage) { + _messages.update { current -> listOf(message) + current } + } + + fun clearMessages() { + _messages.value = emptyList() + } +} diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/component/child/FirebaseCloudMessagingChild.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/component/child/FirebaseCloudMessagingChild.kt new file mode 100644 index 0000000..6b81524 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/component/child/FirebaseCloudMessagingChild.kt @@ -0,0 +1,8 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.component.child + +import ru.bartwell.kick.core.component.Child +import ru.bartwell.kick.module.firebase.cloudmessaging.feature.presentation.FirebaseCloudMessagingComponent + +internal data class FirebaseCloudMessagingChild( + override val component: FirebaseCloudMessagingComponent, +) : Child diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/component/config/FirebaseCloudMessagingConfig.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/component/config/FirebaseCloudMessagingConfig.kt new file mode 100644 index 0000000..497ceb0 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/component/config/FirebaseCloudMessagingConfig.kt @@ -0,0 +1,9 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.component.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.bartwell.kick.core.component.Config + +@Serializable +@SerialName("FirebaseCloudMessaging") +internal data object FirebaseCloudMessagingConfig : Config diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt new file mode 100644 index 0000000..45e5fe9 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt @@ -0,0 +1,11 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +/** + * Parameters required to trigger a local notification that emulates an incoming FCM push. + */ +public data class FirebaseLocalNotificationRequest( + val title: String?, + val body: String?, + val data: Map, + val channelId: String?, +) diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseMessage.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseMessage.kt new file mode 100644 index 0000000..b969095 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseMessage.kt @@ -0,0 +1,28 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +import kotlin.system.getTimeMillis + +/** + * Representation of a Firebase Cloud Messaging push notification that can be shown inside Kick. + */ +public data class FirebaseMessage( + val title: String? = null, + val body: String? = null, + val data: Map = emptyMap(), + val from: String? = null, + val to: String? = null, + val messageId: String? = null, + val sentTimeMillis: Long? = null, + val collapseKey: String? = null, + val channelId: String? = null, + val category: String? = null, + val threadId: String? = null, + val badge: String? = null, + val sound: String? = null, + val tag: String? = null, + val imageUrl: String? = null, + val priority: String? = null, + val ttlSeconds: Long? = null, + val raw: Map = emptyMap(), + val receivedAtMillis: Long = getTimeMillis(), +) diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationStatus.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationStatus.kt new file mode 100644 index 0000000..c97c915 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationStatus.kt @@ -0,0 +1,28 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +/** + * Aggregated notification related information that can be fetched from the host application. + */ +public data class FirebaseNotificationStatus( + val iosPermission: IosNotificationPermissionStatus? = null, + val androidChannel: AndroidNotificationChannelStatus? = null, + val isGooglePlayServicesAvailable: Boolean? = null, +) + +public enum class IosNotificationPermissionStatus(public val description: String) { + NotDetermined("Not determined"), + Denied("Denied"), + Authorized("Authorized"), + Provisional("Provisional"), + Ephemeral("Ephemeral"), + Unknown("Unknown"), +} + +public data class AndroidNotificationChannelStatus( + val id: String, + val name: String? = null, + val description: String? = null, + val importance: String? = null, + val isEnabled: Boolean? = null, + val isAppNotificationsEnabled: Boolean? = null, +) diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.kt new file mode 100644 index 0000000..28f691a --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.kt @@ -0,0 +1,5 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.feature.extension + +import ru.bartwell.kick.core.data.PlatformContext + +internal expect fun PlatformContext.copyToClipboard(label: String, text: String) diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt new file mode 100644 index 0000000..d09b07a --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt @@ -0,0 +1,291 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.feature.presentation + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingActions +import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingDelegate +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest +import ru.bartwell.kick.module.firebase.cloudmessaging.feature.extension.copyToClipboard + +internal class DefaultFirebaseCloudMessagingComponent( + componentContext: ComponentContext, + private val onFinished: () -> Unit, +) : FirebaseCloudMessagingComponent, ComponentContext by componentContext { + + private val json = Json { ignoreUnknownKeys = true; prettyPrint = true } + private val uiScope = coroutineScope() + private val _state = MutableValue( + FirebaseCloudMessagingState( + availabilityMessage = NOT_INITIALISED_MESSAGE, + ) + ) + override val state: Value = _state + + init { + FirebaseCloudMessagingActions.messages + .onEach { messages -> + updateState { copy(messages = messages) } + } + .launchIn(uiScope) + + FirebaseCloudMessagingActions.delegateFlow + .onEach { delegate -> + val isAvailable = delegate?.isFirebaseInitialized == true + updateState { + copy( + isFirebaseAvailable = isAvailable, + availabilityMessage = if (isAvailable) null else NOT_INITIALISED_MESSAGE, + ) + } + if (isAvailable) { + refreshToken(forceRefresh = false) + refreshFirebaseId() + refreshStatus() + } else { + updateState { + copy( + token = null, + firebaseId = null, + status = null, + ) + } + } + } + .launchIn(uiScope) + } + + override fun onBackPressed() { + onFinished() + } + + override fun refreshToken(forceRefresh: Boolean) { + uiScope.launch { + val delegate = FirebaseCloudMessagingActions.currentDelegate() + if (!ensureDelegate(delegate)) { + updateState { + copy( + tokenError = NOT_INITIALISED_MESSAGE, + isTokenLoading = false, + ) + } + return@launch + } + + updateState { copy(isTokenLoading = true, tokenError = null) } + val result = safeCall { delegate.getRegistrationToken(forceRefresh) } + updateState { current -> + result.fold( + onSuccess = { token -> + current.copy(token = token, tokenError = null, isTokenLoading = false) + }, + onFailure = { error -> + current.copy(tokenError = error.message ?: error.toString(), isTokenLoading = false) + }, + ) + } + } + } + + override fun refreshFirebaseId() { + uiScope.launch { + val delegate = FirebaseCloudMessagingActions.currentDelegate() + if (!ensureDelegate(delegate)) { + updateState { + copy( + firebaseIdError = NOT_INITIALISED_MESSAGE, + isFirebaseIdLoading = false, + ) + } + return@launch + } + + updateState { copy(isFirebaseIdLoading = true, firebaseIdError = null) } + val result = safeCall { delegate.getFirebaseInstallationId() } + updateState { current -> + result.fold( + onSuccess = { id -> + current.copy(firebaseId = id, firebaseIdError = null, isFirebaseIdLoading = false) + }, + onFailure = { error -> + current.copy(firebaseIdError = error.message ?: error.toString(), isFirebaseIdLoading = false) + }, + ) + } + } + } + + override fun copyToken(context: PlatformContext) { + state.value.token?.let { token -> + context.copyToClipboard("FCM Token", token) + } + } + + override fun copyFirebaseId(context: PlatformContext) { + state.value.firebaseId?.let { id -> + context.copyToClipboard("Firebase Installation Id", id) + } + } + + override fun refreshStatus() { + uiScope.launch { + val delegate = FirebaseCloudMessagingActions.currentDelegate() + if (!ensureDelegate(delegate)) { + updateState { + copy( + statusError = NOT_INITIALISED_MESSAGE, + isStatusLoading = false, + ) + } + return@launch + } + + updateState { copy(isStatusLoading = true, statusError = null) } + val result = safeCall { delegate.getNotificationStatus() } + updateState { current -> + result.fold( + onSuccess = { status -> + current.copy(status = status, statusError = null, isStatusLoading = false) + }, + onFailure = { error -> + current.copy(statusError = error.message ?: error.toString(), isStatusLoading = false) + }, + ) + } + } + } + + override fun clearMessages() { + FirebaseCloudMessagingActions.clearMessages() + } + + override fun onLocalNotificationTitleChange(value: String) { + updateLocalNotification { copy(title = value).clearFeedback() } + } + + override fun onLocalNotificationBodyChange(value: String) { + updateLocalNotification { copy(body = value).clearFeedback() } + } + + override fun onLocalNotificationDataChange(value: String) { + updateLocalNotification { copy(data = value).clearFeedback() } + } + + override fun onLocalNotificationChannelChange(value: String) { + updateLocalNotification { copy(channelId = value).clearFeedback() } + } + + override fun sendLocalNotification() { + uiScope.launch { + val delegate = FirebaseCloudMessagingActions.currentDelegate() + if (!ensureDelegate(delegate)) { + updateLocalNotification { copy(error = NOT_INITIALISED_MESSAGE, isSending = false) } + return@launch + } + + val current = state.value.localNotification + val dataResult = parseData(current.data) + if (dataResult == null) { + updateLocalNotification { copy(error = "Data must be a valid JSON object") } + return@launch + } + + updateLocalNotification { copy(isSending = true, error = null, successMessage = null) } + val result = safeCall { + delegate.sendLocalNotification( + FirebaseLocalNotificationRequest( + title = current.title.takeIf { it.isNotBlank() }, + body = current.body.takeIf { it.isNotBlank() }, + data = dataResult, + channelId = current.channelId.takeIf { it.isNotBlank() }, + ) + ) + } + updateLocalNotification { local -> + result.fold( + onSuccess = { + local.copy( + isSending = false, + successMessage = "Local notification sent", + error = null, + ) + }, + onFailure = { error -> + local.copy( + isSending = false, + error = error.message ?: error.toString(), + successMessage = null, + ) + }, + ) + } + } + } + + override fun onLocalNotificationFeedbackConsumed() { + updateLocalNotification { copy(successMessage = null, error = null) } + } + + private fun updateState(block: FirebaseCloudMessagingState.() -> FirebaseCloudMessagingState) { + _state.value = _state.value.block() + } + + private fun updateLocalNotification(block: LocalNotificationState.() -> LocalNotificationState) { + updateState { copy(localNotification = localNotification.block()) } + } + + private fun ensureDelegate(delegate: FirebaseCloudMessagingDelegate?): Boolean { + val available = delegate?.isFirebaseInitialized == true + if (!available) { + updateState { + copy( + isFirebaseAvailable = false, + availabilityMessage = NOT_INITIALISED_MESSAGE, + ) + } + } + return available + } + + private fun parseData(raw: String): Map? { + val trimmed = raw.trim() + if (trimmed.isBlank()) return emptyMap() + return try { + val element = json.parseToJsonElement(trimmed) + if (element is JsonObject) { + element.mapValues { entry -> + when (val value = entry.value) { + is JsonPrimitive -> value.content + else -> value.toString() + } + } + } else { + null + } + } catch (error: Throwable) { + null + } + } + + private suspend fun safeCall(block: suspend () -> Result): Result = try { + block() + } catch (error: Throwable) { + Result.failure(error) + } + + private fun LocalNotificationState.clearFeedback(): LocalNotificationState = copy( + error = null, + successMessage = null, + ) + + companion object { + private const val NOT_INITIALISED_MESSAGE: String = "Firebase is not initialised in the host application" + } +} diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingComponent.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingComponent.kt new file mode 100644 index 0000000..45b69b8 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingComponent.kt @@ -0,0 +1,27 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.feature.presentation + +import com.arkivanov.decompose.value.Value +import ru.bartwell.kick.core.component.Component +import ru.bartwell.kick.core.data.PlatformContext + +internal interface FirebaseCloudMessagingComponent : Component { + val state: Value + + fun onBackPressed() + + fun refreshToken(forceRefresh: Boolean) + fun refreshFirebaseId() + fun copyToken(context: PlatformContext) + fun copyFirebaseId(context: PlatformContext) + + fun refreshStatus() + + fun clearMessages() + + fun onLocalNotificationTitleChange(value: String) + fun onLocalNotificationBodyChange(value: String) + fun onLocalNotificationDataChange(value: String) + fun onLocalNotificationChannelChange(value: String) + fun sendLocalNotification() + fun onLocalNotificationFeedbackConsumed() +} diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingContent.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingContent.kt new file mode 100644 index 0000000..70c5e69 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingContent.kt @@ -0,0 +1,403 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.feature.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Send +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import ru.bartwell.kick.core.data.platformContext +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.AndroidNotificationChannelStatus +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus + +@Composable +internal fun FirebaseCloudMessagingContent( + component: FirebaseCloudMessagingComponent, + modifier: Modifier = Modifier, +) { + val state by component.state.subscribeAsState() + val context = platformContext() + val listState = rememberLazyListState() + + Column(modifier = modifier) { + TopAppBar( + title = { Text("Firebase Cloud Messaging") }, + navigationIcon = { + IconButton(onClick = component::onBackPressed, modifier = Modifier.testTag("back")) { + Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + }, + ) + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + state = listState, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + AvailabilitySection(state) + } + + item { + TokenSection(state, onRefresh = { component.refreshToken(forceRefresh = true) }) { + component.copyToken(context) + } + } + + item { + FirebaseIdSection(state, onRefresh = component::refreshFirebaseId) { + component.copyFirebaseId(context) + } + } + + item { + StatusSection(state, onRefresh = component::refreshStatus) + } + + item { + LocalNotificationSection(state.localNotification, + onTitleChange = component::onLocalNotificationTitleChange, + onBodyChange = component::onLocalNotificationBodyChange, + onDataChange = component::onLocalNotificationDataChange, + onChannelChange = component::onLocalNotificationChannelChange, + onSend = component::sendLocalNotification, + onDismissFeedback = component::onLocalNotificationFeedbackConsumed, + ) + } + + item { + MessagesHeader(state.messages, onClear = component::clearMessages) + } + + items(state.messages, key = { it.receivedAtMillis to (it.messageId ?: it.hashCode()) }) { message -> + MessageCard(message) + } + } + } +} + +@Composable +private fun AvailabilitySection(state: FirebaseCloudMessagingState) { + val text = state.availabilityMessage + if (text != null) { + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = text, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun TokenSection( + state: FirebaseCloudMessagingState, + onRefresh: () -> Unit, + onCopy: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text("Registration token", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + IconButton(onClick = onRefresh, enabled = !state.isTokenLoading) { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh token") + } + IconButton(onClick = onCopy, enabled = state.token != null) { + Icon(Icons.Outlined.ContentCopy, contentDescription = "Copy token") + } + } + if (state.isTokenLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + state.token?.let { token -> + SelectionContainer { + Text(token, style = MaterialTheme.typography.bodyMedium) + } + } + state.tokenError?.let { error -> + Text(text = error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun FirebaseIdSection( + state: FirebaseCloudMessagingState, + onRefresh: () -> Unit, + onCopy: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text("Firebase installation id", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + IconButton(onClick = onRefresh, enabled = !state.isFirebaseIdLoading) { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh id") + } + IconButton(onClick = onCopy, enabled = state.firebaseId != null) { + Icon(Icons.Outlined.ContentCopy, contentDescription = "Copy id") + } + } + if (state.isFirebaseIdLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + state.firebaseId?.let { id -> + SelectionContainer { + Text(id, style = MaterialTheme.typography.bodyMedium) + } + } + state.firebaseIdError?.let { error -> + Text(text = error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun StatusSection( + state: FirebaseCloudMessagingState, + onRefresh: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text("Notification status", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + IconButton(onClick = onRefresh, enabled = !state.isStatusLoading) { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh status") + } + } + if (state.isStatusLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + state.status?.let { status -> + StatusDetails(status) + } + state.statusError?.let { error -> + Text(text = error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun StatusDetails(status: FirebaseNotificationStatus) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + status.iosPermission?.let { permission -> + StatusRow(title = "iOS permission", value = permission.description) + } + status.androidChannel?.let { channel -> + StatusRow(title = "Android channel", value = buildAndroidChannelSummary(channel)) + } + status.isGooglePlayServicesAvailable?.let { available -> + StatusRow(title = "Google Play Services", value = if (available) "Available" else "Unavailable") + } + } +} + +@Composable +private fun StatusRow(title: String, value: String) { + Column { + Text(title, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold) + Text(value, style = MaterialTheme.typography.bodyMedium) + } +} + +private fun buildAndroidChannelSummary(channel: AndroidNotificationChannelStatus): String { + val parts = buildList { + add(channel.id) + channel.name?.let { add("Name: $it") } + channel.importance?.let { add("Importance: $it") } + channel.isEnabled?.let { add(if (it) "Enabled" else "Disabled") } + channel.isAppNotificationsEnabled?.let { add(if (it) "Notifications allowed" else "Notifications blocked") } + channel.description?.let { add("Description: $it") } + } + return parts.joinToString(separator = "\n") +} + +@Composable +private fun LocalNotificationSection( + state: LocalNotificationState, + onTitleChange: (String) -> Unit, + onBodyChange: (String) -> Unit, + onDataChange: (String) -> Unit, + onChannelChange: (String) -> Unit, + onSend: () -> Unit, + onDismissFeedback: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Local push emulation", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = state.title, + onValueChange = onTitleChange, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.body, + onValueChange = onBodyChange, + label = { Text("Body") }, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.channelId, + onValueChange = onChannelChange, + label = { Text("Channel id (Android)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.data, + onValueChange = onDataChange, + label = { Text("Data (JSON object)") }, + singleLine = false, + modifier = Modifier.fillMaxWidth().height(120.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Button(onClick = onSend, enabled = !state.isSending, modifier = Modifier.testTag("send_local_notification")) { + Icon(Icons.Outlined.Send, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Send") + } + if (state.isSending) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + state.successMessage?.let { message -> + TextButton(onClick = onDismissFeedback) { + Text(message) + } + } + } + state.error?.let { error -> + Text(text = error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun MessagesHeader(messages: List, onClear: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Received pushes (${messages.size})", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + if (messages.isNotEmpty()) { + OutlinedButton(onClick = onClear) { + Icon(Icons.Outlined.Delete, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Clear") + } + } + } +} + +@Composable +private fun MessageCard(message: FirebaseMessage) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + message.title?.let { title -> + Text(title, style = MaterialTheme.typography.titleMedium) + } + message.body?.let { body -> + Text(body, style = MaterialTheme.typography.bodyMedium) + } + if (message.data.isNotEmpty()) { + Text("Data:", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold) + SelectionContainer { + Text(message.data.entries.joinToString(separator = "\n") { "${it.key}: ${it.value}" }) + } + } + Divider() + InfoRow(label = "From", value = message.from) + InfoRow(label = "To", value = message.to) + InfoRow(label = "Message id", value = message.messageId) + InfoRow(label = "Collapse key", value = message.collapseKey) + InfoRow(label = "Channel", value = message.channelId) + InfoRow(label = "Category", value = message.category) + InfoRow(label = "Thread", value = message.threadId) + InfoRow(label = "Badge", value = message.badge) + InfoRow(label = "Sound", value = message.sound) + InfoRow(label = "Tag", value = message.tag) + InfoRow(label = "Image", value = message.imageUrl) + InfoRow(label = "Priority", value = message.priority) + InfoRow(label = "TTL", value = message.ttlSeconds?.let { "$it s" }) + InfoRow(label = "Sent", value = formatTimestamp(message.sentTimeMillis)) + InfoRow(label = "Received", value = formatTimestamp(message.receivedAtMillis)) + if (message.raw.isNotEmpty()) { + Divider() + Text("Raw payload:", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold) + SelectionContainer { + Text(message.raw.entries.joinToString(separator = "\n") { "${it.key}: ${it.value}" }) + } + } + } + } +} + +@Composable +private fun InfoRow(label: String, value: String?) { + if (!value.isNullOrBlank()) { + Column { + Text(label, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold) + Text(value, style = MaterialTheme.typography.bodySmall) + } + } +} + +private fun formatTimestamp(value: Long?): String? { + if (value == null) return null + return try { + val instant = Instant.fromEpochMilliseconds(value) + val local = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + "%04d-%02d-%02d %02d:%02d:%02d".format( + local.year, + local.monthNumber, + local.dayOfMonth, + local.hour, + local.minute, + local.second, + ) + } catch (_: Throwable) { + value.toString() + } +} diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingState.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingState.kt new file mode 100644 index 0000000..39c169b --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/FirebaseCloudMessagingState.kt @@ -0,0 +1,30 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.feature.presentation + +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus + +internal data class FirebaseCloudMessagingState( + val isFirebaseAvailable: Boolean = false, + val token: String? = null, + val tokenError: String? = null, + val isTokenLoading: Boolean = false, + val firebaseId: String? = null, + val firebaseIdError: String? = null, + val isFirebaseIdLoading: Boolean = false, + val status: FirebaseNotificationStatus? = null, + val statusError: String? = null, + val isStatusLoading: Boolean = false, + val messages: List = emptyList(), + val availabilityMessage: String? = null, + val localNotification: LocalNotificationState = LocalNotificationState(), +) + +internal data class LocalNotificationState( + val title: String = "", + val body: String = "", + val data: String = "{}", + val channelId: String = "", + val isSending: Boolean = false, + val error: String? = null, + val successMessage: String? = null, +) diff --git a/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/PushPayloadExtensions.kt b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/PushPayloadExtensions.kt new file mode 100644 index 0000000..3fa6ea1 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/PushPayloadExtensions.kt @@ -0,0 +1,80 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions + +import platform.Foundation.NSDictionary +import platform.UserNotifications.UNNotification +import ru.bartwell.kick.Kick +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage +import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging + +public fun Kick.Companion.logFirebaseMessage(userInfo: NSDictionary) { + firebaseCloudMessaging.log(userInfo.toFirebaseMessage()) +} + +public fun Kick.Companion.logFirebaseMessage(userInfo: Map) { + firebaseCloudMessaging.log(userInfo.toFirebaseMessage()) +} + +public fun Kick.Companion.logFirebaseMessage(notification: UNNotification) { + val payload = notification.request.content.userInfo + firebaseCloudMessaging.log(payload.toFirebaseMessage()) +} + +private fun NSDictionary.toFirebaseMessage(): FirebaseMessage = entries().associate { entry -> + val key = entry.key?.toString() ?: "" + key to entry.value +}.toFirebaseMessage() + +private fun Map.toFirebaseMessage(): FirebaseMessage { + val rawMap: Map = entries + .mapNotNull { (key, value) -> (key?.toString())?.let { it to value } } + .toMap() + + val aps = rawMap["aps"] as? Map<*, *> + val alert = when (val alertValue = aps?.get("alert")) { + is String -> mapOf("body" to alertValue) + is Map<*, *> -> alertValue.entries.associate { (k, v) -> (k?.toString() ?: "") to v } + else -> emptyMap() + } + + val data = rawMap.filterKeys { it != "aps" && !it.startsWith("gcm.") && it != "google.c.a.e" } + .mapValues { it.value?.toString() ?: "" } + + val messageId = rawMap["gcm.message_id"] ?: rawMap["message_id"] ?: rawMap["google.message_id"] + val collapseKey = rawMap["collapse_key"] ?: aps?.get("thread-id") + val ttl = rawMap["gcm.ttl"] ?: rawMap["ttl"] + val from = rawMap["from"] ?: rawMap["google.c.a.c_id"] + + return FirebaseMessage( + title = alert["title"]?.toString() ?: alert["loc-key"]?.toString(), + body = alert["body"]?.toString(), + data = data, + from = from?.toString(), + to = rawMap["to"]?.toString(), + messageId = messageId?.toString(), + collapseKey = collapseKey?.toString(), + category = (aps?.get("category") ?: rawMap["google.c.a.c_l"])?.toString(), + threadId = aps?.get("thread-id")?.toString(), + badge = aps?.get("badge")?.toString(), + sound = aps?.get("sound")?.toString(), + imageUrl = extractImage(alert)?.toString(), + ttlSeconds = ttl?.toString()?.toLongOrNull(), + raw = rawMap.mapValues { it.value?.toString() ?: "" }, + ) +} + +private fun extractImage(alert: Map): Any? { + return alert["image"] ?: alert["image-url"] ?: alert["imageURL"] +} + +private fun NSDictionary.entries(): List { + val keys = allKeys + val result = mutableListOf() + repeat(keys.count.toInt()) { index -> + val key = keys.objectAtIndex(index.toULong()) + val value = objectForKey(key) + result += MapEntry(key, value) + } + return result +} + +private data class MapEntry(val key: Any?, val value: Any?) diff --git a/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.ios.kt b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.ios.kt new file mode 100644 index 0000000..85e1b94 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/extension/PlatformContext.ios.kt @@ -0,0 +1,10 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.feature.extension + +import platform.UIKit.UIPasteboard +import ru.bartwell.kick.core.data.PlatformContext + +internal actual fun PlatformContext.copyToClipboard(label: String, text: String) { + UIPasteboard.generalPasteboard.apply { + string = text + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 715e6de..4b769d2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -82,3 +82,8 @@ include(":overlay") project(":overlay").projectDir = file("module/logging/overlay") include(":overlay-stub") project(":overlay-stub").projectDir = file("module/logging/overlay-stub") +// Firebase Cloud Messaging +include(":firebase-cloud-messaging") +project(":firebase-cloud-messaging").projectDir = file("module/firebase/firebase-cloud-messaging") +include(":firebase-cloud-messaging-stub") +project(":firebase-cloud-messaging-stub").projectDir = file("module/firebase/firebase-cloud-messaging-stub") From b3de21c5783a01760146f2ede7110776ed46255e Mon Sep 17 00:00:00 2001 From: Artem Bazhanov Date: Wed, 22 Oct 2025 00:52:25 +0300 Subject: [PATCH 2/5] Integrate Firebase Cloud Messaging in sample app --- sample/shared/build.gradle.kts | 19 ++++++++-- ...rebaseCloudMessagingIntegration.android.kt | 35 +++++++++++++++++++ .../kick/sample/shared/TestDataInitializer.kt | 6 ++++ .../FirebaseCloudMessagingIntegration.ios.kt | 14 ++++++++ .../FirebaseCloudMessagingIntegration.jvm.kt | 13 +++++++ ...irebaseCloudMessagingIntegration.wasmJs.kt | 12 +++++++ 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt create mode 100644 sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.ios.kt create mode 100644 sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.jvm.kt create mode 100644 sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.wasmJs.kt diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 7f5c324..c6c4c8d 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -122,10 +122,24 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.sqldelight.android.driver) implementation(libs.ktor.client.okhttp) + implementation( + if (isRelease) { + projects.firebaseCloudMessagingStub + } else { + projects.firebaseCloudMessaging + } + ) } iosMain.dependencies { implementation(libs.sqldelight.native.driver) implementation(libs.ktor.client.darwin) + implementation( + if (isRelease) { + projects.firebaseCloudMessagingStub + } else { + projects.firebaseCloudMessaging + } + ) } nonWasmMain.dependencies { if (isRelease) { @@ -138,18 +152,17 @@ kotlin { api(libs.room.runtime) implementation(libs.room.driver) } - androidMain.dependencies {} - jvmMain.dependencies {} - iosMain.dependencies {} jvmMain.dependencies { implementation(compose.desktop.currentOs) implementation(libs.sqldelight.driver.sqlite) implementation(libs.ktor.client.cio) + implementation(projects.firebaseCloudMessagingStub) } wasmJsMain.dependencies { implementation(libs.ktor.client.js.wasm) implementation(libs.sqldelight.web.worker.driver.wasm) implementation(libs.sqldelight.async.extensions) + implementation(projects.firebaseCloudMessagingStub) } } } diff --git a/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt b/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt new file mode 100644 index 0000000..f6150da --- /dev/null +++ b/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt @@ -0,0 +1,35 @@ +package ru.bartwell.kick.sample.shared + +import android.content.Context +import ru.bartwell.kick.Kick +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.core.data.get +import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingModule +import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingDelegate +import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging + +actual fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: PlatformContext) { + module(FirebaseCloudMessagingModule(context)) +} + +actual fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) { + val delegate = firebaseCloudMessagingDelegate + ?: createFirebaseDelegate(context.get().applicationContext).also { firebaseCloudMessagingDelegate = it } + delegate?.let { Kick.firebaseCloudMessaging.registerDelegate(it) } +} + +private var firebaseCloudMessagingDelegate: FirebaseCloudMessagingDelegate? = null + +private fun createFirebaseDelegate(context: Context): FirebaseCloudMessagingDelegate? { + return runCatching { + val clazz = Class.forName( + "ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.AndroidFirebaseCloudMessagingDelegate" + ) + val constructor = clazz.getConstructor(Context::class.java, String::class.java, String::class.java) + @Suppress("UNCHECKED_CAST") + constructor.newInstance(context, DEFAULT_CHANNEL_ID, DEFAULT_CHANNEL_NAME) as FirebaseCloudMessagingDelegate + }.getOrNull() +} + +private const val DEFAULT_CHANNEL_ID: String = "kick_firebase_debug" +private const val DEFAULT_CHANNEL_NAME: String = "Firebase Debug Pushes" diff --git a/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt b/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt index 099673d..c24715e 100644 --- a/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt +++ b/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt @@ -93,8 +93,11 @@ class TestDataInitializer(context: PlatformContext) { createLayoutModule(context)?.let { module(it) } module(ControlPanelModule(context, createControlPanelItems())) module(OverlayModule(context)) + registerFirebaseCloudMessagingModule(context) } + setupFirebaseCloudMessagingIntegration(context) + startTestLogging() makeTestHttpRequest() startOverlayUpdater() @@ -177,3 +180,6 @@ class TestDataInitializer(context: PlatformContext) { @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") expect fun createRoomModule(context: PlatformContext): Module? expect fun createLayoutModule(context: PlatformContext): Module? + +expect fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: PlatformContext) +expect fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) diff --git a/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.ios.kt b/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.ios.kt new file mode 100644 index 0000000..92e28b3 --- /dev/null +++ b/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.ios.kt @@ -0,0 +1,14 @@ +package ru.bartwell.kick.sample.shared + +import ru.bartwell.kick.Kick +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingModule + +actual fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: PlatformContext) { + module(FirebaseCloudMessagingModule(context)) +} + +actual fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) { + // The sample application does not bundle Firebase on iOS. + // Host apps should register their own FirebaseCloudMessagingDelegate implementation. +} diff --git a/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.jvm.kt b/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.jvm.kt new file mode 100644 index 0000000..8ffd692 --- /dev/null +++ b/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.jvm.kt @@ -0,0 +1,13 @@ +package ru.bartwell.kick.sample.shared + +import ru.bartwell.kick.Kick +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingModule + +actual fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: PlatformContext) { + module(FirebaseCloudMessagingModule(context)) +} + +actual fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) { + // Firebase Cloud Messaging is not available on the desktop sample target. +} diff --git a/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.wasmJs.kt b/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.wasmJs.kt new file mode 100644 index 0000000..59ab1fa --- /dev/null +++ b/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.wasmJs.kt @@ -0,0 +1,12 @@ +package ru.bartwell.kick.sample.shared + +import ru.bartwell.kick.Kick +import ru.bartwell.kick.core.data.PlatformContext + +actual fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: PlatformContext) { + // Firebase Cloud Messaging UI is not exposed for the web sample target. +} + +actual fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) { + // No Firebase support on the web sample target. +} From 03d83cee334c924a466cb07c0c19e014ad7014a0 Mon Sep 17 00:00:00 2001 From: Artem Bazhanov Date: Fri, 24 Oct 2025 23:45:40 +0300 Subject: [PATCH 3/5] Refine Firebase Cloud Messaging integration and diagnostics --- .../AndroidFirebaseCloudMessagingDelegate.kt | 42 ++++++- .../core/actions/RemoteMessageExtensions.kt | 105 ++++++++++++++++-- .../actions/FirebaseCloudMessagingActions.kt | 6 +- .../DefaultFirebaseCloudMessagingComponent.kt | 47 ++++++-- ...rebaseCloudMessagingIntegration.android.kt | 25 ++--- 5 files changed, 187 insertions(+), 38 deletions(-) diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt index 6ffb48a..438301d 100644 --- a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt @@ -44,7 +44,6 @@ public class AndroidFirebaseCloudMessagingDelegate( } override suspend fun sendLocalNotification(request: FirebaseLocalNotificationRequest): Result = runCatching { - ensureFirebase() val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val channelId = ensureChannel(manager, request.channelId) val contentText = request.body ?: request.data.entries.joinToString { "${it.key}: ${it.value}" } @@ -55,16 +54,27 @@ public class AndroidFirebaseCloudMessagingDelegate( .setStyle(NotificationCompat.BigTextStyle().bigText(contentText)) .setAutoCancel(true) .build() - manager.notify(System.currentTimeMillis().toInt(), notification) + try { + manager.notify(System.currentTimeMillis().toInt(), notification) + } catch (error: SecurityException) { + throw LocalNotificationException( + status = collectNotificationStatus(manager), + cause = error, + ) + } } override suspend fun getNotificationStatus(): Result = runCatching { ensureFirebase() val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + collectNotificationStatus(manager) + } + + private fun collectNotificationStatus(manager: NotificationManager): FirebaseNotificationStatus { val channelInfo = buildChannelStatus(manager) val playServices = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(applicationContext) == ConnectionResult.SUCCESS - FirebaseNotificationStatus( + return FirebaseNotificationStatus( androidChannel = channelInfo, isGooglePlayServicesAvailable = playServices, ) @@ -110,6 +120,32 @@ public class AndroidFirebaseCloudMessagingDelegate( require(isFirebaseInitialized) { "Firebase is not initialised" } } + private class LocalNotificationException( + val status: FirebaseNotificationStatus, + cause: Throwable, + ) : IllegalStateException( + buildString { + append("Failed to display local notification. ") + append(cause.message ?: cause::class.java.simpleName) + append(". Ensure notification permissions are granted.") + status.androidChannel?.let { channel -> + append(" Channel '") + append(channel.id) + append("' is") + append(if (channel.isEnabled == true) " enabled" else " disabled") + channel.isAppNotificationsEnabled?.let { enabled -> + append(", app notifications are ") + append(if (enabled) "allowed" else "blocked") + } + } + status.isGooglePlayServicesAvailable?.let { available -> + append(". Google Play Services: ") + append(if (available) "available" else "unavailable") + } + }, + cause, + ) + private fun Int.toImportanceDescription(): String = when (this) { NotificationManager.IMPORTANCE_NONE -> "none" NotificationManager.IMPORTANCE_MIN -> "min" diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt index 5250360..884e46d 100644 --- a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt @@ -1,9 +1,10 @@ package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions +import androidx.core.app.NotificationCompat import com.google.firebase.messaging.RemoteMessage import ru.bartwell.kick.Kick -import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage +import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging public fun Kick.Companion.logFirebaseMessage(message: RemoteMessage) { firebaseCloudMessaging.log(message.toFirebaseMessage()) @@ -13,11 +14,8 @@ private fun RemoteMessage.toFirebaseMessage(): FirebaseMessage { val notification = notification val channelId = notification?.androidChannelId ?: notification?.channelId val ttl = if (ttl == 0) null else ttl.toLong() - val priority = when (priority) { - RemoteMessage.PRIORITY_HIGH -> "high" - RemoteMessage.PRIORITY_NORMAL -> "normal" - else -> priority.toString() - } + val messagePriority = mapMessagePriority(priority) + val notificationPriority = notification?.notificationPriority?.let(::mapNotificationPriority) return FirebaseMessage( title = notification?.title, body = notification?.body, @@ -28,10 +26,101 @@ private fun RemoteMessage.toFirebaseMessage(): FirebaseMessage { sentTimeMillis = sentTime.takeIf { it != 0L }, collapseKey = collapseKey, channelId = channelId, + category = notification?.clickAction, + badge = notification?.notificationCount?.takeIf { it > 0 }?.toString(), tag = notification?.tag, + sound = notification?.sound, imageUrl = notification?.imageUrl?.toString(), - priority = priority, + priority = notificationPriority ?: messagePriority, ttlSeconds = ttl, - raw = data, + raw = buildRawPayload( + notification = notification, + messagePriority = messagePriority, + notificationPriority = notificationPriority, + ttlSeconds = ttl, + ), ) } + +private fun RemoteMessage.buildRawPayload( + notification: RemoteMessage.Notification?, + messagePriority: String?, + notificationPriority: String?, + ttlSeconds: Long?, +): Map { + val note = notification + return buildMap { + data.forEach { (key, value) -> put("data.$key", value) } + from?.let { put("from", it) } + to?.let { put("to", it) } + messageId?.let { put("messageId", it) } + sentTime.takeIf { it != 0L }?.let { put("sentTime", it.toString()) } + collapseKey?.let { put("collapseKey", it) } + ttlSeconds?.let { put("ttlSeconds", it.toString()) } + messagePriority?.let { put("messagePriority", it) } + notificationPriority?.let { put("notificationPriority", it) } + note?.title?.let { put("notification.title", it) } + note?.titleLocalizationKey?.let { put("notification.titleLocKey", it) } + note?.titleLocalizationArgs?.takeUnless { it.isEmpty() }?.let { + put("notification.titleLocArgs", it.joinToString(prefix = "[", postfix = "]")) + } + note?.body?.let { put("notification.body", it) } + note?.bodyLocalizationKey?.let { put("notification.bodyLocKey", it) } + note?.bodyLocalizationArgs?.takeUnless { it.isEmpty() }?.let { + put("notification.bodyLocArgs", it.joinToString(prefix = "[", postfix = "]")) + } + note?.tag?.let { put("notification.tag", it) } + note?.icon?.let { put("notification.icon", it) } + note?.sound?.let { put("notification.sound", it) } + note?.imageUrl?.toString()?.let { put("notification.imageUrl", it) } + note?.clickAction?.let { put("notification.clickAction", it) } + note?.color?.let { put("notification.color", it) } + note?.ticker?.let { put("notification.ticker", it) } + note?.link?.toString()?.let { put("notification.link", it) } + note?.channelId?.let { put("notification.channelId", it) } + note?.androidChannelId?.let { put("notification.androidChannelId", it) } + note?.visibility?.let { put("notification.visibility", mapNotificationVisibility(it)) } + note?.notificationCount?.let { put("notification.badge", it.toString()) } + if (note?.sticky == true) put("notification.sticky", true.toString()) + if (note?.localOnly == true) put("notification.localOnly", true.toString()) + if (note?.defaultSound == true) put("notification.defaultSound", true.toString()) + if (note?.defaultVibrateSettings == true) { + put("notification.defaultVibrate", true.toString()) + } + if (note?.defaultLightSettings == true) { + put("notification.defaultLights", true.toString()) + } + note?.eventTime?.let { put("notification.eventTime", it.toString()) } + note?.lightSettings?.takeUnless { it.isEmpty() }?.let { + put("notification.lightSettings", it.joinToString(prefix = "[", postfix = "]")) + } + note?.vibrateTimings?.takeUnless { it.isEmpty() }?.let { + put("notification.vibrateTimings", it.joinToString(prefix = "[", postfix = "]")) + } + } +} + +private fun mapMessagePriority(priority: Int): String? = when (priority) { + RemoteMessage.PRIORITY_UNKNOWN -> "unknown" + RemoteMessage.PRIORITY_HIGH -> "high" + RemoteMessage.PRIORITY_NORMAL -> "normal" + else -> if (priority == REMOTE_MESSAGE_PRIORITY_LOW) "low" else priority.toString() +} + +private fun mapNotificationPriority(priority: Int): String = when (priority) { + NotificationCompat.PRIORITY_MAX -> "max" + NotificationCompat.PRIORITY_HIGH -> "high" + NotificationCompat.PRIORITY_DEFAULT -> "default" + NotificationCompat.PRIORITY_LOW -> "low" + NotificationCompat.PRIORITY_MIN -> "min" + else -> priority.toString() +} + +private fun mapNotificationVisibility(visibility: Int): String = when (visibility) { + NotificationCompat.VISIBILITY_PUBLIC -> "public" + NotificationCompat.VISIBILITY_PRIVATE -> "private" + NotificationCompat.VISIBILITY_SECRET -> "secret" + else -> visibility.toString() +} + +private const val REMOTE_MESSAGE_PRIORITY_LOW: Int = -1 diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt index 367632f..4260258 100644 --- a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt @@ -58,10 +58,14 @@ internal object FirebaseCloudMessagingActions { } fun emitMessage(message: FirebaseMessage) { - _messages.update { current -> listOf(message) + current } + _messages.update { current -> + (listOf(message) + current).take(MAX_MESSAGES) + } } fun clearMessages() { _messages.value = emptyList() } } + +private const val MAX_MESSAGES: Int = 200 diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt index d09b07a..40871d3 100644 --- a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt @@ -70,9 +70,10 @@ internal class DefaultFirebaseCloudMessagingComponent( uiScope.launch { val delegate = FirebaseCloudMessagingActions.currentDelegate() if (!ensureDelegate(delegate)) { + val message = if (delegate == null) NO_DELEGATE_MESSAGE else NOT_INITIALISED_MESSAGE updateState { copy( - tokenError = NOT_INITIALISED_MESSAGE, + tokenError = message, isTokenLoading = false, ) } @@ -98,9 +99,10 @@ internal class DefaultFirebaseCloudMessagingComponent( uiScope.launch { val delegate = FirebaseCloudMessagingActions.currentDelegate() if (!ensureDelegate(delegate)) { + val message = if (delegate == null) NO_DELEGATE_MESSAGE else NOT_INITIALISED_MESSAGE updateState { copy( - firebaseIdError = NOT_INITIALISED_MESSAGE, + firebaseIdError = message, isFirebaseIdLoading = false, ) } @@ -138,9 +140,10 @@ internal class DefaultFirebaseCloudMessagingComponent( uiScope.launch { val delegate = FirebaseCloudMessagingActions.currentDelegate() if (!ensureDelegate(delegate)) { + val message = if (delegate == null) NO_DELEGATE_MESSAGE else NOT_INITIALISED_MESSAGE updateState { copy( - statusError = NOT_INITIALISED_MESSAGE, + statusError = message, isStatusLoading = false, ) } @@ -185,11 +188,13 @@ internal class DefaultFirebaseCloudMessagingComponent( override fun sendLocalNotification() { uiScope.launch { val delegate = FirebaseCloudMessagingActions.currentDelegate() - if (!ensureDelegate(delegate)) { - updateLocalNotification { copy(error = NOT_INITIALISED_MESSAGE, isSending = false) } + if (!ensureDelegate(delegate, requireFirebase = false)) { + updateLocalNotification { copy(error = NO_DELEGATE_MESSAGE, isSending = false) } return@launch } + val activeDelegate = delegate ?: return@launch + val current = state.value.localNotification val dataResult = parseData(current.data) if (dataResult == null) { @@ -199,7 +204,7 @@ internal class DefaultFirebaseCloudMessagingComponent( updateLocalNotification { copy(isSending = true, error = null, successMessage = null) } val result = safeCall { - delegate.sendLocalNotification( + activeDelegate.sendLocalNotification( FirebaseLocalNotificationRequest( title = current.title.takeIf { it.isNotBlank() }, body = current.body.takeIf { it.isNotBlank() }, @@ -241,17 +246,38 @@ internal class DefaultFirebaseCloudMessagingComponent( updateState { copy(localNotification = localNotification.block()) } } - private fun ensureDelegate(delegate: FirebaseCloudMessagingDelegate?): Boolean { - val available = delegate?.isFirebaseInitialized == true - if (!available) { + private fun ensureDelegate( + delegate: FirebaseCloudMessagingDelegate?, + requireFirebase: Boolean = true, + ): Boolean { + if (delegate == null) { + updateState { + copy( + isFirebaseAvailable = false, + availabilityMessage = NO_DELEGATE_MESSAGE, + ) + } + return false + } + + val initialised = delegate.isFirebaseInitialized + if (!initialised) { updateState { copy( isFirebaseAvailable = false, availabilityMessage = NOT_INITIALISED_MESSAGE, ) } + return !requireFirebase + } + + updateState { + copy( + isFirebaseAvailable = true, + availabilityMessage = null, + ) } - return available + return true } private fun parseData(raw: String): Map? { @@ -287,5 +313,6 @@ internal class DefaultFirebaseCloudMessagingComponent( companion object { private const val NOT_INITIALISED_MESSAGE: String = "Firebase is not initialised in the host application" + private const val NO_DELEGATE_MESSAGE: String = "Firebase Cloud Messaging delegate is not registered" } } diff --git a/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt b/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt index f6150da..4292eb5 100644 --- a/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt +++ b/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt @@ -1,11 +1,10 @@ package ru.bartwell.kick.sample.shared -import android.content.Context import ru.bartwell.kick.Kick import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.core.data.get import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingModule -import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingDelegate +import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.AndroidFirebaseCloudMessagingDelegate import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging actual fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: PlatformContext) { @@ -13,23 +12,17 @@ actual fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: Plat } actual fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) { + val appContext = context.get().applicationContext val delegate = firebaseCloudMessagingDelegate - ?: createFirebaseDelegate(context.get().applicationContext).also { firebaseCloudMessagingDelegate = it } - delegate?.let { Kick.firebaseCloudMessaging.registerDelegate(it) } + ?: AndroidFirebaseCloudMessagingDelegate( + appContext, + DEFAULT_CHANNEL_ID, + DEFAULT_CHANNEL_NAME, + ).also { firebaseCloudMessagingDelegate = it } + Kick.firebaseCloudMessaging.registerDelegate(delegate) } -private var firebaseCloudMessagingDelegate: FirebaseCloudMessagingDelegate? = null - -private fun createFirebaseDelegate(context: Context): FirebaseCloudMessagingDelegate? { - return runCatching { - val clazz = Class.forName( - "ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.AndroidFirebaseCloudMessagingDelegate" - ) - val constructor = clazz.getConstructor(Context::class.java, String::class.java, String::class.java) - @Suppress("UNCHECKED_CAST") - constructor.newInstance(context, DEFAULT_CHANNEL_ID, DEFAULT_CHANNEL_NAME) as FirebaseCloudMessagingDelegate - }.getOrNull() -} +private var firebaseCloudMessagingDelegate: AndroidFirebaseCloudMessagingDelegate? = null private const val DEFAULT_CHANNEL_ID: String = "kick_firebase_debug" private const val DEFAULT_CHANNEL_NAME: String = "Firebase Debug Pushes" From f930c69c726c257ffe157d3275bd54d25cbdc43e Mon Sep 17 00:00:00 2001 From: Artem Bazhanov Date: Sun, 26 Oct 2025 15:10:21 +0300 Subject: [PATCH 4/5] Inline Firebase Cloud Messaging platform integration --- .../build.gradle.kts | 1 + .../firebase/cloudmessaging/KickExtensions.kt | 8 + .../cloudmessaging/RemoteMessageExtensions.kt | 7 + .../FirebaseCloudMessagingAccessor.kt | 4 - .../actions/FirebaseCloudMessagingDelegate.kt | 12 -- .../AndroidFirebaseCloudMessagingDelegate.kt | 174 ---------------- .../FirebasePlatformActions.android.kt | 187 ++++++++++++++++++ .../core/actions/RemoteMessageExtensions.kt | 5 + .../FirebaseCloudMessagingAccessor.kt | 13 -- .../FirebaseCloudMessagingModule.kt | 5 + .../actions/FirebaseCloudMessagingActions.kt | 70 +++---- .../DefaultFirebaseCloudMessagingComponent.kt | 165 +++++++--------- .../actions/FirebasePlatformActions.ios.kt | 28 +++ sample/android/build.gradle.kts | 1 + sample/android/src/main/AndroidManifest.xml | 10 +- .../android/KickFirebaseMessagingService.kt | 13 ++ sample/ios/iosSample/AppDelegate.swift | 9 + sample/shared/build.gradle.kts | 8 +- ...rebaseCloudMessagingIntegration.android.kt | 20 -- .../kick/sample/shared/TestDataInitializer.kt | 3 - .../FirebaseCloudMessagingIntegration.ios.kt | 5 - .../FirebaseCloudMessagingIntegration.jvm.kt | 4 - ...irebaseCloudMessagingIntegration.wasmJs.kt | 3 - 23 files changed, 369 insertions(+), 386 deletions(-) create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/KickExtensions.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/RemoteMessageExtensions.kt delete mode 100644 module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingDelegate.kt delete mode 100644 module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.android.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.ios.kt create mode 100644 sample/android/src/main/java/ru/bartwell/kick/sample/android/KickFirebaseMessagingService.kt diff --git a/module/firebase/firebase-cloud-messaging-stub/build.gradle.kts b/module/firebase/firebase-cloud-messaging-stub/build.gradle.kts index e6e8f54..8a28005 100644 --- a/module/firebase/firebase-cloud-messaging-stub/build.gradle.kts +++ b/module/firebase/firebase-cloud-messaging-stub/build.gradle.kts @@ -60,6 +60,7 @@ kotlin { } androidMain.dependencies { implementation(libs.androidx.activity.compose) + implementation(libs.firebase.messaging) } jvmMain.dependencies { implementation(compose.desktop.currentOs) diff --git a/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/KickExtensions.kt b/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/KickExtensions.kt new file mode 100644 index 0000000..c8ffa60 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/KickExtensions.kt @@ -0,0 +1,8 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging + +import com.google.firebase.messaging.RemoteMessage +import ru.bartwell.kick.Kick + +public fun Kick.Companion.logFirebaseMessage(message: RemoteMessage) { + // No-op in the stub artifact. +} diff --git a/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/RemoteMessageExtensions.kt b/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/RemoteMessageExtensions.kt new file mode 100644 index 0000000..840c1cb --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/RemoteMessageExtensions.kt @@ -0,0 +1,7 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging + +import com.google.firebase.messaging.RemoteMessage + +public fun FirebaseCloudMessagingAccessor.handleFcm(message: RemoteMessage) { + // No-op in the stub artifact. +} diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt index 83a67d7..143772d 100644 --- a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt @@ -1,11 +1,7 @@ package ru.bartwell.kick.module.firebase.cloudmessaging -import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingDelegate import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage public class FirebaseCloudMessagingAccessor internal constructor() { - public fun registerDelegate(delegate: FirebaseCloudMessagingDelegate) {} - public fun unregisterDelegate(delegate: FirebaseCloudMessagingDelegate? = null) {} public fun log(message: FirebaseMessage) {} - public fun clearLoggedMessages() {} } diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingDelegate.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingDelegate.kt deleted file mode 100644 index 1da8d9c..0000000 --- a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingDelegate.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions - -import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest -import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus - -public interface FirebaseCloudMessagingDelegate { - public val isFirebaseInitialized: Boolean - public suspend fun getRegistrationToken(forceRefresh: Boolean): Result - public suspend fun getFirebaseInstallationId(): Result - public suspend fun sendLocalNotification(request: FirebaseLocalNotificationRequest): Result - public suspend fun getNotificationStatus(): Result -} diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt deleted file mode 100644 index 438301d..0000000 --- a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/AndroidFirebaseCloudMessagingDelegate.kt +++ /dev/null @@ -1,174 +0,0 @@ -package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.os.Build -import androidx.core.app.NotificationCompat -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailability -import com.google.android.gms.tasks.Task -import com.google.firebase.FirebaseApp -import com.google.firebase.installations.FirebaseInstallations -import com.google.firebase.messaging.FirebaseMessaging -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlinx.coroutines.suspendCancellableCoroutine -import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.AndroidNotificationChannelStatus -import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest -import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus - -public class AndroidFirebaseCloudMessagingDelegate( - private val context: Context, - private val fallbackChannelId: String = DEFAULT_CHANNEL_ID, - private val fallbackChannelName: String = DEFAULT_CHANNEL_NAME, -) : FirebaseCloudMessagingDelegate { - - private val applicationContext: Context = context.applicationContext - - override val isFirebaseInitialized: Boolean - get() = FirebaseApp.getApps(applicationContext).isNotEmpty() - - override suspend fun getRegistrationToken(forceRefresh: Boolean): Result = runCatching { - ensureFirebase() - val messaging = FirebaseMessaging.getInstance() - if (forceRefresh) { - messaging.deleteToken().await() - } - messaging.token.await() - } - - override suspend fun getFirebaseInstallationId(): Result = runCatching { - ensureFirebase() - FirebaseInstallations.getInstance().id.await() - } - - override suspend fun sendLocalNotification(request: FirebaseLocalNotificationRequest): Result = runCatching { - val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val channelId = ensureChannel(manager, request.channelId) - val contentText = request.body ?: request.data.entries.joinToString { "${it.key}: ${it.value}" } - val notification = NotificationCompat.Builder(applicationContext, channelId) - .setSmallIcon(android.R.drawable.ic_dialog_info) - .setContentTitle(request.title ?: "Firebase push") - .setContentText(contentText) - .setStyle(NotificationCompat.BigTextStyle().bigText(contentText)) - .setAutoCancel(true) - .build() - try { - manager.notify(System.currentTimeMillis().toInt(), notification) - } catch (error: SecurityException) { - throw LocalNotificationException( - status = collectNotificationStatus(manager), - cause = error, - ) - } - } - - override suspend fun getNotificationStatus(): Result = runCatching { - ensureFirebase() - val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - collectNotificationStatus(manager) - } - - private fun collectNotificationStatus(manager: NotificationManager): FirebaseNotificationStatus { - val channelInfo = buildChannelStatus(manager) - val playServices = GoogleApiAvailability.getInstance() - .isGooglePlayServicesAvailable(applicationContext) == ConnectionResult.SUCCESS - return FirebaseNotificationStatus( - androidChannel = channelInfo, - isGooglePlayServicesAvailable = playServices, - ) - } - - private fun buildChannelStatus(manager: NotificationManager): AndroidNotificationChannelStatus { - val notificationsEnabled = manager.areNotificationsEnabled() - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = manager.getNotificationChannel(fallbackChannelId) - AndroidNotificationChannelStatus( - id = channel?.id ?: fallbackChannelId, - name = channel?.name?.toString(), - description = channel?.description, - importance = channel?.importance?.toImportanceDescription(), - isEnabled = channel != null && notificationsEnabled, - isAppNotificationsEnabled = notificationsEnabled, - ) - } else { - AndroidNotificationChannelStatus( - id = fallbackChannelId, - name = null, - description = null, - importance = null, - isEnabled = notificationsEnabled, - isAppNotificationsEnabled = notificationsEnabled, - ) - } - } - - private fun ensureChannel(manager: NotificationManager, requested: String?): String { - val id = requested ?: fallbackChannelId - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val existing = manager.getNotificationChannel(id) - if (existing == null) { - val channel = NotificationChannel(id, fallbackChannelName, NotificationManager.IMPORTANCE_DEFAULT) - manager.createNotificationChannel(channel) - } - } - return id - } - - private fun ensureFirebase() { - require(isFirebaseInitialized) { "Firebase is not initialised" } - } - - private class LocalNotificationException( - val status: FirebaseNotificationStatus, - cause: Throwable, - ) : IllegalStateException( - buildString { - append("Failed to display local notification. ") - append(cause.message ?: cause::class.java.simpleName) - append(". Ensure notification permissions are granted.") - status.androidChannel?.let { channel -> - append(" Channel '") - append(channel.id) - append("' is") - append(if (channel.isEnabled == true) " enabled" else " disabled") - channel.isAppNotificationsEnabled?.let { enabled -> - append(", app notifications are ") - append(if (enabled) "allowed" else "blocked") - } - } - status.isGooglePlayServicesAvailable?.let { available -> - append(". Google Play Services: ") - append(if (available) "available" else "unavailable") - } - }, - cause, - ) - - private fun Int.toImportanceDescription(): String = when (this) { - NotificationManager.IMPORTANCE_NONE -> "none" - NotificationManager.IMPORTANCE_MIN -> "min" - NotificationManager.IMPORTANCE_LOW -> "low" - NotificationManager.IMPORTANCE_DEFAULT -> "default" - NotificationManager.IMPORTANCE_HIGH -> "high" - NotificationManager.IMPORTANCE_MAX -> "max" - else -> toString() - } - - private suspend fun Task.await(): T = suspendCancellableCoroutine { continuation -> - addOnCompleteListener { task -> - if (task.isSuccessful) { - continuation.resume(task.result) - } else { - val exception = task.exception ?: IllegalStateException("Firebase task failed") - continuation.resumeWithException(exception) - } - } - } - - private companion object { - private const val DEFAULT_CHANNEL_ID: String = "kick_firebase_debug" - private const val DEFAULT_CHANNEL_NAME: String = "Firebase Debug Pushes" - } -} diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.android.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.android.kt new file mode 100644 index 0000000..67df2d0 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.android.kt @@ -0,0 +1,187 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.tasks.Task +import com.google.firebase.FirebaseApp +import com.google.firebase.installations.FirebaseInstallations +import com.google.firebase.messaging.FirebaseMessaging +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.core.data.get +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.AndroidNotificationChannelStatus +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus + +private var applicationContext: Context? = null + +internal actual fun platformInitialize(context: PlatformContext) { + applicationContext = context.get().applicationContext +} + +internal actual fun platformIsFirebaseInitialized(): Boolean { + val context = applicationContext ?: return false + return FirebaseApp.getApps(context).isNotEmpty() +} + +internal actual suspend fun platformGetRegistrationToken( + forceRefresh: Boolean, +): Result = runCatching { + val context = requireContext() + ensureFirebaseInitialized(context) + val messaging = FirebaseMessaging.getInstance() + if (forceRefresh) { + messaging.deleteToken().await() + } + messaging.token.await() +} + +internal actual suspend fun platformGetFirebaseInstallationId(): Result = runCatching { + val context = requireContext() + ensureFirebaseInitialized(context) + FirebaseInstallations.getInstance().id.await() +} + +internal actual suspend fun platformSendLocalNotification( + request: FirebaseLocalNotificationRequest, +): Result = runCatching { + val context = requireContext() + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelId = ensureChannel(manager, request.channelId) + val contentText = request.body ?: request.data.entries.joinToString { "${'$'}{it.key}: ${'$'}{it.value}" } + val notification = NotificationCompat.Builder(context, channelId) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(request.title ?: "Firebase push") + .setContentText(contentText) + .setStyle(NotificationCompat.BigTextStyle().bigText(contentText)) + .setAutoCancel(true) + .build() + try { + manager.notify(System.currentTimeMillis().toInt(), notification) + } catch (error: SecurityException) { + throw LocalNotificationException( + status = collectNotificationStatus(context, manager), + cause = error, + ) + } +} + +internal actual suspend fun platformGetNotificationStatus(): Result = runCatching { + val context = requireContext() + ensureFirebaseInitialized(context) + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + collectNotificationStatus(context, manager) +} + +private fun collectNotificationStatus( + context: Context, + manager: NotificationManager, +): FirebaseNotificationStatus { + val channelInfo = buildChannelStatus(manager) + val playServices = GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS + return FirebaseNotificationStatus( + androidChannel = channelInfo, + isGooglePlayServicesAvailable = playServices, + ) +} + +private fun buildChannelStatus(manager: NotificationManager): AndroidNotificationChannelStatus { + val notificationsEnabled = manager.areNotificationsEnabled() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = manager.getNotificationChannel(DEFAULT_CHANNEL_ID) + AndroidNotificationChannelStatus( + id = channel?.id ?: DEFAULT_CHANNEL_ID, + name = channel?.name?.toString(), + description = channel?.description, + importance = channel?.importance?.toImportanceDescription(), + isEnabled = channel != null && notificationsEnabled, + isAppNotificationsEnabled = notificationsEnabled, + ) + } else { + AndroidNotificationChannelStatus( + id = DEFAULT_CHANNEL_ID, + name = null, + description = null, + importance = null, + isEnabled = notificationsEnabled, + isAppNotificationsEnabled = notificationsEnabled, + ) + } +} + +private fun ensureChannel(manager: NotificationManager, requested: String?): String { + val id = requested ?: DEFAULT_CHANNEL_ID + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val existing = manager.getNotificationChannel(id) + if (existing == null) { + val channel = NotificationChannel(id, DEFAULT_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT) + manager.createNotificationChannel(channel) + } + } + return id +} + +private fun ensureFirebaseInitialized(context: Context) { + require(FirebaseApp.getApps(context).isNotEmpty()) { "Firebase is not initialised" } +} + +private fun requireContext(): Context = applicationContext + ?: throw IllegalStateException("Firebase Cloud Messaging module is not initialised with Android context") + +private class LocalNotificationException( + val status: FirebaseNotificationStatus, + cause: Throwable, +) : IllegalStateException( + buildString { + append("Failed to display local notification. ") + append(cause.message ?: cause::class.java.simpleName) + append(". Ensure notification permissions are granted.") + status.androidChannel?.let { channel -> + append(" Channel '") + append(channel.id) + append("' is") + append(if (channel.isEnabled == true) " enabled" else " disabled") + channel.isAppNotificationsEnabled?.let { enabled -> + append(", app notifications are ") + append(if (enabled) "allowed" else "blocked") + } + } + status.isGooglePlayServicesAvailable?.let { available -> + append(". Google Play Services: ") + append(if (available) "available" else "unavailable") + } + }, + cause, +) + +private fun Int.toImportanceDescription(): String = when (this) { + NotificationManager.IMPORTANCE_NONE -> "none" + NotificationManager.IMPORTANCE_MIN -> "min" + NotificationManager.IMPORTANCE_LOW -> "low" + NotificationManager.IMPORTANCE_DEFAULT -> "default" + NotificationManager.IMPORTANCE_HIGH -> "high" + NotificationManager.IMPORTANCE_MAX -> "max" + else -> toString() +} + +private suspend fun Task.await(): T = suspendCancellableCoroutine { continuation -> + addOnCompleteListener { task -> + if (task.isSuccessful) { + continuation.resume(task.result) + } else { + val exception = task.exception ?: IllegalStateException("Firebase task failed") + continuation.resumeWithException(exception) + } + } +} + +private const val DEFAULT_CHANNEL_ID: String = "kick_firebase_debug" +private const val DEFAULT_CHANNEL_NAME: String = "Firebase Debug Pushes" diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt index 884e46d..1a867f0 100644 --- a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt @@ -3,6 +3,7 @@ package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions import androidx.core.app.NotificationCompat import com.google.firebase.messaging.RemoteMessage import ru.bartwell.kick.Kick +import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingAccessor import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging @@ -10,6 +11,10 @@ public fun Kick.Companion.logFirebaseMessage(message: RemoteMessage) { firebaseCloudMessaging.log(message.toFirebaseMessage()) } +public fun FirebaseCloudMessagingAccessor.handleFcm(message: RemoteMessage) { + log(message.toFirebaseMessage()) +} + private fun RemoteMessage.toFirebaseMessage(): FirebaseMessage { val notification = notification val channelId = notification?.androidChannelId ?: notification?.channelId diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt index c210ba2..17c801f 100644 --- a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt @@ -1,23 +1,10 @@ package ru.bartwell.kick.module.firebase.cloudmessaging import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingActions -import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingDelegate import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage public class FirebaseCloudMessagingAccessor internal constructor() { - public fun registerDelegate(delegate: FirebaseCloudMessagingDelegate) { - FirebaseCloudMessagingActions.registerDelegate(delegate) - } - - public fun unregisterDelegate(delegate: FirebaseCloudMessagingDelegate? = null) { - FirebaseCloudMessagingActions.unregisterDelegate(delegate ?: FirebaseCloudMessagingActions.currentDelegate()) - } - public fun log(message: FirebaseMessage) { FirebaseCloudMessagingActions.emitMessage(message) } - - public fun clearLoggedMessages() { - FirebaseCloudMessagingActions.clearMessages() - } } diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt index bff29df..63bb08c 100644 --- a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt @@ -12,6 +12,7 @@ import ru.bartwell.kick.core.component.Config import ru.bartwell.kick.core.data.Module import ru.bartwell.kick.core.data.ModuleDescription import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingActions import ru.bartwell.kick.module.firebase.cloudmessaging.core.component.child.FirebaseCloudMessagingChild import ru.bartwell.kick.module.firebase.cloudmessaging.core.component.config.FirebaseCloudMessagingConfig import ru.bartwell.kick.module.firebase.cloudmessaging.feature.presentation.DefaultFirebaseCloudMessagingComponent @@ -26,6 +27,10 @@ public class FirebaseCloudMessagingModule( override val description: ModuleDescription = ModuleDescription.FIREBASE_CLOUD_MESSAGING override val startConfig: Config = FirebaseCloudMessagingConfig + init { + FirebaseCloudMessagingActions.initialize(context) + } + override fun getComponent( componentContext: ComponentContext, nav: StackNavigation, diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt index 4260258..5fb951f 100644 --- a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt @@ -4,58 +4,32 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus -/** - * API that the host application needs to implement so the module can talk back to Firebase SDK. - */ -public interface FirebaseCloudMessagingDelegate { - /** - * Indicates that Firebase SDK was fully initialised and can be queried safely. - */ - public val isFirebaseInitialized: Boolean - - /** - * Returns current FCM registration token. - */ - public suspend fun getRegistrationToken(forceRefresh: Boolean): Result - - /** - * Returns Firebase installation identifier used by the current device. - */ - public suspend fun getFirebaseInstallationId(): Result - - /** - * Triggers a local notification emulating an incoming FCM push. - */ - public suspend fun sendLocalNotification(request: FirebaseLocalNotificationRequest): Result - - /** - * Collects platform specific notification status information. - */ - public suspend fun getNotificationStatus(): Result -} - internal object FirebaseCloudMessagingActions { private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages.asStateFlow() - private val _delegate = MutableStateFlow(null) - val delegateFlow: StateFlow = _delegate.asStateFlow() + fun initialize(context: PlatformContext) { + platformInitialize(context) + } - fun currentDelegate(): FirebaseCloudMessagingDelegate? = _delegate.value + fun isFirebaseInitialized(): Boolean = platformIsFirebaseInitialized() - fun registerDelegate(delegate: FirebaseCloudMessagingDelegate) { - _delegate.value = delegate - } + suspend fun getRegistrationToken(forceRefresh: Boolean): Result = + platformGetRegistrationToken(forceRefresh) - fun unregisterDelegate(delegate: FirebaseCloudMessagingDelegate?) { - if (_delegate.value == delegate || delegate == null) { - _delegate.value = null - } - } + suspend fun getFirebaseInstallationId(): Result = + platformGetFirebaseInstallationId() + + suspend fun sendLocalNotification(request: FirebaseLocalNotificationRequest): Result = + platformSendLocalNotification(request) + + suspend fun getNotificationStatus(): Result = + platformGetNotificationStatus() fun emitMessage(message: FirebaseMessage) { _messages.update { current -> @@ -69,3 +43,17 @@ internal object FirebaseCloudMessagingActions { } private const val MAX_MESSAGES: Int = 200 + +internal expect fun platformIsFirebaseInitialized(): Boolean + +internal expect fun platformInitialize(context: PlatformContext) + +internal expect suspend fun platformGetRegistrationToken(forceRefresh: Boolean): Result + +internal expect suspend fun platformGetFirebaseInstallationId(): Result + +internal expect suspend fun platformSendLocalNotification( + request: FirebaseLocalNotificationRequest, +): Result + +internal expect suspend fun platformGetNotificationStatus(): Result diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt index 40871d3..2618b1b 100644 --- a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt @@ -11,7 +11,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingActions -import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.FirebaseCloudMessagingDelegate import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest import ru.bartwell.kick.module.firebase.cloudmessaging.feature.extension.copyToClipboard @@ -36,30 +35,13 @@ internal class DefaultFirebaseCloudMessagingComponent( } .launchIn(uiScope) - FirebaseCloudMessagingActions.delegateFlow - .onEach { delegate -> - val isAvailable = delegate?.isFirebaseInitialized == true - updateState { - copy( - isFirebaseAvailable = isAvailable, - availabilityMessage = if (isAvailable) null else NOT_INITIALISED_MESSAGE, - ) - } - if (isAvailable) { - refreshToken(forceRefresh = false) - refreshFirebaseId() - refreshStatus() - } else { - updateState { - copy( - token = null, - firebaseId = null, - status = null, - ) - } - } - } - .launchIn(uiScope) + if (ensureFirebaseAvailability(requireFirebase = false)) { + refreshToken(forceRefresh = false) + refreshFirebaseId() + refreshStatus() + } else { + clearRemoteState() + } } override fun onBackPressed() { @@ -68,20 +50,20 @@ internal class DefaultFirebaseCloudMessagingComponent( override fun refreshToken(forceRefresh: Boolean) { uiScope.launch { - val delegate = FirebaseCloudMessagingActions.currentDelegate() - if (!ensureDelegate(delegate)) { - val message = if (delegate == null) NO_DELEGATE_MESSAGE else NOT_INITIALISED_MESSAGE - updateState { - copy( - tokenError = message, - isTokenLoading = false, - ) + if (!ensureFirebaseAvailability(requireFirebase = true) { message -> + updateState { + copy( + tokenError = message, + isTokenLoading = false, + ) + } } + ) { return@launch } updateState { copy(isTokenLoading = true, tokenError = null) } - val result = safeCall { delegate.getRegistrationToken(forceRefresh) } + val result = FirebaseCloudMessagingActions.getRegistrationToken(forceRefresh) updateState { current -> result.fold( onSuccess = { token -> @@ -97,20 +79,20 @@ internal class DefaultFirebaseCloudMessagingComponent( override fun refreshFirebaseId() { uiScope.launch { - val delegate = FirebaseCloudMessagingActions.currentDelegate() - if (!ensureDelegate(delegate)) { - val message = if (delegate == null) NO_DELEGATE_MESSAGE else NOT_INITIALISED_MESSAGE - updateState { - copy( - firebaseIdError = message, - isFirebaseIdLoading = false, - ) + if (!ensureFirebaseAvailability(requireFirebase = true) { message -> + updateState { + copy( + firebaseIdError = message, + isFirebaseIdLoading = false, + ) + } } + ) { return@launch } updateState { copy(isFirebaseIdLoading = true, firebaseIdError = null) } - val result = safeCall { delegate.getFirebaseInstallationId() } + val result = FirebaseCloudMessagingActions.getFirebaseInstallationId() updateState { current -> result.fold( onSuccess = { id -> @@ -138,20 +120,20 @@ internal class DefaultFirebaseCloudMessagingComponent( override fun refreshStatus() { uiScope.launch { - val delegate = FirebaseCloudMessagingActions.currentDelegate() - if (!ensureDelegate(delegate)) { - val message = if (delegate == null) NO_DELEGATE_MESSAGE else NOT_INITIALISED_MESSAGE - updateState { - copy( - statusError = message, - isStatusLoading = false, - ) + if (!ensureFirebaseAvailability(requireFirebase = true) { message -> + updateState { + copy( + statusError = message, + isStatusLoading = false, + ) + } } + ) { return@launch } updateState { copy(isStatusLoading = true, statusError = null) } - val result = safeCall { delegate.getNotificationStatus() } + val result = FirebaseCloudMessagingActions.getNotificationStatus() updateState { current -> result.fold( onSuccess = { status -> @@ -187,13 +169,7 @@ internal class DefaultFirebaseCloudMessagingComponent( override fun sendLocalNotification() { uiScope.launch { - val delegate = FirebaseCloudMessagingActions.currentDelegate() - if (!ensureDelegate(delegate, requireFirebase = false)) { - updateLocalNotification { copy(error = NO_DELEGATE_MESSAGE, isSending = false) } - return@launch - } - - val activeDelegate = delegate ?: return@launch + ensureFirebaseAvailability(requireFirebase = false) val current = state.value.localNotification val dataResult = parseData(current.data) @@ -203,16 +179,14 @@ internal class DefaultFirebaseCloudMessagingComponent( } updateLocalNotification { copy(isSending = true, error = null, successMessage = null) } - val result = safeCall { - activeDelegate.sendLocalNotification( - FirebaseLocalNotificationRequest( - title = current.title.takeIf { it.isNotBlank() }, - body = current.body.takeIf { it.isNotBlank() }, - data = dataResult, - channelId = current.channelId.takeIf { it.isNotBlank() }, - ) + val result = FirebaseCloudMessagingActions.sendLocalNotification( + FirebaseLocalNotificationRequest( + title = current.title.takeIf { it.isNotBlank() }, + body = current.body.takeIf { it.isNotBlank() }, + data = dataResult, + channelId = current.channelId.takeIf { it.isNotBlank() }, ) - } + ) updateLocalNotification { local -> result.fold( onSuccess = { @@ -246,38 +220,38 @@ internal class DefaultFirebaseCloudMessagingComponent( updateState { copy(localNotification = localNotification.block()) } } - private fun ensureDelegate( - delegate: FirebaseCloudMessagingDelegate?, - requireFirebase: Boolean = true, + private fun ensureFirebaseAvailability( + requireFirebase: Boolean, + onUnavailable: (String) -> Unit = {}, ): Boolean { - if (delegate == null) { - updateState { - copy( - isFirebaseAvailable = false, - availabilityMessage = NO_DELEGATE_MESSAGE, - ) - } - return false + val available = FirebaseCloudMessagingActions.isFirebaseInitialized() + val message = if (available) null else NOT_INITIALISED_MESSAGE + updateState { + copy( + isFirebaseAvailable = available, + availabilityMessage = message, + token = token.takeIf { available }, + firebaseId = firebaseId.takeIf { available }, + status = status.takeIf { available }, + ) } - - val initialised = delegate.isFirebaseInitialized - if (!initialised) { - updateState { - copy( - isFirebaseAvailable = false, - availabilityMessage = NOT_INITIALISED_MESSAGE, - ) - } - return !requireFirebase + if (!available && requireFirebase) { + onUnavailable(NOT_INITIALISED_MESSAGE) } + return available || !requireFirebase + } + private fun clearRemoteState() { updateState { copy( - isFirebaseAvailable = true, - availabilityMessage = null, + token = null, + firebaseId = null, + status = null, + tokenError = null, + firebaseIdError = null, + statusError = null, ) } - return true } private fun parseData(raw: String): Map? { @@ -300,12 +274,6 @@ internal class DefaultFirebaseCloudMessagingComponent( } } - private suspend fun safeCall(block: suspend () -> Result): Result = try { - block() - } catch (error: Throwable) { - Result.failure(error) - } - private fun LocalNotificationState.clearFeedback(): LocalNotificationState = copy( error = null, successMessage = null, @@ -313,6 +281,5 @@ internal class DefaultFirebaseCloudMessagingComponent( companion object { private const val NOT_INITIALISED_MESSAGE: String = "Firebase is not initialised in the host application" - private const val NO_DELEGATE_MESSAGE: String = "Firebase Cloud Messaging delegate is not registered" } } diff --git a/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.ios.kt b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.ios.kt new file mode 100644 index 0000000..c7d773e --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.ios.kt @@ -0,0 +1,28 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions + +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus + +private const val IOS_UNAVAILABLE_MESSAGE: String = + "Firebase Cloud Messaging runtime is not included in this build" + +internal actual fun platformInitialize(context: PlatformContext) { + // Nothing to initialise on iOS for the sample implementation. +} + +internal actual fun platformIsFirebaseInitialized(): Boolean = false + +internal actual suspend fun platformGetRegistrationToken( + forceRefresh: Boolean, +): Result = Result.failure(IllegalStateException(IOS_UNAVAILABLE_MESSAGE)) + +internal actual suspend fun platformGetFirebaseInstallationId(): Result = + Result.failure(IllegalStateException(IOS_UNAVAILABLE_MESSAGE)) + +internal actual suspend fun platformSendLocalNotification( + request: FirebaseLocalNotificationRequest, +): Result = Result.failure(IllegalStateException(IOS_UNAVAILABLE_MESSAGE)) + +internal actual suspend fun platformGetNotificationStatus(): Result = + Result.failure(IllegalStateException(IOS_UNAVAILABLE_MESSAGE)) diff --git a/sample/android/build.gradle.kts b/sample/android/build.gradle.kts index 5385ed6..f3a9675 100644 --- a/sample/android/build.gradle.kts +++ b/sample/android/build.gradle.kts @@ -42,4 +42,5 @@ android { dependencies { implementation(projects.shared) implementation(libs.androidx.activity.compose) + implementation(libs.firebase.messaging) } \ No newline at end of file diff --git a/sample/android/src/main/AndroidManifest.xml b/sample/android/src/main/AndroidManifest.xml index 3aeb31e..60e841c 100644 --- a/sample/android/src/main/AndroidManifest.xml +++ b/sample/android/src/main/AndroidManifest.xml @@ -17,5 +17,13 @@ + + + + + + - + diff --git a/sample/android/src/main/java/ru/bartwell/kick/sample/android/KickFirebaseMessagingService.kt b/sample/android/src/main/java/ru/bartwell/kick/sample/android/KickFirebaseMessagingService.kt new file mode 100644 index 0000000..33e83dc --- /dev/null +++ b/sample/android/src/main/java/ru/bartwell/kick/sample/android/KickFirebaseMessagingService.kt @@ -0,0 +1,13 @@ +package ru.bartwell.kick.sample.android + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import ru.bartwell.kick.Kick +import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging +import ru.bartwell.kick.module.firebase.cloudmessaging.handleFcm + +class KickFirebaseMessagingService : FirebaseMessagingService() { + override fun onMessageReceived(message: RemoteMessage) { + Kick.firebaseCloudMessaging.handleFcm(message) + } +} diff --git a/sample/ios/iosSample/AppDelegate.swift b/sample/ios/iosSample/AppDelegate.swift index fd302ba..0c4d5d3 100644 --- a/sample/ios/iosSample/AppDelegate.swift +++ b/sample/ios/iosSample/AppDelegate.swift @@ -9,4 +9,13 @@ class AppDelegate: NSObject, UIApplicationDelegate { ) -> UISceneConfiguration { return ShortcutActionHandler.shared.getConfiguration(session: connectingSceneSession) } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + KickCompanion().logFirebaseMessage(userInfo: userInfo) + completionHandler(.noData) + } } diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index c6c4c8d..f7b08c9 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -122,13 +122,7 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.sqldelight.android.driver) implementation(libs.ktor.client.okhttp) - implementation( - if (isRelease) { - projects.firebaseCloudMessagingStub - } else { - projects.firebaseCloudMessaging - } - ) + implementation(projects.firebaseCloudMessaging) } iosMain.dependencies { implementation(libs.sqldelight.native.driver) diff --git a/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt b/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt index 4292eb5..8bc52bd 100644 --- a/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt +++ b/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt @@ -1,28 +1,8 @@ package ru.bartwell.kick.sample.shared -import ru.bartwell.kick.Kick import ru.bartwell.kick.core.data.PlatformContext -import ru.bartwell.kick.core.data.get import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingModule -import ru.bartwell.kick.module.firebase.cloudmessaging.core.actions.AndroidFirebaseCloudMessagingDelegate -import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging actual fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: PlatformContext) { module(FirebaseCloudMessagingModule(context)) } - -actual fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) { - val appContext = context.get().applicationContext - val delegate = firebaseCloudMessagingDelegate - ?: AndroidFirebaseCloudMessagingDelegate( - appContext, - DEFAULT_CHANNEL_ID, - DEFAULT_CHANNEL_NAME, - ).also { firebaseCloudMessagingDelegate = it } - Kick.firebaseCloudMessaging.registerDelegate(delegate) -} - -private var firebaseCloudMessagingDelegate: AndroidFirebaseCloudMessagingDelegate? = null - -private const val DEFAULT_CHANNEL_ID: String = "kick_firebase_debug" -private const val DEFAULT_CHANNEL_NAME: String = "Firebase Debug Pushes" diff --git a/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt b/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt index c24715e..b4ac394 100644 --- a/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt +++ b/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt @@ -96,8 +96,6 @@ class TestDataInitializer(context: PlatformContext) { registerFirebaseCloudMessagingModule(context) } - setupFirebaseCloudMessagingIntegration(context) - startTestLogging() makeTestHttpRequest() startOverlayUpdater() @@ -182,4 +180,3 @@ expect fun createRoomModule(context: PlatformContext): Module? expect fun createLayoutModule(context: PlatformContext): Module? expect fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: PlatformContext) -expect fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) diff --git a/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.ios.kt b/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.ios.kt index 92e28b3..ea7eea3 100644 --- a/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.ios.kt +++ b/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.ios.kt @@ -7,8 +7,3 @@ import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingMod actual fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: PlatformContext) { module(FirebaseCloudMessagingModule(context)) } - -actual fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) { - // The sample application does not bundle Firebase on iOS. - // Host apps should register their own FirebaseCloudMessagingDelegate implementation. -} diff --git a/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.jvm.kt b/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.jvm.kt index 8ffd692..ea7eea3 100644 --- a/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.jvm.kt +++ b/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.jvm.kt @@ -7,7 +7,3 @@ import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingMod actual fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: PlatformContext) { module(FirebaseCloudMessagingModule(context)) } - -actual fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) { - // Firebase Cloud Messaging is not available on the desktop sample target. -} diff --git a/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.wasmJs.kt b/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.wasmJs.kt index 59ab1fa..3fbf767 100644 --- a/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.wasmJs.kt +++ b/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.wasmJs.kt @@ -7,6 +7,3 @@ actual fun Kick.Configuration.registerFirebaseCloudMessagingModule(context: Plat // Firebase Cloud Messaging UI is not exposed for the web sample target. } -actual fun setupFirebaseCloudMessagingIntegration(context: PlatformContext) { - // No Firebase support on the web sample target. -} From 8b09fea906262fdbbc789a86e7406e58d712672c Mon Sep 17 00:00:00 2001 From: Artem Bazhanov Date: Sun, 26 Oct 2025 16:07:01 +0300 Subject: [PATCH 5/5] Refine Firebase Cloud Messaging integration --- .../firebase/cloudmessaging/KickExtensions.kt | 8 --- .../data/PlatformPendingIntent.android.kt | 5 ++ .../data/FirebaseLocalNotificationRequest.kt | 4 ++ .../data/FirebaseNotificationImportance.kt | 9 +++ .../core/data/PlatformPendingIntent.kt | 3 + .../cloudmessaging/PushPayloadExtensions.kt | 16 +++++ .../core/data/PlatformPendingIntent.ios.kt | 3 + .../FirebasePlatformActions.android.kt | 70 ++++++++++++++----- .../core/actions/RemoteMessageExtensions.kt | 6 -- .../data/PlatformPendingIntent.android.kt | 5 ++ .../data/FirebaseLocalNotificationRequest.kt | 4 ++ .../data/FirebaseNotificationImportance.kt | 12 ++++ .../core/data/PlatformPendingIntent.kt | 3 + .../core/actions/PushPayloadExtensions.kt | 15 ++-- .../core/data/PlatformPendingIntent.ios.kt | 3 + sample/ios/iosSample/AppDelegate.swift | 2 +- 16 files changed, 128 insertions(+), 40 deletions(-) delete mode 100644 module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/KickExtensions.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.android.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationImportance.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/PushPayloadExtensions.kt create mode 100644 module/firebase/firebase-cloud-messaging-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.ios.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.android.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationImportance.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.kt create mode 100644 module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.ios.kt diff --git a/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/KickExtensions.kt b/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/KickExtensions.kt deleted file mode 100644 index c8ffa60..0000000 --- a/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/KickExtensions.kt +++ /dev/null @@ -1,8 +0,0 @@ -package ru.bartwell.kick.module.firebase.cloudmessaging - -import com.google.firebase.messaging.RemoteMessage -import ru.bartwell.kick.Kick - -public fun Kick.Companion.logFirebaseMessage(message: RemoteMessage) { - // No-op in the stub artifact. -} diff --git a/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.android.kt b/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.android.kt new file mode 100644 index 0000000..e7662f9 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.android.kt @@ -0,0 +1,5 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +import android.app.PendingIntent + +public actual typealias PlatformPendingIntent = PendingIntent diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt index cac8e8f..250b62c 100644 --- a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt @@ -5,4 +5,8 @@ public data class FirebaseLocalNotificationRequest( val body: String?, val data: Map, val channelId: String?, + val channelName: String? = null, + val channelImportance: FirebaseNotificationImportance? = null, + val smallIconResId: Int? = null, + val pendingIntent: PlatformPendingIntent? = null, ) diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationImportance.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationImportance.kt new file mode 100644 index 0000000..624927a --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationImportance.kt @@ -0,0 +1,9 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +public enum class FirebaseNotificationImportance { + MIN, + LOW, + DEFAULT, + HIGH, + MAX, +} diff --git a/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.kt b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.kt new file mode 100644 index 0000000..5df4876 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.kt @@ -0,0 +1,3 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +public expect class PlatformPendingIntent diff --git a/module/firebase/firebase-cloud-messaging-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/PushPayloadExtensions.kt b/module/firebase/firebase-cloud-messaging-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/PushPayloadExtensions.kt new file mode 100644 index 0000000..9770c85 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/PushPayloadExtensions.kt @@ -0,0 +1,16 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging + +import platform.Foundation.NSDictionary +import platform.UserNotifications.UNNotification + +public fun FirebaseCloudMessagingAccessor.handleApnsPayload(userInfo: NSDictionary) { + // No-op in the stub artifact. +} + +public fun FirebaseCloudMessagingAccessor.handleApnsPayload(userInfo: Map) { + // No-op in the stub artifact. +} + +public fun FirebaseCloudMessagingAccessor.handleApnsNotification(notification: UNNotification) { + // No-op in the stub artifact. +} diff --git a/module/firebase/firebase-cloud-messaging-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.ios.kt b/module/firebase/firebase-cloud-messaging-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.ios.kt new file mode 100644 index 0000000..264714a --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.ios.kt @@ -0,0 +1,3 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +public actual class PlatformPendingIntent diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.android.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.android.kt index 67df2d0..a21064f 100644 --- a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.android.kt +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebasePlatformActions.android.kt @@ -18,6 +18,7 @@ import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.core.data.get import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.AndroidNotificationChannelStatus import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseLocalNotificationRequest +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationImportance import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseNotificationStatus private var applicationContext: Context? = null @@ -35,7 +36,9 @@ internal actual suspend fun platformGetRegistrationToken( forceRefresh: Boolean, ): Result = runCatching { val context = requireContext() - ensureFirebaseInitialized(context) + if (!ensureFirebaseInitialized(context)) { + throw FirebaseNotInitializedException() + } val messaging = FirebaseMessaging.getInstance() if (forceRefresh) { messaging.deleteToken().await() @@ -45,7 +48,9 @@ internal actual suspend fun platformGetRegistrationToken( internal actual suspend fun platformGetFirebaseInstallationId(): Result = runCatching { val context = requireContext() - ensureFirebaseInitialized(context) + if (!ensureFirebaseInitialized(context)) { + throw FirebaseNotInitializedException() + } FirebaseInstallations.getInstance().id.await() } @@ -54,20 +59,29 @@ internal actual suspend fun platformSendLocalNotification( ): Result = runCatching { val context = requireContext() val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val channelId = ensureChannel(manager, request.channelId) + val channelId = ensureChannel(manager, request) + if (!manager.areNotificationsEnabled()) { + throw LocalNotificationException( + status = collectNotificationStatus(context, manager, channelId), + cause = IllegalStateException("Notifications are disabled"), + ) + } val contentText = request.body ?: request.data.entries.joinToString { "${'$'}{it.key}: ${'$'}{it.value}" } val notification = NotificationCompat.Builder(context, channelId) - .setSmallIcon(android.R.drawable.ic_dialog_info) + .setSmallIcon(request.smallIconResId ?: android.R.drawable.ic_dialog_info) .setContentTitle(request.title ?: "Firebase push") .setContentText(contentText) .setStyle(NotificationCompat.BigTextStyle().bigText(contentText)) .setAutoCancel(true) + .apply { + request.pendingIntent?.let { intent -> setContentIntent(intent) } + } .build() try { manager.notify(System.currentTimeMillis().toInt(), notification) } catch (error: SecurityException) { throw LocalNotificationException( - status = collectNotificationStatus(context, manager), + status = collectNotificationStatus(context, manager, channelId), cause = error, ) } @@ -75,16 +89,16 @@ internal actual suspend fun platformSendLocalNotification( internal actual suspend fun platformGetNotificationStatus(): Result = runCatching { val context = requireContext() - ensureFirebaseInitialized(context) val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - collectNotificationStatus(context, manager) + collectNotificationStatus(context, manager, DEFAULT_CHANNEL_ID) } private fun collectNotificationStatus( context: Context, manager: NotificationManager, + channelId: String, ): FirebaseNotificationStatus { - val channelInfo = buildChannelStatus(manager) + val channelInfo = buildChannelStatus(manager, channelId) val playServices = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS return FirebaseNotificationStatus( @@ -93,12 +107,15 @@ private fun collectNotificationStatus( ) } -private fun buildChannelStatus(manager: NotificationManager): AndroidNotificationChannelStatus { +private fun buildChannelStatus( + manager: NotificationManager, + requestedChannelId: String, +): AndroidNotificationChannelStatus { val notificationsEnabled = manager.areNotificationsEnabled() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = manager.getNotificationChannel(DEFAULT_CHANNEL_ID) + val channel = manager.getNotificationChannel(requestedChannelId) AndroidNotificationChannelStatus( - id = channel?.id ?: DEFAULT_CHANNEL_ID, + id = channel?.id ?: requestedChannelId, name = channel?.name?.toString(), description = channel?.description, importance = channel?.importance?.toImportanceDescription(), @@ -107,7 +124,7 @@ private fun buildChannelStatus(manager: NotificationManager): AndroidNotificatio ) } else { AndroidNotificationChannelStatus( - id = DEFAULT_CHANNEL_ID, + id = requestedChannelId, name = null, description = null, importance = null, @@ -117,20 +134,27 @@ private fun buildChannelStatus(manager: NotificationManager): AndroidNotificatio } } -private fun ensureChannel(manager: NotificationManager, requested: String?): String { - val id = requested ?: DEFAULT_CHANNEL_ID +private fun ensureChannel( + manager: NotificationManager, + request: FirebaseLocalNotificationRequest, +): String { + val id = request.channelId ?: DEFAULT_CHANNEL_ID if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val existing = manager.getNotificationChannel(id) if (existing == null) { - val channel = NotificationChannel(id, DEFAULT_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT) + val channel = NotificationChannel( + id, + request.channelName ?: DEFAULT_CHANNEL_NAME, + request.channelImportance?.toImportance() ?: NotificationManager.IMPORTANCE_DEFAULT, + ) manager.createNotificationChannel(channel) } } return id } -private fun ensureFirebaseInitialized(context: Context) { - require(FirebaseApp.getApps(context).isNotEmpty()) { "Firebase is not initialised" } +private fun ensureFirebaseInitialized(context: Context): Boolean { + return FirebaseApp.getApps(context).isNotEmpty() } private fun requireContext(): Context = applicationContext @@ -162,6 +186,10 @@ private class LocalNotificationException( cause, ) +private class FirebaseNotInitializedException : IllegalStateException( + "Firebase is not initialised", +) + private fun Int.toImportanceDescription(): String = when (this) { NotificationManager.IMPORTANCE_NONE -> "none" NotificationManager.IMPORTANCE_MIN -> "min" @@ -172,6 +200,14 @@ private fun Int.toImportanceDescription(): String = when (this) { else -> toString() } +private fun FirebaseNotificationImportance.toImportance(): Int = when (this) { + FirebaseNotificationImportance.MIN -> NotificationManager.IMPORTANCE_MIN + FirebaseNotificationImportance.LOW -> NotificationManager.IMPORTANCE_LOW + FirebaseNotificationImportance.DEFAULT -> NotificationManager.IMPORTANCE_DEFAULT + FirebaseNotificationImportance.HIGH -> NotificationManager.IMPORTANCE_HIGH + FirebaseNotificationImportance.MAX -> NotificationManager.IMPORTANCE_MAX +} + private suspend fun Task.await(): T = suspendCancellableCoroutine { continuation -> addOnCompleteListener { task -> if (task.isSuccessful) { diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt index 1a867f0..a63bf22 100644 --- a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt @@ -2,14 +2,8 @@ package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions import androidx.core.app.NotificationCompat import com.google.firebase.messaging.RemoteMessage -import ru.bartwell.kick.Kick import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingAccessor import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage -import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging - -public fun Kick.Companion.logFirebaseMessage(message: RemoteMessage) { - firebaseCloudMessaging.log(message.toFirebaseMessage()) -} public fun FirebaseCloudMessagingAccessor.handleFcm(message: RemoteMessage) { log(message.toFirebaseMessage()) diff --git a/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.android.kt b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.android.kt new file mode 100644 index 0000000..e7662f9 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.android.kt @@ -0,0 +1,5 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +import android.app.PendingIntent + +public actual typealias PlatformPendingIntent = PendingIntent diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt index 45e5fe9..5e89115 100644 --- a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt @@ -8,4 +8,8 @@ public data class FirebaseLocalNotificationRequest( val body: String?, val data: Map, val channelId: String?, + val channelName: String? = null, + val channelImportance: FirebaseNotificationImportance? = null, + val smallIconResId: Int? = null, + val pendingIntent: PlatformPendingIntent? = null, ) diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationImportance.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationImportance.kt new file mode 100644 index 0000000..7c1de5e --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseNotificationImportance.kt @@ -0,0 +1,12 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +/** + * Cross-platform representation of Android notification channel importance levels. + */ +public enum class FirebaseNotificationImportance { + MIN, + LOW, + DEFAULT, + HIGH, + MAX, +} diff --git a/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.kt b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.kt new file mode 100644 index 0000000..5df4876 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.kt @@ -0,0 +1,3 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +public expect class PlatformPendingIntent diff --git a/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/PushPayloadExtensions.kt b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/PushPayloadExtensions.kt index 3fa6ea1..e39f9b7 100644 --- a/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/PushPayloadExtensions.kt +++ b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/PushPayloadExtensions.kt @@ -2,21 +2,20 @@ package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions import platform.Foundation.NSDictionary import platform.UserNotifications.UNNotification -import ru.bartwell.kick.Kick import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage -import ru.bartwell.kick.module.firebase.cloudmessaging.firebaseCloudMessaging +import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingAccessor -public fun Kick.Companion.logFirebaseMessage(userInfo: NSDictionary) { - firebaseCloudMessaging.log(userInfo.toFirebaseMessage()) +public fun FirebaseCloudMessagingAccessor.handleApnsPayload(userInfo: NSDictionary) { + log(userInfo.toFirebaseMessage()) } -public fun Kick.Companion.logFirebaseMessage(userInfo: Map) { - firebaseCloudMessaging.log(userInfo.toFirebaseMessage()) +public fun FirebaseCloudMessagingAccessor.handleApnsPayload(userInfo: Map) { + log(userInfo.toFirebaseMessage()) } -public fun Kick.Companion.logFirebaseMessage(notification: UNNotification) { +public fun FirebaseCloudMessagingAccessor.handleApnsNotification(notification: UNNotification) { val payload = notification.request.content.userInfo - firebaseCloudMessaging.log(payload.toFirebaseMessage()) + log(payload.toFirebaseMessage()) } private fun NSDictionary.toFirebaseMessage(): FirebaseMessage = entries().associate { entry -> diff --git a/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.ios.kt b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.ios.kt new file mode 100644 index 0000000..264714a --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/PlatformPendingIntent.ios.kt @@ -0,0 +1,3 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.data + +public actual class PlatformPendingIntent diff --git a/sample/ios/iosSample/AppDelegate.swift b/sample/ios/iosSample/AppDelegate.swift index 0c4d5d3..cb606da 100644 --- a/sample/ios/iosSample/AppDelegate.swift +++ b/sample/ios/iosSample/AppDelegate.swift @@ -15,7 +15,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { - KickCompanion().logFirebaseMessage(userInfo: userInfo) + KickCompanion().firebaseCloudMessaging.handleApnsPayload(userInfo: userInfo) completionHandler(.noData) } }