From 8a169ff03c894299bc28b83cd6d2f5265a0d5b31 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Wed, 13 Jul 2022 10:42:10 +0200 Subject: [PATCH] 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 --- packages/SystemUI/Android.bp | 2 + .../res/layout/people_space_activity.xml | 104 +------- ...people_space_activity_no_conversations.xml | 2 +- ...ople_space_activity_with_conversations.xml | 115 +++++++++ .../res/layout/people_space_tile_view.xml | 4 +- .../systemui/dagger/SystemUIModule.java | 2 + .../android/systemui/people/PeopleModule.kt | 32 +++ .../systemui/people/PeopleSpaceActivity.java | 142 ++-------- .../people/PeopleStoryIconFactory.java | 12 +- .../systemui/people/PeopleTileViewHelper.java | 27 +- .../people/data/model/PeopleTileModel.kt | 30 +++ .../data/repository/PeopleTileRepository.kt | 61 +++++ .../data/repository/PeopleWidgetRepository.kt | 43 ++++ .../people/ui/view/PeopleViewBinder.kt | 243 ++++++++++++++++++ .../ui/viewmodel/PeopleTileViewModel.kt | 27 ++ .../people/ui/viewmodel/PeopleViewModel.kt | 149 +++++++++++ 16 files changed, 760 insertions(+), 235 deletions(-) create mode 100644 packages/SystemUI/res/layout/people_space_activity_with_conversations.xml create mode 100644 packages/SystemUI/src/com/android/systemui/people/PeopleModule.kt create mode 100644 packages/SystemUI/src/com/android/systemui/people/data/model/PeopleTileModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/people/data/repository/PeopleTileRepository.kt create mode 100644 packages/SystemUI/src/com/android/systemui/people/data/repository/PeopleWidgetRepository.kt create mode 100644 packages/SystemUI/src/com/android/systemui/people/ui/view/PeopleViewBinder.kt create mode 100644 packages/SystemUI/src/com/android/systemui/people/ui/viewmodel/PeopleTileViewModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/people/ui/viewmodel/PeopleViewModel.kt diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp index fa87de2b1353c..ffd6b522e3943 100644 --- a/packages/SystemUI/Android.bp +++ b/packages/SystemUI/Android.bp @@ -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", diff --git a/packages/SystemUI/res/layout/people_space_activity.xml b/packages/SystemUI/res/layout/people_space_activity.xml index 7102375a89bf3..f45cc7c464d51 100644 --- a/packages/SystemUI/res/layout/people_space_activity.xml +++ b/packages/SystemUI/res/layout/people_space_activity.xml @@ -13,103 +13,11 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + android:layout_height="match_parent"> + + diff --git a/packages/SystemUI/res/layout/people_space_activity_no_conversations.xml b/packages/SystemUI/res/layout/people_space_activity_no_conversations.xml index 2e9ff07caed90..e929169cfe3d9 100644 --- a/packages/SystemUI/res/layout/people_space_activity_no_conversations.xml +++ b/packages/SystemUI/res/layout/people_space_activity_no_conversations.xml @@ -16,7 +16,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/layout/people_space_tile_view.xml b/packages/SystemUI/res/layout/people_space_tile_view.xml index 2a2c35dde8418..b0599caae6df8 100644 --- a/packages/SystemUI/res/layout/people_space_tile_view.xml +++ b/packages/SystemUI/res/layout/people_space_tile_view.xml @@ -37,8 +37,8 @@ + android:layout_width="@dimen/avatar_size_for_medium" + android:layout_height="@dimen/avatar_size_for_medium" /> priorityTiles = new ArrayList<>(); - List 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 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(); - } } diff --git a/packages/SystemUI/src/com/android/systemui/people/PeopleStoryIconFactory.java b/packages/SystemUI/src/com/android/systemui/people/PeopleStoryIconFactory.java index 4ee951f3cdb1f..58e700f813882 100644 --- a/packages/SystemUI/src/com/android/systemui/people/PeopleStoryIconFactory.java +++ b/packages/SystemUI/src/com/android/systemui/people/PeopleStoryIconFactory.java @@ -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; } diff --git a/packages/SystemUI/src/com/android/systemui/people/PeopleTileViewHelper.java b/packages/SystemUI/src/com/android/systemui/people/PeopleTileViewHelper.java index 00aa1381ace11..be82b1faac8ef 100644 --- a/packages/SystemUI/src/com/android/systemui/people/PeopleTileViewHelper.java +++ b/packages/SystemUI/src/com/android/systemui/people/PeopleTileViewHelper.java @@ -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()); } diff --git a/packages/SystemUI/src/com/android/systemui/people/data/model/PeopleTileModel.kt b/packages/SystemUI/src/com/android/systemui/people/data/model/PeopleTileModel.kt new file mode 100644 index 0000000000000..5d8539fabc6b1 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/people/data/model/PeopleTileModel.kt @@ -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, +) diff --git a/packages/SystemUI/src/com/android/systemui/people/data/repository/PeopleTileRepository.kt b/packages/SystemUI/src/com/android/systemui/people/data/repository/PeopleTileRepository.kt new file mode 100644 index 0000000000000..01b43d52130b5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/people/data/repository/PeopleTileRepository.kt @@ -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 + + /* The current recent tiles. */ + fun recentTiles(): List +} + +@SysUISingleton +class PeopleTileRepositoryImpl +@Inject +constructor( + private val peopleSpaceWidgetManager: PeopleSpaceWidgetManager, +) : PeopleTileRepository { + override fun priorityTiles(): List { + return peopleSpaceWidgetManager.priorityTiles.map { it.toModel() } + } + + override fun recentTiles(): List { + 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), + ) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/people/data/repository/PeopleWidgetRepository.kt b/packages/SystemUI/src/com/android/systemui/people/data/repository/PeopleWidgetRepository.kt new file mode 100644 index 0000000000000..f2b6cb1fc3f57 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/people/data/repository/PeopleWidgetRepository.kt @@ -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) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/people/ui/view/PeopleViewBinder.kt b/packages/SystemUI/src/com/android/systemui/people/ui/view/PeopleViewBinder.kt new file mode 100644 index 0000000000000..bc982cccaacdf --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/people/ui/view/PeopleViewBinder.kt @@ -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(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(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, + recentTiles: List, + 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(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(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, + 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(tilesId) + layout.removeAllViews() + layout.outlineProvider = ViewOutlineProvider + + val tilesListView = root.requireViewById(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) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/people/ui/viewmodel/PeopleTileViewModel.kt b/packages/SystemUI/src/com/android/systemui/people/ui/viewmodel/PeopleTileViewModel.kt new file mode 100644 index 0000000000000..40205ce9424a4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/people/ui/viewmodel/PeopleTileViewModel.kt @@ -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?, +) diff --git a/packages/SystemUI/src/com/android/systemui/people/ui/viewmodel/PeopleViewModel.kt b/packages/SystemUI/src/com/android/systemui/people/ui/viewmodel/PeopleViewModel.kt new file mode 100644 index 0000000000000..17de991588b8f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/people/ui/viewmodel/PeopleViewModel.kt @@ -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> = _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> = _recentTiles + + /** The ID of the widget currently being edited/added. */ + private val _appWidgetId = MutableStateFlow(INVALID_APPWIDGET_ID) + val appWidgetId: StateFlow = _appWidgetId + + /** Whether the user journey is complete. */ + private val _isFinished = MutableStateFlow(false) + val isFinished: StateFlow = _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 { + return try { + tileRepository.priorityTiles().map { it.toViewModel() } + } catch (e: Exception) { + Log.e(TAG, "Couldn't retrieve priority conversations", e) + emptyList() + } + } + + private fun recentTiles(): List { + 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 create(modelClass: Class): T { + check(modelClass == PeopleViewModel::class.java) + return PeopleViewModel(context, tileRepository, widgetRepository) as T + } + } + + companion object { + private const val TAG = "PeopleSpaceViewModel" + } +}