package com.peachpasswords.ui.updates import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import com.peachpasswords.ui.components.PeachSectionHeader import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.updates.ChecksumMismatchException import com.peachpasswords.updates.ReleaseManifest import com.peachpasswords.updates.ReleaseNote import com.peachpasswords.updates.UpdateCheckResult import com.peachpasswords.updates.UpdateRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.io.File import javax.inject.Inject enum class UpdatePhase { Idle, Checking, UpToDate, Available, Downloading, Ready, Failed } data class UpdateUiState( val phase: UpdatePhase = UpdatePhase.Idle, val manifest: ReleaseManifest? = null, val progress: Float = 0f, val apk: File? = null, val error: String? = null, /** True once the user has asked to install but hasn't granted install-unknown-apps. */ val explainInstallPermission: Boolean = false, ) enum class ChangelogPhase { Idle, Loading, Loaded, Failed } data class ChangelogUiState( val phase: ChangelogPhase = ChangelogPhase.Idle, val entries: List = emptyList(), ) @HiltViewModel class UpdateViewModel @Inject constructor( private val updates: UpdateRepository, ) : ViewModel() { val currentVersion: String = updates.currentVersion val available: StateFlow = updates.available val whatsNew: StateFlow = updates.whatsNew val openRequested: StateFlow = updates.openRequested private val _state = MutableStateFlow( updates.available.value ?.let { UpdateUiState(phase = UpdatePhase.Available, manifest = it) } ?: UpdateUiState(), ) val state: StateFlow = _state.asStateFlow() private val _changelog = MutableStateFlow(ChangelogUiState()) /** Full Kepr release history for the settings changelog, loaded on demand. */ val changelog: StateFlow = _changelog.asStateFlow() fun loadChangelog() { if (_changelog.value.phase == ChangelogPhase.Loading || _changelog.value.phase == ChangelogPhase.Loaded) return _changelog.value = ChangelogUiState(phase = ChangelogPhase.Loading) viewModelScope.launch { val entries = updates.fetchHistory() _changelog.value = if (entries == null) { ChangelogUiState(phase = ChangelogPhase.Failed) } else { ChangelogUiState(phase = ChangelogPhase.Loaded, entries = entries) } } } fun consumeOpenRequest() = updates.consumeOpenRequest() fun dismissWhatsNew() = updates.markChangelogSeen() fun check() { if (_state.value.phase == UpdatePhase.Checking || _state.value.phase == UpdatePhase.Downloading) return _state.value = _state.value.copy(phase = UpdatePhase.Checking, error = null) viewModelScope.launch { _state.value = try { when (val result = updates.check()) { is UpdateCheckResult.Accepted -> UpdateUiState( phase = UpdatePhase.Available, manifest = result.manifest, ) is UpdateCheckResult.NoUpdate -> UpdateUiState(phase = UpdatePhase.UpToDate) is UpdateCheckResult.RollbackSuppressed -> _state.value.copy( phase = if (_state.value.manifest != null) UpdatePhase.Available else UpdatePhase.UpToDate, error = "A rollback was suppressed (served ${result.servedVersion}; local high-water ${result.highWaterVersion}).", ) } } catch (e: Exception) { // A failed re-check doesn't invalidate an update we already know // about — keep it offerable rather than dropping to a dead end. _state.value.copy( phase = if (_state.value.manifest != null) UpdatePhase.Available else UpdatePhase.Failed, error = "Couldn't reach the update server: ${e.javaClass.simpleName}: ${e.message ?: "unknown error"}", ) } } } fun download() { val manifest = _state.value.manifest ?: return if (_state.value.phase == UpdatePhase.Downloading) return _state.value = _state.value.copy(phase = UpdatePhase.Downloading, progress = 0f, error = null) viewModelScope.launch { _state.value = try { val apk = updates.downloadAndVerify(manifest) { p -> _state.value = _state.value.copy(progress = p) } _state.value.copy(phase = UpdatePhase.Ready, apk = apk, progress = 1f) } catch (e: ChecksumMismatchException) { // Deliberately loud: a mismatch means the file is not what Kepr // published, and the download has already been deleted. _state.value.copy( phase = UpdatePhase.Failed, apk = null, error = "The download didn't match its checksum and was deleted. Nothing was installed.", ) } catch (e: Exception) { _state.value.copy( phase = UpdatePhase.Failed, apk = null, error = "Download failed: ${e.javaClass.simpleName}: ${e.message ?: "unknown error"}", ) } } } /** * Install the verified APK. If Android hasn't granted this app the * install-unknown-apps permission, surface the in-app explanation instead of * dropping a bare system dialog on the user. */ fun install(context: Context) { val apk = _state.value.apk ?: return if (!updates.canInstallPackages()) { _state.value = _state.value.copy(explainInstallPermission = true) return } _state.value = _state.value.copy(explainInstallPermission = false) runCatching { context.startActivity(updates.installIntent(apk)) } .onFailure { _state.value = _state.value.copy( phase = UpdatePhase.Failed, error = "No package installer available on this device", ) } } fun openInstallPermissionSettings(context: Context) { runCatching { context.startActivity(updates.unknownSourcesSettingsIntent()) } } fun dismissInstallExplanation() { _state.value = _state.value.copy(explainInstallPermission = false) } } @Composable fun UpdateScreen( onBack: () -> Unit, vm: UpdateViewModel = hiltViewModel(), ) { val state by vm.state.collectAsState() val context = LocalContext.current // Reaching this screen with nothing known yet: check straight away, since the // user got here to find out. LaunchedEffect(Unit) { if (state.phase == UpdatePhase.Idle) vm.check() } ShellBackground { Column( Modifier .fillMaxSize() .systemBarsPadding() .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md), ) { PeachSectionHeader(section = "update", onHome = onBack) Spacer(Modifier.height(PeachSpacing.lg)) Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { Text( when (state.phase) { UpdatePhase.Checking -> "checking…" UpdatePhase.UpToDate -> "you're up to date" UpdatePhase.Available -> "a newer Peach" UpdatePhase.Downloading -> "downloading" UpdatePhase.Ready -> "ready to install" UpdatePhase.Failed -> "couldn't finish" UpdatePhase.Idle -> "updates" }, color = peachColors.T0, style = PeachType.createHeadline, ) Spacer(Modifier.height(PeachSpacing.xs)) Text( buildString { append("Installed v${vm.currentVersion}") state.manifest?.let { append(" · latest v${it.version}") } }, color = peachColors.T1, style = PeachType.labelMono, ) // Android 13+ won't post the "update available" notification without an // explicit grant, so ask here — at the top, next to the version info that // explains why — instead of burying it below the install button where the // user installs and leaves without ever seeing it. NotificationOptIn() state.error?.let { message -> Spacer(Modifier.height(PeachSpacing.md)) Box( Modifier.fillMaxWidth() .clip(RoundedCornerShape(10.dp)) .background(peachColors.BreachBg) .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(10.dp)) .padding(PeachSpacing.md), ) { Text(message, color = peachColors.Breach, style = PeachType.hint) } } state.manifest?.notes?.let { notes -> Spacer(Modifier.height(PeachSpacing.lg)) SectionLabel("WHAT'S IN IT") ReleaseNotes(notes) } if (state.phase == UpdatePhase.Downloading) { Spacer(Modifier.height(PeachSpacing.lg)) ProgressBar(state.progress) } if (state.explainInstallPermission) { Spacer(Modifier.height(PeachSpacing.lg)) InstallPermissionExplanation( onOpenSettings = { vm.openInstallPermissionSettings(context) }, onDismiss = { vm.dismissInstallExplanation() }, ) } Spacer(Modifier.height(PeachSpacing.xl)) when (state.phase) { UpdatePhase.Available -> PrimaryButton("Download v${state.manifest?.version}") { vm.download() } UpdatePhase.Ready -> PrimaryButton("Install v${state.manifest?.version}") { vm.install(context) } UpdatePhase.Downloading -> Unit UpdatePhase.Checking -> Unit else -> PrimaryButton("Check for updates") { vm.check() } } if (state.phase == UpdatePhase.Ready) { Spacer(Modifier.height(PeachSpacing.sm)) Text( "Verified against the checksum Kepr published for this release.", color = peachColors.T1, style = PeachType.hint, ) } Spacer(Modifier.height(PeachSpacing.xl)) Spacer(Modifier.height(PeachSpacing.xxl)) } } } } /** * Android 13+ won't post the "update available" notification without this grant. * Asked for here, next to the explanation of what it's for, rather than as an * unexplained prompt at some unrelated moment. */ @Composable private fun NotificationOptIn() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return val context = LocalContext.current var granted by remember { mutableStateOf( ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED, ) } if (granted) return val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted = it } Spacer(Modifier.height(PeachSpacing.lg)) SectionLabel("NOTIFICATIONS") Text( "Peach can tell you when a new version is out. Nothing else uses this.", color = peachColors.T1, style = PeachType.hint, ) Spacer(Modifier.height(PeachSpacing.sm)) GhostButton("Allow update notifications") { launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } } /** * The in-app explanation that must come before Android's own permission screen — * a bare system prompt for "install unknown apps" reads like something has gone * wrong, and the user has no way to tell what asked for it. */ @Composable private fun InstallPermissionExplanation(onOpenSettings: () -> Unit, onDismiss: () -> Unit) { Column( Modifier.fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(peachColors.SettingsGroupBg) .border(1.dp, peachColors.T2, RoundedCornerShape(12.dp)) .padding(PeachSpacing.md), ) { Text( "Android needs your permission", color = peachColors.T0, style = PeachType.onboardingHeadline, ) Spacer(Modifier.height(PeachSpacing.sm)) Text( "Peach isn't distributed through the Play Store, so Android treats this " + "update like any other app you install yourself. The next screen is " + "Android's own — turn on \"Allow from this source\" for Peach, then come " + "back here and tap install. You can turn it off again afterwards.", color = peachColors.T1, style = PeachType.hint, ) Spacer(Modifier.height(PeachSpacing.md)) Row(horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) { GhostButton("Not now", onDismiss) GhostButton("Open Android settings", onOpenSettings) } } } @Composable internal fun SectionLabel(text: String) { Text( text, modifier = Modifier.padding(bottom = PeachSpacing.sm), color = peachColors.T1, style = PeachType.tinyMono, ) } /** * Release notes as Peach type rather than a raw markdown dump: headings in the * display face, bullets as bullets, inline emphasis markers stripped. Anything * fancier than that is left as plain text on purpose — the notes are prose. */ @Composable internal fun ReleaseNotes(notes: String) { Column(verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs)) { notes.lines().forEach { raw -> val line = raw.trim() when { line.isEmpty() -> Spacer(Modifier.height(PeachSpacing.xs)) line.startsWith("#") -> Text( stripInlineMarkers(line.trimStart('#').trim()), modifier = Modifier.padding(top = PeachSpacing.sm), color = peachColors.T0, style = PeachType.settingsLede.copy(fontSize = 15.sp), ) line.startsWith("- ") || line.startsWith("* ") || line.startsWith("• ") -> Row { Text("· ", color = LocalPeachAccent.current, style = PeachType.body) Text( stripInlineMarkers(line.drop(2).trim()), color = peachColors.T1, style = PeachType.body.copy(lineHeight = 19.sp), ) } else -> Text( stripInlineMarkers(line), color = peachColors.T1, style = PeachType.body.copy(lineHeight = 19.sp), ) } } } } internal fun stripInlineMarkers(line: String): String = line.replace("**", "").replace("__", "").replace("`", "") @Composable private fun ProgressBar(progress: Float) { val accent = LocalPeachAccent.current Column { Box( Modifier.fillMaxWidth().height(4.dp) .clip(RoundedCornerShape(999.dp)) .background(peachColors.T3), ) { Box( Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(4.dp) .clip(RoundedCornerShape(999.dp)) .background(accent), ) } Spacer(Modifier.height(PeachSpacing.sm)) Text("${(progress * 100).toInt()}%", color = peachColors.T1, style = PeachType.labelMono) } } @Composable internal fun PrimaryButton(label: String, onClick: () -> Unit) { val accent = LocalPeachAccent.current Box( Modifier.fillMaxWidth() .clip(RoundedCornerShape(999.dp)) .background(accent.copy(alpha = 0.16f)) .border(1.dp, accent.copy(alpha = 0.55f), RoundedCornerShape(999.dp)) .clickable(onClick = onClick) .padding(horizontal = PeachSpacing.lg, vertical = 11.dp), contentAlignment = Alignment.Center, ) { Text(label, color = accent, style = PeachType.sortPill.copy(fontSize = 11.sp)) } } @Composable private fun GhostButton(label: String, onClick: () -> Unit) { Box( Modifier.clip(RoundedCornerShape(999.dp)) .border(1.dp, peachColors.T2, RoundedCornerShape(999.dp)) .clickable(onClick = onClick) .padding(horizontal = PeachSpacing.md, vertical = 9.dp), contentAlignment = Alignment.Center, ) { Text(label, color = peachColors.T0, style = PeachType.sortPill.copy(fontSize = 10.sp)) } }