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