From 5bec125f564b7031a9ce720c0c387ed295f013ed Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 3 May 2026 13:59:14 +0200 Subject: [PATCH] refactor(app): refactor update logic to make core logic more reusable by moving it into the core.feature.update package --- .../hu/bbara/purefin/ui/screen/AppScreen.kt | 81 +++++++++++++------ .../purefin/update/AppUpdateInstaller.kt | 62 ++++---------- .../bbara/purefin/update/AppUpdateModule.kt | 19 +++++ .../update/BuildConfigAppVersionProvider.kt | 9 +++ .../purefin/feature/update/AppUpdateInfo.kt | 9 +++ .../feature/update/AppUpdateInstaller.kt | 5 ++ .../feature/update/AppUpdateRepository.kt | 57 +++++++++++++ .../feature/update/AppUpdateViewModel.kt | 76 +++++++++++++++++ .../feature/update/AppVersionProvider.kt | 5 ++ 9 files changed, 251 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt create mode 100644 app/src/main/java/hu/bbara/purefin/update/BuildConfigAppVersionProvider.kt create mode 100644 core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateInfo.kt create mode 100644 core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateInstaller.kt create mode 100644 core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateRepository.kt create mode 100644 core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateViewModel.kt create mode 100644 core/src/main/java/hu/bbara/purefin/feature/update/AppVersionProvider.kt diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt index 9d97e5ed..16cf2350 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt @@ -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 @@ -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(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 = private fun Map.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" diff --git a/app/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt b/app/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt index d7138059..9a9aa857 100644 --- a/app/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt +++ b/app/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt @@ -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(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 -) diff --git a/app/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt b/app/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt new file mode 100644 index 00000000..28f6735b --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt @@ -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 +} diff --git a/app/src/main/java/hu/bbara/purefin/update/BuildConfigAppVersionProvider.kt b/app/src/main/java/hu/bbara/purefin/update/BuildConfigAppVersionProvider.kt new file mode 100644 index 00000000..9452f625 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/update/BuildConfigAppVersionProvider.kt @@ -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() +} diff --git a/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateInfo.kt b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateInfo.kt new file mode 100644 index 00000000..52632d04 --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateInfo.kt @@ -0,0 +1,9 @@ +package hu.bbara.purefin.feature.update + +data class AppUpdateInfo( + val versionCode: Long, + val versionName: String?, + val releaseNotes: String?, + val apkUrl: String, + val manifestUrl: String +) diff --git a/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateInstaller.kt b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateInstaller.kt new file mode 100644 index 00000000..97e5908e --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateInstaller.kt @@ -0,0 +1,5 @@ +package hu.bbara.purefin.feature.update + +interface AppUpdateInstaller { + suspend fun installUpdate(update: AppUpdateInfo): String +} diff --git a/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateRepository.kt b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateRepository.kt new file mode 100644 index 00000000..d7b1e653 --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateRepository.kt @@ -0,0 +1,57 @@ +package hu.bbara.purefin.feature.update + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import javax.inject.Inject + +class AppUpdateRepository @Inject constructor( + private val appVersionProvider: AppVersionProvider +) { + private val client = OkHttpClient() + private val json = Json { ignoreUnknownKeys = true } + + suspend fun checkForUpdate(): AppUpdateInfo? { + val manifestUrl = "http://purefin.t.bbara.hu/app/update.json" + if (manifestUrl.isBlank()) { + throw IllegalStateException("Update manifest URL not configured") + } + + val manifest = withContext(Dispatchers.IO) { fetchManifest(manifestUrl) } + if (manifest.versionCode <= appVersionProvider.versionCode) { + return null + } + + return AppUpdateInfo( + versionCode = manifest.versionCode, + versionName = manifest.versionName, + releaseNotes = manifest.releaseNotes, + apkUrl = manifest.apkUrl, + manifestUrl = manifestUrl + ) + } + + 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(body) + } + } +} + +@Serializable +private data class AppUpdateManifest( + val versionCode: Long, + val versionName: String? = null, + val apkUrl: String, + val releaseNotes: String? = null +) diff --git a/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateViewModel.kt b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateViewModel.kt new file mode 100644 index 00000000..eaa4904e --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateViewModel.kt @@ -0,0 +1,76 @@ +package hu.bbara.purefin.feature.update + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AppUpdateViewModel @Inject constructor( + private val appUpdateRepository: AppUpdateRepository, + private val appUpdateInstaller: AppUpdateInstaller +) : ViewModel() { + + private val _isCheckingForUpdates = MutableStateFlow(false) + val isCheckingForUpdates: StateFlow = _isCheckingForUpdates.asStateFlow() + + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + + private val _snackbarMessages = MutableSharedFlow() + val snackbarMessages: SharedFlow = _snackbarMessages.asSharedFlow() + + private var isInstallingUpdate = false + + fun checkForUpdates() { + if (_isCheckingForUpdates.value) { + return + } + + viewModelScope.launch { + _isCheckingForUpdates.value = true + try { + val update = appUpdateRepository.checkForUpdate() + if (update == null) { + _snackbarMessages.emit("Purefin is up to date") + } else { + _availableUpdate.value = update + } + } catch (e: Exception) { + _snackbarMessages.emit(e.message ?: "Update check failed") + } finally { + _isCheckingForUpdates.value = false + } + } + } + + fun acceptUpdate() { + val update = _availableUpdate.value ?: return + if (isInstallingUpdate) { + return + } + + viewModelScope.launch { + isInstallingUpdate = true + _availableUpdate.value = null + try { + _snackbarMessages.emit(appUpdateInstaller.installUpdate(update)) + } catch (e: Exception) { + _snackbarMessages.emit(e.message ?: "Update install failed") + } finally { + isInstallingUpdate = false + } + } + } + + fun declineUpdate() { + _availableUpdate.value = null + } +} diff --git a/core/src/main/java/hu/bbara/purefin/feature/update/AppVersionProvider.kt b/core/src/main/java/hu/bbara/purefin/feature/update/AppVersionProvider.kt new file mode 100644 index 00000000..bb2d7ab2 --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/feature/update/AppVersionProvider.kt @@ -0,0 +1,5 @@ +package hu.bbara.purefin.feature.update + +interface AppVersionProvider { + val versionCode: Long +}