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..8a28005 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/build.gradle.kts @@ -0,0 +1,102 @@ +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) + implementation(libs.firebase.messaging) + } + 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/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/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/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..143772d --- /dev/null +++ b/module/firebase/firebase-cloud-messaging-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt @@ -0,0 +1,7 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging + +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage + +public class FirebaseCloudMessagingAccessor internal constructor() { + public fun log(message: FirebaseMessage) {} +} 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/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..250b62c --- /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,12 @@ +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?, + 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/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/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/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-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/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/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..a21064f --- /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,223 @@ +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.FirebaseNotificationImportance +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() + if (!ensureFirebaseInitialized(context)) { + throw FirebaseNotInitializedException() + } + val messaging = FirebaseMessaging.getInstance() + if (forceRefresh) { + messaging.deleteToken().await() + } + messaging.token.await() +} + +internal actual suspend fun platformGetFirebaseInstallationId(): Result = runCatching { + val context = requireContext() + if (!ensureFirebaseInitialized(context)) { + throw FirebaseNotInitializedException() + } + 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) + 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(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, channelId), + cause = error, + ) + } +} + +internal actual suspend fun platformGetNotificationStatus(): Result = runCatching { + val context = requireContext() + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + collectNotificationStatus(context, manager, DEFAULT_CHANNEL_ID) +} + +private fun collectNotificationStatus( + context: Context, + manager: NotificationManager, + channelId: String, +): FirebaseNotificationStatus { + val channelInfo = buildChannelStatus(manager, channelId) + val playServices = GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS + return FirebaseNotificationStatus( + androidChannel = channelInfo, + isGooglePlayServicesAvailable = playServices, + ) +} + +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(requestedChannelId) + AndroidNotificationChannelStatus( + id = channel?.id ?: requestedChannelId, + name = channel?.name?.toString(), + description = channel?.description, + importance = channel?.importance?.toImportanceDescription(), + isEnabled = channel != null && notificationsEnabled, + isAppNotificationsEnabled = notificationsEnabled, + ) + } else { + AndroidNotificationChannelStatus( + id = requestedChannelId, + name = null, + description = null, + importance = null, + isEnabled = notificationsEnabled, + isAppNotificationsEnabled = notificationsEnabled, + ) + } +} + +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, + request.channelName ?: DEFAULT_CHANNEL_NAME, + request.channelImportance?.toImportance() ?: NotificationManager.IMPORTANCE_DEFAULT, + ) + manager.createNotificationChannel(channel) + } + } + return id +} + +private fun ensureFirebaseInitialized(context: Context): Boolean { + return FirebaseApp.getApps(context).isNotEmpty() +} + +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 class FirebaseNotInitializedException : IllegalStateException( + "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 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) { + 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 new file mode 100644 index 0000000..a63bf22 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/RemoteMessageExtensions.kt @@ -0,0 +1,125 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions + +import androidx.core.app.NotificationCompat +import com.google.firebase.messaging.RemoteMessage +import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingAccessor +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage + +public fun FirebaseCloudMessagingAccessor.handleFcm(message: RemoteMessage) { + 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 messagePriority = mapMessagePriority(priority) + val notificationPriority = notification?.notificationPriority?.let(::mapNotificationPriority) + 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, + category = notification?.clickAction, + badge = notification?.notificationCount?.takeIf { it > 0 }?.toString(), + tag = notification?.tag, + sound = notification?.sound, + imageUrl = notification?.imageUrl?.toString(), + priority = notificationPriority ?: messagePriority, + ttlSeconds = ttl, + 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/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/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..17c801f --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingAccessor.kt @@ -0,0 +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.data.FirebaseMessage + +public class FirebaseCloudMessagingAccessor internal constructor() { + public fun log(message: FirebaseMessage) { + FirebaseCloudMessagingActions.emitMessage(message) + } +} 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..63bb08c --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/FirebaseCloudMessagingModule.kt @@ -0,0 +1,62 @@ +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.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 +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 + + init { + FirebaseCloudMessagingActions.initialize(context) + } + + 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..5fb951f --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/FirebaseCloudMessagingActions.kt @@ -0,0 +1,59 @@ +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.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 + +internal object FirebaseCloudMessagingActions { + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + fun initialize(context: PlatformContext) { + platformInitialize(context) + } + + fun isFirebaseInitialized(): Boolean = platformIsFirebaseInitialized() + + suspend fun getRegistrationToken(forceRefresh: Boolean): Result = + platformGetRegistrationToken(forceRefresh) + + 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 -> + (listOf(message) + current).take(MAX_MESSAGES) + } + } + + fun clearMessages() { + _messages.value = emptyList() + } +} + +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/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..5e89115 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/data/FirebaseLocalNotificationRequest.kt @@ -0,0 +1,15 @@ +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?, + 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/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/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/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/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/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..2618b1b --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/feature/presentation/DefaultFirebaseCloudMessagingComponent.kt @@ -0,0 +1,285 @@ +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.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) + + if (ensureFirebaseAvailability(requireFirebase = false)) { + refreshToken(forceRefresh = false) + refreshFirebaseId() + refreshStatus() + } else { + clearRemoteState() + } + } + + override fun onBackPressed() { + onFinished() + } + + override fun refreshToken(forceRefresh: Boolean) { + uiScope.launch { + if (!ensureFirebaseAvailability(requireFirebase = true) { message -> + updateState { + copy( + tokenError = message, + isTokenLoading = false, + ) + } + } + ) { + return@launch + } + + updateState { copy(isTokenLoading = true, tokenError = null) } + val result = FirebaseCloudMessagingActions.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 { + if (!ensureFirebaseAvailability(requireFirebase = true) { message -> + updateState { + copy( + firebaseIdError = message, + isFirebaseIdLoading = false, + ) + } + } + ) { + return@launch + } + + updateState { copy(isFirebaseIdLoading = true, firebaseIdError = null) } + val result = FirebaseCloudMessagingActions.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 { + if (!ensureFirebaseAvailability(requireFirebase = true) { message -> + updateState { + copy( + statusError = message, + isStatusLoading = false, + ) + } + } + ) { + return@launch + } + + updateState { copy(isStatusLoading = true, statusError = null) } + val result = FirebaseCloudMessagingActions.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 { + ensureFirebaseAvailability(requireFirebase = false) + + 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 = 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 = { + 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 ensureFirebaseAvailability( + requireFirebase: Boolean, + onUnavailable: (String) -> Unit = {}, + ): Boolean { + 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 }, + ) + } + if (!available && requireFirebase) { + onUnavailable(NOT_INITIALISED_MESSAGE) + } + return available || !requireFirebase + } + + private fun clearRemoteState() { + updateState { + copy( + token = null, + firebaseId = null, + status = null, + tokenError = null, + firebaseIdError = null, + statusError = null, + ) + } + } + + 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 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/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/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..e39f9b7 --- /dev/null +++ b/module/firebase/firebase-cloud-messaging/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/cloudmessaging/core/actions/PushPayloadExtensions.kt @@ -0,0 +1,79 @@ +package ru.bartwell.kick.module.firebase.cloudmessaging.core.actions + +import platform.Foundation.NSDictionary +import platform.UserNotifications.UNNotification +import ru.bartwell.kick.module.firebase.cloudmessaging.core.data.FirebaseMessage +import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingAccessor + +public fun FirebaseCloudMessagingAccessor.handleApnsPayload(userInfo: NSDictionary) { + log(userInfo.toFirebaseMessage()) +} + +public fun FirebaseCloudMessagingAccessor.handleApnsPayload(userInfo: Map) { + log(userInfo.toFirebaseMessage()) +} + +public fun FirebaseCloudMessagingAccessor.handleApnsNotification(notification: UNNotification) { + val payload = notification.request.content.userInfo + 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/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/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/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..cb606da 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().firebaseCloudMessaging.handleApnsPayload(userInfo: userInfo) + completionHandler(.noData) + } } diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 7f5c324..f7b08c9 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -122,10 +122,18 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.sqldelight.android.driver) implementation(libs.ktor.client.okhttp) + implementation(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 +146,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..8bc52bd --- /dev/null +++ b/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.android.kt @@ -0,0 +1,8 @@ +package ru.bartwell.kick.sample.shared + +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)) +} 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..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 @@ -93,6 +93,7 @@ class TestDataInitializer(context: PlatformContext) { createLayoutModule(context)?.let { module(it) } module(ControlPanelModule(context, createControlPanelItems())) module(OverlayModule(context)) + registerFirebaseCloudMessagingModule(context) } startTestLogging() @@ -177,3 +178,5 @@ 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) 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..ea7eea3 --- /dev/null +++ b/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.ios.kt @@ -0,0 +1,9 @@ +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)) +} 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..ea7eea3 --- /dev/null +++ b/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.jvm.kt @@ -0,0 +1,9 @@ +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)) +} 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..3fbf767 --- /dev/null +++ b/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/FirebaseCloudMessagingIntegration.wasmJs.kt @@ -0,0 +1,9 @@ +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. +} + 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")