Navigation wiring (per follow-ups flagged by Streams G/H/I): - Add TaskTemplatesBrowserRoute (new) + App.kt composable<TaskTemplatesBrowserRoute> - Wire composable<TaskSuggestionsRoute> (declared by Stream H but unwired) - Wire composable<AddTaskWithResidenceRoute> (declared by Stream I but unwired) MainActivity.onCreate now calls HapticsInit.install(applicationContext) so the Vibrator fallback path works on non-View call-sites (flagged by Stream S). Deferred cleanup (tracked, not done here): - Port push-token registration from legacy MyFirebaseMessagingService.kt to new FcmService (Stream N TODO). - Remove legacy WidgetTaskActionReceiver + manifest entry (Stream M flag). - Residence invite accept/decline APILayer methods (Stream O TODO). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
354 lines
14 KiB
Kotlin
354 lines
14 KiB
Kotlin
package com.tt.honeyDue
|
|
|
|
import android.content.Intent
|
|
import android.net.Uri
|
|
import android.os.Bundle
|
|
import android.util.Log
|
|
import androidx.activity.compose.setContent
|
|
import androidx.activity.enableEdgeToEdge
|
|
import androidx.fragment.app.FragmentActivity
|
|
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.tt.honeyDue.storage.TokenManager
|
|
import com.tt.honeyDue.storage.TokenStorage
|
|
import com.tt.honeyDue.storage.TaskCacheManager
|
|
import com.tt.honeyDue.storage.TaskCacheStorage
|
|
import com.tt.honeyDue.storage.BiometricPreference
|
|
import com.tt.honeyDue.storage.BiometricPreferenceManager
|
|
import com.tt.honeyDue.storage.ThemeStorage
|
|
import com.tt.honeyDue.storage.ThemeStorageManager
|
|
import com.tt.honeyDue.ui.theme.ThemeManager
|
|
import com.tt.honeyDue.fcm.FCMManager
|
|
import com.tt.honeyDue.platform.BillingManager
|
|
import com.tt.honeyDue.network.APILayer
|
|
import com.tt.honeyDue.network.ApiResult
|
|
import com.tt.honeyDue.network.CoilAuthInterceptor
|
|
import com.tt.honeyDue.sharing.ContractorSharingManager
|
|
import com.tt.honeyDue.data.DataManager
|
|
import com.tt.honeyDue.data.PersistenceManager
|
|
import com.tt.honeyDue.models.HoneyDuePackageType
|
|
import com.tt.honeyDue.models.detectHoneyDuePackageType
|
|
import com.tt.honeyDue.analytics.PostHogAnalytics
|
|
import kotlinx.coroutines.launch
|
|
|
|
class MainActivity : FragmentActivity(), 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 BiometricPreference storage
|
|
BiometricPreference.initialize(BiometricPreferenceManager.getInstance(applicationContext))
|
|
|
|
// Initialize cross-platform Haptics backend (P5 Stream S)
|
|
com.tt.honeyDue.ui.haptics.HapticsInit.install(applicationContext)
|
|
|
|
// 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)
|
|
|
|
// Initialize PostHog Analytics
|
|
PostHogAnalytics.initialize(application, debug = true) // Set debug=false for release
|
|
|
|
// Install uncaught exception handler to capture crashes to PostHog
|
|
PostHogAnalytics.setupExceptionHandler()
|
|
|
|
// 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.tt.honeyDue.network.NotificationApi()
|
|
val deviceId = android.provider.Settings.Secure.getString(
|
|
contentResolver,
|
|
android.provider.Settings.Secure.ANDROID_ID
|
|
)
|
|
val request = com.tt.honeyDue.models.DeviceRegistrationRequest(
|
|
deviceId = deviceId,
|
|
registrationId = fcmToken,
|
|
platform = "android",
|
|
name = android.os.Build.MODEL
|
|
)
|
|
|
|
when (val result = notificationApi.registerDevice(authToken, request)) {
|
|
is com.tt.honeyDue.network.ApiResult.Success -> {
|
|
Log.d("MainActivity", "Device registered successfully: ${result.data}")
|
|
}
|
|
is com.tt.honeyDue.network.ApiResult.Error -> {
|
|
Log.e("MainActivity", "Failed to register device: ${result.message}")
|
|
}
|
|
is com.tt.honeyDue.network.ApiResult.Loading,
|
|
is com.tt.honeyDue.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()
|
|
|
|
// Check if widget completed a task - refresh data if dirty
|
|
if (TaskCacheStorage.areTasksDirty()) {
|
|
Log.d("MainActivity", "🔄 Tasks marked dirty by widget, refreshing...")
|
|
TaskCacheStorage.clearDirtyFlag()
|
|
// Force refresh tasks from API
|
|
APILayer.getTasks(forceRefresh = true)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
val isResetLink = data != null &&
|
|
data.scheme == "honeydue" &&
|
|
data.host == "reset-password"
|
|
if (isResetLink) {
|
|
// 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.ishoneyDueFile(applicationContext, uri)) {
|
|
Log.d("MainActivity", "honeyDue 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 = detectHoneyDuePackageType(jsonString)
|
|
Log.d("MainActivity", "Detected package type: $packageType")
|
|
|
|
when (packageType) {
|
|
HoneyDuePackageType.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 {
|
|
// Auth interceptor runs before the network fetcher so every
|
|
// image request carries the current Authorization header, with
|
|
// 401 -> refresh-token -> retry handled transparently. Mirrors
|
|
// iOS AuthenticatedImage.swift (Stream U).
|
|
add(
|
|
CoilAuthInterceptor(
|
|
tokenProvider = { TokenStorage.getToken() },
|
|
refreshToken = {
|
|
val r = APILayer.refreshToken()
|
|
if (r is ApiResult.Success) r.data else null
|
|
},
|
|
authScheme = "Token",
|
|
)
|
|
)
|
|
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)
|
|
}
|