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