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.view.inputmethod.EditorInfo 39 import android.widget.TextView 40 import android.widget.Toast 41 import androidx.core.widget.doAfterTextChanged 42 import androidx.preference.Preference 43 import androidx.preference.PreferenceCategory 44 import androidx.preference.PreferenceGroup 45 import androidx.preference.PreferenceViewHolder 46 import androidx.preference.SwitchPreference 47 import com.android.launcher3.AutoInstallsLayout 48 import com.android.launcher3.ExtendedEditText 49 import com.android.launcher3.LauncherAppState 50 import com.android.launcher3.LauncherPrefs 51 import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_DESKTOP 52 import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_HOTSEAT 53 import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION 54 import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET 55 import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT 56 import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_FOLDER 57 import com.android.launcher3.LauncherSettings.Settings.LAYOUT_DIGEST_LABEL 58 import com.android.launcher3.LauncherSettings.Settings.LAYOUT_DIGEST_TAG 59 import com.android.launcher3.LauncherSettings.Settings.LAYOUT_PROVIDER_KEY 60 import com.android.launcher3.LauncherSettings.Settings.createBlobProviderKey 61 import com.android.launcher3.R 62 import com.android.launcher3.model.data.FolderInfo 63 import com.android.launcher3.model.data.ItemInfo 64 import com.android.launcher3.model.data.LauncherAppWidgetInfo 65 import com.android.launcher3.pm.UserCache 66 import com.android.launcher3.proxy.ProxyActivityStarter 67 import com.android.launcher3.secondarydisplay.SecondaryDisplayLauncher 68 import com.android.launcher3.shortcuts.ShortcutKey 69 import com.android.launcher3.uioverrides.plugins.PluginManagerWrapperImpl 70 import com.android.launcher3.util.Executors.MAIN_EXECUTOR 71 import com.android.launcher3.util.Executors.MODEL_EXECUTOR 72 import com.android.launcher3.util.Executors.ORDERED_BG_EXECUTOR 73 import com.android.launcher3.util.LauncherLayoutBuilder 74 import com.android.launcher3.util.OnboardingPrefs.ALL_APPS_VISITED_COUNT 75 import com.android.launcher3.util.OnboardingPrefs.HOME_BOUNCE_COUNT 76 import com.android.launcher3.util.OnboardingPrefs.HOME_BOUNCE_SEEN 77 import com.android.launcher3.util.OnboardingPrefs.HOTSEAT_DISCOVERY_TIP_COUNT 78 import com.android.launcher3.util.OnboardingPrefs.HOTSEAT_LONGPRESS_TIP_SEEN 79 import com.android.launcher3.util.OnboardingPrefs.TASKBAR_EDU_TOOLTIP_STEP 80 import com.android.launcher3.util.OnboardingPrefs.TASKBAR_SEARCH_EDU_SEEN 81 import com.android.launcher3.util.PluginManagerWrapper 82 import com.android.launcher3.util.StartActivityParams 83 import com.android.launcher3.util.UserIconInfo 84 import com.android.quickstep.util.DeviceConfigHelper 85 import com.android.quickstep.util.DeviceConfigHelper.Companion.NAMESPACE_LAUNCHER 86 import com.android.quickstep.util.DeviceConfigHelper.DebugInfo 87 import com.android.systemui.shared.plugins.PluginEnabler 88 import com.android.systemui.shared.plugins.PluginPrefs 89 import java.io.OutputStreamWriter 90 import java.security.MessageDigest 91 import java.util.Locale 92 import java.util.concurrent.Executor 93 94 /** Helper class to generate UI for Device Config */ 95 class DevOptionsUiHelper(c: Context, attr: AttributeSet?) : PreferenceGroup(c, attr) { 96 97 init { 98 layoutResource = R.layout.developer_options_top_bar 99 isPersistent = false 100 } 101 102 override fun onBindViewHolder(holder: PreferenceViewHolder) { 103 super.onBindViewHolder(holder) 104 105 // Initialize search 106 (holder.findViewById(R.id.filter_box) as TextView?)?.doAfterTextChanged { 107 val query: String = it.toString().lowercase(Locale.getDefault()).replace("_", " ") 108 filterPreferences(query, this) 109 110 // Always keep myself visible 111 this@DevOptionsUiHelper.isVisible = true 112 } 113 } 114 115 private fun filterPreferences(query: String, pg: PreferenceGroup) { 116 val count = pg.preferenceCount 117 var visible = false 118 for (i in 0 until count) { 119 val preference = pg.getPreference(i) 120 if (preference is PreferenceGroup) { 121 filterPreferences(query, preference) 122 } else { 123 val title = 124 preference.title.toString().lowercase(Locale.getDefault()).replace("_", " ") 125 preference.isVisible = query.isEmpty() || title.contains(query) 126 } 127 visible = visible or preference.isVisible 128 } 129 pg.isVisible = visible 130 } 131 132 override fun onAttached() { 133 super.onAttached() 134 135 removeAll() 136 inflateServerFlags(newCategory("Server flags", "Long press to reset")) 137 if (PluginPrefs.hasPlugins(context)) { 138 inflatePluginPrefs(newCategory("Plugins")) 139 } 140 addIntentTargets() 141 addOnboardingPrefsCategory() 142 addLayoutSharePref() 143 } 144 145 private fun newCategory(titleText: String, subTitleText: String? = null) = 146 PreferenceCategory(context).apply { 147 title = titleText 148 summary = subTitleText 149 this@DevOptionsUiHelper.addPreference(this) 150 } 151 152 /** Inflates preferences for all server flags in the provider PreferenceGroup */ 153 private fun inflateServerFlags(parent: PreferenceGroup) { 154 val prefs = DeviceConfigHelper.prefs 155 // Sort the keys in the order of modified first followed by natural order 156 val allProps = 157 DeviceConfigHelper.allProps.values 158 .toList() 159 .sortedWith( 160 Comparator.comparingInt { prop: DebugInfo<*> -> 161 if (prefs.contains(prop.key)) 0 else 1 162 } 163 .thenComparing { prop: DebugInfo<*> -> prop.key } 164 ) 165 166 // First add boolean flags 167 allProps.forEach { 168 if (it.isInt) return@forEach 169 val info = it as DebugInfo<Boolean> 170 171 val preference = CustomSwitchPref { holder, pref -> 172 holder.itemView.setOnLongClickListener { 173 prefs.edit().remove(pref.key).apply() 174 pref.setChecked(info.getBoolValue()) 175 summary = info.getSummary() 176 true 177 } 178 } 179 preference.key = info.key 180 preference.isPersistent = false 181 preference.title = info.key 182 preference.summary = info.getSummary() 183 preference.setChecked(prefs.getBoolean(info.key, info.getBoolValue())) 184 preference.setOnPreferenceChangeListener { _, newVal -> 185 prefs.edit().putBoolean(info.key, newVal as Boolean).apply() 186 preference.summary = info.getSummary() 187 true 188 } 189 parent.addPreference(preference) 190 } 191 192 // Apply Int flags 193 allProps.forEach { 194 if (!it.isInt) return@forEach 195 val info = it as DebugInfo<Int> 196 197 val preference = CustomPref { holder, pref -> 198 val textView = holder.findViewById(R.id.pref_edit_text) as ExtendedEditText 199 textView.setText(info.getIntValueAsString()) 200 textView.setOnEditorActionListener { _, actionId, _ -> 201 if (actionId == EditorInfo.IME_ACTION_DONE) { 202 prefs.edit().putInt(pref.key, textView.text.toString().toInt()).apply() 203 pref.summary = info.getSummary() 204 true 205 } 206 false 207 } 208 textView.setOnBackKeyListener { 209 textView.setText(info.getIntValueAsString()) 210 true 211 } 212 213 holder.itemView.setOnLongClickListener { 214 prefs.edit().remove(pref.key).apply() 215 textView.setText(info.getIntValueAsString()) 216 pref.summary = info.getSummary() 217 true 218 } 219 } 220 preference.key = info.key 221 preference.isPersistent = false 222 preference.title = info.key 223 preference.summary = info.getSummary() 224 preference.widgetLayoutResource = R.layout.develop_options_edit_text 225 parent.addPreference(preference) 226 } 227 } 228 229 /** 230 * Returns the summary to show the description and whether the flag overrides the default value. 231 */ 232 private fun DebugInfo<*>.getSummary() = 233 Html.fromHtml( 234 (if (DeviceConfigHelper.prefs.contains(this.key)) 235 "<font color='red'><b>[OVERRIDDEN]</b></font><br>" 236 else "") + this.desc 237 ) 238 239 private fun DebugInfo<Boolean>.getBoolValue() = 240 DeviceConfigHelper.prefs.getBoolean( 241 this.key, 242 DeviceConfig.getBoolean(NAMESPACE_LAUNCHER, this.key, this.valueInCode), 243 ) 244 245 private fun DebugInfo<Int>.getIntValueAsString() = 246 DeviceConfigHelper.prefs 247 .getInt(this.key, DeviceConfig.getInt(NAMESPACE_LAUNCHER, this.key, this.valueInCode)) 248 .toString() 249 250 /** 251 * Inflates the preferences for plugins 252 * 253 * A single pref is added for a plugin-group. A plugin-group is a collection of plugins in a 254 * single apk which have the same android:process tags defined. The apk should also hold the 255 * PLUGIN_PERMISSION. We collect all the plugin intents which Launcher listens for and fetch all 256 * corresponding plugins on the device. When a plugin-group is enabled/disabled we also need to 257 * notify the pluginManager manually since the broadcast-mechanism only works in sysui process 258 */ 259 private fun inflatePluginPrefs(parent: PreferenceGroup) { 260 val manager = PluginManagerWrapper.INSTANCE[context] as PluginManagerWrapperImpl 261 val pm = context.packageManager 262 263 val pluginPermissionApps = 264 pm.getPackagesHoldingPermissions( 265 arrayOf(PLUGIN_PERMISSION), 266 PackageManager.MATCH_DISABLED_COMPONENTS, 267 ) 268 .map { it.packageName } 269 270 manager.pluginActions 271 .flatMap { action -> 272 pm.queryIntentServices( 273 Intent(action), 274 PackageManager.MATCH_DISABLED_COMPONENTS or 275 PackageManager.GET_RESOLVED_FILTER, 276 ) 277 .filter { pluginPermissionApps.contains(it.serviceInfo.packageName) } 278 } 279 .groupBy { "${it.serviceInfo.packageName}-${it.serviceInfo.processName}" } 280 .values 281 .forEach { infoList -> 282 val pluginInfo = infoList[0]!! 283 val pluginUri = Uri.fromParts("package", pluginInfo.serviceInfo.packageName, null) 284 285 CustomSwitchPref { holder, _ -> 286 holder.itemView.setOnLongClickListener { 287 context.startActivity( 288 Intent(ACTION_APPLICATION_DETAILS_SETTINGS, pluginUri) 289 ) 290 true 291 } 292 } 293 .apply { 294 isPersistent = true 295 title = pluginInfo.loadLabel(pm) 296 isChecked = 297 infoList.all { 298 manager.pluginEnabler.isEnabled(it.serviceInfo.componentName) 299 } 300 summary = 301 infoList 302 .map { it.filter } 303 .filter { it?.countActions() ?: 0 > 0 } 304 .joinToString(prefix = "Plugins: ") { 305 it.getAction(0) 306 .replace("com.android.systemui.action.PLUGIN_", "") 307 .replace("com.android.launcher3.action.PLUGIN_", "") 308 } 309 310 setOnPreferenceChangeListener { _, newVal -> 311 val disabledState = 312 if (newVal as Boolean) PluginEnabler.ENABLED 313 else PluginEnabler.DISABLED_MANUALLY 314 infoList.forEach { 315 manager.pluginEnabler.setDisabled( 316 it.serviceInfo.componentName, 317 disabledState, 318 ) 319 } 320 manager.notifyChange(Intent(Intent.ACTION_PACKAGE_CHANGED, pluginUri)) 321 true 322 } 323 324 parent.addPreference(this) 325 } 326 } 327 } 328 329 private fun addIntentTargets() { 330 val launchSandboxIntent = 331 Intent("com.android.quickstep.action.GESTURE_SANDBOX") 332 .setPackage(context.packageName) 333 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 334 newCategory("Gesture Navigation Sandbox").apply { 335 addPreference( 336 Preference(context).apply { 337 title = "Launch Gesture Tutorial Steps menu" 338 intent = Intent(launchSandboxIntent).putExtra("use_tutorial_menu", true) 339 } 340 ) 341 addPreference( 342 Preference(context).apply { 343 title = "Launch Back Tutorial" 344 intent = 345 Intent(launchSandboxIntent) 346 .putExtra("use_tutorial_menu", false) 347 .putExtra("tutorial_steps", arrayOf("BACK_NAVIGATION")) 348 } 349 ) 350 addPreference( 351 Preference(context).apply { 352 title = "Launch Home Tutorial" 353 intent = 354 Intent(launchSandboxIntent) 355 .putExtra("use_tutorial_menu", false) 356 .putExtra("tutorial_steps", arrayOf("HOME_NAVIGATION")) 357 } 358 ) 359 addPreference( 360 Preference(context).apply { 361 title = "Launch Overview Tutorial" 362 intent = 363 Intent(launchSandboxIntent) 364 .putExtra("use_tutorial_menu", false) 365 .putExtra("tutorial_steps", arrayOf("OVERVIEW_NAVIGATION")) 366 } 367 ) 368 } 369 370 newCategory("Other activity targets").apply { 371 addPreference( 372 Preference(context).apply { 373 title = "Launch Secondary Display" 374 intent = 375 Intent(context, SecondaryDisplayLauncher::class.java) 376 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 377 } 378 ) 379 } 380 } 381 382 private fun addOnboardingPrefsCategory() { 383 newCategory("Onboarding Flows").apply { 384 summary = "Reset these if you want to see the education again." 385 addOnboardPref( 386 "All Apps Bounce", 387 HOME_BOUNCE_SEEN.sharedPrefKey, 388 HOME_BOUNCE_COUNT.sharedPrefKey, 389 ) 390 addOnboardPref( 391 "Hybrid Hotseat Education", 392 HOTSEAT_DISCOVERY_TIP_COUNT.sharedPrefKey, 393 HOTSEAT_LONGPRESS_TIP_SEEN.sharedPrefKey, 394 ) 395 addOnboardPref("Taskbar Education", TASKBAR_EDU_TOOLTIP_STEP.sharedPrefKey) 396 addOnboardPref("Taskbar Search Education", TASKBAR_SEARCH_EDU_SEEN.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 Secure.putString( 472 resolver, 473 LAYOUT_PROVIDER_KEY, 474 createBlobProviderKey(digest), 475 ) 476 477 MODEL_EXECUTOR.submit { model.modelDbController.createEmptyDB() }.get() 478 MAIN_EXECUTOR.submit { model.forceReload() }.get() 479 MODEL_EXECUTOR.submit {}.get() 480 Secure.putString(resolver, LAYOUT_PROVIDER_KEY, null) 481 } 482 } 483 } 484 category.addPreference(this) 485 } 486 } 487 488 private fun LauncherLayoutBuilder.ItemTarget.addItem(info: ItemInfo) { 489 val userType: String? = 490 when (UserCache.INSTANCE.get(context).getUserInfo(info.user).type) { 491 UserIconInfo.TYPE_WORK -> AutoInstallsLayout.USER_TYPE_WORK 492 UserIconInfo.TYPE_CLONED -> AutoInstallsLayout.USER_TYPE_CLONED 493 else -> null 494 } 495 when (info.itemType) { 496 ITEM_TYPE_APPLICATION -> 497 info.targetComponent?.let { c -> putApp(c.packageName, c.className, userType) } 498 ITEM_TYPE_DEEP_SHORTCUT -> 499 ShortcutKey.fromItemInfo(info).let { key -> 500 putShortcut(key.packageName, key.id, userType) 501 } 502 ITEM_TYPE_FOLDER -> 503 (info as FolderInfo).let { folderInfo -> 504 putFolder(folderInfo.title?.toString() ?: "").also { folderBuilder -> 505 folderInfo.getContents().forEach { folderContent -> 506 folderBuilder.addItem(folderContent) 507 } 508 } 509 } 510 ITEM_TYPE_APPWIDGET -> 511 putWidget( 512 (info as LauncherAppWidgetInfo).providerName.packageName, 513 info.providerName.className, 514 info.spanX, 515 info.spanY, 516 userType, 517 ) 518 } 519 } 520 521 private fun createUriPickerIntent( 522 action: String, 523 executor: Executor, 524 callback: (uri: Uri) -> Unit, 525 ): Intent { 526 val pendingIntent = 527 PendingIntent( 528 object : Stub() { 529 override fun send( 530 code: Int, 531 intent: Intent, 532 resolvedType: String?, 533 allowlistToken: IBinder?, 534 finishedReceiver: IIntentReceiver?, 535 requiredPermission: String?, 536 options: Bundle?, 537 ) { 538 intent.data?.let { uri -> executor.execute { callback(uri) } } 539 } 540 } 541 ) 542 val params = StartActivityParams(pendingIntent, 0) 543 params.intent = 544 Intent(action) 545 .addCategory(Intent.CATEGORY_OPENABLE) 546 .setType("text/xml") 547 .putExtra(Intent.EXTRA_TITLE, "launcher_grid.xml") 548 return ProxyActivityStarter.getLaunchIntent(context, params) 549 } 550 551 private inner class CustomSwitchPref( 552 private val bindCallback: (holder: PreferenceViewHolder, pref: SwitchPreference) -> Unit 553 ) : SwitchPreference(context) { 554 555 override fun onBindViewHolder(holder: PreferenceViewHolder) { 556 super.onBindViewHolder(holder) 557 bindCallback.invoke(holder, this) 558 } 559 } 560 561 private inner class CustomPref( 562 private val bindCallback: (holder: PreferenceViewHolder, pref: Preference) -> Unit 563 ) : Preference(context) { 564 565 override fun onBindViewHolder(holder: PreferenceViewHolder) { 566 super.onBindViewHolder(holder) 567 bindCallback.invoke(holder, this) 568 } 569 } 570 571 companion object { 572 const val TAG = "DeviceConfigUIHelper" 573 574 const val PLUGIN_PERMISSION = "com.android.systemui.permission.PLUGIN" 575 } 576 } 577