diff options
17 files changed, 810 insertions, 205 deletions
diff --git a/packages/SystemUI/res/layout/media_projection_app_selector.xml b/packages/SystemUI/res/layout/media_projection_app_selector.xml index 226bc6afc5ab..e4749381243a 100644 --- a/packages/SystemUI/res/layout/media_projection_app_selector.xml +++ b/packages/SystemUI/res/layout/media_projection_app_selector.xml @@ -49,6 +49,8 @@ android:layout_height="wrap_content" android:layout_width="wrap_content" android:textAppearance="?android:attr/textAppearanceLarge" + android:focusable="false" + android:clickable="false" android:gravity="center" android:paddingBottom="@*android:dimen/chooser_view_spacing" android:paddingLeft="24dp" diff --git a/packages/SystemUI/res/layout/media_projection_recent_tasks.xml b/packages/SystemUI/res/layout/media_projection_recent_tasks.xml index a2b3c404f077..31baf26e4a1b 100644 --- a/packages/SystemUI/res/layout/media_projection_recent_tasks.xml +++ b/packages/SystemUI/res/layout/media_projection_recent_tasks.xml @@ -23,8 +23,9 @@ > <FrameLayout + android:id="@+id/media_projection_recent_tasks_container" android:layout_width="match_parent" - android:layout_height="256dp"> + android:layout_height="wrap_content"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/media_projection_recent_tasks_recycler" diff --git a/packages/SystemUI/res/layout/media_projection_task_item.xml b/packages/SystemUI/res/layout/media_projection_task_item.xml index 75f73cd7a1e9..cfa586fb5767 100644 --- a/packages/SystemUI/res/layout/media_projection_task_item.xml +++ b/packages/SystemUI/res/layout/media_projection_task_item.xml @@ -18,7 +18,9 @@ android:layout_height="wrap_content" android:layout_gravity="center" android:gravity="center" - android:orientation="vertical"> + android:orientation="vertical" + android:clickable="true" + > <ImageView android:id="@+id/task_icon" @@ -27,12 +29,12 @@ android:layout_margin="8dp" android:importantForAccessibility="no" /> - <!-- TODO(b/240924926) use a custom view that will handle thumbnail cropping correctly --> - <!-- TODO(b/240924926) dynamically change the view size based on the screen size --> - <ImageView + <!-- This view size will be calculated in runtime --> + <com.android.systemui.mediaprojection.appselector.view.MediaProjectionTaskView android:id="@+id/task_thumbnail" - android:layout_width="100dp" - android:layout_height="216dp" - android:scaleType="centerCrop" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:clickable="false" + android:focusable="false" /> </LinearLayout> diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index 03040d66ebad..2c6e6068c4b1 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -1470,6 +1470,9 @@ <dimen name="media_projection_app_selector_icon_size">32dp</dimen> <dimen name="media_projection_app_selector_recents_padding">16dp</dimen> <dimen name="media_projection_app_selector_loader_size">32dp</dimen> + <dimen name="media_projection_app_selector_task_rounded_corners">10dp</dimen> + <dimen name="media_projection_app_selector_task_icon_size">24dp</dimen> + <dimen name="media_projection_app_selector_task_icon_margin">8dp</dimen> <!-- Dream overlay related dimensions --> <dimen name="dream_overlay_status_bar_height">60dp</dimen> diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java index 06dbab980793..d70b971dba14 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java @@ -43,7 +43,7 @@ import com.android.systemui.flags.FlagsModule; import com.android.systemui.fragments.FragmentService; import com.android.systemui.keyguard.data.BouncerViewModule; import com.android.systemui.log.dagger.LogModule; -import com.android.systemui.media.dagger.MediaProjectionModule; +import com.android.systemui.mediaprojection.appselector.MediaProjectionModule; import com.android.systemui.model.SysUiState; import com.android.systemui.navigationbar.NavigationBarComponent; import com.android.systemui.people.PeopleModule; diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaProjectionAppSelectorActivity.kt b/packages/SystemUI/src/com/android/systemui/media/MediaProjectionAppSelectorActivity.kt index ea5780622822..1ac2a078c8a0 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaProjectionAppSelectorActivity.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaProjectionAppSelectorActivity.kt @@ -16,8 +16,8 @@ package com.android.systemui.media import android.app.ActivityOptions -import android.app.IActivityTaskManager import android.content.Intent +import android.content.res.Configuration import android.media.projection.IMediaProjection import android.media.projection.MediaProjectionManager.EXTRA_MEDIA_PROJECTION import android.os.Binder @@ -25,87 +25,73 @@ import android.os.Bundle import android.os.IBinder import android.os.ResultReceiver import android.os.UserHandle -import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.android.internal.annotations.VisibleForTesting import com.android.internal.app.ChooserActivity import com.android.internal.app.ResolverListController import com.android.internal.app.chooser.NotSelectableTargetInfo import com.android.internal.app.chooser.TargetInfo import com.android.systemui.R +import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorComponent import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorController +import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorResultHandler import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorView import com.android.systemui.mediaprojection.appselector.data.RecentTask -import com.android.systemui.mediaprojection.appselector.view.RecentTasksAdapter -import com.android.systemui.mediaprojection.appselector.view.RecentTasksAdapter.RecentTaskClickListener +import com.android.systemui.mediaprojection.appselector.view.MediaProjectionRecentsViewController +import com.android.systemui.statusbar.policy.ConfigurationController import com.android.systemui.util.AsyncActivityLauncher -import com.android.systemui.util.recycler.HorizontalSpacerItemDecoration import javax.inject.Inject class MediaProjectionAppSelectorActivity( + private val componentFactory: MediaProjectionAppSelectorComponent.Factory, private val activityLauncher: AsyncActivityLauncher, - private val activityTaskManager: IActivityTaskManager, - private val controller: MediaProjectionAppSelectorController, - private val recentTasksAdapterFactory: RecentTasksAdapter.Factory, /** This is used to override the dependency in a screenshot test */ @VisibleForTesting private val listControllerFactory: ((userHandle: UserHandle) -> ResolverListController)? -) : ChooserActivity(), MediaProjectionAppSelectorView, RecentTaskClickListener { +) : ChooserActivity(), MediaProjectionAppSelectorView, MediaProjectionAppSelectorResultHandler { @Inject constructor( + componentFactory: MediaProjectionAppSelectorComponent.Factory, activityLauncher: AsyncActivityLauncher, - activityTaskManager: IActivityTaskManager, - controller: MediaProjectionAppSelectorController, - recentTasksAdapterFactory: RecentTasksAdapter.Factory, - ) : this(activityLauncher, activityTaskManager, controller, recentTasksAdapterFactory, null) + ) : this(componentFactory, activityLauncher, null) - private var recentsRoot: ViewGroup? = null - private var recentsProgress: View? = null - private var recentsRecycler: RecyclerView? = null + private lateinit var configurationController: ConfigurationController + private lateinit var controller: MediaProjectionAppSelectorController + private lateinit var recentsViewController: MediaProjectionRecentsViewController - override fun getLayoutResource() = - R.layout.media_projection_app_selector + override fun getLayoutResource() = R.layout.media_projection_app_selector public override fun onCreate(bundle: Bundle?) { - val queryIntent = Intent(Intent.ACTION_MAIN) - .addCategory(Intent.CATEGORY_LAUNCHER) + val component = + componentFactory.create( + activity = this, + view = this, + resultHandler = this + ) + + // Create a separate configuration controller for this activity as the configuration + // might be different from the global one + configurationController = component.configurationController + controller = component.controller + recentsViewController = component.recentsViewController + + val queryIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) } intent.putExtra(Intent.EXTRA_INTENT, queryIntent) // TODO(b/240939253): update copies val title = getString(R.string.media_projection_dialog_service_title) intent.putExtra(Intent.EXTRA_TITLE, title) super.onCreate(bundle) - controller.init(this) + controller.init() } - private fun createRecentsView(parent: ViewGroup): ViewGroup { - val recentsRoot = LayoutInflater.from(this) - .inflate(R.layout.media_projection_recent_tasks, parent, - /* attachToRoot= */ false) as ViewGroup - - recentsProgress = recentsRoot.requireViewById(R.id.media_projection_recent_tasks_loader) - recentsRecycler = recentsRoot.requireViewById(R.id.media_projection_recent_tasks_recycler) - recentsRecycler?.layoutManager = LinearLayoutManager( - this, LinearLayoutManager.HORIZONTAL, - /* reverseLayout= */false - ) - - val itemDecoration = HorizontalSpacerItemDecoration( - resources.getDimensionPixelOffset( - R.dimen.media_projection_app_selector_recents_padding - ) - ) - recentsRecycler?.addItemDecoration(itemDecoration) - - return recentsRoot + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + configurationController.onConfigurationChanged(newConfig) } - override fun appliedThemeResId(): Int = - R.style.Theme_SystemUI_MediaProjectionAppSelector + override fun appliedThemeResId(): Int = R.style.Theme_SystemUI_MediaProjectionAppSelector override fun createListController(userHandle: UserHandle): ResolverListController = listControllerFactory?.invoke(userHandle) ?: super.createListController(userHandle) @@ -127,9 +113,9 @@ class MediaProjectionAppSelectorActivity( // is typically very fast, so we don't show any loaders. // We wait for the activity to be launched to make sure that the window of the activity // is created and ready to be captured. - val activityStarted = activityLauncher - .startActivityAsUser(intent, userHandle, activityOptions.toBundle()) { - onTargetActivityLaunched(launchToken) + val activityStarted = + activityLauncher.startActivityAsUser(intent, userHandle, activityOptions.toBundle()) { + returnSelectedApp(launchToken) } // Rely on the ActivityManager to pop up a dialog regarding app suspension @@ -163,50 +149,27 @@ class MediaProjectionAppSelectorActivity( } override fun bind(recentTasks: List<RecentTask>) { - val recents = recentsRoot ?: return - val progress = recentsProgress ?: return - val recycler = recentsRecycler ?: return - - if (recentTasks.isEmpty()) { - recents.visibility = View.GONE - return - } - - progress.visibility = View.GONE - recycler.visibility = View.VISIBLE - recents.visibility = View.VISIBLE - - recycler.adapter = recentTasksAdapterFactory.create(recentTasks, this) + recentsViewController.bind(recentTasks) } - override fun onRecentAppClicked(task: RecentTask, view: View) { - val launchCookie = Binder() - val activityOptions = ActivityOptions.makeScaleUpAnimation(view, /* startX= */ 0, - /* startY= */ 0, view.width, view.height) - activityOptions.launchCookie = launchCookie - - activityTaskManager.startActivityFromRecents(task.taskId, activityOptions.toBundle()) - onTargetActivityLaunched(launchCookie) - } - - private fun onTargetActivityLaunched(launchToken: IBinder) { + override fun returnSelectedApp(launchCookie: IBinder) { if (intent.hasExtra(EXTRA_CAPTURE_REGION_RESULT_RECEIVER)) { // The client requested to return the result in the result receiver instead of // activity result, let's send the media projection to the result receiver - val resultReceiver = intent - .getParcelableExtra(EXTRA_CAPTURE_REGION_RESULT_RECEIVER, - ResultReceiver::class.java) as ResultReceiver - val captureRegion = MediaProjectionCaptureTarget(launchToken) - val data = Bundle().apply { - putParcelable(KEY_CAPTURE_TARGET, captureRegion) - } + val resultReceiver = + intent.getParcelableExtra( + EXTRA_CAPTURE_REGION_RESULT_RECEIVER, + ResultReceiver::class.java + ) as ResultReceiver + val captureRegion = MediaProjectionCaptureTarget(launchCookie) + val data = Bundle().apply { putParcelable(KEY_CAPTURE_TARGET, captureRegion) } resultReceiver.send(RESULT_OK, data) } else { // Return the media projection instance as activity result val mediaProjectionBinder = intent.getIBinderExtra(EXTRA_MEDIA_PROJECTION) val projection = IMediaProjection.Stub.asInterface(mediaProjectionBinder) - projection.launchCookie = launchToken + projection.launchCookie = launchCookie val intent = Intent() intent.putExtra(EXTRA_MEDIA_PROJECTION, projection.asBinder()) @@ -223,15 +186,13 @@ class MediaProjectionAppSelectorActivity( override fun shouldShowContentPreview() = false override fun createContentPreviewView(parent: ViewGroup): ViewGroup = - recentsRoot ?: createRecentsView(parent).also { - recentsRoot = it - } + recentsViewController.createView(parent) companion object { /** - * When EXTRA_CAPTURE_REGION_RESULT_RECEIVER is passed as intent extra - * the activity will send the [CaptureRegion] to the result receiver - * instead of returning media projection instance through activity result. + * When EXTRA_CAPTURE_REGION_RESULT_RECEIVER is passed as intent extra the activity will + * send the [CaptureRegion] to the result receiver instead of returning media projection + * instance through activity result. */ const val EXTRA_CAPTURE_REGION_RESULT_RECEIVER = "capture_region_result_receiver" const val KEY_CAPTURE_TARGET = "capture_region" diff --git a/packages/SystemUI/src/com/android/systemui/media/dagger/MediaProjectionModule.kt b/packages/SystemUI/src/com/android/systemui/media/dagger/MediaProjectionModule.kt deleted file mode 100644 index 185b4fca87d0..000000000000 --- a/packages/SystemUI/src/com/android/systemui/media/dagger/MediaProjectionModule.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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.media.dagger - -import android.app.Activity -import android.content.ComponentName -import android.content.Context -import com.android.systemui.dagger.qualifiers.Application -import com.android.systemui.media.MediaProjectionAppSelectorActivity -import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorController -import com.android.systemui.mediaprojection.appselector.data.ActivityTaskManagerThumbnailLoader -import com.android.systemui.mediaprojection.appselector.data.AppIconLoader -import com.android.systemui.mediaprojection.appselector.data.IconLoaderLibAppIconLoader -import com.android.systemui.mediaprojection.appselector.data.RecentTaskListProvider -import com.android.systemui.mediaprojection.appselector.data.RecentTaskThumbnailLoader -import com.android.systemui.mediaprojection.appselector.data.ShellRecentTaskListProvider -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.multibindings.ClassKey -import dagger.multibindings.IntoMap -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class MediaProjectionAppSelector - -@Module -abstract class MediaProjectionModule { - - @Binds - @IntoMap - @ClassKey(MediaProjectionAppSelectorActivity::class) - abstract fun provideMediaProjectionAppSelectorActivity( - activity: MediaProjectionAppSelectorActivity - ): Activity - - @Binds - abstract fun bindRecentTaskThumbnailLoader( - impl: ActivityTaskManagerThumbnailLoader - ): RecentTaskThumbnailLoader - - @Binds - abstract fun bindRecentTaskListProvider( - impl: ShellRecentTaskListProvider - ): RecentTaskListProvider - - @Binds - abstract fun bindAppIconLoader(impl: IconLoaderLibAppIconLoader): AppIconLoader - - companion object { - @Provides - fun provideController( - recentTaskListProvider: RecentTaskListProvider, - context: Context, - @MediaProjectionAppSelector scope: CoroutineScope - ): MediaProjectionAppSelectorController { - val appSelectorComponentName = - ComponentName(context, MediaProjectionAppSelectorActivity::class.java) - - return MediaProjectionAppSelectorController( - recentTaskListProvider, - scope, - appSelectorComponentName - ) - } - - @MediaProjectionAppSelector - @Provides - fun provideCoroutineScope(@Application applicationScope: CoroutineScope): CoroutineScope = - CoroutineScope(applicationScope.coroutineContext + SupervisorJob()) - } -} diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorComponent.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorComponent.kt new file mode 100644 index 000000000000..7fd100fd1398 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorComponent.kt @@ -0,0 +1,123 @@ +/* + * 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.mediaprojection.appselector + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.media.MediaProjectionAppSelectorActivity +import com.android.systemui.mediaprojection.appselector.data.ActivityTaskManagerThumbnailLoader +import com.android.systemui.mediaprojection.appselector.data.AppIconLoader +import com.android.systemui.mediaprojection.appselector.data.IconLoaderLibAppIconLoader +import com.android.systemui.mediaprojection.appselector.data.RecentTaskListProvider +import com.android.systemui.mediaprojection.appselector.data.RecentTaskThumbnailLoader +import com.android.systemui.mediaprojection.appselector.data.ShellRecentTaskListProvider +import com.android.systemui.mediaprojection.appselector.view.MediaProjectionRecentsViewController +import com.android.systemui.mediaprojection.appselector.view.TaskPreviewSizeProvider +import com.android.systemui.statusbar.phone.ConfigurationControllerImpl +import com.android.systemui.statusbar.policy.ConfigurationController +import dagger.Binds +import dagger.BindsInstance +import dagger.Module +import dagger.Provides +import dagger.Subcomponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Qualifier +import javax.inject.Scope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob + +@Qualifier @Retention(AnnotationRetention.BINARY) annotation class MediaProjectionAppSelector + +@Retention(AnnotationRetention.RUNTIME) @Scope annotation class MediaProjectionAppSelectorScope + +@Module(subcomponents = [MediaProjectionAppSelectorComponent::class]) +interface MediaProjectionModule { + @Binds + @IntoMap + @ClassKey(MediaProjectionAppSelectorActivity::class) + fun provideMediaProjectionAppSelectorActivity( + activity: MediaProjectionAppSelectorActivity + ): Activity +} + +/** Scoped values for [MediaProjectionAppSelectorComponent]. + * We create a scope for the activity so certain dependencies like [TaskPreviewSizeProvider] + * could be reused. */ +@Module +interface MediaProjectionAppSelectorModule { + + @Binds + @MediaProjectionAppSelectorScope + fun bindRecentTaskThumbnailLoader( + impl: ActivityTaskManagerThumbnailLoader + ): RecentTaskThumbnailLoader + + @Binds + @MediaProjectionAppSelectorScope + fun bindRecentTaskListProvider(impl: ShellRecentTaskListProvider): RecentTaskListProvider + + @Binds + @MediaProjectionAppSelectorScope + fun bindAppIconLoader(impl: IconLoaderLibAppIconLoader): AppIconLoader + + companion object { + @Provides + @MediaProjectionAppSelector + @MediaProjectionAppSelectorScope + fun provideAppSelectorComponentName(context: Context): ComponentName = + ComponentName(context, MediaProjectionAppSelectorActivity::class.java) + + @Provides + @MediaProjectionAppSelector + @MediaProjectionAppSelectorScope + fun bindConfigurationController( + activity: MediaProjectionAppSelectorActivity + ): ConfigurationController = ConfigurationControllerImpl(activity) + + @Provides + @MediaProjectionAppSelector + @MediaProjectionAppSelectorScope + fun provideCoroutineScope(@Application applicationScope: CoroutineScope): CoroutineScope = + CoroutineScope(applicationScope.coroutineContext + SupervisorJob()) + } +} + +@Subcomponent(modules = [MediaProjectionAppSelectorModule::class]) +@MediaProjectionAppSelectorScope +interface MediaProjectionAppSelectorComponent { + + /** Generates [MediaProjectionAppSelectorComponent]. */ + @Subcomponent.Factory + interface Factory { + /** + * Create a factory to inject the activity into the graph + */ + fun create( + @BindsInstance activity: MediaProjectionAppSelectorActivity, + @BindsInstance view: MediaProjectionAppSelectorView, + @BindsInstance resultHandler: MediaProjectionAppSelectorResultHandler, + ): MediaProjectionAppSelectorComponent + } + + val controller: MediaProjectionAppSelectorController + val recentsViewController: MediaProjectionRecentsViewController + + @MediaProjectionAppSelector val configurationController: ConfigurationController +} diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorController.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorController.kt index 2b381a954e27..d744a40b60d8 100644 --- a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorController.kt +++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorController.kt @@ -17,20 +17,22 @@ package com.android.systemui.mediaprojection.appselector import android.content.ComponentName -import com.android.systemui.media.dagger.MediaProjectionAppSelector import com.android.systemui.mediaprojection.appselector.data.RecentTask import com.android.systemui.mediaprojection.appselector.data.RecentTaskListProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import javax.inject.Inject -class MediaProjectionAppSelectorController( +@MediaProjectionAppSelectorScope +class MediaProjectionAppSelectorController @Inject constructor( private val recentTaskListProvider: RecentTaskListProvider, + private val view: MediaProjectionAppSelectorView, @MediaProjectionAppSelector private val scope: CoroutineScope, - private val appSelectorComponentName: ComponentName + @MediaProjectionAppSelector private val appSelectorComponentName: ComponentName ) { - fun init(view: MediaProjectionAppSelectorView) { + fun init() { scope.launch { val tasks = recentTaskListProvider.loadRecentTasks().sortTasks() view.bind(tasks) diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorResultHandler.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorResultHandler.kt new file mode 100644 index 000000000000..93c3bce87ad3 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorResultHandler.kt @@ -0,0 +1,15 @@ +package com.android.systemui.mediaprojection.appselector + +import android.os.IBinder + +/** + * Interface that allows to continue the media projection flow and return the selected app + * result to the original caller. + */ +interface MediaProjectionAppSelectorResultHandler { + /** + * Return selected app to the original caller of the media projection app picker. + * @param launchCookie launch cookie of the launched activity of the target app + */ + fun returnSelectedApp(launchCookie: IBinder) +} diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/MediaProjectionRecentsViewController.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/MediaProjectionRecentsViewController.kt new file mode 100644 index 000000000000..c816446d5c25 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/MediaProjectionRecentsViewController.kt @@ -0,0 +1,155 @@ +/* + * 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.mediaprojection.appselector.view + +import android.app.ActivityOptions +import android.app.IActivityTaskManager +import android.graphics.Rect +import android.os.Binder +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.android.systemui.R +import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorResultHandler +import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorScope +import com.android.systemui.mediaprojection.appselector.data.RecentTask +import com.android.systemui.mediaprojection.appselector.view.RecentTasksAdapter.RecentTaskClickListener +import com.android.systemui.mediaprojection.appselector.view.TaskPreviewSizeProvider.TaskPreviewSizeListener +import com.android.systemui.util.recycler.HorizontalSpacerItemDecoration +import javax.inject.Inject + +/** + * Controller that handles view of the recent apps selector in the media projection activity. + * It is responsible for creating and updating recent apps view. + */ +@MediaProjectionAppSelectorScope +class MediaProjectionRecentsViewController +@Inject +constructor( + private val recentTasksAdapterFactory: RecentTasksAdapter.Factory, + private val taskViewSizeProvider: TaskPreviewSizeProvider, + private val activityTaskManager: IActivityTaskManager, + private val resultHandler: MediaProjectionAppSelectorResultHandler, +) : RecentTaskClickListener, TaskPreviewSizeListener { + + private var views: Views? = null + private var lastBoundData: List<RecentTask>? = null + + init { + taskViewSizeProvider.addCallback(this) + } + + fun createView(parent: ViewGroup): ViewGroup = + views?.root ?: createRecentViews(parent).also { + views = it + lastBoundData?.let { recents -> bind(recents) } + }.root + + fun bind(recentTasks: List<RecentTask>) { + views?.apply { + if (recentTasks.isEmpty()) { + root.visibility = View.GONE + return + } + + progress.visibility = View.GONE + recycler.visibility = View.VISIBLE + root.visibility = View.VISIBLE + + recycler.adapter = + recentTasksAdapterFactory.create( + recentTasks, + this@MediaProjectionRecentsViewController + ) + } + + lastBoundData = recentTasks + } + + private fun createRecentViews(parent: ViewGroup): Views { + val recentsRoot = + LayoutInflater.from(parent.context) + .inflate(R.layout.media_projection_recent_tasks, parent, /* attachToRoot= */ false) + as ViewGroup + + val container = recentsRoot.findViewById<View>(R.id.media_projection_recent_tasks_container) + container.setTaskHeightSize() + + val progress = recentsRoot.requireViewById<View>(R.id.media_projection_recent_tasks_loader) + val recycler = + recentsRoot.requireViewById<RecyclerView>(R.id.media_projection_recent_tasks_recycler) + recycler.layoutManager = + LinearLayoutManager( + parent.context, + LinearLayoutManager.HORIZONTAL, + /* reverseLayout= */ false + ) + + val itemDecoration = + HorizontalSpacerItemDecoration( + parent.resources.getDimensionPixelOffset( + R.dimen.media_projection_app_selector_recents_padding + ) + ) + recycler.addItemDecoration(itemDecoration) + + return Views(recentsRoot, container, progress, recycler) + } + + override fun onRecentAppClicked(task: RecentTask, view: View) { + val launchCookie = Binder() + val activityOptions = + ActivityOptions.makeScaleUpAnimation( + view, + /* startX= */ 0, + /* startY= */ 0, + view.width, + view.height + ) + activityOptions.launchCookie = launchCookie + + activityTaskManager.startActivityFromRecents(task.taskId, activityOptions.toBundle()) + resultHandler.returnSelectedApp(launchCookie) + } + + override fun onTaskSizeChanged(size: Rect) { + views?.recentsContainer?.setTaskHeightSize() + } + + private fun View.setTaskHeightSize() { + val thumbnailHeight = taskViewSizeProvider.size.height() + val itemHeight = + thumbnailHeight + + context.resources.getDimensionPixelSize( + R.dimen.media_projection_app_selector_task_icon_size + ) + + context.resources.getDimensionPixelSize( + R.dimen.media_projection_app_selector_task_icon_margin + ) * 2 + + layoutParams = layoutParams.apply { height = itemHeight } + } + + private class Views( + val root: ViewGroup, + val recentsContainer: View, + val progress: View, + val recycler: RecyclerView + ) +} diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/MediaProjectionTaskView.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/MediaProjectionTaskView.kt new file mode 100644 index 000000000000..b682bd172837 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/MediaProjectionTaskView.kt @@ -0,0 +1,175 @@ +/* + * 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.mediaprojection.appselector.view + +import android.content.Context +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Shader +import android.util.AttributeSet +import android.view.View +import android.view.WindowManager +import androidx.core.content.getSystemService +import androidx.core.content.res.use +import com.android.internal.R as AndroidR +import com.android.systemui.R +import com.android.systemui.mediaprojection.appselector.data.RecentTask +import com.android.systemui.shared.recents.model.ThumbnailData +import com.android.systemui.shared.recents.utilities.PreviewPositionHelper +import com.android.systemui.shared.recents.utilities.Utilities.isTablet + +/** + * Custom view that shows a thumbnail preview of one recent task based on [ThumbnailData]. + * It handles proper cropping and positioning of the thumbnail using [PreviewPositionHelper]. + */ +class MediaProjectionTaskView +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + View(context, attrs, defStyleAttr) { + + private val defaultBackgroundColor: Int + + init { + val backgroundColorAttribute = intArrayOf(android.R.attr.colorBackgroundFloating) + defaultBackgroundColor = + context.obtainStyledAttributes(backgroundColorAttribute).use { + it.getColor(/* index= */ 0, /* defValue= */ Color.BLACK) + } + } + + private val windowManager: WindowManager = context.getSystemService()!! + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val backgroundPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { color = defaultBackgroundColor } + private val cornerRadius = + context.resources.getDimensionPixelSize( + R.dimen.media_projection_app_selector_task_rounded_corners + ) + private val previewPositionHelper = PreviewPositionHelper() + private val previewRect = Rect() + + private var task: RecentTask? = null + private var thumbnailData: ThumbnailData? = null + + private var bitmapShader: BitmapShader? = null + + fun bindTask(task: RecentTask?, thumbnailData: ThumbnailData?) { + this.task = task + this.thumbnailData = thumbnailData + + // Strip alpha channel to make sure that the color is not semi-transparent + val color = (task?.colorBackground ?: Color.BLACK) or 0xFF000000.toInt() + + paint.color = color + backgroundPaint.color = color + + refresh() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + updateThumbnailMatrix() + invalidate() + } + + override fun onDraw(canvas: Canvas) { + // Always draw the background since the snapshots might be translucent or partially empty + // (For example, tasks been reparented out of dismissing split root when drag-to-dismiss + // split screen). + canvas.drawRoundRect( + 0f, + 1f, + width.toFloat(), + (height - 1).toFloat(), + cornerRadius.toFloat(), + cornerRadius.toFloat(), + backgroundPaint + ) + + val drawBackgroundOnly = task == null || bitmapShader == null || thumbnailData == null + if (drawBackgroundOnly) { + return + } + + // Draw the task thumbnail using bitmap shader in the paint + canvas.drawRoundRect( + 0f, + 0f, + width.toFloat(), + height.toFloat(), + cornerRadius.toFloat(), + cornerRadius.toFloat(), + paint + ) + } + + private fun refresh() { + val thumbnailBitmap = thumbnailData?.thumbnail + + if (thumbnailBitmap != null) { + thumbnailBitmap.prepareToDraw() + bitmapShader = + BitmapShader(thumbnailBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + paint.shader = bitmapShader + updateThumbnailMatrix() + } else { + bitmapShader = null + paint.shader = null + } + + invalidate() + } + + private fun updateThumbnailMatrix() { + previewPositionHelper.isOrientationChanged = false + + val bitmapShader = bitmapShader ?: return + val thumbnailData = thumbnailData ?: return + val display = context.display ?: return + val windowMetrics = windowManager.maximumWindowMetrics + + previewRect.set(0, 0, thumbnailData.thumbnail.width, thumbnailData.thumbnail.height) + + val currentRotation: Int = display.rotation + val displayWidthPx = windowMetrics.bounds.width() + val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL + val isTablet = isTablet(context) + val taskbarSize = + if (isTablet) { + resources.getDimensionPixelSize(AndroidR.dimen.taskbar_frame_height) + } else { + 0 + } + + previewPositionHelper.updateThumbnailMatrix( + previewRect, + thumbnailData, + measuredWidth, + measuredHeight, + displayWidthPx, + taskbarSize, + isTablet, + currentRotation, + isRtl + ) + + bitmapShader.setLocalMatrix(previewPositionHelper.matrix) + paint.shader = bitmapShader + } +} diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTaskViewHolder.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTaskViewHolder.kt index ec5abc7a12f4..15cfeee5174e 100644 --- a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTaskViewHolder.kt +++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTaskViewHolder.kt @@ -16,15 +16,17 @@ package com.android.systemui.mediaprojection.appselector.view +import android.graphics.Rect import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.recyclerview.widget.RecyclerView import com.android.systemui.R -import com.android.systemui.media.dagger.MediaProjectionAppSelector +import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelector import com.android.systemui.mediaprojection.appselector.data.AppIconLoader import com.android.systemui.mediaprojection.appselector.data.RecentTask import com.android.systemui.mediaprojection.appselector.data.RecentTaskThumbnailLoader +import com.android.systemui.statusbar.policy.ConfigurationController.ConfigurationListener import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -32,19 +34,27 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -class RecentTaskViewHolder @AssistedInject constructor( - @Assisted root: ViewGroup, +class RecentTaskViewHolder +@AssistedInject +constructor( + @Assisted private val root: ViewGroup, private val iconLoader: AppIconLoader, private val thumbnailLoader: RecentTaskThumbnailLoader, + private val taskViewSizeProvider: TaskPreviewSizeProvider, @MediaProjectionAppSelector private val scope: CoroutineScope -) : RecyclerView.ViewHolder(root) { +) : RecyclerView.ViewHolder(root), ConfigurationListener, TaskPreviewSizeProvider.TaskPreviewSizeListener { + val thumbnailView: MediaProjectionTaskView = root.requireViewById(R.id.task_thumbnail) private val iconView: ImageView = root.requireViewById(R.id.task_icon) - private val thumbnailView: ImageView = root.requireViewById(R.id.task_thumbnail) private var job: Job? = null + init { + updateThumbnailSize() + } + fun bind(task: RecentTask, onClick: (View) -> Unit) { + taskViewSizeProvider.addCallback(this) job?.cancel() job = @@ -57,20 +67,33 @@ class RecentTaskViewHolder @AssistedInject constructor( } launch { val thumbnail = thumbnailLoader.loadThumbnail(task.taskId) - thumbnailView.setImageBitmap(thumbnail?.thumbnail) + thumbnailView.bindTask(task, thumbnail) } } - thumbnailView.setOnClickListener(onClick) + root.setOnClickListener(onClick) } fun onRecycled() { + taskViewSizeProvider.removeCallback(this) iconView.setImageDrawable(null) - thumbnailView.setImageBitmap(null) + thumbnailView.bindTask(null, null) job?.cancel() job = null } + override fun onTaskSizeChanged(size: Rect) { + updateThumbnailSize() + } + + private fun updateThumbnailSize() { + thumbnailView.layoutParams = + thumbnailView.layoutParams.apply { + width = taskViewSizeProvider.size.width() + height = taskViewSizeProvider.size.height() + } + } + @AssistedFactory fun interface Factory { fun create(root: ViewGroup): RecentTaskViewHolder diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTasksAdapter.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTasksAdapter.kt index 51b4012f88c5..6af50a0eb699 100644 --- a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTasksAdapter.kt +++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTasksAdapter.kt @@ -26,7 +26,9 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -class RecentTasksAdapter @AssistedInject constructor( +class RecentTasksAdapter +@AssistedInject +constructor( @Assisted private val items: List<RecentTask>, @Assisted private val listener: RecentTaskClickListener, private val viewHolderFactory: RecentTaskViewHolder.Factory @@ -34,8 +36,8 @@ class RecentTasksAdapter @AssistedInject constructor( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecentTaskViewHolder { val taskItem = - LayoutInflater.from(parent.context) - .inflate(R.layout.media_projection_task_item, null) as ViewGroup + LayoutInflater.from(parent.context) + .inflate(R.layout.media_projection_task_item, parent, false) as ViewGroup return viewHolderFactory.create(taskItem) } diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/TaskPreviewSizeProvider.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/TaskPreviewSizeProvider.kt new file mode 100644 index 000000000000..88d5eaaff216 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/TaskPreviewSizeProvider.kt @@ -0,0 +1,95 @@ +/* + * 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.mediaprojection.appselector.view + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Rect +import android.view.WindowManager +import com.android.internal.R as AndroidR +import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorScope +import com.android.systemui.mediaprojection.appselector.view.TaskPreviewSizeProvider.TaskPreviewSizeListener +import com.android.systemui.shared.recents.utilities.Utilities.isTablet +import com.android.systemui.statusbar.policy.CallbackController +import com.android.systemui.statusbar.policy.ConfigurationController +import com.android.systemui.statusbar.policy.ConfigurationController.ConfigurationListener +import javax.inject.Inject + +@MediaProjectionAppSelectorScope +class TaskPreviewSizeProvider +@Inject +constructor( + private val context: Context, + private val windowManager: WindowManager, + configurationController: ConfigurationController +) : CallbackController<TaskPreviewSizeListener>, ConfigurationListener { + + /** Returns the size of the task preview on the screen in pixels */ + val size: Rect = calculateSize() + + private val listeners = arrayListOf<TaskPreviewSizeListener>() + + init { + configurationController.addCallback(this) + } + + override fun onConfigChanged(newConfig: Configuration) { + val newSize = calculateSize() + if (newSize != size) { + size.set(newSize) + listeners.forEach { it.onTaskSizeChanged(size) } + } + } + + private fun calculateSize(): Rect { + val windowMetrics = windowManager.maximumWindowMetrics + val maximumWindowHeight = windowMetrics.bounds.height() + val width = windowMetrics.bounds.width() + var height = maximumWindowHeight + + val isTablet = isTablet(context) + if (isTablet) { + val taskbarSize = + context.resources.getDimensionPixelSize(AndroidR.dimen.taskbar_frame_height) + height -= taskbarSize + } + + val previewSize = Rect(0, 0, width, height) + val scale = (height / maximumWindowHeight.toFloat()) / SCREEN_HEIGHT_TO_TASK_HEIGHT_RATIO + previewSize.scale(scale) + + return previewSize + } + + override fun addCallback(listener: TaskPreviewSizeListener) { + listeners += listener + } + + override fun removeCallback(listener: TaskPreviewSizeListener) { + listeners -= listener + } + + interface TaskPreviewSizeListener { + fun onTaskSizeChanged(size: Rect) + } +} + +/** + * How many times smaller the task preview should be on the screen comparing to the height of the + * screen + */ +private const val SCREEN_HEIGHT_TO_TASK_HEIGHT_RATIO = 4f diff --git a/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorControllerTest.kt index 00b1f3268bae..19d2d334b884 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorControllerTest.kt @@ -25,6 +25,7 @@ class MediaProjectionAppSelectorControllerTest : SysuiTestCase() { private val controller = MediaProjectionAppSelectorController( taskListProvider, + view, scope, appSelectorComponentName ) @@ -33,7 +34,7 @@ class MediaProjectionAppSelectorControllerTest : SysuiTestCase() { fun initNoRecentTasks_bindsEmptyList() { taskListProvider.tasks = emptyList() - controller.init(view) + controller.init() verify(view).bind(emptyList()) } @@ -44,7 +45,7 @@ class MediaProjectionAppSelectorControllerTest : SysuiTestCase() { createRecentTask(taskId = 1) ) - controller.init(view) + controller.init() verify(view).bind( listOf( @@ -62,7 +63,7 @@ class MediaProjectionAppSelectorControllerTest : SysuiTestCase() { ) taskListProvider.tasks = tasks - controller.init(view) + controller.init() verify(view).bind( listOf( @@ -84,7 +85,7 @@ class MediaProjectionAppSelectorControllerTest : SysuiTestCase() { ) taskListProvider.tasks = tasks - controller.init(view) + controller.init() verify(view).bind( listOf( diff --git a/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/view/TaskPreviewSizeProviderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/view/TaskPreviewSizeProviderTest.kt new file mode 100644 index 000000000000..464acb68fb07 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/view/TaskPreviewSizeProviderTest.kt @@ -0,0 +1,134 @@ +/* + * 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.mediaprojection.appselector.view + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources +import android.graphics.Rect +import android.util.DisplayMetrics.DENSITY_DEFAULT +import android.view.WindowManager +import android.view.WindowMetrics +import androidx.test.filters.SmallTest +import com.android.internal.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.mediaprojection.appselector.view.TaskPreviewSizeProvider.TaskPreviewSizeListener +import com.android.systemui.statusbar.policy.FakeConfigurationController +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlin.math.min +import org.junit.Before +import org.junit.Test + +@SmallTest +class TaskPreviewSizeProviderTest : SysuiTestCase() { + + private val mockContext: Context = mock() + private val resources: Resources = mock() + private val windowManager: WindowManager = mock() + private val sizeUpdates = arrayListOf<Rect>() + private val testConfigurationController = FakeConfigurationController() + + @Before + fun setup() { + whenever(mockContext.getSystemService(eq(WindowManager::class.java))) + .thenReturn(windowManager) + whenever(mockContext.resources).thenReturn(resources) + } + + @Test + fun size_phoneDisplay_thumbnailSizeIsSmallerAndProportionalToTheScreenSize() { + givenDisplay(width = 400, height = 600, isTablet = false) + + val size = createSizeProvider().size + + assertThat(size).isEqualTo(Rect(0, 0, 100, 150)) + } + + @Test + fun size_tabletDisplay_thumbnailSizeProportionalToTheScreenSizeExcludingTaskbar() { + givenDisplay(width = 400, height = 600, isTablet = true) + givenTaskbarSize(20) + + val size = createSizeProvider().size + + assertThat(size).isEqualTo(Rect(0, 0, 97, 140)) + } + + @Test + fun size_phoneDisplayAndRotate_emitsSizeUpdate() { + givenDisplay(width = 400, height = 600, isTablet = false) + createSizeProvider() + + givenDisplay(width = 600, height = 400, isTablet = false) + testConfigurationController.onConfigurationChanged(Configuration()) + + assertThat(sizeUpdates).containsExactly(Rect(0, 0, 150, 100)) + } + + @Test + fun size_phoneDisplayAndRotateConfigurationChange_returnsUpdatedSize() { + givenDisplay(width = 400, height = 600, isTablet = false) + val sizeProvider = createSizeProvider() + + givenDisplay(width = 600, height = 400, isTablet = false) + testConfigurationController.onConfigurationChanged(Configuration()) + + assertThat(sizeProvider.size).isEqualTo(Rect(0, 0, 150, 100)) + } + + private fun givenTaskbarSize(size: Int) { + whenever(resources.getDimensionPixelSize(eq(R.dimen.taskbar_frame_height))).thenReturn(size) + } + + private fun givenDisplay(width: Int, height: Int, isTablet: Boolean = false) { + val bounds = Rect(0, 0, width, height) + val windowMetrics = WindowMetrics(bounds, null) + whenever(windowManager.maximumWindowMetrics).thenReturn(windowMetrics) + whenever(windowManager.currentWindowMetrics).thenReturn(windowMetrics) + + val minDimension = min(width, height) + + // Calculate DPI so the smallest width is either considered as tablet or as phone + val targetSmallestWidthDpi = + if (isTablet) SMALLEST_WIDTH_DPI_TABLET else SMALLEST_WIDTH_DPI_PHONE + val densityDpi = minDimension * DENSITY_DEFAULT / targetSmallestWidthDpi + + val configuration = Configuration(context.resources.configuration) + configuration.densityDpi = densityDpi + whenever(resources.configuration).thenReturn(configuration) + } + + private fun createSizeProvider(): TaskPreviewSizeProvider { + val listener = + object : TaskPreviewSizeListener { + override fun onTaskSizeChanged(size: Rect) { + sizeUpdates.add(size) + } + } + + return TaskPreviewSizeProvider(mockContext, windowManager, testConfigurationController) + .also { it.addCallback(listener) } + } + + private companion object { + private const val SMALLEST_WIDTH_DPI_TABLET = 800 + private const val SMALLEST_WIDTH_DPI_PHONE = 400 + } +} |