diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e389f7b0..fa1cabaa 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -48,7 +48,7 @@ android {
targetSdk = 33
versionCode = 55
- versionName = "3.2.6"
+ versionName = "3.3.0"
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 11b82afb..871c4f69 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,7 +11,10 @@
-
+
+
+
+
+
+
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()
}
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt
new file mode 100644
index 00000000..5cf6c359
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt
@@ -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.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)
+ }
+}
+
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt
new file mode 100644
index 00000000..fc50bed5
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt
@@ -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()
+
+ 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)
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4a4e6505..5be5ab3f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -659,4 +659,8 @@
Are you sure you want to exit?
Yes
No
+
+ Downloading app update
+ Installing app update
+ App update failed