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:
Trey t
2025-11-11 22:39:39 -06:00
parent 1a4b5d07bf
commit ec7c01e92d
14 changed files with 919 additions and 1 deletions

View File

@@ -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>

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -1,3 +1,4 @@
<resources>
<string name="app_name">MyCrib</string>
<string name="default_notification_channel_id">mycrib_notifications</string>
</resources>

View File

@@ -0,0 +1,85 @@
package com.mycrib.shared.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class DeviceRegistrationRequest(
@SerialName("registration_id")
val registrationId: String,
val platform: String // "android" or "ios"
)
@Serializable
data class DeviceRegistrationResponse(
val id: Int,
@SerialName("registration_id")
val registrationId: String,
val platform: String,
val active: Boolean,
@SerialName("date_created")
val dateCreated: String
)
@Serializable
data class NotificationPreference(
val id: Int,
@SerialName("task_due_soon")
val taskDueSoon: Boolean = true,
@SerialName("task_overdue")
val taskOverdue: Boolean = true,
@SerialName("task_completed")
val taskCompleted: Boolean = true,
@SerialName("task_assigned")
val taskAssigned: Boolean = true,
@SerialName("residence_shared")
val residenceShared: Boolean = true,
@SerialName("warranty_expiring")
val warrantyExpiring: Boolean = true,
@SerialName("created_at")
val createdAt: String,
@SerialName("updated_at")
val updatedAt: String
)
@Serializable
data class UpdateNotificationPreferencesRequest(
@SerialName("task_due_soon")
val taskDueSoon: Boolean? = null,
@SerialName("task_overdue")
val taskOverdue: Boolean? = null,
@SerialName("task_completed")
val taskCompleted: Boolean? = null,
@SerialName("task_assigned")
val taskAssigned: Boolean? = null,
@SerialName("residence_shared")
val residenceShared: Boolean? = null,
@SerialName("warranty_expiring")
val warrantyExpiring: Boolean? = null
)
@Serializable
data class Notification(
val id: Int,
@SerialName("notification_type")
val notificationType: String,
val title: String,
val body: String,
val data: Map<String, String> = emptyMap(),
val sent: Boolean,
@SerialName("sent_at")
val sentAt: String? = null,
val read: Boolean,
@SerialName("read_at")
val readAt: String? = null,
@SerialName("created_at")
val createdAt: String,
@SerialName("task_id")
val taskId: Int? = null
)
@Serializable
data class UnreadCountResponse(
@SerialName("unread_count")
val unreadCount: Int
)

View File

@@ -0,0 +1,186 @@
package com.mycrib.shared.network
import com.mycrib.shared.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
/**
* Register a device for push notifications
*/
suspend fun registerDevice(
token: String,
request: DeviceRegistrationRequest
): ApiResult<DeviceRegistrationResponse> {
return try {
val response = client.post("$baseUrl/notifications/devices/register/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Device registration failed")
}
ApiResult.Error(errorBody["error"] ?: "Device registration failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Unregister a device
*/
suspend fun unregisterDevice(
token: String,
registrationId: String
): ApiResult<Unit> {
return try {
val response = client.post("$baseUrl/notifications/devices/unregister/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(mapOf("registration_id" to registrationId))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Device unregistration failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get user's notification preferences
*/
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
return try {
val response = client.get("$baseUrl/notifications/preferences/my_preferences/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to get preferences", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Update notification preferences
*/
suspend fun updateNotificationPreferences(
token: String,
request: UpdateNotificationPreferencesRequest
): ApiResult<NotificationPreference> {
return try {
val response = client.put("$baseUrl/notifications/preferences/update_preferences/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to update preferences", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get notification history
*/
suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> {
return try {
val response = client.get("$baseUrl/notifications/history/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to get notification history", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Mark a notification as read
*/
suspend fun markNotificationAsRead(
token: String,
notificationId: Int
): ApiResult<Notification> {
return try {
val response = client.post("$baseUrl/notifications/history/$notificationId/mark_as_read/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to mark notification as read", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Mark all notifications as read
*/
suspend fun markAllNotificationsAsRead(token: String): ApiResult<Map<String, Int>> {
return try {
val response = client.post("$baseUrl/notifications/history/mark_all_as_read/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to mark all notifications as read", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get unread notification count
*/
suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> {
return try {
val response = client.get("$baseUrl/notifications/history/unread_count/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to get unread count", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}