MainActivity.kt

8.3 KB · raw

1package com.peachpasswords23import android.content.Intent4import android.content.res.Configuration5import android.graphics.Color as AndroidColor6import android.graphics.drawable.ColorDrawable7import android.os.Bundle8import android.view.WindowManager9import androidx.activity.compose.setContent10import androidx.activity.enableEdgeToEdge11import androidx.compose.foundation.isSystemInDarkTheme12import androidx.compose.runtime.SideEffect13import androidx.compose.ui.unit.dp14import androidx.compose.ui.unit.Density15import androidx.compose.ui.platform.LocalDensity16import androidx.compose.ui.Modifier17import androidx.compose.runtime.CompositionLocalProvider18import androidx.compose.runtime.Composable19import androidx.compose.foundation.layout.fillMaxSize20import androidx.compose.foundation.layout.BoxWithConstraints21import androidx.compose.runtime.collectAsState22import androidx.compose.runtime.getValue23import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen24import androidx.core.view.WindowCompat25import androidx.fragment.app.FragmentActivity26import dagger.hilt.android.AndroidEntryPoint27import javax.inject.Inject28import com.peachpasswords.autofill.AutofillIntents29import com.peachpasswords.autofill.AutofillNavRepository30import com.peachpasswords.ui.PeachAppNavHost31import com.peachpasswords.ui.components.QaFlags32import com.peachpasswords.ui.onboarding.ONBOARD_KEY33import com.peachpasswords.ui.onboarding.ONBOARD_PREFS34import com.peachpasswords.ui.onboarding.onboardingStartDestination35import com.peachpasswords.ui.theme.ACCENT_COLORS36import com.peachpasswords.ui.theme.DEFAULT_ACCENT37import com.peachpasswords.ui.theme.PeachTheme38import com.peachpasswords.updates.EXTRA_OPEN_UPDATE39import com.peachpasswords.updates.UpdateRepository40import com.peachpasswords.vault.VaultLockTimer41import com.peachpasswords.vault.VaultRepository4243// FragmentActivity (not ComponentActivity) so androidx BiometricPrompt can attach.44@AndroidEntryPoint45class MainActivity : FragmentActivity() {46    @Inject lateinit var vault: VaultRepository47    @Inject lateinit var lockTimer: VaultLockTimer48    @Inject lateinit var updates: UpdateRepository49    @Inject lateinit var autofillNav: AutofillNavRepository5051    // Every touch/key event resets the idle auto-lock countdown (CF-1).52    override fun onUserInteraction() {53        super.onUserInteraction()54        lockTimer.poke()55    }5657    // launchMode="singleTask" reuses this instance across `adb shell am start`58    // relaunches, so the QA capture-bypass extra (see onCreate) needs to be59    // re-read here too, not just once at process start.60    override fun onNewIntent(intent: Intent) {61        super.onNewIntent(intent)62        setIntent(intent)63        if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()64        forwardNewEntryRequest(intent)65        if (BuildConfig.DEBUG) {66            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", false)67        }68    }6970    override fun onCreate(savedInstanceState: Bundle?) {71        installSplashScreen()72        super.onCreate(savedInstanceState)7374        // Cold launch from the update notification (the warm case is onNewIntent).75        if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()76        forwardNewEntryRequest(intent)7778        // Match the splash/window fill to the resolved theme's --bg so launch79        // never flashes the wrong colour before Compose draws. Resolved from the80        // persisted setting synchronously (system mode reads the device night mode).81        val initialTheme = vault.settings.value.theme82        val initialDark = when (initialTheme) {83            "light" -> false84            "system" -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==85                Configuration.UI_MODE_NIGHT_YES86            else -> true87        }88        window.setBackgroundDrawable(89            ColorDrawable(AndroidColor.parseColor(if (initialDark) "#0C0A06" else "#EFEEE9")),90        )9192        enableEdgeToEdge()9394        // Block screenshots / recents thumbnails of the vault in release builds.95        if (!BuildConfig.DEBUG) {96            window.setFlags(97                WindowManager.LayoutParams.FLAG_SECURE,98                WindowManager.LayoutParams.FLAG_SECURE,99            )100        } else {101            // QA-only, debug-build-only: `adb shell am start -n <pkg>/.MainActivity102            // --ez qa_disable_secure_flag true` lets automated visual QA screenshot103            // otherwise-FLAG_SECURE screens (SecureFlagEffect). Off unless asked.104            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", false)105        }106107        // Onboarding runs once, only on a fresh install with no vault yet.108        val onboarded = getSharedPreferences(ONBOARD_PREFS, MODE_PRIVATE)109            .getBoolean(ONBOARD_KEY, false)110        val start = onboardingStartDestination(hasVault = vault.hasVault(), onboarded = onboarded)111112        setContent {113            val settings by vault.settings.collectAsState()114            val darkTheme = when (settings.theme) {115                "light" -> false116                "system" -> isSystemInDarkTheme()117                else -> true118            }119            // enableEdgeToEdge() above only knows the system's own day/night120            // mode at launch. This app's theme can be forced independent of121            // that (settings.theme == "dark"/"light"), so on a mismatch —122            // e.g. system in light mode, app forced dark — the status bar123            // icons render in the system's assumed color (dark-on-dark) and124            // become invisible against the app's actual background, reading125            // as if the app extends behind/under the status bar. Keep the126            // system bar icon appearance in sync with the resolved theme.127            SideEffect {128                WindowCompat.getInsetsController(window, window.decorView).apply {129                    isAppearanceLightStatusBars = !darkTheme130                    isAppearanceLightNavigationBars = !darkTheme131                }132            }133            val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT134            PeachTheme(135                darkTheme = darkTheme,136                accent = accent,137                reduceMotion = settings.reduceMotion,138            ) {139                WideScreenScale {140                    PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked)141                }142            }143        }144    }145}146147/** Layout width a phone gives us; anything past this is "extra" to spend. */148private val BASE_WIDTH = 411.dp149150/**151 * Pick up the "+ Create new entry" launcher from the autofill picker and stash152 * the prefill payload in [autofillNav]. The NavHost observes that flow and153 * navigates to the new-entry editor, mirroring the UpdateRepository pattern.154 */155private fun MainActivity.forwardNewEntryRequest(intent: Intent) {156    if (!intent.getBooleanExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, false)) return157    autofillNav.requestNewEntry(158        url = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL),159        username = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME),160        email = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL),161    )162}163164/** Cap on the up-scale — past this, big text stops reading as deliberate. */165private const val MAX_SCALE = 1.35f166167/**168 * On a foldable's inner screen the app was a stretched phone: phone-sized type169 * and rows with the surplus width absorbed as empty space. Scaling the density170 * grows every dp and sp together, so the same single-column layout simply reads171 * as a bigger screen — no per-screen breakpoints to keep in sync.172 *173 * Ceiling: a uniform scale, not real adaptive layout. If a screen ever wants a174 * genuinely different wide-screen arrangement it needs its own breakpoint.175 * No-op at or below [BASE_WIDTH], so phones are untouched.176 */177@Composable178private fun WideScreenScale(content: @Composable () -> Unit) {179    BoxWithConstraints(Modifier.fillMaxSize()) {180        val base = LocalDensity.current181        val scale = (maxWidth / BASE_WIDTH).coerceIn(1f, MAX_SCALE)182        if (scale == 1f) {183            content()184        } else {185            CompositionLocalProvider(186                LocalDensity provides Density(base.density * scale, base.fontScale),187                content = content,188            )189        }190    }191}