package com.peachpasswords import android.content.Intent import android.graphics.Color as AndroidColor import android.graphics.drawable.ColorDrawable import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.SideEffect import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.Modifier import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.fragment.app.FragmentActivity import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import com.peachpasswords.autofill.AutofillIntents import com.peachpasswords.autofill.AutofillNavRepository import com.peachpasswords.ui.PeachAppNavHost import com.peachpasswords.ui.components.Favicons import com.peachpasswords.ui.components.LocalFavicons import com.peachpasswords.ui.components.LocalFaviconsEnabled import com.peachpasswords.ui.components.QaFlags import com.peachpasswords.ui.components.SecureWindowPolicy import com.peachpasswords.ui.onboarding.ONBOARD_KEY import com.peachpasswords.ui.onboarding.ONBOARD_PREFS import com.peachpasswords.ui.onboarding.onboardingStartDestination import com.peachpasswords.ui.theme.ACCENT_COLORS import com.peachpasswords.ui.theme.DEFAULT_ACCENT import com.peachpasswords.ui.theme.PeachTheme import com.peachpasswords.updates.ACTION_OPEN_UPDATE import com.peachpasswords.updates.EXTRA_OPEN_UPDATE import com.peachpasswords.updates.EXTRA_OPEN_UPDATE_TOKEN import com.peachpasswords.updates.UpdateRepository import com.peachpasswords.updates.consumeUpdateOpenToken import com.peachpasswords.vault.VaultLockTimer import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.faviconsAllowed // FragmentActivity (not ComponentActivity) so androidx BiometricPrompt can attach. @AndroidEntryPoint class MainActivity : FragmentActivity() { @Inject lateinit var vault: VaultRepository @Inject lateinit var favicons: Favicons @Inject lateinit var lockTimer: VaultLockTimer @Inject lateinit var updates: UpdateRepository @Inject lateinit var autofillNav: AutofillNavRepository // Every touch/key event resets the idle auto-lock countdown (CF-1). override fun onUserInteraction() { super.onUserInteraction() lockTimer.poke() } // Unfolding or rotating destroys and recreates this activity without the // user leaving the app; record that so the process-level background lock // leaves the vault alone. See PeachApplication.stopWasConfigurationChange. override fun onStop() { (application as PeachApplication).stopWasConfigurationChange = isChangingConfigurations() super.onStop() } // launchMode="singleTask" reuses this instance across `adb shell am start` // relaunches, so the QA capture-bypass extra (see onCreate) needs to be // re-read here too, not just once at process start. override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) routeIntent(intent) if (BuildConfig.DEBUG) { QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra( QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false, ) } } override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) // Cold launch from the update notification (the warm case is onNewIntent). routeIntent(intent) // Match the window fill to --bg so launch never flashes a lighter colour // before Compose draws, whatever the device's own day/night mode is. window.setBackgroundDrawable(ColorDrawable(AndroidColor.parseColor("#0C0A06"))) enableEdgeToEdge() // Default to FLAG_SECURE immediately (before Compose draws) so there is // never an unprotected flash. The user's "allow screenshots" preference // is applied reactively inside setContent once settings are available. if (!BuildConfig.DEBUG) { SecureWindowPolicy.setUserSecure(window, true) } else { QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra( QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false, ) } // Onboarding runs once, only on a fresh install with no vault yet. val onboarded = getSharedPreferences(ONBOARD_PREFS, MODE_PRIVATE) .getBoolean(ONBOARD_KEY, false) val start = onboardingStartDestination(hasVault = vault.hasVault(), onboarded = onboarded) setContent { val settings by vault.settings.collectAsState() // enableEdgeToEdge() picks bar icon appearance from the device's own // day/night mode. Peach is always dark, so on a device in light mode // the icons would render dark-on-dark and vanish against our // background — reading as if the app ran under the status bar. SideEffect { WindowCompat.getInsetsController(window, window.decorView).apply { isAppearanceLightStatusBars = false isAppearanceLightNavigationBars = false } } val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT SideEffect { if (!(BuildConfig.DEBUG && QaFlags.disableSecureCaptureForQa)) { SecureWindowPolicy.setUserSecure(window, !settings.allowScreenshots) } } CompositionLocalProvider( LocalFavicons provides favicons, LocalFaviconsEnabled provides faviconsAllowed(settings), ) { PeachTheme( accent = accent, reduceMotion = settings.reduceMotion, textSize = settings.textSize, ) { WideScreenScale(scalePreference = settings.uiScale) { PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked, uiGateLocked = vault.uiGateLocked) } } } } } } /** Layout width a phone gives us; anything past this is "extra" to spend. */ private val BASE_WIDTH = 411.dp /** * The single place a launch intent is interpreted, shared by cold launch * (`onCreate`) and warm relaunch (`onNewIntent`) — launchMode="singleTask" means * either can be the one that carries a shortcut tap, and the two paths silently * drifting apart is how the shortcut actions went unhandled in the first place. * * Everything here only records a request; nothing navigates. The nav host acts * on it once the vault is unlocked. */ private fun MainActivity.routeIntent(intent: Intent) { if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false) && intent.action == ACTION_OPEN_UPDATE && consumeUpdateOpenToken(this, intent.getStringExtra(EXTRA_OPEN_UPDATE_TOKEN))) { updates.requestOpen() } forwardNewEntryRequest(intent) routeAction(intent.action)?.let(PendingActionQueue::request) } /** * Pick up the "+ Create new entry" launcher from the autofill picker and stash * the prefill payload in [autofillNav]. The NavHost observes that flow and * navigates to the new-entry editor, mirroring the UpdateRepository pattern. */ private fun MainActivity.forwardNewEntryRequest(intent: Intent) { if (!intent.getBooleanExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, false)) return // F3: MainActivity is exported; only accept the prefill payload when the // one-time token proves the launch came from the in-process picker. if (!AutofillIntents.consumeNewEntryToken(intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN))) return autofillNav.requestNewEntry( url = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL), username = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME), email = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL), ) } /** Cap on the up-scale — past this, big text stops reading as deliberate. */ private const val MAX_SCALE = 1.35f /** * On a foldable's inner screen the app was a stretched phone: phone-sized type * and rows with the surplus width absorbed as empty space. Scaling the density * grows every dp and sp together, so the same single-column layout simply reads * as a bigger screen — no per-screen breakpoints to keep in sync. * * Auto mode keeps the existing foldable treatment but leaves tablet-like * windows at phone density; Compact and Large are explicit overrides. * No-op at or below [BASE_WIDTH], so phones are untouched. */ @Composable private fun WideScreenScale( scalePreference: String = "auto", content: @Composable () -> Unit, ) { BoxWithConstraints(Modifier.fillMaxSize()) { val base = LocalDensity.current val scale = wideScreenScaleFor(scalePreference, maxWidth, maxHeight) // Always go through the same slot. Branching between a bare content() // and a wrapped one changes the composition's structure whenever the // scale crosses 1f, which disposes and rebuilds everything inside — // including the nav host and its remembered controller. The settings // flow starts at the default "auto" and only later carries the // persisted value, so a device set to "large" on a tablet-like screen // resolves 1f first and 1.35f a moment later, dropping the user back // on the lock screen to authenticate a second time. val density = remember(base, scale) { Density(base.density * scale, base.fontScale) } CompositionLocalProvider(LocalDensity provides density, content = content) } } internal fun wideScreenScaleFor(preference: String, width: Dp, height: Dp): Float { val phoneScale = (width / BASE_WIDTH).coerceIn(1f, MAX_SCALE) return when (preference) { "compact" -> 1f "large" -> phoneScale else -> if (isTabletLike(width, height)) 1f else phoneScale } } private fun isTabletLike(width: Dp, height: Dp): Boolean { val shortest = minOf(width.value, height.value) val longest = maxOf(width.value, height.value) return shortest >= 720f || (shortest >= 600f && longest / shortest >= 1.45f) }