UpdateScreen.kt

19.5 KB · raw

1package com.peachpasswords.ui.updates23import android.Manifest4import android.content.Context5import android.content.pm.PackageManager6import android.os.Build7import androidx.activity.compose.rememberLauncherForActivityResult8import androidx.activity.result.contract.ActivityResultContracts9import androidx.compose.foundation.background10import androidx.compose.foundation.border11import androidx.compose.foundation.clickable12import androidx.compose.foundation.layout.Arrangement13import androidx.compose.foundation.layout.Box14import androidx.compose.foundation.layout.Column15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.Spacer17import androidx.compose.foundation.layout.fillMaxSize18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.padding21import androidx.compose.foundation.layout.size22import androidx.compose.foundation.layout.systemBarsPadding23import androidx.compose.foundation.rememberScrollState24import androidx.compose.foundation.shape.RoundedCornerShape25import androidx.compose.foundation.verticalScroll26import androidx.compose.material.icons.Icons27import androidx.compose.material.icons.automirrored.rounded.ArrowBack28import androidx.compose.material3.Icon29import androidx.compose.material3.Text30import androidx.compose.runtime.Composable31import androidx.compose.runtime.LaunchedEffect32import androidx.compose.runtime.collectAsState33import androidx.compose.runtime.getValue34import androidx.compose.runtime.mutableStateOf35import androidx.compose.runtime.remember36import androidx.compose.runtime.setValue37import androidx.compose.ui.Alignment38import androidx.compose.ui.Modifier39import androidx.compose.ui.draw.clip40import androidx.compose.ui.platform.LocalContext41import androidx.compose.ui.unit.dp42import androidx.compose.ui.unit.sp43import androidx.core.content.ContextCompat44import androidx.hilt.navigation.compose.hiltViewModel45import androidx.lifecycle.ViewModel46import androidx.lifecycle.viewModelScope47import com.peachpasswords.ui.components.PeachTopBar48import com.peachpasswords.ui.theme.LocalPeachAccent49import com.peachpasswords.ui.theme.PeachSpacing50import com.peachpasswords.ui.theme.PeachType51import com.peachpasswords.ui.theme.ShellBackground52import com.peachpasswords.ui.theme.peachColors53import com.peachpasswords.updates.ChecksumMismatchException54import com.peachpasswords.updates.ReleaseManifest55import com.peachpasswords.updates.ReleaseNote56import com.peachpasswords.updates.UpdateRepository57import dagger.hilt.android.lifecycle.HiltViewModel58import kotlinx.coroutines.flow.MutableStateFlow59import kotlinx.coroutines.flow.StateFlow60import kotlinx.coroutines.flow.asStateFlow61import kotlinx.coroutines.launch62import java.io.File63import javax.inject.Inject6465enum class UpdatePhase { Idle, Checking, UpToDate, Available, Downloading, Ready, Failed }6667data class UpdateUiState(68    val phase: UpdatePhase = UpdatePhase.Idle,69    val manifest: ReleaseManifest? = null,70    val progress: Float = 0f,71    val apk: File? = null,72    val error: String? = null,73    /** True once the user has asked to install but hasn't granted install-unknown-apps. */74    val explainInstallPermission: Boolean = false,75)7677enum class ChangelogPhase { Idle, Loading, Loaded, Failed }7879data class ChangelogUiState(80    val phase: ChangelogPhase = ChangelogPhase.Idle,81    val entries: List<ReleaseNote> = emptyList(),82)8384@HiltViewModel85class UpdateViewModel @Inject constructor(86    private val updates: UpdateRepository,87) : ViewModel() {8889    val currentVersion: String = updates.currentVersion90    val available: StateFlow<ReleaseManifest?> = updates.available91    val whatsNew: StateFlow<String?> = updates.whatsNew92    val openRequested: StateFlow<Boolean> = updates.openRequested9394    private val _state = MutableStateFlow(95        updates.available.value96            ?.let { UpdateUiState(phase = UpdatePhase.Available, manifest = it) }97            ?: UpdateUiState(),98    )99    val state: StateFlow<UpdateUiState> = _state.asStateFlow()100101    private val _changelog = MutableStateFlow(ChangelogUiState())102    /** Full Kepr release history for the settings changelog, loaded on demand. */103    val changelog: StateFlow<ChangelogUiState> = _changelog.asStateFlow()104105    fun loadChangelog() {106        if (_changelog.value.phase == ChangelogPhase.Loading || _changelog.value.phase == ChangelogPhase.Loaded) return107        _changelog.value = ChangelogUiState(phase = ChangelogPhase.Loading)108        viewModelScope.launch {109            val entries = updates.fetchHistory()110            _changelog.value = if (entries == null) {111                ChangelogUiState(phase = ChangelogPhase.Failed)112            } else {113                ChangelogUiState(phase = ChangelogPhase.Loaded, entries = entries)114            }115        }116    }117118    fun consumeOpenRequest() = updates.consumeOpenRequest()119120    fun dismissWhatsNew() = updates.markChangelogSeen()121122    fun check() {123        if (_state.value.phase == UpdatePhase.Checking || _state.value.phase == UpdatePhase.Downloading) return124        _state.value = _state.value.copy(phase = UpdatePhase.Checking, error = null)125        viewModelScope.launch {126            _state.value = try {127                val manifest = updates.check()128                if (manifest == null) {129                    UpdateUiState(phase = UpdatePhase.UpToDate)130                } else {131                    UpdateUiState(phase = UpdatePhase.Available, manifest = manifest)132                }133            } catch (e: Exception) {134                // A failed re-check doesn't invalidate an update we already know135                // about — keep it offerable rather than dropping to a dead end.136                _state.value.copy(137                    phase = if (_state.value.manifest != null) UpdatePhase.Available else UpdatePhase.Failed,138                    error = "Couldn't reach the update server: ${e.javaClass.simpleName}: ${e.message ?: "unknown error"}",139                )140            }141        }142    }143144    fun download() {145        val manifest = _state.value.manifest ?: return146        if (_state.value.phase == UpdatePhase.Downloading) return147        _state.value = _state.value.copy(phase = UpdatePhase.Downloading, progress = 0f, error = null)148        viewModelScope.launch {149            _state.value = try {150                val apk = updates.downloadAndVerify(manifest) { p ->151                    _state.value = _state.value.copy(progress = p)152                }153                _state.value.copy(phase = UpdatePhase.Ready, apk = apk, progress = 1f)154            } catch (e: ChecksumMismatchException) {155                // Deliberately loud: a mismatch means the file is not what Kepr156                // published, and the download has already been deleted.157                _state.value.copy(158                    phase = UpdatePhase.Failed,159                    apk = null,160                    error = "The download didn't match its checksum and was deleted. Nothing was installed.",161                )162            } catch (e: Exception) {163                _state.value.copy(164                    phase = UpdatePhase.Failed,165                    apk = null,166                    error = "Download failed: ${e.javaClass.simpleName}: ${e.message ?: "unknown error"}",167                )168            }169        }170    }171172    /**173     * Install the verified APK. If Android hasn't granted this app the174     * install-unknown-apps permission, surface the in-app explanation instead of175     * dropping a bare system dialog on the user.176     */177    fun install(context: Context) {178        val apk = _state.value.apk ?: return179        if (!updates.canInstallPackages()) {180            _state.value = _state.value.copy(explainInstallPermission = true)181            return182        }183        _state.value = _state.value.copy(explainInstallPermission = false)184        runCatching { context.startActivity(updates.installIntent(apk)) }185            .onFailure {186                _state.value = _state.value.copy(187                    phase = UpdatePhase.Failed,188                    error = "No package installer available on this device",189                )190            }191    }192193    fun openInstallPermissionSettings(context: Context) {194        runCatching { context.startActivity(updates.unknownSourcesSettingsIntent()) }195    }196197    fun dismissInstallExplanation() {198        _state.value = _state.value.copy(explainInstallPermission = false)199    }200}201202@Composable203fun UpdateScreen(204    onBack: () -> Unit,205    vm: UpdateViewModel = hiltViewModel(),206) {207    val state by vm.state.collectAsState()208    val context = LocalContext.current209210    // Reaching this screen with nothing known yet: check straight away, since the211    // user got here to find out.212    LaunchedEffect(Unit) {213        if (state.phase == UpdatePhase.Idle) vm.check()214    }215216    ShellBackground {217        Column(218            Modifier219                .fillMaxSize()220                .systemBarsPadding()221                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),222        ) {223            PeachTopBar(224                title = "Update",225                leading = {226                    Icon(227                        Icons.AutoMirrored.Rounded.ArrowBack,228                        contentDescription = "back",229                        tint = peachColors.T0,230                        modifier = Modifier231                            .size(22.dp)232                            .clip(RoundedCornerShape(PeachSpacing.sm))233                            .clickable(onClick = onBack),234                    )235                },236            )237            Spacer(Modifier.height(PeachSpacing.lg))238239            Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {240                Text(241                    when (state.phase) {242                        UpdatePhase.Checking -> "checking…"243                        UpdatePhase.UpToDate -> "you're up to date"244                        UpdatePhase.Available -> "a newer Peach"245                        UpdatePhase.Downloading -> "downloading"246                        UpdatePhase.Ready -> "ready to install"247                        UpdatePhase.Failed -> "couldn't finish"248                        UpdatePhase.Idle -> "updates"249                    },250                    color = peachColors.T0,251                    style = PeachType.createHeadline,252                )253                Spacer(Modifier.height(PeachSpacing.xs))254                Text(255                    buildString {256                        append("Installed v${vm.currentVersion}")257                        state.manifest?.let { append("  ·  latest v${it.version}") }258                    },259                    color = peachColors.T1,260                    style = PeachType.labelMono,261                )262263                // Android 13+ won't post the "update available" notification without an264                // explicit grant, so ask here — at the top, next to the version info that265                // explains why — instead of burying it below the install button where the266                // user installs and leaves without ever seeing it.267                NotificationOptIn()268269                state.error?.let { message ->270                    Spacer(Modifier.height(PeachSpacing.md))271                    Box(272                        Modifier.fillMaxWidth()273                            .clip(RoundedCornerShape(10.dp))274                            .background(peachColors.BreachBg)275                            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(10.dp))276                            .padding(PeachSpacing.md),277                    ) {278                        Text(message, color = peachColors.Breach, style = PeachType.hint)279                    }280                }281282                state.manifest?.notes?.let { notes ->283                    Spacer(Modifier.height(PeachSpacing.lg))284                    SectionLabel("WHAT'S IN IT")285                    ReleaseNotes(notes)286                }287288                if (state.phase == UpdatePhase.Downloading) {289                    Spacer(Modifier.height(PeachSpacing.lg))290                    ProgressBar(state.progress)291                }292293                if (state.explainInstallPermission) {294                    Spacer(Modifier.height(PeachSpacing.lg))295                    InstallPermissionExplanation(296                        onOpenSettings = { vm.openInstallPermissionSettings(context) },297                        onDismiss = { vm.dismissInstallExplanation() },298                    )299                }300301                Spacer(Modifier.height(PeachSpacing.xl))302                when (state.phase) {303                    UpdatePhase.Available ->304                        PrimaryButton("Download v${state.manifest?.version}") { vm.download() }305                    UpdatePhase.Ready ->306                        PrimaryButton("Install v${state.manifest?.version}") { vm.install(context) }307                    UpdatePhase.Downloading -> Unit308                    UpdatePhase.Checking -> Unit309                    else -> PrimaryButton("Check for updates") { vm.check() }310                }311312                if (state.phase == UpdatePhase.Ready) {313                    Spacer(Modifier.height(PeachSpacing.sm))314                    Text(315                        "Verified against the checksum Kepr published for this release.",316                        color = peachColors.T1,317                        style = PeachType.hint,318                    )319                }320321                Spacer(Modifier.height(PeachSpacing.xl))322                Spacer(Modifier.height(PeachSpacing.xxl))323            }324        }325    }326}327328/**329 * Android 13+ won't post the "update available" notification without this grant.330 * Asked for here, next to the explanation of what it's for, rather than as an331 * unexplained prompt at some unrelated moment.332 */333@Composable334private fun NotificationOptIn() {335    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return336    val context = LocalContext.current337    var granted by remember {338        mutableStateOf(339            ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==340                PackageManager.PERMISSION_GRANTED,341        )342    }343    if (granted) return344    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {345        granted = it346    }347    Spacer(Modifier.height(PeachSpacing.lg))348    SectionLabel("NOTIFICATIONS")349    Text(350        "Peach can tell you when a new version is out. Nothing else uses this.",351        color = peachColors.T1,352        style = PeachType.hint,353    )354    Spacer(Modifier.height(PeachSpacing.sm))355    GhostButton("Allow update notifications") {356        launcher.launch(Manifest.permission.POST_NOTIFICATIONS)357    }358}359360/**361 * The in-app explanation that must come before Android's own permission screen —362 * a bare system prompt for "install unknown apps" reads like something has gone363 * wrong, and the user has no way to tell what asked for it.364 */365@Composable366private fun InstallPermissionExplanation(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {367    Column(368        Modifier.fillMaxWidth()369            .clip(RoundedCornerShape(12.dp))370            .background(peachColors.SettingsGroupBg)371            .border(1.dp, peachColors.T2, RoundedCornerShape(12.dp))372            .padding(PeachSpacing.md),373    ) {374        Text(375            "Android needs your permission",376            color = peachColors.T0,377            style = PeachType.onboardingHeadline,378        )379        Spacer(Modifier.height(PeachSpacing.sm))380        Text(381            "Peach isn't distributed through the Play Store, so Android treats this " +382                "update like any other app you install yourself. The next screen is " +383                "Android's own — turn on \"Allow from this source\" for Peach, then come " +384                "back here and tap install. You can turn it off again afterwards.",385            color = peachColors.T1,386            style = PeachType.hint,387        )388        Spacer(Modifier.height(PeachSpacing.md))389        Row(horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {390            GhostButton("Not now", onDismiss)391            GhostButton("Open Android settings", onOpenSettings)392        }393    }394}395396@Composable397internal fun SectionLabel(text: String) {398    Text(399        text,400        modifier = Modifier.padding(bottom = PeachSpacing.sm),401        color = peachColors.T1,402        style = PeachType.tinyMono,403    )404}405406/**407 * Release notes as Peach type rather than a raw markdown dump: headings in the408 * display face, bullets as bullets, inline emphasis markers stripped. Anything409 * fancier than that is left as plain text on purpose — the notes are prose.410 */411@Composable412internal fun ReleaseNotes(notes: String) {413    Column(verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs)) {414        notes.lines().forEach { raw ->415            val line = raw.trim()416            when {417                line.isEmpty() -> Spacer(Modifier.height(PeachSpacing.xs))418                line.startsWith("#") -> Text(419                    stripInlineMarkers(line.trimStart('#').trim()),420                    modifier = Modifier.padding(top = PeachSpacing.sm),421                    color = peachColors.T0,422                    style = PeachType.settingsLede.copy(fontSize = 15.sp),423                )424                line.startsWith("- ") || line.startsWith("* ") || line.startsWith("• ") -> Row {425                    Text("·  ", color = LocalPeachAccent.current, style = PeachType.body)426                    Text(427                        stripInlineMarkers(line.drop(2).trim()),428                        color = peachColors.T1,429                        style = PeachType.body.copy(lineHeight = 19.sp),430                    )431                }432                else -> Text(433                    stripInlineMarkers(line),434                    color = peachColors.T1,435                    style = PeachType.body.copy(lineHeight = 19.sp),436                )437            }438        }439    }440}441442internal fun stripInlineMarkers(line: String): String =443    line.replace("**", "").replace("__", "").replace("`", "")444445@Composable446private fun ProgressBar(progress: Float) {447    val accent = LocalPeachAccent.current448    Column {449        Box(450            Modifier.fillMaxWidth().height(4.dp)451                .clip(RoundedCornerShape(999.dp))452                .background(peachColors.T3),453        ) {454            Box(455                Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(4.dp)456                    .clip(RoundedCornerShape(999.dp))457                    .background(accent),458            )459        }460        Spacer(Modifier.height(PeachSpacing.sm))461        Text("${(progress * 100).toInt()}%", color = peachColors.T1, style = PeachType.labelMono)462    }463}464465@Composable466internal fun PrimaryButton(label: String, onClick: () -> Unit) {467    val accent = LocalPeachAccent.current468    Box(469        Modifier.fillMaxWidth()470            .clip(RoundedCornerShape(999.dp))471            .background(accent.copy(alpha = 0.16f))472            .border(1.dp, accent.copy(alpha = 0.55f), RoundedCornerShape(999.dp))473            .clickable(onClick = onClick)474            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),475        contentAlignment = Alignment.Center,476    ) {477        Text(label, color = accent, style = PeachType.sortPill.copy(fontSize = 11.sp))478    }479}480481@Composable482private fun GhostButton(label: String, onClick: () -> Unit) {483    Box(484        Modifier.clip(RoundedCornerShape(999.dp))485            .border(1.dp, peachColors.T2, RoundedCornerShape(999.dp))486            .clickable(onClick = onClick)487            .padding(horizontal = PeachSpacing.md, vertical = 9.dp),488        contentAlignment = Alignment.Center,489    ) {490        Text(label, color = peachColors.T0, style = PeachType.sortPill.copy(fontSize = 10.sp))491    }492}