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 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}297298299300301302303@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}328329330331332333334@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}374375376377378379380@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}