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 hourEvery %d hours Every dayEvery %d days Every weekEvery %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" /> + + + +