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