Make the PeopleSpaceActivity screenshot testable

This CL makes the PeopleSpaceActivity screenshot testable by extracting
a ViewModel and ViewBinder out of it. See ag/19289788 for the associated
screenshot tests.

Note that I tried to change the code inflating and updating the View as
less as possible, to avoid introducing bugs. Once this CL and the
associated screenshots are submitted, I will go ahead and refactor this
code even more. This CL is meant to be an example of the kind of
refactoring required to make a UI screenshot testable, so I tried to not
make it too big.

Bug: 238993727
Test: atest PeopleSpaceScreenshotTest
Change-Id: Ib792bd5da41c9e8bdab6cba7108a249bab10ebd2
This commit is contained in:
Jordan Demeulenaere
2022-07-13 10:42:10 +02:00
parent 170bf9e812
commit 8a169ff03c
16 changed files with 760 additions and 235 deletions

View File

@@ -110,6 +110,7 @@ android_library {
"androidx.arch.core_core-runtime",
"androidx.lifecycle_lifecycle-common-java8",
"androidx.lifecycle_lifecycle-extensions",
"androidx.lifecycle_lifecycle-runtime-ktx",
"androidx.dynamicanimation_dynamicanimation",
"androidx-constraintlayout_constraintlayout",
"androidx.exifinterface_exifinterface",
@@ -218,6 +219,7 @@ android_library {
"androidx.arch.core_core-runtime",
"androidx.lifecycle_lifecycle-common-java8",
"androidx.lifecycle_lifecycle-extensions",
"androidx.lifecycle_lifecycle-runtime-ktx",
"androidx.dynamicanimation_dynamicanimation",
"androidx-constraintlayout_constraintlayout",
"androidx.exifinterface_exifinterface",

View File

@@ -13,103 +13,11 @@
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<LinearLayout
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
android:id="@+id/top_level"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/select_conversation_title"
android:text="@string/select_conversation_title"
android:gravity="center"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.ListItem"
android:textColor="?android:attr/textColorPrimary"
android:textSize="24sp"/>
<TextView
android:id="@+id/select_conversation"
android:text="@string/select_conversation_text"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.ListItem"
android:textColor="?android:attr/textColorPrimary"
android:textSize="16sp"
android:paddingVertical="24dp"
android:paddingHorizontal="48dp"/>
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/scroll_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="vertical">
<LinearLayout
android:id="@+id/priority"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="35dp">
<TextView
android:id="@+id/priority_header"
android:text="@string/priority_conversations"
android:layout_width="wrap_content"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Notification.Title"
android:textColor="?androidprv:attr/colorAccentPrimaryVariant"
android:textSize="14sp"
android:paddingStart="16dp"
android:layout_height="wrap_content"/>
<LinearLayout
android:id="@+id/priority_tiles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical"
android:background="@drawable/rounded_bg_full_large_radius"
android:clipToOutline="true">
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/recent"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/recent_header"
android:gravity="start"
android:text="@string/recent_conversations"
android:layout_width="wrap_content"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Notification.Title"
android:textColor="?androidprv:attr/colorAccentPrimaryVariant"
android:textSize="14sp"
android:paddingStart="16dp"
android:layout_height="wrap_content"/>
<LinearLayout
android:id="@+id/recent_tiles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical"
android:background="@drawable/rounded_bg_full_large_radius"
android:clipToOutline="true">
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>
android:layout_height="match_parent">
<!-- The content of people_space_activity_(no|with)_conversations.xml will be added here at
runtime depending on the number of conversations to show. -->
</FrameLayout>

View File

@@ -16,7 +16,7 @@
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
android:id="@+id/top_level"
android:id="@+id/top_level_no_conversations"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp"

View File

@@ -0,0 +1,115 @@
<!--
~ Copyright (C) 2022 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.
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
android:id="@+id/top_level_with_conversations"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/select_conversation_title"
android:text="@string/select_conversation_title"
android:gravity="center"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.ListItem"
android:textColor="?android:attr/textColorPrimary"
android:textSize="24sp"/>
<TextView
android:id="@+id/select_conversation"
android:text="@string/select_conversation_text"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.ListItem"
android:textColor="?android:attr/textColorPrimary"
android:textSize="16sp"
android:paddingVertical="24dp"
android:paddingHorizontal="48dp"/>
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/scroll_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="vertical">
<LinearLayout
android:id="@+id/priority"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="35dp">
<TextView
android:id="@+id/priority_header"
android:text="@string/priority_conversations"
android:layout_width="wrap_content"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Notification.Title"
android:textColor="?androidprv:attr/colorAccentPrimaryVariant"
android:textSize="14sp"
android:paddingStart="16dp"
android:layout_height="wrap_content"/>
<LinearLayout
android:id="@+id/priority_tiles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical"
android:background="@drawable/rounded_bg_full_large_radius"
android:clipToOutline="true">
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/recent"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/recent_header"
android:gravity="start"
android:text="@string/recent_conversations"
android:layout_width="wrap_content"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Notification.Title"
android:textColor="?androidprv:attr/colorAccentPrimaryVariant"
android:textSize="14sp"
android:paddingStart="16dp"
android:layout_height="wrap_content"/>
<LinearLayout
android:id="@+id/recent_tiles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical"
android:background="@drawable/rounded_bg_full_large_radius"
android:clipToOutline="true">
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

View File

@@ -37,8 +37,8 @@
<ImageView
android:id="@+id/tile_view_person_icon"
android:layout_width="52dp"
android:layout_height="52dp" />
android:layout_width="@dimen/avatar_size_for_medium"
android:layout_height="@dimen/avatar_size_for_medium" />
<LinearLayout
android:orientation="horizontal"

View File

@@ -46,6 +46,7 @@ import com.android.systemui.lowlightclock.LowLightClockController;
import com.android.systemui.media.dagger.MediaProjectionModule;
import com.android.systemui.model.SysUiState;
import com.android.systemui.navigationbar.NavigationBarComponent;
import com.android.systemui.people.PeopleModule;
import com.android.systemui.plugins.BcSmartspaceDataPlugin;
import com.android.systemui.privacy.PrivacyModule;
import com.android.systemui.recents.Recents;
@@ -122,6 +123,7 @@ import dagger.Provides;
LogModule.class,
MediaProjectionModule.class,
PeopleHubModule.class,
PeopleModule.class,
PluginModule.class,
PrivacyModule.class,
QsFrameTranslateModule.class,

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) 2022 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.people
import com.android.systemui.people.data.repository.PeopleTileRepository
import com.android.systemui.people.data.repository.PeopleTileRepositoryImpl
import com.android.systemui.people.data.repository.PeopleWidgetRepository
import com.android.systemui.people.data.repository.PeopleWidgetRepositoryImpl
import dagger.Binds
import dagger.Module
/** Dagger module to provide/bind people space dependencies. */
@Module
interface PeopleModule {
@Binds fun bindTileRepository(impl: PeopleTileRepositoryImpl): PeopleTileRepository
@Binds fun bindWidgetRepository(impl: PeopleWidgetRepositoryImpl): PeopleWidgetRepository
}

View File

@@ -19,144 +19,52 @@ package com.android.systemui.people;
import static android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID;
import static android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID;
import static com.android.systemui.people.PeopleTileViewHelper.getPersonIconBitmap;
import static com.android.systemui.people.PeopleTileViewHelper.getSizeInDp;
import android.app.Activity;
import android.app.people.PeopleSpaceTile;
import android.content.Context;
import android.content.Intent;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.Outline;
import android.graphics.drawable.GradientDrawable;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.widget.LinearLayout;
import com.android.systemui.R;
import com.android.systemui.people.widget.PeopleSpaceWidgetManager;
import com.android.systemui.people.widget.PeopleTileKey;
import androidx.activity.ComponentActivity;
import androidx.lifecycle.ViewModelProvider;
import java.util.ArrayList;
import java.util.List;
import com.android.systemui.people.ui.view.PeopleViewBinder;
import com.android.systemui.people.ui.viewmodel.PeopleViewModel;
import javax.inject.Inject;
/** People Tile Widget configuration activity that shows the user their conversation tiles. */
public class PeopleSpaceActivity extends Activity {
public class PeopleSpaceActivity extends ComponentActivity {
private static final String TAG = "PeopleSpaceActivity";
private static final boolean DEBUG = PeopleSpaceUtils.DEBUG;
private PeopleSpaceWidgetManager mPeopleSpaceWidgetManager;
private Context mContext;
private int mAppWidgetId;
private final PeopleViewModel.Factory mViewModelFactory;
private PeopleViewModel mViewModel;
@Inject
public PeopleSpaceActivity(PeopleSpaceWidgetManager peopleSpaceWidgetManager) {
public PeopleSpaceActivity(PeopleViewModel.Factory viewModelFactory) {
super();
mPeopleSpaceWidgetManager = peopleSpaceWidgetManager;
mViewModelFactory = viewModelFactory;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mContext = getApplicationContext();
mAppWidgetId = getIntent().getIntExtra(EXTRA_APPWIDGET_ID,
INVALID_APPWIDGET_ID);
setResult(RESULT_CANCELED);
}
mViewModel = new ViewModelProvider(this, mViewModelFactory).get(PeopleViewModel.class);
/** Builds the conversation selection activity. */
private void buildActivity() {
List<PeopleSpaceTile> priorityTiles = new ArrayList<>();
List<PeopleSpaceTile> recentTiles = new ArrayList<>();
try {
priorityTiles = mPeopleSpaceWidgetManager.getPriorityTiles();
recentTiles = mPeopleSpaceWidgetManager.getRecentTiles();
} catch (Exception e) {
Log.e(TAG, "Couldn't retrieve conversations", e);
}
// Update the widget ID coming from the intent.
int widgetId = getIntent().getIntExtra(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID);
mViewModel.onWidgetIdChanged(widgetId);
// If no conversations, render activity without conversations
if (recentTiles.isEmpty() && priorityTiles.isEmpty()) {
setContentView(R.layout.people_space_activity_no_conversations);
// The Tile preview has colorBackground as its background. Change it so it's different
// than the activity's background.
LinearLayout item = findViewById(android.R.id.background);
GradientDrawable shape = (GradientDrawable) item.getBackground();
final TypedArray ta = mContext.getTheme().obtainStyledAttributes(
new int[]{com.android.internal.R.attr.colorSurface});
shape.setColor(ta.getColor(0, Color.WHITE));
return;
}
setContentView(R.layout.people_space_activity);
setTileViews(R.id.priority, R.id.priority_tiles, priorityTiles);
setTileViews(R.id.recent, R.id.recent_tiles, recentTiles);
}
private ViewOutlineProvider mViewOutlineProvider = new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(),
mContext.getResources().getDimension(R.dimen.people_space_widget_radius));
}
};
/** Sets a {@link PeopleSpaceTileView}s for each conversation. */
private void setTileViews(int viewId, int tilesId, List<PeopleSpaceTile> tiles) {
if (tiles.isEmpty()) {
LinearLayout view = findViewById(viewId);
view.setVisibility(View.GONE);
return;
}
ViewGroup layout = findViewById(tilesId);
layout.setClipToOutline(true);
layout.setOutlineProvider(mViewOutlineProvider);
for (int i = 0; i < tiles.size(); ++i) {
PeopleSpaceTile tile = tiles.get(i);
PeopleSpaceTileView tileView = new PeopleSpaceTileView(mContext,
layout, tile.getId(), i == (tiles.size() - 1));
setTileView(tileView, tile);
}
}
/** Sets {@code tileView} with the data in {@code conversation}. */
private void setTileView(PeopleSpaceTileView tileView, PeopleSpaceTile tile) {
try {
if (tile.getUserName() != null) {
tileView.setName(tile.getUserName().toString());
}
tileView.setPersonIcon(getPersonIconBitmap(mContext, tile,
getSizeInDp(mContext, R.dimen.avatar_size_for_medium,
mContext.getResources().getDisplayMetrics().density)));
PeopleTileKey key = new PeopleTileKey(tile);
tileView.setOnClickListener(v -> storeWidgetConfiguration(tile, key));
} catch (Exception e) {
Log.e(TAG, "Couldn't retrieve shortcut information", e);
}
}
/** Stores the user selected configuration for {@code mAppWidgetId}. */
private void storeWidgetConfiguration(PeopleSpaceTile tile, PeopleTileKey key) {
if (PeopleSpaceUtils.DEBUG) {
if (DEBUG) {
Log.d(TAG, "Put " + tile.getUserName() + "'s shortcut ID: "
+ tile.getId() + " for widget ID: "
+ mAppWidgetId);
}
}
mPeopleSpaceWidgetManager.addNewWidget(mAppWidgetId, key);
finishActivity();
ViewGroup view = PeopleViewBinder.create(this);
PeopleViewBinder.bind(view, mViewModel, /* lifecycleOwner= */ this,
() -> {
finishActivity();
return null;
});
setContentView(view);
}
/** Finish activity with a successful widget configuration result. */
@@ -169,19 +77,13 @@ public class PeopleSpaceActivity extends Activity {
/** Finish activity without choosing a widget. */
public void dismissActivity(View v) {
if (DEBUG) Log.d(TAG, "Activity dismissed with no widgets added!");
setResult(RESULT_CANCELED);
finish();
}
private void setActivityResult(int result) {
Intent resultValue = new Intent();
resultValue.putExtra(EXTRA_APPWIDGET_ID, mAppWidgetId);
resultValue.putExtra(EXTRA_APPWIDGET_ID, mViewModel.getAppWidgetId().getValue());
setResult(result, resultValue);
}
@Override
protected void onResume() {
super.onResume();
// Refresh tile views to sync new conversations.
buildActivity();
}
}

View File

@@ -28,6 +28,7 @@ import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.util.IconDrawableFactory;
import android.util.Log;
import android.view.ContextThemeWrapper;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
@@ -52,16 +53,15 @@ class PeopleStoryIconFactory implements AutoCloseable {
PeopleStoryIconFactory(Context context, PackageManager pm,
IconDrawableFactory iconDrawableFactory, int iconSizeDp) {
context.setTheme(android.R.style.Theme_DeviceDefault_DayNight);
mIconBitmapSize = (int) (iconSizeDp * context.getResources().getDisplayMetrics().density);
mDensity = context.getResources().getDisplayMetrics().density;
mContext = new ContextThemeWrapper(context, android.R.style.Theme_DeviceDefault_DayNight);
mIconBitmapSize = (int) (iconSizeDp * mContext.getResources().getDisplayMetrics().density);
mDensity = mContext.getResources().getDisplayMetrics().density;
mIconSize = mDensity * iconSizeDp;
mPackageManager = pm;
mIconDrawableFactory = iconDrawableFactory;
mImportantConversationColor = context.getColor(R.color.important_conversation);
mAccentColor = Utils.getColorAttr(context,
mImportantConversationColor = mContext.getColor(R.color.important_conversation);
mAccentColor = Utils.getColorAttr(mContext,
com.android.internal.R.attr.colorAccentPrimaryVariant).getDefaultColor();
mContext = context;
}

View File

@@ -75,6 +75,7 @@ import androidx.core.math.MathUtils;
import com.android.internal.annotations.VisibleForTesting;
import com.android.systemui.R;
import com.android.systemui.people.data.model.PeopleTileModel;
import com.android.systemui.people.widget.LaunchConversationActivity;
import com.android.systemui.people.widget.PeopleSpaceWidgetProvider;
import com.android.systemui.people.widget.PeopleTileKey;
@@ -299,7 +300,8 @@ public class PeopleTileViewHelper {
return createLastInteractionRemoteViews();
}
private static boolean isDndBlockingTileData(@Nullable PeopleSpaceTile tile) {
/** Whether the conversation associated with {@code tile} can bypass DND. */
public static boolean isDndBlockingTileData(@Nullable PeopleSpaceTile tile) {
if (tile == null) return false;
int notificationPolicyState = tile.getNotificationPolicyState();
@@ -536,7 +538,8 @@ public class PeopleTileViewHelper {
return views;
}
private static boolean getHasNewStory(PeopleSpaceTile tile) {
/** Whether {@code tile} has a new story. */
public static boolean getHasNewStory(PeopleSpaceTile tile) {
return tile.getStatuses() != null && tile.getStatuses().stream().anyMatch(
c -> c.getActivity() == ACTIVITY_NEW_STORY);
}
@@ -1250,16 +1253,24 @@ public class PeopleTileViewHelper {
}
/** Returns a bitmap with the user icon and package icon. */
public static Bitmap getPersonIconBitmap(Context context, PeopleSpaceTile tile,
public static Bitmap getPersonIconBitmap(Context context, PeopleTileModel tile,
int maxAvatarSize) {
boolean hasNewStory = getHasNewStory(tile);
return getPersonIconBitmap(context, tile, maxAvatarSize, hasNewStory);
return getPersonIconBitmap(context, maxAvatarSize, tile.getHasNewStory(),
tile.getUserIcon(), tile.getKey().getPackageName(), tile.getKey().getUserId(),
tile.isImportant(), tile.isDndBlocking());
}
/** Returns a bitmap with the user icon and package icon. */
private static Bitmap getPersonIconBitmap(
Context context, PeopleSpaceTile tile, int maxAvatarSize, boolean hasNewStory) {
Icon icon = tile.getUserIcon();
return getPersonIconBitmap(context, maxAvatarSize, hasNewStory, tile.getUserIcon(),
tile.getPackageName(), getUserId(tile),
tile.isImportantConversation(), isDndBlockingTileData(tile));
}
private static Bitmap getPersonIconBitmap(
Context context, int maxAvatarSize, boolean hasNewStory, Icon icon, String packageName,
int userId, boolean importantConversation, boolean dndBlockingTileData) {
if (icon == null) {
Drawable placeholder = context.getDrawable(R.drawable.ic_avatar_with_badge).mutate();
placeholder.setColorFilter(getDisabledColorFilter());
@@ -1272,10 +1283,10 @@ public class PeopleTileViewHelper {
RoundedBitmapDrawable roundedDrawable = RoundedBitmapDrawableFactory.create(
context.getResources(), icon.getBitmap());
Drawable personDrawable = storyIcon.getPeopleTileDrawable(roundedDrawable,
tile.getPackageName(), getUserId(tile), tile.isImportantConversation(),
packageName, userId, importantConversation,
hasNewStory);
if (isDndBlockingTileData(tile)) {
if (dndBlockingTileData) {
personDrawable.setColorFilter(getDisabledColorFilter());
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2022 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.people.data.model
import android.graphics.drawable.Icon
import com.android.systemui.people.widget.PeopleTileKey
/** Models a tile/conversation. */
data class PeopleTileModel(
val key: PeopleTileKey,
val username: String,
val userIcon: Icon,
val hasNewStory: Boolean,
val isImportant: Boolean,
val isDndBlocking: Boolean,
)

View File

@@ -0,0 +1,61 @@
/*
* Copyright (C) 2022 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.people.data.repository
import android.app.people.PeopleSpaceTile
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.people.PeopleTileViewHelper
import com.android.systemui.people.data.model.PeopleTileModel
import com.android.systemui.people.widget.PeopleSpaceWidgetManager
import com.android.systemui.people.widget.PeopleTileKey
import javax.inject.Inject
/** A Repository to fetch the current tiles/conversations. */
// TODO(b/238993727): Make the tiles API reactive.
interface PeopleTileRepository {
/* The current priority tiles. */
fun priorityTiles(): List<PeopleTileModel>
/* The current recent tiles. */
fun recentTiles(): List<PeopleTileModel>
}
@SysUISingleton
class PeopleTileRepositoryImpl
@Inject
constructor(
private val peopleSpaceWidgetManager: PeopleSpaceWidgetManager,
) : PeopleTileRepository {
override fun priorityTiles(): List<PeopleTileModel> {
return peopleSpaceWidgetManager.priorityTiles.map { it.toModel() }
}
override fun recentTiles(): List<PeopleTileModel> {
return peopleSpaceWidgetManager.recentTiles.map { it.toModel() }
}
private fun PeopleSpaceTile.toModel(): PeopleTileModel {
return PeopleTileModel(
PeopleTileKey(this),
userName.toString(),
userIcon,
PeopleTileViewHelper.getHasNewStory(this),
isImportantConversation,
PeopleTileViewHelper.isDndBlockingTileData(this),
)
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) 2022 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.people.data.repository
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.people.widget.PeopleSpaceWidgetManager
import com.android.systemui.people.widget.PeopleTileKey
import javax.inject.Inject
interface PeopleWidgetRepository {
/**
* Bind the widget with ID [widgetId] to the tile keyed by [tileKey].
*
* If there is already a widget with [widgetId], this existing widget will be reconfigured and
* associated to this tile. If there is no widget with [widgetId], a new one will be created.
*/
fun setWidgetTile(widgetId: Int, tileKey: PeopleTileKey)
}
@SysUISingleton
class PeopleWidgetRepositoryImpl
@Inject
constructor(
private val peopleSpaceWidgetManager: PeopleSpaceWidgetManager,
) : PeopleWidgetRepository {
override fun setWidgetTile(widgetId: Int, tileKey: PeopleTileKey) {
peopleSpaceWidgetManager.addNewWidget(widgetId, tileKey)
}
}

View File

@@ -0,0 +1,243 @@
/*
* Copyright (C) 2022 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.people.ui.view
import android.content.Context
import android.graphics.Color
import android.graphics.Outline
import android.graphics.drawable.GradientDrawable
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.LinearLayout
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Lifecycle.State.CREATED
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.android.systemui.R
import com.android.systemui.people.PeopleSpaceTileView
import com.android.systemui.people.ui.viewmodel.PeopleTileViewModel
import com.android.systemui.people.ui.viewmodel.PeopleViewModel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
/** A ViewBinder for [PeopleViewModel]. */
object PeopleViewBinder {
private const val TAG = "PeopleSpaceViewBinder"
/**
* The [ViewOutlineProvider] used to clip the corner radius of the recent and priority lists.
*/
private val ViewOutlineProvider =
object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(
0,
0,
view.width,
view.height,
view.context.resources.getDimension(R.dimen.people_space_widget_radius),
)
}
}
/** Create a [View] that can later be [bound][bind] to a [PeopleViewModel]. */
@JvmStatic
fun create(context: Context): ViewGroup {
return LayoutInflater.from(context)
.inflate(R.layout.people_space_activity, /* root= */ null) as ViewGroup
}
/** Bind [view] to [viewModel]. */
@JvmStatic
fun bind(
view: ViewGroup,
viewModel: PeopleViewModel,
lifecycleOwner: LifecycleOwner,
onFinish: () -> Unit,
) {
// Call [onFinish] this activity when the ViewModel tells us so.
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(CREATED) {
viewModel.isFinished.collect { isFinished ->
if (isFinished) {
viewModel.clearIsFinished()
onFinish()
}
}
}
}
// Start collecting the UI data once the Activity is STARTED.
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
viewModel.priorityTiles,
viewModel.recentTiles,
) { priority, recent ->
priority to recent
}
.collect { (priorityTiles, recentTiles) ->
if (priorityTiles.isNotEmpty() || recentTiles.isNotEmpty()) {
setConversationsContent(
view,
priorityTiles,
recentTiles,
viewModel::onTileClicked,
)
} else {
setNoConversationsContent(view)
}
}
}
}
// Make sure to refresh the tiles/conversations when the Activity is resumed, so that it
// updates them when going back to the Activity after leaving it.
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.onTileRefreshRequested()
}
}
}
private fun setNoConversationsContent(view: ViewGroup) {
// This should never happen.
if (view.childCount > 1) {
error("view has ${view.childCount} children, it should have maximum 1")
}
// The static content for no conversations is already shown.
if (view.findViewById<View>(R.id.top_level_no_conversations) != null) {
return
}
// If we were showing the content with conversations earlier, remove it.
if (view.childCount == 1) {
view.removeViewAt(0)
}
val context = view.context
val noConversationsView =
LayoutInflater.from(context)
.inflate(R.layout.people_space_activity_no_conversations, /* root= */ view)
// The Tile preview has colorBackground as its background. Change it so it's different than
// the activity's background.
val item = noConversationsView.findViewById<LinearLayout>(android.R.id.background)
val shape = item.background as GradientDrawable
val ta =
context.theme.obtainStyledAttributes(
intArrayOf(com.android.internal.R.attr.colorSurface)
)
shape.setColor(ta.getColor(0, Color.WHITE))
ta.recycle()
}
private fun setConversationsContent(
view: ViewGroup,
priorityTiles: List<PeopleTileViewModel>,
recentTiles: List<PeopleTileViewModel>,
onTileClicked: (PeopleTileViewModel) -> Unit,
) {
// This should never happen.
if (view.childCount > 1) {
error("view has ${view.childCount} children, it should have maximum 1")
}
// Inflate the content with conversations, if it's not already.
if (view.findViewById<View>(R.id.top_level_with_conversations) == null) {
// If we were showing the content without conversations earlier, remove it.
if (view.childCount == 1) {
view.removeViewAt(0)
}
LayoutInflater.from(view.context)
.inflate(R.layout.people_space_activity_with_conversations, /* root= */ view)
}
// TODO(b/193782241): Replace the NestedScrollView + 2x LinearLayout from this layout into a
// single RecyclerView once this screen is tested by screenshot tests. Introduce a
// PeopleSpaceTileViewBinder that will properly create and bind the View associated to a
// PeopleSpaceTileViewModel (and remove the PeopleSpaceTileView class).
val conversationsView = view.requireViewById<View>(R.id.top_level_with_conversations)
setTileViews(
conversationsView,
R.id.priority,
R.id.priority_tiles,
priorityTiles,
onTileClicked,
)
setTileViews(
conversationsView,
R.id.recent,
R.id.recent_tiles,
recentTiles,
onTileClicked,
)
}
/** Sets a [PeopleSpaceTileView]s for each conversation. */
private fun setTileViews(
root: View,
tilesListId: Int,
tilesId: Int,
tiles: List<PeopleTileViewModel>,
onTileClicked: (PeopleTileViewModel) -> Unit,
) {
// Remove any previously added tile.
// TODO(b/193782241): Once this list is a big RecyclerView, set the current list and use
// DiffUtil to do as less addView/removeView as possible.
val layout = root.requireViewById<ViewGroup>(tilesId)
layout.removeAllViews()
layout.outlineProvider = ViewOutlineProvider
val tilesListView = root.requireViewById<LinearLayout>(tilesListId)
if (tiles.isEmpty()) {
tilesListView.visibility = View.GONE
return
}
tilesListView.visibility = View.VISIBLE
// Add each tile.
tiles.forEachIndexed { i, tile ->
val tileView =
PeopleSpaceTileView(root.context, layout, tile.key.shortcutId, i == tiles.size - 1)
bindTileView(tileView, tile, onTileClicked)
}
}
/** Sets [tileView] with the data in [conversation]. */
private fun bindTileView(
tileView: PeopleSpaceTileView,
tile: PeopleTileViewModel,
onTileClicked: (PeopleTileViewModel) -> Unit,
) {
try {
tileView.setName(tile.username)
tileView.setPersonIcon(tile.icon)
tileView.setOnClickListener { onTileClicked(tile) }
} catch (e: Exception) {
Log.e(TAG, "Couldn't retrieve shortcut information", e)
}
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) 2022 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.people.ui.viewmodel
import android.graphics.Bitmap
import com.android.systemui.people.widget.PeopleTileKey
/** Models UI state for a single tile/conversation. */
data class PeopleTileViewModel(
val key: PeopleTileKey,
val icon: Bitmap,
val username: String?,
)

View File

@@ -0,0 +1,149 @@
/*
* Copyright (C) 2022 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.people.ui.viewmodel
import android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.android.systemui.R
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.people.PeopleSpaceUtils
import com.android.systemui.people.PeopleTileViewHelper
import com.android.systemui.people.data.model.PeopleTileModel
import com.android.systemui.people.data.repository.PeopleTileRepository
import com.android.systemui.people.data.repository.PeopleWidgetRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Models UI state for the people space, allowing the user to select which conversation should be
* associated to a new or existing Conversation widget.
*/
class PeopleViewModel(
@Application private val context: Context,
private val tileRepository: PeopleTileRepository,
private val widgetRepository: PeopleWidgetRepository,
) : ViewModel() {
/**
* The list of the priority tiles/conversations.
*
* Important: Even though this is a Flow, the underlying API used to populate this Flow is not
* reactive and you have to manually call [onTileRefreshRequested] to refresh the tiles.
*/
private val _priorityTiles = MutableStateFlow(priorityTiles())
val priorityTiles: Flow<List<PeopleTileViewModel>> = _priorityTiles
/**
* The list of the priority tiles/conversations.
*
* Important: Even though this is a Flow, the underlying API used to populate this Flow is not
* reactive and you have to manually call [onTileRefreshRequested] to refresh the tiles.
*/
private val _recentTiles = MutableStateFlow(recentTiles())
val recentTiles: Flow<List<PeopleTileViewModel>> = _recentTiles
/** The ID of the widget currently being edited/added. */
private val _appWidgetId = MutableStateFlow(INVALID_APPWIDGET_ID)
val appWidgetId: StateFlow<Int> = _appWidgetId
/** Whether the user journey is complete. */
private val _isFinished = MutableStateFlow(false)
val isFinished: StateFlow<Boolean> = _isFinished
/** Refresh the [priorityTiles] and [recentTiles]. */
fun onTileRefreshRequested() {
_priorityTiles.value = priorityTiles()
_recentTiles.value = recentTiles()
}
/** Called when the [appWidgetId] should be changed to [widgetId]. */
fun onWidgetIdChanged(widgetId: Int) {
_appWidgetId.value = widgetId
}
/** Clear [isFinished], setting it to false. */
fun clearIsFinished() {
_isFinished.value = false
}
/** Called when a tile is clicked. */
fun onTileClicked(tile: PeopleTileViewModel) {
if (PeopleSpaceUtils.DEBUG) {
Log.d(
TAG,
"Put ${tile.username}'s shortcut ID: ${tile.key.shortcutId} for widget ID: " +
_appWidgetId.value
)
}
widgetRepository.setWidgetTile(_appWidgetId.value, tile.key)
_isFinished.value = true
}
private fun priorityTiles(): List<PeopleTileViewModel> {
return try {
tileRepository.priorityTiles().map { it.toViewModel() }
} catch (e: Exception) {
Log.e(TAG, "Couldn't retrieve priority conversations", e)
emptyList()
}
}
private fun recentTiles(): List<PeopleTileViewModel> {
return try {
tileRepository.recentTiles().map { it.toViewModel() }
} catch (e: Exception) {
Log.e(TAG, "Couldn't retrieve recent conversations", e)
emptyList()
}
}
private fun PeopleTileModel.toViewModel(): PeopleTileViewModel {
val icon =
PeopleTileViewHelper.getPersonIconBitmap(
context,
this,
PeopleTileViewHelper.getSizeInDp(
context,
R.dimen.avatar_size_for_medium,
context.resources.displayMetrics.density,
)
)
return PeopleTileViewModel(key, icon, username)
}
/** The Factory that should be used to create a [PeopleViewModel]. */
class Factory
@Inject
constructor(
@Application private val context: Context,
private val tileRepository: PeopleTileRepository,
private val widgetRepository: PeopleWidgetRepository,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
check(modelClass == PeopleViewModel::class.java)
return PeopleViewModel(context, tileRepository, widgetRepository) as T
}
}
companion object {
private const val TAG = "PeopleSpaceViewModel"
}
}