diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1b589e9..ff96bb7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,7 @@ name: Android CI on: + workflow_dispatch: push: branches: [ master ] pull_request: @@ -9,6 +10,7 @@ jobs: build: name: Build on ${{ matrix.os }} runs-on: ${{ matrix.os }} + if: ${{ !startsWith(github.event.head_commit.message, '[skip ci]') }} strategy: fail-fast: false matrix: @@ -21,32 +23,115 @@ jobs: submodules: 'recursive' fetch-depth: 0 + - name: Write key + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' + run: | + if [ ! -z "${{ secrets.KEY_STORE }}" ]; then + echo androidStorePassword='${{ secrets.KEY_STORE_PASSWORD }}' >> gradle.properties + echo androidKeyAlias='${{ secrets.ALIAS }}' >> gradle.properties + echo androidKeyPassword='${{ secrets.KEY_PASSWORD }}' >> gradle.properties + echo androidStoreFile='key.jks' >> gradle.properties + echo ${{ secrets.KEY_STORE }} | base64 --decode > key.jks + fi + - name: Set up JDK 11 uses: actions/setup-java@v2 with: java-version: '11' distribution: 'adopt' - - name: Build Debug + - name: Cache gradle dependencies + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + !~/.gradle/caches/build-cache-* + key: gradle-deps-core-${{ hashFiles('**/build.gradle.kts') }} + restore-keys: | + gradle-deps + + - name: Cache gradle build + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches/build-cache-* + ~/.gradle/buildOutputCleanup/cache.properties + key: gradle-builds-core-${{ github.sha }} + restore-keys: | + gradle-builds + + - name: Cache native build + uses: actions/cache@v2 + with: + path: | + ~/.ccache + patch-loader/build/.lto-cache + key: native-cache-${{ github.sha }} + restore-keys: native-cache- + + - name: Install dep run: | + sudo apt-get install -y ccache ninja-build + ccache -o max_size=1G + ccache -o hash_dir=false + ccache -o compiler_check='%compiler% -dumpmachine; %compiler% -dumpversion' + ccache -zp + + - name: Build with Gradle + run: | + [ $(du -s ~/.gradle/wrapper | awk '{ print $1 }') -gt 250000 ] && rm -rf ~/.gradle/wrapper/* || true + find ~/.gradle/caches -exec touch -d "2 days ago" {} + || true + echo 'org.gradle.caching=true' >> gradle.properties + echo 'org.gradle.parallel=true' >> gradle.properties + echo 'org.gradle.vfs.watch=true' >> gradle.properties echo 'org.gradle.jvmargs=-Xmx2048m' >> gradle.properties - ./gradlew buildDebug + echo 'android.native.buildOutput=verbose' >> gradle.properties + ln -s $(which ninja) $(dirname $(which cmake)) # https://issuetracker.google.com/issues/206099937 + echo "cmake.dir=$(dirname $(dirname $(which cmake)))" >> local.properties + ./gradlew buildAll + ccache -s + - name: Upload Debug artifact uses: actions/upload-artifact@v2 with: name: lspatch-debug - path: | - out/lspatch.jar - out/manager.apk + path: out/debug/* - - name: Build Release - run: | - echo 'org.gradle.jvmargs=-Xmx2048m' >> gradle.properties - ./gradlew buildRelease - name: Upload Release artifact uses: actions/upload-artifact@v2 with: name: lspatch-release + path: out/release/* + + - name: Upload mappings + uses: actions/upload-artifact@v2 + with: + name: mappings path: | - out/lspatch.jar - out/manager.apk + patch-loader/build/outputs/mapping + manager/build/outputs/mapping + + - name: Upload symbols + uses: actions/upload-artifact@v2 + with: + name: symbols + path: | + patch-loader/build/symbols + + - name: Post to channel + if: ${{ github.event_name != 'pull_request' && success() && github.ref == 'refs/heads/master' }} + env: + CHANNEL_ID: ${{ secrets.CHANNEL_ID }} + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + COMMIT_URL: ${{ github.event.head_commit.url }} + run: | + if [ ! -z "${{ secrets.BOT_TOKEN }}" ]; then + export jarRelease=$(find out/release -name "*.jar") + export managerRelease=$(find out/release -name "*.apk") + export jarDebug=$(find out/debug -name "*.jar") + export managerDebug=$(find out/debug -name "*.apk") + ESCAPED=`python3 -c 'import json,os,urllib.parse; msg = json.dumps(os.environ["COMMIT_MESSAGE"]); print(urllib.parse.quote(msg if len(msg) <= 1024 else json.dumps(os.environ["COMMIT_URL"])))'` + curl -v "https://api.telegram.org/bot${BOT_TOKEN}/sendMediaGroup?chat_id=${CHANNEL_ID}&media=%5B%7B%22type%22%3A%22document%22%2C%20%22media%22%3A%22attach%3A%2F%2FjarRelease%22%7D%2C%7B%22type%22%3A%22document%22%2C%20%22media%22%3A%22attach%3A%2F%2FmanagerRelease%22%7D%2C%7B%22type%22%3A%22document%22%2C%20%22media%22%3A%22attach%3A%2F%2FjarDebug%22%7D%2C%7B%22type%22%3A%22document%22%2C%20%22media%22%3A%22attach%3A%2F%2FmanagerDebug%22%2C%22caption%22:${ESCAPED}%7D%5D" -F jarRelease="@$jarRelease" -F managerRelease="@$managerRelease" -F jarDebug="@$jarDebug" -F managerDebug="@$managerDebug" + fi diff --git a/README.md b/README.md index 9273f2a..871ace1 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ You can contribute translation [here](https://lsposed.crowdin.com/lspatch). ## Credits -- [LSPosed](https://github.com/LSPosed/LSPosed): core framework -- [Xpatch](https://github.com/WindySha/Xpatch): fork source +- [LSPosed](https://github.com/LSPosed/LSPosed): Core framework +- [Xpatch](https://github.com/WindySha/Xpatch): Fork source - [Apkzlib](https://android.googlesource.com/platform/tools/apkzlib): Repacking tool ## License diff --git a/build.gradle.kts b/build.gradle.kts index 93f55fc..c979f8c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,10 @@ listOf("Debug", "Release").forEach { variant -> } } +tasks.register("buildAll") { + dependsOn("buildDebug", "buildRelease") +} + fun Project.configureBaseExtension() { extensions.findByType(BaseExtension::class)?.run { compileSdkVersion(androidCompileSdkVersion) diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts index dd4221f..8925e5e 100644 --- a/manager/build.gradle.kts +++ b/manager/build.gradle.kts @@ -1,5 +1,7 @@ val defaultManagerPackageName: String by rootProject.extra val apiCode: Int by rootProject.extra +val verCode: Int by rootProject.extra +val verName: String by rootProject.extra val coreVerCode: Int by rootProject.extra val coreVerName: String by rootProject.extra @@ -13,10 +15,6 @@ plugins { android { defaultConfig { applicationId = defaultManagerPackageName - - buildConfigField("int", "API_CODE", """$apiCode""") - buildConfigField("int", "CORE_VERSION_CODE", """$coreVerCode""") - buildConfigField("String", "CORE_VERSION_NAME", """"$coreVerName"""") } buildTypes { @@ -58,8 +56,8 @@ afterEvaluate { task("build$variantCapped") { dependsOn(tasks["assemble$variantCapped"]) from(variant.outputs.map { it.outputFile }) - into("${rootProject.projectDir}/out") - rename(".*.apk", "manager.apk") + into("${rootProject.projectDir}/out/$variantLowered") + rename(".*.apk", "manager-v$verName-$verCode-$variantLowered.apk") } } } @@ -69,6 +67,7 @@ dependencies { implementation(projects.patch) implementation(projects.services.daemonService) implementation(projects.share.android) + implementation(projects.share.java) compileOnly("dev.rikka.hidden:stub:2.3.1") implementation("dev.rikka.hidden:compat:2.3.1") @@ -86,6 +85,7 @@ dependencies { implementation("com.google.accompanist:accompanist-navigation-animation:0.24.5-alpha") implementation("com.google.accompanist:accompanist-swiperefresh:0.24.5-alpha") implementation("com.google.android.material:material:1.5.0") + implementation("com.google.code.gson:gson:2.9.0") implementation("dev.rikka.shizuku:api:12.1.0") implementation("dev.rikka.shizuku:provider:12.1.0") implementation("org.lsposed.hiddenapibypass:hiddenapibypass:4.3") diff --git a/manager/src/main/ic_launcher-playstore.png b/manager/src/main/ic_launcher-playstore.png index 90798ee..8ea1a48 100644 Binary files a/manager/src/main/ic_launcher-playstore.png and b/manager/src/main/ic_launcher-playstore.png differ diff --git a/manager/src/main/java/org/lsposed/lspatch/Constants.kt b/manager/src/main/java/org/lsposed/lspatch/Constants.kt index 977822d..b7e4a52 100644 --- a/manager/src/main/java/org/lsposed/lspatch/Constants.kt +++ b/manager/src/main/java/org/lsposed/lspatch/Constants.kt @@ -2,6 +2,8 @@ package org.lsposed.lspatch object Constants { + const val PATCH_FILE_SUFFIX = "-lspatched.apk" + const val PREFS_KEYSTORE_PASSWORD = "keystore_password" const val PREFS_KEYSTORE_ALIAS = "keystore_alias" const val PREFS_KEYSTORE_ALIAS_PASSWORD = "keystore_alias_password" diff --git a/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt b/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt index 4151af2..31da106 100644 --- a/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt +++ b/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import org.lsposed.hiddenapibypass.HiddenApiBypass import org.lsposed.lspatch.util.ShizukuApi +import java.io.File const val TAG = "LSPatch Manager" @@ -15,6 +16,7 @@ lateinit var lspApp: LSPApplication class LSPApplication : Application() { lateinit var prefs: SharedPreferences + lateinit var tmpApkDir: File val globalScope = CoroutineScope(Dispatchers.Default) @@ -22,7 +24,9 @@ class LSPApplication : Application() { super.onCreate() HiddenApiBypass.addHiddenApiExemptions(""); lspApp = this - lspApp.filesDir.mkdir() + filesDir.mkdir() + tmpApkDir = cacheDir.resolve("apk") + tmpApkDir.mkdirs() prefs = lspApp.getSharedPreferences("settings", Context.MODE_PRIVATE) ShizukuApi.init() } diff --git a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt index 7b814af..f8ab240 100644 --- a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt +++ b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt @@ -1,17 +1,15 @@ package org.lsposed.lspatch -import android.content.Context import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.lsposed.lspatch.Constants.PATCH_FILE_SUFFIX 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 object Patcher { @@ -26,12 +24,10 @@ object Patcher { private val verbose: Boolean, private val embeddedModules: List? ) { - lateinit var outputDir: File - fun toStringArray(): Array { return buildList { addAll(apkPaths) - add("-o"); add(outputDir.absolutePath) + add("-o"); add(lspApp.tmpApkDir.absolutePath) if (debuggable) add("-d") add("-l"); add(sigbypassLevel.toString()) add("--v1"); add(v1.toString()) @@ -49,26 +45,23 @@ object Patcher { } } - suspend fun patch(context: Context, logger: Logger, options: Options) { + suspend fun patch(logger: Logger, options: Options) { withContext(Dispatchers.IO) { - options.outputDir = Files.createTempDirectory("patch").toFile() - options.outputDir.listFiles()?.forEach(File::delete) LSPatch(logger, *options.toStringArray()).doCommandLine() val uri = lspApp.prefs.getString(PREFS_STORAGE_DIRECTORY, null)?.toUri() ?: throw IOException("Uri is null") - val root = DocumentFile.fromTreeUri(context, uri) + val root = DocumentFile.fromTreeUri(lspApp, uri) ?: throw IOException("DocumentFile is null") root.listFiles().forEach { - if (it.name?.endsWith("-lspatched.apk") == true) it.delete() + if (it.name?.endsWith(PATCH_FILE_SUFFIX) == true) it.delete() } - options.outputDir - .walk() + lspApp.tmpApkDir.walk() .filter { it.isFile } .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) + val output = lspApp.contentResolver.openOutputStream(file.uri) ?: throw IOException("Failed to open output stream") output.use { apk.inputStream().use { input -> diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/activity/MainActivity.kt b/manager/src/main/java/org/lsposed/lspatch/ui/activity/MainActivity.kt index b7acf17..83e7378 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/activity/MainActivity.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/activity/MainActivity.kt @@ -66,7 +66,9 @@ private fun MainNavHost(navController: NavHostController, modifier: Modifier) { ) { PageList.values().forEach { page -> val sb = StringBuilder(page.name) - page.arguments.forEach { sb.append("/{${it.name}}") } + if (page.arguments.isNotEmpty()) { + sb.append(page.arguments.joinToString(",", "?") { "${it.name}={${it.name}}" }) + } composable(route = sb.toString(), arguments = page.arguments, content = page.body) } } diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/AppItem.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/AppItem.kt index 3d02118..ce089f1 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/component/AppItem.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/AppItem.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter @@ -23,10 +24,10 @@ fun AppItem( icon: Drawable, label: String, packageName: String, - additionalInfo: (@Composable () -> Unit)? = null, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, - checked: Boolean? = null + checked: Boolean? = null, + additionalContent: (@Composable () -> Unit)? = null, ) { Column( modifier = modifier @@ -47,10 +48,17 @@ fun AppItem( modifier = Modifier.size(32.dp), tint = Color.Unspecified ) - Column(Modifier.weight(1f)) { - Text(text = label, style = MaterialTheme.typography.bodyMedium) - Text(text = packageName, style = MaterialTheme.typography.bodySmall) - additionalInfo?.invoke() + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + Text(label) + Text( + text = packageName, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall + ) + additionalContent?.invoke() } if (checked != null) { Checkbox( diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt index 15f4b89..04cdea7 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt @@ -41,8 +41,13 @@ fun SearchAppBar( val focusRequester = remember { FocusRequester() } var onSearch by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - if (onSearch) focusRequester.requestFocus() + if (onSearch) { + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } + DisposableEffect(Unit) { + onDispose { + keyboardController?.hide() + } } SmallTopAppBar( @@ -75,8 +80,9 @@ fun SearchAppBar( trailingIcon = { IconButton( onClick = { - onClearClick() onSearch = false + keyboardController?.hide() + onClearClick() }, content = { Icon(Icons.Filled.Close, null) } ) diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/HomePage.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/HomePage.kt index b9fd957..0565e98 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/HomePage.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/HomePage.kt @@ -13,7 +13,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -23,8 +26,8 @@ import androidx.compose.ui.text.font.FontWeight 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.R +import org.lsposed.lspatch.share.LSPConfig import org.lsposed.lspatch.ui.util.HtmlText import org.lsposed.lspatch.ui.util.LocalSnackbarHost import org.lsposed.lspatch.util.ShizukuApi @@ -38,12 +41,11 @@ fun HomePage() { modifier = Modifier .padding(innerPadding) .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { ShizukuCard() - Spacer(Modifier.height(16.dp)) InfoCard() - Spacer(Modifier.height(16.dp)) SupportCard() } } @@ -164,13 +166,13 @@ private fun InfoCard() { Text(text = texts.second, style = MaterialTheme.typography.bodyMedium) } - infoCardContent(stringResource(R.string.home_api_version) to "${BuildConfig.API_CODE}") + infoCardContent(stringResource(R.string.home_api_version) to "${LSPConfig.instance.API_CODE}") Spacer(Modifier.height(24.dp)) - infoCardContent(stringResource(R.string.home_lspatch_version) to BuildConfig.VERSION_NAME + " (${BuildConfig.VERSION_CODE})") + infoCardContent(stringResource(R.string.home_lspatch_version) to LSPConfig.instance.VERSION_NAME + " (${LSPConfig.instance.VERSION_CODE})") Spacer(Modifier.height(24.dp)) - infoCardContent(stringResource(R.string.home_framework_version) to BuildConfig.CORE_VERSION_NAME + " (${BuildConfig.CORE_VERSION_CODE})") + infoCardContent(stringResource(R.string.home_framework_version) to LSPConfig.instance.CORE_VERSION_NAME + " (${LSPConfig.instance.CORE_VERSION_CODE})") Spacer(Modifier.height(24.dp)) infoCardContent(stringResource(R.string.home_system_version) to apiVersion) diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/ManagePage.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/ManagePage.kt index 202bb84..d246d2a 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/ManagePage.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/ManagePage.kt @@ -5,41 +5,56 @@ import android.content.Intent import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.lsposed.lspatch.* +import kotlinx.coroutines.withContext import org.lsposed.lspatch.Constants.PREFS_STORAGE_DIRECTORY import org.lsposed.lspatch.R +import org.lsposed.lspatch.TAG +import org.lsposed.lspatch.lspApp +import org.lsposed.lspatch.ui.component.AppItem import org.lsposed.lspatch.ui.util.LocalNavController import org.lsposed.lspatch.ui.util.LocalSnackbarHost +import org.lsposed.lspatch.ui.viewmodel.ManageViewModel +import org.lsposed.lspatch.util.LSPPackageManager import java.io.IOException @OptIn(ExperimentalMaterial3Api::class) @Composable fun ManagePage() { + val viewModel = viewModel() + Scaffold( topBar = { TopBar() }, floatingActionButton = { Fab() } ) { innerPadding -> - Text( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - text = "This page is not yet implemented", - textAlign = TextAlign.Center - ) + Box(Modifier.padding(innerPadding)) { + Body() + } } } @@ -57,6 +72,7 @@ private fun Fab() { val navController = LocalNavController.current val scope = rememberCoroutineScope() var shouldSelectDirectory by remember { mutableStateOf(false) } + var showNewPatchDialog by remember { mutableStateOf(false) } val errorText = stringResource(R.string.patch_select_dir_error) val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { @@ -67,7 +83,7 @@ private fun Fab() { context.contentResolver.takePersistableUriPermission(uri, takeFlags) lspApp.prefs.edit().putString(PREFS_STORAGE_DIRECTORY, uri.toString()).apply() Log.i(TAG, "Storage directory: ${uri.path}") - navController.navigate(PageList.NewPatch.name) + showNewPatchDialog = true } catch (e: Exception) { Log.e(TAG, "Error when requesting saving directory", e) scope.launch { snackbarHost.showSnackbar(errorText) } @@ -103,6 +119,58 @@ private fun Fab() { ) } + if (showNewPatchDialog) { + AlertDialog( + onDismissRequest = { showNewPatchDialog = false }, + confirmButton = {}, + dismissButton = { + TextButton( + content = { Text(stringResource(android.R.string.cancel)) }, + onClick = { showNewPatchDialog = false } + ) + }, + title = { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.page_new_patch), + textAlign = TextAlign.Center + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + TextButton( + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.secondary), + onClick = { + navController.navigate(PageList.NewPatch.name + "?from=storage") + showNewPatchDialog = false + } + ) { + Text( + modifier = Modifier.padding(vertical = 8.dp), + text = stringResource(R.string.patch_from_storage), + style = MaterialTheme.typography.bodyLarge + ) + } + TextButton( + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.secondary), + onClick = { + navController.navigate(PageList.NewPatch.name + "?from=applist") + showNewPatchDialog = false + } + ) { + Text( + modifier = Modifier.padding(vertical = 8.dp), + text = stringResource(R.string.patch_from_applist), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + ) + } + FloatingActionButton( content = { Icon(Icons.Filled.Add, stringResource(R.string.add)) }, onClick = { @@ -115,7 +183,7 @@ private fun Fab() { context.contentResolver.takePersistableUriPermission(uri, takeFlags) if (DocumentFile.fromTreeUri(context, uri)?.exists() == false) throw IOException("Storage directory was deleted") }.onSuccess { - navController.navigate(PageList.NewPatch.name) + showNewPatchDialog = true }.onFailure { Log.w(TAG, "Failed to take persistable permission for saved uri", it) lspApp.prefs.edit().putString(PREFS_STORAGE_DIRECTORY, null).apply() @@ -125,3 +193,61 @@ private fun Fab() { } ) } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Body() { + val viewModel = viewModel() + + LaunchedEffect(Unit) { + if (LSPPackageManager.appList.isEmpty()) { + withContext(Dispatchers.IO) { + LSPPackageManager.fetchAppList() + } + } + } + + if (viewModel.appList.isEmpty()) { + Box(Modifier.fillMaxSize()) { + Text( + modifier = Modifier.align(Alignment.Center), + text = run { + if (LSPPackageManager.appList.isEmpty()) stringResource(R.string.manage_loading) + else stringResource(R.string.manage_no_apps) + }, + fontFamily = FontFamily.Serif, + style = MaterialTheme.typography.headlineSmall + ) + } + } else { + LazyColumn { + items( + items = viewModel.appList, + key = { it.first.app.packageName } + ) { + AppItem( + modifier = Modifier.animateItemPlacement(spring(stiffness = Spring.StiffnessLow)), + icon = LSPPackageManager.getIcon(it.first), + label = it.first.label, + packageName = it.first.app.packageName, + onClick = {} + ) { + val text = buildAnnotatedString { + val (text, color) = + if (it.second.useManager) stringResource(R.string.patch_local) to MaterialTheme.colorScheme.secondary + else stringResource(R.string.patch_portable) to MaterialTheme.colorScheme.tertiary + append(AnnotatedString(text, SpanStyle(color = color))) + append(" ") + append(it.second.lspConfig.VERSION_CODE.toString()) + } + Text( + text = text, + fontWeight = FontWeight.SemiBold, + fontFamily = FontFamily.Serif, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchPage.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchPage.kt index 82a4435..40b05ca 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchPage.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchPage.kt @@ -6,6 +6,8 @@ import android.content.Context import android.content.pm.PackageInstaller import android.util.Log import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring @@ -35,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavBackStackEntry import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.lsposed.lspatch.Patcher import org.lsposed.lspatch.R import org.lsposed.lspatch.lspApp @@ -43,23 +46,26 @@ 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.* -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.LSPPackageManager +import org.lsposed.lspatch.util.LSPPackageManager.AppInfo import org.lsposed.lspatch.util.ShizukuApi import org.lsposed.patch.util.Logger +import java.io.File private const val TAG = "NewPatchPage" @OptIn(ExperimentalMaterial3Api::class) @Composable -fun NewPatchPage(entry: NavBackStackEntry) { +fun NewPatchPage(from: String, entry: NavBackStackEntry) { val viewModel = viewModel() + val snackbarHost = LocalSnackbarHost.current val navController = LocalNavController.current val lifecycleOwner = LocalLifecycleOwner.current val isCancelled by entry.observeState("isCancelled") LaunchedEffect(Unit) { + lspApp.tmpApkDir.listFiles()?.forEach(File::delete) entry.savedStateHandle.getLiveData("appInfo").observe(lifecycleOwner) { viewModel.configurePatch(it) } @@ -67,9 +73,28 @@ fun NewPatchPage(entry: NavBackStackEntry) { Log.d(TAG, "PatchState: ${viewModel.patchState}") if (viewModel.patchState == PatchState.SELECTING) { + val storageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { apks -> + if (apks.isEmpty()) { + navController.popBackStack() + return@rememberLauncherForActivityResult + } + runBlocking { + LSPPackageManager.getAppInfoFromApks(apks) + .onSuccess { + viewModel.configurePatch(it) + } + .onFailure { + lspApp.globalScope.launch { snackbarHost.showSnackbar(it.message ?: "Unknown error") } + navController.popBackStack() + } + } + } LaunchedEffect(Unit) { if (isCancelled == true) navController.popBackStack() - else navController.navigate(PageList.SelectApps.name + "/false") + else when (from) { + "storage" -> storageLauncher.launch(arrayOf("application/vnd.android.package-archive")) + "applist" -> navController.navigate(PageList.SelectApps.name + "?multiSelect=false") + } } } else { Scaffold( @@ -173,7 +198,7 @@ private fun PatchOptionsBody(modifier: Modifier) { desc = stringResource(R.string.patch_portable_desc), extraContent = { TextButton( - onClick = { navController.navigate(PageList.SelectApps.name + "/true") }, + onClick = { navController.navigate(PageList.SelectApps.name + "?multiSelect=true") }, content = { Text(text = stringResource(R.string.patch_embed_modules), style = MaterialTheme.typography.bodyLarge) } ) } @@ -265,7 +290,6 @@ private class PatchLogger(private val logs: MutableList>) : Lo @Composable private fun DoPatchBody(modifier: Modifier) { val viewModel = viewModel() - val context = LocalContext.current val snackbarHost = LocalSnackbarHost.current val navController = LocalNavController.current val scope = rememberCoroutineScope() @@ -274,14 +298,14 @@ private fun DoPatchBody(modifier: Modifier) { LaunchedEffect(Unit) { try { - Patcher.patch(context, logger, viewModel.patchOptions) + Patcher.patch(logger, viewModel.patchOptions) viewModel.finishPatch() } catch (t: Throwable) { logger.e(t.message.orEmpty()) logger.e(t.stackTraceToString()) viewModel.failPatch() } finally { - viewModel.patchOptions.outputDir.deleteRecursively() + lspApp.tmpApkDir.listFiles()?.forEach(File::delete) } } @@ -336,14 +360,15 @@ private fun DoPatchBody(modifier: Modifier) { var installing by rememberSaveable { mutableStateOf(false) } if (installing) InstallDialog(viewModel.patchApp) { status, message -> scope.launch { + LSPPackageManager.fetchAppList() installing = false if (status == PackageInstaller.STATUS_SUCCESS) { lspApp.globalScope.launch { snackbarHost.showSnackbar(installSuccessfully) } navController.popBackStack() - } else { + } else if (status != LSPPackageManager.STATUS_USER_CANCELLED) { val result = snackbarHost.showSnackbar(installFailed, copyError) if (result == SnackbarResult.ActionPerformed) { - val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val cm = lspApp.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(ClipData.newPlainText("LSPatch", message)) } } @@ -382,7 +407,7 @@ private fun DoPatchBody(modifier: Modifier) { Button( modifier = Modifier.weight(1f), onClick = { - val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val cm = lspApp.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(ClipData.newPlainText("LSPatch", logs.joinToString { it.second + "\n" })) }, content = { Text(stringResource(R.string.patch_copy_error)) } @@ -398,20 +423,26 @@ private fun DoPatchBody(modifier: Modifier) { @Composable private fun InstallDialog(patchApp: AppInfo, onFinish: (Int, String?) -> Unit) { val scope = rememberCoroutineScope() - var uninstallFirst by remember { mutableStateOf(ShizukuApi.isPackageInstalled(patchApp.app.packageName)) } + var uninstallFirst by remember { mutableStateOf(ShizukuApi.isPackageInstalledWithoutPatch(patchApp.app.packageName)) } var installing by remember { mutableStateOf(0) } val doInstall = suspend { Log.i(TAG, "Installing app ${patchApp.app.packageName}") installing = 1 - val (status, message) = LSPPackageInstaller.install() + val (status, message) = LSPPackageManager.install() installing = 0 Log.i(TAG, "Installation end: $status, $message") onFinish(status, message) } + LaunchedEffect(Unit) { + if (!uninstallFirst) { + doInstall() + } + } + if (uninstallFirst) { AlertDialog( - onDismissRequest = { onFinish(-2, "User cancelled") }, + onDismissRequest = { onFinish(LSPPackageManager.STATUS_USER_CANCELLED, "User cancelled") }, confirmButton = { TextButton( onClick = { @@ -419,7 +450,7 @@ private fun InstallDialog(patchApp: AppInfo, onFinish: (Int, String?) -> Unit) { Log.i(TAG, "Uninstalling app ${patchApp.app.packageName}") uninstallFirst = false installing = 2 - val (status, message) = LSPPackageInstaller.uninstall(patchApp.app.packageName) + val (status, message) = LSPPackageManager.uninstall(patchApp.app.packageName) installing = 0 Log.i(TAG, "Uninstallation end: $status, $message") if (status == PackageInstaller.STATUS_SUCCESS) { @@ -434,7 +465,7 @@ private fun InstallDialog(patchApp: AppInfo, onFinish: (Int, String?) -> Unit) { }, dismissButton = { TextButton( - onClick = { onFinish(-2, "User cancelled") }, + onClick = { onFinish(LSPPackageManager.STATUS_USER_CANCELLED, "User cancelled") }, content = { Text(stringResource(android.R.string.cancel)) } ) }, diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/PageList.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/PageList.kt index 1675bb2..937ef95 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/PageList.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/PageList.kt @@ -44,13 +44,16 @@ enum class PageList( body = { SettingsPage() } ), NewPatch( - body = { NewPatchPage(this) } + arguments = listOf( + navArgument("from") { type = NavType.StringType } + ), + body = { NewPatchPage(arguments!!.getString("from")!!, this) } ), SelectApps( arguments = listOf( navArgument("multiSelect") { type = NavType.BoolType } ), - body = { SelectAppsPage(this) } + body = { SelectAppsPage(arguments!!.getBoolean("multiSelect")) } ); val title: String diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/SelectAppsPage.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/SelectAppsPage.kt index 44329df..cd2d372 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/SelectAppsPage.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/SelectAppsPage.kt @@ -28,16 +28,15 @@ import org.lsposed.lspatch.ui.component.SearchAppBar import org.lsposed.lspatch.ui.util.LocalNavController import org.lsposed.lspatch.ui.util.observeState import org.lsposed.lspatch.ui.util.setState -import org.lsposed.lspatch.ui.viewmodel.AppInfo import org.lsposed.lspatch.ui.viewmodel.SelectAppsViewModel +import org.lsposed.lspatch.util.LSPPackageManager +import org.lsposed.lspatch.util.LSPPackageManager.AppInfo @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SelectAppsPage(entry: NavBackStackEntry) { +fun SelectAppsPage(multiSelect: Boolean) { val viewModel = viewModel() val navController = LocalNavController.current - val multiSelect = entry.arguments?.get("multiSelect") as? Boolean - ?: throw IllegalArgumentException("multiSelect is null") var searchPackage by remember { mutableStateOf("") } val filter: (AppInfo) -> Boolean = { @@ -113,7 +112,7 @@ private fun SingleSelect() { ) { AppItem( modifier = Modifier.animateItemPlacement(spring(stiffness = Spring.StiffnessLow)), - icon = viewModel.getIcon(it), + icon = LSPPackageManager.getIcon(it), label = it.label, packageName = it.app.packageName, onClick = { @@ -140,7 +139,7 @@ private fun MultiSelect() { val checked = selected!!.contains(it) AppItem( modifier = Modifier.animateItemPlacement(spring(stiffness = Spring.StiffnessLow)), - icon = viewModel.getIcon(it), + icon = LSPPackageManager.getIcon(it), label = it.label, packageName = it.app.packageName, onClick = { diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/ManageViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/ManageViewModel.kt new file mode 100644 index 0000000..623c87b --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/ManageViewModel.kt @@ -0,0 +1,27 @@ +package org.lsposed.lspatch.ui.viewmodel + +import android.util.Base64 +import android.util.Log +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.lifecycle.ViewModel +import com.google.gson.Gson +import org.lsposed.lspatch.share.PatchConfig +import org.lsposed.lspatch.util.LSPPackageManager +import org.lsposed.lspatch.util.LSPPackageManager.AppInfo + +private const val TAG = "ManageViewModel" + +class ManageViewModel : ViewModel() { + + val appList: List> by derivedStateOf { + LSPPackageManager.appList.mapNotNull { appInfo -> + appInfo.app.metaData?.getString("lspatch")?.let { + val json = Base64.decode(it, Base64.DEFAULT).toString(Charsets.UTF_8) + appInfo to Gson().fromJson(json, PatchConfig::class.java) + } + }.also { + Log.d(TAG, "Loaded ${it.size} patched apps") + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt index 4c7f12f..3081e2e 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.lifecycle.ViewModel import org.lsposed.lspatch.Patcher +import org.lsposed.lspatch.util.LSPPackageManager.AppInfo class NewPatchViewModel : ViewModel() { @@ -27,6 +28,7 @@ class NewPatchViewModel : ViewModel() { private set lateinit var embeddedModules: SnapshotStateList lateinit var patchOptions: Patcher.Options + private set fun configurePatch(app: AppInfo) { patchApp = app diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt index aed9eb4..61bb2e7 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt @@ -1,31 +1,17 @@ package org.lsposed.lspatch.ui.viewmodel -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.graphics.drawable.Drawable -import android.os.Parcelable import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.parcelize.Parcelize -import org.lsposed.lspatch.lspApp -import java.text.Collator -import java.util.* +import org.lsposed.lspatch.util.LSPPackageManager +import org.lsposed.lspatch.util.LSPPackageManager.AppInfo private const val TAG = "SelectAppViewModel" -@Parcelize -class AppInfo(val app: ApplicationInfo, val label: String) : Parcelable - -private var appList = listOf() -private val appIcon = mutableMapOf() - class SelectAppsViewModel : ViewModel() { init { @@ -40,28 +26,13 @@ class SelectAppsViewModel : ViewModel() { fun filterAppList(refresh: Boolean, filter: (AppInfo) -> Boolean) { viewModelScope.launch { - if (appList.isEmpty() || refresh) refreshAppList() - filteredList = appList.filter(filter) - } - } - - fun getIcon(appInfo: AppInfo) = appIcon[appInfo.app.packageName]!! - - private suspend fun refreshAppList() { - Log.d(TAG, "Start refresh apps") - isRefreshing = true - val collection = mutableListOf() - withContext(Dispatchers.IO) { - val pm = lspApp.packageManager - pm.getInstalledApplications(PackageManager.GET_META_DATA).forEach { - val label = pm.getApplicationLabel(it) - appIcon[it.packageName] = pm.getApplicationIcon(it) - collection.add(AppInfo(it, label.toString())) + if (LSPPackageManager.appList.isEmpty() || refresh) { + isRefreshing = true + LSPPackageManager.fetchAppList() + isRefreshing = false } - collection.sortWith(compareBy(Collator.getInstance(Locale.getDefault())) { it.label }) + filteredList = LSPPackageManager.appList.filter(filter) + Log.d(TAG, "Filtered ${filteredList.size} apps") } - appList = collection - isRefreshing = false - Log.d(TAG, "Refreshed ${appList.size} apps") } } diff --git a/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageInstaller.kt b/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt similarity index 54% rename from manager/src/main/java/org/lsposed/lspatch/util/LSPPackageInstaller.kt rename to manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt index d8fd033..6a16c77 100644 --- a/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageInstaller.kt +++ b/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt @@ -1,20 +1,64 @@ package org.lsposed.lspatch.util import android.content.Intent +import android.content.pm.ApplicationInfo import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Parcelable +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import hidden.HiddenApiBridge import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import org.lsposed.lspatch.Constants.PATCH_FILE_SUFFIX import org.lsposed.lspatch.Constants.PREFS_STORAGE_DIRECTORY import org.lsposed.lspatch.lspApp +import org.lsposed.patch.util.ManifestParser +import java.io.File import java.io.IOException +import java.text.Collator +import java.util.* import java.util.concurrent.CountDownLatch +import java.util.zip.ZipFile import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -object LSPPackageInstaller { +object LSPPackageManager { + + const val TAG = "LSPPackageManager" + + @Parcelize + class AppInfo(val app: ApplicationInfo, val label: String) : Parcelable + + const val STATUS_USER_CANCELLED = -2 + + var appList by mutableStateOf(listOf()) + private set + + private val appIcon = mutableMapOf() + + suspend fun fetchAppList() { + withContext(Dispatchers.IO) { + val pm = lspApp.packageManager + val collection = mutableListOf() + pm.getInstalledApplications(PackageManager.GET_META_DATA).forEach { + val label = pm.getApplicationLabel(it) + collection.add(AppInfo(it, label.toString())) + appIcon[it.packageName] = pm.getApplicationIcon(it) + } + collection.sortWith(compareBy(Collator.getInstance(Locale.getDefault())) { it.label }) + appList = collection + } + } + + fun getIcon(appInfo: AppInfo) = appIcon[appInfo.app.packageName]!! suspend fun install(): Pair { var status = PackageInstaller.STATUS_FAILURE @@ -31,11 +75,12 @@ object LSPPackageInstaller { ?: 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) + root.listFiles().forEach { file -> + if (file.name?.endsWith(PATCH_FILE_SUFFIX) != true) return@forEach + val input = lspApp.contentResolver.openInputStream(file.uri) ?: throw IOException("Cannot open input stream") input.use { - session.openWrite(apk.name!!, 0, input.available().toLong()).use { output -> + session.openWrite(file.name!!, 0, input.available().toLong()).use { output -> input.copyTo(output) session.fsync(output) } @@ -94,4 +139,43 @@ object LSPPackageInstaller { } return Pair(status, message) } + + suspend fun getAppInfoFromApks(apks: List): Result { + return withContext(Dispatchers.IO) { + runCatching { + val app = ApplicationInfo() + if (apks.size > 1) app.splitSourceDirs = Array(apks.size - 1) { null } + apks.forEachIndexed { index, uri -> + val src = DocumentFile.fromSingleUri(lspApp, uri) + ?: throw IOException("DocumentFile is null") + val dst = lspApp.tmpApkDir.resolve(src.name!!) + val input = lspApp.contentResolver.openInputStream(uri) + ?: throw IOException("InputStream is null") + input.use { + dst.outputStream().use { output -> + input.copyTo(output) + } + } + ZipFile(dst).use { zip -> + val entry = zip.getEntry("AndroidManifest.xml") + ?: throw IOException("AndroidManifest.xml is not found") + zip.getInputStream(entry).use { + val info = ManifestParser.parseManifestFile(it) + if (app.packageName != null && app.packageName != info.packageName) { + throw IOException("Selected apks are not of the same app") + } + app.packageName = info.packageName + } + } + if (index == 0) app.sourceDir = dst.absolutePath + else app.splitSourceDirs[index - 1] = dst.absolutePath + } + AppInfo(app, app.packageName) + }.recoverCatching { t -> + lspApp.tmpApkDir.listFiles()?.forEach(File::delete) + Log.e(TAG, "Failed to load apks", t) + throw t + } + } + } } diff --git a/manager/src/main/java/org/lsposed/lspatch/util/ShizukuApi.kt b/manager/src/main/java/org/lsposed/lspatch/util/ShizukuApi.kt index 699a34d..47423fc 100644 --- a/manager/src/main/java/org/lsposed/lspatch/util/ShizukuApi.kt +++ b/manager/src/main/java/org/lsposed/lspatch/util/ShizukuApi.kt @@ -63,8 +63,9 @@ object ShizukuApi { return constructor.newInstance(iSession) } - fun isPackageInstalled(packageName: String): Boolean { - return iPackageManager.getPackageInfo(packageName, 0, 0 /* TODO: userId */) != null + fun isPackageInstalledWithoutPatch(packageName: String): Boolean { + val app = iPackageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA, 0 /* TODO: userId */) + return (app != null) && (app.metaData?.containsKey("lspatch") != true) } fun uninstallPackage(packageName: String, intentSender: IntentSender) { diff --git a/manager/src/main/res/drawable-zh-rCN/ic_launcher_background.xml b/manager/src/main/res/drawable-zh-rCN/ic_launcher_background.xml index 174257f..baa363e 100644 --- a/manager/src/main/res/drawable-zh-rCN/ic_launcher_background.xml +++ b/manager/src/main/res/drawable-zh-rCN/ic_launcher_background.xml @@ -23,33 +23,32 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:scaleX="0.28265625" + android:scaleY="0.28265625" + android:translateX="17.82" + android:translateY="17.82"> - - - - + android:fillColor="#c9dc87" + android:pathData="M0,0h256v256h-256z" /> + android:fillAlpha="0.7" + android:fillColor="#fff" + android:pathData="M256,256v-27.38c-26.01,-15.34 -73.6,-25.62 -128,-25.62S26.01,213.28 0,228.62v27.38H256Z" + android:strokeAlpha="0.7" /> + android:fillColor="#0e7c61" + android:pathData="M86.37,232.77l1.67,-9.49 -3.13,2.46 -0.29,-0.35c1.25,-1.33 2.51,-2.73 3.79,-4.22l0.08,-0.47 0.21,0.12c2.17,-2.58 4.29,-5.27 6.35,-8.09l2.22,2.7c-0.57,0.55 -1.16,1.05 -1.79,1.52h7.03c0.38,-0.39 0.76,-0.78 1.14,-1.17l1.85,2.81c-2.03,1.33 -4.16,2.54 -6.38,3.63h6.21l2.92,-1.29 -1.98,11.25 -2.17,-2.34h-4.22l0.89,0.94 -1.24,7.03h2.58l-0.02,0.12h3.4l4.53,-6.45 0.6,0.59 -5.6,7.85 -0.1,-0.12 -0.02,0.12h-4.92l0.02,-0.12c-1.53,0.23 -2.84,1.05 -3.95,2.46l0.43,-2.46 -0.14,0.12 1.78,-10.08h-0.23c-0.29,1.64 -1.16,3.01 -2.6,4.1 -1.85,1.64 -3.95,3.13 -6.29,4.45 -2.34,1.33 -4.62,2.5 -6.83,3.52l-0.23,-0.7c1.9,-1.02 3.96,-2.29 6.18,-3.81 2.22,-1.52 4.06,-3.07 5.5,-4.63 0.78,-0.86 1.3,-1.83 1.57,-2.93h-5.62l-0.17,0.94 -3.05,1.99ZM89.81,228.55h5.62l0.89,-5.04h-5.62l-0.89,5.04ZM90.93,222.22h6.21c1.41,-1.33 2.77,-2.66 4.1,-3.98h-7.27c-1.3,1.17 -2.63,2.31 -4,3.4l0.95,0.59ZM98.69,225.38l-0.56,3.16h0.23l0.04,-0.23 0.19,0.23h5.62l0.89,-5.04h-8.09l1.66,1.88Z" /> + android:fillColor="#0e7c61" + android:pathData="M116.29,218.38h7.04c4.15,0 7.29,1.44 6.47,6.09 -0.79,4.49 -4.53,6.43 -8.68,6.43h-2.62l-1.24,7.04h-4.42l3.45,-19.55ZM121.38,227.38c2.33,0 3.75,-1 4.09,-2.92 0.34,-1.93 -0.79,-2.58 -3.12,-2.58h-2.26l-0.97,5.5h2.26ZM120.52,229.72l3.56,-2.83 4.29,11.03h-4.95l-2.9,-8.2Z" /> + android:fillColor="#0e7c61" + android:pathData="M131.02,230.49c0.87,-4.95 4.86,-7.81 8.63,-7.81s6.75,2.86 5.87,7.81c-0.87,4.94 -4.86,7.79 -8.62,7.79s-6.75,-2.86 -5.88,-7.79ZM141,230.49c0.45,-2.58 -0.16,-4.25 -1.98,-4.25s-3.03,1.67 -3.48,4.25c-0.45,2.58 0.16,4.24 1.98,4.24s3.02,-1.66 3.48,-4.24Z" /> + android:fillColor="#0e7c61" + android:pathData="M147.67,230.49c0.87,-4.95 4.86,-7.81 8.63,-7.81s6.75,2.86 5.87,7.81c-0.87,4.94 -4.86,7.79 -8.62,7.79s-6.75,-2.86 -5.88,-7.79ZM157.65,230.49c0.45,-2.58 -0.16,-4.25 -1.98,-4.25s-3.03,1.67 -3.48,4.25c-0.45,2.58 0.16,4.24 1.98,4.24s3.02,-1.66 3.48,-4.24Z" /> + diff --git a/manager/src/main/res/drawable/ic_launcher_background.xml b/manager/src/main/res/drawable/ic_launcher_background.xml index 229cb10..c07aa2a 100644 --- a/manager/src/main/res/drawable/ic_launcher_background.xml +++ b/manager/src/main/res/drawable/ic_launcher_background.xml @@ -23,42 +23,41 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:scaleX="0.28265625" + android:scaleY="0.28265625" + android:translateX="17.82" + android:translateY="17.82"> - - - - + android:fillColor="#c9dc87" + android:pathData="M0,0h256v256h-256z" /> + android:fillAlpha="0.7" + android:fillColor="#fff" + android:pathData="M256,256v-27.38c-26.01,-15.34 -73.6,-25.62 -128,-25.62S26.01,213.28 0,228.62v27.38H256Z" + android:strokeAlpha="0.7" /> + android:fillColor="#0e7c61" + android:pathData="M78.19,218.79h6.13c3.84,0 6.69,1.36 6.69,5 0,5.12 -4.17,7.38 -8.73,7.38h-2.48l-1.42,7.17h-4.11l3.91,-19.55ZM82.56,227.91c2.91,0 4.39,-1.4 4.39,-3.52 0,-1.64 -1.16,-2.35 -3.26,-2.35h-2.06l-1.15,5.88h2.08ZM81.88,230.21l2.97,-2.63 4.22,10.76h-4.33l-2.85,-8.12Z" /> + android:fillColor="#0e7c61" + android:pathData="M91.9,232.57c0,-5.74 4.24,-9.47 8.34,-9.47 3.47,0 5.78,2.44 5.78,6.13 0,5.74 -4.24,9.47 -8.34,9.47 -3.47,0 -5.78,-2.44 -5.78,-6.13ZM101.87,229.32c0,-1.82 -0.7,-2.91 -2.08,-2.91 -2,0 -3.73,2.62 -3.73,6.09 0,1.82 0.7,2.91 2.08,2.91 2,0 3.73,-2.61 3.73,-6.09Z" /> + android:fillColor="#0e7c61" + android:pathData="M107.89,232.57c0,-5.74 4.24,-9.47 8.34,-9.47 3.47,0 5.78,2.44 5.78,6.13 0,5.74 -4.24,9.47 -8.34,9.47 -3.47,0 -5.78,-2.44 -5.78,-6.13ZM117.86,229.32c0,-1.82 -0.7,-2.91 -2.08,-2.91 -2,0 -3.73,2.62 -3.73,6.09 0,1.82 0.7,2.91 2.08,2.91 2,0 3.73,-2.61 3.73,-6.09Z" /> + android:fillColor="#0e7c61" + android:pathData="M124.86,234.76c0,-0.64 0.11,-1.25 0.22,-1.87l1.3,-6.23h-1.98l0.61,-3.03 2.19,-0.17 1.26,-3.89h3.44l-0.74,3.89h3.37l-0.62,3.2h-3.47l-1.3,6.39c-0.06,0.36 -0.07,0.67 -0.07,0.99 0,0.98 0.48,1.46 1.56,1.46 0.42,0 0.83,-0.15 1.21,-0.34l0.75,2.88c-0.79,0.3 -1.96,0.66 -3.49,0.66 -3,0 -4.21,-1.66 -4.21,-3.94Z" /> + android:fillColor="#0e7c61" + android:pathData="M135.1,235.79c0,-0.53 0.06,-1.07 0.22,-1.82l3.33,-16.65h4.1l-3.38,16.81c-0.06,0.3 -0.06,0.43 -0.06,0.57 0,0.51 0.27,0.69 0.57,0.69 0.17,0 0.28,0 0.53,-0.07l-0.13,3.04c-0.5,0.17 -1.22,0.33 -2.17,0.33 -2.13,0 -3.01,-1.1 -3.01,-2.91Z" /> + android:fillColor="#0e7c61" + android:pathData="M151.39,223.1c3.55,0 4.85,2.49 4.85,5.58 0,1.4 -0.53,2.94 -0.76,3.39h-8.48c-0.1,2.48 1.39,3.53 3.35,3.53 0.92,0 2,-0.51 2.73,-1.04l1.46,2.59c-1.19,0.84 -3.04,1.56 -5.34,1.56 -3.6,0 -6.09,-2.39 -6.09,-6.38 0,-5.51 4.31,-9.23 8.27,-9.23ZM152.75,229.56c0.07,-0.3 0.13,-0.7 0.13,-1.11 0,-1.2 -0.5,-2.2 -2,-2.2 -1.42,0 -2.87,1.12 -3.55,3.31h5.42Z" /> + android:fillColor="#0e7c61" + android:pathData="M156.88,235.81l2.39,-1.96c1.03,1.26 2.13,1.85 3.21,1.85s2.07,-0.61 2.07,-1.41c0,-0.85 -0.87,-1.24 -2.56,-2.2 -1.69,-0.95 -3.01,-2.25 -3.01,-4.14 0,-2.79 2.58,-4.85 5.76,-4.85 2.05,0 3.67,1.01 4.88,2.27l-2.23,2.13c-0.73,-0.74 -1.6,-1.35 -2.66,-1.35 -1.13,0 -1.9,0.64 -1.9,1.42 0,0.95 1.26,1.38 2.45,2.06 1.77,0.96 3.12,2.14 3.12,4.18 0,2.93 -2.59,4.89 -6.19,4.89 -1.8,0 -4.11,-1.04 -5.33,-2.89Z" /> + diff --git a/manager/src/main/res/drawable/ic_launcher_foreground.xml b/manager/src/main/res/drawable/ic_launcher_foreground.xml index 5cf924c..debdc46 100644 --- a/manager/src/main/res/drawable/ic_launcher_foreground.xml +++ b/manager/src/main/res/drawable/ic_launcher_foreground.xml @@ -18,19 +18,17 @@ --> + android:scaleX="0.27421874" + android:scaleY="0.27421874" + android:translateX="18.9" + android:translateY="18.9"> + android:fillColor="#fff" + android:pathData="M167.13,107.36l27.34,-27.34c2.65,-2.67 2.65,-6.97 0,-9.64l-29.8,-29.39c-2.67,-2.65 -6.97,-2.65 -9.64,0l-27.34,27.34 -27.34,-27.34c-1.22,-1.21 -2.86,-1.92 -4.58,-1.98 -1.79,0 -3.51,0.72 -4.79,1.98l-29.67,29.67c-2.47,2.63 -2.47,6.73 0,9.37l27.34,27.34 -27.34,27.34c-2.65,2.67 -2.65,6.97 0,9.64l29.67,29.67c2.67,2.65 6.97,2.65 9.64,0l27.34,-27.34 27.34,27.34c1.29,1.28 3.04,1.99 4.85,1.98 1.82,0.01 3.56,-0.7 4.85,-1.98l29.67,-29.67c2.65,-2.67 2.65,-6.97 0,-9.64l-27.55,-27.34ZM127.69,86.85c3.78,0 6.84,3.06 6.84,6.84s-3.06,6.84 -6.84,6.84 -6.84,-3.06 -6.84,-6.84 3.06,-6.84 6.84,-6.84ZM95.77,100.52l-24.81,-25.02 24.81,-24.81 25.09,24.75 -25.09,25.09ZM114.02,114.19c-3.78,0 -6.84,-3.06 -6.84,-6.84s3.06,-6.84 6.84,-6.84 6.84,3.06 6.84,6.84 -3.06,6.84 -6.84,6.84ZM127.69,127.86c-3.78,0 -6.84,-3.06 -6.84,-6.84s3.06,-6.84 6.84,-6.84 6.84,3.06 6.84,6.84 -3.06,6.84 -6.84,6.84ZM141.36,100.52c3.78,0 6.84,3.06 6.84,6.84s-3.06,6.84 -6.84,6.84 -6.84,-3.06 -6.84,-6.84 3.06,-6.84 6.84,-6.84ZM159.54,164.37l-24.81,-24.75 24.81,-24.81 24.75,24.75 -24.75,24.81Z" /> diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index d48278f..4759d90 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -21,12 +21,16 @@ Manage + Loading + No patched apps yet New Patch Select storage directory Select a directory to store the patched apks Error when setting storage directory + Select apk(s) from storage + Select an installed app Patch Mode Local Patch an app without modules embedded.\nThe patched app need the manager running in background, and Xposed scope can be changed dynamically without re-patch.\nLocal patched apps can only run on the local device. diff --git a/patch-jar/build.gradle.kts b/patch-jar/build.gradle.kts index 86fc9b0..2a7b37e 100644 --- a/patch-jar/build.gradle.kts +++ b/patch-jar/build.gradle.kts @@ -1,3 +1,5 @@ +val verCode: Int by rootProject.extra +val verName: String by rootProject.extra val androidSourceCompatibility: JavaVersion by rootProject.extra val androidTargetCompatibility: JavaVersion by rootProject.extra @@ -14,9 +16,9 @@ dependencies { implementation(projects.patch) } -tasks.jar { - archiveBaseName.set("lspatch") - destinationDirectory.set(file("${rootProject.projectDir}/out")) +fun Jar.configure(variant: String) { + archiveBaseName.set("jar-v$verName-$verCode-$variant") + destinationDirectory.set(file("${rootProject.projectDir}/out/$variant")) manifest { attributes("Main-Class" to "org.lsposed.patch.LSPatch") } @@ -31,21 +33,14 @@ tasks.jar { exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA", "META-INF/*.MF", "META-INF/*.txt", "META-INF/versions/**") } -val jar = tasks.jar.get() - -tasks.register("buildDebug") { - jar.dependsOn(":appstub:copyDebug") - jar.dependsOn(":patch-loader:copyDebug") - dependsOn(tasks.build) +tasks.register("buildDebug") { + dependsOn(":appstub:copyDebug") + dependsOn(":patch-loader:copyDebug") + configure("debug") } -tasks.register("buildRelease") { - jar.dependsOn(":appstub:copyRelease") - jar.dependsOn(":patch-loader:copyRelease") - dependsOn(tasks.build) -} - -tasks["build"].doLast { - println("Build to " + jar.archiveFile) - println("Try \'java -jar " + jar.archiveFileName + "\' find more help") +tasks.register("buildRelease") { + dependsOn(":appstub:copyRelease") + dependsOn(":patch-loader:copyRelease") + configure("release") } diff --git a/patch/src/main/java/org/lsposed/patch/LSPatch.java b/patch/src/main/java/org/lsposed/patch/LSPatch.java index e79de8e..2e9af92 100644 --- a/patch/src/main/java/org/lsposed/patch/LSPatch.java +++ b/patch/src/main/java/org/lsposed/patch/LSPatch.java @@ -21,6 +21,7 @@ import com.wind.meditor.property.ModificationProperty; import com.wind.meditor.utils.NodeValue; import org.apache.commons.io.FilenameUtils; +import org.lsposed.lspatch.share.LSPConfig; import org.lsposed.lspatch.share.PatchConfig; import org.lsposed.patch.util.ApkSignatureHelper; import org.lsposed.patch.util.JavaLogger; @@ -38,8 +39,10 @@ import java.security.KeyStore; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Set; @@ -160,7 +163,11 @@ public class LSPatch { var outputDir = new File(outputPath); outputDir.mkdirs(); - File outputFile = new File(outputDir, String.format("%s-lv%s-lspatched.apk", FilenameUtils.getBaseName(apkFileName), sigbypassLevel)).getAbsoluteFile(); + File outputFile = new File(outputDir, String.format( + Locale.getDefault(), "%s-%d-lspatched.apk", + FilenameUtils.getBaseName(apkFileName), + LSPConfig.instance.VERSION_CODE) + ).getAbsoluteFile(); if (outputFile.exists() && !forceOverwrite) throw new PatchError(outputPath + " exists. Use --force to overwrite"); @@ -237,7 +244,10 @@ public class LSPatch { logger.i("Patching apk..."); // modify manifest - try (var is = new ByteArrayInputStream(modifyManifestFile(manifestEntry.open()))) { + var config = new PatchConfig(useManager, sigbypassLevel, null, appComponentFactory); + var configBytes = new Gson().toJson(config).getBytes(StandardCharsets.UTF_8); + var metadata = Base64.getEncoder().encodeToString(configBytes); + try (var is = new ByteArrayInputStream(modifyManifestFile(manifestEntry.open(), metadata))) { dstZFile.add(ANDROID_MANIFEST_XML, is); } catch (Throwable e) { throw new PatchError("Error when modifying manifest", e); @@ -273,9 +283,8 @@ public class LSPatch { } // save lspatch config to asset.. - var config = new PatchConfig(useManager, sigbypassLevel, originalSignature, appComponentFactory); - var configBytes = new Gson().toJson(config).getBytes(StandardCharsets.UTF_8); - + config = new PatchConfig(useManager, sigbypassLevel, originalSignature, appComponentFactory); + configBytes = new Gson().toJson(config).getBytes(StandardCharsets.UTF_8); try (var is = new ByteArrayInputStream(configBytes)) { dstZFile.add(CONFIG_ASSET_PATH, is); } catch (Throwable e) { @@ -343,13 +352,14 @@ public class LSPatch { } } - private byte[] modifyManifestFile(InputStream is) throws IOException { + private byte[] modifyManifestFile(InputStream is, String metadata) throws IOException { ModificationProperty property = new ModificationProperty(); if (overrideVersionCode) property.addManifestAttribute(new AttributeItem(NodeValue.Manifest.VERSION_CODE, 1)); property.addApplicationAttribute(new AttributeItem(NodeValue.Application.DEBUGGABLE, debuggableFlag)); property.addApplicationAttribute(new AttributeItem("appComponentFactory", PROXY_APP_COMPONENT_FACTORY)); + property.addMetaData(new ModificationProperty.MetaData("lspatch", metadata)); // TODO: replace query_all with queries -> manager property.addUsesPermission("android.permission.QUERY_ALL_PACKAGES"); diff --git a/share/java/build.gradle.kts b/share/java/build.gradle.kts index 6db7e78..0f7e994 100644 --- a/share/java/build.gradle.kts +++ b/share/java/build.gradle.kts @@ -1,3 +1,8 @@ +val apiCode: Int by rootProject.extra +val verCode: Int by rootProject.extra +val verName: String by rootProject.extra +val coreVerCode: Int by rootProject.extra +val coreVerName: String by rootProject.extra val androidSourceCompatibility: JavaVersion by rootProject.extra val androidTargetCompatibility: JavaVersion by rootProject.extra @@ -9,3 +14,20 @@ java { sourceCompatibility = androidSourceCompatibility targetCompatibility = androidTargetCompatibility } + +val generateTask = task("generateJava") { + val template = mapOf( + "apiCode" to apiCode, + "verCode" to verCode, + "verName" to verName, + "coreVerCode" to coreVerCode, + "coreVerName" to coreVerName + ) + inputs.properties(template) + from("src/template/java") + into("$buildDir/generated/java") + expand(template) +} + +sourceSets["main"].java.srcDir("$buildDir/generated/java") +tasks["compileJava"].dependsOn(generateTask) diff --git a/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java b/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java index 406d71e..60cb892 100644 --- a/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java +++ b/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java @@ -6,11 +6,13 @@ public class PatchConfig { public final int sigBypassLevel; public final String originalSignature; public final String appComponentFactory; + public final LSPConfig lspConfig; public PatchConfig(boolean useManager, int sigBypassLevel, String originalSignature, String appComponentFactory) { this.useManager = useManager; this.sigBypassLevel = sigBypassLevel; this.originalSignature = originalSignature; this.appComponentFactory = appComponentFactory; + this.lspConfig = LSPConfig.instance; } } diff --git a/share/java/src/template/java/org.lsposed.lspatch.share/LSPConfig.java b/share/java/src/template/java/org.lsposed.lspatch.share/LSPConfig.java new file mode 100644 index 0000000..0e8c497 --- /dev/null +++ b/share/java/src/template/java/org.lsposed.lspatch.share/LSPConfig.java @@ -0,0 +1,15 @@ +package org.lsposed.lspatch.share; + +public class LSPConfig { + + public static final LSPConfig instance = new LSPConfig(); + + public final int API_CODE = ${apiCode}; + public final int VERSION_CODE = ${verCode}; + public final String VERSION_NAME = "${verName}"; + public final int CORE_VERSION_CODE = ${coreVerCode}; + public final String CORE_VERSION_NAME = "${coreVerName}"; + + private LSPConfig() { + } +}