From 41ef04c1cb01836915a8a8a0c5222e84428eb354 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:15:39 +0530 Subject: [PATCH] feat: in app updater --- .gitignore | 2 + .../src/androidFull/AndroidManifest.xml | 18 + .../core/build/AppFeaturePolicy.android.kt | 8 + .../updater/AndroidAppUpdaterPlatform.kt | 133 ++++ .../updater/AppUpdaterPlatform.android.kt | 27 + .../res/xml/nuvio_updater_file_paths.xml | 6 + .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../core/build/AppFeaturePolicy.android.kt | 8 + .../updater/AndroidAppUpdaterPlatform.kt | 7 + .../updater/AppUpdaterPlatform.android.kt | 24 + .../commonMain/kotlin/com/nuvio/app/App.kt | 22 + .../nuvio/app/core/build/AppFeaturePolicy.kt | 13 + .../app/features/settings/SettingsRootPage.kt | 11 + .../app/features/settings/SettingsScreen.kt | 7 + .../nuvio/app/features/updater/AppUpdater.kt | 601 ++++++++++++++++++ .../features/updater/AppUpdaterPlatform.kt | 23 + .../core/build/AppFeaturePolicy.desktop.kt | 8 + .../updater/AppUpdaterPlatform.desktop.kt | 24 + .../app/core/build/AppFeaturePolicy.ios.kt | 8 + .../app/core/build/AppFeaturePolicy.ios.kt | 8 + .../updater/AppUpdaterPlatform.ios.kt | 24 + 21 files changed, 984 insertions(+) create mode 100644 composeApp/src/androidFull/AndroidManifest.xml create mode 100644 composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt create mode 100644 composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt create mode 100644 composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt create mode 100644 composeApp/src/androidFull/res/xml/nuvio_updater_file_paths.xml create mode 100644 composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt create mode 100644 composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt create mode 100644 composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt create mode 100644 composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt create mode 100644 composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt diff --git a/.gitignore b/.gitignore index 84b9a289..fb351f31 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/composeApp/src/androidFull/AndroidManifest.xml b/composeApp/src/androidFull/AndroidManifest.xml new file mode 100644 index 00000000..16854174 --- /dev/null +++ b/composeApp/src/androidFull/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt new file mode 100644 index 00000000..18e9c681 --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -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 +} \ No newline at end of file diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt new file mode 100644 index 00000000..0f37aa02 --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt @@ -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 = 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 = 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 = 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." } +} \ No newline at end of file diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt new file mode 100644 index 00000000..09009d5d --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -0,0 +1,27 @@ +package com.nuvio.app.features.updater + +actual object AppUpdaterPlatform { + actual val isSupported: Boolean = true + + actual fun getSupportedAbis(): List = 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 = AndroidAppUpdaterPlatform.downloadApk(assetUrl, assetName, onProgress) + + actual fun canRequestPackageInstalls(): Boolean = AndroidAppUpdaterPlatform.canRequestPackageInstalls() + + actual fun openUnknownSourcesSettings() { + AndroidAppUpdaterPlatform.openUnknownSourcesSettings() + } + + actual fun installDownloadedApk(path: String): Result = AndroidAppUpdaterPlatform.installDownloadedApk(path) +} \ No newline at end of file diff --git a/composeApp/src/androidFull/res/xml/nuvio_updater_file_paths.xml b/composeApp/src/androidFull/res/xml/nuvio_updater_file_paths.xml new file mode 100644 index 00000000..761b90fa --- /dev/null +++ b/composeApp/src/androidFull/res/xml/nuvio_updater_file_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 9e05571b..fcd14cf3 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt new file mode 100644 index 00000000..e096b65f --- /dev/null +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -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 +} \ No newline at end of file diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt new file mode 100644 index 00000000..3aab13c8 --- /dev/null +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.updater + +import android.content.Context + +object AndroidAppUpdaterPlatform { + fun initialize(context: Context) = Unit +} \ No newline at end of file diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt new file mode 100644 index 00000000..01acbee9 --- /dev/null +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.updater + +actual object AppUpdaterPlatform { + actual val isSupported: Boolean = false + + actual fun getSupportedAbis(): List = 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 = 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 = + Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index f923c00e..f3b35366 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt new file mode 100644 index 00000000..8fb3bb86 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt @@ -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 +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 27e2c182..0f4cfb6a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -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, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 71752158..3b31e45f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt new file mode 100644 index 00000000..575c6a0c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt @@ -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 = 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? { + 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 = 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>(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? { + 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 = _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]}" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt new file mode 100644 index 00000000..0bc5d713 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.features.updater + +expect object AppUpdaterPlatform { + val isSupported: Boolean + + fun getSupportedAbis(): List + + fun getIgnoredTag(): String? + + fun setIgnoredTag(tag: String?) + + suspend fun downloadApk( + assetUrl: String, + assetName: String, + onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, + ): Result + + fun canRequestPackageInstalls(): Boolean + + fun openUnknownSourcesSettings() + + fun installDownloadedApk(path: String): Result +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt new file mode 100644 index 00000000..e096b65f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -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 +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt new file mode 100644 index 00000000..01acbee9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.updater + +actual object AppUpdaterPlatform { + actual val isSupported: Boolean = false + + actual fun getSupportedAbis(): List = 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 = 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 = + Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) +} \ No newline at end of file diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt new file mode 100644 index 00000000..e096b65f --- /dev/null +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -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 +} \ No newline at end of file diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt new file mode 100644 index 00000000..50678763 --- /dev/null +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -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 +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt new file mode 100644 index 00000000..01acbee9 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.updater + +actual object AppUpdaterPlatform { + actual val isSupported: Boolean = false + + actual fun getSupportedAbis(): List = 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 = 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 = + Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) +} \ No newline at end of file