diff --git a/README.md b/README.md index b5e076c..9ca8369 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,30 @@ Once created don't forget to update the: - Issues Template (bug report + feature request). - Pull Request Template. +## Firebase Push Demo 📬 + +- Replace `app/google-services.json` with the config generated from your Firebase project (keep the same package name or update `APP_ID` accordingly). +- Enable Firebase Cloud Messaging in the Firebase console and download a server key for testing pushes. +- Build and run the sample app; the home screen shows the current FCM registration token and automatically subscribes the device to the `demo` topic. +- Allow the runtime notification permission on Android 13+ when prompted so foreground demos can surface notifications. +- Send a notification from the Firebase console to either the displayed token or the `demo` topic, or trigger it manually: + +```bash +curl -X POST https://fcm.googleapis.com/fcm/send \ + -H "Authorization: key=${FIREBASE_SERVER_KEY}" \ + -H "Content-Type: application/json" \ + -d '{ + "to": "/topics/demo", + "notification": { + "title": "Hello from FCM", + "body": "This push was sent using the legacy HTTP API." + }, + "data": { + "message": "You can also handle custom payload data in the demo service." + } + }' +``` + ## Troubleshooting For help with issues which you might encounter when using this template, please refer to [TROUBLESHOOTING.md](TROUBLESHOOTING.md) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1677d8..46da087 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id("com.android.application") kotlin("android") + alias(libs.plugins.google.services) } val APP_VERSION_NAME : String by project @@ -71,8 +72,11 @@ dependencies { implementation(projects.libraryKotlin) implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity) implementation(libs.androidx.constraint.layout) implementation(libs.androidx.core.ktx) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) testImplementation(libs.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ddf8326..3a6d3e7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,10 +2,17 @@ + + + + + + + + + + diff --git a/app/src/main/java/com/ncorti/kotlin/template/app/MainActivity.kt b/app/src/main/java/com/ncorti/kotlin/template/app/MainActivity.kt index dc8142a..fb29caf 100644 --- a/app/src/main/java/com/ncorti/kotlin/template/app/MainActivity.kt +++ b/app/src/main/java/com/ncorti/kotlin/template/app/MainActivity.kt @@ -1,22 +1,48 @@ package com.ncorti.kotlin.template.app +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle +import android.util.Log import android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessaging import com.ncorti.kotlin.template.app.databinding.ActivityMainBinding +import com.ncorti.kotlin.template.app.firebase.NotificationHelper import com.ncorti.kotlin.template.library.FactorialCalculator import com.ncorti.kotlin.template.library.android.ToastUtil class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding + private var notificationPermissionLauncher: ActivityResultLauncher? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + setupNotificationPermissionLauncher() + NotificationHelper.ensureDefaultChannel(this) + + if (isNotificationPermissionGranted()) { + fetchFirebaseToken() + } else { + binding.textFirebaseToken.text = getString(R.string.firebase_token_permission_required) + requestNotificationPermissionIfNeeded() + fetchFirebaseToken() + } + + subscribeToDemoTopic() + setupUiListeners() + } + + private fun setupUiListeners() { binding.buttonCompute.setOnClickListener { val message = if (binding.editTextFactorial.text.isNotEmpty()) { val input = binding.editTextFactorial.text.toString().toLong() @@ -40,4 +66,86 @@ class MainActivity : AppCompatActivity() { startActivity(intent) } } + + private fun setupNotificationPermissionLauncher() { + notificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + when { + isGranted -> { + Log.d(TAG, "Notification permission granted") + fetchFirebaseToken() + } + + shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { + binding.textFirebaseToken.text = + getString(R.string.firebase_token_permission_rationale) + } + + else -> { + binding.textFirebaseToken.text = + getString(R.string.firebase_token_permission_denied) + } + } + } + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return + } + + if (isNotificationPermissionGranted()) { + return + } + + notificationPermissionLauncher?.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + private fun isNotificationPermissionGranted(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + private fun fetchFirebaseToken() { + binding.textFirebaseToken.text = getString(R.string.firebase_token_loading) + FirebaseMessaging.getInstance().token + .addOnCompleteListener { task -> + if (!task.isSuccessful) { + val errorMessage = + task.exception?.localizedMessage ?: getString(R.string.firebase_token_error_generic) + binding.textFirebaseToken.text = + getString(R.string.firebase_token_error, errorMessage) + Log.w(TAG, "Fetching FCM registration token failed", task.exception) + return@addOnCompleteListener + } + + val token = task.result + binding.textFirebaseToken.text = token + Log.d(TAG, "FCM registration token: $token") + } + } + + private fun subscribeToDemoTopic() { + FirebaseMessaging.getInstance().subscribeToTopic(DEMO_TOPIC) + .addOnCompleteListener { task -> + if (task.isSuccessful) { + Log.d(TAG, "Subscribed to '$DEMO_TOPIC' topic for demo pushes.") + } else { + Log.w(TAG, "Topic subscription failed", task.exception) + } + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + setIntent(intent) + } + + companion object { + private const val TAG = "MainActivity" + private const val DEMO_TOPIC = "demo" + } } diff --git a/app/src/main/java/com/ncorti/kotlin/template/app/firebase/DemoFirebaseMessagingService.kt b/app/src/main/java/com/ncorti/kotlin/template/app/firebase/DemoFirebaseMessagingService.kt new file mode 100644 index 0000000..4ac8460 --- /dev/null +++ b/app/src/main/java/com/ncorti/kotlin/template/app/firebase/DemoFirebaseMessagingService.kt @@ -0,0 +1,68 @@ +package com.ncorti.kotlin.template.app.firebase + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.ncorti.kotlin.template.app.R +import kotlin.random.Random + +class DemoFirebaseMessagingService : FirebaseMessagingService() { + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + + val channelId = NotificationHelper.ensureDefaultChannel(this) + + val title = message.notification?.title + ?: message.data[KEY_TITLE] + ?: getString(R.string.firebase_default_notification_title) + + val body = message.notification?.body + ?: message.data[KEY_BODY] + ?: message.data[KEY_MESSAGE] + ?: getString(R.string.firebase_default_notification_body) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Log.w(TAG, "Notification permission not granted; dropping push message.") + return + } + + Log.d(TAG, "Message received from ${message.from}: $message") + + val notification = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + with(NotificationManagerCompat.from(this)) { + notify(Random.nextInt(0, Int.MAX_VALUE), notification) + } + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + Log.i(TAG, "FCM token refreshed: $token") + } + + private companion object { + private const val TAG = "DemoFirebaseMessaging" + private const val KEY_TITLE = "title" + private const val KEY_BODY = "body" + private const val KEY_MESSAGE = "message" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ncorti/kotlin/template/app/firebase/NotificationHelper.kt b/app/src/main/java/com/ncorti/kotlin/template/app/firebase/NotificationHelper.kt new file mode 100644 index 0000000..1350c78 --- /dev/null +++ b/app/src/main/java/com/ncorti/kotlin/template/app/firebase/NotificationHelper.kt @@ -0,0 +1,33 @@ +package com.ncorti.kotlin.template.app.firebase + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.content.getSystemService +import com.ncorti.kotlin.template.app.R + +object NotificationHelper { + + fun ensureDefaultChannel(context: Context): String { + val channelId = context.getString(R.string.firebase_notification_channel_id) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelName = context.getString(R.string.firebase_notification_channel_name) + val channelDescription = + context.getString(R.string.firebase_notification_channel_description) + + val notificationManager: NotificationManager? = context.getSystemService() + val channel = NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = channelDescription + } + notificationManager?.createNotificationChannel(channel) + } + + return channelId + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index cc49eb8..6c47300 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -24,9 +24,32 @@ android:layout_marginVertical="@dimen/base_grid_size" android:text="@string/description" android:textAppearance="@style/TextAppearance.AppCompat.Body1" - app:layout_constraintBottom_toTopOf="@id/edit_text_factorial" + app:layout_constraintBottom_toTopOf="@id/text_firebase_token_label" app:layout_constraintStart_toStartOf="parent" /> + + + + The Result is: %s Please Enter a Number In Compose + FCM registration token + Fetching token… + Unable to fetch token (%1$s) + unknown error + Grant notification permission to preview incoming pushes. + Enable notifications so we can display Firebase push messages. + Notification permission denied. You can enable it later from system settings. + firebase_demo_channel + Demo push notifications + Shows Firebase Cloud Messaging demo alerts. + Firebase demo message + Send yourself a push from the Firebase console or REST API to see it here. diff --git a/build.gradle.kts b/build.gradle.kts index 54e16ca..bd2b4e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("com.android.library") apply false kotlin("android") apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.google.services) apply false alias(libs.plugins.detekt) alias(libs.plugins.nexus.publish) cleanup diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 41b3901..90b5e90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,10 +16,12 @@ kotlin = "2.2.21" min_sdk_version = "23" nexus_publish = "2.0.0" target_sdk_version = "36" +firebase_bom = "33.7.0" [libraries] junit = { module = "junit:junit", version.ref = "junit" } androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "androidx_activity_compose" } +androidx_activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx_activity_compose" } androidx_appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx_constraint_layout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraint_layout" } androidx_core_ktx = { module = "androidx.core:core-ktx", version.ref = "core_ktx" } @@ -38,8 +40,11 @@ detekt_formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", espresso_core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso_core" } agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } kgp = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase_bom" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" } [plugins] detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus_publish" } \ No newline at end of file +nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus_publish" } +google-services = { id = "com.google.gms.google-services", version = "4.4.2" } \ No newline at end of file