UpdateScreen.kt

19.0 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.PeachSectionHeader48import 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            PeachSectionHeader(section = "update", onHome = onBack)224            Spacer(Modifier.height(PeachSpacing.lg))225226            Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {227                Text(228                    when (state.phase) {229                        UpdatePhase.Checking -> "checking…"230                        UpdatePhase.UpToDate -> "you're up to date"231                        UpdatePhase.Available -> "a newer Peach"232                        UpdatePhase.Downloading -> "downloading"233                        UpdatePhase.Ready -> "ready to install"234                        UpdatePhase.Failed -> "couldn't finish"235                        UpdatePhase.Idle -> "updates"236                    },237                    color = peachColors.T0,238                    style = PeachType.createHeadline,239                )240                Spacer(Modifier.height(PeachSpacing.xs))241                Text(242                    buildString {243                        append("Installed v${vm.currentVersion}")244                        state.manifest?.let { append("  ·  latest v${it.version}") }245                    },246                    color = peachColors.T1,247                    style = PeachType.labelMono,248                )249250                // Android 13+ won't post the "update available" notification without an251                // explicit grant, so ask here — at the top, next to the version info that252                // explains why — instead of burying it below the install button where the253                // user installs and leaves without ever seeing it.254                NotificationOptIn()255256                state.error?.let { message ->257                    Spacer(Modifier.height(PeachSpacing.md))258                    Box(259                        Modifier.fillMaxWidth()260                            .clip(RoundedCornerShape(10.dp))261                            .background(peachColors.BreachBg)262                            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(10.dp))263                            .padding(PeachSpacing.md),264                    ) {265                        Text(message, color = peachColors.Breach, style = PeachType.hint)266                    }267                }268269                state.manifest?.notes?.let { notes ->270                    Spacer(Modifier.height(PeachSpacing.lg))271                    SectionLabel("WHAT'S IN IT")272                    ReleaseNotes(notes)273                }274275                if (state.phase == UpdatePhase.Downloading) {276                    Spacer(Modifier.height(PeachSpacing.lg))277                    ProgressBar(state.progress)278                }279280                if (state.explainInstallPermission) {281                    Spacer(Modifier.height(PeachSpacing.lg))282                    InstallPermissionExplanation(283                        onOpenSettings = { vm.openInstallPermissionSettings(context) },284                        onDismiss = { vm.dismissInstallExplanation() },285                    )286                }287288                Spacer(Modifier.height(PeachSpacing.xl))289                when (state.phase) {290                    UpdatePhase.Available ->291                        PrimaryButton("Download v${state.manifest?.version}") { vm.download() }292                    UpdatePhase.Ready ->293                        PrimaryButton("Install v${state.manifest?.version}") { vm.install(context) }294                    UpdatePhase.Downloading -> Unit295                    UpdatePhase.Checking -> Unit296                    else -> PrimaryButton("Check for updates") { vm.check() }297                }298299                if (state.phase == UpdatePhase.Ready) {300                    Spacer(Modifier.height(PeachSpacing.sm))301                    Text(302                        "Verified against the checksum Kepr published for this release.",303                        color = peachColors.T1,304                        style = PeachType.hint,305                    )306                }307308                Spacer(Modifier.height(PeachSpacing.xl))309                Spacer(Modifier.height(PeachSpacing.xxl))310            }311        }312    }313}314315/**316 * Android 13+ won't post the "update available" notification without this grant.317 * Asked for here, next to the explanation of what it's for, rather than as an318 * unexplained prompt at some unrelated moment.319 */320@Composable321private fun NotificationOptIn() {322    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return323    val context = LocalContext.current324    var granted by remember {325        mutableStateOf(326            ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==327                PackageManager.PERMISSION_GRANTED,328        )329    }330    if (granted) return331    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {332        granted = it333    }334    Spacer(Modifier.height(PeachSpacing.lg))335    SectionLabel("NOTIFICATIONS")336    Text(337        "Peach can tell you when a new version is out. Nothing else uses this.",338        color = peachColors.T1,339        style = PeachType.hint,340    )341    Spacer(Modifier.height(PeachSpacing.sm))342    GhostButton("Allow update notifications") {343        launcher.launch(Manifest.permission.POST_NOTIFICATIONS)344    }345}346347/**348 * The in-app explanation that must come before Android's own permission screen —349 * a bare system prompt for "install unknown apps" reads like something has gone350 * wrong, and the user has no way to tell what asked for it.351 */352@Composable353private fun InstallPermissionExplanation(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {354    Column(355        Modifier.fillMaxWidth()356            .clip(RoundedCornerShape(12.dp))357            .background(peachColors.SettingsGroupBg)358            .border(1.dp, peachColors.T2, RoundedCornerShape(12.dp))359            .padding(PeachSpacing.md),360    ) {361        Text(362            "Android needs your permission",363            color = peachColors.T0,364            style = PeachType.onboardingHeadline,365        )366        Spacer(Modifier.height(PeachSpacing.sm))367        Text(368            "Peach isn't distributed through the Play Store, so Android treats this " +369                "update like any other app you install yourself. The next screen is " +370                "Android's own — turn on \"Allow from this source\" for Peach, then come " +371                "back here and tap install. You can turn it off again afterwards.",372            color = peachColors.T1,373            style = PeachType.hint,374        )375        Spacer(Modifier.height(PeachSpacing.md))376        Row(horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {377            GhostButton("Not now", onDismiss)378            GhostButton("Open Android settings", onOpenSettings)379        }380    }381}382383@Composable384internal fun SectionLabel(text: String) {385    Text(386        text,387        modifier = Modifier.padding(bottom = PeachSpacing.sm),388        color = peachColors.T1,389        style = PeachType.tinyMono,390    )391}392393/**394 * Release notes as Peach type rather than a raw markdown dump: headings in the395 * display face, bullets as bullets, inline emphasis markers stripped. Anything396 * fancier than that is left as plain text on purpose — the notes are prose.397 */398@Composable399internal fun ReleaseNotes(notes: String) {400    Column(verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs)) {401        notes.lines().forEach { raw ->402            val line = raw.trim()403            when {404                line.isEmpty() -> Spacer(Modifier.height(PeachSpacing.xs))405                line.startsWith("#") -> Text(406                    stripInlineMarkers(line.trimStart('#').trim()),407                    modifier = Modifier.padding(top = PeachSpacing.sm),408                    color = peachColors.T0,409                    style = PeachType.settingsLede.copy(fontSize = 15.sp),410                )411                line.startsWith("- ") || line.startsWith("* ") || line.startsWith("• ") -> Row {412                    Text("·  ", color = LocalPeachAccent.current, style = PeachType.body)413                    Text(414                        stripInlineMarkers(line.drop(2).trim()),415                        color = peachColors.T1,416                        style = PeachType.body.copy(lineHeight = 19.sp),417                    )418                }419                else -> Text(420                    stripInlineMarkers(line),421                    color = peachColors.T1,422                    style = PeachType.body.copy(lineHeight = 19.sp),423                )424            }425        }426    }427}428429internal fun stripInlineMarkers(line: String): String =430    line.replace("**", "").replace("__", "").replace("`", "")431432@Composable433private fun ProgressBar(progress: Float) {434    val accent = LocalPeachAccent.current435    Column {436        Box(437            Modifier.fillMaxWidth().height(4.dp)438                .clip(RoundedCornerShape(999.dp))439                .background(peachColors.T3),440        ) {441            Box(442                Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(4.dp)443                    .clip(RoundedCornerShape(999.dp))444                    .background(accent),445            )446        }447        Spacer(Modifier.height(PeachSpacing.sm))448        Text("${(progress * 100).toInt()}%", color = peachColors.T1, style = PeachType.labelMono)449    }450}451452@Composable453internal fun PrimaryButton(label: String, onClick: () -> Unit) {454    val accent = LocalPeachAccent.current455    Box(456        Modifier.fillMaxWidth()457            .clip(RoundedCornerShape(999.dp))458            .background(accent.copy(alpha = 0.16f))459            .border(1.dp, accent.copy(alpha = 0.55f), RoundedCornerShape(999.dp))460            .clickable(onClick = onClick)461            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),462        contentAlignment = Alignment.Center,463    ) {464        Text(label, color = accent, style = PeachType.sortPill.copy(fontSize = 11.sp))465    }466}467468@Composable469private fun GhostButton(label: String, onClick: () -> Unit) {470    Box(471        Modifier.clip(RoundedCornerShape(999.dp))472            .border(1.dp, peachColors.T2, RoundedCornerShape(999.dp))473            .clickable(onClick = onClick)474            .padding(horizontal = PeachSpacing.md, vertical = 9.dp),475        contentAlignment = Alignment.Center,476    ) {477        Text(label, color = peachColors.T0, style = PeachType.sortPill.copy(fontSize = 10.sp))478    }479}