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 dagger.hilt.android.lifecycle.HiltViewModel48import com.peachpasswords.ui.components.PeachSectionHeader49import com.peachpasswords.ui.theme.LocalPeachAccent50import com.peachpasswords.ui.theme.PeachSpacing51import com.peachpasswords.ui.theme.PeachType52import com.peachpasswords.ui.theme.ShellBackground53import com.peachpasswords.ui.theme.peachColors54import com.peachpasswords.updates.ChecksumMismatchException55import com.peachpasswords.updates.ReleaseManifest56import com.peachpasswords.updates.ReleaseNote57import com.peachpasswords.updates.UpdateCheckResult58import com.peachpasswords.updates.UpdateRepository59import kotlinx.coroutines.flow.MutableStateFlow60import kotlinx.coroutines.flow.StateFlow61import kotlinx.coroutines.flow.asStateFlow62import kotlinx.coroutines.launch63import java.io.File64import javax.inject.Inject6566enum class UpdatePhase { Idle, Checking, UpToDate, Available, Downloading, Ready, Failed }6768data class UpdateUiState(69    val phase: UpdatePhase = UpdatePhase.Idle,70    val manifest: ReleaseManifest? = null,71    val progress: Float = 0f,72    val apk: File? = null,73    val error: String? = null,74    /** True once the user has asked to install but hasn't granted install-unknown-apps. */75    val explainInstallPermission: Boolean = false,76)7778enum class ChangelogPhase { Idle, Loading, Loaded, Failed }7980data class ChangelogUiState(81    val phase: ChangelogPhase = ChangelogPhase.Idle,82    val entries: List<ReleaseNote> = emptyList(),83)8485@HiltViewModel86class UpdateViewModel @Inject constructor(87    private val updates: UpdateRepository,88) : ViewModel() {8990    val currentVersion: String = updates.currentVersion91    val available: StateFlow<ReleaseManifest?> = updates.available92    val whatsNew: StateFlow<String?> = updates.whatsNew93    val openRequested: StateFlow<Boolean> = updates.openRequested9495    private val _state = MutableStateFlow(96        updates.available.value97            ?.let { UpdateUiState(phase = UpdatePhase.Available, manifest = it) }98            ?: UpdateUiState(),99    )100    val state: StateFlow<UpdateUiState> = _state.asStateFlow()101102    private val _changelog = MutableStateFlow(ChangelogUiState())103    /** Full Kepr release history for the settings changelog, loaded on demand. */104    val changelog: StateFlow<ChangelogUiState> = _changelog.asStateFlow()105106    fun loadChangelog() {107        if (_changelog.value.phase == ChangelogPhase.Loading || _changelog.value.phase == ChangelogPhase.Loaded) return108        _changelog.value = ChangelogUiState(phase = ChangelogPhase.Loading)109        viewModelScope.launch {110            val entries = updates.fetchHistory()111            _changelog.value = if (entries == null) {112                ChangelogUiState(phase = ChangelogPhase.Failed)113            } else {114                ChangelogUiState(phase = ChangelogPhase.Loaded, entries = entries)115            }116        }117    }118119    fun consumeOpenRequest() = updates.consumeOpenRequest()120121    fun dismissWhatsNew() = updates.markChangelogSeen()122123    fun check() {124        if (_state.value.phase == UpdatePhase.Checking || _state.value.phase == UpdatePhase.Downloading) return125        _state.value = _state.value.copy(phase = UpdatePhase.Checking, error = null)126        viewModelScope.launch {127            _state.value = try {128                when (val result = updates.check()) {129                    is UpdateCheckResult.Accepted -> UpdateUiState(130                        phase = UpdatePhase.Available,131                        manifest = result.manifest,132                    )133                    is UpdateCheckResult.NoUpdate -> UpdateUiState(phase = UpdatePhase.UpToDate)134                    is UpdateCheckResult.RollbackSuppressed -> _state.value.copy(135                        phase = if (_state.value.manifest != null) UpdatePhase.Available else UpdatePhase.UpToDate,136                        error = "A rollback was suppressed (served ${result.servedVersion}; local high-water ${result.highWaterVersion}).",137                    )138                }139            } catch (e: Exception) {140                // A failed re-check doesn't invalidate an update we already know141                // about — keep it offerable rather than dropping to a dead end.142                _state.value.copy(143                    phase = if (_state.value.manifest != null) UpdatePhase.Available else UpdatePhase.Failed,144                    error = "Couldn't reach the update server: ${e.javaClass.simpleName}: ${e.message ?: "unknown error"}",145                )146            }147        }148    }149150    fun download() {151        val manifest = _state.value.manifest ?: return152        if (_state.value.phase == UpdatePhase.Downloading) return153        _state.value = _state.value.copy(phase = UpdatePhase.Downloading, progress = 0f, error = null)154        viewModelScope.launch {155            _state.value = try {156                val apk = updates.downloadAndVerify(manifest) { p ->157                    _state.value = _state.value.copy(progress = p)158                }159                _state.value.copy(phase = UpdatePhase.Ready, apk = apk, progress = 1f)160            } catch (e: ChecksumMismatchException) {161                // Deliberately loud: a mismatch means the file is not what Kepr162                // published, and the download has already been deleted.163                _state.value.copy(164                    phase = UpdatePhase.Failed,165                    apk = null,166                    error = "The download didn't match its checksum and was deleted. Nothing was installed.",167                )168            } catch (e: Exception) {169                _state.value.copy(170                    phase = UpdatePhase.Failed,171                    apk = null,172                    error = "Download failed: ${e.javaClass.simpleName}: ${e.message ?: "unknown error"}",173                )174            }175        }176    }177178    /**179     * Install the verified APK. If Android hasn't granted this app the180     * install-unknown-apps permission, surface the in-app explanation instead of181     * dropping a bare system dialog on the user.182     */183    fun install(context: Context) {184        val apk = _state.value.apk ?: return185        if (!updates.canInstallPackages()) {186            _state.value = _state.value.copy(explainInstallPermission = true)187            return188        }189        _state.value = _state.value.copy(explainInstallPermission = false)190        runCatching { context.startActivity(updates.installIntent(apk)) }191            .onFailure {192                _state.value = _state.value.copy(193                    phase = UpdatePhase.Failed,194                    error = "No package installer available on this device",195                )196            }197    }198199    fun openInstallPermissionSettings(context: Context) {200        runCatching { context.startActivity(updates.unknownSourcesSettingsIntent()) }201    }202203    fun dismissInstallExplanation() {204        _state.value = _state.value.copy(explainInstallPermission = false)205    }206}207208@Composable209fun UpdateScreen(210    onBack: () -> Unit,211    vm: UpdateViewModel = hiltViewModel(),212) {213    val state by vm.state.collectAsState()214    val context = LocalContext.current215216    // Reaching this screen with nothing known yet: check straight away, since the217    // user got here to find out.218    LaunchedEffect(Unit) {219        if (state.phase == UpdatePhase.Idle) vm.check()220    }221222    ShellBackground {223        Column(224            Modifier225                .fillMaxSize()226                .systemBarsPadding()227                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),228        ) {229            PeachSectionHeader(section = "update", onHome = onBack)230            Spacer(Modifier.height(PeachSpacing.lg))231232            Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {233                Text(234                    when (state.phase) {235                        UpdatePhase.Checking -> "checking…"236                        UpdatePhase.UpToDate -> "you're up to date"237                        UpdatePhase.Available -> "a newer Peach"238                        UpdatePhase.Downloading -> "downloading"239                        UpdatePhase.Ready -> "ready to install"240                        UpdatePhase.Failed -> "couldn't finish"241                        UpdatePhase.Idle -> "updates"242                    },243                    color = peachColors.T0,244                    style = PeachType.createHeadline,245                )246                Spacer(Modifier.height(PeachSpacing.xs))247                Text(248                    buildString {249                        append("Installed v${vm.currentVersion}")250                        state.manifest?.let { append("  ·  latest v${it.version}") }251                    },252                    color = peachColors.T1,253                    style = PeachType.labelMono,254                )255256                // Android 13+ won't post the "update available" notification without an257                // explicit grant, so ask here — at the top, next to the version info that258                // explains why — instead of burying it below the install button where the259                // user installs and leaves without ever seeing it.260                NotificationOptIn()261262                state.error?.let { message ->263                    Spacer(Modifier.height(PeachSpacing.md))264                    Box(265                        Modifier.fillMaxWidth()266                            .clip(RoundedCornerShape(10.dp))267                            .background(peachColors.BreachBg)268                            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(10.dp))269                            .padding(PeachSpacing.md),270                    ) {271                        Text(message, color = peachColors.Breach, style = PeachType.hint)272                    }273                }274275                state.manifest?.notes?.let { notes ->276                    Spacer(Modifier.height(PeachSpacing.lg))277                    SectionLabel("WHAT'S IN IT")278                    ReleaseNotes(notes)279                }280281                if (state.phase == UpdatePhase.Downloading) {282                    Spacer(Modifier.height(PeachSpacing.lg))283                    ProgressBar(state.progress)284                }285286                if (state.explainInstallPermission) {287                    Spacer(Modifier.height(PeachSpacing.lg))288                    InstallPermissionExplanation(289                        onOpenSettings = { vm.openInstallPermissionSettings(context) },290                        onDismiss = { vm.dismissInstallExplanation() },291                    )292                }293294                Spacer(Modifier.height(PeachSpacing.xl))295                when (state.phase) {296                    UpdatePhase.Available ->297                        PrimaryButton("Download v${state.manifest?.version}") { vm.download() }298                    UpdatePhase.Ready ->299                        PrimaryButton("Install v${state.manifest?.version}") { vm.install(context) }300                    UpdatePhase.Downloading -> Unit301                    UpdatePhase.Checking -> Unit302                    else -> PrimaryButton("Check for updates") { vm.check() }303                }304305                if (state.phase == UpdatePhase.Ready) {306                    Spacer(Modifier.height(PeachSpacing.sm))307                    Text(308                        "Verified against the checksum Kepr published for this release.",309                        color = peachColors.T1,310                        style = PeachType.hint,311                    )312                }313314                Spacer(Modifier.height(PeachSpacing.xl))315                Spacer(Modifier.height(PeachSpacing.xxl))316            }317        }318    }319}320321/**322 * Android 13+ won't post the "update available" notification without this grant.323 * Asked for here, next to the explanation of what it's for, rather than as an324 * unexplained prompt at some unrelated moment.325 */326@Composable327private fun NotificationOptIn() {328    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return329    val context = LocalContext.current330    var granted by remember {331        mutableStateOf(332            ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==333                PackageManager.PERMISSION_GRANTED,334        )335    }336    if (granted) return337    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {338        granted = it339    }340    Spacer(Modifier.height(PeachSpacing.lg))341    SectionLabel("NOTIFICATIONS")342    Text(343        "Peach can tell you when a new version is out. Nothing else uses this.",344        color = peachColors.T1,345        style = PeachType.hint,346    )347    Spacer(Modifier.height(PeachSpacing.sm))348    GhostButton("Allow update notifications") {349        launcher.launch(Manifest.permission.POST_NOTIFICATIONS)350    }351}352353/**354 * The in-app explanation that must come before Android's own permission screen —355 * a bare system prompt for "install unknown apps" reads like something has gone356 * wrong, and the user has no way to tell what asked for it.357 */358@Composable359private fun InstallPermissionExplanation(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {360    Column(361        Modifier.fillMaxWidth()362            .clip(RoundedCornerShape(12.dp))363            .background(peachColors.SettingsGroupBg)364            .border(1.dp, peachColors.T2, RoundedCornerShape(12.dp))365            .padding(PeachSpacing.md),366    ) {367        Text(368            "Android needs your permission",369            color = peachColors.T0,370            style = PeachType.onboardingHeadline,371        )372        Spacer(Modifier.height(PeachSpacing.sm))373        Text(374            "Peach isn't distributed through the Play Store, so Android treats this " +375                "update like any other app you install yourself. The next screen is " +376                "Android's own — turn on \"Allow from this source\" for Peach, then come " +377                "back here and tap install. You can turn it off again afterwards.",378            color = peachColors.T1,379            style = PeachType.hint,380        )381        Spacer(Modifier.height(PeachSpacing.md))382        Row(horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {383            GhostButton("Not now", onDismiss)384            GhostButton("Open Android settings", onOpenSettings)385        }386    }387}388389@Composable390internal fun SectionLabel(text: String) {391    Text(392        text,393        modifier = Modifier.padding(bottom = PeachSpacing.sm),394        color = peachColors.T1,395        style = PeachType.tinyMono,396    )397}398399/**400 * Release notes as Peach type rather than a raw markdown dump: headings in the401 * display face, bullets as bullets, inline emphasis markers stripped. Anything402 * fancier than that is left as plain text on purpose — the notes are prose.403 */404@Composable405internal fun ReleaseNotes(notes: String) {406    Column(verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs)) {407        notes.lines().forEach { raw ->408            val line = raw.trim()409            when {410                line.isEmpty() -> Spacer(Modifier.height(PeachSpacing.xs))411                line.startsWith("#") -> Text(412                    stripInlineMarkers(line.trimStart('#').trim()),413                    modifier = Modifier.padding(top = PeachSpacing.sm),414                    color = peachColors.T0,415                    style = PeachType.settingsLede.copy(fontSize = 15.sp),416                )417                line.startsWith("- ") || line.startsWith("* ") || line.startsWith("• ") -> Row {418                    Text("·  ", color = LocalPeachAccent.current, style = PeachType.body)419                    Text(420                        stripInlineMarkers(line.drop(2).trim()),421                        color = peachColors.T1,422                        style = PeachType.body.copy(lineHeight = 19.sp),423                    )424                }425                else -> Text(426                    stripInlineMarkers(line),427                    color = peachColors.T1,428                    style = PeachType.body.copy(lineHeight = 19.sp),429                )430            }431        }432    }433}434435internal fun stripInlineMarkers(line: String): String =436    line.replace("**", "").replace("__", "").replace("`", "")437438@Composable439private fun ProgressBar(progress: Float) {440    val accent = LocalPeachAccent.current441    Column {442        Box(443            Modifier.fillMaxWidth().height(4.dp)444                .clip(RoundedCornerShape(999.dp))445                .background(peachColors.T3),446        ) {447            Box(448                Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(4.dp)449                    .clip(RoundedCornerShape(999.dp))450                    .background(accent),451            )452        }453        Spacer(Modifier.height(PeachSpacing.sm))454        Text("${(progress * 100).toInt()}%", color = peachColors.T1, style = PeachType.labelMono)455    }456}457458@Composable459internal fun PrimaryButton(label: String, onClick: () -> Unit) {460    val accent = LocalPeachAccent.current461    Box(462        Modifier.fillMaxWidth()463            .clip(RoundedCornerShape(999.dp))464            .background(accent.copy(alpha = 0.16f))465            .border(1.dp, accent.copy(alpha = 0.55f), RoundedCornerShape(999.dp))466            .clickable(onClick = onClick)467            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),468        contentAlignment = Alignment.Center,469    ) {470        Text(label, color = accent, style = PeachType.sortPill.copy(fontSize = 11.sp))471    }472}473474@Composable475private fun GhostButton(label: String, onClick: () -> Unit) {476    Box(477        Modifier.clip(RoundedCornerShape(999.dp))478            .border(1.dp, peachColors.T2, RoundedCornerShape(999.dp))479            .clickable(onClick = onClick)480            .padding(horizontal = PeachSpacing.md, vertical = 9.dp),481        contentAlignment = Alignment.Center,482    ) {483        Text(label, color = peachColors.T0, style = PeachType.sortPill.copy(fontSize = 10.sp))484    }485}