diff --git a/.github/workflows/android-beta.yml b/.github/workflows/android-beta.yml
index a544a1a5..f92ce048 100644
--- a/.github/workflows/android-beta.yml
+++ b/.github/workflows/android-beta.yml
@@ -172,6 +172,20 @@ jobs:
sha256sum -c "dev.typetype.android_${RELEASE_CODE}.apk.sha256"
)
+ - name: Prepare beta update manifest
+ run: |
+ apk_name="dev.typetype.android_"$RELEASE_CODE".apk"
+ apk_sha256="$(cut -d ' ' -f1 "artifacts/$apk_name.sha256")"
+ jq -n \
+ --arg channel beta \
+ --arg packageName dev.typetype.android \
+ --arg versionName "$RELEASE_VERSION" \
+ --argjson versionCode "$RELEASE_CODE" \
+ --arg apkAsset "$apk_name" \
+ --arg sha256 "$apk_sha256" \
+ '{schemaVersion:1,channel:$channel,packageName:$packageName,versionName:$versionName,versionCode:$versionCode,apkAsset:$apkAsset,sha256:$sha256}' \
+ > artifacts/typetype-update.json
+
- name: Upload beta APK
uses: actions/upload-artifact@v7.0.1
with:
@@ -179,6 +193,7 @@ jobs:
path: |
artifacts/*.apk
artifacts/*.sha256
+ artifacts/typetype-update.json
if-no-files-found: error
compression-level: 0
retention-days: 30
@@ -208,4 +223,5 @@ jobs:
files: |
artifacts/*.apk
artifacts/*.sha256
+ artifacts/typetype-update.json
fail_on_unmatched_files: true
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 4036e3c0..99854df6 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -143,11 +143,14 @@ jobs:
INPUT_TAG_NAME: ${{ inputs.tag_name }}
run: |
version="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' app/build.gradle.kts)"
+ version_code="$(sed -nE 's/^[[:space:]]*versionCode = ([0-9]+)/\1/p' app/build.gradle.kts)"
test -n "$version"
+ [[ "$version_code" =~ ^[1-9][0-9]*$ ]]
expected_tag="v$version"
release_tag="${INPUT_TAG_NAME:-$expected_tag}"
test "$release_tag" = "$expected_tag"
echo "RELEASE_VERSION=$version" >> "$GITHUB_ENV"
+ echo "RELEASE_CODE=$version_code" >> "$GITHUB_ENV"
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
- name: Prepare signing
@@ -215,13 +218,27 @@ jobs:
test -n "$build_tools"
"$build_tools/zipalign" -c -P 16 -v 4 "$apk"
"$build_tools/apksigner" verify --verbose --print-certs "$apk"
+ apk_application_id="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest application-id "$apk")"
apk_version="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest version-name "$apk")"
+ apk_code="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest version-code "$apk")"
+ test "$apk_application_id" = "dev.typetype.android"
test "$apk_version" = "$RELEASE_VERSION"
+ test "$apk_code" = "$RELEASE_CODE"
cp "$apk" "artifacts/TypeType-$RELEASE_TAG.apk"
(
cd artifacts
sha256sum "TypeType-$RELEASE_TAG.apk" \
> "TypeType-$RELEASE_TAG.apk.sha256"
+ apk_sha256="$(cut -d ' ' -f1 "TypeType-$RELEASE_TAG.apk.sha256")"
+ jq -n \
+ --arg channel stable \
+ --arg packageName dev.typetype.android \
+ --arg versionName "$RELEASE_VERSION" \
+ --argjson versionCode "$RELEASE_CODE" \
+ --arg apkAsset "TypeType-$RELEASE_TAG.apk" \
+ --arg sha256 "$apk_sha256" \
+ '{schemaVersion:1,channel:$channel,packageName:$packageName,versionName:$versionName,versionCode:$versionCode,apkAsset:$apkAsset,sha256:$sha256}' \
+ > typetype-update.json
)
- name: Upload release APK
@@ -231,6 +248,7 @@ jobs:
path: |
artifacts/*.apk
artifacts/*.sha256
+ artifacts/typetype-update.json
if-no-files-found: error
compression-level: 0
retention-days: 30
@@ -244,4 +262,5 @@ jobs:
files: |
artifacts/*.apk
artifacts/*.sha256
+ artifacts/typetype-update.json
fail_on_unmatched_files: true
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1a8f6d6f..7af31729 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -26,8 +26,8 @@ android {
applicationId = "dev.typetype.android"
minSdk = 23
targetSdk = 37
- versionCode = 11004
- versionName = "1.10.0-beta.4"
+ versionCode = 11005
+ versionName = "1.10.1-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resValue("string", "app_name", "TypeType")
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index afb26eea..0d0d262d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,6 +10,7 @@
+
@@ -43,6 +44,16 @@
tools:node="remove" />
+
+
+
+
= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
+ context.startActivity(
+ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + context.packageName))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ return AppUpdateInstallLaunch.PermissionRequested
+ }
+ val apkUri = FileProvider.getUriForFile(
+ context,
+ context.packageName + ".updates",
+ file,
+ )
+ val intent = Intent(Intent.ACTION_VIEW)
+ .setDataAndType(apkUri, APK_MIME_TYPE)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ return AppUpdateInstallLaunch.InstallerOpened
+ }
+
+ private fun packageInfoFlags(): Int =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ PackageManager.GET_SIGNING_CERTIFICATES
+ } else {
+ @Suppress("DEPRECATION")
+ PackageManager.GET_SIGNATURES
+ }
+
+ private fun signingMatches(
+ packageManager: PackageManager,
+ installed: PackageInfo,
+ archive: PackageInfo,
+ ): Boolean {
+ val archiveSigners = signerDigests(archive)
+ if (archiveSigners.isEmpty()) return false
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ return archiveSigners.all { digest ->
+ val signerBytes = signerBytes(archive).firstOrNull { sha256(it) == digest }
+ signerBytes != null && packageManager.hasSigningCertificate(
+ context.packageName,
+ signerBytes,
+ PackageManager.CERT_INPUT_RAW_X509,
+ )
+ }
+ }
+ return archiveSigners == signerDigests(installed)
+ }
+
+ private fun signerDigests(info: PackageInfo): Set =
+ signerBytes(info).mapTo(mutableSetOf(), ::sha256)
+
+ private fun signerBytes(info: PackageInfo): List =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ info.signingInfo?.apkContentsSigners?.map { it.toByteArray() }.orEmpty()
+ } else {
+ @Suppress("DEPRECATION")
+ info.signatures?.map { it.toByteArray() }.orEmpty()
+ }
+
+ private fun sha256(value: ByteArray): String =
+ MessageDigest.getInstance("SHA-256").digest(value).joinToString("") { "%02x".format(it) }
+
+ @Suppress("DEPRECATION")
+ private fun PackageInfo.versionCodeLong(): Long =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) longVersionCode else versionCode.toLong()
+
+ private companion object {
+ const val EXPECTED_PACKAGE_NAME = "dev.typetype.android"
+ const val APK_MIME_TYPE = "application/vnd.android.package-archive"
+ const val APK_HEADER_SIZE = 4L
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/data/appupdate/AppUpdateDownloader.kt b/app/src/main/java/dev/typetype/android/data/appupdate/AppUpdateDownloader.kt
new file mode 100644
index 00000000..0c7e5a88
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/data/appupdate/AppUpdateDownloader.kt
@@ -0,0 +1,158 @@
+package dev.typetype.android.data.appupdate
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dev.typetype.android.domain.appupdate.AppUpdateFailure
+import dev.typetype.android.domain.appupdate.AppUpdateFailureReason
+import dev.typetype.android.domain.appupdate.AppUpdateFileDownloader
+import dev.typetype.android.domain.appupdate.AppUpdateRelease
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.security.MessageDigest
+import javax.inject.Inject
+import javax.inject.Named
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+
+class AppUpdateDownloader @Inject constructor(
+ @Named("appUpdates") private val client: OkHttpClient,
+ @ApplicationContext private val context: Context,
+) : AppUpdateFileDownloader {
+ override suspend fun download(
+ release: AppUpdateRelease,
+ onProgress: (downloadedBytes: Long, totalBytes: Long) -> Unit,
+ ): File = withContext(Dispatchers.IO) {
+ val directory = File(context.cacheDir, UPDATE_DIRECTORY).apply { mkdirs() }
+ val safeTag = release.tagName.replace(UNSAFE_FILE_CHARACTERS, "_")
+ val part = File(directory, "$safeTag.apk.part")
+ val target = File(directory, "$safeTag.apk")
+ if (target.exists() && target.length() == release.apkSizeBytes && hasExpectedDigest(target, release.sha256)) {
+ return@withContext target
+ }
+ target.delete()
+ if (part.length() > release.apkSizeBytes) part.delete()
+ var lastFailure: IOException? = null
+
+ repeat(MAX_ATTEMPTS) { attempt ->
+ val currentSize = part.length()
+ val request = Request.Builder()
+ .url(release.apkUrl)
+ .header("Accept", "application/octet-stream")
+ .apply {
+ if (currentSize > 0) header("Range", "bytes=$currentSize-")
+ }
+ .build()
+ try {
+ client.newCall(request).execute().use { response ->
+ if (response.code == 416) {
+ if (currentSize == release.apkSizeBytes && hasExpectedDigest(part, release.sha256)) {
+ if (!part.renameTo(target)) throw IOException("Could not stage the APK")
+ return@withContext target
+ }
+ if (currentSize == release.apkSizeBytes) {
+ part.delete()
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_CHECKSUM)
+ }
+ part.delete()
+ lastFailure = IOException("Update server rejected the partial download")
+ return@use
+ }
+ val append = response.code == 206 && currentSize > 0
+ if (response.code != 200 && !append) {
+ throw IOException("Update download returned HTTP " + response.code)
+ }
+ if (append) {
+ val contentRange = contentRange(response.header("Content-Range"))
+ if (
+ contentRange?.start != currentSize ||
+ contentRange.total != release.apkSizeBytes ||
+ contentRange.endInclusive >= contentRange.total
+ ) {
+ throw IOException("Update download returned an invalid range")
+ }
+ }
+ if (!append && currentSize > 0) part.delete()
+ val body = response.body
+ val responseLength = body.contentLength()
+ val totalSize = release.apkSizeBytes
+ if (responseLength > totalSize || totalSize > MAX_APK_SIZE_BYTES) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ }
+ val initialSize = if (append) currentSize else 0L
+ body.byteStream().use { input ->
+ FileOutputStream(part, append).buffered().use { output ->
+ val buffer = ByteArray(BUFFER_SIZE)
+ var written = 0L
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ if (initialSize + written + count > totalSize) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ }
+ output.write(buffer, 0, count)
+ written += count
+ onProgress(initialSize + written, totalSize)
+ }
+ }
+ }
+ }
+ if (part.length() == release.apkSizeBytes) {
+ if (!hasExpectedDigest(part, release.sha256)) {
+ part.delete()
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_CHECKSUM)
+ }
+ if (!part.renameTo(target)) throw IOException("Could not stage the APK")
+ return@withContext target
+ }
+ lastFailure = IOException("Update download ended before the APK was complete")
+ } catch (failure: AppUpdateFailure) {
+ throw failure
+ } catch (failure: IOException) {
+ lastFailure = failure
+ }
+ if (attempt + 1 < MAX_ATTEMPTS) Thread.sleep(RETRY_DELAY_MILLIS * (attempt + 1))
+ }
+ throw AppUpdateFailure(AppUpdateFailureReason.NETWORK, lastFailure)
+ }
+
+ private fun hasExpectedDigest(file: File, expected: String): Boolean {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().buffered().use { input ->
+ val buffer = ByteArray(BUFFER_SIZE)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ digest.update(buffer, 0, count)
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }.equals(expected, ignoreCase = true)
+ }
+
+ private fun contentRange(value: String?): ContentRange? {
+ val values = CONTENT_RANGE.matchEntire(value.orEmpty())?.groupValues ?: return null
+ return ContentRange(
+ start = values[1].toLongOrNull() ?: return null,
+ endInclusive = values[2].toLongOrNull() ?: return null,
+ total = values[3].toLongOrNull() ?: return null,
+ )
+ }
+
+ private data class ContentRange(
+ val start: Long,
+ val endInclusive: Long,
+ val total: Long,
+ )
+
+ private companion object {
+ val UNSAFE_FILE_CHARACTERS = Regex("[^A-Za-z0-9._-]")
+ val CONTENT_RANGE = Regex("""bytes (\d+)-(\d+)/(\d+)""")
+ const val UPDATE_DIRECTORY = "app-updates"
+ const val MAX_ATTEMPTS = 4
+ const val RETRY_DELAY_MILLIS = 350L
+ const val BUFFER_SIZE = 32 * 1024
+ const val MAX_APK_SIZE_BYTES = 200L * 1024 * 1024
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/data/appupdate/DataStoreAppUpdateSettingsRepository.kt b/app/src/main/java/dev/typetype/android/data/appupdate/DataStoreAppUpdateSettingsRepository.kt
new file mode 100644
index 00000000..3e10f3d8
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/data/appupdate/DataStoreAppUpdateSettingsRepository.kt
@@ -0,0 +1,54 @@
+package dev.typetype.android.data.appupdate
+
+import android.content.Context
+import android.os.Build
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dev.typetype.android.domain.appupdate.AppUpdateChannel
+import dev.typetype.android.domain.appupdate.AppUpdateSettingsRepository
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+@Singleton
+class DataStoreAppUpdateSettingsRepository @Inject constructor(
+ private val dataStore: DataStore,
+ @ApplicationContext private val context: Context,
+) : AppUpdateSettingsRepository {
+ private val initialChannel = detectInitialChannel()
+
+ override fun observeChannel(): Flow = dataStore.data.map { preferences ->
+ preferences[KEY_CHANNEL]
+ ?.let { stored -> AppUpdateChannel.entries.firstOrNull { it.name == stored } }
+ ?: initialChannel
+ }
+
+ override suspend fun setChannel(channel: AppUpdateChannel) {
+ dataStore.edit { it[KEY_CHANNEL] = channel.name }
+ }
+
+ private fun detectInitialChannel(): AppUpdateChannel {
+ val installer = runCatching {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName
+ } else {
+ @Suppress("DEPRECATION")
+ context.packageManager.getInstallerPackageName(context.packageName)
+ }
+ }.getOrNull()
+ return if (installer in F_DROID_INSTALLERS) {
+ AppUpdateChannel.F_DROID
+ } else {
+ AppUpdateChannel.GITHUB_STABLE
+ }
+ }
+
+ private companion object {
+ val KEY_CHANNEL = stringPreferencesKey("app_update_channel")
+ val F_DROID_INSTALLERS = setOf("org.fdroid.fdroid", "org.fdroid.basic")
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/data/appupdate/GitHubAppUpdateRepository.kt b/app/src/main/java/dev/typetype/android/data/appupdate/GitHubAppUpdateRepository.kt
new file mode 100644
index 00000000..06a7f540
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/data/appupdate/GitHubAppUpdateRepository.kt
@@ -0,0 +1,154 @@
+package dev.typetype.android.data.appupdate
+
+import dev.typetype.android.BuildConfig
+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.AppUpdateFileDownloader
+import dev.typetype.android.domain.appupdate.AppUpdatePolicy
+import dev.typetype.android.domain.appupdate.AppUpdateRelease
+import dev.typetype.android.domain.appupdate.AppUpdateRepository
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Named
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.ResponseBody
+
+class GitHubAppUpdateRepository @Inject constructor(
+ @Named("appUpdates") private val client: OkHttpClient,
+ private val json: Json,
+ private val downloader: AppUpdateFileDownloader,
+) : AppUpdateRepository {
+ internal var apiBaseUrl = API_BASE
+ override suspend fun check(
+ channel: AppUpdateChannel,
+ installedVersionName: String,
+ installedVersionCode: Long,
+ ): AppUpdateCheck {
+ if (channel == AppUpdateChannel.F_DROID) return AppUpdateCheck.ManagedByF_Droid
+ val release = fetchRelease(channel)
+ val manifest = GitHubReleaseParser.manifestAsset(release)
+ ?.let { asset ->
+ val text = fetchText(asset.browserDownloadUrl, MAX_MANIFEST_BYTES)
+ decodeJson(text)
+ }
+ val apk = GitHubReleaseParser.findApkAsset(channel, release, manifest)
+ ?: throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ val checksum = if (apk.digest.isNullOrBlank()) {
+ GitHubReleaseParser.checksumAsset(release, apk.name)
+ ?.let { fetchChecksum(it.browserDownloadUrl, apk.name) }
+ } else {
+ null
+ }
+ val appRelease = GitHubReleaseParser.release(channel, release, manifest, checksum)
+ return AppUpdatePolicy.check(channel, installedVersionName, installedVersionCode, appRelease)
+ }
+
+ override suspend fun download(
+ release: AppUpdateRelease,
+ onProgress: (downloadedBytes: Long, totalBytes: Long) -> Unit,
+ ): File = downloader.download(release, onProgress)
+
+ private suspend fun fetchRelease(channel: AppUpdateChannel): GitHubReleaseDto {
+ val path = when (channel) {
+ AppUpdateChannel.F_DROID -> throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ AppUpdateChannel.GITHUB_STABLE -> "/releases/latest"
+ AppUpdateChannel.GITHUB_BETA -> "/releases?per_page=100"
+ }
+ val responseText = fetchText("$apiBaseUrl$path", MAX_RELEASE_RESPONSE_BYTES)
+ return try {
+ val releases = if (channel == AppUpdateChannel.GITHUB_STABLE) {
+ listOf(json.decodeFromString(responseText))
+ } else {
+ json.decodeFromString>(responseText)
+ }
+ GitHubReleaseParser.selectRelease(channel, releases)
+ } catch (failure: AppUpdateFailure) {
+ throw failure
+ } catch (failure: SerializationException) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE, failure)
+ }
+ }
+
+ private suspend fun fetchChecksum(url: String, apkName: String): String {
+ val checksumText = fetchText(url, MAX_CHECKSUM_BYTES).trim()
+ val fields = checksumText.split(WHITESPACE, limit = 2)
+ val digest = fields.firstOrNull()
+ ?.takeIf(SHA_256::matches)
+ ?: throw AppUpdateFailure(AppUpdateFailureReason.INVALID_CHECKSUM)
+ val namedFile = fields.getOrNull(1)?.trim()?.removePrefix("*")
+ if (namedFile != null && namedFile != apkName) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_CHECKSUM)
+ }
+ return digest
+ }
+
+ private suspend fun fetchText(url: String, maximumBytes: Long): String =
+ withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url(url)
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", GITHUB_API_VERSION)
+ .header("User-Agent", "TypeType-Android/" + BuildConfig.VERSION_NAME)
+ .build()
+ try {
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw AppUpdateFailure(AppUpdateFailureReason.NETWORK)
+ }
+ readBounded(response.body, maximumBytes)
+ }
+ } catch (failure: AppUpdateFailure) {
+ throw failure
+ } catch (failure: IOException) {
+ throw AppUpdateFailure(AppUpdateFailureReason.NETWORK, failure)
+ }
+ }
+
+ private fun readBounded(body: ResponseBody, maximumBytes: Long): String {
+ if (body.contentLength() > maximumBytes) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ }
+ val output = ByteArrayOutputStream()
+ body.byteStream().use { input ->
+ val buffer = ByteArray(TEXT_BUFFER_SIZE)
+ var total = 0L
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ total += count
+ if (total > maximumBytes) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ }
+ output.write(buffer, 0, count)
+ }
+ }
+ return output.toString(Charsets.UTF_8.name())
+ }
+
+ private inline fun decodeJson(value: String): T = try {
+ json.decodeFromString(value)
+ } catch (failure: SerializationException) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE, failure)
+ }
+
+ private companion object {
+ const val API_BASE = "https://api.github.com/repos/TypeType-Video/TypeType-Android"
+ const val GITHUB_API_VERSION = "2022-11-28"
+ const val MAX_RELEASE_RESPONSE_BYTES = 2L * 1024 * 1024
+ const val MAX_MANIFEST_BYTES = 32L * 1024
+ const val MAX_CHECKSUM_BYTES = 4L * 1024
+ const val TEXT_BUFFER_SIZE = 8 * 1024
+ val WHITESPACE = Regex("""\s+""")
+ val SHA_256 = Regex("""^[a-fA-F0-9]{64}$""")
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/data/appupdate/GitHubReleaseDtos.kt b/app/src/main/java/dev/typetype/android/data/appupdate/GitHubReleaseDtos.kt
new file mode 100644
index 00000000..e6b73375
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/data/appupdate/GitHubReleaseDtos.kt
@@ -0,0 +1,33 @@
+package dev.typetype.android.data.appupdate
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class GitHubReleaseDto(
+ @SerialName("tag_name") val tagName: String,
+ val draft: Boolean = false,
+ val prerelease: Boolean = false,
+ @SerialName("published_at") val publishedAt: String? = null,
+ val body: String? = null,
+ val assets: List = emptyList(),
+)
+
+@Serializable
+internal data class GitHubReleaseAssetDto(
+ val name: String,
+ val size: Long = 0,
+ val digest: String? = null,
+ @SerialName("browser_download_url") val browserDownloadUrl: String,
+)
+
+@Serializable
+internal data class GitHubUpdateManifestDto(
+ val schemaVersion: Int,
+ val channel: String,
+ val packageName: String,
+ val versionName: String,
+ val versionCode: Long,
+ val apkAsset: String,
+ val sha256: String,
+)
diff --git a/app/src/main/java/dev/typetype/android/data/appupdate/GitHubReleaseParser.kt b/app/src/main/java/dev/typetype/android/data/appupdate/GitHubReleaseParser.kt
new file mode 100644
index 00000000..6fa5461b
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/data/appupdate/GitHubReleaseParser.kt
@@ -0,0 +1,132 @@
+package dev.typetype.android.data.appupdate
+
+import dev.typetype.android.domain.appupdate.AppUpdateChannel
+import dev.typetype.android.domain.appupdate.AppUpdateFailure
+import dev.typetype.android.domain.appupdate.AppUpdateFailureReason
+import dev.typetype.android.domain.appupdate.AppUpdateRelease
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+
+internal object GitHubReleaseParser {
+ private val stableVersion = Regex("""^v(\d+\.\d+\.\d+)$""")
+ private val betaVersion = Regex("""^v(\d+\.\d+\.\d+-beta\.\d+)$""")
+ private val betaAsset = Regex("""^dev\.typetype\.android_(\d+)\.apk$""")
+ private val sha256Value = Regex("""^[a-fA-F0-9]{64}$""")
+
+ fun selectRelease(channel: AppUpdateChannel, releases: List): GitHubReleaseDto {
+ val selected = when (channel) {
+ AppUpdateChannel.F_DROID -> throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ AppUpdateChannel.GITHUB_STABLE -> releases.firstOrNull { !it.draft && !it.prerelease }
+ AppUpdateChannel.GITHUB_BETA -> releases
+ .asSequence()
+ .filter { !it.draft && it.prerelease }
+ .maxByOrNull { it.publishedAt.orEmpty() }
+ } ?: throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ val validTag = when (channel) {
+ AppUpdateChannel.F_DROID -> false
+ AppUpdateChannel.GITHUB_STABLE -> stableVersion.matches(selected.tagName)
+ AppUpdateChannel.GITHUB_BETA -> betaVersion.matches(selected.tagName)
+ }
+ if (!validTag) throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ return selected
+ }
+
+ fun release(
+ channel: AppUpdateChannel,
+ release: GitHubReleaseDto,
+ manifest: GitHubUpdateManifestDto?,
+ checksum: String?,
+ ): AppUpdateRelease {
+ val versionName = release.tagName.removePrefix("v")
+ val apk = findApkAsset(channel, release, manifest)
+ ?: throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ validateAssetUrl(apk, release.tagName)
+ if (apk.size !in 1..MAX_APK_SIZE_BYTES) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ }
+ val digest = apk.digest
+ ?.removePrefix("sha256:")
+ ?.takeIf(sha256Value::matches)
+ ?: checksum?.takeIf(sha256Value::matches)
+ ?: manifest?.sha256?.takeIf(sha256Value::matches)
+ ?: throw AppUpdateFailure(AppUpdateFailureReason.INVALID_CHECKSUM)
+ if (manifest != null) {
+ if (
+ manifest.schemaVersion != SUPPORTED_MANIFEST_VERSION ||
+ manifest.channel != channel.manifestName ||
+ manifest.packageName != PACKAGE_NAME ||
+ manifest.versionName != versionName ||
+ manifest.versionCode <= 0 ||
+ !sha256Value.matches(manifest.sha256) ||
+ apk.digest?.let { it != "sha256:" + manifest.sha256 } == true
+ ) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ }
+ }
+ val versionCode = manifest?.versionCode
+ ?: betaAsset.matchEntire(apk.name)?.groupValues?.getOrNull(1)?.toLongOrNull()
+ if (channel == AppUpdateChannel.GITHUB_BETA && versionCode == null) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ }
+ return AppUpdateRelease(
+ channel = channel,
+ tagName = release.tagName,
+ versionName = versionName,
+ versionCode = versionCode,
+ releaseNotes = release.body.orEmpty().take(MAX_RELEASE_NOTES_LENGTH),
+ apkName = apk.name,
+ apkUrl = apk.browserDownloadUrl,
+ sha256 = digest.lowercase(),
+ apkSizeBytes = apk.size,
+ )
+ }
+
+ fun checksumAsset(release: GitHubReleaseDto, apkName: String): GitHubReleaseAssetDto? =
+ release.assets.firstOrNull { it.name == "$apkName.sha256" }
+ ?.also { validateAssetUrl(it, release.tagName) }
+
+ fun manifestAsset(release: GitHubReleaseDto): GitHubReleaseAssetDto? =
+ release.assets.firstOrNull { it.name == MANIFEST_ASSET_NAME }
+ ?.also { validateAssetUrl(it, release.tagName) }
+
+ fun findApkAsset(
+ channel: AppUpdateChannel,
+ release: GitHubReleaseDto,
+ manifest: GitHubUpdateManifestDto?,
+ ): GitHubReleaseAssetDto? = release.assets.firstOrNull { asset ->
+ if (manifest != null) {
+ asset.name == manifest.apkAsset
+ } else {
+ when (channel) {
+ AppUpdateChannel.GITHUB_STABLE -> asset.name == "TypeType-" + release.tagName + ".apk"
+ AppUpdateChannel.GITHUB_BETA -> betaAsset.matches(asset.name)
+ AppUpdateChannel.F_DROID -> false
+ }
+ }
+ }?.also { validateAssetUrl(it, release.tagName) }
+
+ private fun validateAssetUrl(asset: GitHubReleaseAssetDto, tag: String) {
+ val url = asset.browserDownloadUrl.toHttpUrlOrNull()
+ val expectedPath = "/TypeType-Video/TypeType-Android/releases/download/" + tag + "/" + asset.name
+ if (
+ url?.scheme != "https" ||
+ url.host != GITHUB_HOST ||
+ url.encodedPath != expectedPath
+ ) {
+ throw AppUpdateFailure(AppUpdateFailureReason.INVALID_RELEASE)
+ }
+ }
+
+ private val AppUpdateChannel.manifestName: String
+ get() = when (this) {
+ AppUpdateChannel.GITHUB_STABLE -> "stable"
+ AppUpdateChannel.GITHUB_BETA -> "beta"
+ AppUpdateChannel.F_DROID -> "fdroid"
+ }
+
+ private const val PACKAGE_NAME = "dev.typetype.android"
+ private const val GITHUB_HOST = "github.com"
+ const val MANIFEST_ASSET_NAME = "typetype-update.json"
+ private const val SUPPORTED_MANIFEST_VERSION = 1
+ private const val MAX_APK_SIZE_BYTES = 200L * 1024 * 1024
+ private const val MAX_RELEASE_NOTES_LENGTH = 20_000
+}
diff --git a/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateChannel.kt b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateChannel.kt
new file mode 100644
index 00000000..8ec0b712
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateChannel.kt
@@ -0,0 +1,7 @@
+package dev.typetype.android.domain.appupdate
+
+enum class AppUpdateChannel {
+ F_DROID,
+ GITHUB_STABLE,
+ GITHUB_BETA,
+}
diff --git a/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateCheck.kt b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateCheck.kt
new file mode 100644
index 00000000..fbc156df
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateCheck.kt
@@ -0,0 +1,8 @@
+package dev.typetype.android.domain.appupdate
+
+sealed interface AppUpdateCheck {
+ data object ManagedByF_Droid : AppUpdateCheck
+ data object UpToDate : AppUpdateCheck
+ data class Available(val release: AppUpdateRelease) : AppUpdateCheck
+ data class NewerBuildInstalled(val release: AppUpdateRelease) : AppUpdateCheck
+}
diff --git a/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateFailure.kt b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateFailure.kt
new file mode 100644
index 00000000..2ec52a29
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateFailure.kt
@@ -0,0 +1,18 @@
+package dev.typetype.android.domain.appupdate
+
+import java.io.IOException
+
+enum class AppUpdateFailureReason {
+ NETWORK,
+ INVALID_RELEASE,
+ INVALID_CHECKSUM,
+ INVALID_PACKAGE,
+ DOWNGRADE_BLOCKED,
+ SIGNATURE_MISMATCH,
+ INSTALLER_UNAVAILABLE,
+}
+
+class AppUpdateFailure(
+ val reason: AppUpdateFailureReason,
+ cause: Throwable? = null,
+) : IOException(reason.name, cause)
diff --git a/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateFileDownloader.kt b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateFileDownloader.kt
new file mode 100644
index 00000000..b800ca70
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateFileDownloader.kt
@@ -0,0 +1,10 @@
+package dev.typetype.android.domain.appupdate
+
+import java.io.File
+
+interface AppUpdateFileDownloader {
+ suspend fun download(
+ release: AppUpdateRelease,
+ onProgress: (downloadedBytes: Long, totalBytes: Long) -> Unit,
+ ): File
+}
diff --git a/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdatePolicy.kt b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdatePolicy.kt
new file mode 100644
index 00000000..761222a6
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdatePolicy.kt
@@ -0,0 +1,68 @@
+package dev.typetype.android.domain.appupdate
+
+internal object AppUpdatePolicy {
+ fun check(
+ channel: AppUpdateChannel,
+ installedVersionName: String,
+ installedVersionCode: Long,
+ release: AppUpdateRelease,
+ ): AppUpdateCheck {
+ release.versionCode?.let { candidateCode ->
+ return when {
+ candidateCode > installedVersionCode -> AppUpdateCheck.Available(release)
+ candidateCode == installedVersionCode && release.versionName == installedVersionName ->
+ AppUpdateCheck.UpToDate
+ else -> AppUpdateCheck.NewerBuildInstalled(release)
+ }
+ }
+ if (
+ channel == AppUpdateChannel.GITHUB_STABLE &&
+ installedVersionName.endsWithBetaOf(release.versionName)
+ ) {
+ return AppUpdateCheck.NewerBuildInstalled(release)
+ }
+ val candidate = AppVersion.parse(release.versionName)
+ val installed = AppVersion.parse(installedVersionName)
+ return when {
+ candidate == null || installed == null || candidate > installed ->
+ AppUpdateCheck.Available(release)
+ candidate == installed -> AppUpdateCheck.UpToDate
+ else -> AppUpdateCheck.NewerBuildInstalled(release)
+ }
+ }
+
+ private fun String.endsWithBetaOf(stableVersion: String): Boolean =
+ startsWith("$stableVersion-beta.")
+
+ private data class AppVersion(
+ val major: Long,
+ val minor: Long,
+ val patch: Long,
+ val beta: Long?,
+ ) : Comparable {
+ override fun compareTo(other: AppVersion): Int {
+ compareValuesBy(this, other, AppVersion::major, AppVersion::minor, AppVersion::patch)
+ .takeIf { it != 0 }?.let { return it }
+ return when {
+ beta == null && other.beta == null -> 0
+ beta == null -> 1
+ other.beta == null -> -1
+ else -> beta.compareTo(other.beta)
+ }
+ }
+
+ companion object {
+ private val versionPattern = Regex("""^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$""")
+
+ fun parse(value: String): AppVersion? {
+ val parts = versionPattern.matchEntire(value)?.groupValues ?: return null
+ return AppVersion(
+ major = parts[1].toLongOrNull() ?: return null,
+ minor = parts[2].toLongOrNull() ?: return null,
+ patch = parts[3].toLongOrNull() ?: return null,
+ beta = parts[4].takeIf(String::isNotEmpty)?.toLongOrNull(),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateRelease.kt b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateRelease.kt
new file mode 100644
index 00000000..2b21a7b7
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateRelease.kt
@@ -0,0 +1,13 @@
+package dev.typetype.android.domain.appupdate
+
+data class AppUpdateRelease(
+ val channel: AppUpdateChannel,
+ val tagName: String,
+ val versionName: String,
+ val versionCode: Long?,
+ val releaseNotes: String,
+ val apkName: String,
+ val apkUrl: String,
+ val sha256: String,
+ val apkSizeBytes: Long,
+)
diff --git a/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateRepository.kt b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateRepository.kt
new file mode 100644
index 00000000..0755d07a
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateRepository.kt
@@ -0,0 +1,16 @@
+package dev.typetype.android.domain.appupdate
+
+import java.io.File
+
+interface AppUpdateRepository {
+ suspend fun check(
+ channel: AppUpdateChannel,
+ installedVersionName: String,
+ installedVersionCode: Long,
+ ): AppUpdateCheck
+
+ suspend fun download(
+ release: AppUpdateRelease,
+ onProgress: (downloadedBytes: Long, totalBytes: Long) -> Unit,
+ ): File
+}
diff --git a/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateSettingsRepository.kt b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateSettingsRepository.kt
new file mode 100644
index 00000000..73121169
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/domain/appupdate/AppUpdateSettingsRepository.kt
@@ -0,0 +1,8 @@
+package dev.typetype.android.domain.appupdate
+
+import kotlinx.coroutines.flow.Flow
+
+interface AppUpdateSettingsRepository {
+ fun observeChannel(): Flow
+ suspend fun setChannel(channel: AppUpdateChannel)
+}
diff --git a/app/src/main/java/dev/typetype/android/feature/settings/about/AboutAppUpdatesSection.kt b/app/src/main/java/dev/typetype/android/feature/settings/about/AboutAppUpdatesSection.kt
new file mode 100644
index 00000000..df702980
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/feature/settings/about/AboutAppUpdatesSection.kt
@@ -0,0 +1,203 @@
+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/app/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt
index 5872b208..087843c2 100644
--- a/app/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt
+++ b/app/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt
@@ -81,6 +81,15 @@ 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/app/src/main/java/dev/typetype/android/feature/settings/about/AboutViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/about/AboutViewModel.kt
index 04aa79a0..7985311e 100644
--- a/app/src/main/java/dev/typetype/android/feature/settings/about/AboutViewModel.kt
+++ b/app/src/main/java/dev/typetype/android/feature/settings/about/AboutViewModel.kt
@@ -3,15 +3,27 @@ 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.BuildConfig
+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.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
@@ -21,6 +33,7 @@ data class AboutState(
val role: String = "",
val componentVersions: ComponentVersions? = null,
val isLoadingVersions: Boolean = false,
+ val appUpdate: AppUpdateUiState = AppUpdateUiState(),
)
private data class VersionLoadState(
@@ -33,27 +46,49 @@ class AboutViewModel @Inject constructor(
serverRepository: ServerRepository,
profileRepository: ProfileRepository,
private val componentVersionsRepository: ComponentVersionsRepository,
+ private val appUpdateRepository: AppUpdateRepository,
+ private val appUpdateSettingsRepository: AppUpdateSettingsRepository,
+ private val appUpdateInstaller: AndroidAppUpdateInstaller,
) : ViewModel() {
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,
- ) { server, profile, versions ->
+ appUpdateState,
+ ) { server, profile, versions, appUpdate ->
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() {
@@ -66,4 +101,114 @@ 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 = BuildConfig.VERSION_NAME,
+ installedVersionCode = BuildConfig.VERSION_CODE.toLong(),
+ )
+ 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/app/src/main/java/dev/typetype/android/feature/settings/about/AppUpdateUiState.kt b/app/src/main/java/dev/typetype/android/feature/settings/about/AppUpdateUiState.kt
new file mode 100644
index 00000000..63efe06b
--- /dev/null
+++ b/app/src/main/java/dev/typetype/android/feature/settings/about/AppUpdateUiState.kt
@@ -0,0 +1,31 @@
+package dev.typetype.android.feature.settings.about
+
+import dev.typetype.android.domain.appupdate.AppUpdateChannel
+import dev.typetype.android.domain.appupdate.AppUpdateFailureReason
+import dev.typetype.android.domain.appupdate.AppUpdateRelease
+import java.io.File
+
+enum class AppUpdateUiStatus {
+ IDLE,
+ CHECKING,
+ MANAGED_BY_F_DROID,
+ UP_TO_DATE,
+ AVAILABLE,
+ NEWER_BUILD_INSTALLED,
+ DOWNLOADING,
+ READY_TO_INSTALL,
+ INSTALLER_OPENED,
+ PERMISSION_REQUESTED,
+ FAILED,
+}
+
+data class AppUpdateUiState(
+ val channel: AppUpdateChannel = AppUpdateChannel.GITHUB_STABLE,
+ val status: AppUpdateUiStatus = AppUpdateUiStatus.IDLE,
+ val release: AppUpdateRelease? = null,
+ val downloadedFile: File? = null,
+ val progress: Float = 0f,
+ val downloadedBytes: Long = 0,
+ val totalBytes: Long = 0,
+ val failureReason: AppUpdateFailureReason? = null,
+)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 00fcceb3..b7ea06af 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -713,6 +713,33 @@
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.
License
GPL v3
Server
diff --git a/app/src/main/res/xml/app_update_paths.xml b/app/src/main/res/xml/app_update_paths.xml
new file mode 100644
index 00000000..b6c8720e
--- /dev/null
+++ b/app/src/main/res/xml/app_update_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/test/java/dev/typetype/android/data/appupdate/GitHubAppUpdateRepositoryTest.kt b/app/src/test/java/dev/typetype/android/data/appupdate/GitHubAppUpdateRepositoryTest.kt
new file mode 100644
index 00000000..2508040c
--- /dev/null
+++ b/app/src/test/java/dev/typetype/android/data/appupdate/GitHubAppUpdateRepositoryTest.kt
@@ -0,0 +1,201 @@
+package dev.typetype.android.data.appupdate
+
+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.AppUpdateFileDownloader
+import dev.typetype.android.domain.appupdate.AppUpdateRelease
+import java.io.File
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class GitHubAppUpdateRepositoryTest {
+ private lateinit var server: MockWebServer
+ private lateinit var repository: GitHubAppUpdateRepository
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ repository = GitHubAppUpdateRepository(
+ client = OkHttpClient(),
+ json = Json { ignoreUnknownKeys = true },
+ downloader = UnusedDownloader,
+ ).apply {
+ apiBaseUrl = server.url("/repos/TypeType-Video/TypeType-Android").toString().trimEnd('/')
+ }
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ @Test
+ fun stableCheckReadsLatestReleaseAndIgnoresEqualVersion() = runBlocking {
+ server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(stableRelease()))
+
+ val result = repository.check(AppUpdateChannel.GITHUB_STABLE, "1.10.0", 11002)
+ val request = server.takeRequest()
+
+ assertEquals("/repos/TypeType-Video/TypeType-Android/releases/latest", request.path)
+ assertTrue(result is AppUpdateCheck.UpToDate)
+ }
+
+ @Test
+ fun stableCheckExposesReleaseDetails() = runBlocking {
+ server.enqueue(
+ MockResponse().setHeader("Content-Type", "application/json").setBody(
+ stableRelease(version = "1.11.0", tag = "v1.11.0"),
+ ),
+ )
+
+ val result = repository.check(AppUpdateChannel.GITHUB_STABLE, "1.10.0", 11002)
+ val release = (result as AppUpdateCheck.Available).release
+
+ assertEquals("v1.11.0", release.tagName)
+ assertEquals("1.11.0", release.versionName)
+ assertNull(release.versionCode)
+ assertEquals("TypeType-v1.11.0.apk", release.apkName)
+ assertEquals(HASH, release.sha256)
+ assertEquals("Update notes", release.releaseNotes)
+ }
+
+ @Test
+ fun betaCheckSelectsNewestPrereleaseAndReadsItsCode() = runBlocking {
+ val releases = "[" +
+ betaRelease("1.10.0-beta.3", 11003, "2026-10-20T10:00:00Z") + "," +
+ stableRelease() + "," +
+ betaRelease("1.10.0-beta.4", 11004, "2026-10-21T10:00:00Z") +
+ "]"
+ server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(releases))
+
+ val result = repository.check(AppUpdateChannel.GITHUB_BETA, "1.10.0", 11002)
+ val request = server.takeRequest()
+ val release = (result as AppUpdateCheck.Available).release
+
+ assertEquals("/repos/TypeType-Video/TypeType-Android/releases?per_page=100", request.path)
+ assertEquals("v1.10.0-beta.4", release.tagName)
+ assertEquals(11004L, release.versionCode)
+ assertEquals(AppUpdateChannel.GITHUB_BETA, release.channel)
+ }
+
+ @Test
+ fun stableDoesNotOfferSameVersionAsDowngradeFromBeta() = runBlocking {
+ server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(stableRelease()))
+
+ val result = repository.check(AppUpdateChannel.GITHUB_STABLE, "1.10.0-beta.4", 11004)
+
+ assertTrue(result is AppUpdateCheck.NewerBuildInstalled)
+ }
+
+ @Test
+ fun stableReportsOlderReleaseWhenInstalledBetaHasNewerPatch() = runBlocking {
+ server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(stableRelease()))
+
+ val result = repository.check(AppUpdateChannel.GITHUB_STABLE, "1.10.1-beta.1", 11005)
+
+ assertTrue(result is AppUpdateCheck.NewerBuildInstalled)
+ }
+
+ @Test
+ fun rejectsReleaseAssetsOutsideOfficialGitHubPath() = runBlocking {
+ val body = stableRelease().replace(
+ "https://github.com/TypeType-Video/TypeType-Android/releases/download/",
+ "https://downloads.example/releases/download/",
+ )
+ server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(body))
+
+ val failure = runCatching {
+ repository.check(AppUpdateChannel.GITHUB_STABLE, "1.10.0", 11002)
+ }.exceptionOrNull()
+
+ assertTrue(failure is AppUpdateFailure)
+ assertEquals(AppUpdateFailureReason.INVALID_RELEASE, (failure as AppUpdateFailure).reason)
+ }
+
+ @Test
+ fun releaseManifestMustMatchSelectedChannelAndArtifact() {
+ val releaseDto = Json.decodeFromString(
+ stableRelease(version = "1.11.0", tag = "v1.11.0"),
+ )
+ val release = GitHubReleaseParser.selectRelease(AppUpdateChannel.GITHUB_STABLE, listOf(releaseDto))
+ val manifest = GitHubUpdateManifestDto(
+ schemaVersion = 1,
+ channel = "stable",
+ packageName = "dev.typetype.android",
+ versionName = "1.11.0",
+ versionCode = 11100,
+ apkAsset = "TypeType-v1.11.0.apk",
+ sha256 = HASH,
+ )
+
+ val parsed = GitHubReleaseParser.release(
+ AppUpdateChannel.GITHUB_STABLE,
+ release,
+ manifest,
+ checksum = null,
+ )
+ assertEquals(11100L, parsed.versionCode)
+ assertEquals(HASH, parsed.sha256)
+ }
+
+ private fun stableRelease(
+ version: String = "1.10.0",
+ tag: String = "v1.10.0",
+ ): String = """
+ {
+ "tag_name":"$tag",
+ "draft":false,
+ "prerelease":false,
+ "body":"Update notes",
+ "assets":[{
+ "name":"TypeType-$tag.apk",
+ "size":128,
+ "digest":"sha256:$HASH",
+ "browser_download_url":"https://github.com/TypeType-Video/TypeType-Android/releases/download/$tag/TypeType-$tag.apk"
+ }]
+ }
+ """.trimIndent()
+
+ private fun betaRelease(version: String, versionCode: Int, publishedAt: String): String {
+ val tag = "v$version"
+ val name = "dev.typetype.android_$versionCode.apk"
+ return """
+ {
+ "tag_name":"$tag",
+ "draft":false,
+ "prerelease":true,
+ "published_at":"$publishedAt",
+ "assets":[{
+ "name":"$name",
+ "size":128,
+ "digest":"sha256:$HASH",
+ "browser_download_url":"https://github.com/TypeType-Video/TypeType-Android/releases/download/$tag/$name"
+ }]
+ }
+ """.trimIndent()
+ }
+
+ private object UnusedDownloader : AppUpdateFileDownloader {
+ override suspend fun download(
+ release: AppUpdateRelease,
+ onProgress: (downloadedBytes: Long, totalBytes: Long) -> Unit,
+ ): File = error("The update checker does not download APKs")
+ }
+
+ private companion object {
+ const val HASH = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ }
+}
diff --git a/release-notes/v1.10.1-beta.1.md b/release-notes/v1.10.1-beta.1.md
new file mode 100644
index 00000000..b917a3fb
--- /dev/null
+++ b/release-notes/v1.10.1-beta.1.md
@@ -0,0 +1,9 @@
+TypeType Android 1.10.1-beta.1
+
+- Choose F-Droid, GitHub Stable, or GitHub Beta as this device's update channel.
+- Check GitHub releases for the selected channel and read their release notes in About.
+- Verify the APK checksum, package, version, and signing certificate before opening Android's installer.
+- Resume interrupted APK downloads.
+- Publish channel metadata with beta releases for in-app update checks.
+
+Thanks to everyone testing TypeType and sharing feedback.