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/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()
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/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)
+}
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",
+ )
+}