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.PeachSectionHeader48import 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 PeachSectionHeader(section = "update", onHome = onBack)224 Spacer(Modifier.height(PeachSpacing.lg))225226 Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {227 Text(228 when (state.phase) {229 UpdatePhase.Checking -> "checking…"230 UpdatePhase.UpToDate -> "you're up to date"231 UpdatePhase.Available -> "a newer Peach"232 UpdatePhase.Downloading -> "downloading"233 UpdatePhase.Ready -> "ready to install"234 UpdatePhase.Failed -> "couldn't finish"235 UpdatePhase.Idle -> "updates"236 },237 color = peachColors.T0,238 style = PeachType.createHeadline,239 )240 Spacer(Modifier.height(PeachSpacing.xs))241 Text(242 buildString {243 append("Installed v${vm.currentVersion}")244 state.manifest?.let { append(" · latest v${it.version}") }245 },246 color = peachColors.T1,247 style = PeachType.labelMono,248 )249250 251 252 253 254 NotificationOptIn()255256 state.error?.let { message ->257 Spacer(Modifier.height(PeachSpacing.md))258 Box(259 Modifier.fillMaxWidth()260 .clip(RoundedCornerShape(10.dp))261 .background(peachColors.BreachBg)262 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(10.dp))263 .padding(PeachSpacing.md),264 ) {265 Text(message, color = peachColors.Breach, style = PeachType.hint)266 }267 }268269 state.manifest?.notes?.let { notes ->270 Spacer(Modifier.height(PeachSpacing.lg))271 SectionLabel("WHAT'S IN IT")272 ReleaseNotes(notes)273 }274275 if (state.phase == UpdatePhase.Downloading) {276 Spacer(Modifier.height(PeachSpacing.lg))277 ProgressBar(state.progress)278 }279280 if (state.explainInstallPermission) {281 Spacer(Modifier.height(PeachSpacing.lg))282 InstallPermissionExplanation(283 onOpenSettings = { vm.openInstallPermissionSettings(context) },284 onDismiss = { vm.dismissInstallExplanation() },285 )286 }287288 Spacer(Modifier.height(PeachSpacing.xl))289 when (state.phase) {290 UpdatePhase.Available ->291 PrimaryButton("Download v${state.manifest?.version}") { vm.download() }292 UpdatePhase.Ready ->293 PrimaryButton("Install v${state.manifest?.version}") { vm.install(context) }294 UpdatePhase.Downloading -> Unit295 UpdatePhase.Checking -> Unit296 else -> PrimaryButton("Check for updates") { vm.check() }297 }298299 if (state.phase == UpdatePhase.Ready) {300 Spacer(Modifier.height(PeachSpacing.sm))301 Text(302 "Verified against the checksum Kepr published for this release.",303 color = peachColors.T1,304 style = PeachType.hint,305 )306 }307308 Spacer(Modifier.height(PeachSpacing.xl))309 Spacer(Modifier.height(PeachSpacing.xxl))310 }311 }312 }313}314315316317318319320@Composable321private fun NotificationOptIn() {322 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return323 val context = LocalContext.current324 var granted by remember {325 mutableStateOf(326 ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==327 PackageManager.PERMISSION_GRANTED,328 )329 }330 if (granted) return331 val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {332 granted = it333 }334 Spacer(Modifier.height(PeachSpacing.lg))335 SectionLabel("NOTIFICATIONS")336 Text(337 "Peach can tell you when a new version is out. Nothing else uses this.",338 color = peachColors.T1,339 style = PeachType.hint,340 )341 Spacer(Modifier.height(PeachSpacing.sm))342 GhostButton("Allow update notifications") {343 launcher.launch(Manifest.permission.POST_NOTIFICATIONS)344 }345}346347348349350351352@Composable353private fun InstallPermissionExplanation(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {354 Column(355 Modifier.fillMaxWidth()356 .clip(RoundedCornerShape(12.dp))357 .background(peachColors.SettingsGroupBg)358 .border(1.dp, peachColors.T2, RoundedCornerShape(12.dp))359 .padding(PeachSpacing.md),360 ) {361 Text(362 "Android needs your permission",363 color = peachColors.T0,364 style = PeachType.onboardingHeadline,365 )366 Spacer(Modifier.height(PeachSpacing.sm))367 Text(368 "Peach isn't distributed through the Play Store, so Android treats this " +369 "update like any other app you install yourself. The next screen is " +370 "Android's own — turn on \"Allow from this source\" for Peach, then come " +371 "back here and tap install. You can turn it off again afterwards.",372 color = peachColors.T1,373 style = PeachType.hint,374 )375 Spacer(Modifier.height(PeachSpacing.md))376 Row(horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {377 GhostButton("Not now", onDismiss)378 GhostButton("Open Android settings", onOpenSettings)379 }380 }381}382383@Composable384internal fun SectionLabel(text: String) {385 Text(386 text,387 modifier = Modifier.padding(bottom = PeachSpacing.sm),388 color = peachColors.T1,389 style = PeachType.tinyMono,390 )391}392393394395396397398@Composable399internal fun ReleaseNotes(notes: String) {400 Column(verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs)) {401 notes.lines().forEach { raw ->402 val line = raw.trim()403 when {404 line.isEmpty() -> Spacer(Modifier.height(PeachSpacing.xs))405 line.startsWith("#") -> Text(406 stripInlineMarkers(line.trimStart('#').trim()),407 modifier = Modifier.padding(top = PeachSpacing.sm),408 color = peachColors.T0,409 style = PeachType.settingsLede.copy(fontSize = 15.sp),410 )411 line.startsWith("- ") || line.startsWith("* ") || line.startsWith("• ") -> Row {412 Text("· ", color = LocalPeachAccent.current, style = PeachType.body)413 Text(414 stripInlineMarkers(line.drop(2).trim()),415 color = peachColors.T1,416 style = PeachType.body.copy(lineHeight = 19.sp),417 )418 }419 else -> Text(420 stripInlineMarkers(line),421 color = peachColors.T1,422 style = PeachType.body.copy(lineHeight = 19.sp),423 )424 }425 }426 }427}428429internal fun stripInlineMarkers(line: String): String =430 line.replace("**", "").replace("__", "").replace("`", "")431432@Composable433private fun ProgressBar(progress: Float) {434 val accent = LocalPeachAccent.current435 Column {436 Box(437 Modifier.fillMaxWidth().height(4.dp)438 .clip(RoundedCornerShape(999.dp))439 .background(peachColors.T3),440 ) {441 Box(442 Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(4.dp)443 .clip(RoundedCornerShape(999.dp))444 .background(accent),445 )446 }447 Spacer(Modifier.height(PeachSpacing.sm))448 Text("${(progress * 100).toInt()}%", color = peachColors.T1, style = PeachType.labelMono)449 }450}451452@Composable453internal fun PrimaryButton(label: String, onClick: () -> Unit) {454 val accent = LocalPeachAccent.current455 Box(456 Modifier.fillMaxWidth()457 .clip(RoundedCornerShape(999.dp))458 .background(accent.copy(alpha = 0.16f))459 .border(1.dp, accent.copy(alpha = 0.55f), RoundedCornerShape(999.dp))460 .clickable(onClick = onClick)461 .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),462 contentAlignment = Alignment.Center,463 ) {464 Text(label, color = accent, style = PeachType.sortPill.copy(fontSize = 11.sp))465 }466}467468@Composable469private fun GhostButton(label: String, onClick: () -> Unit) {470 Box(471 Modifier.clip(RoundedCornerShape(999.dp))472 .border(1.dp, peachColors.T2, RoundedCornerShape(999.dp))473 .clickable(onClick = onClick)474 .padding(horizontal = PeachSpacing.md, vertical = 9.dp),475 contentAlignment = Alignment.Center,476 ) {477 Text(label, color = peachColors.T0, style = PeachType.sortPill.copy(fontSize = 10.sp))478 }479}