forked from recloudstream/cloudstream
Updated the in app updater to include notifications and updates without user action on Android 12+
This commit is contained in:
parent
c11f0c101b
commit
751175b3f9
8 changed files with 354 additions and 23 deletions
|
@ -48,7 +48,7 @@ android {
|
|||
targetSdk = 33
|
||||
|
||||
versionCode = 55
|
||||
versionName = "3.2.6"
|
||||
versionName = "3.3.0"
|
||||
|
||||
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
|
||||
|
||||
|
|
|
@ -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" /> <!– Used for getting if vlc is installed –> -->
|
||||
<!-- 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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
val intent = PackageInstallerService.getIntent(
|
||||
context,
|
||||
R.string.download_failed,
|
||||
Toast.LENGTH_LONG
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue