1 /* <lambda>null2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.launcher3.uioverrides.flags 18 19 import android.app.PendingIntent 20 import android.app.blob.BlobHandle.createWithSha256 21 import android.app.blob.BlobStoreManager 22 import android.content.Context 23 import android.content.IIntentReceiver 24 import android.content.IIntentSender.Stub 25 import android.content.Intent 26 import android.content.Intent.ACTION_CREATE_DOCUMENT 27 import android.content.Intent.ACTION_OPEN_DOCUMENT 28 import android.content.pm.PackageManager 29 import android.net.Uri 30 import android.os.Bundle 31 import android.os.IBinder 32 import android.os.ParcelFileDescriptor.AutoCloseOutputStream 33 import android.provider.DeviceConfig 34 import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS 35 import android.provider.Settings.Secure 36 import android.text.Html 37 import android.util.AttributeSet 38 import android.util.Base64 39 import android.util.Base64.NO_PADDING 40 import android.util.Base64.NO_WRAP 41 import android.view.inputmethod.EditorInfo 42 import android.widget.TextView 43 import android.widget.Toast 44 import androidx.core.widget.doAfterTextChanged 45 import androidx.preference.Preference 46 import androidx.preference.PreferenceCategory 47 import androidx.preference.PreferenceGroup 48 import androidx.preference.PreferenceViewHolder 49 import androidx.preference.SwitchPreference 50 import com.android.launcher3.AutoInstallsLayout 51 import com.android.launcher3.ExtendedEditText 52 import com.android.launcher3.LauncherAppState 53 import com.android.launcher3.LauncherPrefs 54 import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_DESKTOP 55 import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_HOTSEAT 56 import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION 57 import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET 58 import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT 59 import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_FOLDER 60 import com.android.launcher3.LauncherSettings.Settings.LAYOUT_DIGEST_KEY 61 import com.android.launcher3.LauncherSettings.Settings.LAYOUT_DIGEST_LABEL 62 import com.android.launcher3.LauncherSettings.Settings.LAYOUT_DIGEST_TAG 63 import com.android.launcher3.R 64 import com.android.launcher3.model.data.FolderInfo 65 import com.android.launcher3.model.data.ItemInfo 66 import com.android.launcher3.model.data.LauncherAppWidgetInfo 67 import com.android.launcher3.pm.UserCache 68 import com.android.launcher3.proxy.ProxyActivityStarter 69 import com.android.launcher3.secondarydisplay.SecondaryDisplayLauncher 70 import com.android.launcher3.shortcuts.ShortcutKey 71 import com.android.launcher3.uioverrides.plugins.PluginManagerWrapperImpl 72 import com.android.launcher3.util.Executors.MAIN_EXECUTOR 73 import com.android.launcher3.util.Executors.MODEL_EXECUTOR 74 import com.android.launcher3.util.Executors.ORDERED_BG_EXECUTOR 75 import com.android.launcher3.util.LauncherLayoutBuilder 76 import com.android.launcher3.util.OnboardingPrefs.ALL_APPS_VISITED_COUNT 77 import com.android.launcher3.util.OnboardingPrefs.HOME_BOUNCE_COUNT 78 import com.android.launcher3.util.OnboardingPrefs.HOME_BOUNCE_SEEN 79 import com.android.launcher3.util.OnboardingPrefs.HOTSEAT_DISCOVERY_TIP_COUNT 80 import com.android.launcher3.util.OnboardingPrefs.HOTSEAT_LONGPRESS_TIP_SEEN 81 import com.android.launcher3.util.OnboardingPrefs.TASKBAR_EDU_TOOLTIP_STEP 82 import com.android.launcher3.util.PluginManagerWrapper 83 import com.android.launcher3.util.StartActivityParams 84 import com.android.launcher3.util.UserIconInfo 85 import com.android.quickstep.util.DeviceConfigHelper 86 import com.android.quickstep.util.DeviceConfigHelper.Companion.NAMESPACE_LAUNCHER 87 import com.android.quickstep.util.DeviceConfigHelper.DebugInfo 88 import com.android.systemui.shared.plugins.PluginEnabler 89 import com.android.systemui.shared.plugins.PluginPrefs 90 import java.io.OutputStreamWriter 91 import java.security.MessageDigest 92 import java.util.Locale 93 import java.util.concurrent.Executor 94 95 /** Helper class to generate UI for Device Config */ 96 class DevOptionsUiHelper(c: Context, attr: AttributeSet?) : PreferenceGroup(c, attr) { 97 98 init { 99 layoutResource = R.layout.developer_options_top_bar 100 isPersistent = false 101 } 102 103 override fun onBindViewHolder(holder: PreferenceViewHolder) { 104 super.onBindViewHolder(holder) 105 106 // Initialize search 107 (holder.findViewById(R.id.filter_box) as TextView?)?.doAfterTextChanged { 108 val query: String = it.toString().lowercase(Locale.getDefault()).replace("_", " ") 109 filterPreferences(query, this) 110 111 // Always keep myself visible 112 this@DevOptionsUiHelper.isVisible = true 113 } 114 } 115 116 private fun filterPreferences(query: String, pg: PreferenceGroup) { 117 val count = pg.preferenceCount 118 var visible = false 119 for (i in 0 until count) { 120 val preference = pg.getPreference(i) 121 if (preference is PreferenceGroup) { 122 filterPreferences(query, preference) 123 } else { 124 val title = 125 preference.title.toString().lowercase(Locale.getDefault()).replace("_", " ") 126 preference.isVisible = query.isEmpty() || title.contains(query) 127 } 128 visible = visible or preference.isVisible 129 } 130 pg.isVisible = visible 131 } 132 133 override fun onAttached() { 134 super.onAttached() 135 136 removeAll() 137 inflateServerFlags(newCategory("Server flags", "Long press to reset")) 138 if (PluginPrefs.hasPlugins(context)) { 139 inflatePluginPrefs(newCategory("Plugins")) 140 } 141 addIntentTargets() 142 addOnboardingPrefsCategory() 143 addLayoutSharePref() 144 } 145 146 private fun newCategory(titleText: String, subTitleText: String? = null) = 147 PreferenceCategory(context).apply { 148 title = titleText 149 summary = subTitleText 150 this@DevOptionsUiHelper.addPreference(this) 151 } 152 153 /** Inflates preferences for all server flags in the provider PreferenceGroup */ 154 private fun inflateServerFlags(parent: PreferenceGroup) { 155 val prefs = DeviceConfigHelper.prefs 156 // Sort the keys in the order of modified first followed by natural order 157 val allProps = 158 DeviceConfigHelper.allProps.values 159 .toList() 160 .sortedWith( 161 Comparator.comparingInt { prop: DebugInfo<*> -> 162 if (prefs.contains(prop.key)) 0 else 1 163 } 164 .thenComparing { prop: DebugInfo<*> -> prop.key } 165 ) 166 167 // First add boolean flags 168 allProps.forEach { 169 if (it.isInt) return@forEach 170 val info = it as DebugInfo<Boolean> 171 172 val preference = CustomSwitchPref { holder, pref -> 173 holder.itemView.setOnLongClickListener { 174 prefs.edit().remove(pref.key).apply() 175 pref.setChecked(info.getBoolValue()) 176 summary = info.getSummary() 177 true 178 } 179 } 180 preference.key = info.key 181 preference.isPersistent = false 182 preference.title = info.key 183 preference.summary = info.getSummary() 184 preference.setChecked(prefs.getBoolean(info.key, info.getBoolValue())) 185 preference.setOnPreferenceChangeListener { _, newVal -> 186 prefs.edit().putBoolean(info.key, newVal as Boolean).apply() 187 preference.summary = info.getSummary() 188 true 189 } 190 parent.addPreference(preference) 191 } 192 193 // Apply Int flags 194 allProps.forEach { 195 if (!it.isInt) return@forEach 196 val info = it as DebugInfo<Int> 197 198 val preference = CustomPref { holder, pref -> 199 val textView = holder.findViewById(R.id.pref_edit_text) as ExtendedEditText 200 textView.setText(info.getIntValueAsString()) 201 textView.setOnEditorActionListener { _, actionId, _ -> 202 if (actionId == EditorInfo.IME_ACTION_DONE) { 203 prefs.edit().putInt(pref.key, textView.text.toString().toInt()).apply() 204 pref.summary = info.getSummary() 205 true 206 } 207 false 208 } 209 textView.setOnBackKeyListener { 210 textView.setText(info.getIntValueAsString()) 211 true 212 } 213 214 holder.itemView.setOnLongClickListener { 215 prefs.edit().remove(pref.key).apply() 216 textView.setText(info.getIntValueAsString()) 217 pref.summary = info.getSummary() 218 true 219 } 220 } 221 preference.key = info.key 222 preference.isPersistent = false 223 preference.title = info.key 224 preference.summary = info.getSummary() 225 preference.widgetLayoutResource = R.layout.develop_options_edit_text 226 parent.addPreference(preference) 227 } 228 } 229 230 /** 231 * Returns the summary to show the description and whether the flag overrides the default value. 232 */ 233 private fun DebugInfo<*>.getSummary() = 234 Html.fromHtml( 235 (if (DeviceConfigHelper.prefs.contains(this.key)) 236 "<font color='red'><b>[OVERRIDDEN]</b></font><br>" 237 else "") + this.desc 238 ) 239 240 private fun DebugInfo<Boolean>.getBoolValue() = 241 DeviceConfigHelper.prefs.getBoolean( 242 this.key, 243 DeviceConfig.getBoolean(NAMESPACE_LAUNCHER, this.key, this.valueInCode) 244 ) 245 246 private fun DebugInfo<Int>.getIntValueAsString() = 247 DeviceConfigHelper.prefs 248 .getInt(this.key, DeviceConfig.getInt(NAMESPACE_LAUNCHER, this.key, this.valueInCode)) 249 .toString() 250 251 /** 252 * Inflates the preferences for plugins 253 * 254 * A single pref is added for a plugin-group. A plugin-group is a collection of plugins in a 255 * single apk which have the same android:process tags defined. The apk should also hold the 256 * PLUGIN_PERMISSION. We collect all the plugin intents which Launcher listens for and fetch all 257 * corresponding plugins on the device. When a plugin-group is enabled/disabled we also need to 258 * notify the pluginManager manually since the broadcast-mechanism only works in sysui process 259 */ 260 private fun inflatePluginPrefs(parent: PreferenceGroup) { 261 val manager = PluginManagerWrapper.INSTANCE[context] as PluginManagerWrapperImpl 262 val pm = context.packageManager 263 264 val pluginPermissionApps = 265 pm.getPackagesHoldingPermissions( 266 arrayOf(PLUGIN_PERMISSION), 267 PackageManager.MATCH_DISABLED_COMPONENTS 268 ) 269 .map { it.packageName } 270 271 manager.pluginActions 272 .flatMap { action -> 273 pm.queryIntentServices( 274 Intent(action), 275 PackageManager.MATCH_DISABLED_COMPONENTS or 276 PackageManager.GET_RESOLVED_FILTER 277 ) 278 .filter { pluginPermissionApps.contains(it.serviceInfo.packageName) } 279 } 280 .groupBy { "${it.serviceInfo.packageName}-${it.serviceInfo.processName}" } 281 .values 282 .forEach { infoList -> 283 val pluginInfo = infoList[0]!! 284 val pluginUri = Uri.fromParts("package", pluginInfo.serviceInfo.packageName, null) 285 286 CustomSwitchPref { holder, _ -> 287 holder.itemView.setOnLongClickListener { 288 context.startActivity( 289 Intent(ACTION_APPLICATION_DETAILS_SETTINGS, pluginUri) 290 ) 291 true 292 } 293 } 294 .apply { 295 isPersistent = true 296 title = pluginInfo.loadLabel(pm) 297 isChecked = 298 infoList.all { 299 manager.pluginEnabler.isEnabled(it.serviceInfo.componentName) 300 } 301 summary = 302 infoList 303 .map { it.filter } 304 .filter { it?.countActions() ?: 0 > 0 } 305 .joinToString(prefix = "Plugins: ") { 306 it.getAction(0) 307 .replace("com.android.systemui.action.PLUGIN_", "") 308 .replace("com.android.launcher3.action.PLUGIN_", "") 309 } 310 311 setOnPreferenceChangeListener { _, newVal -> 312 val disabledState = 313 if (newVal as Boolean) PluginEnabler.ENABLED 314 else PluginEnabler.DISABLED_MANUALLY 315 infoList.forEach { 316 manager.pluginEnabler.setDisabled( 317 it.serviceInfo.componentName, 318 disabledState 319 ) 320 } 321 manager.notifyChange(Intent(Intent.ACTION_PACKAGE_CHANGED, pluginUri)) 322 true 323 } 324 325 parent.addPreference(this) 326 } 327 } 328 } 329 330 private fun addIntentTargets() { 331 val launchSandboxIntent = 332 Intent("com.android.quickstep.action.GESTURE_SANDBOX") 333 .setPackage(context.packageName) 334 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 335 newCategory("Gesture Navigation Sandbox").apply { 336 addPreference( 337 Preference(context).apply { 338 title = "Launch Gesture Tutorial Steps menu" 339 intent = Intent(launchSandboxIntent).putExtra("use_tutorial_menu", true) 340 } 341 ) 342 addPreference( 343 Preference(context).apply { 344 title = "Launch Back Tutorial" 345 intent = 346 Intent(launchSandboxIntent) 347 .putExtra("use_tutorial_menu", false) 348 .putExtra("tutorial_steps", arrayOf("BACK_NAVIGATION")) 349 } 350 ) 351 addPreference( 352 Preference(context).apply { 353 title = "Launch Home Tutorial" 354 intent = 355 Intent(launchSandboxIntent) 356 .putExtra("use_tutorial_menu", false) 357 .putExtra("tutorial_steps", arrayOf("HOME_NAVIGATION")) 358 } 359 ) 360 addPreference( 361 Preference(context).apply { 362 title = "Launch Overview Tutorial" 363 intent = 364 Intent(launchSandboxIntent) 365 .putExtra("use_tutorial_menu", false) 366 .putExtra("tutorial_steps", arrayOf("OVERVIEW_NAVIGATION")) 367 } 368 ) 369 } 370 371 newCategory("Other activity targets").apply { 372 addPreference( 373 Preference(context).apply { 374 title = "Launch Secondary Display" 375 intent = 376 Intent(context, SecondaryDisplayLauncher::class.java) 377 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 378 } 379 ) 380 } 381 } 382 383 private fun addOnboardingPrefsCategory() { 384 newCategory("Onboarding Flows").apply { 385 summary = "Reset these if you want to see the education again." 386 addOnboardPref( 387 "All Apps Bounce", 388 HOME_BOUNCE_SEEN.sharedPrefKey, 389 HOME_BOUNCE_COUNT.sharedPrefKey 390 ) 391 addOnboardPref( 392 "Hybrid Hotseat Education", 393 HOTSEAT_DISCOVERY_TIP_COUNT.sharedPrefKey, 394 HOTSEAT_LONGPRESS_TIP_SEEN.sharedPrefKey 395 ) 396 addOnboardPref("Taskbar Education", TASKBAR_EDU_TOOLTIP_STEP.sharedPrefKey) 397 addOnboardPref("All Apps Visited Count", ALL_APPS_VISITED_COUNT.sharedPrefKey) 398 } 399 } 400 401 private fun PreferenceCategory.addOnboardPref(title: String, vararg keys: String) = 402 this.addPreference( 403 Preference(context).also { 404 it.title = title 405 it.summary = "Tap to reset" 406 it.setOnPreferenceClickListener { _ -> 407 LauncherPrefs.getPrefs(context) 408 .edit() 409 .apply { keys.forEach { key -> remove(key) } } 410 .apply() 411 Toast.makeText(context, "Reset $title", Toast.LENGTH_SHORT).show() 412 true 413 } 414 } 415 ) 416 417 private fun addLayoutSharePref() { 418 val model = LauncherAppState.getInstance(context).model 419 val category = newCategory("Workspace grid layout") 420 Preference(context).apply { 421 title = "Export" 422 intent = 423 createUriPickerIntent(ACTION_CREATE_DOCUMENT, MAIN_EXECUTOR) { uri -> 424 model.enqueueModelUpdateTask { _, dataModel, _ -> 425 val builder = LauncherLayoutBuilder() 426 dataModel.workspaceItems.forEach { info -> 427 val loc = 428 when (info.container) { 429 CONTAINER_DESKTOP -> 430 builder.atWorkspace(info.cellX, info.cellY, info.screenId) 431 CONTAINER_HOTSEAT -> builder.atHotseat(info.screenId) 432 else -> return@forEach 433 } 434 loc.addItem(info) 435 } 436 dataModel.appWidgets.forEach { info -> 437 builder.atWorkspace(info.cellX, info.cellY, info.screenId).addItem(info) 438 } 439 440 context.contentResolver.openOutputStream(uri).use { os -> 441 builder.build(OutputStreamWriter(os)) 442 } 443 444 MAIN_EXECUTOR.execute { 445 Toast.makeText(context, "File saved", Toast.LENGTH_LONG).show() 446 } 447 } 448 } 449 category.addPreference(this) 450 } 451 452 Preference(context).apply { 453 title = "Import" 454 intent = 455 createUriPickerIntent(ACTION_OPEN_DOCUMENT, ORDERED_BG_EXECUTOR) { uri -> 456 val resolver = context.contentResolver 457 val data = 458 resolver.openInputStream(uri).use { stream -> 459 stream?.readAllBytes() ?: return@createUriPickerIntent 460 } 461 462 val digest = MessageDigest.getInstance("SHA-256").digest(data) 463 val handle = createWithSha256(digest, LAYOUT_DIGEST_LABEL, 0, LAYOUT_DIGEST_TAG) 464 val blobManager = context.getSystemService(BlobStoreManager::class.java)!! 465 466 blobManager.openSession(blobManager.createSession(handle)).use { session -> 467 AutoCloseOutputStream(session.openWrite(0, -1)).use { it.write(data) } 468 session.allowPublicAccess() 469 470 session.commit(ORDERED_BG_EXECUTOR) { 471 val key = Base64.encodeToString(digest, NO_WRAP or NO_PADDING) 472 Secure.putString(resolver, LAYOUT_DIGEST_KEY, key) 473 474 MODEL_EXECUTOR.submit { model.modelDbController.createEmptyDB() }.get() 475 MAIN_EXECUTOR.submit { model.forceReload() }.get() 476 MODEL_EXECUTOR.submit {}.get() 477 Secure.putString(resolver, LAYOUT_DIGEST_KEY, null) 478 } 479 } 480 } 481 category.addPreference(this) 482 } 483 } 484 485 private fun LauncherLayoutBuilder.ItemTarget.addItem(info: ItemInfo) { 486 val userType: String? = 487 when (UserCache.INSTANCE.get(context).getUserInfo(info.user).type) { 488 UserIconInfo.TYPE_WORK -> AutoInstallsLayout.USER_TYPE_WORK 489 UserIconInfo.TYPE_CLONED -> AutoInstallsLayout.USER_TYPE_CLONED 490 else -> null 491 } 492 when (info.itemType) { 493 ITEM_TYPE_APPLICATION -> 494 info.targetComponent?.let { c -> putApp(c.packageName, c.className, userType) } 495 ITEM_TYPE_DEEP_SHORTCUT -> 496 ShortcutKey.fromItemInfo(info).let { key -> 497 putShortcut(key.packageName, key.id, userType) 498 } 499 ITEM_TYPE_FOLDER -> 500 (info as FolderInfo).let { folderInfo -> 501 putFolder(folderInfo.title?.toString() ?: "").also { folderBuilder -> 502 folderInfo.getContents().forEach { folderContent -> 503 folderBuilder.addItem(folderContent) 504 } 505 } 506 } 507 ITEM_TYPE_APPWIDGET -> 508 putWidget( 509 (info as LauncherAppWidgetInfo).providerName.packageName, 510 info.providerName.className, 511 info.spanX, 512 info.spanY, 513 userType 514 ) 515 } 516 } 517 518 private fun createUriPickerIntent( 519 action: String, 520 executor: Executor, 521 callback: (uri: Uri) -> Unit 522 ): Intent { 523 val pendingIntent = 524 PendingIntent( 525 object : Stub() { 526 override fun send( 527 code: Int, 528 intent: Intent, 529 resolvedType: String?, 530 allowlistToken: IBinder?, 531 finishedReceiver: IIntentReceiver?, 532 requiredPermission: String?, 533 options: Bundle? 534 ) { 535 intent.data?.let { uri -> executor.execute { callback(uri) } } 536 } 537 } 538 ) 539 val params = StartActivityParams(pendingIntent, 0) 540 params.intent = 541 Intent(action) 542 .addCategory(Intent.CATEGORY_OPENABLE) 543 .setType("text/xml") 544 .putExtra(Intent.EXTRA_TITLE, "launcher_grid.xml") 545 return ProxyActivityStarter.getLaunchIntent(context, params) 546 } 547 548 private inner class CustomSwitchPref( 549 private val bindCallback: (holder: PreferenceViewHolder, pref: SwitchPreference) -> Unit 550 ) : SwitchPreference(context) { 551 552 override fun onBindViewHolder(holder: PreferenceViewHolder) { 553 super.onBindViewHolder(holder) 554 bindCallback.invoke(holder, this) 555 } 556 } 557 558 private inner class CustomPref( 559 private val bindCallback: (holder: PreferenceViewHolder, pref: Preference) -> Unit 560 ) : Preference(context) { 561 562 override fun onBindViewHolder(holder: PreferenceViewHolder) { 563 super.onBindViewHolder(holder) 564 bindCallback.invoke(holder, this) 565 } 566 } 567 568 companion object { 569 const val TAG = "DeviceConfigUIHelper" 570 571 const val PLUGIN_PERMISSION = "com.android.systemui.permission.PLUGIN" 572 } 573 } 574