- Add SharedResidence model and package type detection for .casera files - Add generateSharePackage API endpoint integration - Create ResidenceSharingManager for iOS and Android - Add share button to residence detail screens (owner only) - Add residence import handling with confirmation dialogs - Update Quick Look extensions to show house icon for residence packages - Route .casera imports by type (contractor vs residence) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
311 lines
12 KiB
Kotlin
311 lines
12 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 com.example.casera.network.APILayer
|
|
import com.example.casera.sharing.ContractorSharingManager
|
|
import com.example.casera.data.DataManager
|
|
import com.example.casera.data.PersistenceManager
|
|
import com.example.casera.models.CaseraPackageType
|
|
import com.example.casera.models.detectCaseraPackageType
|
|
import kotlinx.coroutines.launch
|
|
|
|
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
|
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
|
private var pendingContractorImportUri by mutableStateOf<Uri?>(null)
|
|
private var pendingResidenceImportUri by mutableStateOf<Uri?>(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 DataManager with platform-specific managers
|
|
// This loads cached lookup data from disk for faster startup
|
|
DataManager.initialize(
|
|
tokenMgr = TokenManager.getInstance(applicationContext),
|
|
themeMgr = ThemeStorageManager.getInstance(applicationContext),
|
|
persistenceMgr = PersistenceManager.getInstance(applicationContext)
|
|
)
|
|
|
|
// Initialize BillingManager for subscription management
|
|
billingManager = BillingManager.getInstance(applicationContext)
|
|
|
|
// Handle deep link, notification navigation, and file import from intent
|
|
handleDeepLink(intent)
|
|
handleNotificationNavigation(intent)
|
|
handleFileImport(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
|
|
},
|
|
pendingContractorImportUri = pendingContractorImportUri,
|
|
onClearContractorImport = {
|
|
pendingContractorImportUri = null
|
|
},
|
|
pendingResidenceImportUri = pendingResidenceImportUri,
|
|
onClearResidenceImport = {
|
|
pendingResidenceImportUri = 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 onResume() {
|
|
super.onResume()
|
|
// Check if lookups have changed on server (efficient ETag-based check)
|
|
// This ensures app has fresh data when coming back from background
|
|
lifecycleScope.launch {
|
|
Log.d("MainActivity", "🔄 App resumed, checking for lookup updates...")
|
|
APILayer.refreshLookupsIfChanged()
|
|
}
|
|
}
|
|
|
|
override fun onNewIntent(intent: Intent) {
|
|
super.onNewIntent(intent)
|
|
handleDeepLink(intent)
|
|
handleNotificationNavigation(intent)
|
|
handleFileImport(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")
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun handleFileImport(intent: Intent?) {
|
|
if (intent?.action == Intent.ACTION_VIEW) {
|
|
val uri = intent.data
|
|
if (uri != null && ContractorSharingManager.isCaseraFile(applicationContext, uri)) {
|
|
Log.d("MainActivity", "Casera file received: $uri")
|
|
|
|
// Read file content to detect package type
|
|
try {
|
|
val inputStream = contentResolver.openInputStream(uri)
|
|
if (inputStream != null) {
|
|
val jsonString = inputStream.bufferedReader().use { it.readText() }
|
|
inputStream.close()
|
|
|
|
val packageType = detectCaseraPackageType(jsonString)
|
|
Log.d("MainActivity", "Detected package type: $packageType")
|
|
|
|
when (packageType) {
|
|
CaseraPackageType.RESIDENCE -> {
|
|
Log.d("MainActivity", "Routing to residence import")
|
|
pendingResidenceImportUri = uri
|
|
}
|
|
else -> {
|
|
// Default to contractor for backward compatibility
|
|
Log.d("MainActivity", "Routing to contractor import")
|
|
pendingContractorImportUri = uri
|
|
}
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e("MainActivity", "Failed to detect package type, defaulting to contractor", e)
|
|
// Default to contractor on error for backward compatibility
|
|
pendingContractorImportUri = uri
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
} |