UpdateScreen.kt

18.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.UpdateRepository56import dagger.hilt.android.lifecycle.HiltViewModel57import kotlinx.coroutines.flow.MutableStateFlow58import kotlinx.coroutines.flow.StateFlow59import kotlinx.coroutines.flow.asStateFlow60import kotlinx.coroutines.launch61import java.io.File62import javax.inject.Inject6364enum class UpdatePhase { Idle, Checking, UpToDate, Available, Downloading, Ready, Failed }6566data class UpdateUiState(67    val phase: UpdatePhase = UpdatePhase.Idle,68    val manifest: ReleaseManifest? = null,69    val progress: Float = 0f,70    val apk: File? = null,71    val error: String? = null,72    /** True once the user has asked to install but hasn't granted install-unknown-apps. */73    val explainInstallPermission: Boolean = false,74)7576@HiltViewModel77class UpdateViewModel @Inject constructor(78    private val updates: UpdateRepository,79) : ViewModel() {8081    val currentVersion: String = updates.currentVersion82    val available: StateFlow<ReleaseManifest?> = updates.available83    val whatsNew: StateFlow<String?> = updates.whatsNew84    val openRequested: StateFlow<Boolean> = updates.openRequested8586    private val _state = MutableStateFlow(87        updates.available.value88            ?.let { UpdateUiState(phase = UpdatePhase.Available, manifest = it) }89            ?: UpdateUiState(),90    )91    val state: StateFlow<UpdateUiState> = _state.asStateFlow()9293    fun consumeOpenRequest() = updates.consumeOpenRequest()9495    fun dismissWhatsNew() = updates.markChangelogSeen()9697    fun check() {98        if (_state.value.phase == UpdatePhase.Checking || _state.value.phase == UpdatePhase.Downloading) return99        _state.value = _state.value.copy(phase = UpdatePhase.Checking, error = null)100        viewModelScope.launch {101            _state.value = try {102                val manifest = updates.check()103                if (manifest == null) {104                    UpdateUiState(phase = UpdatePhase.UpToDate)105                } else {106                    UpdateUiState(phase = UpdatePhase.Available, manifest = manifest)107                }108            } catch (e: Exception) {109                // A failed re-check doesn't invalidate an update we already know110                // about — keep it offerable rather than dropping to a dead end.111                _state.value.copy(112                    phase = if (_state.value.manifest != null) UpdatePhase.Available else UpdatePhase.Failed,113                    error = "Couldn't reach the update server: ${e.javaClass.simpleName}: ${e.message ?: "unknown error"}",114                )115            }116        }117    }118119    fun download() {120        val manifest = _state.value.manifest ?: return121        if (_state.value.phase == UpdatePhase.Downloading) return122        _state.value = _state.value.copy(phase = UpdatePhase.Downloading, progress = 0f, error = null)123        viewModelScope.launch {124            _state.value = try {125                val apk = updates.downloadAndVerify(manifest) { p ->126                    _state.value = _state.value.copy(progress = p)127                }128                _state.value.copy(phase = UpdatePhase.Ready, apk = apk, progress = 1f)129            } catch (e: ChecksumMismatchException) {130                // Deliberately loud: a mismatch means the file is not what Kepr131                // published, and the download has already been deleted.132                _state.value.copy(133                    phase = UpdatePhase.Failed,134                    apk = null,135                    error = "The download didn't match its checksum and was deleted. Nothing was installed.",136                )137            } catch (e: Exception) {138                _state.value.copy(139                    phase = UpdatePhase.Failed,140                    apk = null,141                    error = "Download failed: ${e.javaClass.simpleName}: ${e.message ?: "unknown error"}",142                )143            }144        }145    }146147    /**148     * Install the verified APK. If Android hasn't granted this app the149     * install-unknown-apps permission, surface the in-app explanation instead of150     * dropping a bare system dialog on the user.151     */152    fun install(context: Context) {153        val apk = _state.value.apk ?: return154        if (!updates.canInstallPackages()) {155            _state.value = _state.value.copy(explainInstallPermission = true)156            return157        }158        _state.value = _state.value.copy(explainInstallPermission = false)159        runCatching { context.startActivity(updates.installIntent(apk)) }160            .onFailure {161                _state.value = _state.value.copy(162                    phase = UpdatePhase.Failed,163                    error = "No package installer available on this device",164                )165            }166    }167168    fun openInstallPermissionSettings(context: Context) {169        runCatching { context.startActivity(updates.unknownSourcesSettingsIntent()) }170    }171172    fun dismissInstallExplanation() {173        _state.value = _state.value.copy(explainInstallPermission = false)174    }175}176177@Composable178fun UpdateScreen(179    onBack: () -> Unit,180    vm: UpdateViewModel = hiltViewModel(),181) {182    val state by vm.state.collectAsState()183    val context = LocalContext.current184185    // Reaching this screen with nothing known yet: check straight away, since the186    // user got here to find out.187    LaunchedEffect(Unit) {188        if (state.phase == UpdatePhase.Idle) vm.check()189    }190191    ShellBackground {192        Column(193            Modifier194                .fillMaxSize()195                .systemBarsPadding()196                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),197        ) {198            PeachTopBar(199                title = "Update",200                leading = {201                    Icon(202                        Icons.AutoMirrored.Rounded.ArrowBack,203                        contentDescription = "back",204                        tint = peachColors.T0,205                        modifier = Modifier206                            .size(22.dp)207                            .clip(RoundedCornerShape(PeachSpacing.sm))208                            .clickable(onClick = onBack),209                    )210                },211            )212            Spacer(Modifier.height(PeachSpacing.lg))213214            Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {215                Text(216                    when (state.phase) {217                        UpdatePhase.Checking -> "checking…"218                        UpdatePhase.UpToDate -> "you're up to date"219                        UpdatePhase.Available -> "a newer Peach"220                        UpdatePhase.Downloading -> "downloading"221                        UpdatePhase.Ready -> "ready to install"222                        UpdatePhase.Failed -> "couldn't finish"223                        UpdatePhase.Idle -> "updates"224                    },225                    color = peachColors.T0,226                    style = PeachType.createHeadline,227                )228                Spacer(Modifier.height(PeachSpacing.xs))229                Text(230                    buildString {231                        append("Installed v${vm.currentVersion}")232                        state.manifest?.let { append("  ·  latest v${it.version}") }233                    },234                    color = peachColors.T1,235                    style = PeachType.labelMono,236                )237238                // Android 13+ won't post the "update available" notification without an239                // explicit grant, so ask here — at the top, next to the version info that240                // explains why — instead of burying it below the install button where the241                // user installs and leaves without ever seeing it.242                NotificationOptIn()243244                state.error?.let { message ->245                    Spacer(Modifier.height(PeachSpacing.md))246                    Box(247                        Modifier.fillMaxWidth()248                            .clip(RoundedCornerShape(10.dp))249                            .background(peachColors.BreachBg)250                            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(10.dp))251                            .padding(PeachSpacing.md),252                    ) {253                        Text(message, color = peachColors.Breach, style = PeachType.hint)254                    }255                }256257                state.manifest?.notes?.let { notes ->258                    Spacer(Modifier.height(PeachSpacing.lg))259                    SectionLabel("WHAT'S IN IT")260                    ReleaseNotes(notes)261                }262263                if (state.phase == UpdatePhase.Downloading) {264                    Spacer(Modifier.height(PeachSpacing.lg))265                    ProgressBar(state.progress)266                }267268                if (state.explainInstallPermission) {269                    Spacer(Modifier.height(PeachSpacing.lg))270                    InstallPermissionExplanation(271                        onOpenSettings = { vm.openInstallPermissionSettings(context) },272                        onDismiss = { vm.dismissInstallExplanation() },273                    )274                }275276                Spacer(Modifier.height(PeachSpacing.xl))277                when (state.phase) {278                    UpdatePhase.Available ->279                        PrimaryButton("Download v${state.manifest?.version}") { vm.download() }280                    UpdatePhase.Ready ->281                        PrimaryButton("Install v${state.manifest?.version}") { vm.install(context) }282                    UpdatePhase.Downloading -> Unit283                    UpdatePhase.Checking -> Unit284                    else -> PrimaryButton("Check for updates") { vm.check() }285                }286287                if (state.phase == UpdatePhase.Ready) {288                    Spacer(Modifier.height(PeachSpacing.sm))289                    Text(290                        "Verified against the checksum Kepr published for this release.",291                        color = peachColors.T1,292                        style = PeachType.hint,293                    )294                }295296                Spacer(Modifier.height(PeachSpacing.xl))297                Spacer(Modifier.height(PeachSpacing.xxl))298            }299        }300    }301}302303/**304 * Android 13+ won't post the "update available" notification without this grant.305 * Asked for here, next to the explanation of what it's for, rather than as an306 * unexplained prompt at some unrelated moment.307 */308@Composable309private fun NotificationOptIn() {310    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return311    val context = LocalContext.current312    var granted by remember {313        mutableStateOf(314            ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==315                PackageManager.PERMISSION_GRANTED,316        )317    }318    if (granted) return319    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {320        granted = it321    }322    Spacer(Modifier.height(PeachSpacing.lg))323    SectionLabel("NOTIFICATIONS")324    Text(325        "Peach can tell you when a new version is out. Nothing else uses this.",326        color = peachColors.T1,327        style = PeachType.hint,328    )329    Spacer(Modifier.height(PeachSpacing.sm))330    GhostButton("Allow update notifications") {331        launcher.launch(Manifest.permission.POST_NOTIFICATIONS)332    }333}334335/**336 * The in-app explanation that must come before Android's own permission screen —337 * a bare system prompt for "install unknown apps" reads like something has gone338 * wrong, and the user has no way to tell what asked for it.339 */340@Composable341private fun InstallPermissionExplanation(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {342    Column(343        Modifier.fillMaxWidth()344            .clip(RoundedCornerShape(12.dp))345            .background(peachColors.SettingsGroupBg)346            .border(1.dp, peachColors.T2, RoundedCornerShape(12.dp))347            .padding(PeachSpacing.md),348    ) {349        Text(350            "Android needs your permission",351            color = peachColors.T0,352            style = PeachType.onboardingHeadline,353        )354        Spacer(Modifier.height(PeachSpacing.sm))355        Text(356            "Peach isn't distributed through the Play Store, so Android treats this " +357                "update like any other app you install yourself. The next screen is " +358                "Android's own — turn on \"Allow from this source\" for Peach, then come " +359                "back here and tap install. You can turn it off again afterwards.",360            color = peachColors.T1,361            style = PeachType.hint,362        )363        Spacer(Modifier.height(PeachSpacing.md))364        Row(horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {365            GhostButton("Not now", onDismiss)366            GhostButton("Open Android settings", onOpenSettings)367        }368    }369}370371@Composable372internal fun SectionLabel(text: String) {373    Text(374        text,375        modifier = Modifier.padding(bottom = PeachSpacing.sm),376        color = peachColors.T1,377        style = PeachType.tinyMono,378    )379}380381/**382 * Release notes as Peach type rather than a raw markdown dump: headings in the383 * display face, bullets as bullets, inline emphasis markers stripped. Anything384 * fancier than that is left as plain text on purpose — the notes are prose.385 */386@Composable387internal fun ReleaseNotes(notes: String) {388    Column(verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs)) {389        notes.lines().forEach { raw ->390            val line = raw.trim()391            when {392                line.isEmpty() -> Spacer(Modifier.height(PeachSpacing.xs))393                line.startsWith("#") -> Text(394                    stripInlineMarkers(line.trimStart('#').trim()),395                    modifier = Modifier.padding(top = PeachSpacing.sm),396                    color = peachColors.T0,397                    style = PeachType.settingsLede.copy(fontSize = 15.sp),398                )399                line.startsWith("- ") || line.startsWith("* ") || line.startsWith("• ") -> Row {400                    Text("·  ", color = LocalPeachAccent.current, style = PeachType.body)401                    Text(402                        stripInlineMarkers(line.drop(2).trim()),403                        color = peachColors.T1,404                        style = PeachType.body.copy(lineHeight = 19.sp),405                    )406                }407                else -> Text(408                    stripInlineMarkers(line),409                    color = peachColors.T1,410                    style = PeachType.body.copy(lineHeight = 19.sp),411                )412            }413        }414    }415}416417internal fun stripInlineMarkers(line: String): String =418    line.replace("**", "").replace("__", "").replace("`", "")419420@Composable421private fun ProgressBar(progress: Float) {422    val accent = LocalPeachAccent.current423    Column {424        Box(425            Modifier.fillMaxWidth().height(4.dp)426                .clip(RoundedCornerShape(999.dp))427                .background(peachColors.T3),428        ) {429            Box(430                Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(4.dp)431                    .clip(RoundedCornerShape(999.dp))432                    .background(accent),433            )434        }435        Spacer(Modifier.height(PeachSpacing.sm))436        Text("${(progress * 100).toInt()}%", color = peachColors.T1, style = PeachType.labelMono)437    }438}439440@Composable441internal fun PrimaryButton(label: String, onClick: () -> Unit) {442    val accent = LocalPeachAccent.current443    Box(444        Modifier.fillMaxWidth()445            .clip(RoundedCornerShape(999.dp))446            .background(accent.copy(alpha = 0.16f))447            .border(1.dp, accent.copy(alpha = 0.55f), RoundedCornerShape(999.dp))448            .clickable(onClick = onClick)449            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),450        contentAlignment = Alignment.Center,451    ) {452        Text(label, color = accent, style = PeachType.sortPill.copy(fontSize = 11.sp))453    }454}455456@Composable457private fun GhostButton(label: String, onClick: () -> Unit) {458    Box(459        Modifier.clip(RoundedCornerShape(999.dp))460            .border(1.dp, peachColors.T2, RoundedCornerShape(999.dp))461            .clickable(onClick = onClick)462            .padding(horizontal = PeachSpacing.md, vertical = 9.dp),463        contentAlignment = Alignment.Center,464    ) {465        Text(label, color = peachColors.T0, style = PeachType.sortPill.copy(fontSize = 10.sp))466    }467}