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>
238 lines
8.8 KiB
Kotlin
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)
|
|
} |