MainActivity.kt

10.6 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.unit.Dp13import androidx.compose.ui.platform.LocalDensity14import androidx.compose.ui.Modifier15import androidx.compose.runtime.CompositionLocalProvider16import androidx.compose.runtime.Composable17import androidx.compose.runtime.remember18import androidx.compose.foundation.layout.fillMaxSize19import androidx.compose.foundation.layout.BoxWithConstraints20import androidx.compose.runtime.collectAsState21import androidx.compose.runtime.getValue22import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen23import androidx.core.view.WindowCompat24import androidx.fragment.app.FragmentActivity25import dagger.hilt.android.AndroidEntryPoint26import javax.inject.Inject27import com.peachpasswords.autofill.AutofillIntents28import com.peachpasswords.autofill.AutofillNavRepository29import com.peachpasswords.ui.PeachAppNavHost30import com.peachpasswords.ui.components.Favicons31import com.peachpasswords.ui.components.LocalFavicons32import com.peachpasswords.ui.components.LocalFaviconsEnabled33import com.peachpasswords.ui.components.QaFlags34import com.peachpasswords.ui.components.SecureWindowPolicy35import com.peachpasswords.ui.onboarding.ONBOARD_KEY36import com.peachpasswords.ui.onboarding.ONBOARD_PREFS37import com.peachpasswords.ui.onboarding.onboardingStartDestination38import com.peachpasswords.ui.theme.ACCENT_COLORS39import com.peachpasswords.ui.theme.DEFAULT_ACCENT40import com.peachpasswords.ui.theme.PeachTheme41import com.peachpasswords.updates.ACTION_OPEN_UPDATE42import com.peachpasswords.updates.EXTRA_OPEN_UPDATE43import com.peachpasswords.updates.EXTRA_OPEN_UPDATE_TOKEN44import com.peachpasswords.updates.UpdateRepository45import com.peachpasswords.updates.consumeUpdateOpenToken46import com.peachpasswords.vault.VaultLockTimer47import com.peachpasswords.vault.VaultRepository48import com.peachpasswords.vault.faviconsAllowed4950// FragmentActivity (not ComponentActivity) so androidx BiometricPrompt can attach.51@AndroidEntryPoint52class MainActivity : FragmentActivity() {53    @Inject lateinit var vault: VaultRepository54    @Inject lateinit var favicons: Favicons55    @Inject lateinit var lockTimer: VaultLockTimer56    @Inject lateinit var updates: UpdateRepository57    @Inject lateinit var autofillNav: AutofillNavRepository5859    // Every touch/key event resets the idle auto-lock countdown (CF-1).60    override fun onUserInteraction() {61        super.onUserInteraction()62        lockTimer.poke()63    }6465    // Unfolding or rotating destroys and recreates this activity without the66    // user leaving the app; record that so the process-level background lock67    // leaves the vault alone. See PeachApplication.stopWasConfigurationChange.68    override fun onStop() {69        (application as PeachApplication).stopWasConfigurationChange = isChangingConfigurations()70        super.onStop()71    }7273    // launchMode="singleTask" reuses this instance across `adb shell am start`74    // relaunches, so the QA capture-bypass extra (see onCreate) needs to be75    // re-read here too, not just once at process start.76    override fun onNewIntent(intent: Intent) {77        super.onNewIntent(intent)78        setIntent(intent)79        routeIntent(intent)80        if (BuildConfig.DEBUG) {81            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra(82                QaFlags.EXTRA_DISABLE_SECURE_CAPTURE,83                false,84            )85        }86    }8788    override fun onCreate(savedInstanceState: Bundle?) {89        installSplashScreen()90        super.onCreate(savedInstanceState)9192        // Cold launch from the update notification (the warm case is onNewIntent).93        routeIntent(intent)9495        // Match the window fill to --bg so launch never flashes a lighter colour96        // before Compose draws, whatever the device's own day/night mode is.97        window.setBackgroundDrawable(ColorDrawable(AndroidColor.parseColor("#0C0A06")))9899        enableEdgeToEdge()100101        // Default to FLAG_SECURE immediately (before Compose draws) so there is102        // never an unprotected flash. The user's "allow screenshots" preference103        // is applied reactively inside setContent once settings are available.104        if (!BuildConfig.DEBUG) {105            SecureWindowPolicy.setUserSecure(window, true)106        } else {107            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra(108                QaFlags.EXTRA_DISABLE_SECURE_CAPTURE,109                false,110            )111        }112113        // Onboarding runs once, only on a fresh install with no vault yet.114        val onboarded = getSharedPreferences(ONBOARD_PREFS, MODE_PRIVATE)115            .getBoolean(ONBOARD_KEY, false)116        val start = onboardingStartDestination(hasVault = vault.hasVault(), onboarded = onboarded)117118        setContent {119            val settings by vault.settings.collectAsState()120            // enableEdgeToEdge() picks bar icon appearance from the device's own121            // day/night mode. Peach is always dark, so on a device in light mode122            // the icons would render dark-on-dark and vanish against our123            // background — reading as if the app ran under the status bar.124            SideEffect {125                WindowCompat.getInsetsController(window, window.decorView).apply {126                    isAppearanceLightStatusBars = false127                    isAppearanceLightNavigationBars = false128                }129            }130            val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT131            SideEffect {132                if (!(BuildConfig.DEBUG && QaFlags.disableSecureCaptureForQa)) {133                    SecureWindowPolicy.setUserSecure(window, !settings.allowScreenshots)134                }135            }136            CompositionLocalProvider(137                LocalFavicons provides favicons,138                LocalFaviconsEnabled provides faviconsAllowed(settings),139            ) {140                PeachTheme(141                    accent = accent,142                    reduceMotion = settings.reduceMotion,143                    textSize = settings.textSize,144                ) {145                    WideScreenScale(scalePreference = settings.uiScale) {146                        PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked, uiGateLocked = vault.uiGateLocked)147                    }148                }149            }150        }151    }152}153154/** Layout width a phone gives us; anything past this is "extra" to spend. */155private val BASE_WIDTH = 411.dp156157/**158 * The single place a launch intent is interpreted, shared by cold launch159 * (`onCreate`) and warm relaunch (`onNewIntent`) — launchMode="singleTask" means160 * either can be the one that carries a shortcut tap, and the two paths silently161 * drifting apart is how the shortcut actions went unhandled in the first place.162 *163 * Everything here only records a request; nothing navigates. The nav host acts164 * on it once the vault is unlocked.165 */166private fun MainActivity.routeIntent(intent: Intent) {167    if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false) &&168        intent.action == ACTION_OPEN_UPDATE &&169        consumeUpdateOpenToken(this, intent.getStringExtra(EXTRA_OPEN_UPDATE_TOKEN))) {170        updates.requestOpen()171    }172    forwardNewEntryRequest(intent)173    routeAction(intent.action)?.let(PendingActionQueue::request)174}175176/**177 * Pick up the "+ Create new entry" launcher from the autofill picker and stash178 * the prefill payload in [autofillNav]. The NavHost observes that flow and179 * navigates to the new-entry editor, mirroring the UpdateRepository pattern.180 */181private fun MainActivity.forwardNewEntryRequest(intent: Intent) {182    if (!intent.getBooleanExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, false)) return183    // F3: MainActivity is exported; only accept the prefill payload when the184    // one-time token proves the launch came from the in-process picker.185    if (!AutofillIntents.consumeNewEntryToken(intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN))) return186    autofillNav.requestNewEntry(187        url = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL),188        username = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME),189        email = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL),190    )191}192193/** Cap on the up-scale — past this, big text stops reading as deliberate. */194private const val MAX_SCALE = 1.35f195196/**197 * On a foldable's inner screen the app was a stretched phone: phone-sized type198 * and rows with the surplus width absorbed as empty space. Scaling the density199 * grows every dp and sp together, so the same single-column layout simply reads200 * as a bigger screen — no per-screen breakpoints to keep in sync.201 *202 * Auto mode keeps the existing foldable treatment but leaves tablet-like203 * windows at phone density; Compact and Large are explicit overrides.204 * No-op at or below [BASE_WIDTH], so phones are untouched.205 */206@Composable207private fun WideScreenScale(208    scalePreference: String = "auto",209    content: @Composable () -> Unit,210) {211    BoxWithConstraints(Modifier.fillMaxSize()) {212        val base = LocalDensity.current213        val scale = wideScreenScaleFor(scalePreference, maxWidth, maxHeight)214        // Always go through the same slot. Branching between a bare content()215        // and a wrapped one changes the composition's structure whenever the216        // scale crosses 1f, which disposes and rebuilds everything inside —217        // including the nav host and its remembered controller. The settings218        // flow starts at the default "auto" and only later carries the219        // persisted value, so a device set to "large" on a tablet-like screen220        // resolves 1f first and 1.35f a moment later, dropping the user back221        // on the lock screen to authenticate a second time.222        val density = remember(base, scale) { Density(base.density * scale, base.fontScale) }223        CompositionLocalProvider(LocalDensity provides density, content = content)224    }225}226227internal fun wideScreenScaleFor(preference: String, width: Dp, height: Dp): Float {228    val phoneScale = (width / BASE_WIDTH).coerceIn(1f, MAX_SCALE)229    return when (preference) {230        "compact" -> 1f231        "large" -> phoneScale232        else -> if (isTabletLike(width, height)) 1f else phoneScale233    }234}235236private fun isTabletLike(width: Dp, height: Dp): Boolean {237    val shortest = minOf(width.value, height.value)238    val longest = maxOf(width.value, height.value)239    return shortest >= 720f || (shortest >= 600f && longest / shortest >= 1.45f)240}