MainActivity.kt

8.7 KB · raw

1package com.peachpasswords23import android.content.Intent4import android.graphics.Color as AndroidColor5import android.graphics.drawable.ColorDrawable6import android.os.Bundle7import androidx.activity.compose.setContent8import androidx.activity.enableEdgeToEdge9import androidx.compose.runtime.SideEffect10import androidx.compose.ui.unit.dp11import androidx.compose.ui.unit.Density12import androidx.compose.ui.platform.LocalDensity13import androidx.compose.ui.Modifier14import androidx.compose.runtime.CompositionLocalProvider15import androidx.compose.runtime.Composable16import androidx.compose.foundation.layout.fillMaxSize17import androidx.compose.foundation.layout.BoxWithConstraints18import androidx.compose.runtime.collectAsState19import androidx.compose.runtime.getValue20import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen21import androidx.core.view.WindowCompat22import androidx.fragment.app.FragmentActivity23import dagger.hilt.android.AndroidEntryPoint24import javax.inject.Inject25import com.peachpasswords.autofill.AutofillIntents26import com.peachpasswords.autofill.AutofillNavRepository27import com.peachpasswords.ui.PeachAppNavHost28import com.peachpasswords.ui.components.Favicons29import com.peachpasswords.ui.components.LocalFavicons30import com.peachpasswords.ui.components.LocalFaviconsEnabled31import com.peachpasswords.ui.components.QaFlags32import com.peachpasswords.ui.components.SecureWindowPolicy33import com.peachpasswords.ui.onboarding.ONBOARD_KEY34import com.peachpasswords.ui.onboarding.ONBOARD_PREFS35import com.peachpasswords.ui.onboarding.onboardingStartDestination36import com.peachpasswords.ui.theme.ACCENT_COLORS37import com.peachpasswords.ui.theme.DEFAULT_ACCENT38import com.peachpasswords.ui.theme.PeachTheme39import com.peachpasswords.updates.EXTRA_OPEN_UPDATE40import com.peachpasswords.updates.UpdateRepository41import com.peachpasswords.vault.VaultLockTimer42import com.peachpasswords.vault.VaultRepository4344// FragmentActivity (not ComponentActivity) so androidx BiometricPrompt can attach.45@AndroidEntryPoint46class MainActivity : FragmentActivity() {47    @Inject lateinit var vault: VaultRepository48    @Inject lateinit var favicons: Favicons49    @Inject lateinit var lockTimer: VaultLockTimer50    @Inject lateinit var updates: UpdateRepository51    @Inject lateinit var autofillNav: AutofillNavRepository5253    // Every touch/key event resets the idle auto-lock countdown (CF-1).54    override fun onUserInteraction() {55        super.onUserInteraction()56        lockTimer.poke()57    }5859    // launchMode="singleTask" reuses this instance across `adb shell am start`60    // relaunches, so the QA capture-bypass extra (see onCreate) needs to be61    // re-read here too, not just once at process start.62    override fun onNewIntent(intent: Intent) {63        super.onNewIntent(intent)64        setIntent(intent)65        routeIntent(intent)66        if (BuildConfig.DEBUG) {67            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra(68                QaFlags.EXTRA_DISABLE_SECURE_CAPTURE,69                false,70            )71        }72    }7374    override fun onCreate(savedInstanceState: Bundle?) {75        installSplashScreen()76        super.onCreate(savedInstanceState)7778        // Cold launch from the update notification (the warm case is onNewIntent).79        routeIntent(intent)8081        // Match the window fill to --bg so launch never flashes a lighter colour82        // before Compose draws, whatever the device's own day/night mode is.83        window.setBackgroundDrawable(ColorDrawable(AndroidColor.parseColor("#0C0A06")))8485        enableEdgeToEdge()8687        // Default to FLAG_SECURE immediately (before Compose draws) so there is88        // never an unprotected flash. The user's "allow screenshots" preference89        // is applied reactively inside setContent once settings are available.90        if (!BuildConfig.DEBUG) {91            SecureWindowPolicy.setUserSecure(window, true)92        } else {93            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra(94                QaFlags.EXTRA_DISABLE_SECURE_CAPTURE,95                false,96            )97        }9899        // Onboarding runs once, only on a fresh install with no vault yet.100        val onboarded = getSharedPreferences(ONBOARD_PREFS, MODE_PRIVATE)101            .getBoolean(ONBOARD_KEY, false)102        val start = onboardingStartDestination(hasVault = vault.hasVault(), onboarded = onboarded)103104        setContent {105            val settings by vault.settings.collectAsState()106            // enableEdgeToEdge() picks bar icon appearance from the device's own107            // day/night mode. Peach is always dark, so on a device in light mode108            // the icons would render dark-on-dark and vanish against our109            // background — reading as if the app ran under the status bar.110            SideEffect {111                WindowCompat.getInsetsController(window, window.decorView).apply {112                    isAppearanceLightStatusBars = false113                    isAppearanceLightNavigationBars = false114                }115            }116            val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT117            SideEffect {118                if (!(BuildConfig.DEBUG && QaFlags.disableSecureCaptureForQa)) {119                    SecureWindowPolicy.setUserSecure(window, !settings.allowScreenshots)120                }121            }122            CompositionLocalProvider(123                LocalFavicons provides favicons,124                LocalFaviconsEnabled provides settings.faviconsEnabled,125            ) {126                PeachTheme(127                    accent = accent,128                    reduceMotion = settings.reduceMotion,129                    textSize = settings.textSize,130                ) {131                    WideScreenScale {132                        PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked, uiGateLocked = vault.uiGateLocked)133                    }134                }135            }136        }137    }138}139140/** Layout width a phone gives us; anything past this is "extra" to spend. */141private val BASE_WIDTH = 411.dp142143/**144 * The single place a launch intent is interpreted, shared by cold launch145 * (`onCreate`) and warm relaunch (`onNewIntent`) — launchMode="singleTask" means146 * either can be the one that carries a shortcut tap, and the two paths silently147 * drifting apart is how the shortcut actions went unhandled in the first place.148 *149 * Everything here only records a request; nothing navigates. The nav host acts on150 * it once the vault is unlocked.151 */152private fun MainActivity.routeIntent(intent: Intent) {153    if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()154    forwardNewEntryRequest(intent)155    routeAction(intent.action)?.let(PendingActionQueue::request)156}157158/**159 * Pick up the "+ Create new entry" launcher from the autofill picker and stash160 * the prefill payload in [autofillNav]. The NavHost observes that flow and161 * navigates to the new-entry editor, mirroring the UpdateRepository pattern.162 */163private fun MainActivity.forwardNewEntryRequest(intent: Intent) {164    if (!intent.getBooleanExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, false)) return165    // F3: MainActivity is exported; only accept the prefill payload when the166    // one-time token proves the launch came from the in-process picker.167    if (!AutofillIntents.consumeNewEntryToken(intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN))) return168    autofillNav.requestNewEntry(169        url = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL),170        username = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME),171        email = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL),172    )173}174175/** Cap on the up-scale — past this, big text stops reading as deliberate. */176private const val MAX_SCALE = 1.35f177178/**179 * On a foldable's inner screen the app was a stretched phone: phone-sized type180 * and rows with the surplus width absorbed as empty space. Scaling the density181 * grows every dp and sp together, so the same single-column layout simply reads182 * as a bigger screen — no per-screen breakpoints to keep in sync.183 *184 * Ceiling: a uniform scale, not real adaptive layout. If a screen ever wants a185 * genuinely different wide-screen arrangement it needs its own breakpoint.186 * No-op at or below [BASE_WIDTH], so phones are untouched.187 */188@Composable189private fun WideScreenScale(content: @Composable () -> Unit) {190    BoxWithConstraints(Modifier.fillMaxSize()) {191        val base = LocalDensity.current192        val scale = (maxWidth / BASE_WIDTH).coerceIn(1f, MAX_SCALE)193        if (scale == 1f) {194            content()195        } else {196            CompositionLocalProvider(197                LocalDensity provides Density(base.density * scale, base.fontScale),198                content = content,199            )200        }201    }202}