feat: in app updater

This commit is contained in:
tapframe 2026-04-19 01:15:39 +05:30
parent 9789828b00
commit 41ef04c1cb
21 changed files with 984 additions and 0 deletions

2
.gitignore vendored
View file

@ -2,6 +2,8 @@
.kotlin
.gradle
**/build/
!composeApp/src/**/kotlin/com/nuvio/app/core/build/
!composeApp/src/**/kotlin/com/nuvio/app/core/build/**
xcuserdata
!src/**/build/
local.properties

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/nuvio_updater_file_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = true
actual val p2pEnabled: Boolean = true
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP
actual val inAppUpdaterEnabled: Boolean = true
}

View file

@ -0,0 +1,133 @@
package com.nuvio.app.features.updater
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
object AndroidAppUpdaterPlatform {
private const val preferencesName = "nuvio_updater"
private const val ignoredTagKey = "ignored_release_tag"
private val httpClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
private var appContext: Context? = null
fun initialize(context: Context) {
appContext = context.applicationContext
}
fun getSupportedAbis(): List<String> = Build.SUPPORTED_ABIS?.toList().orEmpty()
fun getIgnoredTag(): String? =
preferences().getString(ignoredTagKey, null)
fun setIgnoredTag(tag: String?) {
preferences().edit().apply {
if (tag == null) remove(ignoredTagKey) else putString(ignoredTagKey, tag)
}.apply()
}
suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String> = withContext(Dispatchers.IO) {
runCatching {
val context = requireContext()
val safeName = assetName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val destination = File(File(context.cacheDir, "updates"), safeName)
destination.parentFile?.mkdirs()
if (destination.exists()) {
destination.delete()
}
val request = Request.Builder()
.url(assetUrl)
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
error("Download failed with HTTP ${response.code}")
}
val body = response.body ?: error("Empty download body")
val totalBytes = body.contentLength().takeIf { it > 0L }
body.byteStream().use { input ->
FileOutputStream(destination).use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var downloadedBytes = 0L
while (true) {
val read = input.read(buffer)
if (read <= 0) break
output.write(buffer, 0, read)
downloadedBytes += read
onProgress(downloadedBytes, totalBytes)
}
output.flush()
}
}
}
destination.absolutePath
}
}
fun canRequestPackageInstalls(): Boolean {
val context = appContext ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
true
}
}
fun openUnknownSourcesSettings() {
val context = appContext ?: return
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 installDownloadedApk(path: String): Result<Unit> = runCatching {
val context = requireContext()
val apkFile = File(path)
check(apkFile.exists()) { "Downloaded update file is missing." }
val apkUri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile,
)
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(apkUri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
private fun preferences() = requireContext().getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
private fun requireContext(): Context =
requireNotNull(appContext) { "AndroidAppUpdaterPlatform.initialize must be called before use." }
}

View file

@ -0,0 +1,27 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = true
actual fun getSupportedAbis(): List<String> = AndroidAppUpdaterPlatform.getSupportedAbis()
actual fun getIgnoredTag(): String? = AndroidAppUpdaterPlatform.getIgnoredTag()
actual fun setIgnoredTag(tag: String?) {
AndroidAppUpdaterPlatform.setIgnoredTag(tag)
}
actual suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String> = AndroidAppUpdaterPlatform.downloadApk(assetUrl, assetName, onProgress)
actual fun canRequestPackageInstalls(): Boolean = AndroidAppUpdaterPlatform.canRequestPackageInstalls()
actual fun openUnknownSourcesSettings() {
AndroidAppUpdaterPlatform.openUnknownSourcesSettings()
}
actual fun installDownloadedApk(path: String): Result<Unit> = AndroidAppUpdaterPlatform.installDownloadedApk(path)
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="nuvio_updates"
path="updates/" />
</paths>

View file

@ -35,6 +35,7 @@ import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.PosterCardStyleStorage
import com.nuvio.app.features.watched.WatchedStorage
import com.nuvio.app.features.streams.StreamLinkCacheStorage
@ -83,6 +84,7 @@ class MainActivity : ComponentActivity() {
DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.initialize(applicationContext)
DownloadsLiveStatusPlatform.initialize(applicationContext)
AndroidAppUpdaterPlatform.initialize(applicationContext)
PlatformLocalAccountDataCleaner.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.bindActivity(this)

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = false
actual val p2pEnabled: Boolean = false
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
actual val inAppUpdaterEnabled: Boolean = false
}

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.updater
import android.content.Context
object AndroidAppUpdaterPlatform {
fun initialize(context: Context) = Unit
}

View file

@ -0,0 +1,24 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual fun getSupportedAbis(): List<String> = emptyList()
actual fun getIgnoredTag(): String? = null
actual fun setIgnoredTag(tag: String?) = Unit
actual suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
actual fun canRequestPackageInstalls(): Boolean = false
actual fun openUnknownSourcesSettings() = Unit
actual fun installDownloadedApk(path: String): Result<Unit> =
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
}

View file

@ -154,6 +154,8 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.rememberAppUpdaterController
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -442,6 +444,7 @@ private fun MainAppContent(
onSwitchProfile: () -> Unit = {},
) {
val navController = rememberNavController()
val appUpdaterController = rememberAppUpdaterController()
remember {
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
@ -960,6 +963,16 @@ private fun MainAppContent(
onSupportersContributorsSettingsClick = {
navController.navigate(SupportersContributorsSettingsRoute)
},
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
{
appUpdaterController.checkForUpdates(
force = true,
showNoUpdateFeedback = true,
)
}
} else {
null
},
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
onFolderClick = { collectionId, folderId ->
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
@ -1797,6 +1810,13 @@ private fun MainAppContent(
.align(Alignment.TopCenter)
.zIndex(20f),
)
AppUpdaterHost(
controller = appUpdaterController,
modifier = Modifier
.align(Alignment.Center)
.zIndex(25f),
)
}
}
@ -1840,6 +1860,7 @@ private fun AppTabHost(
onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {},
onSupportersContributorsSettingsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
onInitialHomeContentRendered: () -> Unit = {},
@ -1890,6 +1911,7 @@ private fun AppTabHost(
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsSettingsClick,
)
}

View file

@ -0,0 +1,13 @@
package com.nuvio.app.core.build
enum class TrailerPlaybackMode {
IN_APP,
EXTERNAL,
}
expect object AppFeaturePolicy {
val pluginsEnabled: Boolean
val p2pEnabled: Boolean
val trailerPlaybackMode: TrailerPlaybackMode
val inAppUpdaterEnabled: Boolean
}

View file

@ -30,6 +30,7 @@ internal fun LazyListScope.settingsRootContent(
onIntegrationsClick: () -> Unit,
onTraktClick: () -> Unit,
onSupportersContributorsClick: () -> Unit,
onCheckForUpdatesClick: (() -> Unit)? = null,
onDownloadsClick: () -> Unit,
onAccountClick: () -> Unit,
onSwitchProfileClick: (() -> Unit)? = null,
@ -145,6 +146,16 @@ internal fun LazyListScope.settingsRootContent(
isTablet = isTablet,
onClick = onSupportersContributorsClick,
)
if (onCheckForUpdatesClick != null) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = "Check for updates",
description = "Check for new versions of the app.",
icon = Icons.Rounded.CloudDownload,
isTablet = isTablet,
onClick = onCheckForUpdatesClick,
)
}
}
}
}

View file

@ -71,6 +71,7 @@ fun SettingsScreen(
onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
BoxWithConstraints(
@ -190,6 +191,7 @@ fun SettingsScreen(
onSwitchProfile = onSwitchProfile,
onDownloadsClick = onDownloadsClick,
onSupportersContributorsClick = onSupportersContributorsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
} else {
@ -233,6 +235,7 @@ fun SettingsScreen(
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSupportersContributorsClick = onSupportersContributorsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
}
@ -280,6 +283,7 @@ private fun MobileSettingsScreen(
onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
NuvioScreen {
@ -301,6 +305,7 @@ private fun MobileSettingsScreen(
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = onSupportersContributorsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSwitchProfileClick = onSwitchProfile,
@ -430,6 +435,7 @@ private fun TabletSettingsScreen(
onSwitchProfile: (() -> Unit)? = null,
onDownloadsClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
@ -518,6 +524,7 @@ private fun TabletSettingsScreen(
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = { openInlinePage(SettingsPage.Account) },
onSwitchProfileClick = onSwitchProfile,

View file

@ -0,0 +1,601 @@
package com.nuvio.app.features.updater
import androidx.compose.foundation.background
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.padding
import androidx.compose.foundation.layout.size
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.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.build.AppVersionConfig
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
private const val gitHubOwner = "NuvioMedia"
private const val gitHubRepo = "NuvioMobile"
private const val gitHubApiBase = "https://api.github.com"
private const val releaseChannelBranch = "cmp-rewrite"
data class AppUpdate(
val tag: String,
val title: String,
val notes: String,
val releaseUrl: String?,
val assetName: String,
val assetUrl: String,
val assetSizeBytes: Long?,
)
data class AppUpdaterUiState(
val isChecking: Boolean = false,
val update: AppUpdate? = null,
val isUpdateAvailable: Boolean = false,
val isDownloading: Boolean = false,
val downloadProgress: Float? = null,
val downloadedApkPath: String? = null,
val showDialog: Boolean = false,
val showUnknownSourcesDialog: Boolean = false,
val errorMessage: String? = null,
)
@Serializable
private data class GitHubReleaseDto(
@SerialName("tag_name") val tagName: String? = null,
val name: String? = null,
val body: String? = null,
val draft: Boolean = false,
val prerelease: Boolean = false,
@SerialName("html_url") val htmlUrl: String? = null,
@SerialName("target_commitish") val targetCommitish: String? = null,
val assets: List<GitHubAssetDto> = emptyList(),
)
@Serializable
private data class GitHubAssetDto(
val name: String,
@SerialName("browser_download_url") val browserDownloadUrl: String,
val size: Long? = null,
@SerialName("content_type") val contentType: String? = null,
)
private val appUpdaterJson = Json {
ignoreUnknownKeys = true
isLenient = true
}
private class NoChannelReleaseException : IllegalStateException(
"No cmp-rewrite release has been published yet.",
)
private object VersionUtils {
fun normalize(raw: String?): String {
if (raw.isNullOrBlank()) return ""
return raw.trim().removePrefix("v").removePrefix("V")
}
fun parseVersionParts(raw: String?): List<Int>? {
val normalized = normalize(raw)
if (normalized.isBlank()) return null
val parts = normalized.split('.', '-', '_')
.filter { it.isNotBlank() }
.mapNotNull { token -> token.takeWhile { it.isDigit() }.toIntOrNull() }
return parts.takeIf { it.isNotEmpty() }
}
fun isRemoteNewer(remote: String?, local: String?): Boolean {
val remoteParts = parseVersionParts(remote)
val localParts = parseVersionParts(local)
if (remoteParts == null || localParts == null) {
val remoteValue = normalize(remote)
val localValue = normalize(local)
return remoteValue.isNotBlank() && localValue.isNotBlank() && remoteValue != localValue
}
val maxSize = maxOf(remoteParts.size, localParts.size)
for (index in 0 until maxSize) {
val remoteValue = remoteParts.getOrElse(index) { 0 }
val localValue = localParts.getOrElse(index) { 0 }
if (remoteValue != localValue) return remoteValue > localValue
}
return false
}
}
private object AppUpdaterRepository {
suspend fun getLatestChannelUpdate(): Result<AppUpdate> = runCatching {
val response = httpRequestRaw(
method = "GET",
url = "$gitHubApiBase/repos/$gitHubOwner/$gitHubRepo/releases?per_page=20",
headers = mapOf(
"Accept" to "application/vnd.github+json",
"User-Agent" to "NuvioMobile",
),
body = "",
)
if (response.status !in 200..299) {
error("GitHub releases API error: ${response.status}")
}
val releases = appUpdaterJson.decodeFromString<List<GitHubReleaseDto>>(response.body)
val release = releases.firstOrNull { it.matchesRequestedChannel() && !it.draft && !it.prerelease }
?: throw NoChannelReleaseException()
val tag = release.tagName?.takeIf { it.isNotBlank() }
?: release.name?.takeIf { it.isNotBlank() }
?: error("Release has no tag or name")
val asset = chooseBestApkAsset(release.assets)
?: error("No APK asset found in the cmp-rewrite release")
AppUpdate(
tag = tag,
title = release.name?.takeIf { it.isNotBlank() } ?: tag,
notes = release.body.orEmpty(),
releaseUrl = release.htmlUrl,
assetName = asset.name,
assetUrl = asset.browserDownloadUrl,
assetSizeBytes = asset.size,
)
}
private fun GitHubReleaseDto.matchesRequestedChannel(): Boolean {
val channel = releaseChannelBranch
if (targetCommitish?.trim()?.equals(channel, ignoreCase = true) == true) {
return true
}
return listOf(tagName, name)
.filterNotNull()
.any { value -> value.contains(channel, ignoreCase = true) }
}
private fun chooseBestApkAsset(assets: List<GitHubAssetDto>): GitHubAssetDto? {
val apkAssets = assets.filter { asset ->
asset.name.endsWith(".apk", ignoreCase = true) ||
asset.contentType == "application/vnd.android.package-archive"
}
if (apkAssets.isEmpty()) return null
if (apkAssets.size == 1) return apkAssets.first()
val supportedAbis = AppUpdaterPlatform.getSupportedAbis()
for (abi in supportedAbis) {
val candidate = apkAssets.firstOrNull { asset ->
asset.name.contains(abi, ignoreCase = true)
}
if (candidate != null) return candidate
}
return apkAssets.firstOrNull { asset ->
val name = asset.name.lowercase()
name.contains("universal") || name.contains("all")
} ?: apkAssets.first()
}
}
class AppUpdaterController internal constructor(
private val scope: CoroutineScope,
) {
private val _uiState = MutableStateFlow(AppUpdaterUiState())
val uiState: StateFlow<AppUpdaterUiState> = _uiState.asStateFlow()
private var autoCheckStarted = false
fun ensureAutoCheckStarted() {
if (autoCheckStarted || !AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
return
}
autoCheckStarted = true
checkForUpdates(force = false, showNoUpdateFeedback = false)
}
fun checkForUpdates(force: Boolean, showNoUpdateFeedback: Boolean) {
if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
if (showNoUpdateFeedback) {
NuvioToastController.show("In-app updates are not available on this build.")
}
return
}
scope.launch {
_uiState.update { state ->
state.copy(
isChecking = true,
errorMessage = null,
showUnknownSourcesDialog = false,
)
}
val ignoredTag = AppUpdaterPlatform.getIgnoredTag()
val result = AppUpdaterRepository.getLatestChannelUpdate()
result.onSuccess { update ->
val remoteNewer = VersionUtils.isRemoteNewer(update.tag, AppVersionConfig.VERSION_NAME)
val ignored = ignoredTag != null && ignoredTag == update.tag
val shouldShowDialog = force || (remoteNewer && !ignored)
_uiState.update { state ->
state.copy(
isChecking = false,
update = update,
isUpdateAvailable = remoteNewer,
showDialog = shouldShowDialog,
showUnknownSourcesDialog = false,
errorMessage = null,
)
}
if (showNoUpdateFeedback && !remoteNewer) {
NuvioToastController.show("You're using the latest version.")
}
}.onFailure { error ->
_uiState.update { state ->
state.copy(
isChecking = false,
update = null,
isUpdateAvailable = false,
showDialog = force && error !is NoChannelReleaseException,
showUnknownSourcesDialog = false,
errorMessage = if (force && error !is NoChannelReleaseException) {
error.message ?: "Update check failed"
} else {
null
},
)
}
if (showNoUpdateFeedback || error is NoChannelReleaseException) {
NuvioToastController.show(error.message ?: "Update check failed")
}
}
}
}
fun dismissDialog() {
_uiState.update { state ->
state.copy(
showDialog = false,
showUnknownSourcesDialog = false,
errorMessage = null,
)
}
}
fun ignoreThisVersion() {
val tag = _uiState.value.update?.tag ?: return
AppUpdaterPlatform.setIgnoredTag(tag)
dismissDialog()
}
fun downloadUpdate() {
val update = _uiState.value.update ?: return
scope.launch {
_uiState.update { state ->
state.copy(
isDownloading = true,
downloadProgress = 0f,
errorMessage = null,
)
}
AppUpdaterPlatform.downloadApk(
assetUrl = update.assetUrl,
assetName = update.assetName,
) { downloadedBytes, totalBytes ->
val progress = if (totalBytes != null && totalBytes > 0L) {
(downloadedBytes.toFloat() / totalBytes.toFloat()).coerceIn(0f, 1f)
} else {
null
}
_uiState.update { state -> state.copy(downloadProgress = progress) }
}.onSuccess { path ->
_uiState.update { state ->
state.copy(
isDownloading = false,
downloadProgress = 1f,
downloadedApkPath = path,
errorMessage = null,
)
}
installDownloadedUpdate()
}.onFailure { error ->
_uiState.update { state ->
state.copy(
isDownloading = false,
downloadProgress = null,
downloadedApkPath = null,
errorMessage = error.message ?: "Download failed",
showDialog = true,
)
}
}
}
}
fun installDownloadedUpdate() {
val apkPath = _uiState.value.downloadedApkPath ?: return
if (!AppUpdaterPlatform.canRequestPackageInstalls()) {
_uiState.update { state -> state.copy(showUnknownSourcesDialog = true, showDialog = true) }
return
}
AppUpdaterPlatform.installDownloadedApk(apkPath).onSuccess {
_uiState.update { state -> state.copy(showUnknownSourcesDialog = false) }
}.onFailure { error ->
_uiState.update { state ->
state.copy(
errorMessage = error.message ?: "Unable to start installation",
showDialog = true,
)
}
}
}
fun resumeInstallation() {
if (AppUpdaterPlatform.canRequestPackageInstalls()) {
installDownloadedUpdate()
} else {
AppUpdaterPlatform.openUnknownSourcesSettings()
}
}
}
@Composable
fun rememberAppUpdaterController(): AppUpdaterController {
val scope = rememberCoroutineScope()
return remember(scope) { AppUpdaterController(scope) }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppUpdaterHost(
controller: AppUpdaterController,
modifier: Modifier = Modifier,
) {
if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
return
}
val state by controller.uiState.collectAsStateWithLifecycle()
LaunchedEffect(controller) {
controller.ensureAutoCheckStarted()
}
if (!state.showDialog) return
BasicAlertDialog(
onDismissRequest = {
if (!state.isDownloading) {
controller.dismissDialog()
}
},
) {
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 16.dp,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = when {
state.showUnknownSourcesDialog -> "Allow installs to continue"
state.isUpdateAvailable -> state.update?.title ?: "Update available"
else -> "Update status"
},
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = when {
state.showUnknownSourcesDialog -> "Enable app installs for Nuvio, then come back and continue."
state.isDownloading -> "Downloading update..."
state.isUpdateAvailable -> "A new version is ready to install."
else -> "No updates found."
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
state.errorMessage?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
state.update?.let { update ->
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (state.isChecking) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(10.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = update.tag,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
val assetLine = update.assetSizeBytes?.let(::formatFileSize)?.let { size ->
"$size${update.assetName}"
} ?: update.assetName
Text(
text = assetLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (state.isDownloading || state.downloadProgress != null) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LinearProgressIndicator(
progress = { (state.downloadProgress ?: 0f).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
Text(
text = if (state.downloadProgress != null) {
"Downloading ${((state.downloadProgress ?: 0f) * 100).toInt().coerceIn(0, 100)}%"
} else {
"Preparing download"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (update.notes.isNotBlank()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Release notes",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = update.notes,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(14.dp)
.verticalScroll(rememberScrollState()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
if (state.isUpdateAvailable && !state.isDownloading && !state.showUnknownSourcesDialog) {
OutlinedButton(onClick = controller::ignoreThisVersion) {
Text("Ignore")
}
}
OutlinedButton(
onClick = controller::dismissDialog,
enabled = !state.isDownloading,
) {
Text(if (state.isDownloading) "Downloading" else "Later")
}
Button(
onClick = {
when {
state.showUnknownSourcesDialog -> controller.resumeInstallation()
state.downloadedApkPath != null -> controller.installDownloadedUpdate()
else -> controller.downloadUpdate()
}
},
enabled = if (state.showUnknownSourcesDialog || state.downloadedApkPath != null) {
true
} else {
!state.isChecking && !state.isDownloading && state.update != null
},
) {
Text(
when {
state.showUnknownSourcesDialog -> "Continue"
state.downloadedApkPath != null -> "Install"
state.isDownloading -> "Downloading"
else -> "Update"
},
)
}
}
}
}
}
}
private fun formatFileSize(sizeBytes: Long): String {
if (sizeBytes <= 0L) return "0 B"
val units = listOf("B", "KB", "MB", "GB")
var value = sizeBytes.toDouble()
var unitIndex = 0
while (value >= 1024.0 && unitIndex < units.lastIndex) {
value /= 1024.0
unitIndex += 1
}
val roundedValue = if (value >= 10 || unitIndex == 0) {
value.toInt().toString()
} else {
((value * 10).toInt() / 10.0).toString()
}
return "$roundedValue ${units[unitIndex]}"
}

View file

@ -0,0 +1,23 @@
package com.nuvio.app.features.updater
expect object AppUpdaterPlatform {
val isSupported: Boolean
fun getSupportedAbis(): List<String>
fun getIgnoredTag(): String?
fun setIgnoredTag(tag: String?)
suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String>
fun canRequestPackageInstalls(): Boolean
fun openUnknownSourcesSettings()
fun installDownloadedApk(path: String): Result<Unit>
}

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = false
actual val p2pEnabled: Boolean = false
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
actual val inAppUpdaterEnabled: Boolean = false
}

View file

@ -0,0 +1,24 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual fun getSupportedAbis(): List<String> = emptyList()
actual fun getIgnoredTag(): String? = null
actual fun setIgnoredTag(tag: String?) = Unit
actual suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
actual fun canRequestPackageInstalls(): Boolean = false
actual fun openUnknownSourcesSettings() = Unit
actual fun installDownloadedApk(path: String): Result<Unit> =
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
}

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = false
actual val p2pEnabled: Boolean = false
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
actual val inAppUpdaterEnabled: Boolean = false
}

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = true
actual val p2pEnabled: Boolean = true
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP
actual val inAppUpdaterEnabled: Boolean = false
}

View file

@ -0,0 +1,24 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual fun getSupportedAbis(): List<String> = emptyList()
actual fun getIgnoredTag(): String? = null
actual fun setIgnoredTag(tag: String?) = Unit
actual suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
actual fun canRequestPackageInstalls(): Boolean = false
actual fun openUnknownSourcesSettings() = Unit
actual fun installDownloadedApk(path: String): Result<Unit> =
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
}