diff --git a/.editorconfig b/.editorconfig index a02bac8fd..c7c0ccbc7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,7 +7,7 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true -[*.{xml,sq,sqm}] +[*.{xml,sq,sqm,aidl}] indent_size = 4 # noinspection EditorConfigKeyCorrectness diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 16eee105b..7e4cf78a4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -138,9 +138,9 @@ android { buildFeatures { viewBinding = true buildConfig = true + aidl = true // Disable some unused things - aidl = false renderScript = false shaders = false } diff --git a/app/src/main/aidl/mihon/app/shizuku/IShellInterface.aidl b/app/src/main/aidl/mihon/app/shizuku/IShellInterface.aidl new file mode 100644 index 000000000..3f56b52d2 --- /dev/null +++ b/app/src/main/aidl/mihon/app/shizuku/IShellInterface.aidl @@ -0,0 +1,7 @@ +package mihon.app.shizuku; + +interface IShellInterface { + void install(in AssetFileDescriptor apk) = 1; + + void destroy() = 16777114; +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt index 24a8cb377..303a2dad8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt @@ -1,27 +1,73 @@ package eu.kanade.tachiyomi.extension.installer import android.app.Service +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.content.pm.PackageInstaller import android.content.pm.PackageManager -import android.os.Process +import android.os.IBinder +import androidx.core.content.ContextCompat +import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.extension.model.InstallStep -import eu.kanade.tachiyomi.util.system.getUriSize import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch import logcat.LogPriority +import mihon.app.shizuku.IShellInterface +import mihon.app.shizuku.ShellInterface import rikka.shizuku.Shizuku import tachiyomi.core.common.util.system.logcat import tachiyomi.i18n.MR -import java.io.BufferedReader -import java.io.InputStream class ShizukuInstaller(private val service: Service) : Installer(service) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var shellInterface: IShellInterface? = null + + private val shizukuArgs by lazy { + Shizuku.UserServiceArgs( + ComponentName(service, ShellInterface::class.java), + ) + .tag("shizuku_service") + .processNameSuffix("shizuku_service") + .debuggable(BuildConfig.DEBUG) + .daemon(false) + } + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + shellInterface = IShellInterface.Stub.asInterface(service) + ready = true + checkQueue() + } + + override fun onServiceDisconnected(name: ComponentName?) { + shellInterface = null + } + } + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Int.MIN_VALUE) + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) + + if (status == PackageInstaller.STATUS_SUCCESS) { + continueQueue(InstallStep.Installed) + } else { + logcat(LogPriority.ERROR) { "Failed to install extension $packageName: $message" } + continueQueue(InstallStep.Error) + } + } + } + private val shizukuDeadListener = Shizuku.OnBinderDeadListener { logcat { "Shizuku was killed prematurely" } service.stopSelf() @@ -31,8 +77,8 @@ class ShizukuInstaller(private val service: Service) : Installer(service) { override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) { if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) { if (grantResult == PackageManager.PERMISSION_GRANTED) { - ready = true checkQueue() + Shizuku.bindUserService(shizukuArgs, connection) } else { service.stopSelf() } @@ -41,40 +87,34 @@ class ShizukuInstaller(private val service: Service) : Installer(service) { } } + fun initShizuku() { + if (ready) return + if (!Shizuku.pingBinder()) { + logcat(LogPriority.ERROR) { "Shizuku is not ready to use" } + service.toast(MR.strings.ext_installer_shizuku_stopped) + service.stopSelf() + return + } + + if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { + Shizuku.bindUserService(shizukuArgs, connection) + } else { + Shizuku.addRequestPermissionResultListener(shizukuPermissionListener) + Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE) + } + } + override var ready = false override fun processEntry(entry: Entry) { super.processEntry(entry) - scope.launch { - var sessionId: String? = null - try { - val size = service.getUriSize(entry.uri) ?: throw IllegalStateException() - service.contentResolver.openInputStream(entry.uri)!!.use { - val userId = Process.myUserHandle().hashCode() - val createCommand = "pm install-create --user $userId -r -i ${service.packageName} -S $size" - val createResult = exec(createCommand) - sessionId = SESSION_ID_REGEX.find(createResult.out)?.value - ?: throw RuntimeException("Failed to create install session") - - val writeResult = exec("pm install-write -S $size $sessionId base -", it) - if (writeResult.resultCode != 0) { - throw RuntimeException("Failed to write APK to session $sessionId") - } - - val commitResult = exec("pm install-commit $sessionId") - if (commitResult.resultCode != 0) { - throw RuntimeException("Failed to commit install session $sessionId") - } - - continueQueue(InstallStep.Installed) - } - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } - if (sessionId != null) { - exec("pm install-abandon $sessionId") - } - continueQueue(InstallStep.Error) - } + try { + shellInterface?.install( + service.contentResolver.openAssetFileDescriptor(entry.uri, "r"), + ) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } + continueQueue(InstallStep.Error) } } @@ -84,41 +124,26 @@ class ShizukuInstaller(private val service: Service) : Installer(service) { override fun onDestroy() { Shizuku.removeBinderDeadListener(shizukuDeadListener) Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener) + Shizuku.unbindUserService(shizukuArgs, connection, true) + service.unregisterReceiver(receiver) + logcat { "ShizukuInstaller destroy" } scope.cancel() super.onDestroy() } - private fun exec(command: String, stdin: InputStream? = null): ShellResult { - @Suppress("DEPRECATION") - val process = Shizuku.newProcess(arrayOf("sh", "-c", command), null, null) - if (stdin != null) { - process.outputStream.use { stdin.copyTo(it) } - } - val output = process.inputStream.bufferedReader().use(BufferedReader::readText) - val resultCode = process.waitFor() - return ShellResult(resultCode, output) - } - - private data class ShellResult(val resultCode: Int, val out: String) - init { Shizuku.addBinderDeadListener(shizukuDeadListener) - ready = if (Shizuku.pingBinder()) { - if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { - true - } else { - Shizuku.addRequestPermissionResultListener(shizukuPermissionListener) - Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE) - false - } - } else { - logcat(LogPriority.ERROR) { "Shizuku is not ready to use" } - service.toast(MR.strings.ext_installer_shizuku_stopped) - service.stopSelf() - false - } + + ContextCompat.registerReceiver( + service, + receiver, + IntentFilter(ACTION_INSTALL_RESULT), + ContextCompat.RECEIVER_EXPORTED, + ) + + initShizuku() } } private const val SHIZUKU_PERMISSION_REQUEST_CODE = 14045 -private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])") +const val ACTION_INSTALL_RESULT = "${BuildConfig.APPLICATION_ID}.ACTION_INSTALL_RESULT" diff --git a/app/src/main/java/mihon/app/shizuku/ShellInterface.kt b/app/src/main/java/mihon/app/shizuku/ShellInterface.kt new file mode 100644 index 000000000..3326ff270 --- /dev/null +++ b/app/src/main/java/mihon/app/shizuku/ShellInterface.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2024 Mihon Open Source Project + * Copyright 2015-2024 Javier Tomás + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * The file contains code originally licensed under the MIT license: + * + * Copyright (c) 2024 Zachary Wander + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package mihon.app.shizuku + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.pm.PackageInstaller +import android.content.res.AssetFileDescriptor +import android.os.Build +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.os.UserHandle +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.extension.installer.ACTION_INSTALL_RESULT +import rikka.shizuku.SystemServiceHelper +import java.io.OutputStream +import kotlin.system.exitProcess + +class ShellInterface : IShellInterface.Stub() { + + private val context = createContext() + private val userId = UserHandle::class.java + .getMethod("myUserId") + .invoke(null) as Int + private val packageName = BuildConfig.APPLICATION_ID + + @SuppressLint("PrivateApi") + override fun install(apk: AssetFileDescriptor) { + val pmInterface = Class.forName($$"android.content.pm.IPackageManager$Stub") + .getMethod("asInterface", IBinder::class.java) + .invoke(null, SystemServiceHelper.getSystemService("package")) + + val packageInstaller = Class.forName("android.content.pm.IPackageManager") + .getMethod("getPackageInstaller") + .invoke(pmInterface) + + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + setInstallerPackageName(packageName) + } + } + + val sessionId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + packageInstaller::class.java.getMethod( + "createSession", + PackageInstaller.SessionParams::class.java, + String::class.java, + String::class.java, + Int::class.java, + ).invoke(packageInstaller, params, packageName, packageName, userId) as Int + } else { + packageInstaller::class.java.getMethod( + "createSession", + PackageInstaller.SessionParams::class.java, + String::class.java, + Int::class.java, + ).invoke(packageInstaller, params, packageName, userId) as Int + } + + val session = packageInstaller::class.java + .getMethod("openSession", Int::class.java) + .invoke(packageInstaller, sessionId) + + ( + session::class.java.getMethod( + "openWrite", + String::class.java, + Long::class.java, + Long::class.java, + ).invoke(session, "extension", 0L, apk.length) as ParcelFileDescriptor + ).let { fd -> + val revocable = Class.forName("android.os.SystemProperties") + .getMethod("getBoolean", String::class.java, Boolean::class.java) + .invoke(null, "fw.revocable_fd", false) as Boolean + + if (revocable) { + ParcelFileDescriptor.AutoCloseOutputStream(fd) + } else { + Class.forName($$"android.os.FileBridge$FileBridgeOutputStream") + .getConstructor(ParcelFileDescriptor::class.java) + .newInstance(fd) as OutputStream + } + } + .use { output -> + apk.createInputStream().use { input -> input.copyTo(output) } + } + + val statusIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_INSTALL_RESULT).setPackage(packageName), + PendingIntent.FLAG_MUTABLE, + ) + + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) { + session::class.java.getMethod("commit", IntentSender::class.java, Boolean::class.java) + .invoke(session, statusIntent.intentSender, false) + } else { + session::class.java.getMethod("commit", IntentSender::class.java) + .invoke(session, statusIntent.intentSender) + } + } + + override fun destroy() { + exitProcess(0) + } + + @SuppressLint("PrivateApi") + private fun createContext(): Context { + val activityThread = Class.forName("android.app.ActivityThread") + val systemMain = activityThread.getMethod("systemMain").invoke(null) + val systemContext = activityThread.getMethod("getSystemContext").invoke(systemMain) as Context + + val shellUserHandle = UserHandle::class.java + .getConstructor(Int::class.java) + .newInstance(userId) + + val shellContext = systemContext::class.java.getMethod( + "createPackageContextAsUser", + String::class.java, + Int::class.java, + UserHandle::class.java, + ).invoke( + systemContext, + "com.android.shell", + Context.CONTEXT_INCLUDE_CODE or Context.CONTEXT_IGNORE_SECURITY, + shellUserHandle, + ) as Context + + return shellContext.createPackageContext("com.android.shell", 0) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84148a13e..e4a33830c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ aboutlib_version = "13.1.0" leakcanary = "2.14" moko = "0.25.1" okhttp_version = "5.3.0" -shizuku_version = "13.1.0" +shizuku_version = "13.1.5" sqldelight = "2.1.0" sqlite = "2.6.1" voyager = "1.1.0-beta03"