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.platform.LocalDensity import androidx.compose.ui.Modifier import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Composable 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.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.EXTRA_OPEN_UPDATE import com.peachpasswords.updates.UpdateRepository import com.peachpasswords.vault.VaultLockTimer import com.peachpasswords.vault.VaultRepository // FragmentActivity (not ComponentActivity) so androidx BiometricPrompt can attach. @AndroidEntryPoint class MainActivity : FragmentActivity() { @Inject lateinit var vault: VaultRepository @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() } // 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) { SecureWindowPolicy.setUserSecure(window, !settings.allowScreenshots) } } PeachTheme( accent = accent, reduceMotion = settings.reduceMotion, textSize = settings.textSize, ) { WideScreenScale { 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)) 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 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. * * Ceiling: a uniform scale, not real adaptive layout. If a screen ever wants a * genuinely different wide-screen arrangement it needs its own breakpoint. * No-op at or below [BASE_WIDTH], so phones are untouched. */ @Composable private fun WideScreenScale(content: @Composable () -> Unit) { BoxWithConstraints(Modifier.fillMaxSize()) { val base = LocalDensity.current val scale = (maxWidth / BASE_WIDTH).coerceIn(1f, MAX_SCALE) if (scale == 1f) { content() } else { CompositionLocalProvider( LocalDensity provides Density(base.density * scale, base.fontScale), content = content, ) } } }