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:
committed by
Android (Google) Code Review
commit
9fcf0aac75
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -38,6 +38,7 @@ android_library {
|
||||
"androidx.test.espresso.core",
|
||||
"androidx.appcompat_appcompat",
|
||||
"platform-screenshot-diff-core",
|
||||
"guava",
|
||||
],
|
||||
|
||||
kotlincflags: ["-Xjvm-default=all"],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user