1 /*
2  * Copyright (C) 2023 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.settings.accessibility.shortcuts;
18 
19 import static android.app.Activity.RESULT_CANCELED;
20 import static android.provider.Settings.Secure.ACCESSIBILITY_BUTTON_MODE;
21 import static android.provider.Settings.Secure.ACCESSIBILITY_BUTTON_TARGETS;
22 import static android.provider.Settings.Secure.ACCESSIBILITY_DISPLAY_MAGNIFICATION_ENABLED;
23 import static android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_TWO_FINGER_TRIPLE_TAP_ENABLED;
24 import static android.provider.Settings.Secure.ACCESSIBILITY_QS_TARGETS;
25 import static android.provider.Settings.Secure.ACCESSIBILITY_SHORTCUT_TARGET_SERVICE;
26 
27 import static com.android.internal.accessibility.AccessibilityShortcutController.MAGNIFICATION_COMPONENT_NAME;
28 import static com.android.internal.accessibility.AccessibilityShortcutController.MAGNIFICATION_CONTROLLER_NAME;
29 import static com.android.settings.SettingsActivity.EXTRA_SHOW_FRAGMENT_TITLE;
30 
31 import android.app.Activity;
32 import android.app.settings.SettingsEnums;
33 import android.content.ComponentName;
34 import android.content.Context;
35 import android.content.Intent;
36 import android.content.res.Resources;
37 import android.database.ContentObserver;
38 import android.icu.text.ListFormatter;
39 import android.net.Uri;
40 import android.os.Bundle;
41 import android.os.Handler;
42 import android.provider.Settings;
43 import android.text.TextUtils;
44 import android.util.ArrayMap;
45 import android.util.Pair;
46 import android.view.LayoutInflater;
47 import android.view.View;
48 import android.view.ViewGroup;
49 import android.view.accessibility.AccessibilityManager;
50 
51 import androidx.annotation.NonNull;
52 import androidx.annotation.Nullable;
53 import androidx.annotation.VisibleForTesting;
54 import androidx.preference.Preference;
55 import androidx.recyclerview.widget.RecyclerView;
56 
57 import com.android.internal.accessibility.common.ShortcutConstants;
58 import com.android.internal.accessibility.dialog.AccessibilityTarget;
59 import com.android.internal.accessibility.dialog.AccessibilityTargetHelper;
60 import com.android.settings.R;
61 import com.android.settings.SetupWizardUtils;
62 import com.android.settings.accessibility.AccessibilitySetupWizardUtils;
63 import com.android.settings.accessibility.Flags;
64 import com.android.settings.accessibility.PreferredShortcuts;
65 import com.android.settings.core.SubSettingLauncher;
66 import com.android.settings.dashboard.DashboardFragment;
67 import com.android.settingslib.core.AbstractPreferenceController;
68 
69 import com.google.android.setupcompat.template.FooterBarMixin;
70 import com.google.android.setupcompat.util.WizardManagerHelper;
71 import com.google.android.setupdesign.GlifPreferenceLayout;
72 
73 import java.util.ArrayList;
74 import java.util.Collection;
75 import java.util.List;
76 import java.util.Map;
77 import java.util.Set;
78 
79 /**
80  * A screen show various accessibility shortcut options for the given a11y feature
81  */
82 public class EditShortcutsPreferenceFragment extends DashboardFragment {
83     private static final String TAG = "EditShortcutsPreferenceFragment";
84 
85     @VisibleForTesting
86     static final String ARG_KEY_SHORTCUT_TARGETS = "targets";
87     @VisibleForTesting
88     static final String SAVED_STATE_IS_EXPANDED = "isExpanded";
89     private ContentObserver mSettingsObserver;
90 
91     private static final Uri VOLUME_KEYS_SHORTCUT_SETTING =
92             Settings.Secure.getUriFor(ACCESSIBILITY_SHORTCUT_TARGET_SERVICE);
93     private static final Uri BUTTON_SHORTCUT_MODE_SETTING =
94             Settings.Secure.getUriFor(ACCESSIBILITY_BUTTON_MODE);
95     private static final Uri BUTTON_SHORTCUT_SETTING =
96             Settings.Secure.getUriFor(ACCESSIBILITY_BUTTON_TARGETS);
97 
98     private static final Uri TRIPLE_TAP_SHORTCUT_SETTING =
99             Settings.Secure.getUriFor(ACCESSIBILITY_DISPLAY_MAGNIFICATION_ENABLED);
100     private static final Uri TWO_FINGERS_DOUBLE_TAP_SHORTCUT_SETTING =
101             Settings.Secure.getUriFor(ACCESSIBILITY_MAGNIFICATION_TWO_FINGER_TRIPLE_TAP_ENABLED);
102 
103     private static final Uri QUICK_SETTINGS_SHORTCUT_SETTING =
104             Settings.Secure.getUriFor(ACCESSIBILITY_QS_TARGETS);
105 
106     @VisibleForTesting
107     static final Uri[] SHORTCUT_SETTINGS = {
108             VOLUME_KEYS_SHORTCUT_SETTING,
109             BUTTON_SHORTCUT_MODE_SETTING,
110             BUTTON_SHORTCUT_SETTING,
111             TRIPLE_TAP_SHORTCUT_SETTING,
112             TWO_FINGERS_DOUBLE_TAP_SHORTCUT_SETTING,
113             QUICK_SETTINGS_SHORTCUT_SETTING,
114     };
115 
116     private Set<String> mShortcutTargets;
117 
118     @Nullable
119     private AccessibilityManager.TouchExplorationStateChangeListener
120             mTouchExplorationStateChangeListener;
121 
122 
123     /**
124      * Helper method to show the edit shortcut screen
125      */
showEditShortcutScreen( Context context, int metricsCategory, CharSequence screenTitle, ComponentName target, Intent fromIntent)126     public static void showEditShortcutScreen(
127             Context context, int metricsCategory, CharSequence screenTitle,
128             ComponentName target, Intent fromIntent) {
129         Bundle args = new Bundle();
130 
131         if (MAGNIFICATION_COMPONENT_NAME.equals(target)) {
132             // We can remove this branch once b/147990389 is completed
133             args.putStringArray(
134                     ARG_KEY_SHORTCUT_TARGETS, new String[]{MAGNIFICATION_CONTROLLER_NAME});
135         } else {
136             args.putStringArray(
137                     ARG_KEY_SHORTCUT_TARGETS, new String[]{target.flattenToString()});
138         }
139         Intent toIntent = new Intent();
140         if (fromIntent != null) {
141             SetupWizardUtils.copySetupExtras(fromIntent, toIntent);
142         }
143 
144         new SubSettingLauncher(context)
145                 .setDestination(EditShortcutsPreferenceFragment.class.getName())
146                 .setExtras(toIntent.getExtras())
147                 .setArguments(args)
148                 .setSourceMetricsCategory(metricsCategory)
149                 .setTitleText(screenTitle)
150                 .launch();
151     }
152 
153     @Override
onAttach(Context context)154     public void onAttach(Context context) {
155         super.onAttach(context);
156         initializeArguments();
157         initializePreferenceControllerArguments();
158     }
159 
160     @Override
onCreate(Bundle savedInstanceState)161     public void onCreate(Bundle savedInstanceState) {
162         super.onCreate(savedInstanceState);
163         if (savedInstanceState != null) {
164             boolean isExpanded = savedInstanceState.getBoolean(SAVED_STATE_IS_EXPANDED);
165             if (isExpanded) {
166                 onExpanded();
167             }
168         }
169         mSettingsObserver = new ContentObserver(new Handler()) {
170             @Override
171             public void onChange(boolean selfChange, Uri uri) {
172                 if (VOLUME_KEYS_SHORTCUT_SETTING.equals(uri)) {
173                     refreshPreferenceController(VolumeKeysShortcutOptionController.class);
174                 } else if (BUTTON_SHORTCUT_MODE_SETTING.equals(uri)
175                         || BUTTON_SHORTCUT_SETTING.equals(uri)) {
176                     refreshSoftwareShortcutControllers();
177                 } else if (TRIPLE_TAP_SHORTCUT_SETTING.equals(uri)) {
178                     refreshPreferenceController(TripleTapShortcutOptionController.class);
179                 } else if (TWO_FINGERS_DOUBLE_TAP_SHORTCUT_SETTING.equals(uri)) {
180                     refreshPreferenceController(TwoFingerDoubleTapShortcutOptionController.class);
181                 } else if (QUICK_SETTINGS_SHORTCUT_SETTING.equals(uri)) {
182                     refreshPreferenceController(QuickSettingsShortcutOptionController.class);
183                 }
184 
185                 if (getContext() != null) {
186                     PreferredShortcuts.updatePreferredShortcutsFromSettings(
187                             getContext(), mShortcutTargets);
188                 }
189             }
190         };
191 
192         registerSettingsObserver();
193     }
194 
195     @Override
onCreatePreferences(Bundle savedInstanceState, String rootKey)196     public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
197         super.onCreatePreferences(savedInstanceState, rootKey);
198 
199         Activity activity = getActivity();
200 
201         if (!activity.getIntent().getAction().equals(
202                 Settings.ACTION_ACCESSIBILITY_SHORTCUT_SETTINGS)
203                 || !Flags.editShortcutsInFullScreen()) {
204             return;
205         }
206 
207         // TODO(b/325664350): Implement shortcut type for "all shortcuts"
208         List<AccessibilityTarget> accessibilityTargets =
209                 AccessibilityTargetHelper.getInstalledTargets(
210                         activity.getBaseContext(), ShortcutConstants.UserShortcutType.HARDWARE);
211 
212         Pair<String, String> titles = getTitlesFromAccessibilityTargetList(
213                 mShortcutTargets,
214                 accessibilityTargets,
215                 activity.getResources()
216         );
217 
218         activity.setTitle(titles.first);
219 
220         String screenDescriptionPrefKey = getString(
221                 R.string.accessibility_shortcut_description_pref);
222         findPreference(screenDescriptionPrefKey).setSummary(titles.second);
223     }
224 
225     @NonNull
226     @Override
onCreateRecyclerView( @onNull LayoutInflater inflater, @NonNull ViewGroup parent, @Nullable Bundle savedInstanceState)227     public RecyclerView onCreateRecyclerView(
228             @NonNull LayoutInflater inflater, @NonNull ViewGroup parent,
229             @Nullable Bundle savedInstanceState) {
230         if (parent instanceof GlifPreferenceLayout layout) {
231             // Usually for setup wizard
232             return layout.onCreateRecyclerView(inflater, parent, savedInstanceState);
233         } else {
234             return super.onCreateRecyclerView(inflater, parent, savedInstanceState);
235         }
236     }
237 
238     @Override
onViewCreated(@onNull View view, @Nullable Bundle savedInstanceState)239     public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
240         super.onViewCreated(view, savedInstanceState);
241 
242         if (view instanceof GlifPreferenceLayout layout) {
243             // Usually for setup wizard
244             String title = null;
245             Intent intent = getIntent();
246             if (intent != null) {
247                 title = intent.getStringExtra(EXTRA_SHOW_FRAGMENT_TITLE);
248             }
249             AccessibilitySetupWizardUtils.updateGlifPreferenceLayout(getContext(), layout, title,
250                     /* description= */ null, /* icon= */ null);
251 
252             FooterBarMixin mixin = layout.getMixin(FooterBarMixin.class);
253             AccessibilitySetupWizardUtils.setPrimaryButton(getContext(), mixin, R.string.done,
254                     () -> {
255                         setResult(RESULT_CANCELED);
256                         finish();
257                     });
258         }
259     }
260 
261     @Override
onResume()262     public void onResume() {
263         super.onResume();
264         mTouchExplorationStateChangeListener = isTouchExplorationEnabled -> {
265             refreshPreferenceController(QuickSettingsShortcutOptionController.class);
266             refreshPreferenceController(GestureShortcutOptionController.class);
267         };
268 
269         final AccessibilityManager am = getSystemService(
270                 AccessibilityManager.class);
271         am.addTouchExplorationStateChangeListener(mTouchExplorationStateChangeListener);
272         PreferredShortcuts.updatePreferredShortcutsFromSettings(getContext(), mShortcutTargets);
273     }
274 
275     @Override
onPause()276     public void onPause() {
277         super.onPause();
278 
279         if (mTouchExplorationStateChangeListener != null) {
280             final AccessibilityManager am = getSystemService(
281                     AccessibilityManager.class);
282             am.removeTouchExplorationStateChangeListener(mTouchExplorationStateChangeListener);
283         }
284     }
285 
286     @Override
onSaveInstanceState(Bundle outState)287     public void onSaveInstanceState(Bundle outState) {
288         super.onSaveInstanceState(outState);
289         outState.putBoolean(
290                 SAVED_STATE_IS_EXPANDED,
291                 use(AdvancedShortcutsPreferenceController.class).isExpanded());
292     }
293 
294     @Override
onDestroy()295     public void onDestroy() {
296         super.onDestroy();
297         unregisterSettingsObserver();
298     }
299 
registerSettingsObserver()300     private void registerSettingsObserver() {
301         if (mSettingsObserver != null) {
302             for (Uri uri : SHORTCUT_SETTINGS) {
303                 getContentResolver().registerContentObserver(
304                         uri, /* notifyForDescendants= */ false, mSettingsObserver);
305             }
306         }
307     }
308 
unregisterSettingsObserver()309     private void unregisterSettingsObserver() {
310         if (mSettingsObserver != null) {
311             getContentResolver().unregisterContentObserver(mSettingsObserver);
312         }
313     }
314 
initializeArguments()315     private void initializeArguments() {
316         Bundle args = getArguments();
317         if (args == null || args.isEmpty()) {
318             throw new IllegalArgumentException(
319                     EditShortcutsPreferenceFragment.class.getSimpleName()
320                             + " requires non-empty shortcut targets");
321         }
322 
323         String[] targets = args.getStringArray(ARG_KEY_SHORTCUT_TARGETS);
324         if (targets == null) {
325             throw new IllegalArgumentException(
326                     EditShortcutsPreferenceFragment.class.getSimpleName()
327                             + " requires non-empty shortcut targets");
328         }
329 
330         mShortcutTargets = Set.of(targets);
331     }
332 
333     @Override
getMetricsCategory()334     public int getMetricsCategory() {
335         return SettingsEnums.DIALOG_ACCESSIBILITY_SERVICE_EDIT_SHORTCUT;
336     }
337 
338     @Override
getPreferenceScreenResId()339     protected int getPreferenceScreenResId() {
340         return R.xml.accessibility_edit_shortcuts;
341     }
342 
343     @Override
getLogTag()344     protected String getLogTag() {
345         return TAG;
346     }
347 
348     @Override
onPreferenceTreeClick(Preference preference)349     public boolean onPreferenceTreeClick(Preference preference) {
350         if (getString(R.string.accessibility_shortcuts_advanced_collapsed)
351                 .equals(preference.getKey())) {
352             onExpanded();
353             // log here since calling super.onPreferenceTreeClick will be skipped
354             writePreferenceClickMetric(preference);
355             return true;
356         }
357         return super.onPreferenceTreeClick(preference);
358     }
359 
360     @VisibleForTesting
initializePreferenceControllerArguments()361     void initializePreferenceControllerArguments() {
362         boolean isInSuw = WizardManagerHelper.isAnySetupWizard(getIntent());
363 
364         getPreferenceControllers()
365                 .stream()
366                 .flatMap(Collection::stream)
367                 .filter(
368                         controller -> controller instanceof ShortcutOptionPreferenceController)
369                 .forEach(controller -> {
370                     ShortcutOptionPreferenceController shortcutOptionPreferenceController =
371                             (ShortcutOptionPreferenceController) controller;
372                     shortcutOptionPreferenceController.setShortcutTargets(mShortcutTargets);
373                     shortcutOptionPreferenceController.setInSetupWizard(isInSuw);
374                 });
375     }
376 
onExpanded()377     private void onExpanded() {
378         AdvancedShortcutsPreferenceController advanced =
379                 use(AdvancedShortcutsPreferenceController.class);
380         advanced.setExpanded(true);
381 
382         TripleTapShortcutOptionController tripleTapShortcutOptionController =
383                 use(TripleTapShortcutOptionController.class);
384         tripleTapShortcutOptionController.setExpanded(true);
385 
386         refreshPreferenceController(AdvancedShortcutsPreferenceController.class);
387         refreshPreferenceController(TripleTapShortcutOptionController.class);
388     }
389 
refreshPreferenceController( Class<? extends AbstractPreferenceController> controllerClass)390     private void refreshPreferenceController(
391             Class<? extends AbstractPreferenceController> controllerClass) {
392         AbstractPreferenceController controller = use(controllerClass);
393         if (controller != null && getPreferenceScreen() != null) {
394             controller.displayPreference(getPreferenceScreen());
395             if (!TextUtils.isEmpty(controller.getPreferenceKey())) {
396                 controller.updateState(findPreference(controller.getPreferenceKey()));
397             }
398         }
399     }
400 
refreshSoftwareShortcutControllers()401     private void refreshSoftwareShortcutControllers() {
402         // Gesture
403         refreshPreferenceController(GestureShortcutOptionController.class);
404 
405         // FAB
406         refreshPreferenceController(FloatingButtonShortcutOptionController.class);
407 
408         // A11y Nav Button
409         refreshPreferenceController(NavButtonShortcutOptionController.class);
410     }
411 
412     /**
413      * Generates a title & subtitle pair describing the features whose shortcuts are being edited.
414      *
415      * @param shortcutTargets string list of component names corresponding to
416      *                        the relevant shortcut targets.
417      * @param accessibilityTargets list of accessibility targets
418      *                             to try and find corresponding labels in.
419      * @return pair of strings to be used as page title and subtitle.
420      * If there is only one shortcut label, It is displayed in the title and the subtitle is null.
421      * Otherwise, the title is a generic prompt and the subtitle lists all shortcut labels.
422      */
423     @VisibleForTesting
getTitlesFromAccessibilityTargetList( Set<String> shortcutTargets, List<AccessibilityTarget> accessibilityTargets, Resources resources)424     static Pair<String, String> getTitlesFromAccessibilityTargetList(
425             Set<String> shortcutTargets,
426             List<AccessibilityTarget> accessibilityTargets,
427             Resources resources) {
428         ArrayList<CharSequence> featureLabels = new ArrayList<>();
429 
430         Map<String, CharSequence> accessibilityTargetLabels = new ArrayMap<>();
431         accessibilityTargets.forEach((target) -> accessibilityTargetLabels.put(
432                 target.getId(), target.getLabel()));
433 
434         for (String target: shortcutTargets) {
435             if (accessibilityTargetLabels.containsKey(target)) {
436                 featureLabels.add(accessibilityTargetLabels.get(target));
437             } else {
438                 throw new IllegalStateException("Shortcut target does not have a label: " + target);
439             }
440         }
441 
442         if (featureLabels.size() == 1) {
443             return new Pair<>(
444                     resources.getString(
445                             R.string.accessibility_shortcut_title, featureLabels.get(0)),
446                     null
447             );
448         } else if (featureLabels.size() == 0) {
449             throw new IllegalStateException("Found no labels for any shortcut targets.");
450         } else {
451             return new Pair<>(
452                     resources.getString(R.string.accessibility_shortcut_edit_screen_title),
453                     resources.getString(
454                             R.string.accessibility_shortcut_edit_screen_prompt,
455                             ListFormatter.getInstance().format(featureLabels))
456             );
457         }
458     }
459 }
460