mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
19
app/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt
Normal file
19
app/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package hu.bbara.purefin.feature.update
|
||||
|
||||
interface AppUpdateInstaller {
|
||||
suspend fun installUpdate(update: AppUpdateInfo): String
|
||||
}
|
||||
@@ -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<AppUpdateManifest>(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class AppUpdateManifest(
|
||||
val versionCode: Long,
|
||||
val versionName: String? = null,
|
||||
val apkUrl: String,
|
||||
val releaseNotes: String? = null
|
||||
)
|
||||
@@ -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<Boolean> = _isCheckingForUpdates.asStateFlow()
|
||||
|
||||
private val _availableUpdate = MutableStateFlow<AppUpdateInfo?>(null)
|
||||
val availableUpdate: StateFlow<AppUpdateInfo?> = _availableUpdate.asStateFlow()
|
||||
|
||||
private val _snackbarMessages = MutableSharedFlow<String>()
|
||||
val snackbarMessages: SharedFlow<String> = _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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package hu.bbara.purefin.feature.update
|
||||
|
||||
interface AppVersionProvider {
|
||||
val versionCode: Long
|
||||
}
|
||||
Reference in New Issue
Block a user