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