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 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 110 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 131 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 148149150151152 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 186 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 239 240 241 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}302303304305306307308@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}334335336337338339340@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}380381382383384385386@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}