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