├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── gradle.xml ├── misc.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── fivecc │ │ └── tools │ │ └── shortcut_helper │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── aidl │ │ └── fivecc │ │ │ └── tools │ │ │ └── shortcut_helper │ │ │ └── IRootHelper.aidl │ ├── ic_launcher-playstore.png │ ├── java │ │ └── fivecc │ │ │ └── tools │ │ │ └── shortcut_helper │ │ │ ├── App.kt │ │ │ ├── AppInfoCache.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainActivityViewModel.kt │ │ │ ├── RootHelperService.kt │ │ │ ├── Settings.kt │ │ │ ├── coil │ │ │ ├── ShortcutIconFetcher.kt │ │ │ └── ShortcutInfoKeyer.kt │ │ │ ├── ui │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ └── utils │ │ │ ├── PrefUtils.kt │ │ │ ├── ShortcutInfoHelper.kt │ │ │ ├── ShortcutParser.java │ │ │ └── XmlUtils.java │ └── res │ │ ├── drawable │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── fivecc │ └── tools │ └── shortcut_helper │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── hidden-api ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ ├── android │ ├── content │ │ └── pm │ │ │ ├── BaseParceledListSlice.java │ │ │ ├── ILauncherApps.java │ │ │ ├── IPackageManager.java │ │ │ ├── IShortcutService.java │ │ │ ├── IShortcutServiceForS.java │ │ │ ├── PackageManagerHidden.java │ │ │ ├── ParceledListSlice.java │ │ │ └── ShortcutInfoHidden.java │ ├── ddm │ │ └── DdmHandleAppName.java │ ├── os │ │ ├── PersistableBundleHidden.java │ │ ├── ServiceManager.java │ │ └── UserHandleHidden.java │ └── util │ │ ├── TypedXmlPullParser.java │ │ └── XmlHidden.java │ └── com │ └── android │ └── internal │ └── infra │ └── AndroidFuture.java ├── img └── 1.png └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | keystore.properties 17 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Shortcut Helper 2 | 3 | Android 快捷方式助手。 4 | 5 | ## 功能 6 | 7 | 获取系统中注册的所有应用的 shortcuts (快捷方式) ,以及它们的 intent —— 包括桌面快捷方式、长按桌面应用图标的快捷方式等。 8 | 9 | > 具体可参见[应用快捷方式概览](https://developer.android.com/guide/topics/ui/shortcuts?hl=zh-cn) 10 | 11 | 你可以直接获得快捷方式的 intent URI (包括所有的 extras)。 12 | 13 | > 快捷方式的 extras 被限制为基本类型(`PersistenceBundle` 支持的类型) 14 | 15 | ![](img/1.png) 16 | 17 | 弹出的 Dialog 中可以点击复制 uri 或点击下面的 launch 启动这个 intent 。 18 | 应用也可以发起 `Intent.CREATE_SHORTCUT` 的 Intent 来选择一个快捷方式 19 | (如 [Anywhere](https://github.com/zhaobozhen/Anywhere-) 或 [FV 悬浮球](https://play.google.com/store/apps/details?id=com.fooview.android.fooview&hl=zh&gl=US))。 20 | 21 | ## 原理 22 | 23 | 1. ShortcutInfo 的获取 24 | 25 | 由于系统 API 的限制,通过 `LauncherApps` 或者 `ShortcutService` 获取的 shortcuts 都会被剔除部分信息,导致无法获得 intent 。 26 | 因此目前通过解析目录 `/data/system_ce/0/shortcut_services` 下的 xml 获取 ShortcutInfo 。 27 | 28 | 2. 图标的获取 29 | 30 | 参考了 `LauncherApps` 的实现。 31 | 32 | ## 注意 33 | 34 | 1. 目前需要 root 权限(存在 shell 权限的方案,即解析 `dumpsys shortcut` )。 35 | 2. 需要特殊权限的 Intent 暂时无法在 App 内启动。 36 | 3. 目前仅支持解析主用户的 shortcut 。 37 | 4. 有概率出现获取不完全的情况,此时可以尝试刷新。 38 | 5. 目前 `CREATE_SHORTCUT` 返回的快捷方式不含图标。 39 | 6. ~~UI 真的很烂,期待 UI 大师指点~~ 40 | 41 | ## TODO 42 | 43 | 1. 支持 shell 权限(计划使用 Shizuku API) 44 | 2. 更好的 UI 45 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.io.FileInputStream 2 | import java.util.* 3 | 4 | plugins { 5 | id("com.android.application") 6 | id("org.jetbrains.kotlin.android") 7 | id("dev.rikka.tools.refine") 8 | kotlin("kapt") version "1.7.10" 9 | } 10 | 11 | val composeVersion: String by project 12 | 13 | val keystorePropertiesFile = rootProject.file("keystore.properties") 14 | val keystoreProperties = if (keystorePropertiesFile.exists() && keystorePropertiesFile.isFile) { 15 | Properties().apply { 16 | load(FileInputStream(keystorePropertiesFile)) 17 | } 18 | } else null 19 | 20 | android { 21 | if (keystoreProperties != null) { 22 | signingConfigs { 23 | create("release") { 24 | keyAlias = keystoreProperties["keyAlias"] as String 25 | keyPassword = keystoreProperties["keyPassword"] as String 26 | storeFile = file(keystoreProperties["storeFile"] as String) 27 | storePassword = keystoreProperties["storePassword"] as String 28 | } 29 | } 30 | } 31 | 32 | compileSdk = 33 33 | 34 | defaultConfig { 35 | applicationId = "fivecc.tools.shortcut_helper" 36 | minSdk = 26 37 | targetSdk = 33 38 | versionCode = 1 39 | versionName = "1.0" 40 | 41 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 42 | vectorDrawables { 43 | useSupportLibrary = true 44 | } 45 | } 46 | 47 | buildTypes { 48 | debug { 49 | applicationIdSuffix = ".debug" 50 | } 51 | release { 52 | isMinifyEnabled = true 53 | isShrinkResources = true 54 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 55 | if (keystoreProperties != null) { 56 | signingConfig = signingConfigs["release"] 57 | } 58 | } 59 | } 60 | compileOptions { 61 | sourceCompatibility = JavaVersion.VERSION_11 62 | targetCompatibility = JavaVersion.VERSION_11 63 | } 64 | kotlinOptions { 65 | jvmTarget = "11" 66 | } 67 | buildFeatures { 68 | compose = true 69 | } 70 | composeOptions { 71 | kotlinCompilerExtensionVersion = "1.3.1" 72 | } 73 | packagingOptions { 74 | resources { 75 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 76 | } 77 | } 78 | } 79 | 80 | dependencies { 81 | compileOnly(project(":hidden-api")) 82 | implementation("org.lsposed.hiddenapibypass:hiddenapibypass:4.3") 83 | 84 | implementation("androidx.core:core-ktx:1.9.0") 85 | implementation("androidx.compose.ui:ui:$composeVersion") 86 | implementation("androidx.compose.material3:material3:1.0.0-beta02") 87 | implementation("androidx.compose.runtime:runtime-livedata:$composeVersion") 88 | implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion") 89 | implementation("androidx.activity:activity-compose:1.5.1") 90 | implementation("androidx.datastore:datastore-preferences:1.0.0") 91 | 92 | val lifecycleVersion = "2.6.0-alpha01" 93 | 94 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion") 95 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion") 96 | implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion") 97 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion") 98 | implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion") 99 | kapt("androidx.lifecycle:lifecycle-compiler:$lifecycleVersion") 100 | 101 | val libsuVersion = "5.0.2" 102 | implementation("com.github.topjohnwu.libsu:core:$libsuVersion") 103 | implementation("com.github.topjohnwu.libsu:service:$libsuVersion") 104 | implementation("com.github.topjohnwu.libsu:nio:$libsuVersion") 105 | 106 | val coilVersion = "2.2.1" 107 | implementation("io.coil-kt:coil:$coilVersion") 108 | implementation("io.coil-kt:coil-compose:$coilVersion") 109 | implementation("io.coil-kt:coil-svg:$coilVersion") 110 | 111 | implementation("com.google.accompanist:accompanist-swiperefresh:0.26.2-beta") 112 | 113 | testImplementation("junit:junit:4.13.2") 114 | androidTestImplementation("androidx.test.ext:junit:1.1.3") 115 | androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") 116 | androidTestImplementation("androidx.compose.ui:ui-test-junit4:$composeVersion") 117 | debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion") 118 | debugImplementation("androidx.compose.ui:ui-test-manifest:$composeVersion") 119 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/fivecc/tools/shortcut_helper/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("fivecc.tools.shortcut_helper", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 8 | 9 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/aidl/fivecc/tools/shortcut_helper/IRootHelper.aidl: -------------------------------------------------------------------------------- 1 | // IRootHelper.aidl 2 | package fivecc.tools.shortcut_helper; 3 | 4 | import android.content.pm.ShortcutInfo; 5 | import android.os.ParcelFileDescriptor; 6 | 7 | // Declare any non-default types here with import statements 8 | 9 | interface IRootHelper { 10 | List getShortcuts(String method, int user, int flags); 11 | ParcelFileDescriptor getShortcutIconFd(String packageName, String id, int userId); 12 | void startShortcut(in ShortcutInfo shortcutInfo); 13 | } -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5ec1cff/AndroidShortcutHelper/a9a3e9babe33805a447aee36985b24cc7b62cd55/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/App.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.os.Build 6 | import coil.ImageLoader 7 | import coil.ImageLoaderFactory 8 | import coil.decode.SvgDecoder 9 | import coil.disk.DiskCache 10 | import coil.memory.MemoryCache 11 | import fivecc.tools.shortcut_helper.coil.ShortcutIconFetcher 12 | import fivecc.tools.shortcut_helper.coil.ShortcutInfoKeyer 13 | import org.lsposed.hiddenapibypass.HiddenApiBypass 14 | 15 | class App : Application(), ImageLoaderFactory { 16 | companion object { 17 | lateinit var instance: Application 18 | } 19 | override fun attachBaseContext(base: Context?) { 20 | super.attachBaseContext(base) 21 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 22 | HiddenApiBypass.addHiddenApiExemptions("") 23 | } 24 | instance = this 25 | } 26 | 27 | override fun newImageLoader(): ImageLoader { 28 | return ImageLoader.Builder(this) 29 | .components { 30 | add(ShortcutIconFetcher.Factory()) 31 | add(SvgDecoder.Factory()) 32 | add(ShortcutInfoKeyer()) 33 | }.build() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/AppInfoCache.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper 2 | 3 | import android.content.pm.ApplicationInfo 4 | import java.util.concurrent.ConcurrentHashMap 5 | 6 | object AppInfoCache { 7 | private val appInfoCache = ConcurrentHashMap() 8 | private val appLabelCache = ConcurrentHashMap() 9 | 10 | fun getAppInfo(packageName: String): ApplicationInfo { 11 | appInfoCache[packageName]?.let { return it } 12 | val pm = App.instance.packageManager 13 | return pm.getApplicationInfo(packageName, 0).also { appInfoCache[packageName] = it } 14 | } 15 | 16 | fun getAppLabel(packageName: String): String { 17 | appLabelCache[packageName]?.let { return it } 18 | val pm = App.instance.packageManager 19 | val appInfo = getAppInfo(packageName) 20 | return appInfo.loadLabel(pm).toString().also { appLabelCache[packageName] = it } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/MainActivity.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package fivecc.tools.shortcut_helper 4 | 5 | import android.app.Activity 6 | import android.content.Intent 7 | import android.content.pm.ShortcutInfo 8 | import android.os.Bundle 9 | import androidx.activity.ComponentActivity 10 | import androidx.activity.compose.setContent 11 | import androidx.compose.foundation.clickable 12 | import androidx.compose.foundation.layout.* 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.foundation.lazy.items 15 | import androidx.compose.foundation.rememberScrollState 16 | import androidx.compose.foundation.shape.RoundedCornerShape 17 | import androidx.compose.foundation.verticalScroll 18 | import androidx.compose.material.icons.Icons 19 | import androidx.compose.material.icons.filled.Settings 20 | import androidx.compose.material3.* 21 | import androidx.compose.runtime.* 22 | import androidx.compose.runtime.livedata.observeAsState 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.draw.shadow 26 | import androidx.compose.ui.platform.LocalClipboardManager 27 | import androidx.compose.ui.platform.LocalContext 28 | import androidx.compose.ui.text.AnnotatedString 29 | import androidx.compose.ui.text.font.FontWeight 30 | import androidx.compose.ui.text.style.TextAlign 31 | import androidx.compose.ui.unit.dp 32 | import androidx.compose.ui.window.Dialog 33 | import androidx.lifecycle.viewmodel.compose.viewModel 34 | import coil.compose.AsyncImage 35 | import com.google.accompanist.swiperefresh.SwipeRefresh 36 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 37 | import fivecc.tools.shortcut_helper.ui.theme.ShortcutTheme 38 | import fivecc.tools.shortcut_helper.utils.getLabel 39 | import kotlinx.coroutines.launch 40 | 41 | class MainActivity : ComponentActivity() { 42 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) 43 | override fun onCreate(savedInstanceState: Bundle?) { 44 | super.onCreate(savedInstanceState) 45 | RootHelperService.start() 46 | val selectButtonText: String 47 | val action = intent.action 48 | setContent { 49 | ShortcutTheme { 50 | Surface( 51 | modifier = Modifier.fillMaxSize(), 52 | color = MaterialTheme.colorScheme.background 53 | ) { 54 | var settingsDialogShown by remember { mutableStateOf(false) } 55 | val snackBarHostState = remember { SnackbarHostState() } 56 | Scaffold( 57 | snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, 58 | topBar = { 59 | TopAppBar(title = { Text("Shortcuts") }, 60 | actions = { 61 | IconButton(onClick = { settingsDialogShown = true }) { 62 | Icon(Icons.Filled.Settings, contentDescription = "settings") 63 | } 64 | }, 65 | modifier = Modifier.shadow(8.dp)) 66 | }, 67 | content = { 68 | Box(modifier = Modifier 69 | .fillMaxSize() 70 | .padding(it) 71 | .consumedWindowInsets(it), 72 | contentAlignment = Alignment.Center 73 | ) { 74 | val state by RootHelperService.serviceState.observeAsState() 75 | when (state) { 76 | ServiceState.RUNNING -> { 77 | ShortcutScreen(snackBarHostState = snackBarHostState, action = action) 78 | } 79 | ServiceState.STARTING -> { 80 | TipScreen("service is starting") 81 | } 82 | ServiceState.STOPPED -> { 83 | TipScreen("service is unavailable (root permission required)") 84 | } 85 | else -> { 86 | TipScreen("WTF?") 87 | } 88 | } 89 | } 90 | if (settingsDialogShown) { 91 | BaseDialog(onDismiss = { settingsDialogShown = false }) { 92 | Box(modifier = Modifier 93 | .defaultMinSize(minHeight = 200.dp) 94 | .padding(16.dp) 95 | ) { 96 | MyListPreference(listPreference = Settings.WORK_MODE) 97 | } 98 | } 99 | } 100 | } 101 | ) 102 | } 103 | } 104 | } 105 | } 106 | } 107 | 108 | @Composable 109 | fun TipScreen(text: String) { 110 | Text(text = text, textAlign = TextAlign.Center, modifier = Modifier.wrapContentHeight()) 111 | } 112 | 113 | @Composable 114 | fun ShortcutScreen( 115 | snackBarHostState: SnackbarHostState, 116 | action: String? 117 | ) { 118 | val viewModel: MainActivityViewModel = viewModel() 119 | val isRefreshing by viewModel.isRefreshing.collectAsState() 120 | val shortcuts = viewModel.shortcutList 121 | var dialogShowing by remember { mutableStateOf(null) } 122 | val context = LocalContext.current 123 | val settings = Settings(context) 124 | val workMode by settings.getValue(Settings.WORK_MODE.preferencesKey).collectAsState(initial = null) 125 | val scope = rememberCoroutineScope() 126 | val selectButtonText = if (action == Intent.ACTION_CREATE_SHORTCUT) "select" else "launch" 127 | if (workMode == null) { 128 | Box( 129 | modifier = Modifier.fillMaxSize(), 130 | contentAlignment = Alignment.Center 131 | ) { 132 | TipScreen(text = "Please configure work mode first") 133 | } 134 | } else { 135 | SwipeRefresh(state = rememberSwipeRefreshState(isRefreshing), 136 | onRefresh = { 137 | viewModel.loadShortcuts(workMode!!, onError = { 138 | scope.launch { 139 | snackBarHostState.showSnackbar("Error: ${it.message}") 140 | } 141 | }) 142 | } 143 | ) { 144 | if (shortcuts.isEmpty()) { 145 | Box( 146 | modifier = Modifier 147 | .fillMaxSize() 148 | .verticalScroll(rememberScrollState()), 149 | contentAlignment = Alignment.Center 150 | ) { 151 | TipScreen(text = "Nothing to show, try refresh") 152 | } 153 | } else { 154 | LazyColumn( 155 | modifier = Modifier 156 | .fillMaxWidth() 157 | .fillMaxHeight() 158 | ) { 159 | items(shortcuts) { s -> 160 | ShortcutCard(shortcut = s, onClick = { dialogShowing = it }) 161 | } 162 | } 163 | } 164 | } 165 | } 166 | dialogShowing?.let { s -> 167 | ShortcutDialog(shortcut = s, 168 | onDismiss = { dialogShowing = null }, 169 | onSelected = { 170 | dialogShowing = null 171 | if (action == Intent.ACTION_CREATE_SHORTCUT) { 172 | if (context is Activity) { 173 | context.setResult( 174 | ComponentActivity.RESULT_OK, Intent() 175 | .putExtra(Intent.EXTRA_SHORTCUT_INTENT, it.intent) 176 | .putExtra(Intent.EXTRA_SHORTCUT_NAME, it.getLabel()) 177 | ) 178 | context.finish() 179 | } 180 | } else { 181 | runCatching { 182 | s.intent?.also { intent -> 183 | val launchIntent = Intent(intent) 184 | launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 185 | context.startActivity(launchIntent) 186 | } 187 | }.onFailure { t -> 188 | scope.launch { 189 | snackBarHostState.showSnackbar("Error: ${t.message}") 190 | } 191 | } 192 | } 193 | }, 194 | selectButtonText = selectButtonText 195 | ) 196 | } 197 | } 198 | 199 | @Composable 200 | fun ShortcutCard( 201 | shortcut: ShortcutInfo, 202 | onClick: ((ShortcutInfo) -> Unit)? = null 203 | ) { 204 | Row(modifier = Modifier 205 | .clickable(onClick = { onClick?.invoke(shortcut) }) 206 | .padding(all = 8.dp) 207 | .fillMaxWidth()) { 208 | AsyncImage( 209 | model = shortcut, 210 | contentDescription = "icon", 211 | modifier = Modifier.size(40.dp) 212 | ) 213 | Spacer(modifier = Modifier.width(8.dp)) 214 | Column { 215 | Text(text = shortcut.getLabel(), maxLines = 1, fontWeight = FontWeight.Bold) 216 | Spacer(modifier = Modifier.height(4.dp)) 217 | Text(text = AppInfoCache.getAppLabel(shortcut.`package`)) 218 | } 219 | } 220 | } 221 | 222 | @Composable 223 | fun BaseDialog( 224 | onDismiss: () -> Unit, 225 | content: @Composable () -> Unit 226 | ) { 227 | Dialog(onDismissRequest = { onDismiss() }) { 228 | Card(modifier = Modifier.shadow(8.dp), shape = RoundedCornerShape(12.dp)) { 229 | content() 230 | } 231 | } 232 | } 233 | 234 | @Composable 235 | fun ShortcutDialog( 236 | shortcut: ShortcutInfo, 237 | onDismiss: () -> Unit, 238 | selectButtonText: String? = null, 239 | onSelected: ((ShortcutInfo) -> Unit)? = null 240 | ) { 241 | Dialog(onDismissRequest = { onDismiss() }) { 242 | Card(modifier = Modifier.shadow(8.dp), shape = RoundedCornerShape(12.dp)) { 243 | Column(modifier = Modifier.padding(16.dp)) { 244 | Row(modifier = Modifier.fillMaxWidth()) { 245 | AsyncImage( 246 | model = shortcut, 247 | contentDescription = "icon", 248 | modifier = Modifier.size(50.dp) 249 | ) 250 | Spacer(modifier = Modifier.width(8.dp)) 251 | Column { 252 | Text( 253 | text = shortcut.shortLabel.toString(), 254 | maxLines = 1, 255 | fontWeight = FontWeight.Bold 256 | ) 257 | Spacer(modifier = Modifier.height(4.dp)) 258 | Text(text = AppInfoCache.getAppLabel(shortcut.`package`)) 259 | } 260 | } 261 | Spacer(modifier = Modifier.height(4.dp)) 262 | MyTextField(title = "Package Name", content = shortcut.`package`) 263 | MyTextField(title = "ID", content = shortcut.id) 264 | MyTextField(title = "Uri", content = "${shortcut.intent?.toUri(0)}") 265 | if (onSelected != null && selectButtonText != null) { 266 | Box(contentAlignment = Alignment.BottomEnd, modifier = Modifier.fillMaxWidth()) { 267 | Button( 268 | onClick = { onSelected.invoke(shortcut) }, 269 | shape = RoundedCornerShape(8.dp), 270 | ) { 271 | Text(text = selectButtonText) 272 | } 273 | } 274 | } 275 | } 276 | } 277 | } 278 | } 279 | 280 | @OptIn(ExperimentalMaterial3Api::class) 281 | @Composable 282 | fun MyTextField(title: String, content: String) { 283 | val clipboard = LocalClipboardManager.current 284 | OutlinedTextField(value = content, onValueChange = {}, enabled = false, 285 | label = { Text(text = title) }, modifier = Modifier 286 | .fillMaxWidth() 287 | .clickable { 288 | clipboard.setText(AnnotatedString(content)) 289 | }) 290 | } 291 | -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/MainActivityViewModel.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper 2 | 3 | import android.content.pm.ShortcutInfo 4 | import android.os.UserHandleHidden 5 | import androidx.compose.runtime.mutableStateListOf 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import fivecc.tools.shortcut_helper.utils.MATCH_ALL 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.StateFlow 12 | import kotlinx.coroutines.flow.asStateFlow 13 | import kotlinx.coroutines.launch 14 | import kotlinx.coroutines.withContext 15 | 16 | class MainActivityViewModel : ViewModel() { 17 | private val _isRefreshing = MutableStateFlow(false) 18 | 19 | val isRefreshing: StateFlow 20 | get() = _isRefreshing.asStateFlow() 21 | 22 | val shortcutList = mutableStateListOf() 23 | 24 | fun loadShortcuts( 25 | method: String, 26 | onError: ((t: Throwable) -> Unit)? = null 27 | ) { 28 | viewModelScope.launch { 29 | _isRefreshing.emit(true) 30 | val newList = kotlin.runCatching { 31 | withContext(Dispatchers.IO) { 32 | RootHelperService.helper?.getShortcuts( 33 | method, 34 | UserHandleHidden.myUserId(), 35 | MATCH_ALL 36 | ) 37 | } 38 | }.onFailure { onError?.invoke(it) }.getOrNull() 39 | shortcutList.clear() 40 | newList?.also { shortcutList.addAll(it) } 41 | _isRefreshing.emit(false) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/RootHelperService.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION", "CAST_NEVER_SUCCEEDS") 2 | 3 | package fivecc.tools.shortcut_helper 4 | 5 | import android.content.ComponentName 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.content.ServiceConnection 9 | import android.content.pm.ILauncherApps 10 | import android.content.pm.IShortcutService 11 | import android.content.pm.PackageManagerHidden 12 | import android.content.pm.ShortcutInfo 13 | import android.os.Build 14 | import android.os.IBinder 15 | import android.os.ParcelFileDescriptor 16 | import android.os.ServiceManager 17 | import android.system.Os 18 | import androidx.annotation.MainThread 19 | import androidx.lifecycle.MutableLiveData 20 | import com.topjohnwu.superuser.ipc.RootService 21 | import fivecc.tools.shortcut_helper.utils.ShortcutParser 22 | import fivecc.tools.shortcut_helper.utils.getShortcutInfoCompat 23 | import fivecc.tools.shortcut_helper.utils.getUserId 24 | 25 | enum class ServiceState { 26 | STOPPED, 27 | STARTING, 28 | RUNNING 29 | } 30 | 31 | @Suppress("Unchecked_Cast") 32 | class RootHelperService : RootService() { 33 | companion object { 34 | const val METHOD_PARSE_FILE = "parse_file" 35 | const val METHOD_SYSTEM_API = "system_api" 36 | val serviceState = MutableLiveData(ServiceState.STOPPED) 37 | var helper: IRootHelper? = null 38 | private set 39 | private val mConnection = object : ServiceConnection, IBinder.DeathRecipient { 40 | override fun onServiceConnected(p0: ComponentName?, binder: IBinder) { 41 | binder.linkToDeath(this, 0) 42 | helper = IRootHelper.Stub.asInterface(binder) 43 | serviceState.value = ServiceState.RUNNING 44 | } 45 | 46 | override fun onServiceDisconnected(p0: ComponentName?) { 47 | helper = null 48 | serviceState.value = ServiceState.STOPPED 49 | } 50 | 51 | override fun binderDied() { 52 | helper = null 53 | serviceState.postValue(ServiceState.STOPPED) 54 | } 55 | } 56 | 57 | @MainThread 58 | fun start() { 59 | if (serviceState.value == ServiceState.STOPPED) { 60 | bind( 61 | Intent().setComponent( 62 | ComponentName( 63 | BuildConfig.APPLICATION_ID, 64 | RootHelperService::class.java.name 65 | ) 66 | ), 67 | mConnection 68 | ) 69 | serviceState.value = ServiceState.STARTING 70 | } 71 | } 72 | } 73 | 74 | private val shortcutService by lazy { 75 | IShortcutService.Stub.asInterface(ServiceManager.getService(Context.SHORTCUT_SERVICE)) 76 | } 77 | 78 | private val launcherAppsService by lazy { 79 | ILauncherApps.Stub.asInterface(ServiceManager.getService(Context.LAUNCHER_APPS_SERVICE)) 80 | } 81 | 82 | private val mHelper = object : IRootHelper.Stub() { 83 | override fun getShortcuts(method: String, user: Int, flags: Int): MutableList? { 84 | return when (method) { 85 | METHOD_PARSE_FILE -> { 86 | ShortcutParser.loadUserLocked(user) 87 | } 88 | METHOD_SYSTEM_API -> { 89 | val result = mutableListOf() 90 | (packageManager as PackageManagerHidden).getInstalledPackagesAsUser(0, user).forEach { 91 | result.addAll(shortcutService.getShortcutInfoCompat(it.packageName, user, flags)) 92 | } 93 | result 94 | } 95 | else -> { 96 | throw IllegalArgumentException("unknown method $method") 97 | } 98 | } 99 | } 100 | 101 | override fun getShortcutIconFd( 102 | packageName: String?, 103 | id: String?, 104 | userId: Int 105 | ): ParcelFileDescriptor { 106 | return launcherAppsService.getShortcutIconFd("android", packageName, id, userId) 107 | } 108 | 109 | override fun startShortcut(shortcutInfo: ShortcutInfo) { 110 | // Start shortcuts by this API may be rejected by system 111 | // for starting from background, even if we're system ... 112 | val id = shortcutInfo.id 113 | val packageName = shortcutInfo.`package` 114 | val userId = shortcutInfo.getUserId() 115 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 116 | launcherAppsService.startShortcut( 117 | "android", packageName, null, 118 | id, null, null, userId) 119 | } else { 120 | launcherAppsService.startShortcut( 121 | "android", packageName, 122 | id, null, null, userId) 123 | } 124 | } 125 | } 126 | 127 | override fun onCreate() { 128 | super.onCreate() 129 | Os.seteuid(1000) 130 | } 131 | 132 | override fun onBind(intent: Intent): IBinder = mHelper.asBinder() 133 | } -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/Settings.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper 2 | 3 | import android.content.Context 4 | import androidx.annotation.StringRes 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.material3.DropdownMenu 8 | import androidx.compose.material3.DropdownMenuItem 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.* 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.platform.LocalContext 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.text.style.TextAlign 15 | import androidx.compose.ui.unit.dp 16 | import androidx.datastore.core.DataStore 17 | import androidx.datastore.preferences.core.Preferences 18 | import androidx.datastore.preferences.core.edit 19 | import androidx.datastore.preferences.core.stringPreferencesKey 20 | import androidx.datastore.preferences.preferencesDataStore 21 | import kotlinx.coroutines.flow.Flow 22 | import kotlinx.coroutines.flow.map 23 | import kotlinx.coroutines.launch 24 | 25 | data class ListPreference( 26 | val preferencesKey: Preferences.Key, 27 | @StringRes val titleRes: Int, 28 | val values: Map 29 | ) 30 | 31 | class Settings(private val context: Context) { 32 | companion object { 33 | private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") 34 | val WORK_MODE = ListPreference( 35 | stringPreferencesKey("work_mode"), 36 | R.string.work_mode_title, 37 | mapOf( 38 | RootHelperService.METHOD_SYSTEM_API to R.string.work_mode_system_api, 39 | RootHelperService.METHOD_PARSE_FILE to R.string.work_mode_parse_xml 40 | ) 41 | ) 42 | } 43 | 44 | fun getValue(key: Preferences.Key, defaultValue: String? = null): Flow = context.dataStore.data 45 | .map { 46 | it[key] ?: defaultValue 47 | } 48 | 49 | suspend fun setValue(key: Preferences.Key, value: String) { 50 | context.dataStore.edit { 51 | it[key] = value 52 | } 53 | } 54 | } 55 | 56 | @Composable 57 | fun MyListPreference( 58 | listPreference: ListPreference 59 | ) { 60 | var expanded by remember { mutableStateOf(false) } 61 | val context = LocalContext.current 62 | val dataStore = Settings(context) 63 | val scope = rememberCoroutineScope() 64 | val item by dataStore.getValue(listPreference.preferencesKey).collectAsState(initial = null) 65 | Row(modifier = Modifier 66 | .clickable { expanded = true } 67 | .padding(8.dp)) { 68 | Text(text = stringResource(id = listPreference.titleRes)) 69 | Spacer(Modifier.weight(1f)) 70 | Box(modifier = Modifier.width(100.dp)) { 71 | Text(text = stringResource(id = listPreference.values[item] ?: R.string.empty_string), textAlign = TextAlign.Right, modifier = Modifier.fillMaxWidth()) 72 | DropdownMenu( 73 | expanded = expanded, 74 | onDismissRequest = { expanded = false }) { 75 | listPreference.values.forEach { (k, v) -> 76 | DropdownMenuItem( 77 | text = { Text(text = stringResource(id = v)) }, 78 | onClick = { 79 | scope.launch { dataStore.setValue(listPreference.preferencesKey, k) } 80 | expanded = false 81 | } 82 | ) 83 | } 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/coil/ShortcutIconFetcher.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper.coil 2 | 3 | import android.content.pm.ShortcutInfo 4 | import android.os.ParcelFileDescriptor 5 | import android.util.Log 6 | import android.webkit.MimeTypeMap 7 | import coil.ImageLoader 8 | import coil.annotation.ExperimentalCoilApi 9 | import coil.decode.DataSource 10 | import coil.fetch.FetchResult 11 | import coil.fetch.Fetcher 12 | import coil.fetch.SourceResult 13 | import coil.decode.ImageSource 14 | import coil.fetch.DrawableResult 15 | import coil.request.Options 16 | import fivecc.tools.shortcut_helper.RootHelperService 17 | import fivecc.tools.shortcut_helper.utils.* 18 | import okio.buffer 19 | import okio.source 20 | 21 | // frameworks/base/core/java/android/content/pm/LauncherApps.java getShortcutIconDrawable 22 | class ShortcutIconFetcher( 23 | private val info: ShortcutInfo, 24 | private val options: Options 25 | ) : Fetcher { 26 | companion object { 27 | private const val TAG = "ShortcutIconFetcher" 28 | } 29 | 30 | @OptIn(ExperimentalCoilApi::class) 31 | override suspend fun fetch(): FetchResult? { 32 | if (info.hasIconFile()) { 33 | RootHelperService.helper?.getShortcutIconFd( 34 | info.`package`, info.id, 0 35 | )?.also { fd -> 36 | return SourceResult( 37 | source = ImageSource( 38 | source = ParcelFileDescriptor.AutoCloseInputStream(fd).source() 39 | .buffer(), 40 | context = options.context, 41 | metadata = null 42 | ), 43 | mimeType = null, 44 | dataSource = DataSource.DISK 45 | ) 46 | } 47 | } else if (info.hasIconResource()) { 48 | val res = options.context.packageManager.getResourcesForApplication(info.`package`) 49 | return DrawableResult( 50 | drawable = res.getDrawable(info.getIconResourceId()), 51 | isSampled = false, 52 | dataSource = DataSource.MEMORY 53 | ) 54 | } 55 | return null 56 | } 57 | 58 | class Factory : Fetcher.Factory { 59 | override fun create( 60 | data: ShortcutInfo, 61 | options: Options, 62 | imageLoader: ImageLoader 63 | ): Fetcher { 64 | return ShortcutIconFetcher(data, options) 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/coil/ShortcutInfoKeyer.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper.coil 2 | 3 | import android.content.pm.ShortcutInfo 4 | import coil.key.Keyer 5 | import coil.request.Options 6 | 7 | class ShortcutInfoKeyer : Keyer { 8 | override fun key(data: ShortcutInfo, options: Options): String { 9 | return "${data.`package`}_${data.id}" 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.SideEffect 13 | import androidx.compose.ui.graphics.toArgb 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.platform.LocalView 16 | import androidx.core.view.ViewCompat 17 | 18 | private val DarkColorScheme = darkColorScheme( 19 | primary = Purple80, 20 | secondary = PurpleGrey80, 21 | tertiary = Pink80 22 | ) 23 | 24 | private val LightColorScheme = lightColorScheme( 25 | primary = Purple40, 26 | secondary = PurpleGrey40, 27 | tertiary = Pink40 28 | 29 | /* Other default colors to override 30 | background = Color(0xFFFFFBFE), 31 | surface = Color(0xFFFFFBFE), 32 | onPrimary = Color.White, 33 | onSecondary = Color.White, 34 | onTertiary = Color.White, 35 | onBackground = Color(0xFF1C1B1F), 36 | onSurface = Color(0xFF1C1B1F), 37 | */ 38 | ) 39 | 40 | @Composable 41 | fun ShortcutTheme( 42 | darkTheme: Boolean = isSystemInDarkTheme(), 43 | // Dynamic color is available on Android 12+ 44 | dynamicColor: Boolean = true, 45 | content: @Composable () -> Unit 46 | ) { 47 | val colorScheme = when { 48 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 49 | val context = LocalContext.current 50 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 51 | } 52 | darkTheme -> DarkColorScheme 53 | else -> LightColorScheme 54 | } 55 | val view = LocalView.current 56 | if (!view.isInEditMode) { 57 | SideEffect { 58 | (view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb() 59 | ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme 60 | } 61 | } 62 | 63 | MaterialTheme( 64 | colorScheme = colorScheme, 65 | typography = Typography, 66 | content = content 67 | ) 68 | } -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/utils/PrefUtils.kt: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper.utils 2 | 3 | import android.util.Log 4 | 5 | const val TRACE_TAG = "Trace" 6 | 7 | inline fun trace(desc: String, block: () -> T): T { 8 | Log.d(TRACE_TAG, "on ${Thread.currentThread()}: $desc") 9 | val startTime = System.currentTimeMillis() 10 | try { 11 | return block() 12 | } finally { 13 | val time = System.currentTimeMillis() - startTime 14 | Log.d(TRACE_TAG, "execute done ($time): $desc") 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/utils/ShortcutInfoHelper.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("CAST_NEVER_SUCCEEDS", "Unchecked_Cast") 2 | 3 | package fivecc.tools.shortcut_helper.utils 4 | 5 | import android.app.Person 6 | import android.content.ComponentName 7 | import android.content.Intent 8 | import android.content.LocusId 9 | import android.content.pm.* 10 | import android.content.res.Resources 11 | import android.graphics.drawable.Icon 12 | import android.os.Build 13 | import android.os.PersistableBundle 14 | import android.util.Log 15 | import fivecc.tools.shortcut_helper.App 16 | 17 | const val MATCH_PINNED = 1 18 | const val MATCH_DYNAMIC = 1 shl 1 19 | const val MATCH_MANIFEST = 1 shl 2 20 | const val MATCH_CACHED = 1 shl 3 21 | const val MATCH_ALL = MATCH_PINNED or MATCH_DYNAMIC or MATCH_MANIFEST or MATCH_CACHED 22 | 23 | /** 24 | * Use IShortcutManager API to get ShortcutInfo s 25 | * which `bitmapPath` are empty 26 | */ 27 | fun IShortcutService.getShortcutInfoCompat( 28 | packageName: String, userId: Int, matchFlags: Int = MATCH_ALL 29 | ): List { 30 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { 31 | val result = mutableListOf() 32 | if (matchFlags and MATCH_PINNED != 0) { 33 | result.addAll(getPinnedShortcuts(packageName, userId).list as List) 34 | } 35 | if (matchFlags and MATCH_MANIFEST != 0) { 36 | result.addAll(getManifestShortcuts(packageName, userId).list as List) 37 | } 38 | if (matchFlags and MATCH_DYNAMIC != 0) { 39 | result.addAll(getDynamicShortcuts(packageName, userId).list as List) 40 | } 41 | return result 42 | } else { 43 | var flags = 0 44 | if (matchFlags and MATCH_PINNED != 0) { 45 | flags = flags or ShortcutManager.FLAG_MATCH_PINNED 46 | } 47 | if (matchFlags and MATCH_MANIFEST != 0) { 48 | flags = flags or ShortcutManager.FLAG_MATCH_MANIFEST 49 | } 50 | if (matchFlags and MATCH_DYNAMIC != 0) { 51 | flags = flags or ShortcutManager.FLAG_MATCH_DYNAMIC 52 | } 53 | if (matchFlags and MATCH_CACHED != 0) { 54 | flags = flags or ShortcutManager.FLAG_MATCH_CACHED 55 | } 56 | if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R || Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 57 | return getShortcuts(packageName, flags, userId).list as List 58 | } else { 59 | return (this as IShortcutServiceForS).getShortcuts(packageName, flags, userId).get().list as List 60 | } 61 | } 62 | } 63 | 64 | fun ShortcutInfo.hasIconFile(): Boolean { 65 | this as ShortcutInfoHidden 66 | return hasIconFile() 67 | } 68 | 69 | fun ShortcutInfo.hasIconResource(): Boolean { 70 | this as ShortcutInfoHidden 71 | return hasIconResource() 72 | } 73 | 74 | fun ShortcutInfo.getIconResourceId(): Int { 75 | this as ShortcutInfoHidden 76 | return getIconResourceId() 77 | } 78 | 79 | fun ShortcutInfo.getUserId(): Int { 80 | this as ShortcutInfoHidden 81 | return userId 82 | } 83 | 84 | fun ShortcutInfo.getLabel(): String { 85 | shortLabel?.also { return it.toString() } 86 | longLabel?.also { return it.toString() } 87 | val packageName = `package` 88 | val defaultName = "Shortcut:$packageName:$id" 89 | this as ShortcutInfoHidden 90 | val pm = App.instance.packageManager 91 | val shortId = shortLabelResourceId 92 | val longId = longLabelResourceId 93 | val id = if (shortId != 0) shortId 94 | else if (longId != 0) longId 95 | else return defaultName 96 | try { 97 | return pm.getResourcesForApplication(packageName).getString(id) 98 | } catch (e: Resources.NotFoundException) { 99 | Log.e("ShortcutInfoHelper", "getLabel $id for $packageName not found", e) 100 | } 101 | return defaultName 102 | } 103 | 104 | fun newShortcutInfoCompat( 105 | userId: Int, 106 | id: String?, 107 | packageName: String?, 108 | activity: ComponentName?, 109 | icon: Icon?, 110 | title: CharSequence?, 111 | titleResId: Int, 112 | titleResName: String?, 113 | text: CharSequence?, 114 | textResId: Int, 115 | textResName: String?, 116 | disabledMessage: CharSequence?, 117 | disabledMessageResId: Int, 118 | disabledMessageResName: String?, 119 | categories: Set?, 120 | intentsWithExtras: Array?, 121 | rank: Int, 122 | extras: PersistableBundle?, 123 | lastChangedTimestamp: Long, 124 | flags: Int, 125 | iconResId: Int, 126 | iconResName: String?, 127 | bitmapPath: String?, 128 | iconUri: String?, 129 | disabledReason: Int, 130 | persons: Array?, 131 | locusId: LocusId?, 132 | ): ShortcutInfo { 133 | val compat = when (Build.VERSION.SDK_INT) { 134 | Build.VERSION_CODES.O, 135 | Build.VERSION_CODES.O_MR1 -> ShortcutInfoHidden( 136 | userId, id, packageName, activity, icon, 137 | title, titleResId, titleResName, text, textResId, textResName, 138 | disabledMessage, disabledMessageResId, disabledMessageResName, 139 | categories, 140 | intentsWithExtras, 141 | rank, extras, lastChangedTimestamp, flags, 142 | iconResId, iconResName, bitmapPath 143 | ) 144 | Build.VERSION_CODES.P -> ShortcutInfoHidden( 145 | userId, id, packageName, activity, icon, 146 | title, titleResId, titleResName, text, textResId, textResName, 147 | disabledMessage, disabledMessageResId, disabledMessageResName, 148 | categories, 149 | intentsWithExtras, 150 | rank, extras, lastChangedTimestamp, flags, 151 | iconResId, iconResName, bitmapPath, 152 | disabledReason 153 | ) 154 | Build.VERSION_CODES.Q -> ShortcutInfoHidden( 155 | userId, id, packageName, activity, icon, 156 | title, titleResId, titleResName, text, textResId, textResName, 157 | disabledMessage, disabledMessageResId, disabledMessageResName, 158 | categories, 159 | intentsWithExtras, 160 | rank, extras, lastChangedTimestamp, flags, 161 | iconResId, iconResName, bitmapPath, 162 | disabledReason, persons, locusId 163 | ) 164 | Build.VERSION_CODES.R -> ShortcutInfoHidden( 165 | userId, id, packageName, activity, icon, 166 | title, titleResId, titleResName, text, textResId, textResName, 167 | disabledMessage, disabledMessageResId, disabledMessageResName, 168 | categories, 169 | intentsWithExtras, 170 | rank, extras, lastChangedTimestamp, flags, 171 | iconResId, iconResName, bitmapPath, iconUri, 172 | disabledReason, persons, locusId 173 | ) 174 | Build.VERSION_CODES.S, Build.VERSION_CODES.S_V2 -> ShortcutInfoHidden( 175 | userId, id, packageName, activity, icon, 176 | title, titleResId, titleResName, text, textResId, textResName, 177 | disabledMessage, disabledMessageResId, disabledMessageResName, 178 | categories, 179 | intentsWithExtras, 180 | rank, extras, lastChangedTimestamp, flags, 181 | iconResId, iconResName, bitmapPath, iconUri, 182 | disabledReason, persons, locusId, null 183 | ) 184 | else -> ShortcutInfoHidden( 185 | userId, id, packageName, activity, icon, 186 | title, titleResId, titleResName, text, textResId, textResName, 187 | disabledMessage, disabledMessageResId, disabledMessageResName, 188 | categories, 189 | intentsWithExtras, 190 | rank, extras, lastChangedTimestamp, flags, 191 | iconResId, iconResName, bitmapPath, iconUri, 192 | disabledReason, persons, locusId, null, null 193 | ) 194 | } 195 | return compat as ShortcutInfo 196 | } 197 | -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/utils/ShortcutParser.java: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper.utils; 2 | 3 | import android.app.Person; 4 | import android.content.ComponentName; 5 | import android.content.Intent; 6 | import android.content.LocusId; 7 | import android.content.pm.ShortcutInfo; 8 | import android.content.pm.ShortcutInfoHidden; 9 | import android.os.Build; 10 | import android.os.PersistableBundle; 11 | import android.os.PersistableBundleHidden; 12 | import android.text.TextUtils; 13 | import android.util.ArraySet; 14 | import android.util.AtomicFile; 15 | import android.util.Log; 16 | import android.util.Xml; 17 | import android.util.XmlHidden; 18 | 19 | import androidx.annotation.Nullable; 20 | import androidx.annotation.RequiresApi; 21 | 22 | import org.xmlpull.v1.XmlPullParser; 23 | import org.xmlpull.v1.XmlPullParserException; 24 | 25 | import java.io.BufferedInputStream; 26 | import java.io.File; 27 | import java.io.FileInputStream; 28 | import java.io.FileNotFoundException; 29 | import java.io.IOException; 30 | import java.io.InputStream; 31 | import java.net.URISyntaxException; 32 | import java.nio.charset.StandardCharsets; 33 | import java.util.ArrayList; 34 | import java.util.Arrays; 35 | import java.util.List; 36 | import java.util.Objects; 37 | import java.util.function.Consumer; 38 | 39 | // frameworks/base/services/core/java/com/android/server/pm/ShortcutService.java 40 | // frameworks/base/services/core/java/com/android/server/pm/ShortcutUser.java 41 | // frameworks/base/services/core/java/com/android/server/pm/ShortcutPackage.java 42 | public class ShortcutParser { 43 | private static final String TAG = "ShortcutParser"; 44 | 45 | static final String DIRECTORY_PACKAGES = "packages"; 46 | static final String FILENAME_USER_PACKAGES = "shortcuts.xml"; 47 | static final String DIRECTORY_PER_USER = "shortcut_service"; 48 | 49 | static final String TAG_PACKAGE_ROOT = "package"; 50 | static final String TAG_USER_ROOT = "user"; 51 | private static final String TAG_INTENT_EXTRAS_LEGACY = "intent-extras"; 52 | private static final String TAG_INTENT = "intent"; 53 | private static final String TAG_EXTRAS = "extras"; 54 | private static final String TAG_SHORTCUT = "shortcut"; 55 | private static final String TAG_CATEGORIES = "categories"; 56 | private static final String TAG_PERSON = "person"; 57 | 58 | private static final String ATTR_NAME = "name"; 59 | private static final String ATTR_ID = "id"; 60 | private static final String ATTR_ACTIVITY = "activity"; 61 | private static final String ATTR_TITLE = "title"; 62 | private static final String ATTR_TITLE_RES_ID = "titleid"; 63 | private static final String ATTR_TITLE_RES_NAME = "titlename"; 64 | private static final String ATTR_TEXT = "text"; 65 | private static final String ATTR_TEXT_RES_ID = "textid"; 66 | private static final String ATTR_TEXT_RES_NAME = "textname"; 67 | private static final String ATTR_DISABLED_MESSAGE = "dmessage"; 68 | private static final String ATTR_DISABLED_MESSAGE_RES_ID = "dmessageid"; 69 | private static final String ATTR_DISABLED_MESSAGE_RES_NAME = "dmessagename"; 70 | private static final String ATTR_DISABLED_REASON = "disabled-reason"; 71 | private static final String ATTR_INTENT_LEGACY = "intent"; 72 | private static final String ATTR_INTENT_NO_EXTRA = "intent-base"; 73 | private static final String ATTR_RANK = "rank"; 74 | private static final String ATTR_TIMESTAMP = "timestamp"; 75 | private static final String ATTR_FLAGS = "flags"; 76 | private static final String ATTR_ICON_RES_ID = "icon-res"; 77 | private static final String ATTR_ICON_RES_NAME = "icon-resname"; 78 | private static final String ATTR_BITMAP_PATH = "bitmap-path"; 79 | private static final String ATTR_ICON_URI = "icon-uri"; 80 | private static final String ATTR_LOCUS_ID = "locus-id"; 81 | 82 | private static final String ATTR_PERSON_NAME = "name"; 83 | private static final String ATTR_PERSON_URI = "uri"; 84 | private static final String ATTR_PERSON_KEY = "key"; 85 | private static final String ATTR_PERSON_IS_BOT = "is-bot"; 86 | private static final String ATTR_PERSON_IS_IMPORTANT = "is-important"; 87 | 88 | private static final String NAME_CATEGORIES = "categories"; 89 | 90 | private static final String TAG_STRING_ARRAY_XMLUTILS = "string-array"; 91 | private static final String ATTR_NAME_XMLUTILS = "name"; 92 | 93 | private static File getUserFile(int userId) { 94 | return new File(injectUserDataPath(userId), FILENAME_USER_PACKAGES); 95 | } 96 | 97 | private static File injectUserDataPath(int userId) { 98 | // Environment.getDataSystemCeDirectory(userId) 99 | return new File("/data/system_ce/" + userId, DIRECTORY_PER_USER); 100 | } 101 | 102 | public static void closeQuietly(@Nullable AutoCloseable closeable) { 103 | if (closeable != null) { 104 | try { 105 | closeable.close(); 106 | } catch (RuntimeException rethrown) { 107 | throw rethrown; 108 | } catch (Exception ignored) { 109 | } 110 | } 111 | } 112 | 113 | @Nullable 114 | public static List loadUserLocked(int userId) { 115 | final File path = getUserFile(userId); 116 | final AtomicFile file = new AtomicFile(path); 117 | 118 | final FileInputStream in; 119 | try { 120 | in = file.openRead(); 121 | } catch (FileNotFoundException e) { 122 | Log.d(TAG, "Not found " + path); 123 | return null; 124 | } 125 | try { 126 | return loadUserInternal(userId, in, /* forBackup= */ false); 127 | } catch (IOException | XmlPullParserException e) { 128 | Log.e(TAG, "Failed to read file " + file.getBaseFile(), e); 129 | return null; 130 | } finally { 131 | closeQuietly(in); 132 | } 133 | } 134 | 135 | private static XmlPullParser newPullParserCompat(InputStream is) throws XmlPullParserException, IOException { 136 | XmlPullParser parser; 137 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 138 | parser = XmlHidden.resolvePullParser(is); 139 | } else { 140 | parser = Xml.newPullParser(); 141 | parser.setInput(is, StandardCharsets.UTF_8.name()); 142 | } 143 | return parser; 144 | } 145 | 146 | private static List loadUserInternal(int userId, InputStream is, 147 | boolean fromBackup) throws XmlPullParserException, IOException { 148 | XmlPullParser parser = newPullParserCompat(is); 149 | List ret = null; 150 | 151 | int type; 152 | while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) { 153 | if (type != XmlPullParser.START_TAG) { 154 | continue; 155 | } 156 | final int depth = parser.getDepth(); 157 | 158 | final String tag = parser.getName(); 159 | if ((depth == 1) && TAG_USER_ROOT.equals(tag)) { 160 | ret = loadUserFromXml(parser, userId, fromBackup); 161 | } 162 | // throwForInvalidTag(depth, tag); 163 | } 164 | return ret; 165 | } 166 | 167 | private static List loadUserFromXml(XmlPullParser parser, int userId, 168 | boolean fromBackup) throws IOException, XmlPullParserException { 169 | List ret = new ArrayList<>(); 170 | boolean readShortcutItems = false; 171 | final int outerDepth = parser.getDepth(); 172 | int type; 173 | while ((type = parser.next()) != XmlPullParser.END_DOCUMENT 174 | && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { 175 | if (type != XmlPullParser.START_TAG) { 176 | continue; 177 | } 178 | final int depth = parser.getDepth(); 179 | final String tag = parser.getName(); 180 | 181 | if (depth == outerDepth + 1) { 182 | if (Objects.equals(tag, ShortcutParser.TAG_PACKAGE_ROOT)) { 183 | ret.addAll(loadPackageFromXml(parser, fromBackup, userId)); 184 | readShortcutItems = true; 185 | continue; 186 | } 187 | } 188 | warnForInvalidTag(depth, tag); 189 | } 190 | 191 | if (!readShortcutItems) { 192 | // If the shortcuts info was read from the main Xml, skip reading from individual files. 193 | // Data will get stored in the new format during the next call to saveToXml(). 194 | final File root = injectUserDataPath(userId); 195 | 196 | forAllFilesIn(new File(root, DIRECTORY_PACKAGES), (File f) -> { 197 | final List sp = loadPackageFromFile(f, fromBackup, userId); 198 | if (sp != null) { 199 | ret.addAll(sp); 200 | } 201 | }); 202 | } 203 | 204 | return ret; 205 | } 206 | 207 | private static void forAllFilesIn(File path, Consumer callback) { 208 | if (!path.exists()) { 209 | return; 210 | } 211 | File[] list = path.listFiles(); 212 | assert list != null; 213 | for (File f : list) { 214 | callback.accept(f); 215 | } 216 | } 217 | 218 | private static List loadPackageFromFile(File path, boolean fromBackup, int userId) { 219 | 220 | final AtomicFile file = new AtomicFile(path); 221 | final FileInputStream in; 222 | try { 223 | in = file.openRead(); 224 | } catch (FileNotFoundException e) { 225 | return null; 226 | } 227 | 228 | try { 229 | final BufferedInputStream bis = new BufferedInputStream(in); 230 | 231 | List ret = null; 232 | XmlPullParser parser = newPullParserCompat(bis); 233 | 234 | int type; 235 | while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) { 236 | if (type != XmlPullParser.START_TAG) { 237 | continue; 238 | } 239 | final int depth = parser.getDepth(); 240 | 241 | final String tag = parser.getName(); 242 | if ((depth == 1) && TAG_PACKAGE_ROOT.equals(tag)) { 243 | ret = loadPackageFromXml(parser, fromBackup, userId); 244 | } 245 | } 246 | return ret; 247 | } catch (XmlPullParserException | IOException e) { 248 | Log.e(TAG, "loadPackageFromFile", e); 249 | return null; 250 | } finally { 251 | closeQuietly(in); 252 | } 253 | } 254 | 255 | private static List loadPackageFromXml(XmlPullParser parser, boolean fromBackup, int userId) 256 | throws IOException, XmlPullParserException { 257 | 258 | final String packageName = parseStringAttribute(parser, 259 | ATTR_NAME); 260 | 261 | List ret = new ArrayList<>(); 262 | 263 | final int outerDepth = parser.getDepth(); 264 | int type; 265 | while ((type = parser.next()) != XmlPullParser.END_DOCUMENT 266 | && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { 267 | if (type != XmlPullParser.START_TAG) { 268 | continue; 269 | } 270 | final int depth = parser.getDepth(); 271 | final String tag = parser.getName(); 272 | if (depth == outerDepth + 1) { 273 | if (TAG_SHORTCUT.equals(tag)) { 274 | final ShortcutInfo si = parseShortcut(parser, packageName, 275 | userId, fromBackup); 276 | ret.add(si); 277 | continue; 278 | } 279 | } 280 | warnForInvalidTag(depth, tag); 281 | } 282 | return ret; 283 | } 284 | 285 | private static ShortcutInfo parseShortcut(XmlPullParser parser, String packageName, 286 | int userId, boolean fromBackup) 287 | throws IOException, XmlPullParserException { 288 | String id; 289 | ComponentName activityComponent; 290 | // Icon icon; 291 | String title; 292 | int titleResId; 293 | String titleResName; 294 | String text; 295 | int textResId; 296 | String textResName; 297 | String disabledMessage; 298 | int disabledMessageResId; 299 | String disabledMessageResName; 300 | int disabledReason; 301 | Intent intentLegacy; 302 | PersistableBundle intentPersistableExtrasLegacy = null; 303 | ArrayList intents = new ArrayList<>(); 304 | int rank; 305 | PersistableBundle extras = null; 306 | long lastChangedTimestamp; 307 | int flags; 308 | int iconResId; 309 | String iconResName; 310 | String bitmapPath; 311 | String iconUri; 312 | final String locusIdString; 313 | ArraySet categories = null; 314 | ArrayList persons = new ArrayList<>(); 315 | 316 | id = parseStringAttribute(parser, ATTR_ID); 317 | activityComponent = parseComponentNameAttribute(parser, 318 | ATTR_ACTIVITY); 319 | title = parseStringAttribute(parser, ATTR_TITLE); 320 | titleResId = parseIntAttribute(parser, ATTR_TITLE_RES_ID); 321 | titleResName = parseStringAttribute(parser, ATTR_TITLE_RES_NAME); 322 | text = parseStringAttribute(parser, ATTR_TEXT); 323 | textResId = parseIntAttribute(parser, ATTR_TEXT_RES_ID); 324 | textResName = parseStringAttribute(parser, ATTR_TEXT_RES_NAME); 325 | disabledMessage = parseStringAttribute(parser, ATTR_DISABLED_MESSAGE); 326 | disabledMessageResId = parseIntAttribute(parser, 327 | ATTR_DISABLED_MESSAGE_RES_ID); 328 | disabledMessageResName = parseStringAttribute(parser, 329 | ATTR_DISABLED_MESSAGE_RES_NAME); 330 | disabledReason = parseIntAttribute(parser, ATTR_DISABLED_REASON); 331 | intentLegacy = parseIntentAttributeNoDefault(parser, ATTR_INTENT_LEGACY); 332 | rank = (int) parseLongAttribute(parser, ATTR_RANK); 333 | lastChangedTimestamp = parseLongAttribute(parser, ATTR_TIMESTAMP); 334 | flags = (int) parseLongAttribute(parser, ATTR_FLAGS); 335 | iconResId = (int) parseLongAttribute(parser, ATTR_ICON_RES_ID); 336 | iconResName = parseStringAttribute(parser, ATTR_ICON_RES_NAME); 337 | bitmapPath = parseStringAttribute(parser, ATTR_BITMAP_PATH); 338 | iconUri = parseStringAttribute(parser, ATTR_ICON_URI); 339 | locusIdString = parseStringAttribute(parser, ATTR_LOCUS_ID); 340 | 341 | final int outerDepth = parser.getDepth(); 342 | int type; 343 | while ((type = parser.next()) != XmlPullParser.END_DOCUMENT 344 | && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { 345 | if (type != XmlPullParser.START_TAG) { 346 | continue; 347 | } 348 | final int depth = parser.getDepth(); 349 | final String tag = parser.getName(); 350 | switch (tag) { 351 | case TAG_INTENT_EXTRAS_LEGACY: 352 | intentPersistableExtrasLegacy = PersistableBundleHidden.restoreFromXml(parser); 353 | continue; 354 | case TAG_INTENT: 355 | intents.add(parseIntent(parser)); 356 | continue; 357 | case TAG_EXTRAS: 358 | extras = PersistableBundleHidden.restoreFromXml(parser); 359 | continue; 360 | case TAG_CATEGORIES: 361 | // This just contains string-array. 362 | continue; 363 | case TAG_PERSON: 364 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 365 | persons.add(parsePerson(parser)); 366 | } 367 | continue; 368 | case TAG_STRING_ARRAY_XMLUTILS: 369 | if (NAME_CATEGORIES.equals(parseStringAttribute(parser, 370 | ATTR_NAME_XMLUTILS))) { 371 | final String[] ar = XmlUtils.readThisStringArrayXml( 372 | parser, TAG_STRING_ARRAY_XMLUTILS, null); 373 | categories = new ArraySet<>(ar.length); 374 | categories.addAll(Arrays.asList(ar)); 375 | } 376 | continue; 377 | } 378 | throw throwForInvalidTag(depth, tag); 379 | } 380 | 381 | if (intentLegacy != null) { 382 | // For the legacy file format which supported only one intent per shortcut. 383 | ShortcutInfoHidden.setIntentExtras(intentLegacy, intentPersistableExtrasLegacy); 384 | intents.clear(); 385 | intents.add(intentLegacy); 386 | } 387 | 388 | LocusId locusId = null; 389 | if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { 390 | locusId = locusIdString == null ? null : new LocusId(locusIdString); 391 | } 392 | 393 | return ShortcutInfoHelperKt.newShortcutInfoCompat( 394 | userId, id, packageName, activityComponent, /* icon= */ null, 395 | title, titleResId, titleResName, text, textResId, textResName, 396 | disabledMessage, disabledMessageResId, disabledMessageResName, 397 | categories, 398 | intents.toArray(new Intent[intents.size()]), 399 | rank, extras, lastChangedTimestamp, flags, 400 | iconResId, iconResName, bitmapPath, iconUri, 401 | disabledReason, persons.toArray(new Person[persons.size()]), locusId); 402 | } 403 | 404 | private static Intent parseIntent(XmlPullParser parser) 405 | throws IOException, XmlPullParserException { 406 | 407 | Intent intent = parseIntentAttribute(parser, 408 | ATTR_INTENT_NO_EXTRA); 409 | 410 | final int outerDepth = parser.getDepth(); 411 | int type; 412 | while ((type = parser.next()) != XmlPullParser.END_DOCUMENT 413 | && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { 414 | if (type != XmlPullParser.START_TAG) { 415 | continue; 416 | } 417 | final int depth = parser.getDepth(); 418 | final String tag = parser.getName(); 419 | if (TAG_EXTRAS.equals(tag)) { 420 | ShortcutInfoHidden.setIntentExtras(intent, 421 | PersistableBundleHidden.restoreFromXml(parser)); 422 | continue; 423 | } 424 | throw throwForInvalidTag(depth, tag); 425 | } 426 | return intent; 427 | } 428 | 429 | static IOException throwForInvalidTag(int depth, String tag) throws IOException { 430 | throw new IOException(String.format("Invalid tag '%s' found at depth %d", tag, depth)); 431 | } 432 | 433 | static void warnForInvalidTag(int depth, String tag) throws IOException { 434 | Log.w(TAG, String.format("Invalid tag '%s' found at depth %d", tag, depth)); 435 | } 436 | 437 | @RequiresApi(api = Build.VERSION_CODES.P) 438 | private static Person parsePerson(XmlPullParser parser) 439 | throws IOException, XmlPullParserException { 440 | CharSequence name = parseStringAttribute(parser, ATTR_PERSON_NAME); 441 | String uri = parseStringAttribute(parser, ATTR_PERSON_URI); 442 | String key = parseStringAttribute(parser, ATTR_PERSON_KEY); 443 | boolean isBot = parseBooleanAttribute(parser, ATTR_PERSON_IS_BOT); 444 | boolean isImportant = parseBooleanAttribute(parser, 445 | ATTR_PERSON_IS_IMPORTANT); 446 | 447 | Person.Builder builder = new Person.Builder(); 448 | builder.setName(name).setUri(uri).setKey(key).setBot(isBot).setImportant(isImportant); 449 | return builder.build(); 450 | } 451 | 452 | static String parseStringAttribute(XmlPullParser parser, String attribute) { 453 | return parser.getAttributeValue(null, attribute); 454 | } 455 | 456 | static boolean parseBooleanAttribute(XmlPullParser parser, String attribute) { 457 | return parseLongAttribute(parser, attribute) == 1; 458 | } 459 | 460 | static boolean parseBooleanAttribute(XmlPullParser parser, String attribute, boolean def) { 461 | return parseLongAttribute(parser, attribute, (def ? 1 : 0)) == 1; 462 | } 463 | 464 | static int parseIntAttribute(XmlPullParser parser, String attribute) { 465 | return (int) parseLongAttribute(parser, attribute); 466 | } 467 | 468 | static int parseIntAttribute(XmlPullParser parser, String attribute, int def) { 469 | return (int) parseLongAttribute(parser, attribute, def); 470 | } 471 | 472 | static long parseLongAttribute(XmlPullParser parser, String attribute) { 473 | return parseLongAttribute(parser, attribute, 0); 474 | } 475 | 476 | static long parseLongAttribute(XmlPullParser parser, String attribute, long def) { 477 | final String value = parseStringAttribute(parser, attribute); 478 | if (TextUtils.isEmpty(value)) { 479 | return def; 480 | } 481 | try { 482 | return Long.parseLong(value); 483 | } catch (NumberFormatException e) { 484 | return def; 485 | } 486 | } 487 | 488 | @Nullable 489 | static ComponentName parseComponentNameAttribute(XmlPullParser parser, String attribute) { 490 | final String value = parseStringAttribute(parser, attribute); 491 | if (TextUtils.isEmpty(value)) { 492 | return null; 493 | } 494 | return ComponentName.unflattenFromString(value); 495 | } 496 | 497 | @Nullable 498 | static Intent parseIntentAttributeNoDefault(XmlPullParser parser, String attribute) { 499 | final String value = parseStringAttribute(parser, attribute); 500 | Intent parsed = null; 501 | if (!TextUtils.isEmpty(value)) { 502 | try { 503 | parsed = Intent.parseUri(value, /* flags =*/ 0); 504 | } catch (URISyntaxException e) { 505 | Log.e(TAG, "Error parsing intent", e); 506 | } 507 | } 508 | return parsed; 509 | } 510 | 511 | @Nullable 512 | static Intent parseIntentAttribute(XmlPullParser parser, String attribute) { 513 | Intent parsed = parseIntentAttributeNoDefault(parser, attribute); 514 | if (parsed == null) { 515 | // Default intent. 516 | parsed = new Intent(Intent.ACTION_VIEW); 517 | } 518 | return parsed; 519 | } 520 | } 521 | -------------------------------------------------------------------------------- /app/src/main/java/fivecc/tools/shortcut_helper/utils/XmlUtils.java: -------------------------------------------------------------------------------- 1 | package fivecc.tools.shortcut_helper.utils; 2 | 3 | import org.xmlpull.v1.XmlPullParser; 4 | import org.xmlpull.v1.XmlPullParserException; 5 | 6 | // from android 11 source 7 | // frameworks/base/core/java/com/android/internal/util/XmlUtils.java 8 | public class XmlUtils { 9 | public static final String[] readThisStringArrayXml(XmlPullParser parser, String endTag, 10 | String[] name) throws XmlPullParserException, java.io.IOException { 11 | 12 | int num; 13 | try { 14 | num = Integer.parseInt(parser.getAttributeValue(null, "num")); 15 | } catch (NullPointerException e) { 16 | throw new XmlPullParserException("Need num attribute in string-array"); 17 | } catch (NumberFormatException e) { 18 | throw new XmlPullParserException("Not a number in num attribute in string-array"); 19 | } 20 | parser.next(); 21 | 22 | String[] array = new String[num]; 23 | int i = 0; 24 | 25 | int eventType = parser.getEventType(); 26 | do { 27 | if (eventType == parser.START_TAG) { 28 | if (parser.getName().equals("item")) { 29 | try { 30 | array[i] = parser.getAttributeValue(null, "value"); 31 | } catch (NullPointerException e) { 32 | throw new XmlPullParserException("Need value attribute in item"); 33 | } catch (NumberFormatException e) { 34 | throw new XmlPullParserException("Not a number in value attribute in item"); 35 | } 36 | } else { 37 | throw new XmlPullParserException("Expected item tag at: " + parser.getName()); 38 | } 39 | } else if (eventType == parser.END_TAG) { 40 | if (parser.getName().equals(endTag)) { 41 | return array; 42 | } else if (parser.getName().equals("item")) { 43 | i++; 44 | } else { 45 | throw new XmlPullParserException("Expected " + endTag + " end tag at: " + 46 | parser.getName()); 47 | } 48 | } 49 | eventType = parser.next(); 50 | } while (eventType != parser.END_DOCUMENT); 51 | 52 | throw new XmlPullParserException("Document ended before " + endTag + " end tag"); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 17 | 20 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #C47AEC 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Shortcut 3 | 4 | 5 | Work Mode 6 | System API 7 | Parse XML 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |