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