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.ReleaseNote56import com.peachpasswords.updates.UpdateRepository57import dagger.hilt.android.lifecycle.HiltViewModel58import kotlinx.coroutines.flow.MutableStateFlow59import kotlinx.coroutines.flow.StateFlow60import kotlinx.coroutines.flow.asStateFlow61import kotlinx.coroutines.launch62import java.io.File63import javax.inject.Inject6465enum class UpdatePhase { Idle, Checking, UpToDate, Available, Downloading, Ready, Failed }6667data class UpdateUiState(68 val phase: UpdatePhase = UpdatePhase.Idle,69 val manifest: ReleaseManifest? = null,70 val progress: Float = 0f,71 val apk: File? = null,72 val error: String? = null,73 74 val explainInstallPermission: Boolean = false,75)7677enum class ChangelogPhase { Idle, Loading, Loaded, Failed }7879data class ChangelogUiState(80 val phase: ChangelogPhase = ChangelogPhase.Idle,81 val entries: List<ReleaseNote> = emptyList(),82)8384@HiltViewModel85class UpdateViewModel @Inject constructor(86 private val updates: UpdateRepository,87) : ViewModel() {8889 val currentVersion: String = updates.currentVersion90 val available: StateFlow<ReleaseManifest?> = updates.available91 val whatsNew: StateFlow<String?> = updates.whatsNew92 val openRequested: StateFlow<Boolean> = updates.openRequested9394 private val _state = MutableStateFlow(95 updates.available.value96 ?.let { UpdateUiState(phase = UpdatePhase.Available, manifest = it) }97 ?: UpdateUiState(),98 )99 val state: StateFlow<UpdateUiState> = _state.asStateFlow()100101 private val _changelog = MutableStateFlow(ChangelogUiState())102 103 val changelog: StateFlow<ChangelogUiState> = _changelog.asStateFlow()104105 fun loadChangelog() {106 if (_changelog.value.phase == ChangelogPhase.Loading || _changelog.value.phase == ChangelogPhase.Loaded) return107 _changelog.value = ChangelogUiState(phase = ChangelogPhase.Loading)108 viewModelScope.launch {109 val entries = updates.fetchHistory()110 _changelog.value = if (entries == null) {111 ChangelogUiState(phase = ChangelogPhase.Failed)112 } else {113 ChangelogUiState(phase = ChangelogPhase.Loaded, entries = entries)114 }115 }116 }117118 fun consumeOpenRequest() = updates.consumeOpenRequest()119120 fun dismissWhatsNew() = updates.markChangelogSeen()121122 fun check() {123 if (_state.value.phase == UpdatePhase.Checking || _state.value.phase == UpdatePhase.Downloading) return124 _state.value = _state.value.copy(phase = UpdatePhase.Checking, error = null)125 viewModelScope.launch {126 _state.value = try {127 val manifest = updates.check()128 if (manifest == null) {129 UpdateUiState(phase = UpdatePhase.UpToDate)130 } else {131 UpdateUiState(phase = UpdatePhase.Available, manifest = manifest)132 }133 } catch (e: Exception) {134 135 136 _state.value.copy(137 phase = if (_state.value.manifest != null) UpdatePhase.Available else UpdatePhase.Failed,138 error = "Couldn't reach the update server: ${e.javaClass.simpleName}: ${e.message ?: "unknown error"}",139 )140 }141 }142 }143144 fun download() {145 val manifest = _state.value.manifest ?: return146 if (_state.value.phase == UpdatePhase.Downloading) return147 _state.value = _state.value.copy(phase = UpdatePhase.Downloading, progress = 0f, error = null)148 viewModelScope.launch {149 _state.value = try {150 val apk = updates.downloadAndVerify(manifest) { p ->151 _state.value = _state.value.copy(progress = p)152 }153 _state.value.copy(phase = UpdatePhase.Ready, apk = apk, progress = 1f)154 } catch (e: ChecksumMismatchException) {155 156 157 _state.value.copy(158 phase = UpdatePhase.Failed,159 apk = null,160 error = "The download didn't match its checksum and was deleted. Nothing was installed.",161 )162 } catch (e: Exception) {163 _state.value.copy(164 phase = UpdatePhase.Failed,165 apk = null,166 error = "Download failed: ${e.javaClass.simpleName}: ${e.message ?: "unknown error"}",167 )168 }169 }170 }171172 173174175176177 fun install(context: Context) {178 val apk = _state.value.apk ?: return179 if (!updates.canInstallPackages()) {180 _state.value = _state.value.copy(explainInstallPermission = true)181 return182 }183 _state.value = _state.value.copy(explainInstallPermission = false)184 runCatching { context.startActivity(updates.installIntent(apk)) }185 .onFailure {186 _state.value = _state.value.copy(187 phase = UpdatePhase.Failed,188 error = "No package installer available on this device",189 )190 }191 }192193 fun openInstallPermissionSettings(context: Context) {194 runCatching { context.startActivity(updates.unknownSourcesSettingsIntent()) }195 }196197 fun dismissInstallExplanation() {198 _state.value = _state.value.copy(explainInstallPermission = false)199 }200}201202@Composable203fun UpdateScreen(204 onBack: () -> Unit,205 vm: UpdateViewModel = hiltViewModel(),206) {207 val state by vm.state.collectAsState()208 val context = LocalContext.current209210 211 212 LaunchedEffect(Unit) {213 if (state.phase == UpdatePhase.Idle) vm.check()214 }215216 ShellBackground {217 Column(218 Modifier219 .fillMaxSize()220 .systemBarsPadding()221 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),222 ) {223 PeachTopBar(224 title = "Update",225 leading = {226 Icon(227 Icons.AutoMirrored.Rounded.ArrowBack,228 contentDescription = "back",229 tint = peachColors.T0,230 modifier = Modifier231 .size(22.dp)232 .clip(RoundedCornerShape(PeachSpacing.sm))233 .clickable(onClick = onBack),234 )235 },236 )237 Spacer(Modifier.height(PeachSpacing.lg))238239 Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {240 Text(241 when (state.phase) {242 UpdatePhase.Checking -> "checking…"243 UpdatePhase.UpToDate -> "you're up to date"244 UpdatePhase.Available -> "a newer Peach"245 UpdatePhase.Downloading -> "downloading"246 UpdatePhase.Ready -> "ready to install"247 UpdatePhase.Failed -> "couldn't finish"248 UpdatePhase.Idle -> "updates"249 },250 color = peachColors.T0,251 style = PeachType.createHeadline,252 )253 Spacer(Modifier.height(PeachSpacing.xs))254 Text(255 buildString {256 append("Installed v${vm.currentVersion}")257 state.manifest?.let { append(" · latest v${it.version}") }258 },259 color = peachColors.T1,260 style = PeachType.labelMono,261 )262263 264 265 266 267 NotificationOptIn()268269 state.error?.let { message ->270 Spacer(Modifier.height(PeachSpacing.md))271 Box(272 Modifier.fillMaxWidth()273 .clip(RoundedCornerShape(10.dp))274 .background(peachColors.BreachBg)275 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(10.dp))276 .padding(PeachSpacing.md),277 ) {278 Text(message, color = peachColors.Breach, style = PeachType.hint)279 }280 }281282 state.manifest?.notes?.let { notes ->283 Spacer(Modifier.height(PeachSpacing.lg))284 SectionLabel("WHAT'S IN IT")285 ReleaseNotes(notes)286 }287288 if (state.phase == UpdatePhase.Downloading) {289 Spacer(Modifier.height(PeachSpacing.lg))290 ProgressBar(state.progress)291 }292293 if (state.explainInstallPermission) {294 Spacer(Modifier.height(PeachSpacing.lg))295 InstallPermissionExplanation(296 onOpenSettings = { vm.openInstallPermissionSettings(context) },297 onDismiss = { vm.dismissInstallExplanation() },298 )299 }300301 Spacer(Modifier.height(PeachSpacing.xl))302 when (state.phase) {303 UpdatePhase.Available ->304 PrimaryButton("Download v${state.manifest?.version}") { vm.download() }305 UpdatePhase.Ready ->306 PrimaryButton("Install v${state.manifest?.version}") { vm.install(context) }307 UpdatePhase.Downloading -> Unit308 UpdatePhase.Checking -> Unit309 else -> PrimaryButton("Check for updates") { vm.check() }310 }311312 if (state.phase == UpdatePhase.Ready) {313 Spacer(Modifier.height(PeachSpacing.sm))314 Text(315 "Verified against the checksum Kepr published for this release.",316 color = peachColors.T1,317 style = PeachType.hint,318 )319 }320321 Spacer(Modifier.height(PeachSpacing.xl))322 Spacer(Modifier.height(PeachSpacing.xxl))323 }324 }325 }326}327328329330331332333@Composable334private fun NotificationOptIn() {335 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return336 val context = LocalContext.current337 var granted by remember {338 mutableStateOf(339 ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==340 PackageManager.PERMISSION_GRANTED,341 )342 }343 if (granted) return344 val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {345 granted = it346 }347 Spacer(Modifier.height(PeachSpacing.lg))348 SectionLabel("NOTIFICATIONS")349 Text(350 "Peach can tell you when a new version is out. Nothing else uses this.",351 color = peachColors.T1,352 style = PeachType.hint,353 )354 Spacer(Modifier.height(PeachSpacing.sm))355 GhostButton("Allow update notifications") {356 launcher.launch(Manifest.permission.POST_NOTIFICATIONS)357 }358}359360361362363364365@Composable366private fun InstallPermissionExplanation(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {367 Column(368 Modifier.fillMaxWidth()369 .clip(RoundedCornerShape(12.dp))370 .background(peachColors.SettingsGroupBg)371 .border(1.dp, peachColors.T2, RoundedCornerShape(12.dp))372 .padding(PeachSpacing.md),373 ) {374 Text(375 "Android needs your permission",376 color = peachColors.T0,377 style = PeachType.onboardingHeadline,378 )379 Spacer(Modifier.height(PeachSpacing.sm))380 Text(381 "Peach isn't distributed through the Play Store, so Android treats this " +382 "update like any other app you install yourself. The next screen is " +383 "Android's own — turn on \"Allow from this source\" for Peach, then come " +384 "back here and tap install. You can turn it off again afterwards.",385 color = peachColors.T1,386 style = PeachType.hint,387 )388 Spacer(Modifier.height(PeachSpacing.md))389 Row(horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {390 GhostButton("Not now", onDismiss)391 GhostButton("Open Android settings", onOpenSettings)392 }393 }394}395396@Composable397internal fun SectionLabel(text: String) {398 Text(399 text,400 modifier = Modifier.padding(bottom = PeachSpacing.sm),401 color = peachColors.T1,402 style = PeachType.tinyMono,403 )404}405406407408409410411@Composable412internal fun ReleaseNotes(notes: String) {413 Column(verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs)) {414 notes.lines().forEach { raw ->415 val line = raw.trim()416 when {417 line.isEmpty() -> Spacer(Modifier.height(PeachSpacing.xs))418 line.startsWith("#") -> Text(419 stripInlineMarkers(line.trimStart('#').trim()),420 modifier = Modifier.padding(top = PeachSpacing.sm),421 color = peachColors.T0,422 style = PeachType.settingsLede.copy(fontSize = 15.sp),423 )424 line.startsWith("- ") || line.startsWith("* ") || line.startsWith("• ") -> Row {425 Text("· ", color = LocalPeachAccent.current, style = PeachType.body)426 Text(427 stripInlineMarkers(line.drop(2).trim()),428 color = peachColors.T1,429 style = PeachType.body.copy(lineHeight = 19.sp),430 )431 }432 else -> Text(433 stripInlineMarkers(line),434 color = peachColors.T1,435 style = PeachType.body.copy(lineHeight = 19.sp),436 )437 }438 }439 }440}441442internal fun stripInlineMarkers(line: String): String =443 line.replace("**", "").replace("__", "").replace("`", "")444445@Composable446private fun ProgressBar(progress: Float) {447 val accent = LocalPeachAccent.current448 Column {449 Box(450 Modifier.fillMaxWidth().height(4.dp)451 .clip(RoundedCornerShape(999.dp))452 .background(peachColors.T3),453 ) {454 Box(455 Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(4.dp)456 .clip(RoundedCornerShape(999.dp))457 .background(accent),458 )459 }460 Spacer(Modifier.height(PeachSpacing.sm))461 Text("${(progress * 100).toInt()}%", color = peachColors.T1, style = PeachType.labelMono)462 }463}464465@Composable466internal fun PrimaryButton(label: String, onClick: () -> Unit) {467 val accent = LocalPeachAccent.current468 Box(469 Modifier.fillMaxWidth()470 .clip(RoundedCornerShape(999.dp))471 .background(accent.copy(alpha = 0.16f))472 .border(1.dp, accent.copy(alpha = 0.55f), RoundedCornerShape(999.dp))473 .clickable(onClick = onClick)474 .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),475 contentAlignment = Alignment.Center,476 ) {477 Text(label, color = accent, style = PeachType.sortPill.copy(fontSize = 11.sp))478 }479}480481@Composable482private fun GhostButton(label: String, onClick: () -> Unit) {483 Box(484 Modifier.clip(RoundedCornerShape(999.dp))485 .border(1.dp, peachColors.T2, RoundedCornerShape(999.dp))486 .clickable(onClick = onClick)487 .padding(horizontal = PeachSpacing.md, vertical = 9.dp),488 contentAlignment = Alignment.Center,489 ) {490 Text(label, color = peachColors.T0, style = PeachType.sortPill.copy(fontSize = 10.sp))491 }492}