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.