Add an activity for wallet card carousel screen.

The WalletActivity initializes a WalletScreenController to query the
wallet cards and present on the content view.
The next step is to launch the new activity from QuickAccessWalletTile.

Test: manually test on deivce
Test: atest
Bug: b/182964813
Change-Id: Idbf0918e111db0d00f3b60b8c26b968c961df202
This commit is contained in:
Silin Huang
2021-03-29 00:31:35 -07:00
parent a9faa1f148
commit 80142305f6
25 changed files with 2174 additions and 9 deletions

View File

@@ -762,6 +762,17 @@
android:visibleToInstantApps="true">
</activity>
<activity android:name=".wallet.ui.WalletActivity"
android:label="@string/wallet_title"
android:theme="@style/Wallet.Theme"
android:excludeFromRecents="true"
android:showWhenLocked="true"
android:showForAllUsers="true"
android:launchMode="singleInstance"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden"
android:visibleToInstantApps="true">
</activity>
<receiver android:name=".controls.management.ControlsRequestReceiver"
android:exported="true">
<intent-filter>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2019 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:height="50dp"
android:width="50dp" />
<solid android:color="#1A73E8" />
</shape>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2019 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#202124"/>
<corners android:radius="10dp"/>
</shape>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<stroke
android:width="1dp"
android:color="@color/GM2_blue_600"/>
<solid android:color="@color/GM2_blue_600"/>
<corners android:radius="24dp"/>
</shape>
</item>
</selector>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?android:attr/colorControlHighlight">
<item android:id="@android:id/mask">
<shape android:shape="rectangle">
<solid android:color="#DADCE0" />
<corners android:radius="@dimen/wallet_empty_state_corner_radius" />
</shape>
</item>
<item>
<shape>
<stroke
android:width="1dp"
android:color="@color/GM2_grey_900" />
<corners android:radius="@dimen/wallet_empty_state_corner_radius" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<com.android.systemui.wallet.ui.WalletView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/wallet_view"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.android.systemui.wallet.ui.WalletCardView
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginHorizontal="@dimen/card_margin"
android:foreground="?android:attr/selectableItemBackground"
app:cardBackgroundColor="@android:color/transparent"
app:cardElevation="12dp">
<ImageView
android:id="@+id/card_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:contentDescription="@null"
android:scaleType="fitXY"/>
<ImageView
android:id="@+id/add_card_logo"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:drawable="@drawable/ic_qs_plus"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:visibility="gone"/>
</com.android.systemui.wallet.ui.WalletCardView>
</FrameLayout>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
**
** Copyright 2020, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@+id/wallet_empty_state"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="48dp"
android:background="@drawable/wallet_empty_state_bg"
android:orientation="vertical"
android:paddingVertical="52dp"
android:theme="@style/Wallet.Theme"
android:clipChildren="false"
android:visibility="gone">
<ImageView
android:id="@+id/empty_state_icon"
android:layout_width="wrap_content"
android:layout_height="28dp"
android:layout_gravity="center"
android:layout_marginBottom="8dp"
android:contentDescription="@null"
android:scaleType="fitCenter"/>
<TextView
style="@style/Wallet.TextAppearance"
android:id="@+id/empty_state_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
</LinearLayout>
</merge>

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2021 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false">
<LinearLayout
android:id="@+id/card_carousel_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="36dp"
android:orientation="vertical">
<ImageView
android:id="@+id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginVertical="10dp"
android:background="@drawable/circle_wallet_primary_50dp"
android:scaleType="center"
android:contentDescription="@null"/>
<TextView
android:id="@+id/label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Wallet.TextAppearance"
android:textAlignment="center"/>
<com.android.systemui.wallet.ui.WalletCardCarousel
android:id="@+id/card_carousel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:transitionName="dotIndicator"
android:clipChildren="false"
android:clipToPadding="false"
android:layout_marginVertical="24dp"/>
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<Button
android:id="@+id/wallet_button"
android:background="@drawable/wallet_button"
android:textColor="@color/wallet_white"
android:textAlignment="center"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp"
android:layout_marginVertical="24dp"
android:layout_gravity="center_horizontal"/>
</LinearLayout>
<include layout="@layout/wallet_empty_state"/>
<TextView
android:id="@+id/error_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="80dp"
android:layout_marginHorizontal="40dp"
android:textSize="14sp"
android:textColor="#DADCE0"
android:elevation="7dp"
android:background="@drawable/rounded_corners"
android:gravity="center"
android:visibility="gone"/>
</FrameLayout>

View File

@@ -288,4 +288,9 @@
<!-- Accessibility floating menu -->
<color name="accessibility_floating_menu_background">#CCFFFFFF</color> <!-- 80% -->
<color name="accessibility_floating_menu_stroke_dark">#26FFFFFF</color> <!-- 15% -->
<!-- Wallet screen -->
<color name="wallet_white">#FFFFFF</color>
<color name="wallet_card_border">#33FFFFFF</color>
<color name="wallet_primary_text">@color/GM2_grey_900</color>
</resources>

View File

@@ -1421,4 +1421,15 @@
For devices with sensors on the back of the device, this corresponds to the location on the
screen directly in front of the sensor. -->
<dimen name="physical_fingerprint_sensor_center_screen_location_y">610px</dimen>
<!-- Wallet activity screen specs -->
<dimen name="wallet_icon_size">36sp</dimen>
<dimen name="card_margin">16dp</dimen>
<dimen name="card_carousel_dot_offset">24dp</dimen>
<dimen name="card_carousel_dot_unselected_radius">2dp</dimen>
<dimen name="card_carousel_dot_selected_radius">3dp</dimen>
<dimen name="card_carousel_dot_margin">4dp</dimen>
<dimen name="min_wallet_empty_height">208dp</dimen>
<dimen name="wallet_card_border_width">1dp</dimen>
<dimen name="wallet_empty_state_corner_radius">24dp</dimen>
</resources>

View File

@@ -1633,8 +1633,14 @@
<!-- Wallet strings -->
<!-- Wallet empty state, title [CHAR LIMIT=32] -->
<string name="wallet_title">Wallet</string>
<!-- Label of the button underneath the card carousel when device is unlocked. [CHAR LIMIT=NONE] -->
<string name="wallet_button_label_device_unlocked">Show all</string>
<!-- Label of the button underneath the card carousel when device is locked. [CHAR LIMIT=NONE] -->
<string name="wallet_button_label_device_locked">Unlock to pay</string>
<!-- Secondary label of the quick access wallet tile. [CHAR LIMIT=32] -->
<string name="wallet_secondary_label">Ready</string>
<!-- Message shown when an unknown failure occurred when fetching cards. [CHAR LIMIT=NONE] -->
<string name="wallet_error_generic">There was a problem getting your cards, please try again later</string>
<!-- Name of the work status bar icon. -->
<string name="status_bar_work">Work profile</string>

View File

@@ -792,4 +792,18 @@
<item name="android:mirrorForRtl">false</item>
<item name="android:progressDrawable">@drawable/udfps_progress_bar</item>
</style>
<!-- Wallet styles -->
<style name="Wallet" />
<style name="Wallet.TextAppearance">
<item name="android:fontFamily">@*android:string/config_headlineFontFamily</item>
<item name="android:textColor">@color/wallet_primary_text</item>
<item name="android:singleLine">true</item>
<item name="android:textSize">14sp</item>
</style>
<style name="Wallet.Theme">
<item name="android:colorControlHighlight">@*android:color/primary_text_material_dark</item>
</style>
</resources>

View File

@@ -31,7 +31,6 @@ import android.os.HandlerThread;
import android.os.Looper;
import android.os.ServiceManager;
import android.os.UserHandle;
import android.service.quickaccesswallet.QuickAccessWalletClient;
import android.view.Choreographer;
import android.view.IWindowManager;
import android.view.LayoutInflater;
@@ -376,11 +375,4 @@ public class DependencyProvider {
public ModeSwitchesController providesModeSwitchesController(Context context) {
return new ModeSwitchesController(context);
}
/** */
@Provides
@SysUISingleton
public QuickAccessWalletClient provideQuickAccessWalletClient(Context context) {
return QuickAccessWalletClient.create(context);
}
}

View File

@@ -73,6 +73,7 @@ import com.android.systemui.util.settings.SettingsUtilModule;
import com.android.systemui.util.time.SystemClock;
import com.android.systemui.util.time.SystemClockImpl;
import com.android.systemui.volume.dagger.VolumeModule;
import com.android.systemui.wallet.dagger.WalletModule;
import com.android.systemui.wmshell.BubblesManager;
import com.android.wm.shell.bubbles.Bubbles;
@@ -109,7 +110,8 @@ import dagger.Provides;
TunerModule.class,
UserModule.class,
UtilModule.class,
VolumeModule.class
VolumeModule.class,
WalletModule.class
},
subcomponents = {
StatusBarComponent.class,

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.wallet.dagger;
import android.app.Activity;
import android.content.Context;
import android.service.quickaccesswallet.QuickAccessWalletClient;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.wallet.ui.WalletActivity;
import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import dagger.multibindings.ClassKey;
import dagger.multibindings.IntoMap;
/**
* Module for injecting classes in Wallet.
*/
@Module
public abstract class WalletModule {
/** */
@Binds
@IntoMap
@ClassKey(WalletActivity.class)
public abstract Activity provideWalletActivity(WalletActivity activity);
/** */
@SysUISingleton
@Provides
public static QuickAccessWalletClient provideQuickAccessWalletClient(Context context) {
return QuickAccessWalletClient.create(context);
}
}

View File

@@ -0,0 +1,181 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.wallet.ui;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.text.TextPaint;
import android.util.MathUtils;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.ColorUtils;
import androidx.recyclerview.widget.RecyclerView;
import com.android.systemui.R;
final class DotIndicatorDecoration extends RecyclerView.ItemDecoration {
private final int mUnselectedRadius;
private final int mSelectedRadius;
private final int mDotMargin;
@ColorInt private final int mUnselectedColor;
@ColorInt private final int mSelectedColor;
private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final TextPaint mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
private WalletCardCarousel mCardCarousel;
DotIndicatorDecoration(Context context) {
super();
mUnselectedRadius =
context.getResources().getDimensionPixelSize(
R.dimen.card_carousel_dot_unselected_radius);
mSelectedRadius =
context.getResources().getDimensionPixelSize(
R.dimen.card_carousel_dot_selected_radius);
mDotMargin = context.getResources().getDimensionPixelSize(R.dimen.card_carousel_dot_margin);
TextView textView = new TextView(context);
mTextPaint.set(textView.getPaint());
// Text color is not copied from text appearance.
mTextPaint.setColor(ContextCompat.getColor(context, R.color.GM2_blue_600));
mUnselectedColor = ContextCompat.getColor(context, R.color.GM2_grey_300);
mSelectedColor = ContextCompat.getColor(context, R.color.GM2_blue_600);
}
@Override
public void getItemOffsets(
Rect rect, View view, RecyclerView recyclerView, RecyclerView.State state) {
super.getItemOffsets(rect, view, recyclerView, state);
if (recyclerView.getAdapter().getItemCount() > 1) {
rect.bottom =
view.getResources().getDimensionPixelSize(R.dimen.card_carousel_dot_offset);
}
}
@Override
public void onDrawOver(Canvas canvas, RecyclerView recyclerView, RecyclerView.State state) {
super.onDrawOver(canvas, recyclerView, state);
mCardCarousel = (WalletCardCarousel) recyclerView;
int itemCount = recyclerView.getAdapter().getItemCount();
if (itemCount <= 1) {
// Only shown if there are at least 2 items, and it's not a shimmer loader
return;
}
canvas.save();
float animationStartOffset = recyclerView.getWidth() / 6f;
// 0 when a card is still very prominent, ie. edgeToCenterDistance is greater than
// animationStartOffset
// 1 when the two cards are equidistant from the center ie. edgeToCenterDistance == 0
float interpolatedProgress =
1 - Math.min(Math.abs(mCardCarousel.mEdgeToCenterDistance), animationStartOffset)
/ animationStartOffset;
float totalWidth =
mDotMargin * (itemCount - 1)
+ 2 * mUnselectedRadius * (itemCount - 2)
+ 2 * mSelectedRadius;
// Translate the canvas so the drawing can always start at (0, 0) coordinates.
canvas.translate(
(recyclerView.getWidth() - totalWidth) / 2f,
recyclerView.getHeight() - mDotMargin);
int itemsDrawn = 0;
while (itemsDrawn < itemCount) {
// count up from 0 to itemCount - 1 if LTR; count down from itemCount - 1 to 0 if RTL.
int i = isLayoutLtr() ? itemsDrawn : itemCount - itemsDrawn - 1;
if (isSelectedItem(i)) {
drawSelectedDot(canvas, interpolatedProgress, i);
} else if (isNextItemInScrollingDirection(i)) {
drawFadingUnselectedDot(canvas, interpolatedProgress, i);
} else {
drawUnselectedDot(canvas);
}
canvas.translate(mDotMargin, 0);
itemsDrawn++;
}
canvas.restore();
this.mCardCarousel = null; // No need to hold a reference.
}
private void drawSelectedDot(Canvas canvas, float progress, int position) {
// Divide progress by 2 because the other half of the animation is done by
// drawFadingUnselectedDot.
mPaint.setColor(
getTransitionAdjustedColor(
ColorUtils.blendARGB(mSelectedColor, mUnselectedColor, progress / 2)));
float radius = MathUtils.lerp(mSelectedRadius, mUnselectedRadius, progress / 2);
canvas.drawCircle(radius, 0, radius, mPaint);
canvas.translate(radius * 2, 0);
}
private void drawFadingUnselectedDot(Canvas canvas, float progress, int position) {
// Divide progress by 2 because the first half of the animation is done by drawSelectedDot.
int blendedColor =
ColorUtils.blendARGB(
mUnselectedColor, mSelectedColor, progress / 2);
mPaint.setColor(getTransitionAdjustedColor(blendedColor));
float radius = MathUtils.lerp(mSelectedRadius, mUnselectedRadius, progress / 2);
canvas.drawCircle(radius, 0, radius, mPaint);
canvas.translate(radius * 2, 0);
}
private void drawUnselectedDot(Canvas canvas) {
mPaint.setColor(mUnselectedColor);
canvas.drawCircle(mUnselectedRadius, 0, mUnselectedRadius, mPaint);
canvas.translate(mUnselectedRadius * 2, 0);
}
private int getTransitionAdjustedColor(int color) {
int transitionAlphaOverride = 0xff;
return ColorUtils.setAlphaComponent(color, transitionAlphaOverride);
}
private boolean isSelectedItem(int position) {
return mCardCarousel.mCenteredAdapterPosition == position;
}
private boolean isNextItemInScrollingDirection(int position) {
if (isLayoutLtr()) {
return (mCardCarousel.mCenteredAdapterPosition + 1 == position
&& mCardCarousel.mEdgeToCenterDistance >= 0f)
|| (mCardCarousel.mCenteredAdapterPosition - 1 == position
&& mCardCarousel.mEdgeToCenterDistance < 0f);
}
return (mCardCarousel.mCenteredAdapterPosition - 1 == position
&& mCardCarousel.mEdgeToCenterDistance >= 0f)
|| (mCardCarousel.mCenteredAdapterPosition + 1 == position
&& mCardCarousel.mEdgeToCenterDistance < 0f);
}
private boolean isLayoutLtr() {
if (mCardCarousel == null) {
// Shouldn't happen, but assume LTR for now.
return true;
}
return mCardCarousel.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR;
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.wallet.ui;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.os.Handler;
import android.service.quickaccesswallet.QuickAccessWalletClient;
import android.view.MenuItem;
import android.view.Window;
import androidx.annotation.NonNull;
import com.android.systemui.R;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.settings.UserTracker;
import com.android.systemui.statusbar.policy.KeyguardStateController;
import com.android.systemui.util.LifecycleActivity;
import java.util.concurrent.Executor;
import javax.inject.Inject;
/**
* Displays Wallet carousel screen inside an activity.
*/
public class WalletActivity extends LifecycleActivity {
private final QuickAccessWalletClient mQuickAccessWalletClient;
private final KeyguardStateController mKeyguardStateController;
private final ActivityStarter mActivityStarter;
private final Executor mExecutor;
private final Handler mHandler;
private final UserTracker mUserTracker;
private WalletScreenController mWalletScreenController;
@Inject
public WalletActivity(
QuickAccessWalletClient quickAccessWalletClient,
KeyguardStateController keyguardStateController,
ActivityStarter activityStarter,
@Background Executor executor,
@Background Handler handler,
UserTracker userTracker) {
mQuickAccessWalletClient = quickAccessWalletClient;
mKeyguardStateController = keyguardStateController;
mActivityStarter = activityStarter;
mExecutor = executor;
mHandler = handler;
mUserTracker = userTracker;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
getWindow().requestFeature(Window.FEATURE_ACTION_BAR_OVERLAY);
super.onCreate(savedInstanceState);
setContentView(R.layout.quick_access_wallet);
getWindow().getDecorView().setBackgroundColor(getColor(R.color.wallet_white));
setTitle("");
getActionBar().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
getActionBar().setDisplayHomeAsUpEnabled(true);
getActionBar().setHomeAsUpIndicator(R.drawable.ic_close);
getActionBar().setHomeActionContentDescription(R.string.accessibility_desc_close);
WalletView walletView = requireViewById(R.id.wallet_view);
mWalletScreenController = new WalletScreenController(
this,
walletView,
mQuickAccessWalletClient,
mActivityStarter,
mExecutor,
mHandler,
mUserTracker,
!mKeyguardStateController.isUnlocked());
walletView.getWalletButton().setOnClickListener(
v -> mActivityStarter.startActivity(
mQuickAccessWalletClient.createWalletIntent(), true));
}
@Override
protected void onStart() {
super.onStart();
}
@Override
protected void onResume() {
super.onResume();
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
int itemId = item.getItemId();
if (itemId == android.R.id.home) {
finish();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
protected void onDestroy() {
mWalletScreenController.onDismissed();
super.onDestroy();
}
}

View File

@@ -0,0 +1,473 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.wallet.ui;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.view.HapticFeedbackConstants;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.cardview.widget.CardView;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.PagerSnapHelper;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate;
import com.android.systemui.R;
import java.util.Collections;
import java.util.List;
/**
* Card Carousel for displaying Quick Access Wallet cards.
*/
public class WalletCardCarousel extends RecyclerView {
// A negative card margin is required because card shrinkage pushes the cards too far apart
private static final float CARD_MARGIN_RATIO = -.03f;
// Size of the unselected card as a ratio to size of selected card.
private static final float UNSELECTED_CARD_SCALE = .83f;
private static final float CORNER_RADIUS_RATIO = 25f / 700f;
private static final float CARD_ASPECT_RATIO = 700f / 440f;
private static final float CARD_VIEW_WIDTH_RATIO = 0.69f;
static final int CARD_ANIM_ALPHA_DURATION = 100;
static final int CARD_ANIM_ALPHA_DELAY = 50;
private final Rect mSystemGestureExclusionZone = new Rect();
private WalletCardCarouselAdapter mWalletCardCarouselAdapter;
private int mExpectedViewWidth;
private int mCardMarginPx;
private int mCardWidthPx;
private int mCardHeightPx;
private float mCornerRadiusPx;
private int mTotalCardWidth;
private float mCardEdgeToCenterDistance;
private OnSelectionListener mSelectionListener;
private OnCardScrollListener mCardScrollListener;
// Adapter position of the child that is closest to the center of the recycler view, will also
// be used in DotIndicatorDecoration.
int mCenteredAdapterPosition = RecyclerView.NO_POSITION;
// Pixel distance, along y-axis, from the center of the recycler view to the nearest child, will
// also be used in DotIndicatorDecoration.
float mEdgeToCenterDistance = Float.MAX_VALUE;
private float mCardCenterToScreenCenterDistancePx = Float.MAX_VALUE;
// When card data is loaded, this many cards should be animated as data is bound to them.
private int mNumCardsToAnimate;
// When card data is loaded, this is the position of the leftmost card to be animated.
private int mCardAnimationStartPosition;
// When card data is loaded, the animations may be delayed so that other animations can complete
private int mExtraAnimationDelay;
interface OnSelectionListener {
/**
* The card was moved to the center, thus selecting it.
*/
void onCardSelected(@NonNull WalletCardViewInfo card);
/**
* The card was clicked.
*/
void onCardClicked(@NonNull WalletCardViewInfo card);
/**
* Cards should be re-queried due to a layout change
*/
void queryWalletCards();
}
interface OnCardScrollListener {
void onCardScroll(WalletCardViewInfo centerCard, WalletCardViewInfo nextCard,
float percentDistanceFromCenter);
}
public WalletCardCarousel(Context context) {
this(context, null);
}
public WalletCardCarousel(Context context, @Nullable AttributeSet attributeSet) {
super(context, attributeSet);
setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false));
addOnScrollListener(new CardCarouselScrollListener());
new CarouselSnapHelper().attachToRecyclerView(this);
mWalletCardCarouselAdapter = new WalletCardCarouselAdapter();
mWalletCardCarouselAdapter.setHasStableIds(true);
setAdapter(mWalletCardCarouselAdapter);
ViewCompat.setAccessibilityDelegate(this, new CardCarouselAccessibilityDelegate(this));
addItemDecoration(new DotIndicatorDecoration(getContext()));
}
/**
* We need to know the card width before we query cards. Card width depends on layout width.
* But the carousel isn't laid out until set to visible, which only happens after cards are
* returned. Setting the expected view width breaks the chicken-and-egg problem.
*/
void setExpectedViewWidth(int width) {
if (mExpectedViewWidth == width) {
return;
}
mExpectedViewWidth = width;
Resources res = getResources();
DisplayMetrics metrics = res.getDisplayMetrics();
int screenWidth = Math.min(metrics.widthPixels, metrics.heightPixels);
mCardWidthPx = Math.round(Math.min(width, screenWidth) * CARD_VIEW_WIDTH_RATIO);
mCardHeightPx = Math.round(mCardWidthPx / CARD_ASPECT_RATIO);
mCornerRadiusPx = mCardWidthPx * CORNER_RADIUS_RATIO;
mCardMarginPx = Math.round(mCardWidthPx * CARD_MARGIN_RATIO);
mTotalCardWidth = mCardWidthPx + res.getDimensionPixelSize(R.dimen.card_margin) * 2;
mCardEdgeToCenterDistance = mTotalCardWidth / 2f;
updatePadding(width);
if (mSelectionListener != null) {
mSelectionListener.queryWalletCards();
}
}
@Override
public void onViewAdded(View child) {
super.onViewAdded(child);
LayoutParams layoutParams = (LayoutParams) child.getLayoutParams();
layoutParams.leftMargin = mCardMarginPx;
layoutParams.rightMargin = mCardMarginPx;
child.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> updateCardView(child));
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
int width = getWidth();
if (mWalletCardCarouselAdapter.getItemCount() > 1 && width < mTotalCardWidth * 1.5) {
// When 2 or more cards are available but only one whole card can be shown on screen at
// a time, the entire carousel is opted out from system gesture to help users swipe
// between cards without accidentally performing the 'back' gesture. When there is only
// one card or when the carousel is large enough to accommodate several whole cards,
// there is no need to disable the back gesture since either the user can't swipe or has
// plenty of room with which to do so.
mSystemGestureExclusionZone.set(0, 0, width, getHeight());
setSystemGestureExclusionRects(Collections.singletonList(mSystemGestureExclusionZone));
}
if (width != mExpectedViewWidth) {
updatePadding(width);
}
}
void setExtraAnimationDelay(int extraAnimationDelay) {
mExtraAnimationDelay = extraAnimationDelay;
}
void setSelectionListener(OnSelectionListener selectionListener) {
mSelectionListener = selectionListener;
}
void setCardScrollListener(OnCardScrollListener scrollListener) {
mCardScrollListener = scrollListener;
}
int getCardWidthPx() {
return mCardWidthPx;
}
int getCardHeightPx() {
return mCardHeightPx;
}
/**
* Set card data. Returns true if carousel was empty, indicating that views will be animated
*/
boolean setData(List<WalletCardViewInfo> data, int selectedIndex) {
boolean wasEmpty = mWalletCardCarouselAdapter.getItemCount() == 0;
mWalletCardCarouselAdapter.setData(data);
if (wasEmpty) {
scrollToPosition(selectedIndex);
mNumCardsToAnimate = numCardsOnScreen(data.size(), selectedIndex);
mCardAnimationStartPosition = Math.max(selectedIndex - 1, 0);
}
WalletCardViewInfo selectedCard = data.get(selectedIndex);
mCardScrollListener.onCardScroll(selectedCard, selectedCard, 0);
return wasEmpty;
}
@Override
public void scrollToPosition(int position) {
super.scrollToPosition(position);
mSelectionListener.onCardSelected(mWalletCardCarouselAdapter.mData.get(position));
}
/**
* The number of cards shown on screen when one of the cards is position in the center. This is
* also the num
*/
private static int numCardsOnScreen(int numCards, int selectedIndex) {
if (numCards <= 2) {
return numCards;
}
// When there are 3 or more cards, 3 cards will be shown unless the first or last card is
// centered on screen.
return selectedIndex > 0 && selectedIndex < (numCards - 1) ? 3 : 2;
}
/**
* The padding pushes the first and last cards in the list to the center when they are
* selected.
*/
private void updatePadding(int viewWidth) {
int paddingHorizontal = (viewWidth - mTotalCardWidth) / 2 - mCardMarginPx;
paddingHorizontal = Math.max(0, paddingHorizontal); // just in case
setPadding(paddingHorizontal, getPaddingTop(), paddingHorizontal, getPaddingBottom());
// re-center selected card after changing padding (if card is selected)
if (mWalletCardCarouselAdapter != null
&& mWalletCardCarouselAdapter.getItemCount() > 0
&& mCenteredAdapterPosition != NO_POSITION) {
ViewHolder viewHolder = findViewHolderForAdapterPosition(mCenteredAdapterPosition);
if (viewHolder != null) {
View cardView = viewHolder.itemView;
int cardCenter = (cardView.getLeft() + cardView.getRight()) / 2;
int viewCenter = (getLeft() + getRight()) / 2;
int scrollX = cardCenter - viewCenter;
scrollBy(scrollX, 0);
}
}
}
private void updateCardView(View view) {
WalletCardViewHolder viewHolder = (WalletCardViewHolder) view.getTag();
CardView cardView = viewHolder.mCardView;
float center = (float) getWidth() / 2f;
float viewCenter = (view.getRight() + view.getLeft()) / 2f;
float viewWidth = view.getWidth();
float position = (viewCenter - center) / viewWidth;
float scaleFactor = Math.max(UNSELECTED_CARD_SCALE, 1f - Math.abs(position));
cardView.setScaleX(scaleFactor);
cardView.setScaleY(scaleFactor);
// a card is the "centered card" until its edge has moved past the center of the recycler
// view. note that we also need to factor in the negative margin.
// Find the edge that is closer to the center.
int edgePosition =
viewCenter < center ? view.getRight() + mCardMarginPx
: view.getLeft() - mCardMarginPx;
if (Math.abs(viewCenter - center) < mCardCenterToScreenCenterDistancePx) {
int childAdapterPosition = getChildAdapterPosition(view);
if (childAdapterPosition == RecyclerView.NO_POSITION) {
return;
}
mCenteredAdapterPosition = getChildAdapterPosition(view);
mEdgeToCenterDistance = edgePosition - center;
mCardCenterToScreenCenterDistancePx = Math.abs(viewCenter - center);
}
}
private class CardCarouselScrollListener extends OnScrollListener {
private int mOldState = -1;
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
if (newState == RecyclerView.SCROLL_STATE_IDLE && newState != mOldState) {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
}
mOldState = newState;
}
/**
* Callback method to be invoked when the RecyclerView has been scrolled. This will be
* called after the scroll has completed.
*
* <p>This callback will also be called if visible item range changes after a layout
* calculation. In that case, dx and dy will be 0.
*
* @param recyclerView The RecyclerView which scrolled.
* @param dx The amount of horizontal scroll.
* @param dy The amount of vertical scroll.
*/
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
mCenteredAdapterPosition = RecyclerView.NO_POSITION;
mEdgeToCenterDistance = Float.MAX_VALUE;
mCardCenterToScreenCenterDistancePx = Float.MAX_VALUE;
for (int i = 0; i < getChildCount(); i++) {
updateCardView(getChildAt(i));
}
if (mCenteredAdapterPosition == RecyclerView.NO_POSITION || dx == 0) {
return;
}
int nextAdapterPosition =
mCenteredAdapterPosition + (mEdgeToCenterDistance > 0 ? 1 : -1);
if (nextAdapterPosition < 0
|| nextAdapterPosition >= mWalletCardCarouselAdapter.mData.size()) {
return;
}
// Update the label text based on the currently selected card and the next one
WalletCardViewInfo centerCard =
mWalletCardCarouselAdapter.mData.get(mCenteredAdapterPosition);
WalletCardViewInfo nextCard = mWalletCardCarouselAdapter.mData.get(nextAdapterPosition);
float percentDistanceFromCenter =
Math.abs(mEdgeToCenterDistance) / mCardEdgeToCenterDistance;
mCardScrollListener.onCardScroll(centerCard, nextCard, percentDistanceFromCenter);
}
}
private class CarouselSnapHelper extends PagerSnapHelper {
private static final float MILLISECONDS_PER_INCH = 200.0F;
private static final int MAX_SCROLL_ON_FLING_DURATION = 80; // ms
@Override
public View findSnapView(LayoutManager layoutManager) {
View view = super.findSnapView(layoutManager);
if (view == null) {
// implementation decides not to snap
return null;
}
WalletCardViewHolder viewHolder = (WalletCardViewHolder) view.getTag();
WalletCardViewInfo card = viewHolder.mCardViewInfo;
mSelectionListener.onCardSelected(card);
mCardScrollListener.onCardScroll(card, card, 0);
return view;
}
/**
* The default SnapScroller is a little sluggish
*/
@Override
protected LinearSmoothScroller createScroller(LayoutManager layoutManager) {
return new LinearSmoothScroller(getContext()) {
@Override
protected void onTargetFound(View targetView, State state, Action action) {
int[] snapDistances = calculateDistanceToFinalSnap(layoutManager, targetView);
final int dx = snapDistances[0];
final int dy = snapDistances[1];
final int time = calculateTimeForDeceleration(
Math.max(Math.abs(dx), Math.abs(dy)));
if (time > 0) {
action.update(dx, dy, time, mDecelerateInterpolator);
}
}
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
}
@Override
protected int calculateTimeForScrolling(int dx) {
return Math.min(MAX_SCROLL_ON_FLING_DURATION,
super.calculateTimeForScrolling(dx));
}
};
}
}
private class WalletCardCarouselAdapter extends Adapter<WalletCardViewHolder> {
private List<WalletCardViewInfo> mData = Collections.EMPTY_LIST;
@NonNull
@Override
public WalletCardViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext());
View view = inflater.inflate(R.layout.wallet_card_view, viewGroup, false);
WalletCardViewHolder viewHolder = new WalletCardViewHolder(view);
CardView cardView = viewHolder.mCardView;
cardView.setRadius(mCornerRadiusPx);
ViewGroup.LayoutParams layoutParams = cardView.getLayoutParams();
layoutParams.width = mCardWidthPx;
layoutParams.height = mCardHeightPx;
view.setTag(viewHolder);
return viewHolder;
}
@Override
public void onBindViewHolder(@NonNull WalletCardViewHolder viewHolder, int position) {
WalletCardViewInfo cardViewInfo = mData.get(position);
viewHolder.mCardViewInfo = cardViewInfo;
if (cardViewInfo.getCardId().isEmpty()) {
viewHolder.mImageView.setScaleType(ImageView.ScaleType.CENTER);
}
viewHolder.mImageView.setImageDrawable(cardViewInfo.getCardDrawable());
viewHolder.mCardView.setContentDescription(cardViewInfo.getContentDescription());
viewHolder.mCardView.setOnClickListener(
v -> {
if (position != mCenteredAdapterPosition) {
smoothScrollToPosition(position);
} else {
mSelectionListener.onCardClicked(cardViewInfo);
}
});
if (mNumCardsToAnimate > 0 && (position - mCardAnimationStartPosition < 2)) {
mNumCardsToAnimate--;
int startDelay = (position - mCardAnimationStartPosition) * CARD_ANIM_ALPHA_DELAY
+ mExtraAnimationDelay;
viewHolder.itemView.setAlpha(0f);
viewHolder.itemView.animate().alpha(1f)
.setStartDelay(Math.max(0, startDelay))
.setDuration(CARD_ANIM_ALPHA_DURATION).start();
}
}
@Override
public int getItemCount() {
return mData.size();
}
@Override
public long getItemId(int position) {
return mData.get(position).getCardId().hashCode();
}
void setData(List<WalletCardViewInfo> data) {
mData = data;
notifyDataSetChanged();
}
}
private class CardCarouselAccessibilityDelegate extends RecyclerViewAccessibilityDelegate {
private CardCarouselAccessibilityDelegate(@NonNull RecyclerView recyclerView) {
super(recyclerView);
}
@Override
public boolean onRequestSendAccessibilityEvent(
ViewGroup viewGroup, View view, AccessibilityEvent accessibilityEvent) {
int eventType = accessibilityEvent.getEventType();
if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
scrollToPosition(getChildAdapterPosition(view));
}
return super.onRequestSendAccessibilityEvent(viewGroup, view, accessibilityEvent);
}
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.wallet.ui;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.cardview.widget.CardView;
import com.android.systemui.R;
/** Customized card view of the wallet card carousel. */
public class WalletCardView extends CardView {
private final Paint mBorderPaint;
public WalletCardView(@NonNull Context context) {
this(context, null);
}
public WalletCardView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mBorderPaint = new Paint();
mBorderPaint.setColor(context.getColor(R.color.wallet_card_border));
mBorderPaint.setStrokeWidth(
context.getResources().getDimension(R.dimen.wallet_card_border_width));
mBorderPaint.setStyle(Paint.Style.STROKE);
mBorderPaint.setAntiAlias(true);
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
float radius = getRadius();
canvas.drawRoundRect(0, 0, getWidth(), getHeight(), radius, radius, mBorderPaint);
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.wallet.ui;
import android.view.View;
import android.widget.ImageView;
import androidx.cardview.widget.CardView;
import androidx.recyclerview.widget.RecyclerView;
import com.android.systemui.R;
/**
* View holder for the quick access wallet card.
*/
class WalletCardViewHolder extends RecyclerView.ViewHolder {
final CardView mCardView;
final ImageView mImageView;
WalletCardViewInfo mCardViewInfo;
WalletCardViewHolder(View view) {
super(view);
mCardView = view.requireViewById(R.id.card);
mImageView = mCardView.requireViewById(R.id.card_image);
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.wallet.ui;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
interface WalletCardViewInfo {
String getCardId();
/**
* Image of the card.
*/
@NonNull
Drawable getCardDrawable();
/**
* Content description for the card.
*/
@Nullable
CharSequence getContentDescription();
/**
* Icon shown above the card.
*/
@Nullable
Drawable getIcon();
/**
* Text shown above the card.
*/
@NonNull
CharSequence getLabel();
}

View File

@@ -0,0 +1,330 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.wallet.ui;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.os.Handler;
import android.service.quickaccesswallet.GetWalletCardsError;
import android.service.quickaccesswallet.GetWalletCardsRequest;
import android.service.quickaccesswallet.GetWalletCardsResponse;
import android.service.quickaccesswallet.QuickAccessWalletClient;
import android.service.quickaccesswallet.SelectWalletCardRequest;
import android.service.quickaccesswallet.WalletCard;
import android.service.quickaccesswallet.WalletServiceEvent;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import com.android.internal.annotations.VisibleForTesting;
import com.android.systemui.R;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.settings.UserTracker;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
/** Controller for the wallet card carousel screen. */
public class WalletScreenController implements
WalletCardCarousel.OnSelectionListener,
QuickAccessWalletClient.OnWalletCardsRetrievedCallback,
QuickAccessWalletClient.WalletServiceEventListener {
private static final String TAG = "WalletScreenCtrl";
private static final String PREFS_HAS_CARDS = "has_cards";
private static final String PREFS_WALLET_VIEW_HEIGHT = "wallet_view_height";
private static final int MAX_CARDS = 10;
private static final long SELECTION_DELAY_MILLIS = TimeUnit.SECONDS.toMillis(30);
private Context mContext;
private final QuickAccessWalletClient mWalletClient;
private final ActivityStarter mActivityStarter;
private final Executor mExecutor;
private final Handler mHandler;
private final Runnable mSelectionRunnable = this::selectCard;
private final SharedPreferences mPrefs;
private final WalletView mWalletView;
private final WalletCardCarousel mCardCarousel;
@VisibleForTesting String mSelectedCardId;
@VisibleForTesting boolean mIsDismissed;
private boolean mIsDeviceLocked;
private boolean mHasRegisteredListener;
public WalletScreenController(
Context context,
WalletView walletView,
QuickAccessWalletClient walletClient,
ActivityStarter activityStarter,
Executor executor,
Handler handler,
UserTracker userTracker,
boolean isDeviceLocked) {
mContext = context;
mWalletClient = walletClient;
mActivityStarter = activityStarter;
mExecutor = executor;
mHandler = handler;
mPrefs = userTracker.getUserContext().getSharedPreferences(TAG, Context.MODE_PRIVATE);
mWalletView = walletView;
mWalletView.setMinimumHeight(getExpectedMinHeight());
mWalletView.setLayoutParams(
new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
mCardCarousel = mWalletView.getCardCarousel();
if (mCardCarousel != null) {
mCardCarousel.setSelectionListener(this);
}
if (!mPrefs.getBoolean(PREFS_HAS_CARDS, false)) {
// The empty state view is shown preemptively when cards were not returned last time
// to decrease perceived latency.
showEmptyStateView();
}
mIsDeviceLocked = isDeviceLocked;
}
/**
* Implements {@link QuickAccessWalletClient.OnWalletCardsRetrievedCallback}. Called when cards
* are retrieved successfully from the service. This is called on {@link #mExecutor}.
*/
@Override
public void onWalletCardsRetrieved(@NonNull GetWalletCardsResponse response) {
if (mIsDismissed) {
return;
}
List<WalletCard> walletCards = response.getWalletCards();
List<WalletCardViewInfo> data = new ArrayList<>(walletCards.size());
for (WalletCard card : walletCards) {
data.add(new QAWalletCardViewInfo(mContext, card));
}
mHandler.post(() -> {
if (data.isEmpty()) {
showEmptyStateView();
} else {
mWalletView.showCardCarousel(data, response.getSelectedIndex(), mIsDeviceLocked);
}
// The empty state view will not be shown preemptively next time if cards were returned
mPrefs.edit().putBoolean(PREFS_HAS_CARDS, !data.isEmpty()).apply();
removeMinHeightAndRecordHeightOnLayout();
});
}
/**
* Implements {@link QuickAccessWalletClient.OnWalletCardsRetrievedCallback}. Called when there
* is an error during card retrieval. This will be run on the {@link #mExecutor}.
*/
@Override
public void onWalletCardRetrievalError(@NonNull GetWalletCardsError error) {
if (mIsDismissed) {
return;
}
mHandler.post(() -> {
mWalletView.showErrorMessage(error.getMessage());
});
}
/**
* Implements {@link QuickAccessWalletClient.WalletServiceEventListener}. Called when the wallet
* application propagates an event, such as an NFC tap, to the quick access wallet view.
*/
@Override
public void onWalletServiceEvent(WalletServiceEvent event) {
if (mIsDismissed) {
return;
}
switch (event.getEventType()) {
case WalletServiceEvent.TYPE_NFC_PAYMENT_STARTED:
onDismissed();
break;
case WalletServiceEvent.TYPE_WALLET_CARDS_UPDATED:
queryWalletCards();
break;
default:
Log.w(TAG, "onWalletServiceEvent: Unknown event type");
}
}
@Override
public void onCardSelected(@NonNull WalletCardViewInfo card) {
if (mIsDismissed) {
return;
}
mSelectedCardId = card.getCardId();
selectCard();
}
private void selectCard() {
mHandler.removeCallbacks(mSelectionRunnable);
String selectedCardId = mSelectedCardId;
if (mIsDismissed || selectedCardId == null) {
return;
}
mWalletClient.selectWalletCard(new SelectWalletCardRequest(selectedCardId));
// Re-selecting the card keeps the connection bound so we continue to get service events
// even if the user keeps it open for a long time.
mHandler.postDelayed(mSelectionRunnable, SELECTION_DELAY_MILLIS);
}
@Override
public void onCardClicked(@NonNull WalletCardViewInfo cardInfo) {
if (!(cardInfo instanceof QAWalletCardViewInfo)
|| ((QAWalletCardViewInfo) cardInfo).mWalletCard == null
|| ((QAWalletCardViewInfo) cardInfo).mWalletCard.getPendingIntent() == null) {
return;
}
mActivityStarter.startActivity(
((QAWalletCardViewInfo) cardInfo).mWalletCard.getPendingIntent().getIntent(),
true);
}
@Override
public void queryWalletCards() {
if (mIsDismissed) {
return;
}
if (!mHasRegisteredListener) {
// Listener is registered even when device is locked. Should only be registered once.
mWalletClient.addWalletServiceEventListener(this);
mHasRegisteredListener = true;
}
mWalletView.show();
mWalletView.hideErrorMessage();
int iconSizePx = mContext.getResources().getDimensionPixelSize(R.dimen.wallet_icon_size);
int cardWidthPx = mCardCarousel.getCardWidthPx();
int cardHeightPx = mCardCarousel.getCardHeightPx();
GetWalletCardsRequest request =
new GetWalletCardsRequest(cardWidthPx, cardHeightPx, iconSizePx, MAX_CARDS);
mWalletClient.getWalletCards(mExecutor, request, this);
}
void onDismissed() {
if (mIsDismissed) {
return;
}
mIsDismissed = true;
mSelectedCardId = null;
mHandler.removeCallbacks(mSelectionRunnable);
mWalletClient.notifyWalletDismissed();
mWalletClient.removeWalletServiceEventListener(this);
mWalletView.animateDismissal();
// clear refs to the Wallet Activity
mContext = null;
}
private void showEmptyStateView() {
Drawable logo = mWalletClient.getLogo();
CharSequence logoContentDesc = mWalletClient.getServiceLabel();
CharSequence label = mWalletClient.getShortcutLongLabel();
Intent intent = mWalletClient.createWalletIntent();
if (logo == null
|| TextUtils.isEmpty(logoContentDesc)
|| TextUtils.isEmpty(label)
|| intent == null) {
Log.w(TAG, "QuickAccessWalletService manifest entry mis-configured");
// Issue is not likely to be resolved until manifest entries are enabled.
// Hide wallet feature until then.
mWalletView.hide();
mPrefs.edit().putInt(PREFS_WALLET_VIEW_HEIGHT, 0).apply();
} else {
logo.setTint(mContext.getColor(R.color.GM2_grey_900));
mWalletView.showEmptyStateView(
logo,
logoContentDesc,
label,
v -> mActivityStarter.startActivity(intent, true));
}
}
private int getExpectedMinHeight() {
int expectedHeight = mPrefs.getInt(PREFS_WALLET_VIEW_HEIGHT, -1);
if (expectedHeight == -1) {
Resources res = mContext.getResources();
expectedHeight = res.getDimensionPixelSize(R.dimen.min_wallet_empty_height);
}
return expectedHeight;
}
private void removeMinHeightAndRecordHeightOnLayout() {
mWalletView.setMinimumHeight(0);
mWalletView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom) {
mWalletView.removeOnLayoutChangeListener(this);
mPrefs.edit().putInt(PREFS_WALLET_VIEW_HEIGHT, bottom - top).apply();
}
});
}
@VisibleForTesting
static class QAWalletCardViewInfo implements WalletCardViewInfo {
private final WalletCard mWalletCard;
private final Drawable mCardDrawable;
private final Drawable mIconDrawable;
/**
* Constructor is called on background executor, so it is safe to load drawables
* synchronously.
*/
QAWalletCardViewInfo(Context context, WalletCard walletCard) {
mWalletCard = walletCard;
mCardDrawable = mWalletCard.getCardImage().loadDrawable(context);
Icon icon = mWalletCard.getCardIcon();
mIconDrawable = icon == null ? null : icon.loadDrawable(context);
}
@Override
public String getCardId() {
return mWalletCard.getCardId();
}
@Override
public Drawable getCardDrawable() {
return mCardDrawable;
}
@Override
public CharSequence getContentDescription() {
return mWalletCard.getContentDescription();
}
@Override
public Drawable getIcon() {
return mIconDrawable;
}
@Override
public CharSequence getLabel() {
return mWalletCard.getCardLabel();
}
}
}

View File

@@ -0,0 +1,242 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.wallet.ui;
import static com.android.systemui.wallet.ui.WalletCardCarousel.CARD_ANIM_ALPHA_DELAY;
import static com.android.systemui.wallet.ui.WalletCardCarousel.CARD_ANIM_ALPHA_DURATION;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.annotation.Nullable;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.ViewGroup;
import android.view.animation.AnimationUtils;
import android.view.animation.Interpolator;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import com.android.internal.annotations.VisibleForTesting;
import com.android.systemui.R;
import java.util.List;
/** Layout for the wallet screen. */
public class WalletView extends FrameLayout implements WalletCardCarousel.OnCardScrollListener {
private static final int CAROUSEL_IN_ANIMATION_DURATION = 300;
private static final int CAROUSEL_OUT_ANIMATION_DURATION = 200;
private static final int CARD_LABEL_ANIM_DELAY = 133;
private static final int CONTACTLESS_ICON_SIZE = 90;
private final WalletCardCarousel mCardCarousel;
private final ImageView mIcon;
private final TextView mCardLabel;
private final Button mWalletButton;
private final Interpolator mInInterpolator;
private final Interpolator mOutInterpolator;
private final float mAnimationTranslationX;
private final ViewGroup mCardCarouselContainer;
private final TextView mErrorView;
private final ViewGroup mEmptyStateView;
private CharSequence mCenterCardText;
public WalletView(Context context) {
this(context, null);
}
public WalletView(Context context, AttributeSet attrs) {
super(context, attrs);
inflate(context, R.layout.wallet_fullscreen, this);
mCardCarouselContainer = requireViewById(R.id.card_carousel_container);
mCardCarousel = requireViewById(R.id.card_carousel);
mCardCarousel.setCardScrollListener(this);
mIcon = requireViewById(R.id.icon);
mCardLabel = requireViewById(R.id.label);
mWalletButton = requireViewById(R.id.wallet_button);
mErrorView = requireViewById(R.id.error_view);
mEmptyStateView = requireViewById(R.id.wallet_empty_state);
mInInterpolator =
AnimationUtils.loadInterpolator(context, android.R.interpolator.fast_out_slow_in);
mOutInterpolator =
AnimationUtils.loadInterpolator(context, android.R.interpolator.accelerate_cubic);
mAnimationTranslationX = mCardCarousel.getCardWidthPx() / 4f;
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
mCardCarousel.setExpectedViewWidth(getWidth());
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// Forward touch events to card carousel to allow for swiping outside carousel bounds.
return mCardCarousel.onTouchEvent(event) || super.onTouchEvent(event);
}
@Override
public void onCardScroll(WalletCardViewInfo centerCard, WalletCardViewInfo nextCard,
float percentDistanceFromCenter) {
CharSequence centerCardText = centerCard.getLabel();
Drawable icon = centerCard.getIcon();
if (icon != null) {
mIcon.setImageDrawable(resizeDrawable(getResources(), icon));
mIcon.setVisibility(VISIBLE);
} else {
mIcon.setVisibility(INVISIBLE);
}
if (!TextUtils.equals(mCenterCardText, centerCardText)) {
mCenterCardText = centerCardText;
mCardLabel.setText(centerCardText);
}
if (TextUtils.equals(centerCardText, nextCard.getLabel())) {
mCardLabel.setAlpha(1f);
} else {
mCardLabel.setAlpha(percentDistanceFromCenter);
mIcon.setAlpha(percentDistanceFromCenter);
}
}
void showCardCarousel(
List<WalletCardViewInfo> data, int selectedIndex, boolean isDeviceLocked) {
boolean shouldAnimate = mCardCarousel.setData(data, selectedIndex);
mCardCarouselContainer.setVisibility(VISIBLE);
mErrorView.setVisibility(GONE);
if (isDeviceLocked) {
// TODO(b/182964813): Add click action to prompt device unlock.
mWalletButton.setText(R.string.wallet_button_label_device_locked);
} else {
mWalletButton.setText(R.string.wallet_button_label_device_unlocked);
}
if (shouldAnimate) {
// If the empty state is visible, animate it away and delay the card carousel animation
int emptyStateAnimDelay = 0;
if (mEmptyStateView.getVisibility() == VISIBLE) {
emptyStateAnimDelay = CARD_ANIM_ALPHA_DURATION;
mEmptyStateView.animate()
.alpha(0)
.setDuration(emptyStateAnimDelay)
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mEmptyStateView.setVisibility(GONE);
}
})
.start();
}
mCardLabel.setAlpha(0f);
mCardLabel.animate().alpha(1f)
.setStartDelay(CARD_LABEL_ANIM_DELAY + emptyStateAnimDelay)
.setDuration(CARD_ANIM_ALPHA_DURATION)
.start();
mCardCarousel.setExtraAnimationDelay(emptyStateAnimDelay);
mCardCarousel.setTranslationX(mAnimationTranslationX);
mCardCarousel.animate().translationX(0)
.setInterpolator(mInInterpolator)
.setDuration(CAROUSEL_IN_ANIMATION_DURATION)
.setStartDelay(emptyStateAnimDelay)
.start();
}
}
void animateDismissal() {
if (mCardCarouselContainer.getVisibility() != VISIBLE) {
return;
}
mCardCarousel.animate().translationX(mAnimationTranslationX)
.setInterpolator(mOutInterpolator)
.setDuration(CAROUSEL_OUT_ANIMATION_DURATION)
.start();
mCardCarouselContainer.animate()
.alpha(0f)
.setDuration(CARD_ANIM_ALPHA_DURATION)
.setStartDelay(CARD_ANIM_ALPHA_DELAY)
.start();
}
void showEmptyStateView(Drawable logo, CharSequence logoContentDescription, CharSequence label,
OnClickListener clickListener) {
mEmptyStateView.setVisibility(VISIBLE);
mErrorView.setVisibility(GONE);
mCardCarouselContainer.setVisibility(GONE);
ImageView logoView = mEmptyStateView.requireViewById(R.id.empty_state_icon);
logoView.setImageDrawable(logo);
logoView.setContentDescription(logoContentDescription);
mEmptyStateView.<TextView>requireViewById(R.id.empty_state_title).setText(label);
mEmptyStateView.setOnClickListener(clickListener);
}
void showErrorMessage(@Nullable CharSequence message) {
if (TextUtils.isEmpty(message)) {
message = getResources().getText(R.string.wallet_error_generic);
}
mErrorView.setText(message);
mErrorView.setVisibility(VISIBLE);
mCardCarouselContainer.setVisibility(GONE);
mEmptyStateView.setVisibility(GONE);
}
void hide() {
setVisibility(GONE);
}
void show() {
setVisibility(VISIBLE);
}
void hideErrorMessage() {
mErrorView.setVisibility(GONE);
}
WalletCardCarousel getCardCarousel() {
return mCardCarousel;
}
Button getWalletButton() {
return mWalletButton;
}
@VisibleForTesting
TextView getErrorView() {
return mErrorView;
}
@VisibleForTesting
ViewGroup getEmptyStateView() {
return mEmptyStateView;
}
@VisibleForTesting
ViewGroup getCardCarouselContainer() {
return mCardCarouselContainer;
}
private static Drawable resizeDrawable(Resources resources, Drawable drawable) {
Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
return new BitmapDrawable(resources, Bitmap.createScaledBitmap(
bitmap, CONTACTLESS_ICON_SIZE, CONTACTLESS_ICON_SIZE, true));
}
}

View File

@@ -0,0 +1,274 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.wallet.ui;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.os.Handler;
import android.service.quickaccesswallet.GetWalletCardsError;
import android.service.quickaccesswallet.GetWalletCardsRequest;
import android.service.quickaccesswallet.GetWalletCardsResponse;
import android.service.quickaccesswallet.QuickAccessWalletClient;
import android.service.quickaccesswallet.QuickAccessWalletService;
import android.service.quickaccesswallet.WalletCard;
import android.service.quickaccesswallet.WalletServiceEvent;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
import androidx.test.filters.SmallTest;
import com.android.systemui.SysuiTestCase;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.settings.UserTracker;
import com.google.common.util.concurrent.MoreExecutors;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Collections;
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper
@SmallTest
public class WalletScreenControllerTest extends SysuiTestCase {
private static final int MAX_CARDS = 10;
private static final String CARD_ID = "card_id";
private static final CharSequence SHORTCUT_SHORT_LABEL = "View all";
private static final CharSequence SHORTCUT_LONG_LABEL = "Add a payment method";
private static final CharSequence SERVICE_LABEL = "Wallet app";
private final WalletView mWalletView = new WalletView(mContext);
private final Drawable mWalletLogo = mContext.getDrawable(android.R.drawable.ic_lock_lock);
private final Intent mWalletIntent = new Intent(QuickAccessWalletService.ACTION_VIEW_WALLET)
.setComponent(new ComponentName(mContext.getPackageName(), "WalletActivity"));
@Mock
QuickAccessWalletClient mWalletClient;
@Mock
ActivityStarter mActivityStarter;
@Mock
UserTracker mUserTracker;
@Captor
ArgumentCaptor<Intent> mIntentCaptor;
@Captor
ArgumentCaptor<GetWalletCardsRequest> mRequestCaptor;
@Captor
ArgumentCaptor<QuickAccessWalletClient.OnWalletCardsRetrievedCallback> mCallbackCaptor;
@Captor
ArgumentCaptor<QuickAccessWalletClient.WalletServiceEventListener> mListenerCaptor;
private WalletScreenController mController;
private TestableLooper mTestableLooper;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mTestableLooper = TestableLooper.get(this);
when(mUserTracker.getUserContext()).thenReturn(mContext);
when(mWalletClient.getLogo()).thenReturn(mWalletLogo);
when(mWalletClient.getShortcutLongLabel()).thenReturn(SHORTCUT_LONG_LABEL);
when(mWalletClient.getShortcutShortLabel()).thenReturn(SHORTCUT_SHORT_LABEL);
when(mWalletClient.getServiceLabel()).thenReturn(SERVICE_LABEL);
when(mWalletClient.createWalletIntent()).thenReturn(mWalletIntent);
mController = new WalletScreenController(
mContext,
mWalletView,
mWalletClient,
mActivityStarter,
MoreExecutors.directExecutor(),
new Handler(mTestableLooper.getLooper()),
mUserTracker,
/* isDeviceLocked= */false);
}
@Test
public void queryCards_hasCards_showCarousel() {
GetWalletCardsResponse response =
new GetWalletCardsResponse(
Collections.singletonList(createWalletCard(mContext)), 0);
mController.queryWalletCards();
mTestableLooper.processAllMessages();
verify(mWalletClient).getWalletCards(any(), any(), mCallbackCaptor.capture());
mCallbackCaptor.getValue().onWalletCardsRetrieved(response);
mTestableLooper.processAllMessages();
assertEquals(VISIBLE, mWalletView.getCardCarouselContainer().getVisibility());
assertEquals(GONE, mWalletView.getErrorView().getVisibility());
}
@Test
public void queryCards_noCards_showEmptyState() {
GetWalletCardsResponse response = new GetWalletCardsResponse(Collections.EMPTY_LIST, 0);
mController.queryWalletCards();
mTestableLooper.processAllMessages();
verify(mWalletClient).getWalletCards(any(), any(), mCallbackCaptor.capture());
mCallbackCaptor.getValue().onWalletCardsRetrieved(response);
mTestableLooper.processAllMessages();
assertEquals(GONE, mWalletView.getCardCarouselContainer().getVisibility());
assertEquals(VISIBLE, mWalletView.getEmptyStateView().getVisibility());
assertEquals(GONE, mWalletView.getErrorView().getVisibility());
}
@Test
public void queryCards_error_showErrorView() {
String errorMessage = "getWalletCardsError";
GetWalletCardsError error = new GetWalletCardsError(createIcon(), errorMessage);
mController.queryWalletCards();
mTestableLooper.processAllMessages();
verify(mWalletClient).getWalletCards(any(), any(), mCallbackCaptor.capture());
mCallbackCaptor.getValue().onWalletCardRetrievalError(error);
mTestableLooper.processAllMessages();
assertEquals(GONE, mWalletView.getCardCarouselContainer().getVisibility());
assertEquals(GONE, mWalletView.getEmptyStateView().getVisibility());
assertEquals(VISIBLE, mWalletView.getErrorView().getVisibility());
assertEquals(errorMessage, mWalletView.getErrorView().getText().toString());
}
@Test
public void onWalletServiceEvent_nfcPaymentStart_dismiss() {
WalletServiceEvent event =
new WalletServiceEvent(WalletServiceEvent.TYPE_NFC_PAYMENT_STARTED);
mController.onWalletServiceEvent(event);
mTestableLooper.processAllMessages();
assertNull(mController.mSelectedCardId);
assertTrue(mController.mIsDismissed);
verify(mWalletClient).notifyWalletDismissed();
}
@Test
public void onWalletServiceEvent_walletCardsUpdate_queryCards() {
mController.queryWalletCards();
verify(mWalletClient).addWalletServiceEventListener(mListenerCaptor.capture());
WalletServiceEvent event =
new WalletServiceEvent(WalletServiceEvent.TYPE_WALLET_CARDS_UPDATED);
QuickAccessWalletClient.WalletServiceEventListener listener = mListenerCaptor.getValue();
listener.onWalletServiceEvent(event);
mTestableLooper.processAllMessages();
verify(mWalletClient, times(2))
.getWalletCards(any(), mRequestCaptor.capture(), mCallbackCaptor.capture());
GetWalletCardsRequest request = mRequestCaptor.getValue();
assertEquals(MAX_CARDS, request.getMaxCards());
}
@Test
public void onCardSelected() {
mController.onCardSelected(createCardViewInfo());
assertEquals(CARD_ID, mController.mSelectedCardId);
}
@Test
public void onCardClicked_startIntent() {
WalletCardViewInfo walletCardViewInfo = createCardViewInfo();
mController.onCardClicked(walletCardViewInfo);
verify(mActivityStarter).startActivity(mIntentCaptor.capture(), eq(true));
assertEquals(mWalletIntent.getAction(), mIntentCaptor.getValue().getAction());
assertEquals(mWalletIntent.getComponent(), mIntentCaptor.getValue().getComponent());
}
@Test
public void onWalletCardsRetrieved_cardDataEmpty_intentIsNull_hidesWallet() {
when(mWalletClient.createWalletIntent()).thenReturn(null);
GetWalletCardsResponse response = new GetWalletCardsResponse(Collections.emptyList(), 0);
mController.onWalletCardsRetrieved(response);
mTestableLooper.processAllMessages();
assertEquals(GONE, mWalletView.getVisibility());
}
@Test
public void onWalletCardsRetrieved_cardDataEmpty_logoIsNull_hidesWallet() {
when(mWalletClient.getLogo()).thenReturn(null);
GetWalletCardsResponse response = new GetWalletCardsResponse(Collections.emptyList(), 0);
mController.onWalletCardsRetrieved(response);
mTestableLooper.processAllMessages();
assertEquals(GONE, mWalletView.getVisibility());
}
@Test
public void onWalletCardsRetrieved_cardDataEmpty_labelIsEmpty_hidesWallet() {
when(mWalletClient.getShortcutLongLabel()).thenReturn("");
GetWalletCardsResponse response = new GetWalletCardsResponse(Collections.emptyList(), 0);
mController.onWalletCardsRetrieved(response);
mTestableLooper.processAllMessages();
assertEquals(GONE, mWalletView.getVisibility());
}
private WalletCard createWalletCard(Context context) {
PendingIntent pendingIntent =
PendingIntent.getActivity(context, 0, mWalletIntent, PendingIntent.FLAG_IMMUTABLE);
return new WalletCard.Builder(CARD_ID, createIcon(), "description", pendingIntent).build();
}
private static Icon createIcon() {
return Icon.createWithBitmap(Bitmap.createBitmap(70, 44, Bitmap.Config.ARGB_8888));
}
private WalletCardViewInfo createCardViewInfo() {
return new WalletScreenController.QAWalletCardViewInfo(
mContext, createWalletCard(mContext));
}
}