1 /* 2 * Copyright (C) 2021 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.wallet.ui; 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.service.quickaccesswallet.GetWalletCardsError; 28 import android.service.quickaccesswallet.GetWalletCardsRequest; 29 import android.service.quickaccesswallet.GetWalletCardsResponse; 30 import android.service.quickaccesswallet.QuickAccessWalletClient; 31 import android.service.quickaccesswallet.SelectWalletCardRequest; 32 import android.service.quickaccesswallet.WalletCard; 33 import android.text.TextUtils; 34 import android.util.Log; 35 import android.view.View; 36 import android.view.ViewGroup; 37 import android.widget.FrameLayout; 38 39 import androidx.annotation.NonNull; 40 41 import com.android.internal.annotations.VisibleForTesting; 42 import com.android.internal.logging.UiEventLogger; 43 import com.android.keyguard.KeyguardUpdateMonitor; 44 import com.android.systemui.R; 45 import com.android.systemui.plugins.ActivityStarter; 46 import com.android.systemui.plugins.FalsingManager; 47 import com.android.systemui.settings.UserTracker; 48 import com.android.systemui.statusbar.policy.KeyguardStateController; 49 50 import java.util.ArrayList; 51 import java.util.List; 52 import java.util.concurrent.Executor; 53 import java.util.concurrent.TimeUnit; 54 55 /** Controller for the wallet card carousel screen. */ 56 public class WalletScreenController implements 57 WalletCardCarousel.OnSelectionListener, 58 QuickAccessWalletClient.OnWalletCardsRetrievedCallback, 59 KeyguardStateController.Callback { 60 61 private static final String TAG = "WalletScreenCtrl"; 62 private static final String PREFS_WALLET_VIEW_HEIGHT = "wallet_view_height"; 63 private static final int MAX_CARDS = 10; 64 private static final long SELECTION_DELAY_MILLIS = TimeUnit.SECONDS.toMillis(30); 65 66 private Context mContext; 67 private final QuickAccessWalletClient mWalletClient; 68 private final ActivityStarter mActivityStarter; 69 private final Executor mExecutor; 70 private final Handler mHandler; 71 private final KeyguardUpdateMonitor mKeyguardUpdateMonitor; 72 private final KeyguardStateController mKeyguardStateController; 73 private final Runnable mSelectionRunnable = this::selectCard; 74 private final SharedPreferences mPrefs; 75 private final WalletView mWalletView; 76 private final WalletCardCarousel mCardCarousel; 77 private final FalsingManager mFalsingManager; 78 private final UiEventLogger mUiEventLogger; 79 80 81 @VisibleForTesting 82 String mSelectedCardId; 83 @VisibleForTesting 84 boolean mIsDismissed; 85 WalletScreenController( Context context, WalletView walletView, QuickAccessWalletClient walletClient, ActivityStarter activityStarter, Executor executor, Handler handler, UserTracker userTracker, FalsingManager falsingManager, KeyguardUpdateMonitor keyguardUpdateMonitor, KeyguardStateController keyguardStateController, UiEventLogger uiEventLogger)86 public WalletScreenController( 87 Context context, 88 WalletView walletView, 89 QuickAccessWalletClient walletClient, 90 ActivityStarter activityStarter, 91 Executor executor, 92 Handler handler, 93 UserTracker userTracker, 94 FalsingManager falsingManager, 95 KeyguardUpdateMonitor keyguardUpdateMonitor, 96 KeyguardStateController keyguardStateController, 97 UiEventLogger uiEventLogger) { 98 mContext = context; 99 mWalletClient = walletClient; 100 mActivityStarter = activityStarter; 101 mExecutor = executor; 102 mHandler = handler; 103 mFalsingManager = falsingManager; 104 mKeyguardUpdateMonitor = keyguardUpdateMonitor; 105 mKeyguardStateController = keyguardStateController; 106 mUiEventLogger = uiEventLogger; 107 mPrefs = userTracker.getUserContext().getSharedPreferences(TAG, Context.MODE_PRIVATE); 108 mWalletView = walletView; 109 mWalletView.setMinimumHeight(getExpectedMinHeight()); 110 mWalletView.setLayoutParams( 111 new FrameLayout.LayoutParams( 112 ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); 113 mCardCarousel = mWalletView.getCardCarousel(); 114 if (mCardCarousel != null) { 115 mCardCarousel.setSelectionListener(this); 116 } 117 } 118 119 /** 120 * Implements {@link QuickAccessWalletClient.OnWalletCardsRetrievedCallback}. Called when cards 121 * are retrieved successfully from the service. This is called on {@link #mExecutor}. 122 */ 123 @Override onWalletCardsRetrieved(@onNull GetWalletCardsResponse response)124 public void onWalletCardsRetrieved(@NonNull GetWalletCardsResponse response) { 125 if (mIsDismissed) { 126 return; 127 } 128 Log.i(TAG, "Successfully retrieved wallet cards."); 129 List<WalletCard> walletCards = response.getWalletCards(); 130 131 boolean allUnknown = true; 132 for (WalletCard card : walletCards) { 133 if (card.getCardType() != WalletCard.CARD_TYPE_UNKNOWN) { 134 allUnknown = false; 135 break; 136 } 137 } 138 139 List<WalletCardViewInfo> paymentCardData = new ArrayList<>(); 140 for (WalletCard card : walletCards) { 141 if (allUnknown || card.getCardType() == WalletCard.CARD_TYPE_PAYMENT) { 142 paymentCardData.add(new QAWalletCardViewInfo(mContext, card)); 143 } 144 } 145 146 // Get on main thread for UI updates. 147 mHandler.post(() -> { 148 if (mIsDismissed) { 149 return; 150 } 151 if (paymentCardData.isEmpty()) { 152 showEmptyStateView(); 153 } else { 154 int selectedIndex = response.getSelectedIndex(); 155 if (selectedIndex >= paymentCardData.size()) { 156 Log.w(TAG, "Invalid selected card index, showing empty state."); 157 showEmptyStateView(); 158 } else { 159 boolean isUdfpsEnabled = mKeyguardUpdateMonitor.isUdfpsEnrolled() 160 && mKeyguardUpdateMonitor.isFingerprintDetectionRunning(); 161 mWalletView.showCardCarousel( 162 paymentCardData, 163 selectedIndex, 164 !mKeyguardStateController.isUnlocked(), 165 isUdfpsEnabled); 166 } 167 } 168 mUiEventLogger.log(WalletUiEvent.QAW_IMPRESSION); 169 removeMinHeightAndRecordHeightOnLayout(); 170 }); 171 } 172 173 /** 174 * Implements {@link QuickAccessWalletClient.OnWalletCardsRetrievedCallback}. Called when there 175 * is an error during card retrieval. This will be run on the {@link #mExecutor}. 176 */ 177 @Override onWalletCardRetrievalError(@onNull GetWalletCardsError error)178 public void onWalletCardRetrievalError(@NonNull GetWalletCardsError error) { 179 mHandler.post(() -> { 180 if (mIsDismissed) { 181 return; 182 } 183 mWalletView.showErrorMessage(error.getMessage()); 184 }); 185 } 186 187 @Override onKeyguardFadingAwayChanged()188 public void onKeyguardFadingAwayChanged() { 189 queryWalletCards(); 190 } 191 192 @Override onUnlockedChanged()193 public void onUnlockedChanged() { 194 queryWalletCards(); 195 } 196 197 @Override onUncenteredClick(int position)198 public void onUncenteredClick(int position) { 199 if (mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { 200 return; 201 } 202 mCardCarousel.smoothScrollToPosition(position); 203 } 204 205 @Override onCardSelected(@onNull WalletCardViewInfo card)206 public void onCardSelected(@NonNull WalletCardViewInfo card) { 207 if (mIsDismissed) { 208 return; 209 } 210 if (mSelectedCardId != null && !mSelectedCardId.equals(card.getCardId())) { 211 mUiEventLogger.log(WalletUiEvent.QAW_CHANGE_CARD); 212 } 213 mSelectedCardId = card.getCardId(); 214 selectCard(); 215 } 216 selectCard()217 private void selectCard() { 218 mHandler.removeCallbacks(mSelectionRunnable); 219 String selectedCardId = mSelectedCardId; 220 if (mIsDismissed || selectedCardId == null) { 221 return; 222 } 223 mWalletClient.selectWalletCard(new SelectWalletCardRequest(selectedCardId)); 224 // Re-selecting the card keeps the connection bound so we continue to get service events 225 // even if the user keeps it open for a long time. 226 mHandler.postDelayed(mSelectionRunnable, SELECTION_DELAY_MILLIS); 227 } 228 229 230 @Override onCardClicked(@onNull WalletCardViewInfo cardInfo)231 public void onCardClicked(@NonNull WalletCardViewInfo cardInfo) { 232 if (mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { 233 return; 234 } 235 if (!(cardInfo instanceof QAWalletCardViewInfo) 236 || ((QAWalletCardViewInfo) cardInfo).mWalletCard == null 237 || ((QAWalletCardViewInfo) cardInfo).mWalletCard.getPendingIntent() == null) { 238 return; 239 } 240 241 if (!mKeyguardStateController.isUnlocked()) { 242 mUiEventLogger.log(WalletUiEvent.QAW_UNLOCK_FROM_CARD_CLICK); 243 } 244 mUiEventLogger.log(WalletUiEvent.QAW_CLICK_CARD); 245 246 mActivityStarter.startPendingIntentDismissingKeyguard(cardInfo.getPendingIntent()); 247 } 248 249 @Override queryWalletCards()250 public void queryWalletCards() { 251 if (mIsDismissed) { 252 return; 253 } 254 int cardWidthPx = mCardCarousel.getCardWidthPx(); 255 int cardHeightPx = mCardCarousel.getCardHeightPx(); 256 if (cardWidthPx == 0 || cardHeightPx == 0) { 257 return; 258 } 259 260 mWalletView.show(); 261 mWalletView.hideErrorMessage(); 262 int iconSizePx = 263 mContext 264 .getResources() 265 .getDimensionPixelSize(R.dimen.wallet_screen_header_icon_size); 266 GetWalletCardsRequest request = 267 new GetWalletCardsRequest(cardWidthPx, cardHeightPx, iconSizePx, MAX_CARDS); 268 mWalletClient.getWalletCards(mExecutor, request, this); 269 } 270 onDismissed()271 void onDismissed() { 272 if (mIsDismissed) { 273 return; 274 } 275 mIsDismissed = true; 276 mSelectedCardId = null; 277 mHandler.removeCallbacks(mSelectionRunnable); 278 mWalletClient.notifyWalletDismissed(); 279 mWalletView.animateDismissal(); 280 // clear refs to the Wallet Activity 281 mContext = null; 282 } 283 showEmptyStateView()284 private void showEmptyStateView() { 285 Drawable logo = mWalletClient.getLogo(); 286 CharSequence logoContentDesc = mWalletClient.getServiceLabel(); 287 CharSequence label = mWalletClient.getShortcutLongLabel(); 288 Intent intent = mWalletClient.createWalletIntent(); 289 if (logo == null 290 || TextUtils.isEmpty(logoContentDesc) 291 || TextUtils.isEmpty(label) 292 || intent == null) { 293 Log.w(TAG, "QuickAccessWalletService manifest entry mis-configured"); 294 // Issue is not likely to be resolved until manifest entries are enabled. 295 // Hide wallet feature until then. 296 mWalletView.hide(); 297 mPrefs.edit().putInt(PREFS_WALLET_VIEW_HEIGHT, 0).apply(); 298 } else { 299 mWalletView.showEmptyStateView( 300 logo, 301 logoContentDesc, 302 label, 303 v -> mActivityStarter.startActivity(intent, true)); 304 } 305 } 306 getExpectedMinHeight()307 private int getExpectedMinHeight() { 308 int expectedHeight = mPrefs.getInt(PREFS_WALLET_VIEW_HEIGHT, -1); 309 if (expectedHeight == -1) { 310 Resources res = mContext.getResources(); 311 expectedHeight = res.getDimensionPixelSize(R.dimen.min_wallet_empty_height); 312 } 313 return expectedHeight; 314 } 315 removeMinHeightAndRecordHeightOnLayout()316 private void removeMinHeightAndRecordHeightOnLayout() { 317 mWalletView.setMinimumHeight(0); 318 mWalletView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { 319 @Override 320 public void onLayoutChange(View v, int left, int top, int right, int bottom, 321 int oldLeft, int oldTop, int oldRight, int oldBottom) { 322 mWalletView.removeOnLayoutChangeListener(this); 323 mPrefs.edit().putInt(PREFS_WALLET_VIEW_HEIGHT, bottom - top).apply(); 324 } 325 }); 326 } 327 328 @VisibleForTesting 329 static class QAWalletCardViewInfo implements WalletCardViewInfo { 330 331 private final WalletCard mWalletCard; 332 private final Drawable mCardDrawable; 333 private final Drawable mIconDrawable; 334 335 /** 336 * Constructor is called on background executor, so it is safe to load drawables 337 * synchronously. 338 */ QAWalletCardViewInfo(Context context, WalletCard walletCard)339 QAWalletCardViewInfo(Context context, WalletCard walletCard) { 340 mWalletCard = walletCard; 341 Icon cardImageIcon = mWalletCard.getCardImage(); 342 if (cardImageIcon.getType() == Icon.TYPE_URI) { 343 mCardDrawable = null; 344 } else { 345 mCardDrawable = mWalletCard.getCardImage().loadDrawable(context); 346 } 347 Icon icon = mWalletCard.getCardIcon(); 348 mIconDrawable = icon == null ? null : icon.loadDrawable(context); 349 } 350 351 @Override getCardId()352 public String getCardId() { 353 return mWalletCard.getCardId(); 354 } 355 356 @Override getCardDrawable()357 public Drawable getCardDrawable() { 358 return mCardDrawable; 359 } 360 361 @Override getContentDescription()362 public CharSequence getContentDescription() { 363 return mWalletCard.getContentDescription(); 364 } 365 366 @Override getIcon()367 public Drawable getIcon() { 368 return mIconDrawable; 369 } 370 371 @Override getLabel()372 public CharSequence getLabel() { 373 CharSequence label = mWalletCard.getCardLabel(); 374 if (label == null) { 375 return ""; 376 } 377 return label; 378 } 379 380 @Override getPendingIntent()381 public PendingIntent getPendingIntent() { 382 return mWalletCard.getPendingIntent(); 383 } 384 } 385 } 386