MainActivity.kt

10.3 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.EXTRA_OPEN_UPDATE42import com.peachpasswords.updates.UpdateRepository43import com.peachpasswords.vault.VaultLockTimer44import com.peachpasswords.vault.VaultRepository45import com.peachpasswords.vault.faviconsAllowed4647// FragmentActivity (not ComponentActivity) so androidx BiometricPrompt can attach.48@AndroidEntryPoint49class MainActivity : FragmentActivity() {50    @Inject lateinit var vault: VaultRepository51    @Inject lateinit var favicons: Favicons52    @Inject lateinit var lockTimer: VaultLockTimer53    @Inject lateinit var updates: UpdateRepository54    @Inject lateinit var autofillNav: AutofillNavRepository5556    // Every touch/key event resets the idle auto-lock countdown (CF-1).57    override fun onUserInteraction() {58        super.onUserInteraction()59        lockTimer.poke()60    }6162    // Unfolding or rotating destroys and recreates this activity without the63    // user leaving the app; record that so the process-level background lock64    // leaves the vault alone. See PeachApplication.stopWasConfigurationChange.65    override fun onStop() {66        (application as PeachApplication).stopWasConfigurationChange = isChangingConfigurations()67        super.onStop()68    }6970    // launchMode="singleTask" reuses this instance across `adb shell am start`71    // relaunches, so the QA capture-bypass extra (see onCreate) needs to be72    // re-read here too, not just once at process start.73    override fun onNewIntent(intent: Intent) {74        super.onNewIntent(intent)75        setIntent(intent)76        routeIntent(intent)77        if (BuildConfig.DEBUG) {78            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra(79                QaFlags.EXTRA_DISABLE_SECURE_CAPTURE,80                false,81            )82        }83    }8485    override fun onCreate(savedInstanceState: Bundle?) {86        installSplashScreen()87        super.onCreate(savedInstanceState)8889        // Cold launch from the update notification (the warm case is onNewIntent).90        routeIntent(intent)9192        // Match the window fill to --bg so launch never flashes a lighter colour93        // before Compose draws, whatever the device's own day/night mode is.94        window.setBackgroundDrawable(ColorDrawable(AndroidColor.parseColor("#0C0A06")))9596        enableEdgeToEdge()9798        // Default to FLAG_SECURE immediately (before Compose draws) so there is99        // never an unprotected flash. The user's "allow screenshots" preference100        // is applied reactively inside setContent once settings are available.101        if (!BuildConfig.DEBUG) {102            SecureWindowPolicy.setUserSecure(window, true)103        } else {104            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra(105                QaFlags.EXTRA_DISABLE_SECURE_CAPTURE,106                false,107            )108        }109110        // Onboarding runs once, only on a fresh install with no vault yet.111        val onboarded = getSharedPreferences(ONBOARD_PREFS, MODE_PRIVATE)112            .getBoolean(ONBOARD_KEY, false)113        val start = onboardingStartDestination(hasVault = vault.hasVault(), onboarded = onboarded)114115        setContent {116            val settings by vault.settings.collectAsState()117            // enableEdgeToEdge() picks bar icon appearance from the device's own118            // day/night mode. Peach is always dark, so on a device in light mode119            // the icons would render dark-on-dark and vanish against our120            // background — reading as if the app ran under the status bar.121            SideEffect {122                WindowCompat.getInsetsController(window, window.decorView).apply {123                    isAppearanceLightStatusBars = false124                    isAppearanceLightNavigationBars = false125                }126            }127            val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT128            SideEffect {129                if (!(BuildConfig.DEBUG && QaFlags.disableSecureCaptureForQa)) {130                    SecureWindowPolicy.setUserSecure(window, !settings.allowScreenshots)131                }132            }133            CompositionLocalProvider(134                LocalFavicons provides favicons,135                LocalFaviconsEnabled provides faviconsAllowed(settings),136            ) {137                PeachTheme(138                    accent = accent,139                    reduceMotion = settings.reduceMotion,140                    textSize = settings.textSize,141                ) {142                    WideScreenScale(scalePreference = settings.uiScale) {143                        PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked, uiGateLocked = vault.uiGateLocked)144                    }145                }146            }147        }148    }149}150151/** Layout width a phone gives us; anything past this is "extra" to spend. */152private val BASE_WIDTH = 411.dp153154/**155 * The single place a launch intent is interpreted, shared by cold launch156 * (`onCreate`) and warm relaunch (`onNewIntent`) — launchMode="singleTask" means157 * either can be the one that carries a shortcut tap, and the two paths silently158 * drifting apart is how the shortcut actions went unhandled in the first place.159 *160 * Everything here only records a request; nothing navigates. The nav host acts on161 * it once the vault is unlocked.162 */163private fun MainActivity.routeIntent(intent: Intent) {164    if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()165    forwardNewEntryRequest(intent)166    routeAction(intent.action)?.let(PendingActionQueue::request)167}168169/**170 * Pick up the "+ Create new entry" launcher from the autofill picker and stash171 * the prefill payload in [autofillNav]. The NavHost observes that flow and172 * navigates to the new-entry editor, mirroring the UpdateRepository pattern.173 */174private fun MainActivity.forwardNewEntryRequest(intent: Intent) {175    if (!intent.getBooleanExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, false)) return176    // F3: MainActivity is exported; only accept the prefill payload when the177    // one-time token proves the launch came from the in-process picker.178    if (!AutofillIntents.consumeNewEntryToken(intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN))) return179    autofillNav.requestNewEntry(180        url = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL),181        username = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME),182        email = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL),183    )184}185186/** Cap on the up-scale — past this, big text stops reading as deliberate. */187private const val MAX_SCALE = 1.35f188189/**190 * On a foldable's inner screen the app was a stretched phone: phone-sized type191 * and rows with the surplus width absorbed as empty space. Scaling the density192 * grows every dp and sp together, so the same single-column layout simply reads193 * as a bigger screen — no per-screen breakpoints to keep in sync.194 *195 * Auto mode keeps the existing foldable treatment but leaves tablet-like196 * windows at phone density; Compact and Large are explicit overrides.197 * No-op at or below [BASE_WIDTH], so phones are untouched.198 */199@Composable200private fun WideScreenScale(201    scalePreference: String = "auto",202    content: @Composable () -> Unit,203) {204    BoxWithConstraints(Modifier.fillMaxSize()) {205        val base = LocalDensity.current206        val scale = wideScreenScaleFor(scalePreference, maxWidth, maxHeight)207        // Always go through the same slot. Branching between a bare content()208        // and a wrapped one changes the composition's structure whenever the209        // scale crosses 1f, which disposes and rebuilds everything inside —210        // including the nav host and its remembered controller. The settings211        // flow starts at the default "auto" and only later carries the212        // persisted value, so a device set to "large" on a tablet-like screen213        // resolves 1f first and 1.35f a moment later, dropping the user back214        // on the lock screen to authenticate a second time.215        val density = remember(base, scale) { Density(base.density * scale, base.fontScale) }216        CompositionLocalProvider(LocalDensity provides density, content = content)217    }218}219220internal fun wideScreenScaleFor(preference: String, width: Dp, height: Dp): Float {221    val phoneScale = (width / BASE_WIDTH).coerceIn(1f, MAX_SCALE)222    return when (preference) {223        "compact" -> 1f224        "large" -> phoneScale225        else -> if (isTabletLike(width, height)) 1f else phoneScale226    }227}228229private fun isTabletLike(width: Dp, height: Dp): Boolean {230    val shortest = minOf(width.value, height.value)231    val longest = maxOf(width.value, height.value)232    return shortest >= 720f || (shortest >= 600f && longest / shortest >= 1.45f)233}