Add push notification support for Android and iOS
- Integrated Firebase Cloud Messaging (FCM) for Android - Integrated Apple Push Notification Service (APNs) for iOS - Created shared notification models and API client - Added device registration and token management - Added notification permission handling for Android - Created PushNotificationManager for iOS with AppDelegate - Added placeholder google-services.json (needs to be replaced with actual config) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
@@ -50,6 +51,20 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Firebase Cloud Messaging Service -->
|
||||
<service
|
||||
android:name=".MyFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Default notification channel ID -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/default_notification_channel_id" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -3,6 +3,7 @@ package com.example.mycrib
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -11,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
@@ -24,6 +26,8 @@ import com.mycrib.storage.TokenManager
|
||||
import com.mycrib.storage.TokenStorage
|
||||
import com.mycrib.storage.TaskCacheManager
|
||||
import com.mycrib.storage.TaskCacheStorage
|
||||
import com.example.mycrib.fcm.FCMManager
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
||||
@@ -41,6 +45,9 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||
// Handle deep link from intent
|
||||
handleDeepLink(intent)
|
||||
|
||||
// Request notification permission and setup FCM
|
||||
setupFCM()
|
||||
|
||||
setContent {
|
||||
App(
|
||||
deepLinkResetToken = deepLinkResetToken,
|
||||
@@ -51,6 +58,74 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupFCM() {
|
||||
// Request notification permission if needed
|
||||
if (!FCMManager.isNotificationPermissionGranted(this)) {
|
||||
FCMManager.requestNotificationPermission(this)
|
||||
}
|
||||
|
||||
// Get FCM token and register with backend
|
||||
lifecycleScope.launch {
|
||||
val fcmToken = FCMManager.getFCMToken()
|
||||
if (fcmToken != null) {
|
||||
Log.d("MainActivity", "FCM Token: $fcmToken")
|
||||
registerDeviceWithBackend(fcmToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registerDeviceWithBackend(fcmToken: String) {
|
||||
try {
|
||||
val authToken = TokenStorage.getToken()
|
||||
if (authToken != null) {
|
||||
val notificationApi = com.mycrib.shared.network.NotificationApi()
|
||||
val request = com.mycrib.shared.models.DeviceRegistrationRequest(
|
||||
registrationId = fcmToken,
|
||||
platform = "android"
|
||||
)
|
||||
|
||||
when (val result = notificationApi.registerDevice(authToken, request)) {
|
||||
is com.mycrib.shared.network.ApiResult.Success -> {
|
||||
Log.d("MainActivity", "Device registered successfully: ${result.data}")
|
||||
}
|
||||
is com.mycrib.shared.network.ApiResult.Error -> {
|
||||
Log.e("MainActivity", "Failed to register device: ${result.message}")
|
||||
}
|
||||
is com.mycrib.shared.network.ApiResult.Loading,
|
||||
is com.mycrib.shared.network.ApiResult.Idle -> {
|
||||
// These states shouldn't occur for direct API calls
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d("MainActivity", "No auth token available, will register device after login")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error registering device", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
when (requestCode) {
|
||||
FCMManager.NOTIFICATION_PERMISSION_REQUEST_CODE -> {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
Log.d("MainActivity", "Notification permission granted")
|
||||
// Get FCM token now that permission is granted
|
||||
lifecycleScope.launch {
|
||||
FCMManager.getFCMToken()
|
||||
}
|
||||
} else {
|
||||
Log.d("MainActivity", "Notification permission denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleDeepLink(intent)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.example.mycrib
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MyFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
Log.d(TAG, "New FCM token: $token")
|
||||
|
||||
// Store token locally for registration with backend
|
||||
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_FCM_TOKEN, token)
|
||||
.apply()
|
||||
|
||||
// Send token to backend API if user is logged in
|
||||
// Note: In a real app, you might want to use WorkManager for reliable delivery
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val authToken = com.mycrib.storage.TokenStorage.getToken()
|
||||
if (authToken != null) {
|
||||
val notificationApi = com.mycrib.shared.network.NotificationApi()
|
||||
val request = com.mycrib.shared.models.DeviceRegistrationRequest(
|
||||
registrationId = token,
|
||||
platform = "android"
|
||||
)
|
||||
|
||||
when (val result = notificationApi.registerDevice(authToken, request)) {
|
||||
is com.mycrib.shared.network.ApiResult.Success -> {
|
||||
Log.d(TAG, "Device registered successfully with new token")
|
||||
}
|
||||
is com.mycrib.shared.network.ApiResult.Error -> {
|
||||
Log.e(TAG, "Failed to register device with new token: ${result.message}")
|
||||
}
|
||||
is com.mycrib.shared.network.ApiResult.Loading,
|
||||
is com.mycrib.shared.network.ApiResult.Idle -> {
|
||||
// These states shouldn't occur for direct API calls
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error registering device with new token", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
super.onMessageReceived(message)
|
||||
|
||||
Log.d(TAG, "Message received from: ${message.from}")
|
||||
|
||||
// Check if message contains notification payload
|
||||
message.notification?.let { notification ->
|
||||
Log.d(TAG, "Notification: ${notification.title} - ${notification.body}")
|
||||
sendNotification(
|
||||
notification.title ?: "MyCrib",
|
||||
notification.body ?: "",
|
||||
message.data
|
||||
)
|
||||
}
|
||||
|
||||
// Check if message contains data payload
|
||||
if (message.data.isNotEmpty()) {
|
||||
Log.d(TAG, "Message data: ${message.data}")
|
||||
|
||||
// If there's no notification payload, create one from data
|
||||
if (message.notification == null) {
|
||||
val title = message.data["title"] ?: "MyCrib"
|
||||
val body = message.data["body"] ?: ""
|
||||
sendNotification(title, body, message.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNotification(title: String, body: String, data: Map<String, String>) {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
|
||||
// Add data to intent for handling when notification is clicked
|
||||
data.forEach { (key, value) ->
|
||||
putExtra(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_ONE_SHOT
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
pendingIntentFlags
|
||||
)
|
||||
|
||||
val channelId = getString(R.string.default_notification_channel_id)
|
||||
val notificationBuilder = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
// Create notification channel for Android O and above
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
channelId,
|
||||
"MyCrib Notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Notifications for tasks, residences, and warranties"
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FCMService"
|
||||
private const val NOTIFICATION_ID = 0
|
||||
private const val PREFS_NAME = "mycrib_prefs"
|
||||
private const val KEY_FCM_TOKEN = "fcm_token"
|
||||
|
||||
fun getStoredToken(context: Context): String? {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_FCM_TOKEN, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.example.mycrib.fcm
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.tasks.Task
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import kotlinx.coroutines.tasks.await
|
||||
|
||||
object FCMManager {
|
||||
private const val TAG = "FCMManager"
|
||||
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1001
|
||||
|
||||
/**
|
||||
* Check if notification permission is granted (Android 13+)
|
||||
*/
|
||||
fun isNotificationPermissionGranted(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
// Permission automatically granted on older versions
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permission (Android 13+)
|
||||
*/
|
||||
fun requestNotificationPermission(activity: Activity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get FCM token
|
||||
*/
|
||||
suspend fun getFCMToken(): String? {
|
||||
return try {
|
||||
val token = FirebaseMessaging.getInstance().token.await()
|
||||
Log.d(TAG, "FCM token retrieved: $token")
|
||||
token
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get FCM token", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a topic
|
||||
*/
|
||||
suspend fun subscribeToTopic(topic: String) {
|
||||
try {
|
||||
FirebaseMessaging.getInstance().subscribeToTopic(topic).await()
|
||||
Log.d(TAG, "Subscribed to topic: $topic")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to subscribe to topic: $topic", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a topic
|
||||
*/
|
||||
suspend fun unsubscribeFromTopic(topic: String) {
|
||||
try {
|
||||
FirebaseMessaging.getInstance().unsubscribeFromTopic(topic).await()
|
||||
Log.d(TAG, "Unsubscribed from topic: $topic")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to unsubscribe from topic: $topic", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">MyCrib</string>
|
||||
<string name="default_notification_channel_id">mycrib_notifications</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user