Files
honeyDueKMP/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt
Trey T 1ba95db629 Integration: wire 3 new P2 screens into App.kt nav + HapticsInit bootstrap
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>
2026-04-18 13:28:06 -05:00

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