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" /> false true + + @android:color/transparent + @android:color/transparent + shortEdges diff --git a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/Bitmap.kt b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/Bitmap.kt index 3d26cdab891d3..a4a70a49fce3f 100644 --- a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/Bitmap.kt +++ b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/Bitmap.kt @@ -24,6 +24,8 @@ import platform.test.screenshot.matchers.MSSIMMatcher import platform.test.screenshot.matchers.PixelPerfectMatcher /** Draw this [View] into a [Bitmap]. */ +// TODO(b/195673633): Remove this once Compose screenshot tests use hardware rendering for their +// tests. fun View.drawIntoBitmap(): Bitmap { val bitmap = Bitmap.createBitmap( diff --git a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewCapture.kt b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewCapture.kt new file mode 100644 index 0000000000000..c609e6f8b4bf9 --- /dev/null +++ b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewCapture.kt @@ -0,0 +1,180 @@ +package com.android.systemui.testing.screenshot + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.PixelCopy +import android.view.SurfaceView +import android.view.View +import android.view.ViewTreeObserver +import android.view.Window +import androidx.annotation.RequiresApi +import androidx.concurrent.futures.ResolvableFuture +import androidx.test.annotation.ExperimentalTestApi +import androidx.test.core.internal.os.HandlerExecutor +import androidx.test.platform.graphics.HardwareRendererCompat +import com.google.common.util.concurrent.ListenableFuture + +/* + * This file was forked from androidx/test/core/view/ViewCapture.kt to add [Window] parameter to + * [View.captureToBitmap]. + * TODO(b/195673633): Remove this fork and use the AndroidX version instead. + */ + +/** + * Asynchronously captures an image of the underlying view into a [Bitmap]. + * + * For devices below [Build.VERSION_CODES#O] (or if the view's window cannot be determined), the + * image is obtained using [View#draw]. Otherwise, [PixelCopy] is used. + * + * This method will also enable [HardwareRendererCompat#setDrawingEnabled(boolean)] if required. + * + * This API is primarily intended for use in lower layer libraries or frameworks. For test authors, + * its recommended to use espresso or compose's captureToImage. + * + * This API is currently experimental and subject to change or removal. + */ +@ExperimentalTestApi +@RequiresApi(Build.VERSION_CODES.JELLY_BEAN) +fun View.captureToBitmap(window: Window? = null): ListenableFuture { + val bitmapFuture: ResolvableFuture = ResolvableFuture.create() + val mainExecutor = HandlerExecutor(Handler(Looper.getMainLooper())) + + // disable drawing again if necessary once work is complete + if (!HardwareRendererCompat.isDrawingEnabled()) { + HardwareRendererCompat.setDrawingEnabled(true) + bitmapFuture.addListener({ HardwareRendererCompat.setDrawingEnabled(false) }, mainExecutor) + } + + mainExecutor.execute { + val forceRedrawFuture = forceRedraw() + forceRedrawFuture.addListener({ generateBitmap(bitmapFuture, window) }, mainExecutor) + } + + return bitmapFuture +} + +/** + * Trigger a redraw of the given view. + * + * Should only be called on UI thread. + * + * @return a [ListenableFuture] that will be complete once ui drawing is complete + */ +// NoClassDefFoundError occurs on API 15 +@RequiresApi(Build.VERSION_CODES.JELLY_BEAN) +// @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +@ExperimentalTestApi +fun View.forceRedraw(): ListenableFuture { + val future: ResolvableFuture = ResolvableFuture.create() + + if (Build.VERSION.SDK_INT >= 29 && isHardwareAccelerated) { + viewTreeObserver.registerFrameCommitCallback() { future.set(null) } + } else { + viewTreeObserver.addOnDrawListener( + object : ViewTreeObserver.OnDrawListener { + var handled = false + override fun onDraw() { + if (!handled) { + handled = true + future.set(null) + // cannot remove on draw listener inside of onDraw + Handler(Looper.getMainLooper()).post { + viewTreeObserver.removeOnDrawListener(this) + } + } + } + } + ) + } + invalidate() + return future +} + +private fun View.generateBitmap( + bitmapFuture: ResolvableFuture, + window: Window? = null, +) { + if (bitmapFuture.isCancelled) { + return + } + val destBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + when { + Build.VERSION.SDK_INT < 26 -> generateBitmapFromDraw(destBitmap, bitmapFuture) + this is SurfaceView -> generateBitmapFromSurfaceViewPixelCopy(destBitmap, bitmapFuture) + else -> { + val window = window ?: getActivity()?.window + if (window != null) { + generateBitmapFromPixelCopy(window, destBitmap, bitmapFuture) + } else { + Log.i( + "View.captureToImage", + "Could not find window for view. Falling back to View#draw instead of PixelCopy" + ) + generateBitmapFromDraw(destBitmap, bitmapFuture) + } + } + } +} + +@SuppressWarnings("NewApi") +private fun SurfaceView.generateBitmapFromSurfaceViewPixelCopy( + destBitmap: Bitmap, + bitmapFuture: ResolvableFuture +) { + val onCopyFinished = + PixelCopy.OnPixelCopyFinishedListener { result -> + if (result == PixelCopy.SUCCESS) { + bitmapFuture.set(destBitmap) + } else { + bitmapFuture.setException( + RuntimeException(String.format("PixelCopy failed: %d", result)) + ) + } + } + PixelCopy.request(this, null, destBitmap, onCopyFinished, handler) +} + +internal fun View.generateBitmapFromDraw( + destBitmap: Bitmap, + bitmapFuture: ResolvableFuture +) { + destBitmap.density = resources.displayMetrics.densityDpi + computeScroll() + val canvas = Canvas(destBitmap) + canvas.translate((-scrollX).toFloat(), (-scrollY).toFloat()) + draw(canvas) + bitmapFuture.set(destBitmap) +} + +private fun View.getActivity(): Activity? { + fun Context.getActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> this.baseContext.getActivity() + else -> null + } + } + return context.getActivity() +} + +private fun View.generateBitmapFromPixelCopy( + window: Window, + destBitmap: Bitmap, + bitmapFuture: ResolvableFuture +) { + val locationInWindow = intArrayOf(0, 0) + getLocationInWindow(locationInWindow) + val x = locationInWindow[0] + val y = locationInWindow[1] + val boundsInWindow = Rect(x, y, x + width, y + height) + + return window.generateBitmapFromPixelCopy(boundsInWindow, destBitmap, bitmapFuture) +} diff --git a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewScreenshotTestRule.kt b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewScreenshotTestRule.kt index 3209c8bb1f8ac..60130e1086ef0 100644 --- a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewScreenshotTestRule.kt +++ b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewScreenshotTestRule.kt @@ -18,10 +18,22 @@ package com.android.systemui.testing.screenshot import android.app.Activity import android.app.Dialog +import android.graphics.Bitmap +import android.graphics.HardwareRenderer +import android.os.Looper import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.view.Window +import androidx.activity.ComponentActivity +import androidx.test.espresso.Espresso import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.rules.RuleChain import org.junit.rules.TestRule @@ -59,29 +71,39 @@ class ViewScreenshotTestRule(emulationSpec: DeviceEmulationSpec) : TestRule { */ fun screenshotTest( goldenIdentifier: String, - layoutParams: LayoutParams = - LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT), - viewProvider: (Activity) -> View, + mode: Mode = Mode.WrapContent, + viewProvider: (ComponentActivity) -> View, ) { activityRule.scenario.onActivity { activity -> // Make sure that the activity draws full screen and fits the whole display instead of // the system bars. - activity.window.setDecorFitsSystemWindows(false) - activity.setContentView(viewProvider(activity), layoutParams) + val window = activity.window + window.setDecorFitsSystemWindows(false) + + // Set the content. + activity.setContentView(viewProvider(activity), mode.layoutParams) + + // Elevation/shadows is not deterministic when doing hardware rendering, so we disable + // it for any view in the hierarchy. + window.decorView.removeElevationRecursively() } // We call onActivity again because it will make sure that our Activity is done measuring, // laying out and drawing its content (that we set in the previous onActivity lambda). + var contentView: View? = null activityRule.scenario.onActivity { activity -> // Check that the content is what we expected. val content = activity.requireViewById(android.R.id.content) assertEquals(1, content.childCount) - screenshotRule.assertBitmapAgainstGolden( - content.getChildAt(0).drawIntoBitmap(), - goldenIdentifier, - matcher - ) + contentView = content.getChildAt(0) } + + val bitmap = contentView?.toBitmap() ?: error("contentView is null") + screenshotRule.assertBitmapAgainstGolden( + bitmap, + goldenIdentifier, + matcher, + ) } /** @@ -104,25 +126,78 @@ class ViewScreenshotTestRule(emulationSpec: DeviceEmulationSpec) : TestRule { create() window.setWindowAnimations(0) + // Elevation/shadows is not deterministic when doing hardware rendering, so we + // disable it for any view in the hierarchy. + window.decorView.removeElevationRecursively() + // Show the dialog. show() } } - // We call onActivity again because it will make sure that our Dialog is done measuring, - // laying out and drawing its content (that we set in the previous onActivity lambda). - activityRule.scenario.onActivity { - // Check that the content is what we expected. - val dialog = dialog ?: error("dialog is null") - try { - screenshotRule.assertBitmapAgainstGolden( - dialog.window.decorView.drawIntoBitmap(), - goldenIdentifier, - matcher, + try { + val bitmap = dialog?.toBitmap() ?: error("dialog is null") + screenshotRule.assertBitmapAgainstGolden( + bitmap, + goldenIdentifier, + matcher, + ) + } finally { + dialog?.dismiss() + } + } + + private fun View.removeElevationRecursively() { + this.elevation = 0f + + if (this is ViewGroup) { + repeat(childCount) { i -> getChildAt(i).removeElevationRecursively() } + } + } + + private fun Dialog.toBitmap(): Bitmap { + val window = window + return window.decorView.toBitmap(window) + } + + private fun View.toBitmap(window: Window? = null): Bitmap { + if (Looper.getMainLooper() == Looper.myLooper()) { + error("toBitmap() can't be called from the main thread") + } + + if (!HardwareRenderer.isDrawingEnabled()) { + error("Hardware rendering is not enabled") + } + + // Make sure we are idle. + Espresso.onIdle() + + val mainExecutor = context.mainExecutor + return runBlocking { + suspendCoroutine { continuation -> + Futures.addCallback( + captureToBitmap(window), + object : FutureCallback { + override fun onSuccess(result: Bitmap?) { + continuation.resumeWith(Result.success(result!!)) + } + + override fun onFailure(t: Throwable) { + continuation.resumeWith(Result.failure(t)) + } + }, + // We know that we are not on the main thread, so we can block the current + // thread and wait for the result in the main thread. + mainExecutor, ) - } finally { - dialog.dismiss() } } } + + enum class Mode(val layoutParams: LayoutParams) { + WrapContent(LayoutParams(WRAP_CONTENT, WRAP_CONTENT)), + MatchSize(LayoutParams(MATCH_PARENT, MATCH_PARENT)), + MatchWidth(LayoutParams(MATCH_PARENT, WRAP_CONTENT)), + MatchHeight(LayoutParams(WRAP_CONTENT, MATCH_PARENT)), + } } diff --git a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/WindowCapture.kt b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/WindowCapture.kt new file mode 100644 index 0000000000000..d34f46bf48a67 --- /dev/null +++ b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/WindowCapture.kt @@ -0,0 +1,37 @@ +package com.android.systemui.testing.screenshot + +import android.graphics.Bitmap +import android.graphics.Rect +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.Window +import androidx.concurrent.futures.ResolvableFuture + +/* + * This file was forked from androidx/test/core/view/WindowCapture.kt. + * TODO(b/195673633): Remove this fork and use the AndroidX version instead. + */ +fun Window.generateBitmapFromPixelCopy( + boundsInWindow: Rect? = null, + destBitmap: Bitmap, + bitmapFuture: ResolvableFuture +) { + val onCopyFinished = + PixelCopy.OnPixelCopyFinishedListener { result -> + if (result == PixelCopy.SUCCESS) { + bitmapFuture.set(destBitmap) + } else { + bitmapFuture.setException( + RuntimeException(String.format("PixelCopy failed: %d", result)) + ) + } + } + PixelCopy.request( + this, + boundsInWindow, + destBitmap, + onCopyFinished, + Handler(Looper.getMainLooper()) + ) +} diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java index 0e0073a1690f8..fe9622250e676 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java @@ -45,6 +45,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; @@ -124,6 +125,7 @@ import dagger.Provides; LogModule.class, MediaProjectionModule.class, PeopleHubModule.class, + PeopleModule.class, PluginModule.class, PrivacyModule.class, QsFrameTranslateModule.class, diff --git a/packages/SystemUI/src/com/android/systemui/people/PeopleModule.kt b/packages/SystemUI/src/com/android/systemui/people/PeopleModule.kt new file mode 100644 index 0000000000000..dd35445f9b7b1 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/people/PeopleModule.kt @@ -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 +} diff --git a/packages/SystemUI/src/com/android/systemui/people/PeopleSpaceActivity.java b/packages/SystemUI/src/com/android/systemui/people/PeopleSpaceActivity.java index 93a3f81fdd6b4..e845aa85a121a 100644 --- a/packages/SystemUI/src/com/android/systemui/people/PeopleSpaceActivity.java +++ b/packages/SystemUI/src/com/android/systemui/people/PeopleSpaceActivity.java @@ -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 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" + } +}