Implement install patched app & Refactor UI

This commit is contained in:
Nullptr 2022-05-05 21:07:45 +08:00
parent f5d2c20a37
commit 5d3f0ec9f7
20 changed files with 578 additions and 250 deletions

View File

@ -46,8 +46,8 @@ val coreVerName by extra(
val androidMinSdkVersion by extra(28)
val androidTargetSdkVersion by extra(32)
val androidCompileSdkVersion by extra(32)
val androidCompileNdkVersion by extra("23.1.7779620")
val androidBuildToolsVersion by extra("31.0.0")
val androidCompileNdkVersion by extra("24.0.8215888")
val androidBuildToolsVersion by extra("32.0.0")
val androidSourceCompatibility by extra(JavaVersion.VERSION_11)
val androidTargetCompatibility by extra(JavaVersion.VERSION_11)

View File

@ -5,6 +5,7 @@ val coreVerName: String by rootProject.extra
plugins {
id("com.android.application")
id("dev.rikka.tools.refine")
id("kotlin-parcelize")
kotlin("android")
}
@ -64,10 +65,13 @@ afterEvaluate {
}
dependencies {
implementation(projects.hiddenapi.bridge)
implementation(projects.patch)
implementation(projects.services.daemonService)
implementation(projects.share.android)
compileOnly("dev.rikka.hidden:stub:2.3.1")
implementation("dev.rikka.hidden:compat:2.3.1")
implementation("androidx.core:core-ktx:1.7.0")
implementation("androidx.activity:activity-compose:1.6.0-alpha01")
implementation("androidx.compose.material:material-icons-extended:1.1.1")
@ -84,4 +88,5 @@ dependencies {
implementation("com.google.android.material:material:1.5.0")
implementation("dev.rikka.shizuku:api:12.1.0")
implementation("dev.rikka.shizuku:provider:12.1.0")
implementation("org.lsposed.hiddenapibypass:hiddenapibypass:4.3")
}

View File

@ -2,5 +2,8 @@ package org.lsposed.lspatch
object Constants {
const val PREFS_KEYSTORE_PASSWORD = "keystore_password"
const val PREFS_KEYSTORE_ALIAS = "keystore_alias"
const val PREFS_KEYSTORE_ALIAS_PASSWORD = "keystore_alias_password"
const val PREFS_STORAGE_DIRECTORY = "storage_directory"
}

View File

@ -3,39 +3,27 @@ package org.lsposed.lspatch
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import rikka.shizuku.Shizuku
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.lsposed.hiddenapibypass.HiddenApiBypass
import org.lsposed.lspatch.util.ShizukuApi
const val TAG = "LSPatch Manager"
lateinit var lspApp: LSPApplication
class LSPApplication : Application() {
companion object {
var shizukuBinderAvalable = false
var shizukuGranted by mutableStateOf(false)
lateinit var appContext: Context
lateinit var prefs: SharedPreferences
init {
Shizuku.addBinderReceivedListenerSticky {
shizukuBinderAvalable = true
shizukuGranted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
}
Shizuku.addBinderDeadListener {
shizukuBinderAvalable = false
shizukuGranted = false
}
}
}
val globalScope = CoroutineScope(Dispatchers.Default)
override fun onCreate() {
super.onCreate()
appContext = applicationContext
appContext.filesDir.mkdir()
prefs = appContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
HiddenApiBypass.addHiddenApiExemptions("");
lspApp = this
lspApp.filesDir.mkdir()
prefs = lspApp.getSharedPreferences("settings", Context.MODE_PRIVATE)
ShizukuApi.init()
}
}

View File

@ -5,38 +5,37 @@ import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.lsposed.lspatch.Constants.PREFS_STORAGE_DIRECTORY
import org.lsposed.lspatch.config.MyKeyStore
import org.lsposed.patch.LSPatch
import org.lsposed.patch.util.Logger
import java.io.File
import java.io.IOException
import java.nio.file.Files
import kotlin.io.path.absolutePathString
object Patcher {
class Options(
private val apkPaths: List<String>,
private val debuggable: Boolean,
private val sigbypassLevel: Int,
private val v1: Boolean,
private val v2: Boolean,
private val v3: Boolean,
private val useManager: Boolean,
private val overrideVersionCode: Boolean,
private val verbose: Boolean,
private val embeddedModules: List<String>?
) {
lateinit var outputPath: String
lateinit var outputDir: File
fun toStringArray(): Array<String> {
return buildList {
add("-f")
addAll(apkPaths)
add("-o"); add(outputPath)
add("-o"); add(outputDir.absolutePath)
if (debuggable) add("-d")
add("-l"); add(sigbypassLevel.toString())
add("--v1"); add(v1.toString())
add("--v2"); add(v2.toString())
add("--v3"); add(v3.toString())
if (useManager) add("--manager")
if (overrideVersionCode) add("-r")
if (verbose) add("-v")
@ -52,24 +51,27 @@ object Patcher {
suspend fun patch(context: Context, logger: Logger, options: Options) {
withContext(Dispatchers.IO) {
options.outputPath = Files.createTempDirectory("patch").absolutePathString()
options.outputDir = Files.createTempDirectory("patch").toFile()
options.outputDir.listFiles()?.forEach(File::delete)
LSPatch(logger, *options.toStringArray()).doCommandLine()
val uri = LSPApplication.prefs.getString(Constants.PREFS_STORAGE_DIRECTORY, null)?.toUri()
?: throw IllegalStateException("Uri is null")
val uri = lspApp.prefs.getString(PREFS_STORAGE_DIRECTORY, null)?.toUri()
?: throw IOException("Uri is null")
val root = DocumentFile.fromTreeUri(context, uri)
?: throw IllegalStateException("DocumentFile is null")
root.listFiles().forEach { it.delete() }
File(options.outputPath)
?: throw IOException("DocumentFile is null")
root.listFiles().forEach {
if (it.name?.endsWith("-lspatched.apk") == true) it.delete()
}
options.outputDir
.walk()
.filter { it.isFile }
.forEach {
val file = root.createFile("application/vnd.android.package-archive", it.name)
?: throw IllegalStateException("Failed to create output file")
val os = context.contentResolver.openOutputStream(file.uri)
?: throw IllegalStateException("Failed to open output stream")
os.use { output ->
it.inputStream().use { input ->
.forEach { apk ->
val file = root.createFile("application/vnd.android.package-archive", apk.name)
?: throw IOException("Failed to create output file")
val output = context.contentResolver.openOutputStream(file.uri)
?: throw IOException("Failed to open output stream")
output.use {
apk.inputStream().use { input ->
input.copyTo(output)
}
}

View File

@ -6,24 +6,26 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.lsposed.lspatch.LSPApplication.Companion.appContext
import org.lsposed.lspatch.LSPApplication.Companion.prefs
import org.lsposed.lspatch.Constants.PREFS_KEYSTORE_ALIAS
import org.lsposed.lspatch.Constants.PREFS_KEYSTORE_ALIAS_PASSWORD
import org.lsposed.lspatch.Constants.PREFS_KEYSTORE_PASSWORD
import org.lsposed.lspatch.lspApp
import java.io.File
object MyKeyStore {
val file = File("${appContext.filesDir}/keystore.bks")
val file = File("${lspApp.filesDir}/keystore.bks")
val tmpFile = File("${appContext.filesDir}/keystore.bks.tmp")
val tmpFile = File("${lspApp.filesDir}/keystore.bks.tmp")
val password: String
get() = prefs.getString("keystore_password", "123456")!!
get() = lspApp.prefs.getString("keystore_password", "123456")!!
val alias: String
get() = prefs.getString("keystore_alias", "key0")!!
get() = lspApp.prefs.getString("keystore_alias", "key0")!!
val aliasPassword: String
get() = prefs.getString("keystore_alias_password", "123456")!!
get() = lspApp.prefs.getString("keystore_alias_password", "123456")!!
private var mUseDefault by mutableStateOf(!file.exists())
val useDefault by derivedStateOf { mUseDefault }
@ -31,10 +33,10 @@ object MyKeyStore {
suspend fun reset() {
withContext(Dispatchers.IO) {
file.delete()
prefs.edit()
.putString("keystore_password", "123456")
.putString("keystore_alias", "key0")
.putString("keystore_alias_password", "123456")
lspApp.prefs.edit()
.putString(PREFS_KEYSTORE_PASSWORD, "123456")
.putString(PREFS_KEYSTORE_ALIAS, "key0")
.putString(PREFS_KEYSTORE_ALIAS_PASSWORD, "123456")
.apply()
mUseDefault = true
}
@ -43,10 +45,10 @@ object MyKeyStore {
suspend fun setCustom(password: String, alias: String, aliasPassword: String) {
withContext(Dispatchers.IO) {
tmpFile.renameTo(file)
prefs.edit()
.putString("keystore_password", password)
.putString("keystore_alias", alias)
.putString("keystore_alias_password", aliasPassword)
lspApp.prefs.edit()
.putString(PREFS_KEYSTORE_PASSWORD, password)
.putString(PREFS_KEYSTORE_ALIAS, alias)
.putString(PREFS_KEYSTORE_ALIAS_PASSWORD, aliasPassword)
.apply()
mUseDefault = false
}

View File

@ -17,6 +17,7 @@ import com.google.accompanist.navigation.animation.rememberAnimatedNavController
import org.lsposed.lspatch.ui.page.PageList
import org.lsposed.lspatch.ui.theme.LSPTheme
import org.lsposed.lspatch.ui.util.LocalNavController
import org.lsposed.lspatch.ui.util.LocalSnackbarHost
import org.lsposed.lspatch.ui.util.currentRoute
class MainActivity : ComponentActivity() {
@ -27,11 +28,14 @@ class MainActivity : ComponentActivity() {
setContent {
val navController = rememberAnimatedNavController()
val currentRoute = navController.currentRoute
val currentPage = if (currentRoute == null) null else PageList.valueOf(currentRoute.substringBefore('/'))
var mainPage by rememberSaveable { mutableStateOf(PageList.Home) }
LSPTheme {
CompositionLocalProvider(LocalNavController provides navController) {
val snackbarHostState = remember { SnackbarHostState() }
CompositionLocalProvider(
LocalNavController provides navController,
LocalSnackbarHost provides snackbarHostState
) {
Scaffold(
bottomBar = {
MainNavigationBar(mainPage) {
@ -42,7 +46,8 @@ class MainActivity : ComponentActivity() {
}
}
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { innerPadding ->
MainNavHost(navController, Modifier.padding(innerPadding))
}

View File

@ -24,19 +24,16 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.lsposed.lspatch.BuildConfig
import org.lsposed.lspatch.LSPApplication
import org.lsposed.lspatch.R
import org.lsposed.lspatch.ui.util.HtmlText
import org.lsposed.lspatch.ui.util.LocalSnackbarHost
import org.lsposed.lspatch.util.ShizukuApi
import rikka.shizuku.Shizuku
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomePage() {
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
topBar = { TopBar() },
snackbarHost = { SnackbarHost(snackbarHostState) }
) { innerPadding ->
Scaffold(topBar = { TopBar() }) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
@ -45,7 +42,7 @@ fun HomePage() {
) {
ShizukuCard()
Spacer(Modifier.height(16.dp))
InfoCard(snackbarHostState)
InfoCard()
Spacer(Modifier.height(16.dp))
SupportCard()
}
@ -64,12 +61,12 @@ private fun TopBar() {
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.titleMedium
)
},
}
)
}
private val listener: (Int, Int) -> Unit = { _, grantResult ->
LSPApplication.shizukuGranted = grantResult == PackageManager.PERMISSION_GRANTED
ShizukuApi.isPermissionGranted = grantResult == PackageManager.PERMISSION_GRANTED
}
@OptIn(ExperimentalMaterial3Api::class)
@ -87,12 +84,12 @@ private fun ShizukuCard() {
ElevatedCard(
modifier = Modifier.clickable {
if (LSPApplication.shizukuBinderAvalable && !LSPApplication.shizukuGranted) {
if (ShizukuApi.isBinderAvalable && !ShizukuApi.isPermissionGranted) {
Shizuku.requestPermission(114514)
}
},
containerColor = run {
if (LSPApplication.shizukuGranted) MaterialTheme.colorScheme.secondaryContainer
if (ShizukuApi.isPermissionGranted) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.errorContainer
}
) {
@ -102,11 +99,11 @@ private fun ShizukuCard() {
.padding(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (LSPApplication.shizukuGranted) {
Icon(Icons.Outlined.CheckCircle, stringResource(R.string.home_shizuku_available))
if (ShizukuApi.isPermissionGranted) {
Icon(Icons.Outlined.CheckCircle, stringResource(R.string.shizuku_available))
Column(Modifier.padding(start = 20.dp)) {
Text(
text = stringResource(R.string.home_shizuku_available),
text = stringResource(R.string.shizuku_available),
fontFamily = FontFamily.Serif,
style = MaterialTheme.typography.titleMedium
)
@ -117,10 +114,10 @@ private fun ShizukuCard() {
)
}
} else {
Icon(Icons.Outlined.Warning, stringResource(R.string.home_shizuku_unavailable))
Icon(Icons.Outlined.Warning, stringResource(R.string.shizuku_unavailable))
Column(Modifier.padding(start = 20.dp)) {
Text(
text = stringResource(R.string.home_shizuku_unavailable),
text = stringResource(R.string.shizuku_unavailable),
fontFamily = FontFamily.Serif,
style = MaterialTheme.typography.titleMedium
)
@ -151,8 +148,9 @@ private val device = buildString {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InfoCard(snackbarHostState: SnackbarHostState) {
private fun InfoCard() {
val context = LocalContext.current
val snackbarHost = LocalSnackbarHost.current
val scope = rememberCoroutineScope()
ElevatedCard {
Column(
@ -190,7 +188,7 @@ private fun InfoCard(snackbarHostState: SnackbarHostState) {
onClick = {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("LSPatch", contents.toString()))
scope.launch { snackbarHostState.showSnackbar(copiedMessage) }
scope.launch { snackbarHost.showSnackbar(copiedMessage) }
},
content = { Text(stringResource(android.R.string.copy)) }
)

View File

@ -5,29 +5,31 @@ import android.content.Intent
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.launch
import org.lsposed.lspatch.Constants
import org.lsposed.lspatch.LSPApplication
import org.lsposed.lspatch.*
import org.lsposed.lspatch.Constants.PREFS_STORAGE_DIRECTORY
import org.lsposed.lspatch.R
import org.lsposed.lspatch.TAG
import org.lsposed.lspatch.ui.util.LocalNavController
import org.lsposed.lspatch.ui.util.LocalSnackbarHost
import java.io.IOException
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ManagePage() {
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
topBar = { TopBar() },
floatingActionButton = { Fab(snackbarHostState) },
snackbarHost = { SnackbarHost(snackbarHostState) }
floatingActionButton = { Fab() }
) { innerPadding ->
}
@ -41,8 +43,9 @@ private fun TopBar() {
}
@Composable
private fun Fab(snackbarHostState: SnackbarHostState) {
private fun Fab() {
val context = LocalContext.current
val snackbarHost = LocalSnackbarHost.current
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
var shouldSelectDirectory by remember { mutableStateOf(false) }
@ -54,12 +57,12 @@ private fun Fab(snackbarHostState: SnackbarHostState) {
val uri = it.data?.data ?: throw IOException("No data")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
LSPApplication.prefs.edit().putString(Constants.PREFS_STORAGE_DIRECTORY, uri.toString()).apply()
lspApp.prefs.edit().putString(PREFS_STORAGE_DIRECTORY, uri.toString()).apply()
Log.i(TAG, "Storage directory: ${uri.path}")
navController.navigate(PageList.NewPatch.name)
} catch (e: Exception) {
Log.e(TAG, "Error when requesting saving directory", e)
scope.launch { snackbarHostState.showSnackbar(errorText) }
scope.launch { snackbarHost.showSnackbar(errorText) }
}
}
@ -81,7 +84,13 @@ private fun Fab(snackbarHostState: SnackbarHostState) {
onClick = { shouldSelectDirectory = false }
)
},
title = { Text(stringResource(R.string.patch_select_dir_title)) },
title = {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.patch_select_dir_title),
textAlign = TextAlign.Center
)
},
text = { Text(stringResource(R.string.patch_select_dir_text)) }
)
}
@ -89,17 +98,19 @@ private fun Fab(snackbarHostState: SnackbarHostState) {
FloatingActionButton(
content = { Icon(Icons.Filled.Add, stringResource(R.string.add)) },
onClick = {
val uri = LSPApplication.prefs.getString(Constants.PREFS_STORAGE_DIRECTORY, null)?.toUri()
val uri = lspApp.prefs.getString(PREFS_STORAGE_DIRECTORY, null)?.toUri()
if (uri == null) {
shouldSelectDirectory = true
} else {
try {
runCatching {
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
if (DocumentFile.fromTreeUri(context, uri)?.exists() == false) throw IOException("Storage directory was deleted")
}.onSuccess {
navController.navigate(PageList.NewPatch.name)
} catch (e: SecurityException) {
Log.e(TAG, "Failed to take persistable permission for saved uri", e)
LSPApplication.prefs.edit().putString(Constants.PREFS_STORAGE_DIRECTORY, null).apply()
}.onFailure {
Log.w(TAG, "Failed to take persistable permission for saved uri", it)
lspApp.prefs.edit().putString(PREFS_STORAGE_DIRECTORY, null).apply()
shouldSelectDirectory = true
}
}

View File

@ -3,6 +3,7 @@ package org.lsposed.lspatch.ui.page
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageInstaller
import android.util.Log
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
@ -25,81 +26,71 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.launch
import org.lsposed.lspatch.Patcher
import org.lsposed.lspatch.R
import org.lsposed.lspatch.TAG
import org.lsposed.lspatch.lspApp
import org.lsposed.lspatch.ui.component.SelectionColumn
import org.lsposed.lspatch.ui.component.ShimmerAnimation
import org.lsposed.lspatch.ui.component.settings.SettingsCheckBox
import org.lsposed.lspatch.ui.component.settings.SettingsItem
import org.lsposed.lspatch.ui.util.LocalNavController
import org.lsposed.lspatch.ui.util.isScrolledToEnd
import org.lsposed.lspatch.ui.util.lastItemIndex
import org.lsposed.lspatch.ui.util.observeState
import org.lsposed.lspatch.ui.util.*
import org.lsposed.lspatch.ui.viewmodel.AppInfo
import org.lsposed.lspatch.ui.viewmodel.NewPatchViewModel
import org.lsposed.lspatch.ui.viewmodel.NewPatchViewModel.PatchState
import org.lsposed.lspatch.util.LSPPackageInstaller
import org.lsposed.lspatch.util.ShizukuApi
import org.lsposed.patch.util.Logger
import java.io.File
private enum class PatchState {
SELECTING, CONFIGURING, SUBMITTING, PATCHING, FINISHED, ERROR
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewPatchPage(entry: NavBackStackEntry) {
val viewModel = viewModel<NewPatchViewModel>()
val navController = LocalNavController.current
val patchApp by entry.observeState<AppInfo>("appInfo")
val lifecycleOwner = LocalLifecycleOwner.current
val isCancelled by entry.observeState<Boolean>("isCancelled")
var patchState by rememberSaveable { mutableStateOf(PatchState.SELECTING) }
var patchOptions by rememberSaveable { mutableStateOf<Patcher.Options?>(null) }
if (patchState == PatchState.SELECTING) {
when {
isCancelled == true -> {
LaunchedEffect(entry) { navController.popBackStack() }
return
}
patchApp != null -> patchState = PatchState.CONFIGURING
}
entry.savedStateHandle.getLiveData<AppInfo>("appInfo").observe(lifecycleOwner) {
viewModel.patchApp = it
}
Log.d(TAG, "NewPatchPage: $patchState")
if (patchState == PatchState.SELECTING) {
LaunchedEffect(entry) {
navController.navigate(PageList.SelectApps.name + "/false")
Log.d(TAG, "NewPatchPage: ${viewModel.patchState}")
if (viewModel.patchState == PatchState.SELECTING) {
when {
isCancelled == true -> {
LaunchedEffect(viewModel) { navController.popBackStack() }
return
}
viewModel.patchApp != null -> {
LaunchedEffect(viewModel) { viewModel.configurePatch() }
}
else -> {
LaunchedEffect(viewModel) { navController.navigate(PageList.SelectApps.name + "/false") }
}
}
} else {
Scaffold(
topBar = { TopBar(patchApp!!) },
topBar = { TopBar(viewModel.patchApp!!) },
floatingActionButton = {
if (patchState == PatchState.CONFIGURING) {
ConfiguringFab { patchState = PatchState.SUBMITTING }
if (viewModel.patchState == PatchState.CONFIGURING) {
ConfiguringFab()
}
}
) { innerPadding ->
if (patchState == PatchState.CONFIGURING || patchState == PatchState.SUBMITTING) {
PatchOptionsBody(
modifier = Modifier.padding(innerPadding),
patchState = patchState,
patchApp = patchApp!!,
onSubmit = {
patchOptions = it
patchState = PatchState.PATCHING
if (viewModel.patchState == PatchState.CONFIGURING) {
entry.savedStateHandle.getLiveData<SnapshotStateList<AppInfo>>("selected", SnapshotStateList()).observe(lifecycleOwner) {
viewModel.embeddedModules = it
}
)
PatchOptionsBody(Modifier.padding(innerPadding))
} else {
DoPatchBody(
modifier = Modifier.padding(innerPadding),
patchState = patchState,
patchOptions = patchOptions!!,
onFinish = { patchState = PatchState.FINISHED },
onFail = { patchState = PatchState.ERROR }
)
DoPatchBody(Modifier.padding(innerPadding))
}
}
}
@ -126,11 +117,12 @@ private fun TopBar(patchApp: AppInfo) {
}
@Composable
private fun ConfiguringFab(onClick: () -> Unit) {
private fun ConfiguringFab() {
val viewModel = viewModel<NewPatchViewModel>()
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.patch_start)) },
icon = { Icon(Icons.Outlined.AutoFixHigh, null) },
onClick = onClick
onClick = { viewModel.submitPatch() }
)
}
@ -144,31 +136,9 @@ private fun sigBypassLvStr(level: Int) = when (level) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PatchOptionsBody(
modifier: Modifier,
patchState: PatchState,
patchApp: AppInfo,
onSubmit: (Patcher.Options) -> Unit
) {
val navController = LocalNavController.current
private fun PatchOptionsBody(modifier: Modifier) {
val viewModel = viewModel<NewPatchViewModel>()
val embeddedModules = navController.currentBackStackEntry!!
.savedStateHandle.getLiveData<SnapshotStateList<AppInfo>>("selected", SnapshotStateList())
if (patchState == PatchState.SUBMITTING) LaunchedEffect(patchApp) {
if (viewModel.useManager) embeddedModules.value?.clear()
val options = Patcher.Options(
apkPaths = listOf(patchApp.app.sourceDir) + (patchApp.app.splitSourceDirs ?: emptyArray()),
debuggable = viewModel.debuggable,
sigbypassLevel = viewModel.sigBypassLevel,
v1 = viewModel.sign[0], v2 = viewModel.sign[1], v3 = viewModel.sign[2],
useManager = viewModel.useManager,
overrideVersionCode = viewModel.overrideVersionCode,
verbose = true,
embeddedModules = embeddedModules.value?.flatMap { listOf(it.app.sourceDir) + (it.app.splitSourceDirs ?: emptyArray()) }
)
onSubmit(options)
}
val navController = LocalNavController.current
Column(modifier.verticalScroll(rememberScrollState())) {
Text(
@ -194,10 +164,9 @@ private fun PatchOptionsBody(
desc = stringResource(R.string.patch_portable_desc),
extraContent = {
TextButton(
onClick = { navController.navigate(PageList.SelectApps.name + "/true") }
) {
Text(text = stringResource(R.string.patch_embed_modules), style = MaterialTheme.typography.bodyLarge)
}
onClick = { navController.navigate(PageList.SelectApps.name + "/true") },
content = { Text(text = stringResource(R.string.patch_embed_modules), style = MaterialTheme.typography.bodyLarge) }
)
}
)
}
@ -265,19 +234,7 @@ private fun PatchOptionsBody(
}
}
@Composable
private fun DoPatchBody(
modifier: Modifier,
patchState: PatchState,
patchOptions: Patcher.Options,
onFinish: () -> Unit,
onFail: () -> Unit
) {
val context = LocalContext.current
val navController = LocalNavController.current
val logs = remember { mutableStateListOf<Pair<Int, String>>() }
val logger = remember {
object : Logger() {
private class PatchLogger(private val logs: MutableList<Pair<Int, String>>) : Logger() {
override fun d(msg: String) {
if (verbose) {
Log.d(TAG, msg)
@ -294,25 +251,34 @@ private fun DoPatchBody(
Log.e(TAG, msg)
logs += Log.ERROR to msg
}
}
}
}
LaunchedEffect(patchOptions) {
@Composable
private fun DoPatchBody(modifier: Modifier) {
val viewModel = viewModel<NewPatchViewModel>()
val context = LocalContext.current
val snackbarHost = LocalSnackbarHost.current
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val logs = remember { mutableStateListOf<Pair<Int, String>>() }
val logger = remember { PatchLogger(logs) }
LaunchedEffect(viewModel) {
try {
Patcher.patch(context, logger, patchOptions)
onFinish()
Patcher.patch(context, logger, viewModel.patchOptions)
viewModel.finishPatch()
} catch (t: Throwable) {
logger.e(t.message.orEmpty())
logger.e(t.stackTraceToString())
onFail()
viewModel.failPatch()
} finally {
File(patchOptions.outputPath).deleteRecursively()
viewModel.patchOptions.outputDir.deleteRecursively()
}
}
BoxWithConstraints(modifier.padding(24.dp)) {
val shellBoxMaxHeight =
if (patchState == PatchState.PATCHING) maxHeight
if (viewModel.patchState == PatchState.PATCHING) maxHeight
else maxHeight - ButtonDefaults.MinHeight - 12.dp
Column(
Modifier
@ -320,7 +286,7 @@ private fun DoPatchBody(
.wrapContentHeight()
.animateContentSize(spring(stiffness = Spring.StiffnessLow))
) {
ShimmerAnimation(enabled = patchState == PatchState.PATCHING) {
ShimmerAnimation(enabled = viewModel.patchState == PatchState.PATCHING) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
) {
@ -351,38 +317,130 @@ private fun DoPatchBody(
}
}
if (patchState == PatchState.FINISHED) {
when (viewModel.patchState) {
PatchState.FINISHED -> {
val shizukuUnavailable = stringResource(R.string.shizuku_unavailable)
val installSuccessfully = stringResource(R.string.patch_install_successfully)
val installFailed = stringResource(R.string.patch_install_failed)
var installing by rememberSaveable { mutableStateOf(false) }
if (installing) InstallDialog(viewModel.patchApp!!) { status, message ->
installing = false
scope.launch {
if (status == PackageInstaller.STATUS_SUCCESS) {
lspApp.globalScope.launch { snackbarHost.showSnackbar(installSuccessfully) }
navController.popBackStack()
} else {
snackbarHost.showSnackbar(installFailed)
}
}
}
Row(Modifier.padding(top = 12.dp)) {
Button(
onClick = { navController.popBackStack() },
modifier = Modifier.weight(1f),
onClick = { navController.popBackStack() },
content = { Text(stringResource(R.string.patch_return)) }
)
Spacer(Modifier.weight(0.2f))
Button(
onClick = { /* TODO: Install */ },
modifier = Modifier.weight(1f),
onClick = {
if (!ShizukuApi.isPermissionGranted) {
scope.launch {
snackbarHost.showSnackbar(shizukuUnavailable)
}
}
installing = true
},
content = { Text(stringResource(R.string.patch_install)) }
)
}
} else if (patchState == PatchState.ERROR) {
}
PatchState.ERROR -> {
Row(Modifier.padding(top = 12.dp)) {
Button(
onClick = { navController.popBackStack() },
modifier = Modifier.weight(1f),
onClick = { navController.popBackStack() },
content = { Text(stringResource(R.string.patch_return)) }
)
Spacer(Modifier.weight(0.2f))
Button(
modifier = Modifier.weight(1f),
onClick = {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("LSPatch", logs.joinToString { it.second + "\n" }))
},
modifier = Modifier.weight(1f),
content = { Text(stringResource(R.string.patch_copy_error)) }
)
}
}
else -> Unit
}
}
}
}
@Composable
private fun InstallDialog(patchApp: AppInfo, onFinish: (Int, String?) -> Unit) {
val scope = rememberCoroutineScope()
var uninstallFirst by remember { mutableStateOf(ShizukuApi.isPackageInstalled(patchApp.app.packageName)) }
var installing by remember { mutableStateOf(false) }
val doInstall = suspend {
Log.i(TAG, "Installing app ${patchApp.app.packageName}")
installing = true
val (status, message) = LSPPackageInstaller.install()
installing = false
Log.i(TAG, "Installation end: $status, $message")
onFinish(status, message)
}
if (uninstallFirst) {
AlertDialog(
onDismissRequest = { onFinish(-2, "User cancelled") },
confirmButton = {
TextButton(
onClick = {
scope.launch {
Log.i(TAG, "Uninstalling app ${patchApp.app.packageName}")
val (status, message) = LSPPackageInstaller.uninstall(patchApp.app.packageName)
Log.i(TAG, "Uninstallation end: $status, $message")
if (status != PackageInstaller.STATUS_SUCCESS) onFinish(status, message)
uninstallFirst = false
doInstall()
}
},
content = { Text(stringResource(android.R.string.ok)) }
)
},
dismissButton = {
TextButton(
onClick = { onFinish(-2, "User cancelled") },
content = { Text(stringResource(android.R.string.cancel)) }
)
},
title = {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.patch_uninstall),
textAlign = TextAlign.Center
)
},
text = { Text(stringResource(R.string.patch_uninstall_text)) }
)
}
if (installing) {
AlertDialog(
onDismissRequest = {},
confirmButton = {},
title = {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.patch_installing),
fontFamily = FontFamily.Serif,
textAlign = TextAlign.Center
)
}
)
}
}

View File

@ -6,6 +6,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -19,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.lsposed.lspatch.R
@ -150,7 +152,13 @@ private fun KeyStore() {
onClick = { dropDownExpanded = false; showDialog = false }
)
},
title = { Text(stringResource(R.string.settings_keystore_dialog_title)) },
title = {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.settings_keystore_dialog_title),
textAlign = TextAlign.Center
)
},
text = {
Column {
val interactionSource = remember { MutableInteractionSource() }
@ -163,7 +171,7 @@ private fun KeyStore() {
}
val wrongText = when {
wrongAliasPassword -> stringResource(R.string.settings_keystore_wrong_keystore)
wrongAliasPassword -> stringResource(R.string.settings_keystore_wrong_alias_password)
wrongAliasName -> stringResource(R.string.settings_keystore_wrong_alias)
wrongPassword -> stringResource(R.string.settings_keystore_wrong_password)
wrongKeystore -> stringResource(R.string.settings_keystore_wrong_keystore)

View File

@ -0,0 +1,13 @@
package org.lsposed.lspatch.ui.util
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.compositionLocalOf
import androidx.navigation.NavHostController
val LocalNavController = compositionLocalOf<NavHostController> {
error("CompositionLocal LocalNavController not present")
}
val LocalSnackbarHost = compositionLocalOf<SnackbarHostState> {
error("CompositionLocal LocalSnackbarController not present")
}

View File

@ -1,18 +1,12 @@
package org.lsposed.lspatch.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.livedata.observeAsState
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
val LocalNavController = compositionLocalOf<NavHostController> {
error("CompositionLocal LocalNavController not present")
}
val NavController.currentRoute: String?
@Composable get() = currentBackStackEntryAsState().value?.destination?.route

View File

@ -4,13 +4,53 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.ViewModel
import org.lsposed.lspatch.Patcher
class NewPatchViewModel : ViewModel() {
enum class PatchState {
SELECTING, CONFIGURING, PATCHING, FINISHED, ERROR
}
var patchState by mutableStateOf(PatchState.SELECTING)
private set
var patchApp by mutableStateOf<AppInfo?>(null)
var useManager by mutableStateOf(true)
var debuggable by mutableStateOf(false)
var overrideVersionCode by mutableStateOf(false)
var sign = mutableStateListOf(false, true, true)
var sign = mutableStateListOf(false, true)
var sigBypassLevel by mutableStateOf(2)
lateinit var embeddedModules: SnapshotStateList<AppInfo>
lateinit var patchOptions: Patcher.Options
fun configurePatch() {
patchState = PatchState.CONFIGURING
}
fun submitPatch() {
if (useManager) embeddedModules.clear()
patchOptions = Patcher.Options(
apkPaths = listOf(patchApp!!.app.sourceDir) + (patchApp!!.app.splitSourceDirs ?: emptyArray()),
debuggable = debuggable,
sigbypassLevel = sigBypassLevel,
v1 = sign[0], v2 = sign[1],
useManager = useManager,
overrideVersionCode = overrideVersionCode,
verbose = true,
embeddedModules = embeddedModules.flatMap { listOf(it.app.sourceDir) + (it.app.splitSourceDirs ?: emptyArray()) }
)
patchState = PatchState.PATCHING
}
fun finishPatch() {
patchState = PatchState.FINISHED
}
fun failPatch() {
patchState = PatchState.ERROR
}
}

View File

@ -0,0 +1,41 @@
package org.lsposed.lspatch.util
import android.content.IIntentReceiver
import android.content.IIntentSender
import android.content.Intent
import android.content.IntentSender
import android.os.Bundle
import android.os.IBinder
object IntentSenderHelper {
fun newIntentSender(binder: IIntentSender): IntentSender {
return IntentSender::class.java.getConstructor(IIntentSender::class.java).newInstance(binder)
}
class IIntentSenderAdaptor(private val listener: (Intent) -> Unit) : IIntentSender.Stub() {
override fun send(
code: Int,
intent: Intent,
resolvedType: String?,
finishedReceiver: IIntentReceiver?,
requiredPermission: String?,
options: Bundle?
): Int {
listener(intent)
return 0
}
override fun send(
code: Int,
intent: Intent,
resolvedType: String?,
whitelistToken: IBinder?,
finishedReceiver: IIntentReceiver?,
requiredPermission: String?,
options: Bundle?
) {
listener(intent)
}
}
}

View File

@ -0,0 +1,97 @@
package org.lsposed.lspatch.util
import android.content.Intent
import android.content.pm.PackageInstaller
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import hidden.HiddenApiBridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.lsposed.lspatch.Constants.PREFS_STORAGE_DIRECTORY
import org.lsposed.lspatch.lspApp
import java.io.IOException
import java.util.concurrent.CountDownLatch
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
object LSPPackageInstaller {
suspend fun install(): Pair<Int, String?> {
var status = PackageInstaller.STATUS_FAILURE
var message: String? = null
withContext(Dispatchers.IO) {
runCatching {
val params = PackageInstaller.SessionParams::class.java.getConstructor(Int::class.javaPrimitiveType)
.newInstance(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
var flags = HiddenApiBridge.PackageInstaller_SessionParams_installFlags(params)
flags = flags or 0x00000004 /* PackageManager.INSTALL_ALLOW_TEST */ or 0x00000002 /* PackageManager.INSTALL_REPLACE_EXISTING */
HiddenApiBridge.PackageInstaller_SessionParams_installFlags(params, flags)
ShizukuApi.createPackageInstallerSession(params).use { session ->
val uri = lspApp.prefs.getString(PREFS_STORAGE_DIRECTORY, null)?.toUri()
?: throw IOException("Uri is null")
val root = DocumentFile.fromTreeUri(lspApp, uri)
?: throw IOException("DocumentFile is null")
root.listFiles().forEach { apk ->
val input = lspApp.contentResolver.openInputStream(apk.uri)
?: throw IOException("Cannot open input stream")
input.use {
session.openWrite(apk.name!!, 0, input.available().toLong()).use { output ->
input.copyTo(output)
session.fsync(output)
}
}
}
var result: Intent? = null
suspendCoroutine<Unit> { cont ->
val countDownLatch = CountDownLatch(1)
val adapter = IntentSenderHelper.IIntentSenderAdaptor { intent ->
result = intent
countDownLatch.countDown()
}
val intentSender = IntentSenderHelper.newIntentSender(adapter)
session.commit(intentSender)
countDownLatch.await()
cont.resume(Unit)
}
result?.let {
status = it.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
message = it.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
} ?: throw IOException("Intent is null")
}
}.onFailure {
status = PackageInstaller.STATUS_FAILURE
message = it.message + "\n" + it.stackTraceToString()
}
}
return Pair(status, message)
}
suspend fun uninstall(packageName: String): Pair<Int, String?> {
var status = PackageInstaller.STATUS_FAILURE
var message: String? = null
withContext(Dispatchers.IO) {
runCatching {
var result: Intent? = null
suspendCoroutine<Unit> { cont ->
val countDownLatch = CountDownLatch(1)
val adapter = IntentSenderHelper.IIntentSenderAdaptor { intent ->
result = intent
countDownLatch.countDown()
}
val intentSender = IntentSenderHelper.newIntentSender(adapter)
ShizukuApi.uninstallPackage(packageName, intentSender)
countDownLatch.await()
cont.resume(Unit)
}
result?.let {
status = it.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
message = it.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
} ?: throw IOException("Intent is null")
}.onFailure {
status = PackageInstaller.STATUS_FAILURE
message = it.message + "\n" + it.stackTraceToString()
}
}
return Pair(status, message)
}
}

View File

@ -0,0 +1,60 @@
package org.lsposed.lspatch.util
import android.content.IntentSender
import android.content.pm.*
import android.os.IBinder
import android.os.IInterface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import rikka.shizuku.Shizuku
import rikka.shizuku.ShizukuBinderWrapper
import rikka.shizuku.SystemServiceHelper
object ShizukuApi {
private fun IBinder.wrap() = ShizukuBinderWrapper(this)
private fun IInterface.asShizukuBinder() = this.asBinder().wrap()
private val iPackageManager: IPackageManager by lazy {
IPackageManager.Stub.asInterface(SystemServiceHelper.getSystemService("package").wrap())
}
private val iPackageInstaller: IPackageInstaller by lazy {
IPackageInstaller.Stub.asInterface(iPackageManager.packageInstaller.asShizukuBinder())
}
private val packageInstaller: PackageInstaller by lazy {
PackageInstaller::class.java.getConstructor(IPackageInstaller::class.java, String::class.java, Int::class.javaPrimitiveType)
.newInstance(iPackageInstaller, "com.android.shell", 0)
}
var isBinderAvalable = false
var isPermissionGranted by mutableStateOf(false)
fun init() {
Shizuku.addBinderReceivedListenerSticky {
isBinderAvalable = true
isPermissionGranted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
}
Shizuku.addBinderDeadListener {
isBinderAvalable = false
isPermissionGranted = false
}
}
fun createPackageInstallerSession(params: PackageInstaller.SessionParams): PackageInstaller.Session {
val sessionId = packageInstaller.createSession(params)
val iSession = IPackageInstallerSession.Stub.asInterface(iPackageInstaller.openSession(sessionId).asShizukuBinder())
val constructor by lazy { PackageInstaller.Session::class.java.getConstructor(IPackageInstallerSession::class.java) }
return constructor.newInstance(iSession)
}
fun isPackageInstalled(packageName: String): Boolean {
return iPackageManager.getPackageInfo(packageName, 0, 0 /* TODO: userId */) != null
}
fun uninstallPackage(packageName: String, intentSender: IntentSender) {
packageInstaller.uninstall(packageName, intentSender)
}
}

View File

@ -1,12 +1,12 @@
<resources>
<string name="add">Add</string>
<string name="shizuku_available">Shizuku service available</string>
<string name="shizuku_unavailable">Shizuku service not connected</string>
<string name="page_repo">Repo</string>
<string name="page_logs">Logs</string>
<!-- Home Page -->
<string name="home_shizuku_available">Shizuku service available</string>
<string name="home_shizuku_unavailable">Shizuku service not connected</string>
<string name="home_shizuku_warning">Some functions unavailable</string>
<string name="home_api_version">API Version</string>
<string name="home_lspatch_version">LSPatch Version</string>
@ -44,6 +44,11 @@
<string name="patch_start">Start Patch</string>
<string name="patch_return">Return</string>
<string name="patch_install">Install</string>
<string name="patch_installing">Installing</string>
<string name="patch_uninstall">Uninstall</string>
<string name="patch_uninstall_text">Due to different signatures, you need to uninstall the original app before installing the patched one.\nMake sure you have backed up personal data.</string>
<string name="patch_install_successfully">Install successfully</string>
<string name="patch_install_failed">Install failed</string>
<string name="patch_copy_error">Copy error</string>
<!-- Select Apps Page -->

View File

@ -82,9 +82,6 @@ public class LSPatch {
@Parameter(names = {"--v2"}, arity = 1, description = "Sign with v2 signature")
private boolean v2 = true;
@Parameter(names = {"--v3"}, arity = 1, description = "Sign with v3 signature")
private boolean v3 = true;
@Parameter(names = {"--manager"}, description = "Use manager (Cannot work with embedding modules)")
private boolean useManager = false;

View File

@ -10,6 +10,7 @@ pluginManagement {
plugins {
id("com.android.library") version agpVersion
id("com.android.application") version agpVersion
id("dev.rikka.tools.refine") version "3.1.1"
}
}