MainActivity.kt

7.4 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.ui.PeachAppNavHost29import com.peachpasswords.ui.components.QaFlags30import com.peachpasswords.ui.onboarding.ONBOARD_KEY31import com.peachpasswords.ui.onboarding.ONBOARD_PREFS32import com.peachpasswords.ui.onboarding.onboardingStartDestination33import com.peachpasswords.ui.theme.ACCENT_COLORS34import com.peachpasswords.ui.theme.DEFAULT_ACCENT35import com.peachpasswords.ui.theme.PeachTheme36import com.peachpasswords.updates.EXTRA_OPEN_UPDATE37import com.peachpasswords.updates.UpdateRepository38import com.peachpasswords.vault.VaultLockTimer39import com.peachpasswords.vault.VaultRepository4041// FragmentActivity (not ComponentActivity) so androidx BiometricPrompt can attach.42@AndroidEntryPoint43class MainActivity : FragmentActivity() {44    @Inject lateinit var vault: VaultRepository45    @Inject lateinit var lockTimer: VaultLockTimer46    @Inject lateinit var updates: UpdateRepository4748    // Every touch/key event resets the idle auto-lock countdown (CF-1).49    override fun onUserInteraction() {50        super.onUserInteraction()51        lockTimer.poke()52    }5354    // launchMode="singleTask" reuses this instance across `adb shell am start`55    // relaunches, so the QA capture-bypass extra (see onCreate) needs to be56    // re-read here too, not just once at process start.57    override fun onNewIntent(intent: Intent) {58        super.onNewIntent(intent)59        setIntent(intent)60        if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()61        if (BuildConfig.DEBUG) {62            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", false)63        }64    }6566    override fun onCreate(savedInstanceState: Bundle?) {67        installSplashScreen()68        super.onCreate(savedInstanceState)6970        // Cold launch from the update notification (the warm case is onNewIntent).71        if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()7273        // Match the splash/window fill to the resolved theme's --bg so launch74        // never flashes the wrong colour before Compose draws. Resolved from the75        // persisted setting synchronously (system mode reads the device night mode).76        val initialTheme = vault.settings.value.theme77        val initialDark = when (initialTheme) {78            "light" -> false79            "system" -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==80                Configuration.UI_MODE_NIGHT_YES81            else -> true82        }83        window.setBackgroundDrawable(84            ColorDrawable(AndroidColor.parseColor(if (initialDark) "#0C0A06" else "#EFEEE9")),85        )8687        enableEdgeToEdge()8889        // Block screenshots / recents thumbnails of the vault in release builds.90        if (!BuildConfig.DEBUG) {91            window.setFlags(92                WindowManager.LayoutParams.FLAG_SECURE,93                WindowManager.LayoutParams.FLAG_SECURE,94            )95        } else {96            // QA-only, debug-build-only: `adb shell am start -n <pkg>/.MainActivity97            // --ez qa_disable_secure_flag true` lets automated visual QA screenshot98            // otherwise-FLAG_SECURE screens (SecureFlagEffect). Off unless asked.99            QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", false)100        }101102        // Onboarding runs once, only on a fresh install with no vault yet.103        val onboarded = getSharedPreferences(ONBOARD_PREFS, MODE_PRIVATE)104            .getBoolean(ONBOARD_KEY, false)105        val start = onboardingStartDestination(hasVault = vault.hasVault(), onboarded = onboarded)106107        setContent {108            val settings by vault.settings.collectAsState()109            val darkTheme = when (settings.theme) {110                "light" -> false111                "system" -> isSystemInDarkTheme()112                else -> true113            }114            // enableEdgeToEdge() above only knows the system's own day/night115            // mode at launch. This app's theme can be forced independent of116            // that (settings.theme == "dark"/"light"), so on a mismatch —117            // e.g. system in light mode, app forced dark — the status bar118            // icons render in the system's assumed color (dark-on-dark) and119            // become invisible against the app's actual background, reading120            // as if the app extends behind/under the status bar. Keep the121            // system bar icon appearance in sync with the resolved theme.122            SideEffect {123                WindowCompat.getInsetsController(window, window.decorView).apply {124                    isAppearanceLightStatusBars = !darkTheme125                    isAppearanceLightNavigationBars = !darkTheme126                }127            }128            val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT129            PeachTheme(130                darkTheme = darkTheme,131                accent = accent,132                reduceMotion = settings.reduceMotion,133            ) {134                WideScreenScale {135                    PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked)136                }137            }138        }139    }140}141142/** Layout width a phone gives us; anything past this is "extra" to spend. */143private val BASE_WIDTH = 411.dp144145/** Cap on the up-scale — past this, big text stops reading as deliberate. */146private const val MAX_SCALE = 1.35f147148/**149 * On a foldable's inner screen the app was a stretched phone: phone-sized type150 * and rows with the surplus width absorbed as empty space. Scaling the density151 * grows every dp and sp together, so the same single-column layout simply reads152 * as a bigger screen — no per-screen breakpoints to keep in sync.153 *154 * Ceiling: a uniform scale, not real adaptive layout. If a screen ever wants a155 * genuinely different wide-screen arrangement it needs its own breakpoint.156 * No-op at or below [BASE_WIDTH], so phones are untouched.157 */158@Composable159private fun WideScreenScale(content: @Composable () -> Unit) {160    BoxWithConstraints(Modifier.fillMaxSize()) {161        val base = LocalDensity.current162        val scale = (maxWidth / BASE_WIDTH).coerceIn(1f, MAX_SCALE)163        if (scale == 1f) {164            content()165        } else {166            CompositionLocalProvider(167                LocalDensity provides Density(base.density * scale, base.fontScale),168                content = content,169            )170        }171    }172}