MainActivity.kt

9.8 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.foundation.layout.fillMaxSize18import androidx.compose.foundation.layout.BoxWithConstraints19import androidx.compose.runtime.collectAsState20import androidx.compose.runtime.getValue21import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen22import androidx.core.view.WindowCompat23import androidx.fragment.app.FragmentActivity24import dagger.hilt.android.AndroidEntryPoint25import javax.inject.Inject26import com.peachpasswords.autofill.AutofillIntents27import com.peachpasswords.autofill.AutofillNavRepository28import com.peachpasswords.ui.PeachAppNavHost29import com.peachpasswords.ui.components.Favicons30import com.peachpasswords.ui.components.LocalFavicons31import com.peachpasswords.ui.components.LocalFaviconsEnabled32import com.peachpasswords.ui.components.QaFlags33import com.peachpasswords.ui.components.SecureWindowPolicy34import com.peachpasswords.ui.onboarding.ONBOARD_KEY35import com.peachpasswords.ui.onboarding.ONBOARD_PREFS36import com.peachpasswords.ui.onboarding.onboardingStartDestination37import com.peachpasswords.ui.theme.ACCENT_COLORS38import com.peachpasswords.ui.theme.DEFAULT_ACCENT39import com.peachpasswords.ui.theme.PeachTheme40import com.peachpasswords.updates.EXTRA_OPEN_UPDATE41import com.peachpasswords.updates.UpdateRepository42import com.peachpasswords.vault.VaultLockTimer43import com.peachpasswords.vault.VaultRepository44import com.peachpasswords.vault.faviconsAllowed4546// FragmentActivity (not ComponentActivity) so androidx BiometricPrompt can attach.47@AndroidEntryPoint48class MainActivity : FragmentActivity() {49    @Inject lateinit var vault: VaultRepository50    @Inject lateinit var favicons: Favicons51    @Inject lateinit var lockTimer: VaultLockTimer52    @Inject lateinit var updates: UpdateRepository53    @Inject lateinit var autofillNav: AutofillNavRepository5455    // Every touch/key event resets the idle auto-lock countdown (CF-1).56    override fun onUserInteraction() {57        super.onUserInteraction()58        lockTimer.poke()59    }6061    // Unfolding or rotating destroys and recreates this activity without the62    // user leaving the app; record that so the process-level background lock63    // leaves the vault alone. See PeachApplication.stopWasConfigurationChange.64    override fun onStop() {65        (application as PeachApplication).stopWasConfigurationChange = isChangingConfigurations()66        super.onStop()67    }6869    // launchMode="singleTask" reuses this instance across `adb shell am start`70    // relaunches, so the QA capture-bypass extra (see onCreate) needs to be71    // re-read here too, not just once at process start.72    override fun onNewIntent(intent: Intent) {73        super.onNewIntent(intent)74        setIntent(intent)75        routeIntent(intent)76        if (BuildConfig.DEBUG) {77            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra(78                QaFlags.EXTRA_DISABLE_SECURE_CAPTURE,79                false,80            )81        }82    }8384    override fun onCreate(savedInstanceState: Bundle?) {85        installSplashScreen()86        super.onCreate(savedInstanceState)8788        // Cold launch from the update notification (the warm case is onNewIntent).89        routeIntent(intent)9091        // Match the window fill to --bg so launch never flashes a lighter colour92        // before Compose draws, whatever the device's own day/night mode is.93        window.setBackgroundDrawable(ColorDrawable(AndroidColor.parseColor("#0C0A06")))9495        enableEdgeToEdge()9697        // Default to FLAG_SECURE immediately (before Compose draws) so there is98        // never an unprotected flash. The user's "allow screenshots" preference99        // is applied reactively inside setContent once settings are available.100        if (!BuildConfig.DEBUG) {101            SecureWindowPolicy.setUserSecure(window, true)102        } else {103            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra(104                QaFlags.EXTRA_DISABLE_SECURE_CAPTURE,105                false,106            )107        }108109        // Onboarding runs once, only on a fresh install with no vault yet.110        val onboarded = getSharedPreferences(ONBOARD_PREFS, MODE_PRIVATE)111            .getBoolean(ONBOARD_KEY, false)112        val start = onboardingStartDestination(hasVault = vault.hasVault(), onboarded = onboarded)113114        setContent {115            val settings by vault.settings.collectAsState()116            // enableEdgeToEdge() picks bar icon appearance from the device's own117            // day/night mode. Peach is always dark, so on a device in light mode118            // the icons would render dark-on-dark and vanish against our119            // background — reading as if the app ran under the status bar.120            SideEffect {121                WindowCompat.getInsetsController(window, window.decorView).apply {122                    isAppearanceLightStatusBars = false123                    isAppearanceLightNavigationBars = false124                }125            }126            val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT127            SideEffect {128                if (!(BuildConfig.DEBUG && QaFlags.disableSecureCaptureForQa)) {129                    SecureWindowPolicy.setUserSecure(window, !settings.allowScreenshots)130                }131            }132            CompositionLocalProvider(133                LocalFavicons provides favicons,134                LocalFaviconsEnabled provides faviconsAllowed(settings),135            ) {136                PeachTheme(137                    accent = accent,138                    reduceMotion = settings.reduceMotion,139                    textSize = settings.textSize,140                ) {141                    WideScreenScale(scalePreference = settings.uiScale) {142                        PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked, uiGateLocked = vault.uiGateLocked)143                    }144                }145            }146        }147    }148}149150/** Layout width a phone gives us; anything past this is "extra" to spend. */151private val BASE_WIDTH = 411.dp152153/**154 * The single place a launch intent is interpreted, shared by cold launch155 * (`onCreate`) and warm relaunch (`onNewIntent`) — launchMode="singleTask" means156 * either can be the one that carries a shortcut tap, and the two paths silently157 * drifting apart is how the shortcut actions went unhandled in the first place.158 *159 * Everything here only records a request; nothing navigates. The nav host acts on160 * it once the vault is unlocked.161 */162private fun MainActivity.routeIntent(intent: Intent) {163    if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()164    forwardNewEntryRequest(intent)165    routeAction(intent.action)?.let(PendingActionQueue::request)166}167168/**169 * Pick up the "+ Create new entry" launcher from the autofill picker and stash170 * the prefill payload in [autofillNav]. The NavHost observes that flow and171 * navigates to the new-entry editor, mirroring the UpdateRepository pattern.172 */173private fun MainActivity.forwardNewEntryRequest(intent: Intent) {174    if (!intent.getBooleanExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, false)) return175    // F3: MainActivity is exported; only accept the prefill payload when the176    // one-time token proves the launch came from the in-process picker.177    if (!AutofillIntents.consumeNewEntryToken(intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN))) return178    autofillNav.requestNewEntry(179        url = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL),180        username = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME),181        email = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL),182    )183}184185/** Cap on the up-scale — past this, big text stops reading as deliberate. */186private const val MAX_SCALE = 1.35f187188/**189 * On a foldable's inner screen the app was a stretched phone: phone-sized type190 * and rows with the surplus width absorbed as empty space. Scaling the density191 * grows every dp and sp together, so the same single-column layout simply reads192 * as a bigger screen — no per-screen breakpoints to keep in sync.193 *194 * Auto mode keeps the existing foldable treatment but leaves tablet-like195 * windows at phone density; Compact and Large are explicit overrides.196 * No-op at or below [BASE_WIDTH], so phones are untouched.197 */198@Composable199private fun WideScreenScale(200    scalePreference: String = "auto",201    content: @Composable () -> Unit,202) {203    BoxWithConstraints(Modifier.fillMaxSize()) {204        val base = LocalDensity.current205        val scale = wideScreenScaleFor(scalePreference, maxWidth, maxHeight)206        if (scale == 1f) {207            content()208        } else {209            CompositionLocalProvider(210                LocalDensity provides Density(base.density * scale, base.fontScale),211                content = content,212            )213        }214    }215}216217internal fun wideScreenScaleFor(preference: String, width: Dp, height: Dp): Float {218    val phoneScale = (width / BASE_WIDTH).coerceIn(1f, MAX_SCALE)219    return when (preference) {220        "compact" -> 1f221        "large" -> phoneScale222        else -> if (isTabletLike(width, height)) 1f else phoneScale223    }224}225226private fun isTabletLike(width: Dp, height: Dp): Boolean {227    val shortest = minOf(width.value, height.value)228    val longest = maxOf(width.value, height.value)229    return shortest >= 720f || (shortest >= 600f && longest / shortest >= 1.45f)230}