From c3527592d94f913f806c0ad99681b530c6377d95 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Wed, 8 Jul 2026 09:24:15 +0500 Subject: [PATCH 1/3] Add automatic app update checks --- app/src/main/AndroidManifest.xml | 12 +- .../kino/puber/data/api/KinoPubApiClient.kt | 114 +++++++- .../com/kino/puber/data/api/KtorPlugins.kt | 41 ++- .../data/api/models/GitHubReleaseModels.kt | 23 ++ .../java/com/kino/puber/data/di/modules.kt | 9 + .../data/repository/AppUpdateDownload.kt | 160 +++++++++++ .../data/repository/AppUpdateInstaller.kt | 64 +++++ .../puber/data/repository/AppUpdateModels.kt | 13 + .../AppUpdatePreferencesRepository.kt | 17 ++ .../data/repository/AppUpdateRepository.kt | 71 +++++ .../kino/puber/data/repository/AppVersion.kt | 46 ++++ .../data/repository/IAppUpdateRepository.kt | 6 + .../java/com/kino/puber/domain/di/modules.kt | 3 + .../interactor/update/AppUpdateInteractor.kt | 53 ++++ .../interactor/update/IAppUpdateInteractor.kt | 26 ++ .../device/settings/DeviceSettingsContent.kt | 20 ++ .../device/settings/DeviceSettingsPreview.kt | 13 + .../settings/model/DeviceSettingsActions.kt | 1 + .../settings/model/DeviceSettingsViewState.kt | 1 + .../device/settings/vm/DeviceSettingsVM.kt | 12 + .../puber/ui/feature/root/component/App.kt | 30 ++- .../update/component/UpdatePromptOverlay.kt | 251 ++++++++++++++++++ .../update/model/UpdatePromptAction.kt | 11 + .../update/model/UpdatePromptViewState.kt | 33 +++ .../ui/feature/update/vm/UpdatePromptVM.kt | 251 ++++++++++++++++++ app/src/main/res/values/strings.xml | 22 ++ app/src/main/res/xml/update_file_paths.xml | 6 + .../data/repository/AppUpdateDownloadTest.kt | 73 +++++ .../repository/AppUpdateRepositoryTest.kt | 164 ++++++++++++ .../puber/data/repository/AppVersionTest.kt | 85 ++++++ .../update/AppUpdateInteractorTest.kt | 101 +++++++ .../feature/update/vm/UpdatePromptVMTest.kt | 214 +++++++++++++++ 32 files changed, 1939 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/kino/puber/data/api/models/GitHubReleaseModels.kt create mode 100644 app/src/main/java/com/kino/puber/data/repository/AppUpdateDownload.kt create mode 100644 app/src/main/java/com/kino/puber/data/repository/AppUpdateInstaller.kt create mode 100644 app/src/main/java/com/kino/puber/data/repository/AppUpdateModels.kt create mode 100644 app/src/main/java/com/kino/puber/data/repository/AppUpdatePreferencesRepository.kt create mode 100644 app/src/main/java/com/kino/puber/data/repository/AppUpdateRepository.kt create mode 100644 app/src/main/java/com/kino/puber/data/repository/AppVersion.kt create mode 100644 app/src/main/java/com/kino/puber/data/repository/IAppUpdateRepository.kt create mode 100644 app/src/main/java/com/kino/puber/domain/interactor/update/AppUpdateInteractor.kt create mode 100644 app/src/main/java/com/kino/puber/domain/interactor/update/IAppUpdateInteractor.kt create mode 100644 app/src/main/java/com/kino/puber/ui/feature/update/component/UpdatePromptOverlay.kt create mode 100644 app/src/main/java/com/kino/puber/ui/feature/update/model/UpdatePromptAction.kt create mode 100644 app/src/main/java/com/kino/puber/ui/feature/update/model/UpdatePromptViewState.kt create mode 100644 app/src/main/java/com/kino/puber/ui/feature/update/vm/UpdatePromptVM.kt create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/xml/update_file_paths.xml create mode 100644 app/src/test/kotlin/com/kino/puber/data/repository/AppUpdateDownloadTest.kt create mode 100644 app/src/test/kotlin/com/kino/puber/data/repository/AppUpdateRepositoryTest.kt create mode 100644 app/src/test/kotlin/com/kino/puber/data/repository/AppVersionTest.kt create mode 100644 app/src/test/kotlin/com/kino/puber/domain/interactor/update/AppUpdateInteractorTest.kt create mode 100644 app/src/test/kotlin/com/kino/puber/ui/feature/update/vm/UpdatePromptVMTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d166ee6..5230207 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/kino/puber/data/api/KinoPubApiClient.kt b/app/src/main/java/com/kino/puber/data/api/KinoPubApiClient.kt index 29a3d7d..7f0bb85 100644 --- a/app/src/main/java/com/kino/puber/data/api/KinoPubApiClient.kt +++ b/app/src/main/java/com/kino/puber/data/api/KinoPubApiClient.kt @@ -23,6 +23,7 @@ import com.kino.puber.data.api.models.DeviceResponse import com.kino.puber.data.api.models.DeviceSettings import com.kino.puber.data.api.models.Episode import com.kino.puber.data.api.models.Genre +import com.kino.puber.data.api.models.GitHubReleaseResponse import com.kino.puber.data.api.models.History import com.kino.puber.data.api.models.Item import com.kino.puber.data.api.models.ItemFiles @@ -63,13 +64,20 @@ import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.Url import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.Cache import okhttp3.OkHttpClient @@ -108,7 +116,7 @@ class KinoPubApiClient( BearerTokens(accessToken.orEmpty(), refreshToken.orEmpty()) } - sendWithoutRequest { true } + sendWithoutRequest { request -> shouldSendKinoPubToken(request.url.host) } refreshTokens { val refreshToken = cryptoPreferenceRepository.getRefreshToken().orEmpty() @@ -823,6 +831,102 @@ class KinoPubApiClient( httpClient.get("${KinoPubConfig.EXTRA_API_BASE_URL}api2/v1.1/items/collections/$itemId") } + // App update API + + suspend fun getLatestGitHubRelease(owner: String, repo: String): Result = apiCall { + httpClient.get("https://api.github.com/repos/$owner/$repo/releases/latest") { + headers { + append("Accept", "application/vnd.github+json") + } + } + } + + suspend fun downloadUpdateAsset( + url: String, + targetFile: File, + onProgress: (Int) -> Unit, + ): Result = withContext(Dispatchers.IO) { + val tempFile = File("${targetFile.absolutePath}.download") + try { + targetFile.parentFile?.mkdirs() + if (tempFile.exists() && !tempFile.delete()) { + throw IllegalStateException("Unable to delete stale update download") + } + + val response = httpClient.get(url) + if (!response.status.isSuccess()) { + throw IllegalStateException("Update download failed with HTTP ${response.status.value}") + } + + val totalBytes = response.headers[HttpHeaders.ContentLength]?.toLongOrNull() + val channel = response.bodyAsChannel() + val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE) + var downloadedBytes = 0L + var lastProgress = -1 + + fun dispatchProgress(percent: Int) { + val coercedPercent = percent.coerceIn(0, 100) + if (coercedPercent != lastProgress) { + lastProgress = coercedPercent + onProgress(coercedPercent) + } + } + + if (totalBytes != null && totalBytes > 0L) { + dispatchProgress(0) + } + + tempFile.outputStream().buffered().use { output -> + while (!channel.isClosedForRead) { + val bytesRead = channel.readAvailable(buffer) + if (bytesRead == -1) { + break + } + + output.write(buffer, 0, bytesRead) + downloadedBytes += bytesRead + + if (totalBytes != null && totalBytes > 0L) { + dispatchProgress(((downloadedBytes * 100L) / totalBytes).toInt()) + } + } + } + + if (!tempFile.renameTo(targetFile)) { + if (targetFile.exists() && !targetFile.delete()) { + throw IllegalStateException("Unable to replace existing update download") + } + if (!tempFile.renameTo(targetFile)) { + throw IllegalStateException("Unable to finalize update download") + } + } + + dispatchProgress(100) + Result.success(targetFile) + } catch (error: CancellationException) { + tempFile.delete() + throw error + } catch (error: Exception) { + tempFile.delete() + Result.failure(error) + } + } + + suspend fun getUpdateChecksum(url: String): Result = withContext(Dispatchers.IO) { + try { + val response = httpClient.get(url) + if (!response.status.isSuccess()) { + throw IllegalStateException("Update checksum download failed with HTTP ${response.status.value}") + } + + Result.success(response.bodyAsText()) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Result.failure(error) + } + } + // Helper method for API calls suspend inline fun apiCall( @@ -830,6 +934,8 @@ class KinoPubApiClient( ): Result = try { val response = block() Result.success(response.body()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Result.failure(e) } @@ -967,10 +1073,16 @@ class KinoPubApiClient( } } + private fun shouldSendKinoPubToken(host: String): Boolean { + return host == Url(KinoPubConfig.MAIN_API_BASE_URL).host || + host == Url(KinoPubConfig.OAUTH_BASE_URL).host + } + companion object { private const val MAX_RETRIES = 3 private const val CONNECT_TIMEOUT = 60_000L private const val READ_TIMEOUT = 120_000L private const val CACHE_SIZE = 50L * 1024 * 1024 // 50 MB + private const val DOWNLOAD_BUFFER_SIZE = 8 * 1024 } } diff --git a/app/src/main/java/com/kino/puber/data/api/KtorPlugins.kt b/app/src/main/java/com/kino/puber/data/api/KtorPlugins.kt index d3e63ee..91f556e 100644 --- a/app/src/main/java/com/kino/puber/data/api/KtorPlugins.kt +++ b/app/src/main/java/com/kino/puber/data/api/KtorPlugins.kt @@ -6,6 +6,7 @@ import io.ktor.client.plugins.api.createClientPlugin import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.request import io.ktor.content.TextContent +import io.ktor.http.HttpHeaders import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.availableForRead import io.ktor.utils.io.core.build @@ -81,9 +82,6 @@ val CurlLogger = createClientPlugin("CurlLogger") { } onResponse { response -> - val responseBody = response.bodyAsChannel() - val content = readTextLimited(responseBody, maxBodySize) - log("<-- ${response.status} ${response.request.url}") response.headers.entries().forEach { (key, values) -> values.forEach { value -> @@ -91,6 +89,22 @@ val CurlLogger = createClientPlugin("CurlLogger") { } } + if ( + shouldSkipBodyLogging( + host = response.request.url.host, + path = response.request.url.encodedPath, + contentType = response.headers[HttpHeaders.ContentType], + ) + ) { + log("") + log("") + log("<-- END HTTP") + return@onResponse + } + + val responseBody = response.bodyAsChannel() + val content = readTextLimited(responseBody, maxBodySize) + log("") log(content) log("<-- END HTTP (${content.length}-char body)") @@ -116,4 +130,23 @@ private suspend fun readTextLimited(channel: ByteReadChannel, maxSize: Long): St } catch (_: Exception) { "" } -} \ No newline at end of file +} + +private fun shouldSkipBodyLogging(host: String, path: String, contentType: String?): Boolean { + val normalizedHost = host.lowercase() + val normalizedPath = path.lowercase() + val normalizedContentType = contentType.orEmpty().lowercase() + + return normalizedHost.isGitHubHost() || + normalizedPath.endsWith(".apk") || + normalizedPath.endsWith(".sha256") || + normalizedContentType.contains("application/vnd.android.package-archive") || + normalizedContentType.startsWith("application/octet-stream") +} + +private fun String.isGitHubHost(): Boolean { + return this == "github.com" || + endsWith(".github.com") || + this == "githubusercontent.com" || + endsWith(".githubusercontent.com") +} diff --git a/app/src/main/java/com/kino/puber/data/api/models/GitHubReleaseModels.kt b/app/src/main/java/com/kino/puber/data/api/models/GitHubReleaseModels.kt new file mode 100644 index 0000000..91e6821 --- /dev/null +++ b/app/src/main/java/com/kino/puber/data/api/models/GitHubReleaseModels.kt @@ -0,0 +1,23 @@ +package com.kino.puber.data.api.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GitHubReleaseResponse( + @SerialName("tag_name") val tagName: String = "", + val name: String? = null, + val body: String? = null, + @SerialName("html_url") val htmlUrl: String? = null, + val draft: Boolean = false, + val prerelease: Boolean = false, + val assets: List = emptyList(), +) + +@Serializable +data class GitHubReleaseAssetResponse( + val name: String = "", + @SerialName("browser_download_url") val browserDownloadUrl: String = "", + val size: Long? = null, + @SerialName("content_type") val contentType: String? = null, +) diff --git a/app/src/main/java/com/kino/puber/data/di/modules.kt b/app/src/main/java/com/kino/puber/data/di/modules.kt index ff24e33..71a5b34 100644 --- a/app/src/main/java/com/kino/puber/data/di/modules.kt +++ b/app/src/main/java/com/kino/puber/data/di/modules.kt @@ -5,9 +5,14 @@ package com.kino.puber.data.di import android.net.ConnectivityManager import com.kino.puber.core.session.SessionEventBus import com.kino.puber.data.api.KinoPubApiClient +import com.kino.puber.data.repository.AppUpdateDownloader +import com.kino.puber.data.repository.AppUpdateInstaller +import com.kino.puber.data.repository.AppUpdatePreferencesRepository +import com.kino.puber.data.repository.AppUpdateRepository import com.kino.puber.data.repository.CryptoPreferenceRepository import com.kino.puber.data.repository.DeviceInfoRepository import com.kino.puber.data.repository.DeviceSettingsRepository +import com.kino.puber.data.repository.IAppUpdateRepository import com.kino.puber.data.repository.ICryptoPreferenceRepository import com.kino.puber.data.repository.IDeviceInfoRepository import com.kino.puber.data.repository.IDeviceSettingsRepository @@ -58,6 +63,10 @@ private const val MIB = 1024L * 1024L private const val MEDIA_CACHE_SIZE_BYTES = 512L * MIB val repositoryModule = module { + singleOf(::AppUpdateRepository) { bind() } + singleOf(::AppUpdatePreferencesRepository) + singleOf(::AppUpdateInstaller) + singleOf(::AppUpdateDownloader) singleOf(::KinoPubRepository) { bind() } singleOf(::CryptoPreferenceRepository) { bind() } singleOf(::DeviceInfoRepository) { bind() } diff --git a/app/src/main/java/com/kino/puber/data/repository/AppUpdateDownload.kt b/app/src/main/java/com/kino/puber/data/repository/AppUpdateDownload.kt new file mode 100644 index 0000000..d22fcf3 --- /dev/null +++ b/app/src/main/java/com/kino/puber/data/repository/AppUpdateDownload.kt @@ -0,0 +1,160 @@ +package com.kino.puber.data.repository + +import android.content.Context +import android.os.Environment +import com.kino.puber.data.api.KinoPubApiClient +import java.io.File +import java.security.MessageDigest + +internal class AppUpdateDownloader( + context: Context, + private val apiClient: KinoPubApiClient, +) { + + private val appContext = context.applicationContext + + suspend fun download( + update: AvailableUpdate, + onProgress: (AppUpdateDownload.Progress) -> Unit, + ): AppUpdateDownload { + val updatesDirectory = getUpdatesDirectory() + ?: return AppUpdateDownload.Error.StorageUnavailable + val targetFile = File(updatesDirectory, sanitizeFileName(update.apkAssetName)) + + val downloadedFile = apiClient.downloadUpdateAsset( + url = update.apkDownloadUrl, + targetFile = targetFile, + onProgress = { percent -> onProgress(AppUpdateDownload.Progress(percent)) }, + ).getOrElse { error -> + return AppUpdateDownload.Error.DownloadFailed(error) + } + + val checksumUrl = update.checksumDownloadUrl + ?: return AppUpdateDownload.Completed(downloadedFile) + + val checksumContent = apiClient.getUpdateChecksum(checksumUrl).getOrElse { error -> + downloadedFile.delete() + return AppUpdateDownload.Error.ChecksumDownloadFailed(error) + } + + return when (val verification = AppUpdateChecksum.verifySha256(downloadedFile, checksumContent)) { + AppUpdateChecksumVerification.Match -> AppUpdateDownload.Completed(downloadedFile) + AppUpdateChecksumVerification.InvalidChecksum -> { + downloadedFile.delete() + AppUpdateDownload.Error.InvalidChecksum + } + is AppUpdateChecksumVerification.Mismatch -> { + downloadedFile.delete() + AppUpdateDownload.Error.ChecksumMismatch( + expected = verification.expected, + actual = verification.actual, + ) + } + } + } + + private fun getUpdatesDirectory(): File? { + val downloadsDirectory = appContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + ?: return null + return File(downloadsDirectory, UPDATES_DIRECTORY_NAME).apply { + mkdirs() + } + } + + private fun sanitizeFileName(raw: String): String { + val sanitized = raw.substringAfterLast('/').map { char -> + if (char.isLetterOrDigit() || char == '.' || char == '_' || char == '-') { + char + } else { + '_' + } + }.joinToString(separator = "") + + return sanitized.takeIf { it.endsWith(APK_EXTENSION, ignoreCase = true) } + ?: DEFAULT_APK_NAME + } + + private companion object { + const val UPDATES_DIRECTORY_NAME = "updates" + const val APK_EXTENSION = ".apk" + const val DEFAULT_APK_NAME = "update.apk" + } +} + +internal sealed interface AppUpdateDownload { + data class Progress(val percent: Int) : AppUpdateDownload + data class Completed(val file: File) : AppUpdateDownload + + sealed interface Error : AppUpdateDownload { + data object StorageUnavailable : Error + data class DownloadFailed(val cause: Throwable) : Error + data class ChecksumDownloadFailed(val cause: Throwable) : Error + data object InvalidChecksum : Error + data class ChecksumMismatch(val expected: String, val actual: String) : Error + } +} + +internal object AppUpdateChecksum { + + fun verifySha256(file: File, checksumContent: String): AppUpdateChecksumVerification { + val expected = parseSha256(checksumContent) + ?: return AppUpdateChecksumVerification.InvalidChecksum + val actual = calculateSha256(file) + + return if (expected.equals(actual, ignoreCase = true)) { + AppUpdateChecksumVerification.Match + } else { + AppUpdateChecksumVerification.Mismatch(expected = expected, actual = actual) + } + } + + fun parseSha256(checksumContent: String): String? { + return checksumContent + .lineSequence() + .map { line -> line.trim().takeWhile { char -> !char.isWhitespace() }.lowercase() } + .firstOrNull(::isSha256) + } + + fun calculateSha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(HASH_BUFFER_SIZE) + + file.inputStream().buffered().use { input -> + while (true) { + val bytesRead = input.read(buffer) + if (bytesRead == -1) { + break + } + digest.update(buffer, 0, bytesRead) + } + } + + return digest.digest().toHexString() + } + + private fun isSha256(value: String): Boolean { + return value.length == SHA256_HEX_LENGTH && value.all { char -> + char in '0'..'9' || char in 'a'..'f' + } + } + + private fun ByteArray.toHexString(): String { + return buildString(size * 2) { + this@toHexString.forEach { byte -> + val value = byte.toInt() and 0xff + append(HEX_CHARS[value ushr 4]) + append(HEX_CHARS[value and 0x0f]) + } + } + } + + private const val HASH_BUFFER_SIZE = 8 * 1024 + private const val SHA256_HEX_LENGTH = 64 + private val HEX_CHARS = "0123456789abcdef".toCharArray() +} + +internal sealed interface AppUpdateChecksumVerification { + data object Match : AppUpdateChecksumVerification + data object InvalidChecksum : AppUpdateChecksumVerification + data class Mismatch(val expected: String, val actual: String) : AppUpdateChecksumVerification +} diff --git a/app/src/main/java/com/kino/puber/data/repository/AppUpdateInstaller.kt b/app/src/main/java/com/kino/puber/data/repository/AppUpdateInstaller.kt new file mode 100644 index 0000000..6bd1cc7 --- /dev/null +++ b/app/src/main/java/com/kino/puber/data/repository/AppUpdateInstaller.kt @@ -0,0 +1,64 @@ +package com.kino.puber.data.repository + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider +import java.io.File + +internal class AppUpdateInstaller( + private val context: Context, +) { + + fun canRequestPackageInstalls(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + context.packageManager.canRequestPackageInstalls() + } + + fun openInstallPermissionSettings() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val intent = Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + + fun launchInstaller(file: File): InstallLaunchResult { + if (!canRequestPackageInstalls()) { + return InstallLaunchResult.PermissionRequired + } + + return runCatching { + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.update_files", + file, + ) + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, APK_MIME_TYPE) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + context.startActivity(intent) + InstallLaunchResult.Launched + }.getOrElse { error -> + InstallLaunchResult.Failed(error) + } + } + + private companion object { + const val APK_MIME_TYPE = "application/vnd.android.package-archive" + } +} + +internal sealed interface InstallLaunchResult { + data object Launched : InstallLaunchResult + data object PermissionRequired : InstallLaunchResult + data class Failed(val cause: Throwable) : InstallLaunchResult +} diff --git a/app/src/main/java/com/kino/puber/data/repository/AppUpdateModels.kt b/app/src/main/java/com/kino/puber/data/repository/AppUpdateModels.kt new file mode 100644 index 0000000..9ae48d8 --- /dev/null +++ b/app/src/main/java/com/kino/puber/data/repository/AppUpdateModels.kt @@ -0,0 +1,13 @@ +package com.kino.puber.data.repository + +internal data class AvailableUpdate( + val version: AppVersion, + val tagName: String, + val title: String, + val releaseNotes: String?, + val apkAssetName: String, + val apkDownloadUrl: String, + val apkSizeBytes: Long?, + val checksumDownloadUrl: String?, + val releasePageUrl: String?, +) diff --git a/app/src/main/java/com/kino/puber/data/repository/AppUpdatePreferencesRepository.kt b/app/src/main/java/com/kino/puber/data/repository/AppUpdatePreferencesRepository.kt new file mode 100644 index 0000000..37065ce --- /dev/null +++ b/app/src/main/java/com/kino/puber/data/repository/AppUpdatePreferencesRepository.kt @@ -0,0 +1,17 @@ +package com.kino.puber.data.repository + +import android.content.Context + +internal class AppUpdatePreferencesRepository(context: Context) { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + var autoUpdateCheckEnabled: Boolean + get() = prefs.getBoolean(KEY_AUTO_UPDATE_CHECK_ENABLED, true) + set(value) = prefs.edit().putBoolean(KEY_AUTO_UPDATE_CHECK_ENABLED, value).apply() + + private companion object { + const val PREFS_NAME = "app_update_preferences" + const val KEY_AUTO_UPDATE_CHECK_ENABLED = "auto_update_check_enabled" + } +} diff --git a/app/src/main/java/com/kino/puber/data/repository/AppUpdateRepository.kt b/app/src/main/java/com/kino/puber/data/repository/AppUpdateRepository.kt new file mode 100644 index 0000000..c9bdd70 --- /dev/null +++ b/app/src/main/java/com/kino/puber/data/repository/AppUpdateRepository.kt @@ -0,0 +1,71 @@ +package com.kino.puber.data.repository + +import com.kino.puber.core.logger.log +import com.kino.puber.data.api.KinoPubApiClient +import com.kino.puber.data.api.models.GitHubReleaseAssetResponse +import com.kino.puber.data.api.models.GitHubReleaseResponse + +internal class AppUpdateRepository( + private val apiClient: KinoPubApiClient, +) : IAppUpdateRepository { + + override suspend fun getAvailableUpdate(currentVersionName: String): Result { + val currentVersion = AppVersion.parse(currentVersionName) + ?: return Result.success(null).also { + log("App update: ignoring malformed current version '$currentVersionName'") + } + + return apiClient.getLatestGitHubRelease(GITHUB_OWNER, GITHUB_REPO).mapCatching { release -> + release.toAvailableUpdate(currentVersion) + } + } + + private fun GitHubReleaseResponse.toAvailableUpdate(currentVersion: AppVersion): AvailableUpdate? { + if (draft || prerelease) { + return null + } + + val latestVersion = AppVersion.parse(tagName) + ?: return null.also { + log("App update: ignoring malformed latest release tag '$tagName'") + } + + if (latestVersion <= currentVersion) { + return null + } + + val apkAsset = selectApkAsset(assets) ?: return null + val checksumAsset = assets.firstOrNull { asset -> + asset.name == "${apkAsset.name}.sha256" && asset.browserDownloadUrl.isNotBlank() + } + + return AvailableUpdate( + version = latestVersion, + tagName = tagName, + title = name?.takeIf { it.isNotBlank() } ?: tagName, + releaseNotes = body, + apkAssetName = apkAsset.name, + apkDownloadUrl = apkAsset.browserDownloadUrl, + apkSizeBytes = apkAsset.size, + checksumDownloadUrl = checksumAsset?.browserDownloadUrl, + releasePageUrl = htmlUrl, + ) + } + + private fun selectApkAsset(assets: List): GitHubReleaseAssetResponse? { + val apkAssets = assets.filter { asset -> + asset.name.endsWith(APK_EXTENSION, ignoreCase = true) && asset.browserDownloadUrl.isNotBlank() + } + + return apkAssets.firstOrNull { asset -> + asset.contentType.equals(APK_CONTENT_TYPE, ignoreCase = true) + } ?: apkAssets.firstOrNull() + } + + private companion object { + const val GITHUB_OWNER = "rovkinmax" + const val GITHUB_REPO = "Puber" + const val APK_EXTENSION = ".apk" + const val APK_CONTENT_TYPE = "application/vnd.android.package-archive" + } +} diff --git a/app/src/main/java/com/kino/puber/data/repository/AppVersion.kt b/app/src/main/java/com/kino/puber/data/repository/AppVersion.kt new file mode 100644 index 0000000..32d442a --- /dev/null +++ b/app/src/main/java/com/kino/puber/data/repository/AppVersion.kt @@ -0,0 +1,46 @@ +package com.kino.puber.data.repository + +internal data class AppVersion( + val major: Int, + val minor: Int, + val patch: Int, +) : Comparable { + + override fun compareTo(other: AppVersion): Int { + return compareValuesBy(this, other, AppVersion::major, AppVersion::minor, AppVersion::patch) + } + + override fun toString(): String { + return "$major.$minor.$patch" + } + + companion object { + fun parse(raw: String): AppVersion? { + val normalized = raw + .trim() + .let { version -> if (version.startsWith("v", ignoreCase = true)) version.drop(1) else version } + .substringBefore('-') + + val parts = normalized.split('.') + if (parts.size != VERSION_PARTS_COUNT) { + return null + } + + val versionParts = parts.map { part -> + if (part.isEmpty() || part.any { !it.isDigit() }) { + return null + } + + part.toIntOrNull() ?: return null + } + + return AppVersion( + major = versionParts[0], + minor = versionParts[1], + patch = versionParts[2], + ) + } + + private const val VERSION_PARTS_COUNT = 3 + } +} diff --git a/app/src/main/java/com/kino/puber/data/repository/IAppUpdateRepository.kt b/app/src/main/java/com/kino/puber/data/repository/IAppUpdateRepository.kt new file mode 100644 index 0000000..b985c2e --- /dev/null +++ b/app/src/main/java/com/kino/puber/data/repository/IAppUpdateRepository.kt @@ -0,0 +1,6 @@ +package com.kino.puber.data.repository + +internal interface IAppUpdateRepository { + + suspend fun getAvailableUpdate(currentVersionName: String): Result +} diff --git a/app/src/main/java/com/kino/puber/domain/di/modules.kt b/app/src/main/java/com/kino/puber/domain/di/modules.kt index 3839368..e3d4d7a 100644 --- a/app/src/main/java/com/kino/puber/domain/di/modules.kt +++ b/app/src/main/java/com/kino/puber/domain/di/modules.kt @@ -10,11 +10,14 @@ import com.kino.puber.domain.interactor.device.DeviceSettingInteractor import com.kino.puber.domain.interactor.device.IDeviceInfoInteractor import com.kino.puber.domain.interactor.device.IDeviceSettingInteractor import com.kino.puber.domain.interactor.genre.GenreInteractor +import com.kino.puber.domain.interactor.update.AppUpdateInteractor +import com.kino.puber.domain.interactor.update.IAppUpdateInteractor import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val interactorModule = module { + singleOf(::AppUpdateInteractor) { bind() } singleOf(::AuthInteractor) { bind() } singleOf(::DeviceInfoInteractor) { bind() } singleOf(::DeviceSettingInteractor) { bind() } diff --git a/app/src/main/java/com/kino/puber/domain/interactor/update/AppUpdateInteractor.kt b/app/src/main/java/com/kino/puber/domain/interactor/update/AppUpdateInteractor.kt new file mode 100644 index 0000000..77c0c4c --- /dev/null +++ b/app/src/main/java/com/kino/puber/domain/interactor/update/AppUpdateInteractor.kt @@ -0,0 +1,53 @@ +package com.kino.puber.domain.interactor.update + +import com.kino.puber.data.repository.AppUpdateDownload +import com.kino.puber.data.repository.AppUpdateDownloader +import com.kino.puber.data.repository.AppUpdateInstaller +import com.kino.puber.data.repository.AppUpdatePreferencesRepository +import com.kino.puber.data.repository.AvailableUpdate +import com.kino.puber.data.repository.IAppUpdateRepository +import com.kino.puber.data.repository.InstallLaunchResult +import java.io.File + +internal class AppUpdateInteractor( + private val updateRepository: IAppUpdateRepository, + private val preferencesRepository: AppUpdatePreferencesRepository, + private val updateDownloader: AppUpdateDownloader, + private val updateInstaller: AppUpdateInstaller, +) : IAppUpdateInteractor { + + override suspend fun checkForUpdate(currentVersionName: String): Result { + if (!preferencesRepository.autoUpdateCheckEnabled) { + return Result.success(null) + } + + return updateRepository.getAvailableUpdate(currentVersionName) + } + + override fun isAutoCheckEnabled(): Boolean { + return preferencesRepository.autoUpdateCheckEnabled + } + + override fun setAutoCheckEnabled(enabled: Boolean) { + preferencesRepository.autoUpdateCheckEnabled = enabled + } + + override suspend fun downloadUpdate( + update: AvailableUpdate, + onProgress: (AppUpdateDownload.Progress) -> Unit, + ): AppUpdateDownload { + return updateDownloader.download(update, onProgress) + } + + override fun canRequestPackageInstalls(): Boolean { + return updateInstaller.canRequestPackageInstalls() + } + + override fun openInstallPermissionSettings() { + updateInstaller.openInstallPermissionSettings() + } + + override fun launchInstaller(file: File): InstallLaunchResult { + return updateInstaller.launchInstaller(file) + } +} diff --git a/app/src/main/java/com/kino/puber/domain/interactor/update/IAppUpdateInteractor.kt b/app/src/main/java/com/kino/puber/domain/interactor/update/IAppUpdateInteractor.kt new file mode 100644 index 0000000..19e974e --- /dev/null +++ b/app/src/main/java/com/kino/puber/domain/interactor/update/IAppUpdateInteractor.kt @@ -0,0 +1,26 @@ +package com.kino.puber.domain.interactor.update + +import com.kino.puber.data.repository.AppUpdateDownload +import com.kino.puber.data.repository.AvailableUpdate +import com.kino.puber.data.repository.InstallLaunchResult +import java.io.File + +internal interface IAppUpdateInteractor { + + suspend fun checkForUpdate(currentVersionName: String): Result + + fun isAutoCheckEnabled(): Boolean + + fun setAutoCheckEnabled(enabled: Boolean) + + suspend fun downloadUpdate( + update: AvailableUpdate, + onProgress: (AppUpdateDownload.Progress) -> Unit, + ): AppUpdateDownload + + fun canRequestPackageInstalls(): Boolean + + fun openInstallPermissionSettings() + + fun launchInstaller(file: File): InstallLaunchResult +} diff --git a/app/src/main/java/com/kino/puber/ui/feature/device/settings/DeviceSettingsContent.kt b/app/src/main/java/com/kino/puber/ui/feature/device/settings/DeviceSettingsContent.kt index c3711c0..0001eb6 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/device/settings/DeviceSettingsContent.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/device/settings/DeviceSettingsContent.kt @@ -283,6 +283,26 @@ private fun DeviceSettingsList( ) } + // App updates section + item { + Spacer(modifier = Modifier.height(16.dp)) + } + item { + Text( + text = stringResource(R.string.settings_updates_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + item { + LocalToggleItem( + label = stringResource(R.string.settings_auto_update_check), + description = stringResource(R.string.settings_auto_update_check_subtitle), + checked = state.autoUpdateCheckEnabled, + onToggle = { onAction(DeviceSettingsActions.ToggleAutoUpdateCheck) }, + ) + } + // Debug section item { Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/java/com/kino/puber/ui/feature/device/settings/DeviceSettingsPreview.kt b/app/src/main/java/com/kino/puber/ui/feature/device/settings/DeviceSettingsPreview.kt index d9c1d0e..c0174eb 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/device/settings/DeviceSettingsPreview.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/device/settings/DeviceSettingsPreview.kt @@ -167,6 +167,19 @@ private fun SuccessSavingTogglePreview() = PuberTheme { ) } +@Preview(name = "Success — updates disabled", device = TV_1080p) +@Composable +private fun SuccessUpdatesDisabledPreview() = PuberTheme { + DeviceSettingsContent( + state = DeviceSettingsState.Success( + settings = previewAllSettings, + device = previewDevice, + autoUpdateCheckEnabled = false, + ), + apiDomain = previewApiDomain, + ) +} + @Preview(name = "Success — unsupported items", device = TV_1080p) @Composable private fun UnsupportedPreview() = PuberTheme { diff --git a/app/src/main/java/com/kino/puber/ui/feature/device/settings/model/DeviceSettingsActions.kt b/app/src/main/java/com/kino/puber/ui/feature/device/settings/model/DeviceSettingsActions.kt index 69ac2d9..7605396 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/device/settings/model/DeviceSettingsActions.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/device/settings/model/DeviceSettingsActions.kt @@ -17,6 +17,7 @@ internal sealed class DeviceSettingsActions : UIAction { data object ToggleSurroundAudio : DeviceSettingsActions() data object ToggleWatchedIndicators : DeviceSettingsActions() data class ChangeNavigationMode(val mode: NavigationMode) : DeviceSettingsActions() + data object ToggleAutoUpdateCheck : DeviceSettingsActions() data object OpenApiDomainDialog : DeviceSettingsActions() data object CloseApiDomainDialog : DeviceSettingsActions() data class SaveApiDomain(val domain: String) : DeviceSettingsActions() diff --git a/app/src/main/java/com/kino/puber/ui/feature/device/settings/model/DeviceSettingsViewState.kt b/app/src/main/java/com/kino/puber/ui/feature/device/settings/model/DeviceSettingsViewState.kt index ffc3b83..cc8af73 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/device/settings/model/DeviceSettingsViewState.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/device/settings/model/DeviceSettingsViewState.kt @@ -26,5 +26,6 @@ internal sealed interface DeviceSettingsState { val preferSurroundAudio: Boolean = false, val watchedIndicatorsEnabled: Boolean = true, val navigationMode: NavigationMode = NavigationMode.TopTabs, + val autoUpdateCheckEnabled: Boolean = true, ) : DeviceSettingsState } diff --git a/app/src/main/java/com/kino/puber/ui/feature/device/settings/vm/DeviceSettingsVM.kt b/app/src/main/java/com/kino/puber/ui/feature/device/settings/vm/DeviceSettingsVM.kt index 52c2c0f..dd2d718 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/device/settings/vm/DeviceSettingsVM.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/device/settings/vm/DeviceSettingsVM.kt @@ -19,6 +19,7 @@ import com.kino.puber.domain.interactor.api.ApiDomainUpdateResult import com.kino.puber.domain.interactor.device.DeviceSettingType import com.kino.puber.domain.interactor.device.IDeviceInfoInteractor import com.kino.puber.domain.interactor.device.IDeviceSettingInteractor +import com.kino.puber.domain.interactor.update.IAppUpdateInteractor import com.kino.puber.ui.feature.device.settings.mappers.DeviceCapabilities import com.kino.puber.ui.feature.device.settings.mappers.DeviceUiSettingsMapper import com.kino.puber.ui.feature.device.settings.model.DeviceSettingUIModel @@ -34,6 +35,7 @@ internal class DeviceSettingsVM( private val playerPreferencesRepository: PlayerPreferencesRepository, private val navigationPreferencesRepository: NavigationPreferencesRepository, private val apiDomainInteractor: ApiDomainInteractor, + private val appUpdateInteractor: IAppUpdateInteractor, override val errorHandler: ErrorHandler, private val resources: ResourceProvider, router: AppRouter, @@ -75,6 +77,7 @@ internal class DeviceSettingsVM( preferSurroundAudio = playerPreferencesRepository.preferSurroundAudio, watchedIndicatorsEnabled = playerPreferencesRepository.watchedIndicatorsEnabled, navigationMode = navigationPreferencesRepository.getNavigationMode(), + autoUpdateCheckEnabled = appUpdateInteractor.isAutoCheckEnabled(), ) ) ) @@ -100,6 +103,7 @@ internal class DeviceSettingsVM( DeviceSettingsActions.ToggleSurroundAudio -> toggleSurroundAudio() DeviceSettingsActions.ToggleWatchedIndicators -> toggleWatchedIndicators() is DeviceSettingsActions.ChangeNavigationMode -> onChangeNavigationMode(action.mode) + DeviceSettingsActions.ToggleAutoUpdateCheck -> toggleAutoUpdateCheck() DeviceSettingsActions.OpenApiDomainDialog -> openApiDomainDialog() DeviceSettingsActions.CloseApiDomainDialog -> closeApiDomainDialog() is DeviceSettingsActions.SaveApiDomain -> saveApiDomain(action.domain) @@ -274,6 +278,14 @@ internal class DeviceSettingsVM( showMessage(resources.getString(R.string.device_settings_restart_required)) } + private fun toggleAutoUpdateCheck() { + val currentState = stateValue.state + if (currentState !is DeviceSettingsState.Success) return + val newValue = !currentState.autoUpdateCheckEnabled + appUpdateInteractor.setAutoCheckEnabled(newValue) + updateViewState(stateValue.copy(state = currentState.copy(autoUpdateCheckEnabled = newValue))) + } + private fun onUnlinkDevice() { //todo } diff --git a/app/src/main/java/com/kino/puber/ui/feature/root/component/App.kt b/app/src/main/java/com/kino/puber/ui/feature/root/component/App.kt index 590e6ff..5863b59 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/root/component/App.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/root/component/App.kt @@ -3,11 +3,17 @@ package com.kino.puber.ui.feature.root.component import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.lifecycle.Lifecycle +import com.kino.puber.core.di.LocalPuberKoinScope +import com.kino.puber.core.di.puberViewModel import androidx.tv.material3.Surface import com.kino.puber.core.session.SessionEvent import com.kino.puber.core.session.SessionEventBus +import com.kino.puber.core.ui.uikit.component.LifecycleAction import com.kino.puber.core.ui.model.VideoItemTypeMapper import com.kino.puber.core.ui.model.VideoItemUIMapper import com.kino.puber.core.ui.navigation.AppLauncher @@ -17,9 +23,12 @@ import com.kino.puber.core.ui.navigation.component.FlowComponent import com.kino.puber.core.ui.uikit.theme.PuberTheme import com.kino.puber.core.ui.navigation.AppRouter import com.kino.puber.ui.ScreensImpl -import com.kino.puber.core.di.LocalPuberKoinScope +import com.kino.puber.ui.feature.update.component.UpdatePromptOverlay +import com.kino.puber.ui.feature.update.model.UpdatePromptAction +import com.kino.puber.ui.feature.update.vm.UpdatePromptVM import org.koin.core.module.Module import org.koin.core.module.dsl.scopedOf +import org.koin.core.module.dsl.viewModelOf import org.koin.core.qualifier.named import org.koin.core.scope.ScopeID import org.koin.dsl.module @@ -34,6 +43,7 @@ private fun buildFlowModule( scoped { ScreensImpl } scoped { VideoItemUIMapper(get(), get()) } scopedOf(::VideoItemTypeMapper) + viewModelOf(::UpdatePromptVM) } } @@ -52,6 +62,23 @@ private fun SessionExpiredHandler() { } } +@Composable +private fun UpdatePromptHost() { + val vm = puberViewModel() + val state by vm.collectViewState() + val onAction = remember(vm) { vm::onAction } + + LifecycleAction( + event = Lifecycle.Event.ON_RESUME, + onAction = onAction, + action = UpdatePromptAction.OnResume, + ) + UpdatePromptOverlay( + state = state, + onAction = onAction, + ) +} + @Composable fun App() { val appLauncher = AppLauncherImpl.rememberAppLauncher() @@ -71,6 +98,7 @@ fun App() { }, ) { SessionExpiredHandler() + UpdatePromptHost() } } } diff --git a/app/src/main/java/com/kino/puber/ui/feature/update/component/UpdatePromptOverlay.kt b/app/src/main/java/com/kino/puber/ui/feature/update/component/UpdatePromptOverlay.kt new file mode 100644 index 0000000..124de3a --- /dev/null +++ b/app/src/main/java/com/kino/puber/ui/feature/update/component/UpdatePromptOverlay.kt @@ -0,0 +1,251 @@ +package com.kino.puber.ui.feature.update.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kino.puber.R +import com.kino.puber.core.ui.uikit.component.TvDialogOverlay +import com.kino.puber.core.ui.uikit.component.TvSafeButton +import com.kino.puber.core.ui.uikit.model.UIAction +import com.kino.puber.data.repository.AvailableUpdate +import com.kino.puber.ui.feature.update.model.UpdatePromptAction +import com.kino.puber.ui.feature.update.model.UpdatePromptViewState +import kotlinx.coroutines.delay + +private val DialogWidth = 820.dp +private val DialogPadding = 24.dp +private val DialogCornerRadius = 18.dp +private val ContentSpacing = 16.dp +private val NotesMaxHeight = 180.dp +private const val FocusDelayMs = 100L + +@Composable +internal fun UpdatePromptOverlay( + state: UpdatePromptViewState, + onAction: (UIAction) -> Unit, + modifier: Modifier = Modifier, +) { + if (state == UpdatePromptViewState.Hidden) { + return + } + + val primaryFocusRequester = remember { FocusRequester() } + LaunchedEffect(state) { + delay(FocusDelayMs) + runCatching { primaryFocusRequester.requestFocus() } + } + + TvDialogOverlay(onDismiss = { onAction(UpdatePromptAction.DismissClicked) }) { + Card( + modifier = modifier + .width(DialogWidth) + .padding(24.dp), + shape = RoundedCornerShape(DialogCornerRadius), + ) { + Column( + modifier = Modifier.padding(DialogPadding), + verticalArrangement = Arrangement.spacedBy(ContentSpacing), + ) { + when (state) { + is UpdatePromptViewState.Available -> AvailableContent( + update = state.update, + primaryFocusRequester = primaryFocusRequester, + onAction = onAction, + ) + is UpdatePromptViewState.Downloading -> DownloadingContent(state) + is UpdatePromptViewState.PermissionRequired -> PermissionRequiredContent( + primaryFocusRequester = primaryFocusRequester, + onAction = onAction, + ) + is UpdatePromptViewState.Error -> ErrorContent( + state = state, + primaryFocusRequester = primaryFocusRequester, + onAction = onAction, + ) + UpdatePromptViewState.Hidden -> Unit + } + } + } + } +} + +@Composable +private fun AvailableContent( + update: AvailableUpdate, + primaryFocusRequester: FocusRequester, + onAction: (UIAction) -> Unit, +) { + Header( + title = stringResource(R.string.update_prompt_title), + version = update.version.toString(), + ) + ReleaseNotes(notes = update.releaseNotes) + ActionRow { + TvSafeButton( + text = stringResource(R.string.update_prompt_update), + onClick = { onAction(UpdatePromptAction.UpdateClicked) }, + primary = true, + modifier = Modifier.focusRequester(primaryFocusRequester), + ) + TvSafeButton( + text = stringResource(R.string.update_prompt_not_now), + onClick = { onAction(UpdatePromptAction.DismissClicked) }, + ) + } +} + +@Composable +private fun DownloadingContent(state: UpdatePromptViewState.Downloading) { + Header( + title = stringResource(R.string.update_prompt_title), + version = state.update.version.toString(), + ) + Text( + text = stringResource(R.string.update_prompt_downloading, state.progressPercent), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + LinearProgressIndicator( + progress = { state.progressPercent.coerceIn(0, 100) / 100f }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + ) +} + +@Composable +private fun PermissionRequiredContent( + primaryFocusRequester: FocusRequester, + onAction: (UIAction) -> Unit, +) { + Text( + text = stringResource(R.string.update_prompt_permission_required_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.update_prompt_permission_required), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ActionRow { + TvSafeButton( + text = stringResource(R.string.update_prompt_open_settings), + onClick = { onAction(UpdatePromptAction.OpenInstallPermissionSettingsClicked) }, + primary = true, + modifier = Modifier.focusRequester(primaryFocusRequester), + ) + TvSafeButton( + text = stringResource(R.string.update_prompt_retry_install), + onClick = { onAction(UpdatePromptAction.RetryInstallClicked) }, + ) + TvSafeButton( + text = stringResource(R.string.update_prompt_not_now), + onClick = { onAction(UpdatePromptAction.DismissClicked) }, + ) + } +} + +@Composable +private fun ErrorContent( + state: UpdatePromptViewState.Error, + primaryFocusRequester: FocusRequester, + onAction: (UIAction) -> Unit, +) { + Text( + text = stringResource(R.string.update_prompt_error_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = state.message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ActionRow { + TvSafeButton( + text = stringResource( + if (state.canRetryInstall) { + R.string.update_prompt_retry_install + } else { + R.string.update_prompt_retry + } + ), + onClick = { onAction(UpdatePromptAction.RetryInstallClicked) }, + primary = true, + modifier = Modifier.focusRequester(primaryFocusRequester), + ) + TvSafeButton( + text = stringResource(R.string.update_prompt_not_now), + onClick = { onAction(UpdatePromptAction.DismissClicked) }, + ) + } +} + +@Composable +private fun Header(title: String, version: String) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.update_prompt_version, version), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun ReleaseNotes(notes: String?) { + val displayNotes = notes?.takeIf { it.isNotBlank() } ?: return + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.update_prompt_notes_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = displayNotes, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = NotesMaxHeight) + .verticalScroll(rememberScrollState()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ActionRow(content: @Composable () -> Unit) { + Column { + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + content() + } + } +} diff --git a/app/src/main/java/com/kino/puber/ui/feature/update/model/UpdatePromptAction.kt b/app/src/main/java/com/kino/puber/ui/feature/update/model/UpdatePromptAction.kt new file mode 100644 index 0000000..fc90165 --- /dev/null +++ b/app/src/main/java/com/kino/puber/ui/feature/update/model/UpdatePromptAction.kt @@ -0,0 +1,11 @@ +package com.kino.puber.ui.feature.update.model + +import com.kino.puber.core.ui.uikit.model.UIAction + +internal sealed interface UpdatePromptAction : UIAction { + data object UpdateClicked : UpdatePromptAction + data object DismissClicked : UpdatePromptAction + data object OpenInstallPermissionSettingsClicked : UpdatePromptAction + data object RetryInstallClicked : UpdatePromptAction + data object OnResume : UpdatePromptAction +} diff --git a/app/src/main/java/com/kino/puber/ui/feature/update/model/UpdatePromptViewState.kt b/app/src/main/java/com/kino/puber/ui/feature/update/model/UpdatePromptViewState.kt new file mode 100644 index 0000000..05d23f5 --- /dev/null +++ b/app/src/main/java/com/kino/puber/ui/feature/update/model/UpdatePromptViewState.kt @@ -0,0 +1,33 @@ +package com.kino.puber.ui.feature.update.model + +import androidx.compose.runtime.Immutable +import com.kino.puber.data.repository.AvailableUpdate +import java.io.File + +@Immutable +internal sealed class UpdatePromptViewState { + data object Hidden : UpdatePromptViewState() + + @Immutable + data class Available(val update: AvailableUpdate) : UpdatePromptViewState() + + @Immutable + data class Downloading( + val update: AvailableUpdate, + val progressPercent: Int, + ) : UpdatePromptViewState() + + @Immutable + data class PermissionRequired( + val update: AvailableUpdate, + val downloadedFile: File, + ) : UpdatePromptViewState() + + @Immutable + data class Error( + val update: AvailableUpdate?, + val downloadedFile: File?, + val message: String, + val canRetryInstall: Boolean, + ) : UpdatePromptViewState() +} diff --git a/app/src/main/java/com/kino/puber/ui/feature/update/vm/UpdatePromptVM.kt b/app/src/main/java/com/kino/puber/ui/feature/update/vm/UpdatePromptVM.kt new file mode 100644 index 0000000..657e82a --- /dev/null +++ b/app/src/main/java/com/kino/puber/ui/feature/update/vm/UpdatePromptVM.kt @@ -0,0 +1,251 @@ +package com.kino.puber.ui.feature.update.vm + +import com.kino.puber.BuildConfig +import com.kino.puber.R +import com.kino.puber.core.error.ErrorEntity +import com.kino.puber.core.error.ErrorHandler +import com.kino.puber.core.system.ResourceProvider +import com.kino.puber.core.ui.PuberVM +import com.kino.puber.core.ui.navigation.AppRouter +import com.kino.puber.core.ui.uikit.model.UIAction +import com.kino.puber.data.repository.AppUpdateDownload +import com.kino.puber.data.repository.AvailableUpdate +import com.kino.puber.data.repository.InstallLaunchResult +import com.kino.puber.domain.interactor.update.IAppUpdateInteractor +import com.kino.puber.ui.feature.update.model.UpdatePromptAction +import com.kino.puber.ui.feature.update.model.UpdatePromptViewState +import kotlinx.coroutines.Job +import java.io.File + +internal class UpdatePromptVM( + router: AppRouter, + override val errorHandler: ErrorHandler, + private val updateInteractor: IAppUpdateInteractor, + private val resources: ResourceProvider, +) : PuberVM(router) { + + override val initialViewState = UpdatePromptViewState.Hidden + + private var dismissedTagName: String? = null + private var downloadJob: Job? = null + private var downloadRequestId: Long = 0L + + override fun onStart() { + if (!updateInteractor.isAutoCheckEnabled()) { + return + } + + launch { + updateInteractor.checkForUpdate(BuildConfig.VERSION_NAME) + .onSuccess { update -> + if (update != null && update.tagName != dismissedTagName) { + updateViewState(UpdatePromptViewState.Available(update)) + } + } + // Automatic check failures are intentionally silent. + } + } + + override fun onAction(action: UIAction) { + when (action) { + UpdatePromptAction.UpdateClicked -> startDownloadFromCurrentState() + UpdatePromptAction.DismissClicked -> dismiss() + UpdatePromptAction.OpenInstallPermissionSettingsClicked -> openInstallPermissionSettings() + UpdatePromptAction.RetryInstallClicked -> retryFromCurrentState() + UpdatePromptAction.OnResume -> retryInstallerAfterPermission() + else -> super.onAction(action) + } + } + + override fun onBackPressed() { + if (stateValue != UpdatePromptViewState.Hidden) { + dismiss() + } else if (!router.dispatchBackPressed()) { + router.back() + } + } + + override fun dispatchError(error: ErrorEntity) { + val message = error.message.ifBlank { + resources.getString(R.string.update_prompt_install_failed) + } + when (val state = stateValue) { + is UpdatePromptViewState.Available -> { + updateViewState( + UpdatePromptViewState.Error( + update = state.update, + downloadedFile = null, + message = message, + canRetryInstall = false, + ) + ) + } + is UpdatePromptViewState.Downloading -> { + updateViewState( + UpdatePromptViewState.Error( + update = state.update, + downloadedFile = null, + message = message, + canRetryInstall = false, + ) + ) + } + is UpdatePromptViewState.PermissionRequired -> { + updateViewState( + UpdatePromptViewState.Error( + update = state.update, + downloadedFile = state.downloadedFile, + message = message, + canRetryInstall = true, + ) + ) + } + is UpdatePromptViewState.Error -> { + updateViewState(state.copy(message = message)) + } + UpdatePromptViewState.Hidden -> Unit + } + } + + private fun startDownloadFromCurrentState() { + val update = when (val state = stateValue) { + is UpdatePromptViewState.Available -> state.update + is UpdatePromptViewState.Error -> state.update + else -> null + } ?: return + + startDownload(update) + } + + private fun startDownload(update: AvailableUpdate) { + downloadJob?.cancel() + val currentDownloadRequestId = ++downloadRequestId + updateViewState(UpdatePromptViewState.Downloading(update = update, progressPercent = 0)) + downloadJob = launch { + val result = updateInteractor.downloadUpdate(update) { progress -> + if (isActiveDownload(currentDownloadRequestId, update)) { + updateViewState( + UpdatePromptViewState.Downloading( + update = update, + progressPercent = progress.percent, + ) + ) + } + } + + if (!isActiveDownload(currentDownloadRequestId, update)) { + return@launch + } + + when (result) { + is AppUpdateDownload.Completed -> handleInstallLaunch(update, result.file) + is AppUpdateDownload.Progress -> { + updateViewState(UpdatePromptViewState.Downloading(update, result.percent)) + } + AppUpdateDownload.Error.StorageUnavailable -> showDownloadError( + update = update, + messageRes = R.string.update_prompt_storage_unavailable, + ) + is AppUpdateDownload.Error.DownloadFailed, + is AppUpdateDownload.Error.ChecksumDownloadFailed, + -> showDownloadError( + update = update, + messageRes = R.string.update_prompt_download_failed, + ) + AppUpdateDownload.Error.InvalidChecksum, + is AppUpdateDownload.Error.ChecksumMismatch, + -> showDownloadError( + update = update, + messageRes = R.string.update_prompt_checksum_failed, + ) + } + } + } + + private fun isActiveDownload(requestId: Long, update: AvailableUpdate): Boolean { + if (downloadRequestId != requestId) { + return false + } + + val state = stateValue as? UpdatePromptViewState.Downloading ?: return false + return state.update.tagName == update.tagName + } + + private fun showDownloadError(update: AvailableUpdate, messageRes: Int) { + updateViewState( + UpdatePromptViewState.Error( + update = update, + downloadedFile = null, + message = resources.getString(messageRes), + canRetryInstall = false, + ) + ) + } + + private fun handleInstallLaunch(update: AvailableUpdate, file: File) { + when (updateInteractor.launchInstaller(file)) { + InstallLaunchResult.Launched -> updateViewState(UpdatePromptViewState.Hidden) + InstallLaunchResult.PermissionRequired -> { + updateViewState(UpdatePromptViewState.PermissionRequired(update = update, downloadedFile = file)) + } + is InstallLaunchResult.Failed -> showInstallError(update, file) + } + } + + private fun openInstallPermissionSettings() { + val state = stateValue as? UpdatePromptViewState.PermissionRequired ?: return + runCatching { + updateInteractor.openInstallPermissionSettings() + }.onFailure { + showInstallError(update = state.update, file = state.downloadedFile) + } + } + + private fun retryFromCurrentState() { + when (val state = stateValue) { + is UpdatePromptViewState.PermissionRequired -> handleInstallLaunch(state.update, state.downloadedFile) + is UpdatePromptViewState.Error -> { + val file = state.downloadedFile + val update = state.update + when { + file != null && update != null -> handleInstallLaunch(update, file) + update != null -> startDownload(update) + } + } + else -> Unit + } + } + + private fun retryInstallerAfterPermission() { + val state = stateValue as? UpdatePromptViewState.PermissionRequired ?: return + if (updateInteractor.canRequestPackageInstalls()) { + handleInstallLaunch(update = state.update, file = state.downloadedFile) + } + } + + private fun dismiss() { + downloadRequestId++ + downloadJob?.cancel() + downloadJob = null + + when (val state = stateValue) { + is UpdatePromptViewState.Available -> dismissedTagName = state.update.tagName + is UpdatePromptViewState.Downloading -> dismissedTagName = state.update.tagName + is UpdatePromptViewState.PermissionRequired -> dismissedTagName = state.update.tagName + is UpdatePromptViewState.Error -> dismissedTagName = state.update?.tagName + UpdatePromptViewState.Hidden -> Unit + } + updateViewState(UpdatePromptViewState.Hidden) + } + + private fun showInstallError(update: AvailableUpdate, file: File) { + updateViewState( + UpdatePromptViewState.Error( + update = update, + downloadedFile = file, + message = resources.getString(R.string.update_prompt_install_failed), + canRetryInstall = true, + ) + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f496f0c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + Обновления + Автоматически проверять обновления + Puber будет искать новую версию при запуске приложения. + Доступна новая версия + Версия: %1$s + Что нового + Обновить + Не сейчас + Загружаю обновление… %1$d%% + Разреши установку обновления + Android требует разрешить установку приложений из Puber. Открой настройки, включи разрешение и вернись в приложение. + Открыть настройки + Повторить установку + Повторить + Не удалось обновить + Не удалось скачать обновление. Проверь подключение и попробуй еще раз. + Проверка файла обновления не прошла. Попробуй скачать обновление заново. + Не удалось открыть установщик Android. Попробуй еще раз. + Нет доступа к папке загрузки обновления. Проверь хранилище устройства и попробуй еще раз. + diff --git a/app/src/main/res/xml/update_file_paths.xml b/app/src/main/res/xml/update_file_paths.xml new file mode 100644 index 0000000..3b43ae1 --- /dev/null +++ b/app/src/main/res/xml/update_file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/test/kotlin/com/kino/puber/data/repository/AppUpdateDownloadTest.kt b/app/src/test/kotlin/com/kino/puber/data/repository/AppUpdateDownloadTest.kt new file mode 100644 index 0000000..3822cb0 --- /dev/null +++ b/app/src/test/kotlin/com/kino/puber/data/repository/AppUpdateDownloadTest.kt @@ -0,0 +1,73 @@ +package com.kino.puber.data.repository + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class AppUpdateDownloadTest { + + @TempDir + lateinit var tempDir: File + + @Test + fun parseSha256_returnsHash_whenChecksumUsesShasumFormat() { + val result = AppUpdateChecksum.parseSha256("$HELLO_SHA256 puber-v1.5.0.apk\n") + + assertEquals(HELLO_SHA256, result) + } + + @Test + fun parseSha256_returnsHash_whenChecksumContainsRawHash() { + val result = AppUpdateChecksum.parseSha256(HELLO_SHA256.uppercase()) + + assertEquals(HELLO_SHA256, result) + } + + @Test + fun parseSha256_returnsNull_whenChecksumDoesNotContainSha256() { + val result = AppUpdateChecksum.parseSha256("not-a-checksum puber.apk") + + assertNull(result) + } + + @Test + fun verifySha256_returnsMatch_whenFileMatchesChecksum() { + val file = File(tempDir, "puber.apk").apply { + writeText("hello") + } + + val result = AppUpdateChecksum.verifySha256(file, "$HELLO_SHA256 puber.apk") + + assertEquals(AppUpdateChecksumVerification.Match, result) + } + + @Test + fun verifySha256_returnsMismatch_whenFileDoesNotMatchChecksum() { + val file = File(tempDir, "puber.apk").apply { + writeText("different") + } + + val result = AppUpdateChecksum.verifySha256(file, "$HELLO_SHA256 puber.apk") + + assertTrue(result is AppUpdateChecksumVerification.Mismatch) + assertEquals(HELLO_SHA256, (result as AppUpdateChecksumVerification.Mismatch).expected) + } + + @Test + fun verifySha256_returnsInvalidChecksum_whenChecksumCannotBeParsed() { + val file = File(tempDir, "puber.apk").apply { + writeText("hello") + } + + val result = AppUpdateChecksum.verifySha256(file, "invalid") + + assertEquals(AppUpdateChecksumVerification.InvalidChecksum, result) + } + + private companion object { + const val HELLO_SHA256 = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + } +} diff --git a/app/src/test/kotlin/com/kino/puber/data/repository/AppUpdateRepositoryTest.kt b/app/src/test/kotlin/com/kino/puber/data/repository/AppUpdateRepositoryTest.kt new file mode 100644 index 0000000..f0d9831 --- /dev/null +++ b/app/src/test/kotlin/com/kino/puber/data/repository/AppUpdateRepositoryTest.kt @@ -0,0 +1,164 @@ +package com.kino.puber.data.repository + +import com.kino.puber.data.api.KinoPubApiClient +import com.kino.puber.data.api.models.GitHubReleaseAssetResponse +import com.kino.puber.data.api.models.GitHubReleaseResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.IOException + +class AppUpdateRepositoryTest { + + private val apiClient = mockk() + private val repository = AppUpdateRepository(apiClient) + + @Test + fun getAvailableUpdate_returnsUpdate_whenLatestReleaseIsNewer() = runTest { + val apkAsset = releaseAsset( + name = "puber-v1.5.0.apk", + browserDownloadUrl = "https://github.com/rovkinmax/Puber/releases/download/v1.5.0/puber-v1.5.0.apk", + size = 42_000_000, + contentType = "application/vnd.android.package-archive", + ) + val checksumAsset = releaseAsset( + name = "puber-v1.5.0.apk.sha256", + browserDownloadUrl = "https://github.com/rovkinmax/Puber/releases/download/v1.5.0/puber-v1.5.0.apk.sha256", + ) + coEvery { apiClient.getLatestGitHubRelease("rovkinmax", "Puber") } returns Result.success( + release( + tagName = "v1.5.0", + name = "Puber 1.5.0", + body = "Release notes", + htmlUrl = "https://github.com/rovkinmax/Puber/releases/tag/v1.5.0", + assets = listOf(apkAsset, checksumAsset), + ) + ) + + val result = repository.getAvailableUpdate(currentVersionName = "1.4.0") + + val update = result.getOrThrow() + assertEquals(AppVersion(major = 1, minor = 5, patch = 0), update?.version) + assertEquals("v1.5.0", update?.tagName) + assertEquals("Puber 1.5.0", update?.title) + assertEquals("Release notes", update?.releaseNotes) + assertEquals(apkAsset.name, update?.apkAssetName) + assertEquals(apkAsset.browserDownloadUrl, update?.apkDownloadUrl) + assertEquals(apkAsset.size, update?.apkSizeBytes) + assertEquals(checksumAsset.browserDownloadUrl, update?.checksumDownloadUrl) + assertEquals("https://github.com/rovkinmax/Puber/releases/tag/v1.5.0", update?.releasePageUrl) + } + + @Test + fun getAvailableUpdate_prefersApkAssetWithPackageArchiveContentType() = runTest { + val genericApkAsset = releaseAsset( + name = "generic.apk", + browserDownloadUrl = "https://example.com/generic.apk", + contentType = "application/octet-stream", + ) + val packageApkAsset = releaseAsset( + name = "package.apk", + browserDownloadUrl = "https://example.com/package.apk", + contentType = "application/vnd.android.package-archive", + ) + coEvery { apiClient.getLatestGitHubRelease("rovkinmax", "Puber") } returns Result.success( + release(tagName = "1.5.0", assets = listOf(genericApkAsset, packageApkAsset)) + ) + + val result = repository.getAvailableUpdate(currentVersionName = "1.4.0") + + assertEquals("package.apk", result.getOrThrow()?.apkAssetName) + } + + @Test + fun getAvailableUpdate_returnsNull_whenLatestReleaseIsNotNewer() = runTest { + coEvery { apiClient.getLatestGitHubRelease("rovkinmax", "Puber") } returns Result.success( + release(tagName = "1.4.0", assets = listOf(releaseAsset(name = "puber.apk"))) + ) + + val equalResult = repository.getAvailableUpdate(currentVersionName = "1.4.0") + + assertNull(equalResult.getOrThrow()) + + coEvery { apiClient.getLatestGitHubRelease("rovkinmax", "Puber") } returns Result.success( + release(tagName = "1.3.9", assets = listOf(releaseAsset(name = "puber.apk"))) + ) + + val olderResult = repository.getAvailableUpdate(currentVersionName = "1.4.0") + + assertNull(olderResult.getOrThrow()) + } + + @Test + fun getAvailableUpdate_returnsNull_whenReleaseCannotBeUsed() = runTest { + val unusableReleases = listOf( + release(tagName = "1.5.0", draft = true, assets = listOf(releaseAsset(name = "puber.apk"))), + release(tagName = "1.5.0", prerelease = true, assets = listOf(releaseAsset(name = "puber.apk"))), + release(tagName = "malformed", assets = listOf(releaseAsset(name = "puber.apk"))), + release(tagName = "1.5.0", assets = emptyList()), + release(tagName = "1.5.0", assets = listOf(releaseAsset(name = "puber.zip"))), + ) + + unusableReleases.forEach { release -> + coEvery { apiClient.getLatestGitHubRelease("rovkinmax", "Puber") } returns Result.success(release) + + val result = repository.getAvailableUpdate(currentVersionName = "1.4.0") + + assertNull(result.getOrThrow(), "Expected release '$release' to be ignored") + } + } + + @Test + fun getAvailableUpdate_returnsNullAndSkipsApiCall_whenCurrentVersionIsMalformed() = runTest { + val result = repository.getAvailableUpdate(currentVersionName = "dev-build") + + assertNull(result.getOrThrow()) + coVerify(exactly = 0) { apiClient.getLatestGitHubRelease(any(), any()) } + } + + @Test + fun getAvailableUpdate_returnsFailure_whenApiFails() = runTest { + val exception = IOException("Network error") + coEvery { apiClient.getLatestGitHubRelease("rovkinmax", "Puber") } returns Result.failure(exception) + + val result = repository.getAvailableUpdate(currentVersionName = "1.4.0") + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + private fun release( + tagName: String, + name: String? = null, + body: String? = null, + htmlUrl: String? = null, + draft: Boolean = false, + prerelease: Boolean = false, + assets: List = emptyList(), + ) = GitHubReleaseResponse( + tagName = tagName, + name = name, + body = body, + htmlUrl = htmlUrl, + draft = draft, + prerelease = prerelease, + assets = assets, + ) + + private fun releaseAsset( + name: String, + browserDownloadUrl: String = "https://example.com/$name", + size: Long? = null, + contentType: String? = null, + ) = GitHubReleaseAssetResponse( + name = name, + browserDownloadUrl = browserDownloadUrl, + size = size, + contentType = contentType, + ) +} diff --git a/app/src/test/kotlin/com/kino/puber/data/repository/AppVersionTest.kt b/app/src/test/kotlin/com/kino/puber/data/repository/AppVersionTest.kt new file mode 100644 index 0000000..5fe84da --- /dev/null +++ b/app/src/test/kotlin/com/kino/puber/data/repository/AppVersionTest.kt @@ -0,0 +1,85 @@ +package com.kino.puber.data.repository + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AppVersionTest { + + @Test + fun parse_returnsVersion_whenRawVersionHasThreeNumericComponents() { + val result = AppVersion.parse("1.4.0") + + assertEquals(AppVersion(major = 1, minor = 4, patch = 0), result) + } + + @Test + fun parse_returnsVersion_whenRawVersionHasLeadingLowercaseV() { + val result = AppVersion.parse("v1.4.1") + + assertEquals(AppVersion(major = 1, minor = 4, patch = 1), result) + } + + @Test + fun parse_returnsVersion_whenRawVersionHasLeadingUppercaseV() { + val result = AppVersion.parse("V2.0.3") + + assertEquals(AppVersion(major = 2, minor = 0, patch = 3), result) + } + + @Test + fun parse_returnsVersion_whenRawVersionHasSuffix() { + val result = AppVersion.parse("1.4.0-dev") + + assertEquals(AppVersion(major = 1, minor = 4, patch = 0), result) + } + + @Test + fun parse_returnsNull_whenRawVersionHasMalformedTags() { + val malformedTags = listOf( + "", + "v", + "1.4", + "1.4.0.1", + "1.x.0", + "1..0", + "1.4.-1", + "release-1.4.0", + "1.4.0+build", + ) + + malformedTags.forEach { raw -> + assertNull(AppVersion.parse(raw), "Expected '$raw' to be rejected") + } + } + + @Test + fun compareTo_ordersVersionsByMajorMinorAndPatch() { + val olderPatch = AppVersion(major = 1, minor = 4, patch = 0) + val newerPatch = AppVersion(major = 1, minor = 4, patch = 1) + val newerMinor = AppVersion(major = 1, minor = 5, patch = 0) + val newerMajor = AppVersion(major = 2, minor = 0, patch = 0) + + assertTrue(newerPatch > olderPatch) + assertTrue(newerMinor > newerPatch) + assertTrue(newerMajor > newerMinor) + assertFalse(olderPatch > newerMajor) + } + + @Test + fun equals_returnsTrue_whenVersionPartsMatch() { + assertEquals( + AppVersion(major = 1, minor = 4, patch = 0), + AppVersion(major = 1, minor = 4, patch = 0), + ) + } + + @Test + fun toString_returnsNormalizedSemanticVersion() { + val result = AppVersion.parse("v1.4.0-dev") + + assertEquals("1.4.0", result.toString()) + } +} diff --git a/app/src/test/kotlin/com/kino/puber/domain/interactor/update/AppUpdateInteractorTest.kt b/app/src/test/kotlin/com/kino/puber/domain/interactor/update/AppUpdateInteractorTest.kt new file mode 100644 index 0000000..b1f1d85 --- /dev/null +++ b/app/src/test/kotlin/com/kino/puber/domain/interactor/update/AppUpdateInteractorTest.kt @@ -0,0 +1,101 @@ +package com.kino.puber.domain.interactor.update + +import com.kino.puber.data.repository.AppUpdateDownload +import com.kino.puber.data.repository.AppUpdateDownloader +import com.kino.puber.data.repository.AppUpdateInstaller +import com.kino.puber.data.repository.AppUpdatePreferencesRepository +import com.kino.puber.data.repository.AppVersion +import com.kino.puber.data.repository.AvailableUpdate +import com.kino.puber.data.repository.IAppUpdateRepository +import com.kino.puber.data.repository.InstallLaunchResult +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.io.File + +class AppUpdateInteractorTest { + + private val updateRepository = mockk() + private val preferencesRepository = mockk() + private val updateDownloader = mockk() + private val updateInstaller = mockk() + private val interactor = AppUpdateInteractor( + updateRepository = updateRepository, + preferencesRepository = preferencesRepository, + updateDownloader = updateDownloader, + updateInstaller = updateInstaller, + ) + + @Test + fun checkForUpdate_returnsNullAndSkipsRepository_whenAutoCheckDisabled() = runTest { + every { preferencesRepository.autoUpdateCheckEnabled } returns false + + val result = interactor.checkForUpdate(currentVersionName = "1.4.0") + + assertNull(result.getOrThrow()) + coVerify(exactly = 0) { updateRepository.getAvailableUpdate(any()) } + } + + @Test + fun checkForUpdate_callsRepository_whenAutoCheckEnabled() = runTest { + val update = availableUpdate() + every { preferencesRepository.autoUpdateCheckEnabled } returns true + coEvery { updateRepository.getAvailableUpdate("1.4.0") } returns Result.success(update) + + val result = interactor.checkForUpdate(currentVersionName = "1.4.0") + + assertEquals(update, result.getOrThrow()) + coVerify(exactly = 1) { updateRepository.getAvailableUpdate("1.4.0") } + } + + @Test + fun setAutoCheckEnabled_persistsPreference() { + every { preferencesRepository.autoUpdateCheckEnabled = false } just Runs + + interactor.setAutoCheckEnabled(enabled = false) + + verify(exactly = 1) { preferencesRepository.autoUpdateCheckEnabled = false } + } + + @Test + fun downloadUpdate_delegatesToDownloader() = runTest { + val update = availableUpdate() + val completed = AppUpdateDownload.Completed(File("update.apk")) + coEvery { updateDownloader.download(update, any()) } returns completed + + val result = interactor.downloadUpdate(update) { } + + assertEquals(completed, result) + coVerify(exactly = 1) { updateDownloader.download(update, any()) } + } + + @Test + fun launchInstaller_delegatesToInstaller() { + val file = File("update.apk") + every { updateInstaller.launchInstaller(file) } returns InstallLaunchResult.PermissionRequired + + val result = interactor.launchInstaller(file) + + assertEquals(InstallLaunchResult.PermissionRequired, result) + } + + private fun availableUpdate() = AvailableUpdate( + version = AppVersion(major = 1, minor = 5, patch = 0), + tagName = "v1.5.0", + title = "Puber 1.5.0", + releaseNotes = "Release notes", + apkAssetName = "puber-v1.5.0.apk", + apkDownloadUrl = "https://example.com/puber-v1.5.0.apk", + apkSizeBytes = 42_000_000, + checksumDownloadUrl = "https://example.com/puber-v1.5.0.apk.sha256", + releasePageUrl = "https://github.com/rovkinmax/Puber/releases/tag/v1.5.0", + ) +} diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/update/vm/UpdatePromptVMTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/update/vm/UpdatePromptVMTest.kt new file mode 100644 index 0000000..72c58fb --- /dev/null +++ b/app/src/test/kotlin/com/kino/puber/ui/feature/update/vm/UpdatePromptVMTest.kt @@ -0,0 +1,214 @@ +package com.kino.puber.ui.feature.update.vm + +import com.kino.puber.core.error.ErrorHandler +import com.kino.puber.core.ui.navigation.AppRouter +import com.kino.puber.data.repository.AppUpdateDownload +import com.kino.puber.data.repository.AppVersion +import com.kino.puber.data.repository.AvailableUpdate +import com.kino.puber.data.repository.InstallLaunchResult +import com.kino.puber.domain.interactor.update.IAppUpdateInteractor +import com.kino.puber.ui.feature.update.model.UpdatePromptAction +import com.kino.puber.ui.feature.update.model.UpdatePromptViewState +import com.kino.puber.util.FakeResourceProvider +import com.kino.puber.util.MainDispatcherExtension +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import java.io.File +import java.io.IOException + +class UpdatePromptVMTest { + + companion object { + @JvmField + @RegisterExtension + val mainDispatcher = MainDispatcherExtension() + } + + private val router = mockk(relaxed = true) + private val errorHandler = mockk(relaxed = true) + private val updateInteractor = mockk(relaxUnitFun = true) + + private fun createVM(): UpdatePromptVM { + every { updateInteractor.isAutoCheckEnabled() } returns true + coEvery { updateInteractor.checkForUpdate(any()) } returns Result.success(null) + every { updateInteractor.canRequestPackageInstalls() } returns true + return UpdatePromptVM( + router = router, + errorHandler = errorHandler, + updateInteractor = updateInteractor, + resources = FakeResourceProvider(), + ) + } + + @Test + fun onStart_keepsHiddenAndSkipsCheck_whenAutoCheckDisabled() { + every { updateInteractor.isAutoCheckEnabled() } returns false + + val vm = UpdatePromptVM( + router = router, + errorHandler = errorHandler, + updateInteractor = updateInteractor, + resources = FakeResourceProvider(), + ) + vm.testOnStart() + + assertEquals(UpdatePromptViewState.Hidden, vm.testStateValue) + coVerify(exactly = 0) { updateInteractor.checkForUpdate(any()) } + } + + @Test + fun onStart_keepsHidden_whenNoUpdateExists() { + val vm = createVM() + + vm.testOnStart() + + assertEquals(UpdatePromptViewState.Hidden, vm.testStateValue) + } + + @Test + fun onStart_keepsHidden_whenAutomaticCheckFails() { + val vm = createVM() + coEvery { updateInteractor.checkForUpdate(any()) } returns Result.failure(IOException("Network error")) + + vm.testOnStart() + + assertEquals(UpdatePromptViewState.Hidden, vm.testStateValue) + } + + @Test + fun onStart_showsAvailable_whenUpdateExists() { + val update = availableUpdate() + val vm = createVM() + coEvery { updateInteractor.checkForUpdate(any()) } returns Result.success(update) + + vm.testOnStart() + + assertEquals(UpdatePromptViewState.Available(update), vm.testStateValue) + } + + @Test + fun dismiss_hidesCurrentUpdateForSession() { + val update = availableUpdate() + val vm = createVM() + coEvery { updateInteractor.checkForUpdate(any()) } returns Result.success(update) + vm.testOnStart() + + vm.onAction(UpdatePromptAction.DismissClicked) + + assertEquals(UpdatePromptViewState.Hidden, vm.testStateValue) + } + + @Test + fun updateClicked_downloadsAndLaunchesInstaller_whenDownloadCompletes() { + val update = availableUpdate() + val file = File("update.apk") + coEvery { updateInteractor.downloadUpdate(update, any()) } returns AppUpdateDownload.Completed(file) + every { updateInteractor.launchInstaller(file) } returns InstallLaunchResult.Launched + val vm = createVM() + coEvery { updateInteractor.checkForUpdate(any()) } returns Result.success(update) + vm.testOnStart() + + vm.onAction(UpdatePromptAction.UpdateClicked) + + assertEquals(UpdatePromptViewState.Hidden, vm.testStateValue) + coVerify(exactly = 1) { updateInteractor.downloadUpdate(update, any()) } + verify(exactly = 1) { updateInteractor.launchInstaller(file) } + } + + @Test + fun updateClicked_showsPermissionRequired_whenInstallerNeedsPermission() { + val update = availableUpdate() + val file = File("update.apk") + coEvery { updateInteractor.downloadUpdate(update, any()) } returns AppUpdateDownload.Completed(file) + every { updateInteractor.launchInstaller(file) } returns InstallLaunchResult.PermissionRequired + val vm = createVM() + coEvery { updateInteractor.checkForUpdate(any()) } returns Result.success(update) + vm.testOnStart() + + vm.onAction(UpdatePromptAction.UpdateClicked) + + assertEquals(UpdatePromptViewState.PermissionRequired(update, file), vm.testStateValue) + } + + @Test + fun onResume_retriesInstaller_whenPermissionWasGranted() { + val update = availableUpdate() + val file = File("update.apk") + coEvery { updateInteractor.downloadUpdate(update, any()) } returns AppUpdateDownload.Completed(file) + every { updateInteractor.launchInstaller(file) } returns InstallLaunchResult.PermissionRequired andThen + InstallLaunchResult.Launched + every { updateInteractor.canRequestPackageInstalls() } returns true + val vm = createVM() + coEvery { updateInteractor.checkForUpdate(any()) } returns Result.success(update) + vm.testOnStart() + + vm.onAction(UpdatePromptAction.UpdateClicked) + vm.onAction(UpdatePromptAction.OnResume) + + assertEquals(UpdatePromptViewState.Hidden, vm.testStateValue) + verify(exactly = 2) { updateInteractor.launchInstaller(file) } + } + + @Test + fun updateClicked_showsError_whenDownloadFails() { + val update = availableUpdate() + coEvery { updateInteractor.downloadUpdate(update, any()) } returns AppUpdateDownload.Error.DownloadFailed( + IOException("Network error") + ) + val vm = createVM() + coEvery { updateInteractor.checkForUpdate(any()) } returns Result.success(update) + vm.testOnStart() + + vm.onAction(UpdatePromptAction.UpdateClicked) + + assertTrue(vm.testStateValue is UpdatePromptViewState.Error) + assertEquals(update, (vm.testStateValue as UpdatePromptViewState.Error).update) + } + + @Test + fun dismiss_keepsPromptHidden_whenCanceledDownloadReturnsError() = runTest { + val update = availableUpdate() + val downloadStarted = CompletableDeferred() + val releaseDownload = CompletableDeferred() + coEvery { updateInteractor.downloadUpdate(update, any()) } coAnswers { + downloadStarted.complete(Unit) + try { + releaseDownload.await() + AppUpdateDownload.Completed(File("unexpected.apk")) + } catch (_: CancellationException) { + AppUpdateDownload.Error.DownloadFailed(IOException("Canceled download")) + } + } + val vm = createVM() + coEvery { updateInteractor.checkForUpdate(any()) } returns Result.success(update) + vm.testOnStart() + + vm.onAction(UpdatePromptAction.UpdateClicked) + downloadStarted.await() + vm.onAction(UpdatePromptAction.DismissClicked) + + assertEquals(UpdatePromptViewState.Hidden, vm.testStateValue) + } + + private fun availableUpdate() = AvailableUpdate( + version = AppVersion(major = 1, minor = 5, patch = 0), + tagName = "v1.5.0", + title = "Puber 1.5.0", + releaseNotes = "Release notes", + apkAssetName = "puber-v1.5.0.apk", + apkDownloadUrl = "https://example.com/puber-v1.5.0.apk", + apkSizeBytes = 42_000_000, + checksumDownloadUrl = "https://example.com/puber-v1.5.0.apk.sha256", + releasePageUrl = "https://github.com/rovkinmax/Puber/releases/tag/v1.5.0", + ) +} From 93a4ebb9583cfd750fc90960dcdc0499620e4d5b Mon Sep 17 00:00:00 2001 From: max rovkin Date: Wed, 8 Jul 2026 16:57:40 +0500 Subject: [PATCH 2/3] Add update prompt overlay previews --- .../component/UpdatePromptOverlayPreview.kt | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 app/src/main/java/com/kino/puber/ui/feature/update/component/UpdatePromptOverlayPreview.kt diff --git a/app/src/main/java/com/kino/puber/ui/feature/update/component/UpdatePromptOverlayPreview.kt b/app/src/main/java/com/kino/puber/ui/feature/update/component/UpdatePromptOverlayPreview.kt new file mode 100644 index 0000000..3acb719 --- /dev/null +++ b/app/src/main/java/com/kino/puber/ui/feature/update/component/UpdatePromptOverlayPreview.kt @@ -0,0 +1,92 @@ +package com.kino.puber.ui.feature.update.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Devices.TV_1080p +import androidx.compose.ui.tooling.preview.Preview +import com.kino.puber.core.ui.uikit.theme.PuberTheme +import com.kino.puber.data.repository.AppVersion +import com.kino.puber.data.repository.AvailableUpdate +import com.kino.puber.ui.feature.update.model.UpdatePromptViewState +import java.io.File + +private val previewUpdate = AvailableUpdate( + version = AppVersion(1, 5, 0), + tagName = "v1.5.0", + title = "Puber 1.5.0", + releaseNotes = """ + - Added automatic update checks on app launch. + - Added release notes before installing a new APK. + - Improved update download progress and checksum validation. + """.trimIndent(), + apkAssetName = "puber-prod-release.apk", + apkDownloadUrl = "https://github.com/rovkinmax/Puber/releases/download/v1.5.0/puber-prod-release.apk", + apkSizeBytes = 42_000_000, + checksumDownloadUrl = "https://github.com/rovkinmax/Puber/releases/download/v1.5.0/puber-prod-release.apk.sha256", + releasePageUrl = "https://github.com/rovkinmax/Puber/releases/tag/v1.5.0", +) + +@Preview(name = "Update prompt - available", device = TV_1080p) +@Composable +private fun AvailablePreview() = UpdatePromptPreviewTheme { + UpdatePromptOverlay( + state = UpdatePromptViewState.Available(previewUpdate), + onAction = {}, + ) +} + +@Preview(name = "Update prompt - downloading", device = TV_1080p) +@Composable +private fun DownloadingPreview() = UpdatePromptPreviewTheme { + UpdatePromptOverlay( + state = UpdatePromptViewState.Downloading( + update = previewUpdate, + progressPercent = 42, + ), + onAction = {}, + ) +} + +@Preview(name = "Update prompt - permission required", device = TV_1080p) +@Composable +private fun PermissionRequiredPreview() = UpdatePromptPreviewTheme { + UpdatePromptOverlay( + state = UpdatePromptViewState.PermissionRequired( + update = previewUpdate, + downloadedFile = File("/tmp/puber-prod-release.apk"), + ), + onAction = {}, + ) +} + +@Preview(name = "Update prompt - download error", device = TV_1080p) +@Composable +private fun DownloadErrorPreview() = UpdatePromptPreviewTheme { + UpdatePromptOverlay( + state = UpdatePromptViewState.Error( + update = previewUpdate, + downloadedFile = null, + message = "Unable to download the update. Check the connection and try again.", + canRetryInstall = false, + ), + onAction = {}, + ) +} + +@Preview(name = "Update prompt - install error", device = TV_1080p) +@Composable +private fun InstallErrorPreview() = UpdatePromptPreviewTheme { + UpdatePromptOverlay( + state = UpdatePromptViewState.Error( + update = previewUpdate, + downloadedFile = File("/tmp/puber-prod-release.apk"), + message = "Android installer could not be opened. Try again.", + canRetryInstall = true, + ), + onAction = {}, + ) +} + +@Composable +private fun UpdatePromptPreviewTheme(content: @Composable () -> Unit) { + PuberTheme(content = content) +} From 643225d402f9a5092306ebf05b46d58dbde1a7a4 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Wed, 8 Jul 2026 16:59:45 +0500 Subject: [PATCH 3/3] Make TV dialog overlay previews safe --- .../kino/puber/core/ui/uikit/component/TvDialogOverlay.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/kino/puber/core/ui/uikit/component/TvDialogOverlay.kt b/app/src/main/java/com/kino/puber/core/ui/uikit/component/TvDialogOverlay.kt index 7f5fa4a..897c330 100644 --- a/app/src/main/java/com/kino/puber/core/ui/uikit/component/TvDialogOverlay.kt +++ b/app/src/main/java/com/kino/puber/core/ui/uikit/component/TvDialogOverlay.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode private const val DialogScrimAlpha = 0.62f @@ -39,7 +40,9 @@ internal fun TvDialogOverlay( latestFocusRestorer.value?.onDialogClosed() } } - BackHandler(onBack = dismiss) + if (!LocalInspectionMode.current) { + BackHandler(onBack = dismiss) + } Box( modifier = modifier .fillMaxSize()