feat(update): implement in-app update check and installation process

This commit is contained in:
2026-05-03 13:39:27 +02:00
parent ae4c2b34f4
commit 3a748397f8
7 changed files with 273 additions and 0 deletions

View File

@@ -40,6 +40,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}

View File

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".PurefinApplication"
@@ -16,6 +18,7 @@
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Purefin">
<activity
android:name=".PurefinActivity"
@@ -36,6 +39,9 @@
android:name="hu.bbara.purefin.download.PurefinDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".update.AppUpdateInstallReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -5,11 +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.SnackbarHostState
import androidx.compose.runtime.Composable
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
@@ -20,9 +25,11 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import hu.bbara.purefin.feature.browse.home.AppViewModel
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
@@ -37,6 +44,11 @@ fun AppScreen(
val nextUp by viewModel.nextUp.collectAsState()
val isRefreshing by viewModel.isRefreshing.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>
@@ -56,6 +68,21 @@ 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)) {
@@ -75,9 +102,12 @@ fun AppScreen(
)
},
onProfileClick = {},
onCheckForUpdates = onCheckForUpdates,
isCheckingForUpdates = isCheckingForUpdates,
onSettingsClick = {},
onLogoutClick = viewModel::logout,
onSearchClick = viewModel::openSearch,
snackbarHostState = snackbarHostState,
selectedTab = selectedTab,
onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize()

View File

@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import hu.bbara.purefin.ui.model.LibraryUiModel
@@ -25,9 +27,12 @@ fun HomeScreen(
onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryUiModel) -> Unit,
onProfileClick: () -> Unit,
onCheckForUpdates: () -> Unit,
isCheckingForUpdates: Boolean,
onSettingsClick: () -> Unit,
onLogoutClick: () -> Unit,
onSearchClick: () -> Unit,
snackbarHostState: SnackbarHostState,
selectedTab: Int,
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier
@@ -41,10 +46,13 @@ fun HomeScreen(
HomeTopBar(
onSearchClick = onSearchClick,
onProfileClick = onProfileClick,
onCheckForUpdates = onCheckForUpdates,
isCheckingForUpdates = isCheckingForUpdates,
onSettingsClick = onSettingsClick,
onLogoutClick = onLogoutClick
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
AppBottomBar(
selectedTab = selectedTab,

View File

@@ -19,6 +19,8 @@ import androidx.compose.ui.unit.dp
fun HomeTopBar(
onSearchClick: () -> Unit,
onProfileClick: () -> Unit,
onCheckForUpdates: () -> Unit,
isCheckingForUpdates: Boolean,
onSettingsClick: () -> Unit,
onLogoutClick: () -> Unit,
) {
@@ -44,6 +46,14 @@ fun HomeTopBar(
onProfileClick()
}
)
DropdownMenuItem(
text = { Text(if (isCheckingForUpdates) "Checking updates..." else "Check for updates") },
enabled = !isCheckingForUpdates,
onClick = {
isProfileMenuExpanded = false
onCheckForUpdates()
}
)
DropdownMenuItem(
text = { Text("Settings") },
onClick = {

View File

@@ -0,0 +1,44 @@
package hu.bbara.purefin.update
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.util.Log
class AppUpdateInstallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirmIntent = intent.pendingUserActionIntent() ?: return
confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(confirmIntent)
} catch (e: ActivityNotFoundException) {
Log.e(TAG, "Install confirmation activity missing", e)
}
}
PackageInstaller.STATUS_SUCCESS -> Unit
else -> {
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
Log.e(TAG, "Install failed: $status $message")
}
}
}
private fun Intent.pendingUserActionIntent(): Intent? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(Intent.EXTRA_INTENT)
}
}
private companion object {
const val TAG = "AppUpdateInstaller"
}
}

View File

@@ -0,0 +1,174 @@
package hu.bbara.purefin.update
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import hu.bbara.purefin.BuildConfig
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
class AppUpdateInstaller(
context: Context,
private val client: OkHttpClient = OkHttpClient()
) {
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"
}
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) }
}
withContext(Dispatchers.IO) {
commitInstallSession(apkFile, manifest.versionCode)
}
val versionLabel = manifest.versionName?.takeIf { it.isNotBlank() } ?: manifest.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)
?: throw IllegalStateException("APK URL invalid")
val request = Request.Builder()
.url(apkUrl)
.build()
val updateDir = appContext.cacheDir.resolve("updates")
updateDir.mkdirs()
updateDir.listFiles()?.forEach { it.delete() }
val apkFile = File(updateDir, "purefin-${manifest.versionCode}.apk")
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("APK download failed: HTTP ${response.code}")
}
val body = response.body ?: throw IllegalStateException("APK download empty")
body.byteStream().use { input ->
apkFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
return apkFile
}
private fun validateApk(apkFile: File, expectedVersionCode: Long) {
val packageInfo = appContext.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
?: throw IllegalStateException("Downloaded file is not a valid APK")
// if (packageInfo.packageName != appContext.packageName) {
// throw IllegalStateException("Downloaded APK package does not match Purefin")
// }
// if (packageInfo.longVersionCode != expectedVersionCode) {
// throw IllegalStateException("Downloaded APK version does not match update manifest")
// }
// if (packageInfo.longVersionCode <= BuildConfig.VERSION_CODE.toLong()) {
// throw IllegalStateException("Downloaded APK is not newer")
// }
}
private fun commitInstallSession(apkFile: File, versionCode: Long) {
val installer = appContext.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(appContext.packageName)
setInstallReason(PackageManager.INSTALL_REASON_USER)
setSize(apkFile.length())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
setPackageSource(PackageInstaller.PACKAGE_SOURCE_DOWNLOADED_FILE)
}
}
val sessionId = installer.createSession(params)
try {
installer.openSession(sessionId).use { session ->
apkFile.inputStream().use { input ->
session.openWrite("purefin-$versionCode.apk", 0, apkFile.length()).use { output ->
input.copyTo(output)
session.fsync(output)
}
}
session.commit(statusReceiver(sessionId))
}
} catch (e: Exception) {
installer.abandonSession(sessionId)
throw e
}
}
private fun statusReceiver(sessionId: Int): IntentSender {
val intent = Intent(appContext, AppUpdateInstallReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
appContext,
sessionId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
return pendingIntent.intentSender
}
private fun openInstallPermissionSettings() {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${appContext.packageName}")
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
appContext.startActivity(intent)
} catch (_: ActivityNotFoundException) {
appContext.startActivity(
Intent(Settings.ACTION_SECURITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}
@Serializable
private data class AppUpdateManifest(
val versionCode: Long,
val versionName: String? = null,
val apkUrl: String
)