From 08f51d5469f5fea8018bd469906f66414c2b3467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 25 Dec 2025 01:49:27 +0800 Subject: [PATCH] Add alternative support for QUERY_ALL_PACKAGES in play flavor --- app/build.gradle | 11 + app/src/main/AndroidManifest.xml | 2 +- .../java/io/nekohasekai/sfa/Application.kt | 2 - .../nekohasekai/sfa/bg/AppChangeReceiver.kt | 61 ++- .../screen/settings/ProfileOverrideScreen.kt | 396 +++++++++++++++--- .../nekohasekai/sfa/constant/SettingsKey.kt | 1 + .../io/nekohasekai/sfa/database/Settings.kt | 4 + .../sfa/ui/debug/VPNScanActivity.kt | 9 +- .../ui/profileoverride/PerAppProxyActivity.kt | 80 ++-- app/src/main/res/values-zh-rCN/strings.xml | 11 +- app/src/main/res/values/strings.xml | 10 +- .../sfa/vendor/PackageQueryManager.kt | 47 +++ .../sfa/vendor/IRootPackageManager.aidl | 7 + .../sfa/vendor/PackageQueryManager.kt | 101 +++++ .../sfa/vendor/RootPackageManager.kt | 98 +++++ .../sfa/vendor/RootPackageManagerService.kt | 28 ++ .../sfa/vendor/ShizukuPackageManager.kt | 114 +++++ .../{minApi23 => other}/AndroidManifest.xml | 11 +- .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 4 - .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 4 - app/src/play/AndroidManifest.xml | 15 +- .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 8 - 22 files changed, 855 insertions(+), 169 deletions(-) create mode 100644 app/src/minApi21/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt create mode 100644 app/src/minApi23/aidl/io/nekohasekai/sfa/vendor/IRootPackageManager.aidl create mode 100644 app/src/minApi23/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt create mode 100644 app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManager.kt create mode 100644 app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManagerService.kt create mode 100644 app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPackageManager.kt rename app/src/{minApi23 => other}/AndroidManifest.xml (63%) diff --git a/app/build.gradle b/app/build.gradle index 86d0820..3f68401 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -79,9 +79,11 @@ android { sourceSets { play { java.srcDirs += ['src/minApi23/java'] + aidl.srcDirs += ['src/minApi23/aidl'] } other { java.srcDirs += ['src/minApi23/java', 'src/github/java'] + aidl.srcDirs += ['src/minApi23/aidl'] } otherLegacy { java.srcDirs += ['src/minApi21/java', 'src/github/java'] @@ -202,6 +204,15 @@ dependencies { otherImplementation "dev.rikka.shizuku:provider:$shizukuVersion" otherImplementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3' + // libsu for ROOT package query (all flavors) + def libsuVersion = '6.0.0' + playImplementation "com.github.topjohnwu.libsu:core:$libsuVersion" + playImplementation "com.github.topjohnwu.libsu:service:$libsuVersion" + otherImplementation "com.github.topjohnwu.libsu:core:$libsuVersion" + otherImplementation "com.github.topjohnwu.libsu:service:$libsuVersion" + otherLegacyImplementation "com.github.topjohnwu.libsu:core:$libsuVersion" + otherLegacyImplementation "com.github.topjohnwu.libsu:service:$libsuVersion" + // Compose dependencies - API 23+ (play/other) def composeBom23 = platform('androidx.compose:compose-bom:2025.12.01') def activityVersion23 = "1.12.2" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 800f7f6..2f3ec7d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -238,4 +238,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt index d2880f9..b80d701 100644 --- a/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -42,8 +42,6 @@ class Application : Application() { UpdateProfileWork.reconfigureUpdater() } - // Only register AppChangeReceiver if Per-app Proxy is available - // This receiver needs QUERY_ALL_PACKAGES permission to function if (Vendor.isPerAppProxyAvailable()) { registerReceiver( AppChangeReceiver(), diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt index 871d003..37d3189 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt @@ -3,9 +3,18 @@ package io.nekohasekai.sfa.bg import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.util.Log +import android.widget.Toast +import io.nekohasekai.sfa.R import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity +import io.nekohasekai.sfa.vendor.PackageQueryManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class AppChangeReceiver : BroadcastReceiver() { companion object { @@ -17,10 +26,6 @@ class AppChangeReceiver : BroadcastReceiver() { intent: Intent, ) { Log.d(TAG, "onReceive: ${intent.action}") - checkUpdate(intent) - } - - private fun checkUpdate(intent: Intent) { if (!Settings.perAppProxyEnabled) { Log.d(TAG, "per app proxy disabled") return @@ -29,19 +34,41 @@ class AppChangeReceiver : BroadcastReceiver() { Log.d(TAG, "managed mode disabled") return } - val packageName = intent.dataString?.substringAfter("package:") - if (packageName.isNullOrBlank()) { - Log.d(TAG, "missing package name in intent") - return - } - val isChinaApp = PerAppProxyActivity.scanChinaPackage(packageName) - Log.d(TAG, "scan china app result for $packageName: $isChinaApp") - if (isChinaApp) { - Settings.perAppProxyManagedList += packageName - Log.d(TAG, "added to managed list") - } else { - Settings.perAppProxyManagedList -= packageName - Log.d(TAG, "removed from managed list") + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + rescanAllApps() + } catch (e: Exception) { + Log.e(TAG, "Failed to rescan apps", e) + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.error_title, Toast.LENGTH_SHORT).show() + } + } finally { + pendingResult.finish() + } } } + + private suspend fun rescanAllApps() { + Log.d(TAG, "rescanning all apps") + val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } else { + @Suppress("DEPRECATION") + PackageManager.GET_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } + val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags) + val chinaApps = mutableSetOf() + for (packageInfo in installedPackages) { + if (PerAppProxyActivity.scanChinaPackage(packageInfo)) { + chinaApps.add(packageInfo.packageName) + } + } + Settings.perAppProxyManagedList = chinaApps + Log.d(TAG, "rescan complete, found ${chinaApps.size} china apps") + } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt index 4e918f1..94cefde 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt @@ -2,15 +2,14 @@ package io.nekohasekai.sfa.compose.screen.settings import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -22,6 +21,7 @@ import androidx.compose.material.icons.outlined.AppShortcut import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.Route import androidx.compose.material.icons.outlined.SmartToy +import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -30,10 +30,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -46,11 +50,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity -import io.nekohasekai.sfa.vendor.Vendor +import io.nekohasekai.sfa.vendor.PackageQueryManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -66,7 +69,58 @@ fun ProfileOverrideScreen(navController: NavController) { var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) } var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) } var isScanning by remember { mutableStateOf(false) } - var showPerAppProxyDialog by remember { mutableStateOf(false) } + var showShizukuDialog by remember { mutableStateOf(false) } + var showRootDialog by remember { mutableStateOf(false) } + var showModeDialog by remember { mutableStateOf(false) } + + val needsPrivilegedQuery = PackageQueryManager.needsPrivilegedQuery + var packageQueryMode by remember { mutableStateOf(Settings.perAppProxyPackageQueryMode) } + val useRootMode = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT + + val isShizukuInstalled by PackageQueryManager.shizukuInstalled.collectAsState() + val isShizukuBinderReady by PackageQueryManager.shizukuBinderReady.collectAsState() + val isShizukuPermissionGranted by PackageQueryManager.shizukuPermissionGranted.collectAsState() + val isShizukuAvailable = isShizukuBinderReady && isShizukuPermissionGranted + + DisposableEffect(needsPrivilegedQuery) { + if (needsPrivilegedQuery) { + PackageQueryManager.registerListeners() + } + onDispose { + if (needsPrivilegedQuery) { + PackageQueryManager.unregisterListeners() + } + } + } + + // Auto-disable per-app proxy if Shizuku authorization is revoked (only when using Shizuku mode) + LaunchedEffect(isShizukuAvailable, useRootMode) { + if (needsPrivilegedQuery && !useRootMode && !isShizukuAvailable && perAppProxyEnabled) { + perAppProxyEnabled = false + withContext(Dispatchers.IO) { + Settings.perAppProxyEnabled = false + } + } + } + + // Auto-close dialog and enable feature when Shizuku becomes available + LaunchedEffect(isShizukuAvailable) { + if (needsPrivilegedQuery && isShizukuAvailable && showShizukuDialog) { + showShizukuDialog = false + perAppProxyEnabled = true + withContext(Dispatchers.IO) { + Settings.perAppProxyEnabled = true + } + if (managedModeEnabled) { + isScanning = true + val chinaApps = scanAllChinaApps() + withContext(Dispatchers.IO) { + Settings.perAppProxyManagedList = chinaApps + } + isScanning = false + } + } + } Column( modifier = @@ -158,7 +212,11 @@ fun ProfileOverrideScreen(navController: NavController) { } // Section: Per-App Proxy - val isPerAppProxyAvailable = Vendor.isPerAppProxyAvailable() + val canUsePerAppProxy = if (needsPrivilegedQuery) { + if (useRootMode) true else isShizukuAvailable + } else { + true + } Text( text = stringResource(R.string.per_app_proxy), @@ -178,6 +236,52 @@ fun ProfileOverrideScreen(navController: NavController) { ), ) { Column { + // Mode selector (only when privileged query is needed) + if (needsPrivilegedQuery) { + val modeEnabled = !perAppProxyEnabled + val disabledAlpha = 0.38f + ListItem( + headlineContent = { + Text( + stringResource(R.string.per_app_proxy_package_query_mode), + style = MaterialTheme.typography.bodyLarge, + color = if (modeEnabled) Color.Unspecified + else MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha), + ) + }, + supportingContent = { + Text( + if (useRootMode) "ROOT" else "Shizuku", + style = MaterialTheme.typography.bodyMedium, + color = if (modeEnabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = disabledAlpha), + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = if (modeEnabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha), + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = if (modeEnabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha), + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable(enabled = modeEnabled) { showModeDialog = true }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + // Enabled toggle ListItem( headlineContent = { @@ -186,19 +290,6 @@ fun ProfileOverrideScreen(navController: NavController) { style = MaterialTheme.typography.bodyLarge, ) }, - supportingContent = - if (!isPerAppProxyAvailable) { - { - Text( - text = context.getString(R.string.per_app_proxy_disabled_play_store), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 4.dp), - ) - } - } else { - null - }, leadingContent = { Icon( imageVector = Icons.Outlined.FilterList, @@ -207,38 +298,40 @@ fun ProfileOverrideScreen(navController: NavController) { ) }, trailingContent = { - if (isPerAppProxyAvailable) { - if (isScanning) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } else { - Switch( - checked = perAppProxyEnabled, - onCheckedChange = { checked -> - perAppProxyEnabled = checked - scope.launch(Dispatchers.IO) { - Settings.perAppProxyEnabled = checked - } - if (checked && managedModeEnabled) { - isScanning = true - scope.launch { - val chinaApps = scanAllChinaApps() - withContext(Dispatchers.IO) { - Settings.perAppProxyManagedList = chinaApps - } - isScanning = false + Switch( + checked = perAppProxyEnabled, + onCheckedChange = { checked -> + if (checked && needsPrivilegedQuery) { + if (useRootMode) { + showRootDialog = true + } else { + showShizukuDialog = true + } + } else { + perAppProxyEnabled = checked + scope.launch(Dispatchers.IO) { + Settings.perAppProxyEnabled = checked + } + if (checked && managedModeEnabled) { + isScanning = true + scope.launch { + val chinaApps = scanAllChinaApps() + withContext(Dispatchers.IO) { + Settings.perAppProxyManagedList = chinaApps } + isScanning = false } - }, - ) - } - } + } + } + }, + enabled = !isScanning, + ) }, modifier = Modifier.clip( - if (perAppProxyEnabled && isPerAppProxyAvailable) { + if (needsPrivilegedQuery) { + RoundedCornerShape(0.dp) + } else if (perAppProxyEnabled && canUsePerAppProxy) { RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) } else { RoundedCornerShape(12.dp) @@ -250,7 +343,7 @@ fun ProfileOverrideScreen(navController: NavController) { ), ) - if (perAppProxyEnabled && isPerAppProxyAvailable) { + if (perAppProxyEnabled && canUsePerAppProxy) { // Manage entry val manageEnabled = !managedModeEnabled val disabledAlpha = 0.38f @@ -366,23 +459,199 @@ fun ProfileOverrideScreen(navController: NavController) { } } - // Dialog for Per-app Proxy disabled message - if (showPerAppProxyDialog) { + // Shizuku dialog + if (showShizukuDialog) { AlertDialog( - onDismissRequest = { showPerAppProxyDialog = false }, + onDismissRequest = { showShizukuDialog = false }, title = { - Text(stringResource(R.string.unavailable)) + Text(stringResource(R.string.per_app_proxy)) }, text = { - Text(context.getString(R.string.per_app_proxy_disabled_message)) + Text(stringResource(R.string.per_app_proxy_shizuku_required)) + }, + confirmButton = { + when { + isShizukuAvailable -> { + TextButton( + onClick = { + showShizukuDialog = false + perAppProxyEnabled = true + scope.launch(Dispatchers.IO) { + Settings.perAppProxyEnabled = true + } + if (managedModeEnabled) { + isScanning = true + scope.launch { + val chinaApps = scanAllChinaApps() + withContext(Dispatchers.IO) { + Settings.perAppProxyManagedList = chinaApps + } + isScanning = false + } + } + }, + ) { + Text(stringResource(R.string.ok)) + } + } + isShizukuBinderReady -> { + TextButton( + onClick = { + PackageQueryManager.requestShizukuPermission() + }, + ) { + Text(stringResource(R.string.request_shizuku)) + } + } + isShizukuInstalled -> { + TextButton( + onClick = { + showShizukuDialog = false + val intent = context.packageManager.getLaunchIntentForPackage("moe.shizuku.privileged.api") + if (intent != null) { + context.startActivity(intent) + } + }, + ) { + Text(stringResource(R.string.start_shizuku)) + } + } + else -> { + TextButton( + onClick = { + showShizukuDialog = false + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/")) + context.startActivity(intent) + }, + ) { + Text(stringResource(R.string.get_shizuku)) + } + } + } + }, + dismissButton = { + if (!isShizukuAvailable) { + TextButton( + onClick = { showShizukuDialog = false }, + ) { + Text(stringResource(R.string.cancel)) + } + } + }, + ) + } + + // ROOT dialog + if (showRootDialog) { + AlertDialog( + onDismissRequest = { showRootDialog = false }, + title = { + Text(stringResource(R.string.per_app_proxy)) + }, + text = { + Text(stringResource(R.string.per_app_proxy_root_required)) }, confirmButton = { TextButton( - onClick = { showPerAppProxyDialog = false }, + onClick = { + scope.launch { + val hasRoot = PackageQueryManager.checkRootAvailable() + if (hasRoot) { + showRootDialog = false + perAppProxyEnabled = true + withContext(Dispatchers.IO) { + Settings.perAppProxyEnabled = true + } + if (managedModeEnabled) { + isScanning = true + val chinaApps = scanAllChinaApps() + withContext(Dispatchers.IO) { + Settings.perAppProxyManagedList = chinaApps + } + isScanning = false + } + } else { + showRootDialog = false + Toast.makeText( + context, + R.string.root_access_denied, + Toast.LENGTH_LONG + ).show() + } + } + }, ) { - Text(context.getString(R.string.ok)) + Text(stringResource(R.string.ok)) } }, + dismissButton = { + TextButton( + onClick = { showRootDialog = false }, + ) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + // Mode selection dialog + if (showModeDialog) { + AlertDialog( + onDismissRequest = { showModeDialog = false }, + title = { + Text(stringResource(R.string.per_app_proxy_package_query_mode)) + }, + text = { + Column { + ListItem( + headlineContent = { Text("Shizuku") }, + leadingContent = { + RadioButton( + selected = packageQueryMode == Settings.PACKAGE_QUERY_MODE_SHIZUKU, + onClick = null, + ) + }, + modifier = Modifier.clickable { + packageQueryMode = Settings.PACKAGE_QUERY_MODE_SHIZUKU + PackageQueryManager.setQueryMode(Settings.PACKAGE_QUERY_MODE_SHIZUKU) + scope.launch(Dispatchers.IO) { + Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_SHIZUKU + } + if (perAppProxyEnabled && !isShizukuAvailable) { + perAppProxyEnabled = false + scope.launch(Dispatchers.IO) { + Settings.perAppProxyEnabled = false + } + } + showModeDialog = false + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + ListItem( + headlineContent = { Text("ROOT") }, + leadingContent = { + RadioButton( + selected = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT, + onClick = null, + ) + }, + modifier = Modifier.clickable { + packageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT + PackageQueryManager.setQueryMode(Settings.PACKAGE_QUERY_MODE_ROOT) + scope.launch(Dispatchers.IO) { + Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT + } + showModeDialog = false + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + }, + confirmButton = {}, ) } } @@ -390,25 +659,22 @@ fun ProfileOverrideScreen(navController: NavController) { private suspend fun scanAllChinaApps(): Set = withContext(Dispatchers.Default) { val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.MATCH_UNINSTALLED_PACKAGES + PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS } else { @Suppress("DEPRECATION") - PackageManager.GET_UNINSTALLED_PACKAGES + PackageManager.GET_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS } - val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Application.packageManager.getInstalledPackages( - PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong()) - ) - } else { - @Suppress("DEPRECATION") - Application.packageManager.getInstalledPackages(packageManagerFlags) - } + val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags) val chinaApps = mutableSetOf() installedPackages.map { packageInfo -> async { - if (PerAppProxyActivity.scanChinaPackage(packageInfo.packageName)) { + if (PerAppProxyActivity.scanChinaPackage(packageInfo)) { synchronized(chinaApps) { chinaApps.add(packageInfo.packageName) } diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 9aca0d7..291d155 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -20,6 +20,7 @@ object SettingsKey { const val PER_APP_PROXY_LIST = "per_app_proxy_list" const val PER_APP_PROXY_MANAGED_MODE = "per_app_proxy_managed_mode" const val PER_APP_PROXY_MANAGED_LIST = "per_app_proxy_managed_list" + const val PER_APP_PROXY_PACKAGE_QUERY_MODE = "per_app_proxy_package_query_mode" const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 1b23881..ee1850c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -79,6 +79,10 @@ object Settings { var perAppProxyManagedMode by dataStore.boolean(SettingsKey.PER_APP_PROXY_MANAGED_MODE) { false } var perAppProxyManagedList by dataStore.stringSet(SettingsKey.PER_APP_PROXY_MANAGED_LIST) { emptySet() } + const val PACKAGE_QUERY_MODE_SHIZUKU = "SHIZUKU" + const val PACKAGE_QUERY_MODE_ROOT = "ROOT" + var perAppProxyPackageQueryMode by dataStore.string(SettingsKey.PER_APP_PROXY_PACKAGE_QUERY_MODE) { PACKAGE_QUERY_MODE_SHIZUKU } + fun getEffectivePerAppProxyList(): Set { return if (perAppProxyManagedMode) { perAppProxyList union perAppProxyManagedList diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt index 55c34a8..b03581c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt @@ -22,6 +22,7 @@ import io.nekohasekai.sfa.databinding.ViewVpnAppItemBinding import io.nekohasekai.sfa.ktx.dp2px import io.nekohasekai.sfa.ktx.toStringIterator import io.nekohasekai.sfa.ui.shared.AbstractActivity +import io.nekohasekai.sfa.vendor.PackageQueryManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -139,13 +140,7 @@ class VPNScanActivity : AbstractActivity() { @Suppress("DEPRECATION") PackageManager.GET_UNINSTALLED_PACKAGES } - val installedPackages = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(flag.toLong())) - } else { - @Suppress("DEPRECATION") - packageManager.getInstalledPackages(flag) - } + val installedPackages = PackageQueryManager.getInstalledPackages(flag) val vpnAppList = installedPackages.filter { it.services?.any { it.permission == Manifest.permission.BIND_VPN_SERVICE && it.applicationInfo != null } diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt index 8d13a8b..6519517 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt @@ -32,7 +32,8 @@ import io.nekohasekai.sfa.databinding.DialogProgressbarBinding import io.nekohasekai.sfa.databinding.ViewAppListItemBinding import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.ui.shared.AbstractActivity -import io.nekohasekai.sfa.vendor.Vendor +import io.nekohasekai.sfa.vendor.PackageQueryManager +import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -80,6 +81,8 @@ class PerAppProxyActivity : AbstractActivity() { val applicationLabel by lazy { appInfo.loadLabel(packageManager).toString() } + + val info: PackageInfo get() = packageInfo } private lateinit var adapter: ApplicationAdapter @@ -91,19 +94,6 @@ class PerAppProxyActivity : AbstractActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Check if Per-app Proxy is available - if (!Vendor.isPerAppProxyAvailable()) { - MaterialAlertDialogBuilder(this) - .setTitle("Unavailable") - .setMessage(getString(R.string.per_app_proxy_disabled_message)) - .setPositiveButton(R.string.ok) { _, _ -> - finish() - } - .setCancelable(false) - .show() - return - } - setTitle(R.string.per_app_proxy) ViewCompat.setOnApplyWindowInsetsListener(binding.appList) { view, windowInsets -> @@ -127,7 +117,9 @@ class PerAppProxyActivity : AbstractActivity() { binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_exclude_description) } } - reloadApplicationList() + if (!reloadApplicationList()) { + return@withContext + } filterApplicationList() withContext(Dispatchers.Main) { adapter = ApplicationAdapter(displayPackages) @@ -139,25 +131,31 @@ class PerAppProxyActivity : AbstractActivity() { } } - private fun reloadApplicationList() { + private suspend fun reloadApplicationList(): Boolean { val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES + PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS } else { @Suppress("DEPRECATION") - PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES + PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS } - val installedPackages = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getInstalledPackages( - PackageManager.PackageInfoFlags.of( - packageManagerFlags.toLong(), - ), - ) - } else { - @Suppress("DEPRECATION") - packageManager.getInstalledPackages(packageManagerFlags) + val installedPackages = try { + PackageQueryManager.getInstalledPackages(packageManagerFlags) + } catch (e: PrivilegedAccessRequiredException) { + withContext(Dispatchers.Main) { + Toast.makeText( + this@PerAppProxyActivity, + R.string.privileged_access_required, + Toast.LENGTH_LONG + ).show() + finish() } + return false + } val packages = mutableListOf() for (packageInfo in installedPackages) { if (packageInfo.packageName == packageName) continue @@ -173,6 +171,7 @@ class PerAppProxyActivity : AbstractActivity() { } this.packages = packages this.selectedUIDs = selectedUIDs + return true } private fun filterApplicationList(selectedUIDs: Set = this.selectedUIDs) { @@ -592,7 +591,7 @@ class PerAppProxyActivity : AbstractActivity() { val progressInt = AtomicInteger() currentPackages.map { it -> async { - if (scanChinaPackage(it.packageName)) { + if (scanChinaPackage(it.info)) { foundApps[it.packageName] = it } runOnUiThread { @@ -729,36 +728,17 @@ class PerAppProxyActivity : AbstractActivity() { ("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex() } - fun scanChinaPackage(packageName: String): Boolean { + fun scanChinaPackage(packageInfo: PackageInfo): Boolean { + val packageName = packageInfo.packageName skipPrefixList.forEach { if (packageName == it || packageName.startsWith("$it.")) return false } - val packageManagerFlags = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - } else { - @Suppress("DEPRECATION") - PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - } if (packageName.matches(chinaAppRegex)) { Log.d("PerAppProxyActivity", "Match package name: $packageName") return true } try { - val packageInfo = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Application.packageManager.getPackageInfo( - packageName, - PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong()), - ) - } else { - @Suppress("DEPRECATION") - Application.packageManager.getPackageInfo( - packageName, - packageManagerFlags, - ) - } val appInfo = packageInfo.applicationInfo ?: return false packageInfo.services?.forEach { if (it.name.matches(chinaAppRegex)) { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 22e977b..55c2f8a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -180,9 +180,16 @@ 在 Android 10 及更高版本中,需要<strong>后台位置</strong>权限。选择<strong>始终允许</strong>以授予权限。 通知权限 sing-box 无法在没有发送通知权限的情况下显示实时网速。请授予权限或禁用实时网速通知后再启动服务。 - Play 商店版本中不可用 - Google Play 拒绝允许我们使用 QUERY_ALL_PACKAGES 权限(同时不禁止其他类似应用这样做),而这是列出应用程序所必需的。 + 模式 + 通过 Play 商店安装时,分应用代理需要 Shizuku。Google Play 拒绝允许我们使用 QUERY_ALL_PACKAGES 权限(同时不禁止其他类似应用这样做),而这是列出应用程序所必需的。 + 通过 Play 商店安装时,分应用代理需要 ROOT。Google Play 拒绝允许我们使用 QUERY_ALL_PACKAGES 权限(同时不禁止其他类似应用这样做),而这是列出应用程序所必需的。 + 分应用代理(Shizuku 模式) + 启动 Shizuku + 授权 Shizuku + 获取 Shizuku 需要 Root 权限 + Root 权限被拒绝 + 需要 Root 或 Shizuku 权限来获取完整应用列表 连接 其他 实验性功能 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7295ff2..1ca8b5f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -154,6 +154,8 @@ Shizuku is not installed or not running Shizuku allows apps to use system APIs directly with higher privileges Get Shizuku + Start Shizuku + Request Shizuku ROOT and Shizuku are not available %s is not available or permission denied Auto Update @@ -247,9 +249,13 @@ On Android 10 and up, <strong>background location</strong> permission is required. Select <strong>Allow all the time</strong> to grant the permission. Notification permission sing-box is unable to show real-time network speeds without the permission to send notifications. Please grant the permission or disable real-time network speeds notification before starting the service. - Unavailable in the Play Store version - Google Play refuses to allow us to use the QUERY_ALL_PACKAGES permission (while not prohibiting other similar apps from doing so), which is required for listing apps. + Mode + When installed from the Play Store, per-app proxy requires Shizuku. Google Play refuses to allow us to use the QUERY_ALL_PACKAGES permission (while not prohibiting other similar apps from doing so), which is required for listing apps. + When installed from the Play Store, per-app proxy requires ROOT. Google Play refuses to allow us to use the QUERY_ALL_PACKAGES permission (while not prohibiting other similar apps from doing so), which is required for listing apps. + Per-App Proxy (Shizuku mode) Root access required + Root access denied + Root or Shizuku access required to get the complete app list Dashboard Items Reset order Reset diff --git a/app/src/minApi21/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt b/app/src/minApi21/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt new file mode 100644 index 0000000..e0ccd52 --- /dev/null +++ b/app/src/minApi21/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt @@ -0,0 +1,47 @@ +package io.nekohasekai.sfa.vendor + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import io.nekohasekai.sfa.Application +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object PackageQueryManager { + + val needsPrivilegedQuery: Boolean = false + + private val _queryMode = MutableStateFlow("") + val queryMode: StateFlow = _queryMode + + val shizukuInstalled: StateFlow = MutableStateFlow(false) + val shizukuBinderReady: StateFlow = MutableStateFlow(false) + val shizukuPermissionGranted: StateFlow = MutableStateFlow(false) + val rootAvailable: StateFlow = MutableStateFlow(null) + val rootServiceConnected: StateFlow = MutableStateFlow(false) + + fun isShizukuAvailable(): Boolean = false + + fun registerListeners() {} + + fun unregisterListeners() {} + + fun requestShizukuPermission() {} + + suspend fun checkRootAvailable(): Boolean = false + + fun setQueryMode(mode: String) { + _queryMode.value = mode + } + + suspend fun getInstalledPackages(flags: Int): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Application.packageManager.getInstalledPackages( + PackageManager.PackageInfoFlags.of(flags.toLong()) + ) + } else { + @Suppress("DEPRECATION") + Application.packageManager.getInstalledPackages(flags) + } + } +} diff --git a/app/src/minApi23/aidl/io/nekohasekai/sfa/vendor/IRootPackageManager.aidl b/app/src/minApi23/aidl/io/nekohasekai/sfa/vendor/IRootPackageManager.aidl new file mode 100644 index 0000000..ce43869 --- /dev/null +++ b/app/src/minApi23/aidl/io/nekohasekai/sfa/vendor/IRootPackageManager.aidl @@ -0,0 +1,7 @@ +package io.nekohasekai.sfa.vendor; + +import android.content.pm.PackageInfo; + +interface IRootPackageManager { + List getInstalledPackages(int flags); +} diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt new file mode 100644 index 0000000..b14b9fe --- /dev/null +++ b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt @@ -0,0 +1,101 @@ +package io.nekohasekai.sfa.vendor + +import android.Manifest +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class PrivilegedAccessRequiredException(message: String) : Exception(message) + +object PackageQueryManager { + + private const val TAG = "PackageQueryManager" + + val needsPrivilegedQuery: Boolean by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Check if QUERY_ALL_PACKAGES is declared in manifest + val packageInfo = Application.packageManager.getPackageInfo( + Application.application.packageName, + PackageManager.GET_PERMISSIONS + ) + val hasPermission = packageInfo.requestedPermissions?.contains( + Manifest.permission.QUERY_ALL_PACKAGES + ) == true + !hasPermission + } else { + false + } + } + + private val _queryMode = MutableStateFlow(Settings.perAppProxyPackageQueryMode) + val queryMode: StateFlow = _queryMode + + val shizukuInstalled: StateFlow get() = ShizukuPackageManager.shizukuInstalled + val shizukuBinderReady: StateFlow get() = ShizukuPackageManager.binderReady + val shizukuPermissionGranted: StateFlow get() = ShizukuPackageManager.permissionGranted + val rootAvailable: StateFlow get() = RootPackageManager.rootAvailable + val rootServiceConnected: StateFlow get() = RootPackageManager.serviceConnected + + fun isShizukuAvailable(): Boolean = + ShizukuPackageManager.isAvailable() && ShizukuPackageManager.checkPermission() + + fun registerListeners() { + ShizukuPackageManager.registerListeners() + _queryMode.value = Settings.perAppProxyPackageQueryMode + } + + fun unregisterListeners() { + ShizukuPackageManager.unregisterListeners() + } + + fun requestShizukuPermission() { + ShizukuPackageManager.requestPermission() + } + + suspend fun checkRootAvailable(): Boolean { + return RootPackageManager.checkRootAvailable() + } + + fun setQueryMode(mode: String) { + _queryMode.value = mode + } + + suspend fun getInstalledPackages(flags: Int): List { + if (!needsPrivilegedQuery) { + return getPackagesViaPackageManager(flags) + } + + val mode = _queryMode.value + + if (mode == Settings.PACKAGE_QUERY_MODE_ROOT) { + if (rootAvailable.value != true) { + val isAvailable = RootPackageManager.checkRootAvailable() + if (!isAvailable) { + throw PrivilegedAccessRequiredException("ROOT access required") + } + } + return RootPackageManager.getInstalledPackages(flags) + } + + if (!isShizukuAvailable()) { + throw PrivilegedAccessRequiredException("Shizuku access required") + } + return ShizukuPackageManager.getInstalledPackages(flags) + } + + private fun getPackagesViaPackageManager(flags: Int): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Application.packageManager.getInstalledPackages( + PackageManager.PackageInfoFlags.of(flags.toLong()) + ) + } else { + @Suppress("DEPRECATION") + Application.packageManager.getInstalledPackages(flags) + } + } +} diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManager.kt b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManager.kt new file mode 100644 index 0000000..6079065 --- /dev/null +++ b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManager.kt @@ -0,0 +1,98 @@ +package io.nekohasekai.sfa.vendor + +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageInfo +import android.os.IBinder +import com.topjohnwu.superuser.Shell +import com.topjohnwu.superuser.ipc.RootService +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +object RootPackageManager { + + init { + Shell.enableVerboseLogging = BuildConfig.DEBUG + Shell.setDefaultBuilder( + Shell.Builder.create() + .setFlags(Shell.FLAG_MOUNT_MASTER) + .setTimeout(10) + ) + } + + private val _rootAvailable = MutableStateFlow(null) + val rootAvailable: StateFlow = _rootAvailable + + private val _serviceConnected = MutableStateFlow(false) + val serviceConnected: StateFlow = _serviceConnected + + private var service: IRootPackageManager? = null + private var connection: ServiceConnection? = null + private val connectionMutex = Mutex() + + suspend fun checkRootAvailable(): Boolean { + Shell.getCachedShell()?.close() + return suspendCancellableCoroutine { continuation -> + Shell.getShell { shell -> + val available = shell.isRoot + _rootAvailable.value = available + continuation.resume(available) + } + } + } + + suspend fun bindService(): IRootPackageManager = connectionMutex.withLock { + service?.let { return it } + + return withContext(Dispatchers.Main) { + suspendCancellableCoroutine { continuation -> + val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + val svc = IRootPackageManager.Stub.asInterface(binder) + service = svc + connection = this + _serviceConnected.value = true + continuation.resume(svc) + } + + override fun onServiceDisconnected(name: ComponentName?) { + service = null + connection = null + _serviceConnected.value = false + } + } + + val intent = Intent(Application.application, RootPackageManagerService::class.java) + RootService.bind(intent, conn) + + continuation.invokeOnCancellation { + RootService.unbind(conn) + } + } + } + } + + fun unbindService() { + connection?.let { + RootService.unbind(it) + connection = null + service = null + _serviceConnected.value = false + } + } + + suspend fun getInstalledPackages(flags: Int): List { + val svc = bindService() + return svc.getInstalledPackages(flags) + } +} diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManagerService.kt b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManagerService.kt new file mode 100644 index 0000000..e93b911 --- /dev/null +++ b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManagerService.kt @@ -0,0 +1,28 @@ +package io.nekohasekai.sfa.vendor + +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.IBinder +import com.topjohnwu.superuser.ipc.RootService + +class RootPackageManagerService : RootService() { + + private val binder = object : IRootPackageManager.Stub() { + override fun getInstalledPackages(flags: Int): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages( + PackageManager.PackageInfoFlags.of(flags.toLong()) + ) + } else { + @Suppress("DEPRECATION") + packageManager.getInstalledPackages(flags) + } + } + } + + override fun onBind(intent: Intent): IBinder { + return binder + } +} diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPackageManager.kt b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPackageManager.kt new file mode 100644 index 0000000..6afffe2 --- /dev/null +++ b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPackageManager.kt @@ -0,0 +1,114 @@ +package io.nekohasekai.sfa.vendor + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.IBinder +import io.nekohasekai.sfa.Application +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.lsposed.hiddenapibypass.HiddenApiBypass +import rikka.shizuku.Shizuku +import rikka.shizuku.ShizukuBinderWrapper +import rikka.shizuku.SystemServiceHelper + +object ShizukuPackageManager { + + private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" + + private val _shizukuInstalled = MutableStateFlow(false) + val shizukuInstalled: StateFlow = _shizukuInstalled + + private val _binderReady = MutableStateFlow(false) + val binderReady: StateFlow = _binderReady + + private val _permissionGranted = MutableStateFlow(false) + val permissionGranted: StateFlow = _permissionGranted + + private val binderReceivedListener = Shizuku.OnBinderReceivedListener { + _binderReady.value = true + _permissionGranted.value = checkPermission() + } + + private val binderDeadListener = Shizuku.OnBinderDeadListener { + _binderReady.value = false + _permissionGranted.value = false + } + + private val permissionResultListener = Shizuku.OnRequestPermissionResultListener { _, grantResult -> + _permissionGranted.value = grantResult == PackageManager.PERMISSION_GRANTED + } + + fun registerListeners() { + Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) + Shizuku.addBinderDeadListener(binderDeadListener) + Shizuku.addRequestPermissionResultListener(permissionResultListener) + _shizukuInstalled.value = isShizukuInstalled() + _binderReady.value = isAvailable() + _permissionGranted.value = checkPermission() + } + + fun isShizukuInstalled(): Boolean { + return try { + Application.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } + } + + fun unregisterListeners() { + Shizuku.removeBinderReceivedListener(binderReceivedListener) + Shizuku.removeBinderDeadListener(binderDeadListener) + Shizuku.removeRequestPermissionResultListener(permissionResultListener) + } + + fun isAvailable(): Boolean = ShizukuInstaller.isAvailable() + + fun checkPermission(): Boolean = ShizukuInstaller.checkPermission() + + fun requestPermission() = ShizukuInstaller.requestPermission() + + fun getInstalledPackages(flags: Int): List { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + HiddenApiBypass.addHiddenApiExemptions("") + } + + val packageManagerBinder = SystemServiceHelper.getSystemService("package") + val wrappedBinder = ShizukuBinderWrapper(packageManagerBinder) + + val iPackageManagerClass = Class.forName("android.content.pm.IPackageManager") + val stubClass = Class.forName("android.content.pm.IPackageManager\$Stub") + val asInterfaceMethod = stubClass.getMethod("asInterface", IBinder::class.java) + val iPackageManager = asInterfaceMethod.invoke(null, wrappedBinder) + + val userId = android.os.Process.myUserHandle().hashCode() + + val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val method = iPackageManagerClass.getMethod( + "getInstalledPackages", + Long::class.javaPrimitiveType, + Int::class.javaPrimitiveType + ) + method.invoke(iPackageManager, flags.toLong(), userId) + } else { + val method = iPackageManagerClass.getMethod( + "getInstalledPackages", + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType + ) + method.invoke(iPackageManager, flags, userId) + } + + return extractPackageList(result) + } + + @Suppress("UNCHECKED_CAST") + private fun extractPackageList(parceledListSlice: Any?): List { + if (parceledListSlice == null) return emptyList() + + val getListMethod = parceledListSlice.javaClass.getMethod("getList") + val list = getListMethod.invoke(parceledListSlice) as? List<*> + return list?.filterIsInstance() ?: emptyList() + } +} diff --git a/app/src/minApi23/AndroidManifest.xml b/app/src/other/AndroidManifest.xml similarity index 63% rename from app/src/minApi23/AndroidManifest.xml rename to app/src/other/AndroidManifest.xml index 89cd700..ee4ba4a 100644 --- a/app/src/minApi23/AndroidManifest.xml +++ b/app/src/other/AndroidManifest.xml @@ -1,10 +1,5 @@ - - - - - + + + diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt index 68e340c..0525a7d 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -95,10 +95,6 @@ object Vendor : VendorInterface { return null } - override fun isPerAppProxyAvailable(): Boolean { - return true - } - override fun supportsTrackSelection(): Boolean { return true } diff --git a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt index 196bf99..6d4706f 100644 --- a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -95,10 +95,6 @@ object Vendor : VendorInterface { return null } - override fun isPerAppProxyAvailable(): Boolean { - return true - } - override fun supportsTrackSelection(): Boolean { return true } diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml index df4efac..763aa6f 100644 --- a/app/src/play/AndroidManifest.xml +++ b/app/src/play/AndroidManifest.xml @@ -7,4 +7,17 @@ android:name="android.permission.QUERY_ALL_PACKAGES" tools:node="remove" /> - \ No newline at end of file + + + + + + + diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt index 713fd46..6def77d 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -93,19 +93,11 @@ object Vendor : VendorInterface { } } - override fun isPerAppProxyAvailable(): Boolean { - // Per-app Proxy is disabled for Play Store builds due to QUERY_ALL_PACKAGES permission restriction - return false - } - override fun supportsTrackSelection(): Boolean { - // Play Store doesn't support track selection return false } override fun checkUpdateAsync(): UpdateInfo? { - // Play Store updates are handled by the Play Core library - // We can't get version info in the same way as GitHub return null } }