1 /* 2 * Copyright (C) 2020 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.plugin.globalactions.wallet; 18 19 import android.app.PendingIntent; 20 import android.content.Context; 21 import android.content.Intent; 22 import android.content.SharedPreferences; 23 import android.content.res.Resources; 24 import android.graphics.drawable.Drawable; 25 import android.graphics.drawable.Icon; 26 import android.os.Handler; 27 import android.os.UserHandle; 28 import android.os.Looper; 29 import android.service.quickaccesswallet.GetWalletCardsError; 30 import android.service.quickaccesswallet.GetWalletCardsRequest; 31 import android.service.quickaccesswallet.GetWalletCardsResponse; 32 import android.service.quickaccesswallet.QuickAccessWalletClient; 33 import android.service.quickaccesswallet.SelectWalletCardRequest; 34 import android.service.quickaccesswallet.WalletCard; 35 import android.service.quickaccesswallet.WalletServiceEvent; 36 import android.text.TextUtils; 37 import android.util.Log; 38 import android.view.View; 39 import android.widget.FrameLayout; 40 41 import com.android.systemui.plugin.globalactions.wallet.WalletPopupMenu.OverflowItem; 42 import com.android.systemui.plugins.GlobalActionsPanelPlugin; 43 44 import java.util.ArrayList; 45 import java.util.List; 46 import java.util.concurrent.ExecutorService; 47 import java.util.concurrent.Executors; 48 import java.util.concurrent.TimeUnit; 49 50 public class WalletPanelViewController implements 51 GlobalActionsPanelPlugin.PanelViewController, 52 WalletCardCarousel.OnSelectionListener, 53 QuickAccessWalletClient.OnWalletCardsRetrievedCallback, 54 QuickAccessWalletClient.WalletServiceEventListener { 55 56 private static final String TAG = "WalletPanelViewCtrl"; 57 private static final int MAX_CARDS = 10; 58 private static final long SELECTION_DELAY_MILLIS = TimeUnit.SECONDS.toMillis(30); 59 private static final String PREFS_WALLET_VIEW_HEIGHT = "wallet_view_height"; 60 private static final String PREFS_HAS_CARDS = "has_cards"; 61 private static final String SETTINGS_PKG = "com.android.settings"; 62 private static final String SETTINGS_ACTION = SETTINGS_PKG + ".GLOBAL_ACTIONS_PANEL_SETTINGS"; 63 private final Context mSysuiContext; 64 private final Context mPluginContext; 65 private final QuickAccessWalletClient mWalletClient; 66 private final WalletView mWalletView; 67 private final WalletCardCarousel mWalletCardCarousel; 68 private final GlobalActionsPanelPlugin.Callbacks mPluginCallbacks; 69 private final ExecutorService mExecutor; 70 private final Handler mHandler; 71 private final Runnable mSelectionRunnable = this::selectCard; 72 private final SharedPreferences mPrefs; 73 private boolean mIsDeviceLocked; 74 private boolean mIsDismissed; 75 private boolean mHasRegisteredListener; 76 private String mSelectedCardId; 77 WalletPanelViewController( Context sysuiContext, Context pluginContext, QuickAccessWalletClient walletClient, GlobalActionsPanelPlugin.Callbacks pluginCallbacks, boolean isDeviceLocked)78 public WalletPanelViewController( 79 Context sysuiContext, 80 Context pluginContext, 81 QuickAccessWalletClient walletClient, 82 GlobalActionsPanelPlugin.Callbacks pluginCallbacks, 83 boolean isDeviceLocked) { 84 mSysuiContext = sysuiContext; 85 mPluginContext = pluginContext; 86 mWalletClient = walletClient; 87 mPrefs = mSysuiContext.getSharedPreferences(TAG, Context.MODE_PRIVATE); 88 mPluginCallbacks = pluginCallbacks; 89 mIsDeviceLocked = isDeviceLocked; 90 mWalletView = new WalletView(pluginContext); 91 mWalletView.setMinimumHeight(getExpectedMinHeight()); 92 mWalletView.setLayoutParams( 93 new FrameLayout.LayoutParams( 94 FrameLayout.LayoutParams.MATCH_PARENT, 95 FrameLayout.LayoutParams.WRAP_CONTENT)); 96 mWalletCardCarousel = mWalletView.getCardCarousel(); 97 mWalletCardCarousel.setSelectionListener(this); 98 mHandler = new Handler(Looper.myLooper()); 99 mExecutor = Executors.newSingleThreadExecutor(); 100 if (!mPrefs.getBoolean(PREFS_HAS_CARDS, false)) { 101 // The empty state view is shown preemptively when cards were not returned last time 102 // to decrease perceived latency. 103 showEmptyStateView(); 104 } 105 } 106 107 /** 108 * Implements {@link GlobalActionsPanelPlugin.PanelViewController}. Returns the {@link View} 109 * containing the Quick Access Wallet. 110 */ 111 @Override getPanelContent()112 public View getPanelContent() { 113 return mWalletView; 114 } 115 116 /** 117 * Implements {@link GlobalActionsPanelPlugin.PanelViewController}. Invoked when the view 118 * containing the Quick Access Wallet is dismissed. 119 */ 120 @Override onDismissed()121 public void onDismissed() { 122 if (mIsDismissed) { 123 return; 124 } 125 mIsDismissed = true; 126 mSelectedCardId = null; 127 mHandler.removeCallbacks(mSelectionRunnable); 128 mWalletClient.notifyWalletDismissed(); 129 mWalletClient.removeWalletServiceEventListener(this); 130 mWalletView.animateDismissal(); 131 } 132 133 /** 134 * Implements {@link GlobalActionsPanelPlugin.PanelViewController}. Invoked when the device is 135 * either locked or unlocked while the wallet is visible. 136 */ 137 @Override onDeviceLockStateChanged(boolean deviceLocked)138 public void onDeviceLockStateChanged(boolean deviceLocked) { 139 if (mIsDismissed || mIsDeviceLocked == deviceLocked || !mIsDeviceLocked) { 140 // Disregard repeat events and events after unlock 141 return; 142 } 143 mIsDeviceLocked = deviceLocked; 144 // Cards are re-queried because the wallet application may wish to change card art, icons, 145 // text, or other attributes depending on the lock state of the device. 146 queryWalletCards(); 147 } 148 149 /** 150 * Query wallet cards from the client and display them on screen. 151 */ queryWalletCards()152 void queryWalletCards() { 153 if (mIsDismissed) { 154 return; 155 } 156 if (!mHasRegisteredListener) { 157 // Listener is registered even when device is locked. Should only be registered once. 158 mWalletClient.addWalletServiceEventListener(this); 159 mHasRegisteredListener = true; 160 } 161 if (mIsDeviceLocked && !mWalletClient.isWalletFeatureAvailableWhenDeviceLocked()) { 162 mWalletView.hide(); 163 return; 164 } 165 mWalletView.show(); 166 mWalletView.hideErrorMessage(); 167 int cardWidthPx = mWalletCardCarousel.getCardWidthPx(); 168 int cardHeightPx = mWalletCardCarousel.getCardHeightPx(); 169 int iconSizePx = mWalletView.getIconSizePx(); 170 GetWalletCardsRequest request = 171 new GetWalletCardsRequest(cardWidthPx, cardHeightPx, iconSizePx, MAX_CARDS); 172 mWalletClient.getWalletCards(mExecutor, request, this); 173 } 174 175 /** 176 * Implements {@link QuickAccessWalletClient.OnWalletCardsRetrievedCallback}. Called when cards 177 * are retrieved successfully from the service. This is called on {@link #mExecutor}. 178 */ 179 @Override onWalletCardsRetrieved(GetWalletCardsResponse response)180 public void onWalletCardsRetrieved(GetWalletCardsResponse response) { 181 if (mIsDismissed) { 182 return; 183 } 184 List<WalletCard> walletCards = response.getWalletCards(); 185 List<WalletCardViewInfo> data = new ArrayList<>(walletCards.size()); 186 for (WalletCard card : walletCards) { 187 data.add(new QAWalletCardViewInfo(card)); 188 } 189 190 // Get on main thread for UI updates 191 mWalletView.post(() -> { 192 if (mIsDismissed) { 193 return; 194 } 195 if (data.isEmpty()) { 196 showEmptyStateView(); 197 } else { 198 mWalletView.showCardCarousel(data, response.getSelectedIndex(), getOverflowItems()); 199 } 200 // The empty state view will not be shown preemptively next time if cards were returned 201 mPrefs.edit().putBoolean(PREFS_HAS_CARDS, !data.isEmpty()).apply(); 202 removeMinHeightAndRecordHeightOnLayout(); 203 }); 204 } 205 206 /** 207 * Implements {@link QuickAccessWalletClient.OnWalletCardsRetrievedCallback}. Called when there 208 * is an error during card retrieval. This will be run on the {@link #mExecutor}. 209 */ 210 @Override onWalletCardRetrievalError(GetWalletCardsError error)211 public void onWalletCardRetrievalError(GetWalletCardsError error) { 212 mWalletView.post(() -> { 213 if (mIsDismissed) { 214 return; 215 } 216 mWalletView.showErrorMessage(error.getMessage()); 217 }); 218 } 219 220 /** 221 * Implements {@link QuickAccessWalletClient.WalletServiceEventListener}. Called when the wallet 222 * application propagates an event, such as an NFC tap, to the quick access wallet view. 223 */ 224 @Override onWalletServiceEvent(WalletServiceEvent event)225 public void onWalletServiceEvent(WalletServiceEvent event) { 226 if (mIsDismissed) { 227 return; 228 } 229 switch (event.getEventType()) { 230 case WalletServiceEvent.TYPE_NFC_PAYMENT_STARTED: 231 mPluginCallbacks.dismissGlobalActionsMenu(); 232 onDismissed(); 233 break; 234 case WalletServiceEvent.TYPE_WALLET_CARDS_UPDATED: 235 queryWalletCards(); 236 break; 237 default: 238 Log.w(TAG, "onWalletServiceEvent: Unknown event type"); 239 } 240 } 241 242 /** 243 * Implements {@link WalletCardCarousel.OnSelectionListener}. Called when the user selects a 244 * card from the carousel by scrolling to it. 245 */ 246 @Override onCardSelected(WalletCardViewInfo card)247 public void onCardSelected(WalletCardViewInfo card) { 248 if (mIsDismissed) { 249 return; 250 } 251 mSelectedCardId = card.getCardId(); 252 selectCard(); 253 } 254 selectCard()255 private void selectCard() { 256 mHandler.removeCallbacks(mSelectionRunnable); 257 String selectedCardId = mSelectedCardId; 258 if (mIsDismissed || selectedCardId == null) { 259 return; 260 } 261 mWalletClient.selectWalletCard(new SelectWalletCardRequest(selectedCardId)); 262 // Re-selecting the card keeps the connection bound so we continue to get service events 263 // even if the user keeps it open for a long time. 264 mHandler.postDelayed(mSelectionRunnable, SELECTION_DELAY_MILLIS); 265 } 266 267 /** 268 * Implements {@link WalletCardCarousel.OnSelectionListener}. Called when the user clicks on a 269 * card. 270 */ 271 @Override onCardClicked(WalletCardViewInfo card)272 public void onCardClicked(WalletCardViewInfo card) { 273 if (mIsDismissed) { 274 return; 275 } 276 PendingIntent pendingIntent = ((QAWalletCardViewInfo) card).mWalletCard.getPendingIntent(); 277 startPendingIntent(pendingIntent); 278 } 279 getOverflowItems()280 private OverflowItem[] getOverflowItems() { 281 CharSequence walletLabel = mWalletClient.getShortcutShortLabel(); 282 Intent walletIntent = mWalletClient.createWalletIntent(); 283 CharSequence settingsLabel = mPluginContext.getString(R.string.settings); 284 Intent settingsIntent = new Intent(SETTINGS_ACTION).setPackage(SETTINGS_PKG); 285 OverflowItem settings = new OverflowItem(settingsLabel, () -> startIntent(settingsIntent)); 286 if (!TextUtils.isEmpty(walletLabel) && walletIntent != null) { 287 OverflowItem wallet = new OverflowItem(walletLabel, () -> startIntent(walletIntent)); 288 return new OverflowItem[]{wallet, settings}; 289 } else { 290 return new OverflowItem[]{settings}; 291 } 292 } 293 showEmptyStateView()294 private void showEmptyStateView() { 295 Drawable logo = mWalletClient.getLogo(); 296 CharSequence logoContentDesc = mWalletClient.getServiceLabel(); 297 CharSequence label = mWalletClient.getShortcutLongLabel(); 298 Intent intent = mWalletClient.createWalletIntent(); 299 if (logo == null 300 || TextUtils.isEmpty(logoContentDesc) 301 || TextUtils.isEmpty(label) 302 || intent == null) { 303 Log.w(TAG, "QuickAccessWalletService manifest entry mis-configured"); 304 // Issue is not likely to be resolved until manifest entries are enabled. 305 // Hide wallet feature until then. 306 mWalletView.hide(); 307 mPrefs.edit().putInt(PREFS_WALLET_VIEW_HEIGHT, 0).apply(); 308 } else { 309 mWalletView.showEmptyStateView(logo, logoContentDesc, label, v -> startIntent(intent)); 310 } 311 } 312 startIntent(Intent intent)313 private void startIntent(Intent intent) { 314 PendingIntent pendingIntent = PendingIntent.getActivity(mSysuiContext, 0, intent, 315 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); 316 startPendingIntent(pendingIntent); 317 } 318 startPendingIntent(PendingIntent pendingIntent)319 private void startPendingIntent(PendingIntent pendingIntent) { 320 mPluginCallbacks.startPendingIntentDismissingKeyguard(pendingIntent); 321 mPluginCallbacks.dismissGlobalActionsMenu(); 322 onDismissed(); 323 } 324 325 /** 326 * The total view height depends on whether cards are shown or not. Since it is not known at 327 * construction time whether cards will be available, the best we can do is set the height to 328 * whatever it was the last time. Setting the height correctly ahead of time is important 329 * because Home Controls are shown below the wallet and may be displayed before card data is 330 * loaded, causing the home controls to jump down when card data arrives. 331 */ getExpectedMinHeight()332 private int getExpectedMinHeight() { 333 int expectedHeight = mPrefs.getInt(PREFS_WALLET_VIEW_HEIGHT, -1); 334 if (expectedHeight == -1) { 335 Resources res = mPluginContext.getResources(); 336 expectedHeight = res.getDimensionPixelSize(R.dimen.min_wallet_empty_height); 337 } 338 return expectedHeight; 339 } 340 removeMinHeightAndRecordHeightOnLayout()341 private void removeMinHeightAndRecordHeightOnLayout() { 342 mWalletView.setMinimumHeight(0); 343 mWalletView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { 344 @Override 345 public void onLayoutChange(View v, int left, int top, int right, int bottom, 346 int oldLeft, int oldTop, int oldRight, int oldBottom) { 347 mWalletView.removeOnLayoutChangeListener(this); 348 mPrefs.edit().putInt(PREFS_WALLET_VIEW_HEIGHT, bottom - top).apply(); 349 } 350 }); 351 } 352 353 private class QAWalletCardViewInfo implements WalletCardViewInfo { 354 355 private final WalletCard mWalletCard; 356 private final Drawable mCardDrawable; 357 private final Drawable mIconDrawable; 358 359 /** 360 * Constructor is called on background executor, so it is safe to load drawables 361 * synchronously. 362 */ QAWalletCardViewInfo(WalletCard walletCard)363 QAWalletCardViewInfo(WalletCard walletCard) { 364 mWalletCard = walletCard; 365 Icon cardImage = mWalletCard.getCardImage(); 366 if (cardImage.getType() == Icon.TYPE_URI) { 367 // Do not allow icon created with content URI. 368 mCardDrawable = null; 369 } else { 370 mCardDrawable = 371 mWalletCard.getCardImage().loadDrawable(mPluginContext); 372 } 373 Icon icon = mWalletCard.getCardIcon(); 374 mIconDrawable = icon == null ? null : icon.loadDrawable(mPluginContext); 375 } 376 377 @Override getCardId()378 public String getCardId() { 379 return mWalletCard.getCardId(); 380 } 381 382 @Override getCardDrawable()383 public Drawable getCardDrawable() { 384 return mCardDrawable; 385 } 386 387 @Override getContentDescription()388 public CharSequence getContentDescription() { 389 return mWalletCard.getContentDescription(); 390 } 391 392 @Override getIcon()393 public Drawable getIcon() { 394 return mIconDrawable; 395 } 396 397 @Override getText()398 public CharSequence getText() { 399 return mWalletCard.getCardLabel(); 400 } 401 } 402 } 403