refactor(app): refactor update logic to make core logic more reusable by moving it into the core.feature.update package

This commit is contained in:
2026-05-03 13:59:14 +02:00
parent 3a748397f8
commit 5bec125f56
9 changed files with 251 additions and 72 deletions

View File

@@ -5,16 +5,16 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.navigation3.runtime.NavBackStack
@@ -24,17 +24,18 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.feature.update.AppUpdateInfo
import hu.bbara.purefin.feature.update.AppUpdateViewModel
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.update.AppUpdateInstaller
import hu.bbara.purefin.ui.screen.download.DownloadsScreen
import hu.bbara.purefin.ui.screen.home.HomeScreen
import hu.bbara.purefin.ui.screen.libraries.LibrariesScreen
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@Composable
fun AppScreen(
viewModel: AppViewModel = hiltViewModel(),
updateViewModel: AppUpdateViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val libraries by viewModel.libraries.collectAsState()
@@ -43,12 +44,10 @@ fun AppScreen(
val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState()
val isRefreshing by viewModel.isRefreshing.collectAsState()
val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsState()
val availableUpdate by updateViewModel.availableUpdate.collectAsState()
val navigationManager = LocalNavigationManager.current
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val appUpdateInstaller = remember(context) { AppUpdateInstaller(context) }
var isCheckingForUpdates by remember { mutableStateOf(false) }
@Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(AppTabRoute.Home) as NavBackStack<AppTabRoute>
@@ -60,6 +59,12 @@ fun AppScreen(
onPauseOrDispose { }
}
LaunchedEffect(updateViewModel) {
updateViewModel.snackbarMessages.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
val onTabSelected = remember(backStack) {
{ selectedIndex: Int ->
val route = selectedIndex.toAppTabRoute()
@@ -68,21 +73,6 @@ fun AppScreen(
}
}
}
val onCheckForUpdates = {
if (!isCheckingForUpdates) {
coroutineScope.launch {
isCheckingForUpdates = true
val message = try {
appUpdateInstaller.checkForUpdateAndInstall()
} catch (e: Exception) {
e.message ?: "Update check failed"
} finally {
isCheckingForUpdates = false
}
snackbarHostState.showSnackbar(message)
}
}
}
val tabEntryProvider = entryProvider {
entry<AppTabRoute.Home>(metadata = appTabMetadata(AppTabRoute.Home)) {
@@ -102,7 +92,7 @@ fun AppScreen(
)
},
onProfileClick = {},
onCheckForUpdates = onCheckForUpdates,
onCheckForUpdates = updateViewModel::checkForUpdates,
isCheckingForUpdates = isCheckingForUpdates,
onSettingsClick = {},
onLogoutClick = viewModel::logout,
@@ -150,6 +140,14 @@ fun AppScreen(
entryProvider = tabEntryProvider,
modifier = modifier.fillMaxSize()
)
availableUpdate?.let { update ->
UpdateAvailableDialog(
update = update,
onAccept = updateViewModel::acceptUpdate,
onDecline = updateViewModel::declineUpdate
)
}
}
@Serializable
@@ -183,4 +181,35 @@ private fun appTabMetadata(route: AppTabRoute): Map<String, Any> =
private fun Map<String, Any>.appTabIndex(): Int =
this[APP_TAB_INDEX_METADATA] as? Int ?: 0
@Composable
private fun UpdateAvailableDialog(
update: AppUpdateInfo,
onAccept: () -> Unit,
onDecline: () -> Unit
) {
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
val updateText = if (releaseNotes == null) {
"Purefin $versionLabel is available.\n\nVersion code: ${update.versionCode}"
} else {
"Purefin $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
}
AlertDialog(
onDismissRequest = onDecline,
title = { Text("Update available") },
text = { Text(updateText) },
confirmButton = {
TextButton(onClick = onAccept) {
Text("Install")
}
},
dismissButton = {
TextButton(onClick = onDecline) {
Text("Not now")
}
}
)
}
private const val APP_TAB_INDEX_METADATA = "app_tab_index"

View File

@@ -10,67 +10,44 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import hu.bbara.purefin.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.feature.update.AppUpdateInfo
import hu.bbara.purefin.feature.update.AppUpdateInstaller
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import javax.inject.Inject
class AppUpdateInstaller(
context: Context,
private val client: OkHttpClient = OkHttpClient()
) {
class AndroidAppUpdateInstaller @Inject constructor(
@ApplicationContext context: Context
) : AppUpdateInstaller {
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
suspend fun checkForUpdateAndInstall(): String {
val manifestUrl = "http://purefin.t.bbara.hu/app/update.json"
if (manifestUrl.isBlank()) {
return "Update manifest URL not configured"
}
val manifest = withContext(Dispatchers.IO) { fetchManifest(manifestUrl) }
if (manifest.versionCode <= BuildConfig.VERSION_CODE.toLong()) {
return "Purefin is up to date"
}
private val client = OkHttpClient()
override suspend fun installUpdate(update: AppUpdateInfo): String {
if (!appContext.packageManager.canRequestPackageInstalls()) {
openInstallPermissionSettings()
return "Allow app installs, then check again"
}
val apkFile = withContext(Dispatchers.IO) {
downloadApk(manifestUrl, manifest)
.also { validateApk(it, manifest.versionCode) }
downloadApk(update)
.also { validateApk(it, update.versionCode) }
}
withContext(Dispatchers.IO) {
commitInstallSession(apkFile, manifest.versionCode)
commitInstallSession(apkFile, update.versionCode)
}
val versionLabel = manifest.versionName?.takeIf { it.isNotBlank() } ?: manifest.versionCode.toString()
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
return "Downloaded Purefin $versionLabel"
}
private fun fetchManifest(manifestUrl: String): AppUpdateManifest {
val request = Request.Builder()
.url(manifestUrl)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("Update check failed: HTTP ${response.code}")
}
val body = response.body?.string() ?: throw IllegalStateException("Update manifest empty")
return json.decodeFromString<AppUpdateManifest>(body)
}
}
private fun downloadApk(manifestUrl: String, manifest: AppUpdateManifest): File {
val apkUrl = manifestUrl.toHttpUrl().resolve(manifest.apkUrl)
private fun downloadApk(update: AppUpdateInfo): File {
val apkUrl = update.manifestUrl.toHttpUrl().resolve(update.apkUrl)
?: throw IllegalStateException("APK URL invalid")
val request = Request.Builder()
.url(apkUrl)
@@ -78,7 +55,7 @@ class AppUpdateInstaller(
val updateDir = appContext.cacheDir.resolve("updates")
updateDir.mkdirs()
updateDir.listFiles()?.forEach { it.delete() }
val apkFile = File(updateDir, "purefin-${manifest.versionCode}.apk")
val apkFile = File(updateDir, "purefin-${update.versionCode}.apk")
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
@@ -165,10 +142,3 @@ class AppUpdateInstaller(
}
}
}
@Serializable
private data class AppUpdateManifest(
val versionCode: Long,
val versionName: String? = null,
val apkUrl: String
)

View File

@@ -0,0 +1,19 @@
package hu.bbara.purefin.update
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.feature.update.AppUpdateInstaller
import hu.bbara.purefin.feature.update.AppVersionProvider
@Module
@InstallIn(SingletonComponent::class)
abstract class AppUpdateModule {
@Binds
abstract fun bindAppUpdateInstaller(impl: AndroidAppUpdateInstaller): AppUpdateInstaller
@Binds
abstract fun bindAppVersionProvider(impl: BuildConfigAppVersionProvider): AppVersionProvider
}

View File

@@ -0,0 +1,9 @@
package hu.bbara.purefin.update
import hu.bbara.purefin.BuildConfig
import hu.bbara.purefin.feature.update.AppVersionProvider
import javax.inject.Inject
class BuildConfigAppVersionProvider @Inject constructor() : AppVersionProvider {
override val versionCode: Long = BuildConfig.VERSION_CODE.toLong()
}