mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
feat(update): implement in-app update check and installation process
This commit is contained in:
@@ -40,6 +40,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
174
app/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt
Normal file
174
app/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user