add external installer support

pull/1327/merge
deniscerri 3 weeks ago
parent d4cf8accd2
commit acd35ed2ee
No known key found for this signature in database
GPG Key ID: 95C43D517D830350

@ -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'
}

@ -27,11 +27,17 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<queries>
<!-- YTDLnis recognized packages -->
<package android:name="com.deniscerri.ytdl.python" />
<package android:name="com.deniscerri.ytdl.ffmpeg" />
<package android:name="com.deniscerri.ytdl.nodejs" />
<package android:name="com.deniscerri.ytdl.deno" />
<package android:name="com.deniscerri.ytdl.aria2c" />
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent>
</queries>
<application

@ -21,6 +21,7 @@ import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
@ -58,6 +59,7 @@ import com.deniscerri.ytdl.ui.HomeFragment
import com.deniscerri.ytdl.ui.downloads.DownloadQueueMainFragment
import com.deniscerri.ytdl.ui.downloads.HistoryFragment
import com.deniscerri.ytdl.ui.more.settings.SettingsActivity
import com.deniscerri.ytdl.util.ApkInstallUtil
import com.deniscerri.ytdl.util.CrashListener
import com.deniscerri.ytdl.util.NavbarUtil
import com.deniscerri.ytdl.util.NavbarUtil.applyNavBarStyle
@ -110,6 +112,8 @@ class MainActivity : BaseActivity() {
private lateinit var navController : NavController
private var loadingRuntimeDialog: androidx.appcompat.app.AlertDialog? = null
private lateinit var installLauncher: ActivityResultLauncher<Intent>
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()
}
}
}
}
}
}

@ -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<Intent>
override fun findPref(key: String) = findPreference<Preference>(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)

@ -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<Intent>
val hostLifecycleOwner: LifecycleOwner
val hostViewModelStoreOwner: ViewModelStoreOwner
val activityResultDelegate: PreferenceActivityResultDelegate

@ -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<Intent>
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()

@ -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<PackageItem> = mutableListOf()
private var packageReleases: List<PackageBase.PackageRelease> = mutableListOf()
private lateinit var installLauncher: ActivityResultLauncher<Intent>
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()
}
}
}
}

@ -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

@ -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>) -> 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<Intent> {
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<Intent>,
onResult: (Result<Unit>) -> 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<String>, env: Array<String>?, dir: String?): Process {
val method: Method = Shizuku::class.java.getDeclaredMethod(
"newProcess",
Array<String>::class.java,
Array<String>::class.java,
String::class.java
)
method.isAccessible = true
return method.invoke(null, cmd, env, dir) as Process
}
private fun installApkWithShizuku(apkFile: File): Result<Unit> {
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>) -> 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))
}
}
}

@ -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<Intent>
) {
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<Intent>
) {
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<Intent>
installLauncher: ActivityResultLauncher<Intent>,
onResult: (result: Result<Unit>) -> 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<Intent>
installLauncher: ActivityResultLauncher<Intent>,
onResult: (result: Result<Unit>) -> 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<ExternalInstallerApp>()
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<TextView>(R.id.app_name).text = app.appName
card.findViewById<ShapeableImageView>(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()
}
}

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?android:colorAccent" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17,18H7V6h7V1H7C5.9,1 5,1.9 5,3v18c0,1.1 0.9,2 2,2h10c1.1,0 2,-0.9 2,-2v-5h-2V18z"/>
<path android:fillColor="@android:color/white" android:pathData="M18,14l5,-5l-1.41,-1.41l-2.59,2.58l0,-7.17l-2,0l0,7.17l-2.59,-2.58l-1.41,1.41z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?android:colorAccent" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M11,2v20c-5.07,-0.5 -9,-4.79 -9,-10s3.93,-9.5 9,-10zM13.03,2v8.99L22,10.99c-0.47,-4.74 -4.24,-8.52 -8.97,-8.99zM13.03,13.01L13.03,22c4.74,-0.47 8.5,-4.25 8.97,-8.99h-8.97z"/>
</vector>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:orientation="vertical"
android:layout_height="match_parent">
</LinearLayout>

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView android:id="@+id/download_card_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:backgroundTint="@android:color/transparent"
android:checkable="true"
android:clickable="true"
android:focusable="true"
app:checkedIcon="@null"
app:strokeColor="?attr/colorPrimary"
app:cardPreventCornerOverlap="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Avatar"
app:strokeWidth="0dp"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:paddingEnd="20dp"
android:paddingStart="10dp"
android:paddingVertical="10dp"
android:layout_height="wrap_content">
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/checkBox"
android:layout_width="wrap_content"
android:minWidth="0dp"
android:visibility="gone"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/app_icon"
android:layout_width="0dp"
android:layout_height="0dp"
android:adjustViewBounds="true"
android:layout_marginStart="10dp"
android:background="?attr/colorSurfaceVariant"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toStartOf="@+id/download_item_data"
app:layout_constraintHorizontal_weight="0.1"
app:layout_constraintStart_toEndOf="@id/checkBox"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Avatar2" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/download_item_data"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_weight="0.7"
app:layout_constraintStart_toEndOf="@+id/app_icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0">
<TextView
android:id="@+id/app_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:paddingHorizontal="5dp"
android:scrollbars="none"
android:textSize="18sp"
android:layout_margin="20dp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

@ -1612,4 +1612,17 @@
<item>playlist</item>
<item>media_type</item>
</string-array>
<string-array name="apk_install_methods">
<item>@string/system</item>
<item>Shizuku</item>
<item>@string/external</item>
</string-array>
<string-array name="apk_install_methods_values">
<item>system</item>
<item>shizuku</item>
<item>external</item>
</string-array>
</resources>

@ -574,6 +574,11 @@
<string name="bgutils_pot_server_summary">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!</string>
<string name="please_install_package">Please install package %s</string>
<string name="please_install_package_or_package">Please install %1$s package or %2$s package</string>
<string name="installation_method">Installation method</string>
<string name="system_install_failed">System install failed. Please choose another installation method in the settings!</string>
<string name="choose_installer_app">Choose an installer app</string>
<string name="notset">Not set</string>
<string name="external">External</string>
<plurals name="every_hours"><item quantity="one">Every hour</item><item quantity="other">Every %d hours</item></plurals>
<plurals name="every_days"><item quantity="one">Every day</item><item quantity="other">Every %d days</item></plurals>
<plurals name="every_weeks"><item quantity="one">Every week</item><item quantity="other">Every %d weeks</item></plurals>

@ -75,6 +75,21 @@
app:key="packages"
app:title="@string/packages" />
<ListPreference
app:icon="@drawable/baseline_install_mobile_24"
android:defaultValue="system"
android:entries="@array/apk_install_methods"
android:entryValues="@array/apk_install_methods_values"
app:key="apk_install_method"
app:useSimpleSummaryProvider="true"
app:title="@string/installation_method" />
<Preference
app:icon="@drawable/baseline_pie_chart_24"
app:key="apk_install_external_apk_id"
app:isPreferenceVisible="false"
app:title="@string/choose_installer_app" />
<Preference
app:icon="@drawable/ic_chapters"
app:key="changelog"

Loading…
Cancel
Save