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