Files
honeyDueKMP/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt
Trey t 2965ec4031 Add actionable push notifications for iOS and Android
iOS:
- Add notification categories with action buttons (complete, view, cancel, etc.)
- Handle notification actions in AppDelegate with API calls
- Add navigation to specific task from notification tap
- Register UNNotificationCategory for each task state

Android:
- Add NotificationActionReceiver BroadcastReceiver for handling actions
- Update MyFirebaseMessagingService to show action buttons
- Add deep link handling in MainActivity for task navigation
- Register receiver in AndroidManifest.xml

Shared:
- Add navigateToTaskId parameter to App for cross-platform navigation
- Add notification observers in MainTabView/AllTasksView for refresh

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 14:23:25 -06:00

238 lines
8.8 KiB
Kotlin

package com.example.casera
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
import androidx.compose.runtime.Composable
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
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.request.crossfade
import coil3.util.DebugLogger
import okio.FileSystem
import com.example.casera.storage.TokenManager
import com.example.casera.storage.TokenStorage
import com.example.casera.storage.TaskCacheManager
import com.example.casera.storage.TaskCacheStorage
import com.example.casera.storage.ThemeStorage
import com.example.casera.storage.ThemeStorageManager
import com.example.casera.ui.theme.ThemeManager
import com.example.casera.fcm.FCMManager
import com.example.casera.platform.BillingManager
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
private var deepLinkResetToken by mutableStateOf<String?>(null)
private var navigateToTaskId by mutableStateOf<Int?>(null)
private lateinit var billingManager: BillingManager
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// Initialize TokenStorage with Android TokenManager
TokenStorage.initialize(TokenManager.getInstance(applicationContext))
// Initialize TaskCacheStorage for offline task caching
TaskCacheStorage.initialize(TaskCacheManager.getInstance(applicationContext))
// Initialize ThemeStorage and ThemeManager
ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext))
ThemeManager.initialize()
// Initialize BillingManager for subscription management
billingManager = BillingManager.getInstance(applicationContext)
// Handle deep link and notification navigation from intent
handleDeepLink(intent)
handleNotificationNavigation(intent)
// Request notification permission and setup FCM
setupFCM()
// Verify subscriptions if user is authenticated
verifySubscriptionsOnLaunch()
setContent {
App(
deepLinkResetToken = deepLinkResetToken,
onClearDeepLinkToken = {
deepLinkResetToken = null
},
navigateToTaskId = navigateToTaskId,
onClearNavigateToTask = {
navigateToTaskId = null
}
)
}
}
/**
* Verify subscriptions with Google Play and sync with backend on app launch
*/
private fun verifySubscriptionsOnLaunch() {
val authToken = TokenStorage.getToken()
if (authToken == null) {
Log.d("MainActivity", "No auth token, skipping subscription verification")
return
}
Log.d("MainActivity", "🔄 Verifying subscriptions on launch...")
billingManager.startConnection(
onSuccess = {
Log.d("MainActivity", "✅ Billing connected, restoring purchases...")
lifecycleScope.launch {
val restored = billingManager.restorePurchases()
if (restored) {
Log.d("MainActivity", "✅ Subscriptions verified and synced with backend")
} else {
Log.d("MainActivity", "📦 No active subscriptions found")
}
}
},
onError = { error ->
Log.e("MainActivity", "❌ Failed to connect to billing: $error")
}
)
}
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.example.casera.network.NotificationApi()
val deviceId = android.provider.Settings.Secure.getString(
contentResolver,
android.provider.Settings.Secure.ANDROID_ID
)
val request = com.example.casera.models.DeviceRegistrationRequest(
deviceId = deviceId,
registrationId = fcmToken,
platform = "android",
name = android.os.Build.MODEL
)
when (val result = notificationApi.registerDevice(authToken, request)) {
is com.example.casera.network.ApiResult.Success -> {
Log.d("MainActivity", "Device registered successfully: ${result.data}")
}
is com.example.casera.network.ApiResult.Error -> {
Log.e("MainActivity", "Failed to register device: ${result.message}")
}
is com.example.casera.network.ApiResult.Loading,
is com.example.casera.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)
handleNotificationNavigation(intent)
}
private fun handleNotificationNavigation(intent: Intent?) {
val taskId = intent?.getIntExtra(NotificationActionReceiver.EXTRA_NAVIGATE_TO_TASK, -1)
if (taskId != null && taskId != -1) {
Log.d("MainActivity", "Navigating to task from notification: $taskId")
navigateToTaskId = taskId
}
}
private fun handleDeepLink(intent: Intent?) {
val data: Uri? = intent?.data
if (data != null && data.scheme == "mycrib" && data.host == "reset-password") {
// Extract token from query parameter
val token = data.getQueryParameter("token")
if (token != null) {
deepLinkResetToken = token
println("Deep link received with token: $token")
}
}
}
override fun newImageLoader(context: PlatformContext): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(KtorNetworkFetcherFactory())
}
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "image_cache")
.maxSizeBytes(512L * 1024 * 1024) // 512MB
.build()
}
.crossfade(true)
.logger(DebugLogger())
.build()
}
}
@Preview
@Composable
fun AppAndroidPreview() {
App(deepLinkResetToken = null)
}