From a939373e57338a73ab3207a57879efde3f621c09 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 27 Oct 2026 13:23:04 +0100 Subject: [PATCH 1/7] fix: recover playback after the server evicts an idle session --- .../stream/SabrPlaybackContractFailure.kt | 14 +++++ .../stream/SabrPlaybackSessionPreparer.kt | 2 +- .../data/stream/SabrPlaybackWindowWait.kt | 7 +-- .../data/stream/SabrMissingSessionTest.kt | 61 +++++++++++++++++++ .../android/services/PlaybackRecoveryCause.kt | 3 +- 5 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 core/data/src/test/java/dev/typetype/android/data/stream/SabrMissingSessionTest.kt diff --git a/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContractFailure.kt b/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContractFailure.kt index a4da0054..5779e45b 100644 --- a/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContractFailure.kt +++ b/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContractFailure.kt @@ -1,12 +1,24 @@ package dev.typetype.android.data.stream import dev.typetype.android.core.error.CodedFailure +import dev.typetype.android.data.network.ServerResponseException +import dev.typetype.android.data.network.extractServerError import retrofit2.Response +const val SABR_PLAYBACK_NOT_FOUND = "sabr_playback_not_found" + internal fun Response<*>.rejectSabrRedirect(message: String) { if (code() in 300..399) sabrContractMismatch(message) } +// The server answers an evicted idle session with a 404 and its generic "error" code. +internal fun sabrSessionResponseException(response: Response<*>): ServerResponseException { + val error = extractServerError(response) + return ServerResponseException( + if (response.code() == HTTP_NOT_FOUND) error.copy(code = SABR_PLAYBACK_NOT_FOUND) else error, + ) +} + internal fun sabrContractMismatch(message: String, cause: Throwable? = null): Nothing = throw SabrPlaybackContractFailure(message, "youtube_sabr_contract_mismatch", cause) @@ -21,3 +33,5 @@ private class SabrPlaybackContractFailure( override val requestId: String? = null override val statusCode: Int? = null } + +private const val HTTP_NOT_FOUND = 404 diff --git a/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt b/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt index 6b050fc5..ef37727e 100644 --- a/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt +++ b/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt @@ -80,7 +80,7 @@ internal class SabrPlaybackSessionPreparer( listOf("sabr", "playback", binding.sessionId, "seek"), "SABR seek left its server session endpoint", ) - if (!response.isSuccessful) throw serverResponseException(response) + if (!response.isSuccessful) throw sabrSessionResponseException(response) val control = response.body() ?.requireControlResponse(target, binding.sessionId, binding.generation) ?: sabrContractMismatch("SABR returned an empty seek response") diff --git a/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackWindowWait.kt b/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackWindowWait.kt index b6b24483..3e5c05ed 100644 --- a/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackWindowWait.kt +++ b/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackWindowWait.kt @@ -5,7 +5,6 @@ import dev.typetype.android.data.network.TypeTypeMediaApi import dev.typetype.android.data.network.dto.SabrPlaybackPositionRequestDto import dev.typetype.android.data.network.dto.SabrPlaybackResponse import dev.typetype.android.data.network.dto.SabrPlaybackWindowRequestDto -import dev.typetype.android.data.network.serverResponseException import dev.typetype.android.domain.stream.SabrPlaybackBufferedRange import dev.typetype.android.domain.stream.SabrPlaybackSession import dev.typetype.android.domain.stream.SabrPlaybackTarget @@ -45,7 +44,7 @@ internal class SabrPlaybackWindowWait( api.prefetchSabrPlayback(control.sessionId, request()) } prefetch.requireWindowEndpoint(baseUrl, control.sessionId, "prefetch") - if (!prefetch.isSuccessful) throw serverResponseException(prefetch) + if (!prefetch.isSuccessful) throw sabrSessionResponseException(prefetch) val pending = prefetch.body() ?.requireWindowIdentity(control) ?: sabrContractMismatch("SABR returned an empty prefetch response") @@ -55,7 +54,7 @@ internal class SabrPlaybackWindowWait( api.sabrPlaybackSegments(control.sessionId, request()) } segments.requireWindowEndpoint(baseUrl, control.sessionId, "segments") - if (!segments.isSuccessful) throw serverResponseException(segments) + if (!segments.isSuccessful) throw sabrSessionResponseException(segments) val window = segments.body() ?.requireWindowIdentity(control) ?: sabrContractMismatch("SABR returned an empty segment window") @@ -108,7 +107,7 @@ internal class SabrPlaybackWindowWait( listOf("sabr", "playback", control.sessionId, "position"), "SABR position update left its server session endpoint", ) - if (!response.isSuccessful) throw serverResponseException(response) + if (!response.isSuccessful) throw sabrSessionResponseException(response) val body = response.body() ?: sabrContractMismatch("SABR returned an empty position response") if (body.sessionId != control.sessionId || body.generation != control.generation) { diff --git a/core/data/src/test/java/dev/typetype/android/data/stream/SabrMissingSessionTest.kt b/core/data/src/test/java/dev/typetype/android/data/stream/SabrMissingSessionTest.kt new file mode 100644 index 00000000..55f6e78d --- /dev/null +++ b/core/data/src/test/java/dev/typetype/android/data/stream/SabrMissingSessionTest.kt @@ -0,0 +1,61 @@ +package dev.typetype.android.data.stream + +import dev.typetype.android.data.network.ServerResponseException +import dev.typetype.android.domain.stream.SabrPlaybackSnapshot +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SabrMissingSessionTest { + private val script = SabrServerScript() + + @After + fun tearDown() = script.shutdown() + + @Test + fun anEvictedSessionIsReportedAsMissing() = runBlocking { + script.enqueue(EVICTED_SESSION_BODY, code = 404) + + val failure = runCatching { refresh() }.exceptionOrNull() + + assertTrue(failure is ServerResponseException) + assertEquals(SABR_PLAYBACK_NOT_FOUND, (failure as ServerResponseException).failureCode) + assertEquals(404, failure.statusCode) + } + + @Test + fun anEvictedSessionDuringSeekIsReportedAsMissing() = runBlocking { + script.enqueue(EVICTED_SESSION_BODY, code = 404) + + val failure = runCatching { + SabrPlaybackSessionPreparer(pause = {}) + .seek(script.api, script.baseUrl, script.target(), script.binding(), PLAYER_TIME_MS) + }.exceptionOrNull() + + assertEquals(SABR_PLAYBACK_NOT_FOUND, (failure as ServerResponseException).failureCode) + } + + @Test + fun aMissingVideoKeepsTheServerCode() = runBlocking { + script.enqueue("""{"error":"Video not found","code":"video_not_found"}""", code = 404) + + val failure = runCatching { + SabrPlaybackSessionPreparer(pause = {}) + .prepareOnce(script.api, script.baseUrl, script.target(), PLAYER_TIME_MS) + }.exceptionOrNull() + + assertEquals("video_not_found", (failure as ServerResponseException).failureCode) + } + + private suspend fun refresh() = SabrPlaybackSessionPreparer(pause = {}) + .refresh(script.api, script.baseUrl, script.target(), script.binding()) { + SabrPlaybackSnapshot(playerTimeMs = PLAYER_TIME_MS, bufferedRanges = emptyList()) + } + + private companion object { + const val PLAYER_TIME_MS = 245_000L + const val EVICTED_SESSION_BODY = """{"error":"No active SABR playback session","code":"error"}""" + } +} diff --git a/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryCause.kt b/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryCause.kt index 830c90d6..d6314d75 100644 --- a/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryCause.kt +++ b/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryCause.kt @@ -4,6 +4,7 @@ import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.HttpDataSource import dev.typetype.android.core.error.CodedFailure +import dev.typetype.android.data.stream.SABR_PLAYBACK_NOT_FOUND import dev.typetype.android.data.stream.SABR_PREPARATION_TIMEOUT import dev.typetype.android.data.stream.SabrPlaybackRecoveryException import java.io.InterruptedIOException @@ -81,7 +82,7 @@ private fun Int.recoveryCause(sabrMedia: Boolean = true): PlaybackRecoveryCause private val SESSION_FAILURE_CODES = setOf( SABR_PREPARATION_TIMEOUT, SABR_CONTRACT_FAILURE_CODE, - "sabr_playback_not_found", + SABR_PLAYBACK_NOT_FOUND, ) private val GONE_STATUS_CODES = setOf(404, 409, 410) private const val MAX_CAUSE_DEPTH = 8 From a1271c70a17a39a99e8c5fe83fe7ae60318caf7e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 27 Oct 2026 13:57:16 +0100 Subject: [PATCH 2/7] feat: read release notes as sections and bullets --- .../settings/updates/ReleaseNotesBlocks.kt | 49 ++++++++++++++ .../updates/ReleaseNotesBlocksTest.kt | 66 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/ReleaseNotesBlocks.kt create mode 100644 feature/settings/src/test/java/dev/typetype/android/feature/settings/updates/ReleaseNotesBlocksTest.kt diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/ReleaseNotesBlocks.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/ReleaseNotesBlocks.kt new file mode 100644 index 00000000..17cc4a2a --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/ReleaseNotesBlocks.kt @@ -0,0 +1,49 @@ +package dev.typetype.android.feature.settings.updates + +internal sealed interface ReleaseNotesBlock { + data class Heading(val text: String) : ReleaseNotesBlock + + data class Bullet(val text: String) : ReleaseNotesBlock + + data class Paragraph(val text: String) : ReleaseNotesBlock +} + +internal fun parseReleaseNotes(markdown: String): List { + val blocks = mutableListOf() + val paragraph = StringBuilder() + fun flushParagraph() { + if (paragraph.isNotBlank()) blocks += ReleaseNotesBlock.Paragraph(paragraph.toString().trim()) + paragraph.clear() + } + markdown.lineSequence().map(String::trim).forEach { line -> + when { + line.isEmpty() -> flushParagraph() + line.startsWith("# ") -> flushParagraph() + line.startsWith("#") -> { + flushParagraph() + blocks += ReleaseNotesBlock.Heading(line.trimStart('#').trim().plainText()) + } + line.startsWith("- ") || line.startsWith("* ") -> { + flushParagraph() + blocks += ReleaseNotesBlock.Bullet(line.drop(2).trim().plainText()) + } + else -> paragraph.append(' ').append(line.plainText()) + } + } + flushParagraph() + return blocks.filterNot { it is ReleaseNotesBlock.Heading && it.text.isBlank() } +} + +internal fun List.dropRepeatedTitle(versionName: String): List { + val first = firstOrNull() as? ReleaseNotesBlock.Paragraph ?: return this + return if (first.text.endsWith(versionName) && first.text.length <= MAX_TITLE_LENGTH) drop(1) else this +} + +private fun String.plainText(): String = this + .replace(LINK, "$1") + .replace(BOLD, "$1") + .replace("`", "") + +private val LINK = Regex("""\[([^\]]+)]\([^)]*\)""") +private val BOLD = Regex("""\*\*(\S(?:.*?\S)?)\*\*""") +private const val MAX_TITLE_LENGTH = 64 diff --git a/feature/settings/src/test/java/dev/typetype/android/feature/settings/updates/ReleaseNotesBlocksTest.kt b/feature/settings/src/test/java/dev/typetype/android/feature/settings/updates/ReleaseNotesBlocksTest.kt new file mode 100644 index 00000000..d995a792 --- /dev/null +++ b/feature/settings/src/test/java/dev/typetype/android/feature/settings/updates/ReleaseNotesBlocksTest.kt @@ -0,0 +1,66 @@ +package dev.typetype.android.feature.settings.updates + +import dev.typetype.android.feature.settings.updates.ReleaseNotesBlock.Bullet +import dev.typetype.android.feature.settings.updates.ReleaseNotesBlock.Heading +import dev.typetype.android.feature.settings.updates.ReleaseNotesBlock.Paragraph +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReleaseNotesBlocksTest { + @Test + fun stableNotesBecomeSectionsBulletsAndParagraphs() { + val notes = """ + # TypeType for Android 1.11.0 + + TypeType for Android 1.11.0 brings + personalized recommendations. + + ## Playback and recovery + + - Resume playback after a long pause. + * Keep retrying after network loss. + """.trimIndent() + + assertEquals( + listOf( + Paragraph("TypeType for Android 1.11.0 brings personalized recommendations."), + Heading("Playback and recovery"), + Bullet("Resume playback after a long pause."), + Bullet("Keep retrying after network loss."), + ), + parseReleaseNotes(notes), + ) + } + + @Test + fun inlineMarkdownIsShownAsPlainText() { + val notes = "- **Full changelog:** see [the compare view](https://example.com) and `p256dh` keys" + + assertEquals( + listOf(Bullet("Full changelog: see the compare view and p256dh keys")), + parseReleaseNotes(notes), + ) + } + + @Test + fun underscoresInsideWordsAreKept() { + assertEquals(listOf(Paragraph("retry_fresh_session")), parseReleaseNotes("retry_fresh_session")) + } + + @Test + fun aBetaTitleLineRepeatingTheVersionIsDropped() { + val blocks = parseReleaseNotes("TypeType Android 1.11.0-beta.9\n\n- Recommendations have their own entry.") + + assertEquals( + listOf(Bullet("Recommendations have their own entry.")), + blocks.dropRepeatedTitle("1.11.0-beta.9"), + ) + } + + @Test + fun anIntroductionMentioningTheVersionIsKept() { + val blocks = listOf(Paragraph("TypeType for Android 1.11.0 brings recommendations and a lot more for 1.11.0")) + + assertEquals(blocks, blocks.dropRepeatedTitle("1.11.0")) + } +} From 517cd823b9cbd6257fe5f836e7bd78a2b635f295 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 27 Oct 2026 13:57:16 +0100 Subject: [PATCH 3/7] refactor: move app update logic out of the About screen --- .../settings/about/AboutAppUpdatesSection.kt | 203 ------------------ .../feature/settings/about/AboutScreen.kt | 9 - .../feature/settings/about/AboutViewModel.kt | 147 +------------ .../{about => updates}/AppUpdateUiState.kt | 2 +- .../settings/updates/AppUpdatesViewModel.kt | 153 +++++++++++++ 5 files changed, 155 insertions(+), 359 deletions(-) delete mode 100644 feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutAppUpdatesSection.kt rename feature/settings/src/main/java/dev/typetype/android/feature/settings/{about => updates}/AppUpdateUiState.kt (93%) create mode 100644 feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdatesViewModel.kt diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutAppUpdatesSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutAppUpdatesSection.kt deleted file mode 100644 index df702980..00000000 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutAppUpdatesSection.kt +++ /dev/null @@ -1,203 +0,0 @@ -package dev.typetype.android.feature.settings.about - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.material3.Button -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import dev.typetype.android.R -import dev.typetype.android.domain.appupdate.AppUpdateChannel -import dev.typetype.android.domain.appupdate.AppUpdateFailureReason - -@Composable -internal fun AboutAppUpdatesSection( - state: AppUpdateUiState, - onChannelChanged: (AppUpdateChannel) -> Unit, - onCheck: () -> Unit, - onDownload: () -> Unit, - onInstall: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = stringResource(R.string.about_section_app_updates), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp), - ) - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - UpdateChannelRow( - channel = AppUpdateChannel.F_DROID, - selected = state.channel == AppUpdateChannel.F_DROID, - label = stringResource(R.string.about_update_channel_fdroid), - enabled = state.status != AppUpdateUiStatus.DOWNLOADING, - onClick = onChannelChanged, - ) - UpdateChannelRow( - channel = AppUpdateChannel.GITHUB_STABLE, - selected = state.channel == AppUpdateChannel.GITHUB_STABLE, - label = stringResource(R.string.about_update_channel_github_stable), - enabled = state.status != AppUpdateUiStatus.DOWNLOADING, - onClick = onChannelChanged, - ) - UpdateChannelRow( - channel = AppUpdateChannel.GITHUB_BETA, - selected = state.channel == AppUpdateChannel.GITHUB_BETA, - label = stringResource(R.string.about_update_channel_github_beta), - enabled = state.status != AppUpdateUiStatus.DOWNLOADING, - onClick = onChannelChanged, - ) - } - if (state.channel == AppUpdateChannel.GITHUB_BETA) { - Text( - text = stringResource(R.string.about_update_beta_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - UpdateStatus(state) - state.release?.let { release -> - Text( - text = stringResource(R.string.about_update_release, release.versionName), - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 16.dp), - ) - if (release.releaseNotes.isNotBlank()) { - Text( - text = release.releaseNotes, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - } - if (state.status == AppUpdateUiStatus.DOWNLOADING) { - LinearProgressIndicator( - progress = { state.progress }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) - Text( - text = stringResource(R.string.about_update_progress, (state.progress * 100).toInt()), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (state.channel != AppUpdateChannel.F_DROID) { - Button( - onClick = onCheck, - enabled = state.status != AppUpdateUiStatus.CHECKING && - state.status != AppUpdateUiStatus.DOWNLOADING, - ) { - Text(stringResource(R.string.about_update_check)) - } - } - if (state.status == AppUpdateUiStatus.AVAILABLE) { - Button(onClick = onDownload) { - Text(stringResource(R.string.about_update_download)) - } - } - if ( - state.status == AppUpdateUiStatus.READY_TO_INSTALL || - state.status == AppUpdateUiStatus.PERMISSION_REQUESTED - ) { - Button(onClick = onInstall) { - Text(stringResource(R.string.about_update_install)) - } - } - } - } -} - -@Composable -private fun UpdateChannelRow( - channel: AppUpdateChannel, - selected: Boolean, - label: String, - enabled: Boolean, - onClick: (AppUpdateChannel) -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = selected, - enabled = enabled, - role = Role.RadioButton, - onClick = { onClick(channel) }, - ) - .padding(horizontal = 12.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton(selected = selected, onClick = null, enabled = enabled) - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp), - ) - } -} - -@Composable -private fun UpdateStatus(state: AppUpdateUiState) { - val text = when (state.status) { - AppUpdateUiStatus.IDLE -> null - AppUpdateUiStatus.CHECKING -> stringResource(R.string.about_update_checking) - AppUpdateUiStatus.MANAGED_BY_F_DROID -> stringResource(R.string.about_update_managed_by_fdroid) - AppUpdateUiStatus.UP_TO_DATE -> stringResource(R.string.about_update_up_to_date) - AppUpdateUiStatus.AVAILABLE -> stringResource(R.string.about_update_available) - AppUpdateUiStatus.NEWER_BUILD_INSTALLED -> - stringResource(R.string.about_update_newer_build_installed) - AppUpdateUiStatus.DOWNLOADING -> stringResource(R.string.about_update_downloading) - AppUpdateUiStatus.READY_TO_INSTALL -> stringResource(R.string.about_update_ready_to_install) - AppUpdateUiStatus.INSTALLER_OPENED -> stringResource(R.string.about_update_installer_opened) - AppUpdateUiStatus.PERMISSION_REQUESTED -> stringResource(R.string.about_update_permission_requested) - AppUpdateUiStatus.FAILED -> state.failureReason?.let(::failureMessage) - ?.let { stringResource(it) } - ?: stringResource(R.string.about_update_failed) - } - text?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = if (state.status == AppUpdateUiStatus.FAILED) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } -} - -private fun failureMessage(reason: AppUpdateFailureReason): Int = when (reason) { - AppUpdateFailureReason.NETWORK -> R.string.about_update_failed_network - AppUpdateFailureReason.INVALID_RELEASE -> R.string.about_update_failed_release - AppUpdateFailureReason.INVALID_CHECKSUM -> R.string.about_update_failed_checksum - AppUpdateFailureReason.INVALID_PACKAGE -> R.string.about_update_failed_package - AppUpdateFailureReason.DOWNGRADE_BLOCKED -> R.string.about_update_failed_downgrade - AppUpdateFailureReason.SIGNATURE_MISMATCH -> R.string.about_update_failed_signature - AppUpdateFailureReason.INSTALLER_UNAVAILABLE -> R.string.about_update_failed_installer -} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt index 81cd0da0..a2c41b77 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt @@ -80,15 +80,6 @@ fun AboutScreen( ) } } - item { - AboutAppUpdatesSection( - state = state.appUpdate, - onChannelChanged = viewModel::setAppUpdateChannel, - onCheck = viewModel::checkAppUpdate, - onDownload = viewModel::downloadAppUpdate, - onInstall = viewModel::installAppUpdate, - ) - } item { AboutVersionsSection( versions = state.componentVersions, diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutViewModel.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutViewModel.kt index f407dce5..ddd82fc8 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutViewModel.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutViewModel.kt @@ -3,27 +3,16 @@ package dev.typetype.android.feature.settings.about import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dev.typetype.android.data.appupdate.AndroidAppUpdateInstaller -import dev.typetype.android.data.appupdate.AppUpdateInstallLaunch -import dev.typetype.android.domain.appupdate.AppUpdateChannel -import dev.typetype.android.domain.appupdate.AppUpdateCheck -import dev.typetype.android.domain.appupdate.AppUpdateFailure -import dev.typetype.android.domain.appupdate.AppUpdateFailureReason -import dev.typetype.android.domain.appupdate.AppUpdateRepository -import dev.typetype.android.domain.appupdate.AppUpdateSettingsRepository import dev.typetype.android.domain.profile.ProfileRepository import dev.typetype.android.domain.server.ServerRepository import dev.typetype.android.domain.version.ComponentVersions import dev.typetype.android.domain.version.AppVersion import dev.typetype.android.domain.version.ComponentVersionsRepository import javax.inject.Inject -import java.io.IOException -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -33,7 +22,6 @@ data class AboutState( val role: String = "", val componentVersions: ComponentVersions? = null, val isLoadingVersions: Boolean = false, - val appUpdate: AppUpdateUiState = AppUpdateUiState(), ) private data class VersionLoadState( @@ -47,51 +35,28 @@ class AboutViewModel @Inject constructor( profileRepository: ProfileRepository, private val componentVersionsRepository: ComponentVersionsRepository, appVersion: AppVersion, - private val appUpdateRepository: AppUpdateRepository, - private val appUpdateSettingsRepository: AppUpdateSettingsRepository, - private val appUpdateInstaller: AndroidAppUpdateInstaller, ) : ViewModel() { val versionName: String = appVersion.name - private val versionCode: Long = appVersion.code private val versionLoadState = MutableStateFlow(VersionLoadState()) - private val appUpdateState = MutableStateFlow(AppUpdateUiState()) private var versionRefreshJob: Job? = null - private var appUpdateJob: Job? = null val state = combine( serverRepository.observeCurrentServer(), profileRepository.observe(), versionLoadState, - appUpdateState, - ) { server, profile, versions, appUpdate -> + ) { server, profile, versions -> AboutState( serverBaseUrl = server?.baseUrl.orEmpty(), publicUsername = profile?.publicUsername.orEmpty(), role = profile?.role.orEmpty(), componentVersions = versions.versions, isLoadingVersions = versions.isLoading, - appUpdate = appUpdate, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AboutState()) init { viewModelScope.launch { profileRepository.refresh() } refreshVersions() - viewModelScope.launch { - appUpdateSettingsRepository.observeChannel() - .distinctUntilChanged() - .collect { channel -> - appUpdateState.value = AppUpdateUiState( - channel = channel, - status = if (channel == AppUpdateChannel.F_DROID) { - AppUpdateUiStatus.MANAGED_BY_F_DROID - } else { - AppUpdateUiStatus.IDLE - }, - ) - if (channel != AppUpdateChannel.F_DROID) checkAppUpdate(channel) - } - } } fun refreshVersions() { @@ -104,114 +69,4 @@ class AboutViewModel @Inject constructor( ) } } - - fun setAppUpdateChannel(channel: AppUpdateChannel) { - viewModelScope.launch { appUpdateSettingsRepository.setChannel(channel) } - } - - fun checkAppUpdate() { - val channel = appUpdateState.value.channel - if (channel != AppUpdateChannel.F_DROID) checkAppUpdate(channel) - } - - fun downloadAppUpdate() { - val release = appUpdateState.value.release ?: return - appUpdateJob?.cancel() - appUpdateJob = viewModelScope.launch { - appUpdateState.value = appUpdateState.value.copy( - status = AppUpdateUiStatus.DOWNLOADING, - downloadedFile = null, - progress = 0f, - downloadedBytes = 0, - totalBytes = release.apkSizeBytes, - failureReason = null, - ) - try { - val file = appUpdateRepository.download(release) { downloaded, total -> - appUpdateState.value = appUpdateState.value.copy( - downloadedBytes = downloaded, - totalBytes = total, - progress = if (total > 0) (downloaded.toFloat() / total).coerceIn(0f, 1f) else 0f, - ) - } - appUpdateInstaller.validate(file, release) - appUpdateState.value = appUpdateState.value.copy( - status = AppUpdateUiStatus.READY_TO_INSTALL, - downloadedFile = file, - progress = 1f, - downloadedBytes = file.length(), - failureReason = null, - ) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (failure: Exception) { - failAppUpdate(failure) - } - } - } - - fun installAppUpdate() { - val file = appUpdateState.value.downloadedFile ?: return - try { - appUpdateState.value = appUpdateState.value.copy( - status = when (appUpdateInstaller.launch(file)) { - AppUpdateInstallLaunch.InstallerOpened -> AppUpdateUiStatus.INSTALLER_OPENED - AppUpdateInstallLaunch.PermissionRequested -> AppUpdateUiStatus.PERMISSION_REQUESTED - }, - ) - } catch (failure: Exception) { - failAppUpdate(failure) - } - } - - private fun checkAppUpdate(channel: AppUpdateChannel) { - appUpdateJob?.cancel() - appUpdateJob = viewModelScope.launch { - appUpdateState.value = AppUpdateUiState(channel = channel, status = AppUpdateUiStatus.CHECKING) - try { - val check = appUpdateRepository.check( - channel = channel, - installedVersionName = versionName, - installedVersionCode = versionCode, - ) - appUpdateState.value = when (check) { - AppUpdateCheck.ManagedByF_Droid -> AppUpdateUiState( - channel = channel, - status = AppUpdateUiStatus.MANAGED_BY_F_DROID, - ) - AppUpdateCheck.UpToDate -> AppUpdateUiState( - channel = channel, - status = AppUpdateUiStatus.UP_TO_DATE, - ) - is AppUpdateCheck.Available -> AppUpdateUiState( - channel = channel, - status = AppUpdateUiStatus.AVAILABLE, - release = check.release, - ) - is AppUpdateCheck.NewerBuildInstalled -> AppUpdateUiState( - channel = channel, - status = AppUpdateUiStatus.NEWER_BUILD_INSTALLED, - release = check.release, - ) - } - } catch (cancelled: CancellationException) { - throw cancelled - } catch (failure: Exception) { - failAppUpdate(failure, channel) - } - } - } - - private fun failAppUpdate(failure: Exception, channel: AppUpdateChannel = appUpdateState.value.channel) { - val reason = when (failure) { - is AppUpdateFailure -> failure.reason - is IOException -> AppUpdateFailureReason.NETWORK - else -> AppUpdateFailureReason.INSTALLER_UNAVAILABLE - } - appUpdateState.value = appUpdateState.value.copy( - channel = channel, - status = AppUpdateUiStatus.FAILED, - failureReason = reason, - ) - } } diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AppUpdateUiState.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateUiState.kt similarity index 93% rename from feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AppUpdateUiState.kt rename to feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateUiState.kt index 63efe06b..0b66d5d2 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AppUpdateUiState.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateUiState.kt @@ -1,4 +1,4 @@ -package dev.typetype.android.feature.settings.about +package dev.typetype.android.feature.settings.updates import dev.typetype.android.domain.appupdate.AppUpdateChannel import dev.typetype.android.domain.appupdate.AppUpdateFailureReason diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdatesViewModel.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdatesViewModel.kt new file mode 100644 index 00000000..90ddea20 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdatesViewModel.kt @@ -0,0 +1,153 @@ +package dev.typetype.android.feature.settings.updates + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.typetype.android.data.appupdate.AndroidAppUpdateInstaller +import dev.typetype.android.data.appupdate.AppUpdateInstallLaunch +import dev.typetype.android.domain.appupdate.AppUpdateChannel +import dev.typetype.android.domain.appupdate.AppUpdateCheck +import dev.typetype.android.domain.appupdate.AppUpdateFailure +import dev.typetype.android.domain.appupdate.AppUpdateFailureReason +import dev.typetype.android.domain.appupdate.AppUpdateRepository +import dev.typetype.android.domain.appupdate.AppUpdateSettingsRepository +import dev.typetype.android.domain.version.AppVersion +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@HiltViewModel +class AppUpdatesViewModel @Inject constructor( + appVersion: AppVersion, + private val repository: AppUpdateRepository, + private val settings: AppUpdateSettingsRepository, + private val installer: AndroidAppUpdateInstaller, +) : ViewModel() { + val versionName: String = appVersion.name + private val versionCode: Long = appVersion.code + private val mutableState = MutableStateFlow(AppUpdateUiState()) + val state: StateFlow = mutableState.asStateFlow() + private var job: Job? = null + + init { + viewModelScope.launch { + settings.observeChannel() + .distinctUntilChanged() + .collect { channel -> + mutableState.value = AppUpdateUiState( + channel = channel, + status = if (channel == AppUpdateChannel.F_DROID) { + AppUpdateUiStatus.MANAGED_BY_F_DROID + } else { + AppUpdateUiStatus.IDLE + }, + ) + if (channel != AppUpdateChannel.F_DROID) check(channel) + } + } + } + + fun selectChannel(channel: AppUpdateChannel) { + viewModelScope.launch { settings.setChannel(channel) } + } + + fun check() { + val channel = mutableState.value.channel + if (channel != AppUpdateChannel.F_DROID) check(channel) + } + + fun download() { + val release = mutableState.value.release ?: return + job?.cancel() + job = viewModelScope.launch { + mutableState.value = mutableState.value.copy( + status = AppUpdateUiStatus.DOWNLOADING, + downloadedFile = null, + progress = 0f, + downloadedBytes = 0, + totalBytes = release.apkSizeBytes, + failureReason = null, + ) + try { + val file = repository.download(release) { downloaded, total -> + mutableState.value = mutableState.value.copy( + downloadedBytes = downloaded, + totalBytes = total, + progress = if (total > 0) (downloaded.toFloat() / total).coerceIn(0f, 1f) else 0f, + ) + } + installer.validate(file, release) + mutableState.value = mutableState.value.copy( + status = AppUpdateUiStatus.READY_TO_INSTALL, + downloadedFile = file, + progress = 1f, + downloadedBytes = file.length(), + failureReason = null, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + fail(failure) + } + } + } + + fun install() { + val file = mutableState.value.downloadedFile ?: return + try { + mutableState.value = mutableState.value.copy( + status = when (installer.launch(file)) { + AppUpdateInstallLaunch.InstallerOpened -> AppUpdateUiStatus.INSTALLER_OPENED + AppUpdateInstallLaunch.PermissionRequested -> AppUpdateUiStatus.PERMISSION_REQUESTED + }, + ) + } catch (failure: Exception) { + fail(failure) + } + } + + private fun check(channel: AppUpdateChannel) { + job?.cancel() + job = viewModelScope.launch { + mutableState.value = AppUpdateUiState(channel = channel, status = AppUpdateUiStatus.CHECKING) + try { + val result = repository.check( + channel = channel, + installedVersionName = versionName, + installedVersionCode = versionCode, + ) + mutableState.value = result.toUiState(channel) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + fail(failure, channel) + } + } + } + + private fun fail(failure: Exception, channel: AppUpdateChannel = mutableState.value.channel) { + val reason = when (failure) { + is AppUpdateFailure -> failure.reason + is IOException -> AppUpdateFailureReason.NETWORK + else -> AppUpdateFailureReason.INSTALLER_UNAVAILABLE + } + mutableState.value = mutableState.value.copy( + channel = channel, + status = AppUpdateUiStatus.FAILED, + failureReason = reason, + ) + } +} + +private fun AppUpdateCheck.toUiState(channel: AppUpdateChannel): AppUpdateUiState = when (this) { + AppUpdateCheck.ManagedByF_Droid -> AppUpdateUiState(channel, AppUpdateUiStatus.MANAGED_BY_F_DROID) + AppUpdateCheck.UpToDate -> AppUpdateUiState(channel, AppUpdateUiStatus.UP_TO_DATE) + is AppUpdateCheck.Available -> AppUpdateUiState(channel, AppUpdateUiStatus.AVAILABLE, release) + is AppUpdateCheck.NewerBuildInstalled -> AppUpdateUiState(channel, AppUpdateUiStatus.NEWER_BUILD_INSTALLED, release) +} From d6afe79da744b2a227fb8e679e0948fbaf1b493f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 27 Oct 2026 13:57:16 +0100 Subject: [PATCH 4/7] feat: add the app update status card --- .../resources/src/main/res/values/strings.xml | 64 +++--- .../settings/updates/AppUpdateStatusCard.kt | 212 ++++++++++++++++++ .../settings/updates/AppUpdateStatusText.kt | 35 +++ 3 files changed, 284 insertions(+), 27 deletions(-) create mode 100644 feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateStatusCard.kt create mode 100644 feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateStatusText.kt diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index 43d3b659..6d3316e2 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -722,33 +722,43 @@ Importing YouTube data %1$s · %2$s Application - App updates - F-Droid manages updates - GitHub Stable - GitHub Beta - Beta builds may be less stable. You can switch back when a newer Stable build is released. - Check - Checking GitHub for updates… - F-Droid will check for updates. - You are up to date on this channel. - An update is available. - Your installed build is newer than the latest release on this channel. - Version %1$s - Download - Downloading and verifying APK… - %1$d%% downloaded - The APK is verified and ready to install. - Install update - Android’s installer was opened. Confirm the update there. - Allow TypeType to install apps, then return here and tap Install update. - Could not prepare this update. - Could not reach GitHub. Check your connection and try again. - GitHub returned invalid release information. - The APK checksum did not match. The file was discarded. - The downloaded file is not a compatible TypeType update. - Android will not install an older build over this one. - The APK signing certificate does not match this installation. - Could not open Android’s package installer. + Updates + Update channel and new versions + TypeType %1$s + Checking for updates… + F-Droid checks for updates + You’re up to date + Version %1$s is available + This build is newer than the latest release on this channel + Downloading version %1$s… + Version %1$s is verified and ready to install + Confirm the update in Android’s installer + Allow TypeType to install apps, then tap Install + Could not prepare this update + Could not reach GitHub. Check your connection and try again. + GitHub returned invalid release information. + The APK checksum did not match. The file was discarded. + The downloaded file is not a compatible TypeType update. + Android will not install an older build over this one. + The APK signing certificate does not match this installation. + Could not open Android’s package installer. + %1$s to download + %1$s of %2$s + Check for updates + Download + Install + Try again + Update channel + F-Droid + Updates come from the F-Droid app + GitHub Stable + Tested releases, downloaded from GitHub + GitHub Beta + Early builds that may be less stable + What’s new in %1$s + Show more + Show less + Every download is checked against its checksum and signing certificate before Android installs it. License GPL v3 Server diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateStatusCard.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateStatusCard.kt new file mode 100644 index 00000000..25a022df --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateStatusCard.kt @@ -0,0 +1,212 @@ +package dev.typetype.android.feature.settings.updates + +import android.text.format.Formatter +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.typetype.android.R + +@Composable +internal fun AppUpdateStatusCard( + versionName: String, + state: AppUpdateUiState, + onCheck: () -> Unit, + onDownload: () -> Unit, + onInstall: () -> Unit, +) { + val highlighted = state.status.isActionable() + val tint by animateFloatAsState(if (highlighted) 0.12f else 0f, label = "update-card-tint") + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = lerp(MaterialTheme.colorScheme.surfaceContainerLow, MaterialTheme.colorScheme.primary, tint), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = if (highlighted) 0.7f else 0.18f)), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.animateContentSize().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + AppMark() + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(R.string.updates_installed_version, versionName), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + ) + StatusLine(state) + } + } + if (state.status == AppUpdateUiStatus.DOWNLOADING) DownloadProgress(state) + state.release?.takeIf { state.status == AppUpdateUiStatus.AVAILABLE && it.apkSizeBytes > 0 }?.let { + Text( + stringResource(R.string.updates_download_size, Formatter.formatShortFileSize(LocalContext.current, it.apkSizeBytes)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + PrimaryAction(state, onCheck, onDownload, onInstall) + } + } +} + +@Composable +private fun AppMark() { + Box( + modifier = Modifier + .size(52.dp) + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.errorContainer), + contentAlignment = Alignment.Center, + ) { + Image(painterResource(R.drawable.ic_launcher_monochrome), contentDescription = null, modifier = Modifier.size(36.dp)) + } +} + +@Composable +private fun StatusLine(state: AppUpdateUiState) { + AnimatedContent( + targetState = state.statusText(), + transitionSpec = { fadeIn(tween(220, delayMillis = 90)) togetherWith fadeOut(tween(90)) }, + label = "update-status", + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) { text -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(state.status.dotColor()), + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = if (state.status == AppUpdateUiStatus.FAILED) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } +} + +@Composable +private fun DownloadProgress(state: AppUpdateUiState) { + val progress by animateFloatAsState(state.progress, label = "update-progress") + val context = LocalContext.current + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + LinearProgressIndicator( + progress = { progress }, + strokeCap = StrokeCap.Round, + gapSize = 0.dp, + drawStopIndicator = {}, + modifier = Modifier.fillMaxWidth().height(6.dp), + ) + if (state.totalBytes > 0) { + Text( + stringResource( + R.string.updates_progress, + Formatter.formatShortFileSize(context, state.downloadedBytes), + Formatter.formatShortFileSize(context, state.totalBytes), + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun PrimaryAction( + state: AppUpdateUiState, + onCheck: () -> Unit, + onDownload: () -> Unit, + onInstall: () -> Unit, +) { + val wide = Modifier.fillMaxWidth().height(48.dp) + when (state.status) { + AppUpdateUiStatus.AVAILABLE -> Button(onClick = onDownload, modifier = wide) { + Text(stringResource(R.string.updates_download)) + } + AppUpdateUiStatus.READY_TO_INSTALL, + AppUpdateUiStatus.PERMISSION_REQUESTED, + AppUpdateUiStatus.INSTALLER_OPENED, + -> Button(onClick = onInstall, modifier = wide) { Text(stringResource(R.string.updates_install)) } + AppUpdateUiStatus.FAILED -> FilledTonalButton( + onClick = if (state.release != null) onDownload else onCheck, + modifier = wide, + ) { Text(stringResource(R.string.updates_retry)) } + AppUpdateUiStatus.IDLE, + AppUpdateUiStatus.CHECKING, + AppUpdateUiStatus.UP_TO_DATE, + AppUpdateUiStatus.NEWER_BUILD_INSTALLED, + -> FilledTonalButton( + onClick = onCheck, + enabled = state.status != AppUpdateUiStatus.CHECKING, + modifier = wide, + ) { Text(stringResource(R.string.updates_check)) } + AppUpdateUiStatus.DOWNLOADING, + AppUpdateUiStatus.MANAGED_BY_F_DROID, + -> Unit + } +} + +private fun AppUpdateUiStatus.isActionable(): Boolean = when (this) { + AppUpdateUiStatus.AVAILABLE, + AppUpdateUiStatus.DOWNLOADING, + AppUpdateUiStatus.READY_TO_INSTALL, + AppUpdateUiStatus.PERMISSION_REQUESTED, + AppUpdateUiStatus.INSTALLER_OPENED, + -> true + else -> false +} + +@Composable +private fun AppUpdateUiStatus.dotColor() = when (this) { + AppUpdateUiStatus.FAILED -> MaterialTheme.colorScheme.error + AppUpdateUiStatus.UP_TO_DATE, + AppUpdateUiStatus.NEWER_BUILD_INSTALLED, + AppUpdateUiStatus.MANAGED_BY_F_DROID, + -> MaterialTheme.colorScheme.tertiary + AppUpdateUiStatus.IDLE, + AppUpdateUiStatus.CHECKING, + -> MaterialTheme.colorScheme.outline + else -> MaterialTheme.colorScheme.primary +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateStatusText.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateStatusText.kt new file mode 100644 index 00000000..e550f0d2 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateStatusText.kt @@ -0,0 +1,35 @@ +package dev.typetype.android.feature.settings.updates + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import dev.typetype.android.R +import dev.typetype.android.domain.appupdate.AppUpdateFailureReason + +@Composable +internal fun AppUpdateUiState.statusText(): String { + val version = release?.versionName.orEmpty() + return when (status) { + AppUpdateUiStatus.IDLE, + AppUpdateUiStatus.CHECKING, + -> stringResource(R.string.updates_status_checking) + AppUpdateUiStatus.MANAGED_BY_F_DROID -> stringResource(R.string.updates_status_fdroid) + AppUpdateUiStatus.UP_TO_DATE -> stringResource(R.string.updates_status_up_to_date) + AppUpdateUiStatus.AVAILABLE -> stringResource(R.string.updates_status_available, version) + AppUpdateUiStatus.NEWER_BUILD_INSTALLED -> stringResource(R.string.updates_status_newer_build) + AppUpdateUiStatus.DOWNLOADING -> stringResource(R.string.updates_status_downloading, version) + AppUpdateUiStatus.READY_TO_INSTALL -> stringResource(R.string.updates_status_ready, version) + AppUpdateUiStatus.INSTALLER_OPENED -> stringResource(R.string.updates_status_installer_opened) + AppUpdateUiStatus.PERMISSION_REQUESTED -> stringResource(R.string.updates_status_permission) + AppUpdateUiStatus.FAILED -> stringResource(failureReason?.messageRes() ?: R.string.updates_status_failed) + } +} + +private fun AppUpdateFailureReason.messageRes(): Int = when (this) { + AppUpdateFailureReason.NETWORK -> R.string.updates_failed_network + AppUpdateFailureReason.INVALID_RELEASE -> R.string.updates_failed_release + AppUpdateFailureReason.INVALID_CHECKSUM -> R.string.updates_failed_checksum + AppUpdateFailureReason.INVALID_PACKAGE -> R.string.updates_failed_package + AppUpdateFailureReason.DOWNGRADE_BLOCKED -> R.string.updates_failed_downgrade + AppUpdateFailureReason.SIGNATURE_MISMATCH -> R.string.updates_failed_signature + AppUpdateFailureReason.INSTALLER_UNAVAILABLE -> R.string.updates_failed_installer +} From f87f59a3a35c9183486e9fcdb8140aa6d4aa7efa Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 27 Oct 2026 13:57:16 +0100 Subject: [PATCH 5/7] feat: add the update channel picker and release notes card --- .../settings/updates/AppUpdateChannelGroup.kt | 140 ++++++++++++++++++ .../settings/updates/ReleaseNotesCard.kt | 92 ++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateChannelGroup.kt create mode 100644 feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/ReleaseNotesCard.kt diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateChannelGroup.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateChannelGroup.kt new file mode 100644 index 00000000..d8ad07d3 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdateChannelGroup.kt @@ -0,0 +1,140 @@ +package dev.typetype.android.feature.settings.updates + +import androidx.annotation.StringRes +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.Science +import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material.icons.outlined.Verified +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.appupdate.AppUpdateChannel + +@Composable +internal fun AppUpdateChannelGroup( + selected: AppUpdateChannel, + enabled: Boolean, + onSelect: (AppUpdateChannel) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.updates_channel_label), + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(horizontal = 4.dp), + ) + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.selectableGroup()) { + ChannelOption.entries.forEachIndexed { index, option -> + ChannelRow( + option = option, + selected = option.channel == selected, + enabled = enabled, + onClick = { onSelect(option.channel) }, + ) + if (index < ChannelOption.entries.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(start = 68.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + } + } + } + } +} + +@Composable +private fun ChannelRow(option: ChannelOption, selected: Boolean, enabled: Boolean, onClick: () -> Unit) { + val contentAlpha by animateFloatAsState(if (enabled) 1f else 0.5f, label = "channel-alpha") + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 72.dp) + .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + .alpha(contentAlpha), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(MaterialTheme.shapes.medium) + .background( + if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + option.icon, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(option.titleRes), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ), + ) + Text( + stringResource(option.descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) { + androidx.compose.animation.AnimatedVisibility(visible = selected, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut()) { + Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + } + } +} + +private enum class ChannelOption( + val channel: AppUpdateChannel, + @StringRes val titleRes: Int, + @StringRes val descriptionRes: Int, + val icon: ImageVector, +) { + FDroid(AppUpdateChannel.F_DROID, R.string.updates_channel_fdroid, R.string.updates_channel_fdroid_description, Icons.Outlined.Storefront), + Stable(AppUpdateChannel.GITHUB_STABLE, R.string.updates_channel_stable, R.string.updates_channel_stable_description, Icons.Outlined.Verified), + Beta(AppUpdateChannel.GITHUB_BETA, R.string.updates_channel_beta, R.string.updates_channel_beta_description, Icons.Outlined.Science), +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/ReleaseNotesCard.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/ReleaseNotesCard.kt new file mode 100644 index 00000000..0349f7cc --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/ReleaseNotesCard.kt @@ -0,0 +1,92 @@ +package dev.typetype.android.feature.settings.updates + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.appupdate.AppUpdateRelease + +@Composable +internal fun ReleaseNotesCard(release: AppUpdateRelease) { + val blocks = remember(release.releaseNotes, release.versionName) { + parseReleaseNotes(release.releaseNotes).dropRepeatedTitle(release.versionName) + } + var expanded by rememberSaveable(release.tagName) { mutableStateOf(false) } + val collapsible = blocks.size > COLLAPSED_BLOCKS + val visible = if (expanded || !collapsible) blocks else blocks.take(COLLAPSED_BLOCKS) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.updates_whats_new, release.versionName), + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(horizontal = 4.dp), + ) + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.animateContentSize().padding(start = 18.dp, end = 18.dp, top = 16.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + visible.forEachIndexed { index, block -> NotesBlock(block, first = index == 0) } + if (collapsible) { + TextButton(onClick = { expanded = !expanded }) { + Text(stringResource(if (expanded) R.string.updates_show_less else R.string.updates_show_more)) + } + } + } + } + } +} + +@Composable +private fun NotesBlock(block: ReleaseNotesBlock, first: Boolean) { + when (block) { + is ReleaseNotesBlock.Heading -> Text( + block.text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = if (first) 0.dp else 8.dp), + ) + is ReleaseNotesBlock.Bullet -> Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + Modifier + .padding(top = 8.dp) + .size(5.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant), + ) + Text(block.text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + } + is ReleaseNotesBlock.Paragraph -> Text( + block.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private const val COLLAPSED_BLOCKS = 8 From edb7b8f58a3d163edcb7fbdd3eb579f2030ff57b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 27 Oct 2026 13:57:16 +0100 Subject: [PATCH 6/7] feat: open app updates from their own settings entry --- .../typetype/android/AdaptiveSettingsHost.kt | 3 + .../java/dev/typetype/android/AppNavHost.kt | 5 ++ .../typetype/android/SettingsNavigation.kt | 2 + .../android/core/ui/navigation/Routes.kt | 3 + .../SettingsScreenAccessibilityTest.kt | 2 + .../android/feature/settings/SettingsRoute.kt | 2 + .../feature/settings/SettingsScreen.kt | 3 + .../settings/updates/AppUpdatesScreen.kt | 69 +++++++++++++++++++ 8 files changed, 89 insertions(+) create mode 100644 feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdatesScreen.kt diff --git a/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt b/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt index a3f27cf9..c1ee85a0 100644 --- a/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt +++ b/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt @@ -17,6 +17,7 @@ import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import dev.typetype.android.core.ui.navigation.AboutRoute +import dev.typetype.android.core.ui.navigation.AppUpdatesRoute import dev.typetype.android.core.ui.navigation.AccountsRoute import dev.typetype.android.core.ui.navigation.AppearanceRoute import dev.typetype.android.core.ui.navigation.BlockedSettingsRoute @@ -75,6 +76,7 @@ internal fun AdaptiveSettingsHost( onOpenPrivacy = { navController.selectSettings(PrivacySettingsRoute) }, onOpenDiagnostics = { navController.selectSettings(DiagnosticsRoute) }, onOpenBlocked = { navController.selectSettings(BlockedSettingsRoute) }, + onOpenUpdates = { navController.selectSettings(AppUpdatesRoute) }, onOpenAbout = { navController.selectSettings(AboutRoute) }, onSignOut = onSignOut, ) @@ -113,6 +115,7 @@ private fun NavDestination.settingsTitle(): Int? = when { hasRoute() -> R.string.settings_privacy_title hasRoute() -> R.string.diagnostics_title hasRoute() -> R.string.settings_blocked_title + hasRoute() -> R.string.updates_title hasRoute() || hasRoute() -> R.string.settings_about_title else -> null } diff --git a/app/src/main/java/dev/typetype/android/AppNavHost.kt b/app/src/main/java/dev/typetype/android/AppNavHost.kt index d905eadb..f535c1cf 100644 --- a/app/src/main/java/dev/typetype/android/AppNavHost.kt +++ b/app/src/main/java/dev/typetype/android/AppNavHost.kt @@ -15,6 +15,7 @@ import androidx.navigation.compose.rememberNavController import dev.typetype.android.core.ui.branding.DeArrowBrandingViewModel import dev.typetype.android.core.ui.branding.LocalDeArrowBranding import dev.typetype.android.core.ui.navigation.AboutRoute +import dev.typetype.android.core.ui.navigation.AppUpdatesRoute import dev.typetype.android.core.ui.navigation.AccountsRoute import dev.typetype.android.core.ui.navigation.AddServerRoute import dev.typetype.android.core.ui.navigation.AppearanceRoute @@ -47,6 +48,7 @@ import dev.typetype.android.feature.player.PlayerViewModel import dev.typetype.android.feature.player.components.LocalPlaybackRecovery import dev.typetype.android.feature.search.SearchRoute as SearchRouteScreen import dev.typetype.android.feature.settings.about.AboutScreen +import dev.typetype.android.feature.settings.updates.AppUpdatesScreen import dev.typetype.android.feature.settings.accounts.AccountSettingsRoute as AccountSettingsRouteScreen import dev.typetype.android.feature.settings.appearance.AppearanceRoute as AppearanceRouteScreen import dev.typetype.android.feature.settings.blocked.BlockedSettingsRoute as BlockedSettingsRouteScreen @@ -269,6 +271,9 @@ fun AppNavHost( onNavigateBack = { navController.popBackStack() }, ) } + composable { + AppUpdatesScreen(onNavigateBack = { navController.popBackStack() }) + } composable { AboutScreen( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt index 045df587..cdf1dd40 100644 --- a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt +++ b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt @@ -4,6 +4,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable import dev.typetype.android.core.ui.navigation.AboutRoute +import dev.typetype.android.core.ui.navigation.AppUpdatesRoute import dev.typetype.android.core.ui.navigation.AccountsRoute import dev.typetype.android.core.ui.navigation.AppearanceRoute import dev.typetype.android.core.ui.navigation.BlockedSettingsRoute @@ -49,6 +50,7 @@ internal fun NavGraphBuilder.settingsDestinations( onOpenPrivacy = { navController.navigate(PrivacySettingsRoute) }, onOpenDiagnostics = { navController.navigate(DiagnosticsRoute) }, onOpenBlocked = { navController.navigate(BlockedSettingsRoute) }, + onOpenUpdates = { navController.navigate(AppUpdatesRoute) }, onOpenAbout = { navController.navigate(AboutRoute) }, onSignOut = onSignOut, ) diff --git a/core/ui/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt b/core/ui/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt index a767aa79..0de3626f 100644 --- a/core/ui/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt +++ b/core/ui/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt @@ -80,6 +80,9 @@ data object DiagnosticsRoute @Serializable data object BlockedSettingsRoute +@Serializable +data object AppUpdatesRoute + @Serializable data object AboutRoute diff --git a/feature/settings/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt b/feature/settings/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt index 9a6e5155..6d529043 100644 --- a/feature/settings/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt +++ b/feature/settings/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt @@ -127,6 +127,7 @@ class SettingsScreenAccessibilityTest { onOpenPrivacy = {}, onOpenDiagnostics = {}, onOpenBlocked = {}, + onOpenUpdates = {}, onOpenAbout = {}, onSignOut = {}, ) @@ -147,6 +148,7 @@ class SettingsScreenAccessibilityTest { R.string.settings_privacy_title, R.string.diagnostics_title, R.string.settings_blocked_title, + R.string.updates_title, R.string.settings_about_title, R.string.settings_sign_out, ).map(::resource) diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt index e04a321a..d3dc38a1 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt @@ -21,6 +21,7 @@ fun SettingsRoute( onOpenPrivacy: () -> Unit, onOpenDiagnostics: () -> Unit, onOpenBlocked: () -> Unit, + onOpenUpdates: () -> Unit, onOpenAbout: () -> Unit, onSignOut: () -> Unit, selectedTitleRes: Int? = null, @@ -46,6 +47,7 @@ fun SettingsRoute( onOpenPrivacy = onOpenPrivacy, onOpenDiagnostics = onOpenDiagnostics, onOpenBlocked = onOpenBlocked, + onOpenUpdates = onOpenUpdates, onOpenAbout = onOpenAbout, onSignOut = onSignOut, ) diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt index 01fcf986..c5c22910 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PlayCircle import androidx.compose.material.icons.filled.RssFeed import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -69,6 +70,7 @@ fun SettingsScreen( onOpenPrivacy: () -> Unit = {}, onOpenDiagnostics: () -> Unit = {}, onOpenBlocked: () -> Unit = {}, + onOpenUpdates: () -> Unit = {}, onOpenAbout: () -> Unit = {}, onSignOut: () -> Unit, selectedTitleRes: Int? = null, @@ -100,6 +102,7 @@ fun SettingsScreen( add(SettingsEntry(R.string.settings_privacy_title, R.string.settings_privacy_subtitle, Icons.Filled.Lock, onOpenPrivacy)) add(SettingsEntry(R.string.diagnostics_title, R.string.diagnostics_subtitle, Icons.Filled.BugReport, onOpenDiagnostics)) add(SettingsEntry(R.string.settings_blocked_title, R.string.settings_blocked_subtitle, Icons.Filled.Block, onOpenBlocked)) + add(SettingsEntry(R.string.updates_title, R.string.updates_entry_subtitle, Icons.Filled.SystemUpdate, onOpenUpdates)) add(SettingsEntry(R.string.settings_about_title, R.string.settings_about_subtitle, Icons.Filled.Info, onOpenAbout)) } Surface( diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdatesScreen.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdatesScreen.kt new file mode 100644 index 00000000..22aaeb9e --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/updates/AppUpdatesScreen.kt @@ -0,0 +1,69 @@ +package dev.typetype.android.feature.settings.updates + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.typetype.android.R +import dev.typetype.android.feature.settings.SettingsDetailTopBar + +@Composable +fun AppUpdatesScreen( + onNavigateBack: () -> Unit, + viewModel: AppUpdatesViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.fillMaxSize()) { + SettingsDetailTopBar(title = stringResource(R.string.updates_title), onNavigateBack = onNavigateBack) + LazyColumn( + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + item(key = "status") { + AppUpdateStatusCard( + versionName = viewModel.versionName, + state = state, + onCheck = viewModel::check, + onDownload = viewModel::download, + onInstall = viewModel::install, + ) + } + item(key = "channel") { + AppUpdateChannelGroup( + selected = state.channel, + enabled = state.status != AppUpdateUiStatus.DOWNLOADING, + onSelect = viewModel::selectChannel, + ) + } + val release = state.release + if (release != null && state.status.showsReleaseNotes() && release.releaseNotes.isNotBlank()) { + item(key = "notes") { ReleaseNotesCard(release) } + } + item(key = "note") { + Text( + stringResource(R.string.updates_verified_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + } + } + } +} + +private fun AppUpdateUiStatus.showsReleaseNotes(): Boolean = + this != AppUpdateUiStatus.NEWER_BUILD_INSTALLED && this != AppUpdateUiStatus.UP_TO_DATE From 597d50ab1f2fa311b49b9efe517e02708858294e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 27 Oct 2026 15:29:55 +0100 Subject: [PATCH 7/7] chore: prepare 1.11.0 beta 10 --- app/build.gradle.kts | 4 ++-- release-notes/v1.11.0-beta.10.md | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.11.0-beta.10.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a520e192..30cf90ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { defaultConfig { applicationId = "dev.typetype.android" - versionCode = 11109 - versionName = "1.11.0-beta.9" + versionCode = 11110 + versionName = "1.11.0-beta.10" } signingConfigs { diff --git a/release-notes/v1.11.0-beta.10.md b/release-notes/v1.11.0-beta.10.md new file mode 100644 index 00000000..51028262 --- /dev/null +++ b/release-notes/v1.11.0-beta.10.md @@ -0,0 +1,4 @@ +TypeType Android 1.11.0-beta.10 + +- App updates now have their own entry in Settings: see your version and whether an update is available, choose the update channel, and read what's new in a clean layout. +- Resume playback after a long pause, when the server has already closed the idle session.