1 /* 2 * Copyright (C) 2015 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 package com.android.systemui.qs.external; 17 18 import static android.view.WindowManager.LayoutParams.TYPE_QS_DIALOG; 19 20 import android.app.PendingIntent; 21 import android.content.ComponentName; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.content.pm.PackageManager; 25 import android.content.pm.ResolveInfo; 26 import android.content.pm.ServiceInfo; 27 import android.graphics.drawable.Drawable; 28 import android.metrics.LogMaker; 29 import android.net.Uri; 30 import android.os.Binder; 31 import android.os.Handler; 32 import android.os.IBinder; 33 import android.os.Looper; 34 import android.os.RemoteException; 35 import android.provider.Settings; 36 import android.service.quicksettings.IQSTileService; 37 import android.service.quicksettings.Tile; 38 import android.service.quicksettings.TileService; 39 import android.text.TextUtils; 40 import android.text.format.DateUtils; 41 import android.util.Log; 42 import android.view.IWindowManager; 43 import android.view.View; 44 import android.view.WindowManagerGlobal; 45 import android.widget.Switch; 46 47 import androidx.annotation.NonNull; 48 import androidx.annotation.Nullable; 49 import androidx.annotation.WorkerThread; 50 51 import com.android.internal.annotations.VisibleForTesting; 52 import com.android.internal.logging.MetricsLogger; 53 import com.android.internal.logging.nano.MetricsProto.MetricsEvent; 54 import com.android.systemui.animation.ActivityLaunchAnimator; 55 import com.android.systemui.dagger.qualifiers.Background; 56 import com.android.systemui.dagger.qualifiers.Main; 57 import com.android.systemui.plugins.ActivityStarter; 58 import com.android.systemui.plugins.FalsingManager; 59 import com.android.systemui.plugins.qs.QSTile.State; 60 import com.android.systemui.plugins.statusbar.StatusBarStateController; 61 import com.android.systemui.qs.QSHost; 62 import com.android.systemui.qs.QsEventLogger; 63 import com.android.systemui.qs.external.TileLifecycleManager.TileChangeListener; 64 import com.android.systemui.qs.logging.QSLogger; 65 import com.android.systemui.qs.tileimpl.QSTileImpl; 66 import com.android.systemui.settings.DisplayTracker; 67 68 import java.util.Objects; 69 import java.util.concurrent.atomic.AtomicBoolean; 70 71 import javax.inject.Inject; 72 73 import dagger.Lazy; 74 75 public class CustomTile extends QSTileImpl<State> implements TileChangeListener { 76 public static final String PREFIX = "custom("; 77 78 private static final long CUSTOM_STALE_TIMEOUT = DateUtils.HOUR_IN_MILLIS; 79 80 private static final boolean DEBUG = false; 81 82 // We don't want to thrash binding and unbinding if the user opens and closes the panel a lot. 83 // So instead we have a period of waiting. 84 private static final long UNBIND_DELAY = 30000; 85 86 private final ComponentName mComponent; 87 private final Tile mTile; 88 private final IWindowManager mWindowManager; 89 private final IBinder mToken = new Binder(); 90 private final IQSTileService mService; 91 private final TileServiceManager mServiceManager; 92 private final int mUser; 93 private final CustomTileStatePersister mCustomTileStatePersister; 94 private final DisplayTracker mDisplayTracker; 95 @Nullable 96 private android.graphics.drawable.Icon mDefaultIcon; 97 @Nullable 98 private CharSequence mDefaultLabel; 99 @Nullable 100 private View mViewClicked; 101 102 private final Context mUserContext; 103 104 private boolean mListening; 105 private boolean mIsTokenGranted; 106 private boolean mIsShowingDialog; 107 108 private final TileServiceKey mKey; 109 110 private final AtomicBoolean mInitialDefaultIconFetched = new AtomicBoolean(false); 111 private final TileServices mTileServices; 112 CustomTile( QSHost host, QsEventLogger uiEventLogger, Looper backgroundLooper, Handler mainHandler, FalsingManager falsingManager, MetricsLogger metricsLogger, StatusBarStateController statusBarStateController, ActivityStarter activityStarter, QSLogger qsLogger, String action, Context userContext, CustomTileStatePersister customTileStatePersister, TileServices tileServices, DisplayTracker displayTracker )113 private CustomTile( 114 QSHost host, 115 QsEventLogger uiEventLogger, 116 Looper backgroundLooper, 117 Handler mainHandler, 118 FalsingManager falsingManager, 119 MetricsLogger metricsLogger, 120 StatusBarStateController statusBarStateController, 121 ActivityStarter activityStarter, 122 QSLogger qsLogger, 123 String action, 124 Context userContext, 125 CustomTileStatePersister customTileStatePersister, 126 TileServices tileServices, 127 DisplayTracker displayTracker 128 ) { 129 super(host, uiEventLogger, backgroundLooper, mainHandler, falsingManager, metricsLogger, 130 statusBarStateController, activityStarter, qsLogger); 131 mTileServices = tileServices; 132 mWindowManager = WindowManagerGlobal.getWindowManagerService(); 133 mComponent = ComponentName.unflattenFromString(action); 134 mTile = new Tile(); 135 mUserContext = userContext; 136 mUser = mUserContext.getUserId(); 137 mKey = new TileServiceKey(mComponent, mUser); 138 139 mServiceManager = tileServices.getTileWrapper(this); 140 mService = mServiceManager.getTileService(); 141 mCustomTileStatePersister = customTileStatePersister; 142 mDisplayTracker = displayTracker; 143 } 144 145 @Override handleInitialize()146 protected void handleInitialize() { 147 updateDefaultTileAndIcon(); 148 if (mInitialDefaultIconFetched.compareAndSet(false, true)) { 149 if (mDefaultIcon == null) { 150 Log.w(TAG, "No default icon for " + getTileSpec() + ", destroying tile"); 151 mHost.removeTile(getTileSpec()); 152 } 153 } 154 if (mServiceManager.isToggleableTile()) { 155 // Replace states with BooleanState 156 resetStates(); 157 } 158 mServiceManager.setTileChangeListener(this); 159 if (mServiceManager.isActiveTile()) { 160 Tile t = mCustomTileStatePersister.readState(mKey); 161 if (t != null) { 162 applyTileState(t, /* overwriteNulls */ false); 163 mServiceManager.clearPendingBind(); 164 refreshState(); 165 } 166 } 167 } 168 169 @Override getStaleTimeout()170 protected long getStaleTimeout() { 171 return CUSTOM_STALE_TIMEOUT + DateUtils.MINUTE_IN_MILLIS * mHost.indexOf(getTileSpec()); 172 } 173 updateDefaultTileAndIcon()174 private void updateDefaultTileAndIcon() { 175 try { 176 PackageManager pm = mUserContext.getPackageManager(); 177 int flags = PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_DIRECT_BOOT_AWARE; 178 if (isSystemApp(pm)) { 179 flags |= PackageManager.MATCH_DISABLED_COMPONENTS; 180 } 181 182 ServiceInfo info = pm.getServiceInfo(mComponent, flags); 183 int icon = info.icon != 0 ? info.icon 184 : info.applicationInfo.icon; 185 // Update the icon if its not set or is the default icon. 186 boolean updateIcon = mTile.getIcon() == null 187 || iconEquals(mTile.getIcon(), mDefaultIcon); 188 mDefaultIcon = icon != 0 ? android.graphics.drawable.Icon 189 .createWithResource(mComponent.getPackageName(), icon) : null; 190 if (updateIcon) { 191 mTile.setIcon(mDefaultIcon); 192 } 193 // Update the label if there is no label or it is the default label. 194 boolean updateLabel = mTile.getLabel() == null 195 || TextUtils.equals(mTile.getLabel(), mDefaultLabel); 196 mDefaultLabel = info.loadLabel(pm); 197 if (updateLabel) { 198 mTile.setLabel(mDefaultLabel); 199 } 200 } catch (PackageManager.NameNotFoundException e) { 201 mDefaultIcon = null; 202 mDefaultLabel = null; 203 } 204 } 205 isSystemApp(PackageManager pm)206 private boolean isSystemApp(PackageManager pm) throws PackageManager.NameNotFoundException { 207 return pm.getApplicationInfo(mComponent.getPackageName(), 0).isSystemApp(); 208 } 209 210 /** 211 * Compare two icons, only works for resources. 212 */ iconEquals(@ullable android.graphics.drawable.Icon icon1, @Nullable android.graphics.drawable.Icon icon2)213 private boolean iconEquals(@Nullable android.graphics.drawable.Icon icon1, 214 @Nullable android.graphics.drawable.Icon icon2) { 215 if (icon1 == icon2) { 216 return true; 217 } 218 if (icon1 == null || icon2 == null) { 219 return false; 220 } 221 if (icon1.getType() != android.graphics.drawable.Icon.TYPE_RESOURCE 222 || icon2.getType() != android.graphics.drawable.Icon.TYPE_RESOURCE) { 223 return false; 224 } 225 if (icon1.getResId() != icon2.getResId()) { 226 return false; 227 } 228 if (!Objects.equals(icon1.getResPackage(), icon2.getResPackage())) { 229 return false; 230 } 231 return true; 232 } 233 234 @Override onTileChanged(ComponentName tile)235 public void onTileChanged(ComponentName tile) { 236 mHandler.post(this::updateDefaultTileAndIcon); 237 } 238 239 /** 240 * Custom tile is considered available if there is a default icon (obtained from PM). 241 * <p> 242 * It will return {@code true} before initialization, so tiles are not destroyed prematurely. 243 */ 244 @Override isAvailable()245 public boolean isAvailable() { 246 if (mInitialDefaultIconFetched.get()) { 247 return mDefaultIcon != null; 248 } else { 249 return true; 250 } 251 } 252 getUser()253 public int getUser() { 254 return mUser; 255 } 256 getComponent()257 public ComponentName getComponent() { 258 return mComponent; 259 } 260 261 @Override populate(LogMaker logMaker)262 public LogMaker populate(LogMaker logMaker) { 263 return super.populate(logMaker).setComponentName(mComponent); 264 } 265 getQsTile()266 public Tile getQsTile() { 267 // TODO(b/191145007) Move to background thread safely 268 updateDefaultTileAndIcon(); 269 return mTile; 270 } 271 272 /** 273 * Update state of {@link this#mTile} from a remote {@link TileService}. 274 * 275 * @param tile tile populated with state to apply 276 */ updateTileState(Tile tile)277 public void updateTileState(Tile tile) { 278 // This comes from a binder call IQSService.updateQsTile 279 mHandler.post(() -> handleUpdateTileState(tile)); 280 } 281 handleUpdateTileState(Tile tile)282 private void handleUpdateTileState(Tile tile) { 283 applyTileState(tile, /* overwriteNulls */ true); 284 if (mServiceManager.isActiveTile()) { 285 mCustomTileStatePersister.persistState(mKey, tile); 286 } 287 } 288 289 @WorkerThread applyTileState(Tile tile, boolean overwriteNulls)290 private void applyTileState(Tile tile, boolean overwriteNulls) { 291 if (tile.getIcon() != null || overwriteNulls) { 292 mTile.setIcon(tile.getIcon()); 293 } 294 if (tile.getLabel() != null || overwriteNulls) { 295 mTile.setLabel(tile.getLabel()); 296 } 297 if (tile.getSubtitle() != null || overwriteNulls) { 298 mTile.setSubtitle(tile.getSubtitle()); 299 } 300 if (tile.getContentDescription() != null || overwriteNulls) { 301 mTile.setContentDescription(tile.getContentDescription()); 302 } 303 if (tile.getStateDescription() != null || overwriteNulls) { 304 mTile.setStateDescription(tile.getStateDescription()); 305 } 306 mTile.setActivityLaunchForClick(tile.getActivityLaunchForClick()); 307 mTile.setState(tile.getState()); 308 } 309 onDialogShown()310 public void onDialogShown() { 311 mIsShowingDialog = true; 312 } 313 onDialogHidden()314 public void onDialogHidden() { 315 mIsShowingDialog = false; 316 try { 317 if (DEBUG) Log.d(TAG, "Removing token"); 318 mWindowManager.removeWindowToken(mToken, mDisplayTracker.getDefaultDisplayId()); 319 } catch (RemoteException e) { 320 } 321 } 322 323 @Override handleSetListening(boolean listening)324 public void handleSetListening(boolean listening) { 325 super.handleSetListening(listening); 326 if (mListening == listening) return; 327 mListening = listening; 328 329 try { 330 if (listening) { 331 updateDefaultTileAndIcon(); 332 refreshState(); 333 if (!mServiceManager.isActiveTile() || !isTileReady()) { 334 mServiceManager.setBindRequested(true); 335 mService.onStartListening(); 336 } 337 } else { 338 mViewClicked = null; 339 mService.onStopListening(); 340 if (mIsTokenGranted && !mIsShowingDialog) { 341 try { 342 if (DEBUG) Log.d(TAG, "Removing token"); 343 mWindowManager.removeWindowToken(mToken, 344 mDisplayTracker.getDefaultDisplayId()); 345 } catch (RemoteException e) { 346 } 347 mIsTokenGranted = false; 348 } 349 mIsShowingDialog = false; 350 mServiceManager.setBindRequested(false); 351 } 352 } catch (RemoteException e) { 353 // Called through wrapper, won't happen here. 354 } 355 } 356 357 @Override handleDestroy()358 protected void handleDestroy() { 359 super.handleDestroy(); 360 if (mIsTokenGranted) { 361 try { 362 if (DEBUG) Log.d(TAG, "Removing token"); 363 mWindowManager.removeWindowToken(mToken, mDisplayTracker.getDefaultDisplayId()); 364 } catch (RemoteException e) { 365 } 366 } 367 mTileServices.freeService(this, mServiceManager); 368 } 369 370 @Override newTileState()371 public State newTileState() { 372 if (mServiceManager != null && mServiceManager.isToggleableTile()) { 373 return new BooleanState(); 374 } 375 return new State(); 376 } 377 378 @Override getLongClickIntent()379 public Intent getLongClickIntent() { 380 Intent i = new Intent(TileService.ACTION_QS_TILE_PREFERENCES); 381 i.setPackage(mComponent.getPackageName()); 382 i = resolveIntent(i); 383 if (i != null) { 384 i.putExtra(Intent.EXTRA_COMPONENT_NAME, mComponent); 385 i.putExtra(TileService.EXTRA_STATE, mTile.getState()); 386 return i; 387 } 388 return new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).setData( 389 Uri.fromParts("package", mComponent.getPackageName(), null)); 390 } 391 392 @Nullable resolveIntent(Intent i)393 private Intent resolveIntent(Intent i) { 394 ResolveInfo result = mContext.getPackageManager().resolveActivityAsUser(i, 0, mUser); 395 return result != null ? new Intent(TileService.ACTION_QS_TILE_PREFERENCES) 396 .setClassName(result.activityInfo.packageName, result.activityInfo.name) : null; 397 } 398 399 @Override handleClick(@ullable View view)400 protected void handleClick(@Nullable View view) { 401 if (mTile.getState() == Tile.STATE_UNAVAILABLE) { 402 return; 403 } 404 mViewClicked = view; 405 try { 406 if (DEBUG) Log.d(TAG, "Adding token"); 407 mWindowManager.addWindowToken(mToken, TYPE_QS_DIALOG, 408 mDisplayTracker.getDefaultDisplayId(), null /* options */); 409 mIsTokenGranted = true; 410 } catch (RemoteException e) { 411 } 412 try { 413 if (mServiceManager.isActiveTile()) { 414 mServiceManager.setBindRequested(true); 415 mService.onStartListening(); 416 } 417 418 if (mTile.getActivityLaunchForClick() != null) { 419 startActivityAndCollapse(mTile.getActivityLaunchForClick()); 420 } else { 421 mService.onClick(mToken); 422 } 423 } catch (RemoteException e) { 424 // Called through wrapper, won't happen here. 425 } 426 } 427 428 @Override getTileLabel()429 public CharSequence getTileLabel() { 430 return getState().label; 431 } 432 433 @Override handleUpdateState(State state, Object arg)434 protected void handleUpdateState(State state, Object arg) { 435 int tileState = mTile.getState(); 436 if (mServiceManager.hasPendingBind()) { 437 tileState = Tile.STATE_UNAVAILABLE; 438 } 439 state.state = tileState; 440 Drawable drawable = null; 441 try { 442 drawable = mTile.getIcon().loadDrawable(mUserContext); 443 } catch (Exception e) { 444 Log.w(TAG, "Invalid icon, forcing into unavailable state"); 445 state.state = Tile.STATE_UNAVAILABLE; 446 drawable = mDefaultIcon.loadDrawable(mUserContext); 447 } 448 449 final Drawable drawableF = drawable; 450 state.iconSupplier = () -> { 451 if (drawableF == null) return null; 452 Drawable.ConstantState cs = drawableF.getConstantState(); 453 if (cs != null) { 454 return new DrawableIcon(cs.newDrawable()); 455 } 456 return null; 457 }; 458 state.label = mTile.getLabel(); 459 460 CharSequence subtitle = mTile.getSubtitle(); 461 if (subtitle != null && subtitle.length() > 0) { 462 state.secondaryLabel = subtitle; 463 } else { 464 state.secondaryLabel = null; 465 } 466 467 if (mTile.getContentDescription() != null) { 468 state.contentDescription = mTile.getContentDescription(); 469 } else { 470 state.contentDescription = state.label; 471 } 472 473 if (mTile.getStateDescription() != null) { 474 state.stateDescription = mTile.getStateDescription(); 475 } else { 476 state.stateDescription = null; 477 } 478 479 if (state instanceof BooleanState) { 480 state.expandedAccessibilityClassName = Switch.class.getName(); 481 ((BooleanState) state).value = (state.state == Tile.STATE_ACTIVE); 482 } 483 484 } 485 486 @Override getMetricsCategory()487 public int getMetricsCategory() { 488 return MetricsEvent.QS_CUSTOM; 489 } 490 491 @Override getMetricsSpec()492 public final String getMetricsSpec() { 493 return mComponent.getPackageName(); 494 } 495 startUnlockAndRun()496 public void startUnlockAndRun() { 497 mActivityStarter.postQSRunnableDismissingKeyguard(() -> { 498 try { 499 mService.onUnlockComplete(); 500 } catch (RemoteException e) { 501 } 502 }); 503 } 504 505 /** 506 * Starts an {@link android.app.Activity} 507 * @param pendingIntent A PendingIntent for an Activity to be launched immediately. 508 */ startActivityAndCollapse(PendingIntent pendingIntent)509 public void startActivityAndCollapse(PendingIntent pendingIntent) { 510 if (!pendingIntent.isActivity()) { 511 Log.i(TAG, "Intent not for activity."); 512 } else if (!mIsTokenGranted) { 513 Log.i(TAG, "Launching activity before click"); 514 } else { 515 Log.i(TAG, "The activity is starting"); 516 ActivityLaunchAnimator.Controller controller = mViewClicked == null 517 ? null 518 : ActivityLaunchAnimator.Controller.fromView(mViewClicked, 0); 519 mUiHandler.post(() -> 520 mActivityStarter.startPendingIntentDismissingKeyguard( 521 pendingIntent, null, controller) 522 ); 523 } 524 } 525 toSpec(ComponentName name)526 public static String toSpec(ComponentName name) { 527 return PREFIX + name.flattenToShortString() + ")"; 528 } 529 getComponentFromSpec(String spec)530 public static ComponentName getComponentFromSpec(String spec) { 531 final String action = spec.substring(PREFIX.length(), spec.length() - 1); 532 if (action.isEmpty()) { 533 throw new IllegalArgumentException("Empty custom tile spec action"); 534 } 535 return ComponentName.unflattenFromString(action); 536 } 537 getAction(String spec)538 private static String getAction(String spec) { 539 if (spec == null || !spec.startsWith(PREFIX) || !spec.endsWith(")")) { 540 throw new IllegalArgumentException("Bad custom tile spec: " + spec); 541 } 542 final String action = spec.substring(PREFIX.length(), spec.length() - 1); 543 if (action.isEmpty()) { 544 throw new IllegalArgumentException("Empty custom tile spec action"); 545 } 546 return action; 547 } 548 549 /** 550 * Create a {@link CustomTile} for a given spec and user. 551 * 552 * @param builder including injected common dependencies. 553 * @param spec as provided by {@link CustomTile#toSpec} 554 * @param userContext context for the user that is creating this tile. 555 * @return a new {@link CustomTile} 556 */ create(Builder builder, String spec, Context userContext)557 public static CustomTile create(Builder builder, String spec, Context userContext) { 558 return builder 559 .setSpec(spec) 560 .setUserContext(userContext) 561 .build(); 562 } 563 564 public static class Builder { 565 final Lazy<QSHost> mQSHostLazy; 566 final QsEventLogger mUiEventLogger; 567 final Looper mBackgroundLooper; 568 final Handler mMainHandler; 569 private final FalsingManager mFalsingManager; 570 final MetricsLogger mMetricsLogger; 571 final StatusBarStateController mStatusBarStateController; 572 final ActivityStarter mActivityStarter; 573 final QSLogger mQSLogger; 574 final CustomTileStatePersister mCustomTileStatePersister; 575 private TileServices mTileServices; 576 final DisplayTracker mDisplayTracker; 577 578 Context mUserContext; 579 String mSpec = ""; 580 581 @Inject Builder( Lazy<QSHost> hostLazy, QsEventLogger uiEventLogger, @Background Looper backgroundLooper, @Main Handler mainHandler, FalsingManager falsingManager, MetricsLogger metricsLogger, StatusBarStateController statusBarStateController, ActivityStarter activityStarter, QSLogger qsLogger, CustomTileStatePersister customTileStatePersister, TileServices tileServices, DisplayTracker displayTracker )582 public Builder( 583 Lazy<QSHost> hostLazy, 584 QsEventLogger uiEventLogger, 585 @Background Looper backgroundLooper, 586 @Main Handler mainHandler, 587 FalsingManager falsingManager, 588 MetricsLogger metricsLogger, 589 StatusBarStateController statusBarStateController, 590 ActivityStarter activityStarter, 591 QSLogger qsLogger, 592 CustomTileStatePersister customTileStatePersister, 593 TileServices tileServices, 594 DisplayTracker displayTracker 595 ) { 596 mQSHostLazy = hostLazy; 597 mUiEventLogger = uiEventLogger; 598 mBackgroundLooper = backgroundLooper; 599 mMainHandler = mainHandler; 600 mFalsingManager = falsingManager; 601 mMetricsLogger = metricsLogger; 602 mStatusBarStateController = statusBarStateController; 603 mActivityStarter = activityStarter; 604 mQSLogger = qsLogger; 605 mCustomTileStatePersister = customTileStatePersister; 606 mTileServices = tileServices; 607 mDisplayTracker = displayTracker; 608 } 609 setSpec(@onNull String spec)610 Builder setSpec(@NonNull String spec) { 611 mSpec = spec; 612 return this; 613 } 614 setUserContext(@onNull Context userContext)615 Builder setUserContext(@NonNull Context userContext) { 616 mUserContext = userContext; 617 return this; 618 } 619 620 @VisibleForTesting build()621 public CustomTile build() { 622 if (mUserContext == null) { 623 throw new NullPointerException("UserContext cannot be null"); 624 } 625 String action = getAction(mSpec); 626 return new CustomTile( 627 mQSHostLazy.get(), 628 mUiEventLogger, 629 mBackgroundLooper, 630 mMainHandler, 631 mFalsingManager, 632 mMetricsLogger, 633 mStatusBarStateController, 634 mActivityStarter, 635 mQSLogger, 636 action, 637 mUserContext, 638 mCustomTileStatePersister, 639 mTileServices, 640 mDisplayTracker 641 ); 642 } 643 } 644 } 645