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