Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
15 changes: 15 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
<uses-permission
android:name="android.permission.POST_NOTIFICATIONS"
tools:targetApi="tiramisu" />

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:defaultNotificationChannelId="@string/firebase_notification_channel_id"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
Expand All @@ -18,6 +25,14 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".firebase.DemoFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>

</manifest>
108 changes: 108 additions & 0 deletions app/src/main/java/com/ncorti/kotlin/template/app/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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<String>? = 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()
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
27 changes: 25 additions & 2 deletions app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />

<TextView
android:id="@+id/text_firebase_token_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/firebase_token_label"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
app:layout_constraintBottom_toTopOf="@id/text_firebase_token"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/description" />

<TextView
android:id="@+id/text_firebase_token"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/base_grid_size"
android:textIsSelectable="true"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/edit_text_factorial"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_firebase_token_label"
tools:text="Fetching token…" />

<EditText
android:id="@+id/edit_text_factorial"
android:layout_width="match_parent"
Expand All @@ -37,7 +60,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/description"
app:layout_constraintTop_toBottomOf="@id/text_firebase_token"
app:layout_constraintTop_toTopOf="parent" />

<TextView
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,16 @@
<string name="notification_title">The Result is: %s</string>
<string name="please_enter_a_number">Please Enter a Number</string>
<string name="compose_version">In Compose</string>
<string name="firebase_token_label">FCM registration token</string>
<string name="firebase_token_loading">Fetching token…</string>
<string name="firebase_token_error">Unable to fetch token (%1$s)</string>
<string name="firebase_token_error_generic">unknown error</string>
<string name="firebase_token_permission_required">Grant notification permission to preview incoming pushes.</string>
<string name="firebase_token_permission_rationale">Enable notifications so we can display Firebase push messages.</string>
<string name="firebase_token_permission_denied">Notification permission denied. You can enable it later from system settings.</string>
<string name="firebase_notification_channel_id">firebase_demo_channel</string>
<string name="firebase_notification_channel_name">Demo push notifications</string>
<string name="firebase_notification_channel_description">Shows Firebase Cloud Messaging demo alerts.</string>
<string name="firebase_default_notification_title">Firebase demo message</string>
<string name="firebase_default_notification_body">Send yourself a push from the Firebase console or REST API to see it here.</string>
</resources>
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading