package com.peachpasswords import android.content.Intent import android.content.res.Configuration import android.graphics.Color as AndroidColor import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme 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.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) if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen() forwardNewEntryRequest(intent) if (BuildConfig.DEBUG) { QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", false) } } override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) // Cold launch from the update notification (the warm case is onNewIntent). if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen() forwardNewEntryRequest(intent) // Match the splash/window fill to the resolved theme's --bg so launch // never flashes the wrong colour before Compose draws. Resolved from the // persisted setting synchronously (system mode reads the device night mode). val initialTheme = vault.settings.value.theme val initialDark = when (initialTheme) { "light" -> false "system" -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES else -> true } window.setBackgroundDrawable( ColorDrawable(AndroidColor.parseColor(if (initialDark) "#0C0A06" else "#EFEEE9")), ) enableEdgeToEdge() // Block screenshots / recents thumbnails of the vault in release builds. if (!BuildConfig.DEBUG) { window.setFlags( WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE, ) } else { // QA-only, debug-build-only: `adb shell am start -n /.MainActivity // --ez qa_disable_secure_flag true` lets automated visual QA screenshot // otherwise-FLAG_SECURE screens (SecureFlagEffect). Off unless asked. QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", 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() val darkTheme = when (settings.theme) { "light" -> false "system" -> isSystemInDarkTheme() else -> true } // enableEdgeToEdge() above only knows the system's own day/night // mode at launch. This app's theme can be forced independent of // that (settings.theme == "dark"/"light"), so on a mismatch — // e.g. system in light mode, app forced dark — the status bar // icons render in the system's assumed color (dark-on-dark) and // become invisible against the app's actual background, reading // as if the app extends behind/under the status bar. Keep the // system bar icon appearance in sync with the resolved theme. SideEffect { WindowCompat.getInsetsController(window, window.decorView).apply { isAppearanceLightStatusBars = !darkTheme isAppearanceLightNavigationBars = !darkTheme } } val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT PeachTheme( darkTheme = darkTheme, accent = accent, reduceMotion = settings.reduceMotion, ) { WideScreenScale { PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked) } } } } } /** Layout width a phone gives us; anything past this is "extra" to spend. */ private val BASE_WIDTH = 411.dp /** * 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, ) } } }