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.systemui.accessibility.accessibilitymenu;
18 
19 import android.Manifest;
20 import android.accessibilityservice.AccessibilityButtonController;
21 import android.accessibilityservice.AccessibilityService;
22 import android.app.KeyguardManager;
23 import android.content.BroadcastReceiver;
24 import android.content.Context;
25 import android.content.Intent;
26 import android.content.IntentFilter;
27 import android.content.SharedPreferences;
28 import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
29 import android.content.pm.PackageManager;
30 import android.content.pm.ResolveInfo;
31 import android.content.res.Configuration;
32 import android.hardware.display.BrightnessInfo;
33 import android.hardware.display.DisplayManager;
34 import android.media.AudioManager;
35 import android.os.Handler;
36 import android.os.Looper;
37 import android.os.SystemClock;
38 import android.provider.Settings;
39 import android.util.Log;
40 import android.view.Display;
41 import android.view.KeyEvent;
42 import android.view.MotionEvent;
43 import android.view.View;
44 import android.view.accessibility.AccessibilityEvent;
45 
46 import androidx.preference.PreferenceManager;
47 
48 import com.android.settingslib.display.BrightnessUtils;
49 import com.android.systemui.accessibility.accessibilitymenu.model.A11yMenuShortcut.ShortcutId;
50 import com.android.systemui.accessibility.accessibilitymenu.view.A11yMenuOverlayLayout;
51 
52 import java.util.List;
53 
54 /** @hide */
55 public class AccessibilityMenuService extends AccessibilityService
56         implements View.OnTouchListener {
57 
58     public static final String PACKAGE_NAME = AccessibilityMenuService.class.getPackageName();
59     public static final String PACKAGE_TESTS = ".tests";
60     public static final String INTENT_TOGGLE_MENU = ".toggle_menu";
61     public static final String INTENT_HIDE_MENU = ".hide_menu";
62     public static final String INTENT_GLOBAL_ACTION = ".global_action";
63     public static final String INTENT_GLOBAL_ACTION_EXTRA = "GLOBAL_ACTION";
64     public static final String INTENT_OPEN_BLOCKED = "OPEN_BLOCKED";
65 
66     private static final String TAG = "A11yMenuService";
67     private static final long BUFFER_MILLISECONDS_TO_PREVENT_UPDATE_FAILURE = 100L;
68     private static final long HIDE_UI_DELAY_MS = 100L;
69 
70     private static final int BRIGHTNESS_UP_INCREMENT_GAMMA =
71             (int) Math.ceil(BrightnessUtils.GAMMA_SPACE_MAX * 0.11f);
72     private static final int BRIGHTNESS_DOWN_INCREMENT_GAMMA =
73             (int) -Math.ceil(BrightnessUtils.GAMMA_SPACE_MAX * 0.11f);
74 
75     private long mLastTimeTouchedOutside = 0L;
76     // Timeout used to ignore the A11y button onClick() when ACTION_OUTSIDE is also received on
77     // clicking on the A11y button.
78     public static final long BUTTON_CLICK_TIMEOUT = 200;
79 
80     private A11yMenuOverlayLayout mA11yMenuLayout;
81     private SharedPreferences mPrefs;
82 
83     private static boolean sInitialized = false;
84 
85     private AudioManager mAudioManager;
86 
87     // TODO(b/136716947): Support multi-display once a11y framework side is ready.
88     private DisplayManager mDisplayManager;
89 
90     private KeyguardManager mKeyguardManager;
91 
92     private final DisplayManager.DisplayListener mDisplayListener =
93             new DisplayManager.DisplayListener() {
94                 int mRotation;
95 
96                 @Override
97                 public void onDisplayAdded(int displayId) {
98                 }
99 
100                 @Override
101                 public void onDisplayRemoved(int displayId) {
102                     // TODO(b/136716947): Need to reset A11yMenuOverlayLayout by display id.
103                 }
104 
105                 @Override
106                 public void onDisplayChanged(int displayId) {
107                     if (mA11yMenuLayout == null) {
108                         return;
109                     }
110                     Display display = mDisplayManager.getDisplay(Display.DEFAULT_DISPLAY);
111                     if (mRotation != display.getRotation()) {
112                         mRotation = display.getRotation();
113                         mA11yMenuLayout.updateViewLayout();
114                     }
115                 }
116             };
117 
118     private final BroadcastReceiver mHideMenuReceiver = new BroadcastReceiver() {
119         @Override
120         public void onReceive(Context context, Intent intent) {
121             mA11yMenuLayout.hideMenu();
122         }
123     };
124 
125     private final BroadcastReceiver mToggleMenuReceiver = new BroadcastReceiver() {
126         @Override
127         public void onReceive(Context context, Intent intent) {
128             toggleVisibility();
129         }
130     };
131 
132     /**
133      * Update a11y menu layout when large button setting is changed.
134      */
135     private final OnSharedPreferenceChangeListener mSharedPreferenceChangeListener =
136             (SharedPreferences prefs, String key) -> {
137                 {
138                     if (key.equals(getString(R.string.pref_large_buttons))) {
139                         mA11yMenuLayout.configureLayout();
140                     }
141                 }
142             };
143 
144     // Update layout.
145     private final Handler mHandler = new Handler(Looper.getMainLooper());
146     private final Runnable mOnConfigChangedRunnable = new Runnable() {
147         @Override
148         public void run() {
149             if (!sInitialized) {
150                 return;
151             }
152             // Re-assign theme to service after onConfigurationChanged
153             getTheme().applyStyle(R.style.ServiceTheme, true);
154             // Caches & updates the page index to ViewPager when a11y menu is refreshed.
155             // Otherwise, the menu page would reset on a UI update.
156             int cachedPageIndex = mA11yMenuLayout.getPageIndex();
157             mA11yMenuLayout.configureLayout(cachedPageIndex);
158         }
159     };
160 
161     @Override
onCreate()162     public void onCreate() {
163         super.onCreate();
164         setTheme(R.style.ServiceTheme);
165 
166         getAccessibilityButtonController().registerAccessibilityButtonCallback(
167                 new AccessibilityButtonController.AccessibilityButtonCallback() {
168                     /**
169                      * {@inheritDoc}
170                      */
171                     @Override
172                     public void onClicked(AccessibilityButtonController controller) {
173                         toggleVisibility();
174                     }
175 
176                     /**
177                      * {@inheritDoc}
178                      */
179                     @Override
180                     public void onAvailabilityChanged(AccessibilityButtonController controller,
181                             boolean available) {}
182                 }
183         );
184     }
185 
186     @Override
onDestroy()187     public void onDestroy() {
188         if (mHandler.hasCallbacks(mOnConfigChangedRunnable)) {
189             mHandler.removeCallbacks(mOnConfigChangedRunnable);
190         }
191 
192         super.onDestroy();
193     }
194 
195     @Override
onServiceConnected()196     protected void onServiceConnected() {
197         mA11yMenuLayout = new A11yMenuOverlayLayout(this);
198 
199         IntentFilter hideMenuFilter = new IntentFilter();
200         hideMenuFilter.addAction(Intent.ACTION_SCREEN_OFF);
201         hideMenuFilter.addAction(INTENT_HIDE_MENU);
202 
203         // Including WRITE_SECURE_SETTINGS enforces that we only listen to apps
204         // with the restricted WRITE_SECURE_SETTINGS permission who broadcast this intent.
205         registerReceiver(mHideMenuReceiver, hideMenuFilter,
206                 Manifest.permission.WRITE_SECURE_SETTINGS, null,
207                 Context.RECEIVER_EXPORTED);
208         registerReceiver(mToggleMenuReceiver,
209                 new IntentFilter(INTENT_TOGGLE_MENU),
210                 Manifest.permission.WRITE_SECURE_SETTINGS, null,
211                 Context.RECEIVER_EXPORTED);
212 
213         mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
214         mPrefs.registerOnSharedPreferenceChangeListener(mSharedPreferenceChangeListener);
215 
216 
217         mDisplayManager = getSystemService(DisplayManager.class);
218         mDisplayManager.registerDisplayListener(mDisplayListener, null);
219         mAudioManager = getSystemService(AudioManager.class);
220         mKeyguardManager = getSystemService(KeyguardManager.class);
221 
222         sInitialized = true;
223     }
224 
225     @Override
onAccessibilityEvent(AccessibilityEvent event)226     public void onAccessibilityEvent(AccessibilityEvent event) {}
227 
228     /**
229      * This method would notify service when device configuration, such as display size,
230      * localization, orientation or theme, is changed.
231      *
232      * @param newConfig the new device configuration.
233      */
234     @Override
onConfigurationChanged(Configuration newConfig)235     public void onConfigurationChanged(Configuration newConfig) {
236         // Prevent update layout failure
237         // if multiple onConfigurationChanged are called at the same time.
238         if (mHandler.hasCallbacks(mOnConfigChangedRunnable)) {
239             mHandler.removeCallbacks(mOnConfigChangedRunnable);
240         }
241         mHandler.postDelayed(
242                 mOnConfigChangedRunnable, BUFFER_MILLISECONDS_TO_PREVENT_UPDATE_FAILURE);
243     }
244 
245     /**
246      * Performs global action and broadcasts an intent indicating the action was performed.
247      * This is unnecessary for any current functionality, but is used for testing.
248      * Refer to {@code performGlobalAction()}.
249      *
250      * @param globalAction Global action to be performed.
251      * @return {@code true} if successful, {@code false} otherwise.
252      */
performGlobalActionInternal(int globalAction)253     private boolean performGlobalActionInternal(int globalAction) {
254         Intent intent = new Intent(INTENT_GLOBAL_ACTION);
255         intent.putExtra(INTENT_GLOBAL_ACTION_EXTRA, globalAction);
256         intent.setPackage(PACKAGE_NAME + PACKAGE_TESTS);
257         sendBroadcast(intent);
258         Log.i("A11yMenuService", "Broadcasting global action " + globalAction);
259         return performGlobalAction(globalAction);
260     }
261 
262     /**
263      * Handles click events of shortcuts.
264      *
265      * @param view the shortcut button being clicked.
266      */
handleClick(View view)267     public void handleClick(View view) {
268         // Shortcuts are repeatable in a11y menu rather than unique, so use tag ID to handle.
269         int viewTag = (int) view.getTag();
270 
271         // First check if this was a shortcut which should keep a11y menu visible. If so,
272         // perform the shortcut and return without hiding the UI.
273         if (viewTag == ShortcutId.ID_BRIGHTNESS_UP_VALUE.ordinal()) {
274             adjustBrightness(BRIGHTNESS_UP_INCREMENT_GAMMA);
275             return;
276         } else if (viewTag == ShortcutId.ID_BRIGHTNESS_DOWN_VALUE.ordinal()) {
277             adjustBrightness(BRIGHTNESS_DOWN_INCREMENT_GAMMA);
278             return;
279         } else if (viewTag == ShortcutId.ID_VOLUME_UP_VALUE.ordinal()) {
280             adjustVolume(AudioManager.ADJUST_RAISE);
281             return;
282         } else if (viewTag == ShortcutId.ID_VOLUME_DOWN_VALUE.ordinal()) {
283             adjustVolume(AudioManager.ADJUST_LOWER);
284             return;
285         }
286 
287         // Hide the a11y menu UI before performing the following shortcut actions.
288         mA11yMenuLayout.hideMenu();
289 
290         if (viewTag == ShortcutId.ID_ASSISTANT_VALUE.ordinal()) {
291             // Always restart the voice command activity, so that the UI is reloaded.
292             startActivityIfIntentIsSafe(
293                     new Intent(Intent.ACTION_VOICE_COMMAND),
294                     Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
295         } else if (viewTag == ShortcutId.ID_A11YSETTING_VALUE.ordinal()) {
296             startActivityIfIntentIsSafe(
297                     new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS),
298                     Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
299         } else if (viewTag == ShortcutId.ID_POWER_VALUE.ordinal()) {
300             performGlobalActionInternal(GLOBAL_ACTION_POWER_DIALOG);
301         } else if (viewTag == ShortcutId.ID_RECENT_VALUE.ordinal()) {
302             performGlobalActionInternal(GLOBAL_ACTION_RECENTS);
303         } else if (viewTag == ShortcutId.ID_LOCKSCREEN_VALUE.ordinal()) {
304             // Delay before locking the screen to give time for the UI to close.
305             mHandler.postDelayed(
306                     () -> performGlobalActionInternal(GLOBAL_ACTION_LOCK_SCREEN),
307                     HIDE_UI_DELAY_MS);
308         } else if (viewTag == ShortcutId.ID_QUICKSETTING_VALUE.ordinal()) {
309             performGlobalActionInternal(GLOBAL_ACTION_QUICK_SETTINGS);
310         } else if (viewTag == ShortcutId.ID_NOTIFICATION_VALUE.ordinal()) {
311             performGlobalActionInternal(GLOBAL_ACTION_NOTIFICATIONS);
312         } else if (viewTag == ShortcutId.ID_SCREENSHOT_VALUE.ordinal()) {
313             mHandler.postDelayed(
314                     () -> performGlobalActionInternal(GLOBAL_ACTION_TAKE_SCREENSHOT),
315                     HIDE_UI_DELAY_MS);
316         }
317     }
318 
319     /**
320      * Adjusts brightness using the same logic and utils class as the SystemUI brightness slider.
321      *
322      * @see BrightnessUtils
323      * @see com.android.systemui.settings.brightness.BrightnessController
324      * @param increment The increment amount in gamma-space
325      */
adjustBrightness(int increment)326     private void adjustBrightness(int increment) {
327         Display display = mDisplayManager.getDisplay(Display.DEFAULT_DISPLAY);
328         BrightnessInfo info = display.getBrightnessInfo();
329         int gamma = BrightnessUtils.convertLinearToGammaFloat(
330                 info.brightness,
331                 info.brightnessMinimum,
332                 info.brightnessMaximum
333         );
334         gamma = Math.max(
335                 BrightnessUtils.GAMMA_SPACE_MIN,
336                 Math.min(BrightnessUtils.GAMMA_SPACE_MAX, gamma + increment));
337 
338         float brightness = BrightnessUtils.convertGammaToLinearFloat(
339                 gamma,
340                 info.brightnessMinimum,
341                 info.brightnessMaximum
342         );
343         mDisplayManager.setBrightness(display.getDisplayId(), brightness);
344         mA11yMenuLayout.showSnackbar(
345                 getString(R.string.brightness_percentage_label,
346                         (gamma / (BrightnessUtils.GAMMA_SPACE_MAX / 100))));
347     }
348 
adjustVolume(int direction)349     private void adjustVolume(int direction) {
350         mAudioManager.adjustStreamVolume(
351                 AudioManager.STREAM_MUSIC, direction,
352                 AudioManager.FLAG_REMOVE_SOUND_AND_VIBRATE);
353         final int maxVolume = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
354         final int volume = mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
355         mA11yMenuLayout.showSnackbar(
356                 getString(
357                         R.string.music_volume_percentage_label,
358                         (int) (100.0 / maxVolume * volume))
359         );
360     }
361 
startActivityIfIntentIsSafe(Intent intent, int flag)362     private void startActivityIfIntentIsSafe(Intent intent, int flag) {
363         PackageManager packageManager = getPackageManager();
364         List<ResolveInfo> activities = packageManager.queryIntentActivities(intent,
365                 PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY));
366         if (!activities.isEmpty()) {
367             intent.setFlags(flag);
368             startActivity(intent);
369         }
370     }
371 
372     @Override
onInterrupt()373     public void onInterrupt() {
374     }
375 
376     @Override
onUnbind(Intent intent)377     public boolean onUnbind(Intent intent) {
378         unregisterReceiver(mHideMenuReceiver);
379         unregisterReceiver(mToggleMenuReceiver);
380         mDisplayManager.unregisterDisplayListener(mDisplayListener);
381         mPrefs.unregisterOnSharedPreferenceChangeListener(mSharedPreferenceChangeListener);
382         sInitialized = false;
383         if (mA11yMenuLayout != null) {
384             mA11yMenuLayout.clearLayout();
385             mA11yMenuLayout = null;
386         }
387         return super.onUnbind(intent);
388     }
389 
390     @Override
onKeyEvent(KeyEvent event)391     protected boolean onKeyEvent(KeyEvent event) {
392         if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
393             mA11yMenuLayout.hideMenu();
394         }
395         return false;
396     }
397 
398     @Override
onTouch(View v, MotionEvent event)399     public boolean onTouch(View v, MotionEvent event) {
400         if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
401             if (mA11yMenuLayout.hideMenu()) {
402                 mLastTimeTouchedOutside = SystemClock.uptimeMillis();
403             }
404         }
405         return false;
406     }
407 
toggleVisibility()408     private void toggleVisibility() {
409         boolean locked = mKeyguardManager != null && mKeyguardManager.isKeyguardLocked();
410         if (!locked) {
411             if (SystemClock.uptimeMillis() - mLastTimeTouchedOutside
412                     > BUTTON_CLICK_TIMEOUT) {
413                 mA11yMenuLayout.toggleVisibility();
414             }
415         } else {
416             // Broadcast for testing.
417             Intent intent = new Intent(INTENT_OPEN_BLOCKED);
418             intent.setPackage(PACKAGE_NAME + PACKAGE_TESTS);
419             sendBroadcast(intent);
420         }
421     }
422 }
423