Updated the in app updater to include notifications and updates without user action on Android 12+

This commit is contained in:
Blatzar 2022-12-11 18:14:09 +01:00
parent c11f0c101b
commit 751175b3f9
8 changed files with 354 additions and 23 deletions

View file

@ -48,7 +48,7 @@ android {
targetSdk = 33
versionCode = 55
versionName = "3.2.6"
versionName = "3.3.0"
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")

View file

@ -11,7 +11,10 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!-- Used for app update -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Used for app notifications on Android 13+ -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> <!-- not used atm, but code exist that requires it that are not run -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> <!-- Used for Android TV watch next -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <!-- Used for updates without prompt -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Used for update service -->
<!-- <permission android:name="android.permission.QUERY_ALL_PACKAGES" /> &lt;!&ndash; Used for getting if vlc is installed &ndash;&gt; -->
<!-- Fixes android tv fuckery -->
<uses-feature
@ -170,6 +173,10 @@
android:name=".ui.ControllerActivity"
android:exported="false" />
<service
android:name=".utils.PackageInstallerService"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View file

@ -88,7 +88,6 @@ import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API

View file

@ -128,7 +128,7 @@ class SettingsUpdates : PreferenceFragmentCompat() {
ioSafe {
if (activity?.runAutoUpdate(false) == false) {
activity?.runOnUiThread {
CommonActivity.showToast(
showToast(
activity,
R.string.no_update_found,
Toast.LENGTH_SHORT

View file

@ -7,16 +7,14 @@ import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.preference.PreferenceManager
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okio.BufferedSink
@ -116,6 +114,7 @@ class InAppUpdater {
)?.groupValues?.get(2)
}
}).toList().lastOrNull()
val foundAsset = found?.assets?.getOrNull(0)
val currentVersion = packageName?.let {
packageManager.getPackageInfo(
@ -245,6 +244,9 @@ class InAppUpdater {
}
}
/**
* @param checkAutoUpdate if the update check was launched automatically
**/
suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
@ -254,13 +256,20 @@ class InAppUpdater {
)
) {
val update = getAppUpdate()
if (update.shouldUpdate && update.updateURL != null) {
//Check if update should be skipped
if (
update.shouldUpdate &&
update.updateURL != null) {
// Check if update should be skipped
val updateNodeId =
settingsManager.getString(getString(R.string.skip_update_key), "")
if (update.updateNodeId.equals(updateNodeId)) {
// Skips the update if its an automatic update and the update is skipped
// This allows updating manually
if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) {
return false
}
runOnUiThread {
try {
val currentVersion = packageName?.let {
@ -283,16 +292,23 @@ class InAppUpdater {
builder.apply {
setPositiveButton(R.string.update) { _, _ ->
showToast(context, R.string.download_started, Toast.LENGTH_LONG)
ioSafe {
if (!downloadUpdate(update.updateURL))
runOnUiThread {
showToast(
context,
R.string.download_failed,
Toast.LENGTH_LONG
)
}
}
val intent = PackageInstallerService.getIntent(
context,
update.updateURL
)
ContextCompat.startForegroundService(context, intent)
// ioSafe {
// if (
// !downloadUpdate(update.updateURL)
// )
// runOnUiThread {
// showToast(
// context,
// R.string.download_failed,
// Toast.LENGTH_LONG
// )
// }
// }
}
setNegativeButton(R.string.cancel) { _, _ -> }
@ -302,8 +318,7 @@ class InAppUpdater {
settingsManager.edit().putString(
getString(R.string.skip_update_key),
update.updateNodeId ?: ""
)
.apply()
).apply()
}
}
}

View file

@ -0,0 +1,106 @@
package com.lagradost.cloudstream3.utils
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.os.Build
import com.lagradost.cloudstream3.mvvm.logError
import java.io.InputStream
const val INSTALL_ACTION = "ApkInstaller.INSTALL_ACTION"
class ApkInstaller(private val service: PackageInstallerService) {
private val packageInstaller = service.packageManager.packageInstaller
enum class InstallProgressStatus {
Preparing,
Downloading,
Installing,
Failed,
}
private val installActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.getIntExtra(
PackageInstaller.EXTRA_STATUS,
PackageInstaller.STATUS_FAILURE
)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val userAction = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
userAction?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(userAction)
}
}
}
}
fun installApk(
context: Context,
inputStream: InputStream,
size: Long,
installProgress: (bytesRead: Int) -> Unit,
installProgressStatus: (InstallProgressStatus) -> Unit
) {
installProgressStatus.invoke(InstallProgressStatus.Preparing)
var activeSession: Int? = null
try {
val installParams =
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
activeSession = packageInstaller.createSession(installParams)
installParams.setSize(size)
val session = packageInstaller.openSession(activeSession)
installProgressStatus.invoke(InstallProgressStatus.Downloading)
session.openWrite(context.packageName, 0, size)
.use { outputStream ->
val buffer = ByteArray(1024)
var bytesRead = inputStream.read(buffer)
while (bytesRead >= 0) {
outputStream.write(buffer, 0, bytesRead)
bytesRead = inputStream.read(buffer)
installProgress.invoke(bytesRead)
}
inputStream.close()
}
installProgressStatus.invoke(InstallProgressStatus.Installing)
val intentSender = PendingIntent.getBroadcast(
service,
activeSession,
Intent(INSTALL_ACTION),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
).intentSender
session.commit(intentSender)
} catch (e: Exception) {
logError(e)
service.unregisterReceiver(installActionReceiver)
installProgressStatus.invoke(InstallProgressStatus.Failed)
activeSession?.let { sessionId ->
packageInstaller.abandonSession(sessionId)
}
}
}
init {
service.registerReceiver(installActionReceiver, IntentFilter(INSTALL_ACTION))
service.receivers.add(installActionReceiver)
}
}

View file

@ -0,0 +1,200 @@
package com.lagradost.cloudstream3.utils
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.math.roundToInt
class PackageInstallerService : Service() {
val receivers = mutableListOf<BroadcastReceiver>()
private val baseNotification by lazy {
val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else 0
val intent = Intent(this, MainActivity::class.java)
val pendingIntent =
PendingIntent.getActivity(this, 0, intent, flag)
NotificationCompat.Builder(this, UPDATE_CHANNEL_ID)
.setAutoCancel(false)
.setColorized(true)
.setOnlyAlertOnce(true)
.setSilent(true)
// If low priority then the notification might not show :(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(this.colorFromAttribute(R.attr.colorPrimary))
.setContentTitle(getString(R.string.update_notification_downloading))
.setContentIntent(pendingIntent)
.setSmallIcon(R.drawable.rdload)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel =
NotificationChannel(UPDATE_CHANNEL_ID, UPDATE_CHANNEL_NAME, importance).apply {
description = UPDATE_CHANNEL_DESCRIPTION
}
// Register the channel with the system
val notificationManager: NotificationManager =
this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
override fun onCreate() {
createNotificationChannel()
startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build())
}
private val updateLock = Mutex()
private suspend fun downloadUpdate(url: String): Boolean {
try {
Log.d(LOG_TAG, "Downloading update: $url")
// Delete all old updates
ioSafe {
val appUpdateName = "CloudStream"
val appUpdateSuffix = "apk"
this@PackageInstallerService.cacheDir.listFiles()?.filter {
it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix
}?.forEach {
it.deleteOnExit()
}
}
updateLock.withLock {
updateNotificationProgress(
0f,
ApkInstaller.InstallProgressStatus.Downloading
)
val body = app.get(url).body
val inputStream = body.byteStream()
val installer = ApkInstaller(this)
val totalSize = body.contentLength()
var currentSize = 0
installer.installApk(this, inputStream, totalSize, {
currentSize += it
// Prevent div 0
if (totalSize == 0L) return@installApk
val percentage = currentSize / totalSize.toFloat()
updateNotificationProgress(
percentage,
ApkInstaller.InstallProgressStatus.Downloading
)
}) { status ->
updateNotificationProgress(0f, status)
}
}
return true
} catch (e: Exception) {
updateNotificationProgress(0f, ApkInstaller.InstallProgressStatus.Failed)
return false
}
}
private fun updateNotificationProgress(
percentage: Float,
state: ApkInstaller.InstallProgressStatus
) {
// Log.d(LOG_TAG, "Downloading app update progress $percentage | $state")
val text = when (state) {
ApkInstaller.InstallProgressStatus.Installing -> R.string.update_notification_installing
ApkInstaller.InstallProgressStatus.Preparing, ApkInstaller.InstallProgressStatus.Downloading -> R.string.update_notification_downloading
ApkInstaller.InstallProgressStatus.Failed -> R.string.update_notification_failed
}
val newNotification = baseNotification
.setContentTitle(getString(text))
.apply {
if (state == ApkInstaller.InstallProgressStatus.Failed) {
setSmallIcon(R.drawable.rderror)
setAutoCancel(true)
} else {
setProgress(
10000, (10000 * percentage).roundToInt(),
state != ApkInstaller.InstallProgressStatus.Downloading
)
}
}
.build()
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Persistent notification on failure
val id =
if (state == ApkInstaller.InstallProgressStatus.Failed) UPDATE_NOTIFICATION_ID + 1 else UPDATE_NOTIFICATION_ID
notificationManager.notify(id, newNotification)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val url = intent?.getStringExtra(EXTRA_URL) ?: return START_NOT_STICKY
ioSafe {
downloadUpdate(url)
// Close the service after the update is done
// If no sleep then the install prompt may not appear and the notification
// will disappear instantly
delay(10_000)
this@PackageInstallerService.stopSelf()
}
return START_NOT_STICKY
}
override fun onDestroy() {
receivers.forEach {
try {
this.unregisterReceiver(it)
} catch (_: IllegalArgumentException) {
// Receiver not registered
}
}
super.onDestroy()
}
override fun onBind(i: Intent?): IBinder? = null
companion object {
private const val EXTRA_URL = "EXTRA_URL"
private const val LOG_TAG = "PackageInstallerService"
const val UPDATE_CHANNEL_ID = "cloudstream3.updates"
const val UPDATE_CHANNEL_NAME = "App Updates"
const val UPDATE_CHANNEL_DESCRIPTION = "App updates notification channel"
const val UPDATE_NOTIFICATION_ID = -68454136
fun getIntent(
context: Context,
url: String,
): Intent {
return Intent(context, PackageInstallerService::class.java)
.putExtra(EXTRA_URL, url)
}
}
}

View file

@ -659,4 +659,8 @@
<string name="confirm_exit_dialog">Are you sure you want to exit?</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="update_notification_downloading">Downloading app update</string>
<string name="update_notification_installing">Installing app update</string>
<string name="update_notification_failed">App update failed</string>
</resources>