From acd35ed2ee8a9521ae657e842891dda429f0aada Mon Sep 17 00:00:00 2001
From: deniscerri <64997243+deniscerri@users.noreply.github.com>
Date: Sat, 5 Sep 2026 12:43:49 +0200
Subject: [PATCH] add external installer support
---
app/build.gradle | 2 +
app/src/main/AndroidManifest.xml | 6 +
.../java/com/deniscerri/ytdl/MainActivity.kt | 24 +-
.../ui/more/settings/BaseSettingsFragment.kt | 12 +
.../ytdl/ui/more/settings/SettingHost.kt | 4 +
.../ytdl/ui/more/settings/SettingsActivity.kt | 8 +
.../settings/updating/PackagesFragment.kt | 34 ++-
.../settings/updating/UpdateSettingsModule.kt | 51 +++-
.../deniscerri/ytdl/util/ApkInstallUtil.kt | 235 ++++++++++++++++++
.../java/com/deniscerri/ytdl/util/UiUtil.kt | 138 +++++++---
.../drawable/baseline_install_mobile_24.xml | 7 +
.../res/drawable/baseline_pie_chart_24.xml | 5 +
.../layout/choose_installer_app_dialog.xml | 8 +
.../main/res/layout/installer_app_card.xml | 87 +++++++
app/src/main/res/values/arrays.xml | 13 +
app/src/main/res/values/strings.xml | 5 +
app/src/main/res/xml/updating_preferences.xml | 15 ++
17 files changed, 598 insertions(+), 56 deletions(-)
create mode 100644 app/src/main/java/com/deniscerri/ytdl/util/ApkInstallUtil.kt
create mode 100644 app/src/main/res/drawable/baseline_install_mobile_24.xml
create mode 100644 app/src/main/res/drawable/baseline_pie_chart_24.xml
create mode 100644 app/src/main/res/layout/choose_installer_app_dialog.xml
create mode 100644 app/src/main/res/layout/installer_app_card.xml
diff --git a/app/build.gradle b/app/build.gradle
index 776898a8..2c43b05c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -258,4 +258,6 @@ dependencies {
//noinspection Aligned16KB
implementation 'com.github.termux.termux-app:terminal-emulator:v0.118.3'
+ implementation 'dev.rikka.shizuku:api:13.1.5'
+ implementation 'dev.rikka.shizuku:provider:13.1.5'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e68a41f2..77467adf 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -27,11 +27,17 @@
+
+
+
+
+
+
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
CrashListener(this).registerExceptionHandler()
@@ -551,9 +555,6 @@ class MainActivity : BaseActivity() {
dialog.show()
}
- private var installPackageLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- RuntimeManager.reInit(this)
- }
private fun callAutoUpdates(firstRun : Boolean = false) {
if (BuildConfig.FLAVOR == "github" && preferences.getBoolean("update_app", false)) {
@@ -574,7 +575,8 @@ class MainActivity : BaseActivity() {
layoutInflater,
updateUtil,
this@MainActivity,
- preferences
+ preferences,
+ installLauncher
)
}
}
@@ -597,6 +599,7 @@ class MainActivity : BaseActivity() {
skipRemindingPackageUpdate.add(latestRelease.tag_name)
preferences.edit().putStringSet("skip_reminding_package_update", skipRemindingPackageUpdate).apply()
withContext(Dispatchers.Main) {
+ val installIntent = ApkInstallUtil.registerInstallLauncher(this@MainActivity)
UiUtil.showNewPackageUpdateSnackBar(
latestRelease,
pkg,
@@ -606,8 +609,17 @@ class MainActivity : BaseActivity() {
navigationBarView,
layoutInflater,
this@MainActivity,
- installPackageLauncher
- )
+ installIntent
+ ) { result ->
+ result.onSuccess {
+ RuntimeManager.reInit(this@MainActivity)
+ }.onFailure { f ->
+ Snackbar.make(findViewById(R.id.frame_layout), f.message ?: "", Snackbar.LENGTH_LONG).apply {
+ anchorView = navigationBarView
+ show()
+ }
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/BaseSettingsFragment.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/BaseSettingsFragment.kt
index c158f09c..d7269d25 100644
--- a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/BaseSettingsFragment.kt
+++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/BaseSettingsFragment.kt
@@ -1,10 +1,12 @@
package com.deniscerri.ytdl.ui.more.settings
import android.annotation.SuppressLint
+import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.view.View
+import androidx.activity.result.ActivityResultLauncher
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
@@ -16,16 +18,21 @@ import androidx.preference.PreferenceGroup
import androidx.preference.PreferenceGroupAdapter
import androidx.preference.PreferenceManager
import androidx.preference.PreferenceScreen
+import com.deniscerri.ytdl.util.ApkInstallUtil
abstract class BaseSettingsFragment : PreferenceFragmentCompat(), SettingHost {
abstract val title: Int
+
+ private lateinit var installLauncher: ActivityResultLauncher
+
override fun findPref(key: String) = findPreference(key)
@SuppressLint("NotifyDataSetChanged")
override fun refreshUI() {
listView.adapter?.notifyDataSetChanged()
}
override fun getHostContext() = requireActivity()
+ override fun getAppInstallLauncher() = installLauncher
override val activityResultDelegate = PreferenceActivityResultDelegate(this)
override fun requestGetParentFragmentManager() = parentFragmentManager
override fun requestRecreateActivity() = requireActivity().recreate()
@@ -73,6 +80,11 @@ abstract class BaseSettingsFragment : PreferenceFragmentCompat(), SettingHost {
}
}
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ installLauncher = ApkInstallUtil.registerInstallLauncher(this)
+ }
+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/SettingHost.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/SettingHost.kt
index 80e7dfa5..d9812940 100644
--- a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/SettingHost.kt
+++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/SettingHost.kt
@@ -1,7 +1,10 @@
package com.deniscerri.ytdl.ui.more.settings
import android.app.Activity
+import android.content.Intent
import android.view.View
+import androidx.activity.result.ActivityResultCaller
+import androidx.activity.result.ActivityResultLauncher
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModelStoreOwner
@@ -11,6 +14,7 @@ interface SettingHost {
fun findPref(key: String): Preference?
fun refreshUI()
fun getHostContext(): Activity
+ fun getAppInstallLauncher(): ActivityResultLauncher
val hostLifecycleOwner: LifecycleOwner
val hostViewModelStoreOwner: ViewModelStoreOwner
val activityResultDelegate: PreferenceActivityResultDelegate
diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/SettingsActivity.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/SettingsActivity.kt
index 1d3543dc..add32b88 100644
--- a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/SettingsActivity.kt
+++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/SettingsActivity.kt
@@ -2,10 +2,12 @@ package com.deniscerri.ytdl.ui.more.settings
import android.annotation.SuppressLint
import android.content.Context
+import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.activity.addCallback
+import androidx.activity.result.ActivityResultLauncher
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
@@ -25,6 +27,7 @@ import com.deniscerri.ytdl.database.viewmodel.SettingsViewModel
import com.deniscerri.ytdl.databinding.ActivitySettingsBinding
import com.deniscerri.ytdl.ui.BaseActivity
import com.deniscerri.ytdl.ui.more.settings.search.SettingsSearchAdapter
+import com.deniscerri.ytdl.util.ApkInstallUtil
import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
@@ -40,6 +43,8 @@ class SettingsActivity : BaseActivity(), SettingHost {
private lateinit var navController: NavController
private lateinit var searchAdapter: SettingsSearchAdapter
+ private lateinit var installLauncher: ActivityResultLauncher
+
override fun findPref(key: String): Preference? {
return settingViewModel.settingsFlow.value.first.find { it.preference.key == key }?.preference
}
@@ -48,6 +53,7 @@ class SettingsActivity : BaseActivity(), SettingHost {
settingViewModel.indexSearchSettings()
}
override fun getHostContext() = this
+ override fun getAppInstallLauncher() = installLauncher
override val activityResultDelegate = PreferenceActivityResultDelegate(this)
override val hostViewModelStoreOwner by lazy {
this
@@ -83,6 +89,8 @@ class SettingsActivity : BaseActivity(), SettingHost {
settingViewModel = ViewModelProvider(this)[SettingsViewModel::class.java]
setContentView(binding.root)
+ installLauncher = ApkInstallUtil.registerInstallLauncher(this)
+
ViewCompat.setOnApplyWindowInsetsListener(binding.appBar) { v, insets ->
val topInset = insets.getInsets(
WindowInsetsCompat.Type.statusBars()
diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/PackagesFragment.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/PackagesFragment.kt
index 079b4e78..65e77016 100644
--- a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/PackagesFragment.kt
+++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/PackagesFragment.kt
@@ -17,6 +17,7 @@ import android.view.Window
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import androidx.core.view.isVisible
@@ -51,6 +52,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.deniscerri.ytdl.core.packages.Deno
+import com.deniscerri.ytdl.util.ApkInstallUtil
import com.deniscerri.ytdl.util.Extensions.hasPermission
@@ -68,6 +70,13 @@ class PackagesFragment : Fragment(), PackagesAdapter.OnItemClickListener, Packag
private var packages: List = mutableListOf()
private var packageReleases: List = mutableListOf()
+ private lateinit var installLauncher: ActivityResultLauncher
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ installLauncher = ApkInstallUtil.registerInstallLauncher(this)
+ }
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -161,12 +170,6 @@ class PackagesFragment : Fragment(), PackagesAdapter.OnItemClickListener, Packag
}
}
- private var installLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- bottomSheet?.dismiss()
- listAdapter.notifyDataSetChanged()
- RuntimeManager.reInit(requireContext())
- }
-
override fun onDeleteDownloadedPackageClick(item: PackageBase.PackageRelease) {
deleteDownloadedVersion(tmpItem!!, item.version)
}
@@ -191,6 +194,23 @@ class PackagesFragment : Fragment(), PackagesAdapter.OnItemClickListener, Packag
override fun onDownloadReleaseClick(item: PackageBase.PackageRelease) {
bottomSheet?.dismiss()
- UiUtil.showNewReleaseUpdateDialog(item, tmpItem!!, requireActivity(), viewLifecycleOwner, requireView(), null, installLauncher)
+
+ UiUtil.showNewReleaseUpdateDialog(
+ item,
+ tmpItem!!,
+ requireActivity(),
+ viewLifecycleOwner,
+ requireView(),
+ null,
+ installLauncher
+ ) { result ->
+ result.onSuccess {
+ bottomSheet?.dismiss()
+ listAdapter.notifyDataSetChanged()
+ RuntimeManager.reInit(requireContext())
+ }.onFailure { f ->
+ Snackbar.make(requireActivity().findViewById(R.id.frame_layout), f.message ?: "", Snackbar.LENGTH_LONG).show()
+ }
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/UpdateSettingsModule.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/UpdateSettingsModule.kt
index 55cebf90..72f858fa 100644
--- a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/UpdateSettingsModule.kt
+++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/UpdateSettingsModule.kt
@@ -2,8 +2,10 @@ package com.deniscerri.ytdl.ui.more.settings.updating
import android.content.Context
import android.content.SharedPreferences
+import android.content.pm.PackageManager
import android.view.View
import android.widget.TextView
+import androidx.core.content.PackageManagerCompat
import androidx.core.content.edit
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
@@ -13,8 +15,9 @@ import com.deniscerri.ytdl.BuildConfig
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.database.viewmodel.SettingsViewModel
import com.deniscerri.ytdl.database.viewmodel.YTDLPViewModel
-import com.deniscerri.ytdl.ui.more.settings.SettingModule
import com.deniscerri.ytdl.ui.more.settings.SettingHost
+import com.deniscerri.ytdl.ui.more.settings.SettingModule
+import com.deniscerri.ytdl.util.ApkInstallUtil
import com.deniscerri.ytdl.util.FileUtil
import com.deniscerri.ytdl.util.UiUtil
import com.deniscerri.ytdl.util.UpdateUtil
@@ -24,6 +27,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
+
object UpdateSettingsModule : SettingModule {
override fun bindLogic(pref: Preference,host: SettingHost) {
val context = pref.context
@@ -32,7 +36,7 @@ object UpdateSettingsModule : SettingModule {
val ytdlpViewModel = ViewModelProvider(host.hostViewModelStoreOwner)[YTDLPViewModel::class.java]
val settingsViewModel = ViewModelProvider(host.hostViewModelStoreOwner)[SettingsViewModel::class.java]
- val canUpdateApp = BuildConfig.FLAVOR == "github";
+ val canUpdateApp = BuildConfig.FLAVOR == "github"
when(pref.key) {
"ytdlp_source_label" -> {
@@ -90,6 +94,47 @@ object UpdateSettingsModule : SettingModule {
}
}
}
+ "apk_install_method" -> {
+ pref.apply {
+ setOnPreferenceChangeListener { _, newValue ->
+ host.findPref("apk_install_external_apk_id")?.isVisible = (newValue as String) == "external"
+ host.refreshUI()
+ true
+ }
+ }
+ }
+ "apk_install_external_apk_id" -> {
+ pref.apply {
+ isVisible = preferences.getString("apk_install_method", "system")!! == "external"
+ val packageName = preferences.getString("apk_install_external_apk_id", "")!!
+
+ fun setDetails(packageName: String) {
+ if (packageName == "") {
+ summary = context.getString(R.string.notset)
+ } else {
+ try {
+ val appIcon = host.getHostContext().packageManager.getApplicationIcon(packageName)
+ val appInfo = host.getHostContext().packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
+ icon = appIcon
+ summary = host.getHostContext().packageManager.getApplicationLabel(appInfo).toString()
+ } catch (e: Exception) {
+ summary = context.getString(R.string.notset)
+ }
+ }
+ }
+ setDetails(packageName)
+
+ onPreferenceClickListener =
+ Preference.OnPreferenceClickListener {
+ UiUtil.showChooseInstallerAppDialog(host.getHostContext()) {
+ preferences.edit().putString("apk_install_external_apk_id", it).apply()
+ setDetails(it)
+ host.refreshUI()
+ }
+ true
+ }
+ }
+ }
"version" -> {
pref.apply {
val nativeLibraryDir = context.applicationInfo?.nativeLibraryDir
@@ -113,7 +158,7 @@ object UpdateSettingsModule : SettingModule {
settingsViewModel.backup()
}
}
- UiUtil.showNewAppUpdateDialog(res.getOrNull()!!, host.getHostContext(), updateUtil, host.hostLifecycleOwner, preferences)
+ UiUtil.showNewAppUpdateDialog(res.getOrNull()!!, host.getHostContext(), updateUtil, host.hostLifecycleOwner, preferences, host.getAppInstallLauncher())
}
}
true
diff --git a/app/src/main/java/com/deniscerri/ytdl/util/ApkInstallUtil.kt b/app/src/main/java/com/deniscerri/ytdl/util/ApkInstallUtil.kt
new file mode 100644
index 00000000..6032810e
--- /dev/null
+++ b/app/src/main/java/com/deniscerri/ytdl/util/ApkInstallUtil.kt
@@ -0,0 +1,235 @@
+package com.deniscerri.ytdl.util
+
+import android.app.Activity
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.Uri
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.FileProvider
+import androidx.preference.PreferenceManager
+import com.deniscerri.ytdl.R
+import com.deniscerri.ytdl.util.Extensions.hasPermission
+import rikka.shizuku.Shizuku
+import java.io.File
+import java.lang.reflect.Method
+
+object ApkInstallUtil {
+
+ // Holds the callback for the currently in-flight "system" install,
+ // since ActivityResultLauncher's own callback is registered once and
+ // can't take a per-call lambda directly.
+ private var pendingInstallCallback: ((Result) -> Unit)? = null
+
+ /**
+ * Call this once per Activity/Fragment (e.g. in onCreate) to create the launcher.
+ * Wires the launcher's result back into whatever callback was passed to installApk().
+ */
+ fun registerInstallLauncher(
+ caller: androidx.activity.result.ActivityResultCaller
+ ): ActivityResultLauncher {
+ return caller.registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ val success = result.resultCode == Activity.RESULT_OK
+ pendingInstallCallback?.invoke(
+ if (success) Result.success(Unit)
+ else Result.failure(Exception("Install cancelled or failed"))
+ )
+ pendingInstallCallback = null
+ }
+ }
+
+ fun installApk(
+ context: Context,
+ apkFile: File,
+ installLauncher: ActivityResultLauncher,
+ onResult: (Result) -> Unit
+ ) {
+ val preferences = PreferenceManager.getDefaultSharedPreferences(context)
+ val installMethod = preferences.getString("apk_install_method", "system")
+
+ when (installMethod) {
+ "system" -> {
+ val canRequestPackageInstalls = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ android.Manifest.permission.REQUEST_INSTALL_PACKAGES.hasPermission(context)
+ } else {
+ true
+ }
+
+ if (canRequestPackageInstalls) {
+ val contentUri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", apkFile)
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(contentUri, "application/vnd.android.package-archive")
+ flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ }
+ pendingInstallCallback = onResult // set BEFORE launch
+ installLauncher.launch(intent)
+ } else {
+ onResult(Result.failure(Exception(context.getString(R.string.system_install_failed))))
+ }
+ }
+ "shizuku" -> {
+ if (!checkShizukuPermission()) {
+ onResult(Result.failure(Exception("Please start Shizuku service!")))
+ return
+ }
+ Thread {
+ val result = installApkWithShizuku(apkFile)
+ Handler(Looper.getMainLooper()).post { onResult(result) }
+ }.start()
+ }
+ "external" -> {
+ val packageName = preferences.getString("apk_install_external_apk_id", "")!!
+ if (packageName.isEmpty()) {
+ onResult(Result.failure(Exception("External Installer not configured!")))
+ return
+ }
+ installApkWithExternalInstaller(context, apkFile, packageName, onResult)
+ }
+ else -> onResult(Result.success(Unit))
+ }
+ }
+
+ private const val REQUEST_CODE_SHIZUKU = 1001
+
+ fun checkShizukuPermission(): Boolean {
+ if (!Shizuku.pingBinder()) {
+ return false // Shizuku service is not running
+ }
+
+ return if (Shizuku.isPreV11()) {
+ false
+ } else {
+ when {
+ Shizuku.checkSelfPermission() == android.content.pm.PackageManager.PERMISSION_GRANTED -> true
+ Shizuku.shouldShowRequestPermissionRationale() -> false
+ else -> {
+ Shizuku.requestPermission(REQUEST_CODE_SHIZUKU)
+ false
+ }
+ }
+ }
+ }
+
+ @Throws(Exception::class)
+ private fun newShizukuProcess(cmd: Array, env: Array?, dir: String?): Process {
+ val method: Method = Shizuku::class.java.getDeclaredMethod(
+ "newProcess",
+ Array::class.java,
+ Array::class.java,
+ String::class.java
+ )
+ method.isAccessible = true
+ return method.invoke(null, cmd, env, dir) as Process
+ }
+
+ private fun installApkWithShizuku(apkFile: File): Result {
+ return try {
+ val command = arrayOf("pm", "install", "-r", apkFile.absolutePath)
+ val process: Process = newShizukuProcess(command, null, null)
+
+ process.outputStream.use { stdin ->
+ apkFile.inputStream().use { input ->
+ input.copyTo(stdin)
+ }
+ }
+
+ val output = process.inputStream.bufferedReader().readText()
+ val errorOutput = process.errorStream.bufferedReader().readText()
+ val exitCode = process.waitFor()
+
+ if (exitCode == 0) Result.success(Unit)
+ else Result.failure(Exception(errorOutput.ifBlank { output }))
+ } catch (e: Exception) {
+ e.printStackTrace()
+ Result.failure(e)
+ }
+ }
+
+ private fun installApkWithExternalInstaller(
+ context: Context,
+ apkFile: File,
+ targetInstallerPackage: String,
+ onResult: (Result) -> Unit
+ ) {
+ try {
+ val pm = context.packageManager
+ val info = pm.getPackageArchiveInfo(apkFile.absolutePath, 0)
+ val apkPackageName = info?.packageName
+
+ if (apkPackageName == null) {
+ onResult(Result.failure(Exception("Could not read APK package info")))
+ return
+ }
+
+ val apkUri: Uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ apkFile
+ )
+
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ `package` = targetInstallerPackage
+ setDataAndType(apkUri, "application/vnd.android.package-archive")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ val filter = IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addAction(Intent.ACTION_PACKAGE_REPLACED)
+ addDataScheme("package")
+ }
+
+ var isReceiverRegistered = true
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, i: Intent) {
+ val installedPkg = i.data?.schemeSpecificPart
+ if (installedPkg == apkPackageName) {
+ if (isReceiverRegistered) {
+ isReceiverRegistered = false
+ try {
+ context.unregisterReceiver(this)
+ } catch (e: IllegalArgumentException) {
+ // already unregistered elsewhere — safe to ignore
+ }
+ }
+ Handler(Looper.getMainLooper()).post {
+ onResult(Result.success(Unit))
+ }
+ }
+ }
+ }
+
+ val appContext = context.applicationContext
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ appContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
+ } else {
+ appContext.registerReceiver(receiver, filter)
+ }
+
+ context.startActivity(intent)
+
+ // No cancellation broadcast exists, so time out after a while and
+ // just stop listening; caller won't get an explicit failure signal here.
+ Handler(Looper.getMainLooper()).postDelayed({
+ if (isReceiverRegistered) {
+ try {
+ appContext.unregisterReceiver(receiver)
+ } catch (_: Exception) {
+ }
+ isReceiverRegistered = false
+ }
+ }, 5 * 60_000L)
+
+ } catch (e: Exception) {
+ onResult(Result.failure(e))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/deniscerri/ytdl/util/UiUtil.kt b/app/src/main/java/com/deniscerri/ytdl/util/UiUtil.kt
index 5da0252b..9cb32ecd 100644
--- a/app/src/main/java/com/deniscerri/ytdl/util/UiUtil.kt
+++ b/app/src/main/java/com/deniscerri/ytdl/util/UiUtil.kt
@@ -11,6 +11,8 @@ import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.text.Editable
@@ -93,6 +95,7 @@ import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
+import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputLayout
@@ -2757,12 +2760,13 @@ object UiUtil {
layoutInflater: LayoutInflater,
updateUtil: UpdateUtil,
lifecycleOwner: LifecycleOwner,
- preferences: SharedPreferences
+ preferences: SharedPreferences,
+ installLauncher: ActivityResultLauncher
) {
val customView = layoutInflater.inflate(R.layout.layout_update_snackbar, null)
val triggerAction = View.OnClickListener {
container.removeView(customView)
- showNewAppUpdateDialog(v, context, updateUtil, lifecycleOwner, preferences)
+ showNewAppUpdateDialog(v, context, updateUtil, lifecycleOwner, preferences, installLauncher)
}
customView.setOnClickListener(triggerAction)
@@ -2774,7 +2778,14 @@ object UiUtil {
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
- fun showNewAppUpdateDialog(v: GithubRelease, context: Activity, updateUtil: UpdateUtil, lifecycleOwner: LifecycleOwner, preferences: SharedPreferences) {
+ fun showNewAppUpdateDialog(
+ v: GithubRelease,
+ context: Activity,
+ updateUtil: UpdateUtil,
+ lifecycleOwner: LifecycleOwner,
+ preferences: SharedPreferences,
+ installLauncher: ActivityResultLauncher
+ ) {
if (context.isFinishing || context.isDestroyed) return
var positiveButton: Button? = null
var tmpDownloadJob: Job? = null
@@ -2842,23 +2853,12 @@ object UiUtil {
fileResp.onSuccess { file ->
lifecycleScope.launch {
withContext(Dispatchers.Main) {
- val canRequestPackageInstalls = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- android.Manifest.permission.REQUEST_INSTALL_PACKAGES.hasPermission(context)
- } else {
- true
- }
-
- if (canRequestPackageInstalls) {
- val contentUri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file)
- val intent = Intent(Intent.ACTION_VIEW).apply {
- setDataAndType(contentUri, "application/vnd.android.package-archive")
- flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ ApkInstallUtil.installApk(context, file, installLauncher) { result ->
+ result.onSuccess {
+ }.onFailure { f ->
+ Snackbar.make(context.findViewById(R.id.frame_layout), f.message ?: "", Snackbar.LENGTH_LONG).show()
}
- context.startActivity(intent)
- } else {
- Snackbar.make(context.findViewById(R.id.frame_layout), context.getString(R.string.install_downloaded_file), Snackbar.LENGTH_LONG).show()
}
-
view.dismiss()
}
}
@@ -2876,12 +2876,13 @@ object UiUtil {
anchorView: View?,
layoutInflater: LayoutInflater,
lifecycleOwner: LifecycleOwner,
- installLauncher: ActivityResultLauncher
+ installLauncher: ActivityResultLauncher,
+ onResult: (result: Result) -> Unit
) {
val customView = layoutInflater.inflate(R.layout.layout_package_update_snackbar, null)
val triggerAction = View.OnClickListener {
container.removeView(customView)
- showNewReleaseUpdateDialog(v, packageItem, context, lifecycleOwner, frameLayout, anchorView, installLauncher)
+ showNewReleaseUpdateDialog(v, packageItem, context, lifecycleOwner, frameLayout, anchorView, installLauncher, onResult)
}
customView.setOnClickListener(triggerAction)
@@ -2900,7 +2901,8 @@ object UiUtil {
lifecycleOwner: LifecycleOwner,
activityView: View,
snackbarAnchorView: View?,
- installLauncher: ActivityResultLauncher
+ installLauncher: ActivityResultLauncher,
+ onResult: (result: Result) -> Unit
) {
var tmpDownloadJob : Job? = null
@@ -2959,25 +2961,7 @@ object UiUtil {
lifecycleScope.launch {
withContext(Dispatchers.Main) {
view.dismiss()
-
- val canRequestPackageInstalls = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- android.Manifest.permission.REQUEST_INSTALL_PACKAGES.hasPermission(context)
- } else {
- true
- }
-
- if (canRequestPackageInstalls) {
- val contentUri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file)
- val intent = Intent(Intent.ACTION_VIEW).apply {
- setDataAndType(contentUri, "application/vnd.android.package-archive")
- flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
- }
- installLauncher.launch(intent)
- } else {
- val snackbar = Snackbar.make(activityView, context.getString(R.string.install_downloaded_file), Snackbar.LENGTH_LONG)
- snackbar.anchorView = snackbarAnchorView
- snackbar.show()
- }
+ ApkInstallUtil.installApk(context, file, installLauncher, onResult)
}
}
}
@@ -3196,4 +3180,78 @@ object UiUtil {
toTextInput.editText!!.setText(maxDelay)
}
+ fun showChooseInstallerAppDialog(context: Activity, appIdSelected: (appId: String) -> Unit) {
+ val builder = MaterialAlertDialogBuilder(context)
+ builder.setTitle(context.getString(R.string.choose_installer_app))
+ builder.setIcon(R.drawable.baseline_install_mobile_24)
+ val view = context.layoutInflater.inflate(R.layout.choose_installer_app_dialog, null)
+
+ data class ExternalInstallerApp(
+ val appName: String,
+ val packageName: String,
+ val icon: Drawable
+ )
+
+ lateinit var alertDialog: AlertDialog
+
+ val installersList = mutableListOf()
+ val packageManager = context.packageManager
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(Uri.parse("content://dummy"), "application/vnd.android.package-archive")
+ }
+
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong())
+ } else {
+ 0
+ }
+
+ val resolveInfoList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ packageManager.queryIntentActivities(intent, flags as PackageManager.ResolveInfoFlags)
+ } else {
+ @Suppress("DEPRECATION")
+ packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL)
+ }
+
+ for (resolveInfo in resolveInfoList) {
+ val activityInfo = resolveInfo.activityInfo ?: continue
+ val appPackageName = activityInfo.packageName
+
+ // Exclude your own app from the list of external options
+ if (appPackageName == context.packageName) continue
+
+ val appName = resolveInfo.loadLabel(packageManager).toString()
+ val appIcon = resolveInfo.loadIcon(packageManager)
+
+ installersList.add(
+ ExternalInstallerApp(
+ appName = appName,
+ packageName = appPackageName,
+ icon = appIcon
+ )
+ )
+ }
+
+ val finalList = installersList.distinctBy { it.packageName }.sortedBy { it.appName }
+ if (finalList.isEmpty()) {
+ return
+ }
+
+ finalList.forEach { app ->
+ val card = context.layoutInflater.inflate(R.layout.installer_app_card, null)
+ card.findViewById(R.id.app_name).text = app.appName
+ card.findViewById(R.id.app_icon).setImageDrawable(app.icon)
+
+ card.setOnClickListener {
+ appIdSelected(app.packageName)
+ alertDialog.dismiss()
+ }
+
+ (view as LinearLayout).addView(card)
+ }
+
+ builder.setView(view)
+ alertDialog = builder.create()
+ alertDialog.show()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/baseline_install_mobile_24.xml b/app/src/main/res/drawable/baseline_install_mobile_24.xml
new file mode 100644
index 00000000..59d8e471
--- /dev/null
+++ b/app/src/main/res/drawable/baseline_install_mobile_24.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/baseline_pie_chart_24.xml b/app/src/main/res/drawable/baseline_pie_chart_24.xml
new file mode 100644
index 00000000..ba142d99
--- /dev/null
+++ b/app/src/main/res/drawable/baseline_pie_chart_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/choose_installer_app_dialog.xml b/app/src/main/res/layout/choose_installer_app_dialog.xml
new file mode 100644
index 00000000..b8821855
--- /dev/null
+++ b/app/src/main/res/layout/choose_installer_app_dialog.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/installer_app_card.xml b/app/src/main/res/layout/installer_app_card.xml
new file mode 100644
index 00000000..7397aa76
--- /dev/null
+++ b/app/src/main/res/layout/installer_app_card.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index cb67abd4..fb84c131 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -1612,4 +1612,17 @@
- playlist
- media_type
+
+
+ - @string/system
+ - Shizuku
+ - @string/external
+
+
+
+ - system
+ - shizuku
+ - external
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5494db0a..4901ea86 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -574,6 +574,11 @@
This is a JavaScript HTTP server, on port 4416. If down, it automatically starts the server when download queue starts executing, or observe sources is scheduled. To shut it down, you have to do it manually from notifications!
Please install package %s
Please install %1$s package or %2$s package
+ Installation method
+ System install failed. Please choose another installation method in the settings!
+ Choose an installer app
+ Not set
+ External
- Every hour
- Every %d hours
- Every day
- Every %d days
- Every week
- Every %d weeks
diff --git a/app/src/main/res/xml/updating_preferences.xml b/app/src/main/res/xml/updating_preferences.xml
index e19ef85e..bb8b1e66 100644
--- a/app/src/main/res/xml/updating_preferences.xml
+++ b/app/src/main/res/xml/updating_preferences.xml
@@ -75,6 +75,21 @@
app:key="packages"
app:title="@string/packages" />
+
+
+
+