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 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 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 141 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 162 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 179180181182183 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 217 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 257 258 259 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}320321322323324325326@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}352353354355356357358@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}398399400401402403404@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}