mirror of https://github.com/deniscerri/ytdlnis
add external installer support
parent
d4cf8accd2
commit
acd35ed2ee
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue