• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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