UpdateScreen.kt

18.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.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 — check your connection",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 — check your connection and try again",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                state.error?.let { message ->239                    Spacer(Modifier.height(PeachSpacing.md))240                    Box(241                        Modifier.fillMaxWidth()242                            .clip(RoundedCornerShape(10.dp))243                            .background(peachColors.BreachBg)244                            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(10.dp))245                            .padding(PeachSpacing.md),246                    ) {247                        Text(message, color = peachColors.Breach, style = PeachType.hint)248                    }249                }250251                state.manifest?.notes?.let { notes ->252                    Spacer(Modifier.height(PeachSpacing.lg))253                    SectionLabel("WHAT'S IN IT")254                    ReleaseNotes(notes)255                }256257                if (state.phase == UpdatePhase.Downloading) {258                    Spacer(Modifier.height(PeachSpacing.lg))259                    ProgressBar(state.progress)260                }261262                if (state.explainInstallPermission) {263                    Spacer(Modifier.height(PeachSpacing.lg))264                    InstallPermissionExplanation(265                        onOpenSettings = { vm.openInstallPermissionSettings(context) },266                        onDismiss = { vm.dismissInstallExplanation() },267                    )268                }269270                Spacer(Modifier.height(PeachSpacing.xl))271                when (state.phase) {272                    UpdatePhase.Available ->273                        PrimaryButton("Download v${state.manifest?.version}") { vm.download() }274                    UpdatePhase.Ready ->275                        PrimaryButton("Install v${state.manifest?.version}") { vm.install(context) }276                    UpdatePhase.Downloading -> Unit277                    UpdatePhase.Checking -> Unit278                    else -> PrimaryButton("Check for updates") { vm.check() }279                }280281                if (state.phase == UpdatePhase.Ready) {282                    Spacer(Modifier.height(PeachSpacing.sm))283                    Text(284                        "Verified against the checksum Kepr published for this release.",285                        color = peachColors.T1,286                        style = PeachType.hint,287                    )288                }289290                Spacer(Modifier.height(PeachSpacing.xl))291                NotificationOptIn()292                Spacer(Modifier.height(PeachSpacing.xxl))293            }294        }295    }296}297298/**299 * Android 13+ won't post the "update available" notification without this grant.300 * Asked for here, next to the explanation of what it's for, rather than as an301 * unexplained prompt at some unrelated moment.302 */303@Composable304private fun NotificationOptIn() {305    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return306    val context = LocalContext.current307    var granted by remember {308        mutableStateOf(309            ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==310                PackageManager.PERMISSION_GRANTED,311        )312    }313    if (granted) return314    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {315        granted = it316    }317    SectionLabel("NOTIFICATIONS")318    Text(319        "Peach can tell you when a new version is out. Nothing else uses this.",320        color = peachColors.T1,321        style = PeachType.hint,322    )323    Spacer(Modifier.height(PeachSpacing.sm))324    GhostButton("Allow update notifications") {325        launcher.launch(Manifest.permission.POST_NOTIFICATIONS)326    }327}328329/**330 * The in-app explanation that must come before Android's own permission screen —331 * a bare system prompt for "install unknown apps" reads like something has gone332 * wrong, and the user has no way to tell what asked for it.333 */334@Composable335private fun InstallPermissionExplanation(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {336    Column(337        Modifier.fillMaxWidth()338            .clip(RoundedCornerShape(12.dp))339            .background(peachColors.SettingsGroupBg)340            .border(1.dp, peachColors.T2, RoundedCornerShape(12.dp))341            .padding(PeachSpacing.md),342    ) {343        Text(344            "Android needs your permission",345            color = peachColors.T0,346            style = PeachType.onboardingHeadline,347        )348        Spacer(Modifier.height(PeachSpacing.sm))349        Text(350            "Peach isn't distributed through the Play Store, so Android treats this " +351                "update like any other app you install yourself. The next screen is " +352                "Android's own — turn on \"Allow from this source\" for Peach, then come " +353                "back here and tap install. You can turn it off again afterwards.",354            color = peachColors.T1,355            style = PeachType.hint,356        )357        Spacer(Modifier.height(PeachSpacing.md))358        Row(horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {359            GhostButton("Not now", onDismiss)360            GhostButton("Open Android settings", onOpenSettings)361        }362    }363}364365@Composable366internal fun SectionLabel(text: String) {367    Text(368        text,369        modifier = Modifier.padding(bottom = PeachSpacing.sm),370        color = peachColors.T1,371        style = PeachType.tinyMono,372    )373}374375/**376 * Release notes as Peach type rather than a raw markdown dump: headings in the377 * display face, bullets as bullets, inline emphasis markers stripped. Anything378 * fancier than that is left as plain text on purpose — the notes are prose.379 */380@Composable381internal fun ReleaseNotes(notes: String) {382    Column(verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs)) {383        notes.lines().forEach { raw ->384            val line = raw.trim()385            when {386                line.isEmpty() -> Spacer(Modifier.height(PeachSpacing.xs))387                line.startsWith("#") -> Text(388                    stripInlineMarkers(line.trimStart('#').trim()),389                    modifier = Modifier.padding(top = PeachSpacing.sm),390                    color = peachColors.T0,391                    style = PeachType.settingsLede.copy(fontSize = 15.sp),392                )393                line.startsWith("- ") || line.startsWith("* ") || line.startsWith("• ") -> Row {394                    Text("·  ", color = LocalPeachAccent.current, style = PeachType.body)395                    Text(396                        stripInlineMarkers(line.drop(2).trim()),397                        color = peachColors.T1,398                        style = PeachType.body.copy(lineHeight = 19.sp),399                    )400                }401                else -> Text(402                    stripInlineMarkers(line),403                    color = peachColors.T1,404                    style = PeachType.body.copy(lineHeight = 19.sp),405                )406            }407        }408    }409}410411internal fun stripInlineMarkers(line: String): String =412    line.replace("**", "").replace("__", "").replace("`", "")413414@Composable415private fun ProgressBar(progress: Float) {416    val accent = LocalPeachAccent.current417    Column {418        Box(419            Modifier.fillMaxWidth().height(4.dp)420                .clip(RoundedCornerShape(999.dp))421                .background(peachColors.T3),422        ) {423            Box(424                Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(4.dp)425                    .clip(RoundedCornerShape(999.dp))426                    .background(accent),427            )428        }429        Spacer(Modifier.height(PeachSpacing.sm))430        Text("${(progress * 100).toInt()}%", color = peachColors.T1, style = PeachType.labelMono)431    }432}433434@Composable435internal fun PrimaryButton(label: String, onClick: () -> Unit) {436    val accent = LocalPeachAccent.current437    Box(438        Modifier.fillMaxWidth()439            .clip(RoundedCornerShape(999.dp))440            .background(accent.copy(alpha = 0.16f))441            .border(1.dp, accent.copy(alpha = 0.55f), RoundedCornerShape(999.dp))442            .clickable(onClick = onClick)443            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),444        contentAlignment = Alignment.Center,445    ) {446        Text(label, color = accent, style = PeachType.sortPill.copy(fontSize = 11.sp))447    }448}449450@Composable451private fun GhostButton(label: String, onClick: () -> Unit) {452    Box(453        Modifier.clip(RoundedCornerShape(999.dp))454            .border(1.dp, peachColors.T2, RoundedCornerShape(999.dp))455            .clickable(onClick = onClick)456            .padding(horizontal = PeachSpacing.md, vertical = 9.dp),457        contentAlignment = Alignment.Center,458    ) {459        Text(label, color = peachColors.T0, style = PeachType.sortPill.copy(fontSize = 10.sp))460    }461}