Merge changes from topics "people-space-screenshot", "screenshot-hardware" into tm-qpr-dev

* changes:
  Make the PeopleSpaceActivity screenshot testable
  Render View into Bitmap using hardware acceleration (1/2)
This commit is contained in:
Jordan Demeulenaere
2022-07-20 08:15:01 +00:00
committed by Android (Google) Code Review
22 changed files with 1083 additions and 257 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

@@ -38,6 +38,7 @@ android_library {
"androidx.test.espresso.core",
"androidx.appcompat_appcompat",
"platform-screenshot-diff-core",
"guava",
],
kotlincflags: ["-Xjvm-default=all"],

View File

@@ -19,6 +19,12 @@
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
<!-- We make the status and navigation bars transparent so that the screenshotted content is
not clipped by the status bar height when drawn into the Bitmap (which is what happens
given that we draw the view into the Bitmap using hardware acceleration). -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Make sure that device specific cutouts don't impact the outcome of screenshot tests -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>

View File

@@ -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(

View File

@@ -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<Bitmap> {
val bitmapFuture: ResolvableFuture<Bitmap> = 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<Void> {
val future: ResolvableFuture<Void> = 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<Bitmap>,
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<Bitmap>
) {
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<Bitmap>
) {
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<Bitmap>
) {
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)
}

View File

@@ -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<ViewGroup>(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<Bitmap> {
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)),
}
}

View File

@@ -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<Bitmap>
) {
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())
)
}

View File

@@ -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,

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"
}
}