mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
feat(tv-update): implement update check and dialog for available updates on android tv devices
This commit is contained in:
@@ -15,7 +15,7 @@ class AppUpdateRepository @Inject constructor(
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun checkForUpdate(): AppUpdateInfo? {
|
||||
val manifestUrl = "http://purefin.t.bbara.hu/app/update.json"
|
||||
val manifestUrl = appVersionProvider.updateManifestUrl
|
||||
if (manifestUrl.isBlank()) {
|
||||
throw IllegalStateException("Update manifest URL not configured")
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class AppUpdateViewModel @Inject constructor(
|
||||
|
||||
private var isInstallingUpdate = false
|
||||
|
||||
fun checkForUpdates() {
|
||||
fun checkForUpdates(showUpToDateMessage: Boolean = true) {
|
||||
if (_isCheckingForUpdates.value) {
|
||||
return
|
||||
}
|
||||
@@ -39,7 +39,9 @@ class AppUpdateViewModel @Inject constructor(
|
||||
try {
|
||||
val update = appUpdateRepository.checkForUpdate()
|
||||
if (update == null) {
|
||||
_snackbarMessages.emit("Purefin is up to date")
|
||||
if (showUpToDateMessage) {
|
||||
_snackbarMessages.emit("Purefin is up to date")
|
||||
}
|
||||
} else {
|
||||
_availableUpdate.value = update
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ package hu.bbara.purefin.feature.update
|
||||
|
||||
interface AppVersionProvider {
|
||||
val versionCode: Long
|
||||
val updateManifestUrl: String
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package hu.bbara.purefin.update
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.feature.update.AppVersionProvider
|
||||
import javax.inject.Inject
|
||||
|
||||
class AndroidAppVersionProvider @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) : AppVersionProvider {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
override val versionCode: Long
|
||||
get() = appContext.packageManager.currentPackageInfo().longVersionCode
|
||||
|
||||
override val updateManifestUrl: String
|
||||
get() = appContext.packageManager.currentApplicationInfo()
|
||||
.metaData
|
||||
?.getString(UPDATE_MANIFEST_URL_META_DATA)
|
||||
.orEmpty()
|
||||
|
||||
private fun PackageManager.currentPackageInfo(): PackageInfo =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getPackageInfo(appContext.packageName, PackageManager.PackageInfoFlags.of(0))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getPackageInfo(appContext.packageName, 0)
|
||||
}
|
||||
|
||||
private fun PackageManager.currentApplicationInfo(): ApplicationInfo =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getApplicationInfo(
|
||||
appContext.packageName,
|
||||
PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong())
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getApplicationInfo(appContext.packageName, PackageManager.GET_META_DATA)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UPDATE_MANIFEST_URL_META_DATA = "hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
138
core/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt
Normal file
138
core/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt
Normal file
@@ -0,0 +1,138 @@
|
||||
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 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 okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class AndroidAppUpdateInstaller @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) : AppUpdateInstaller {
|
||||
private val appContext = context.applicationContext
|
||||
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(update)
|
||||
.also { validateApk(it) }
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
commitInstallSession(apkFile, update.versionCode)
|
||||
}
|
||||
|
||||
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||
return "Downloaded ${appLabel()} $versionLabel"
|
||||
}
|
||||
|
||||
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)
|
||||
.build()
|
||||
val updateDir = appContext.cacheDir.resolve("updates")
|
||||
updateDir.mkdirs()
|
||||
updateDir.listFiles()?.forEach { it.delete() }
|
||||
val apkFile = File(updateDir, "purefin-${update.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) {
|
||||
appContext.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
|
||||
?: throw IllegalStateException("Downloaded file is not a valid APK")
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun appLabel(): String =
|
||||
appContext.applicationInfo.loadLabel(appContext.packageManager).toString()
|
||||
}
|
||||
@@ -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: AndroidAppVersionProvider): AppVersionProvider
|
||||
}
|
||||
Reference in New Issue
Block a user