diff options
14 files changed, 330 insertions, 10 deletions
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp index e148d4f547a4..252915703511 100644 --- a/packages/SystemUI/Android.bp +++ b/packages/SystemUI/Android.bp @@ -271,6 +271,7 @@ android_library { "LowLightDreamLib", "motion_tool_lib", "androidx.core_core-animation-testing-nodeps", + "androidx.compose.ui_ui", ], } diff --git a/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt b/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt index 6e728ce7248f..e253fb925ceb 100644 --- a/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt +++ b/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt @@ -17,13 +17,21 @@ package com.android.systemui.compose +import android.content.Context +import android.view.View import androidx.activity.ComponentActivity +import androidx.lifecycle.LifecycleOwner import com.android.systemui.people.ui.viewmodel.PeopleViewModel +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel /** The Compose facade, when Compose is *not* available. */ object ComposeFacade : BaseComposeFacade { override fun isComposeAvailable(): Boolean = false + override fun composeInitializer(): ComposeInitializer { + throwComposeUnavailableError() + } + override fun setPeopleSpaceActivityContent( activity: ComponentActivity, viewModel: PeopleViewModel, @@ -32,7 +40,15 @@ object ComposeFacade : BaseComposeFacade { throwComposeUnavailableError() } - private fun throwComposeUnavailableError() { + override fun createFooterActionsView( + context: Context, + viewModel: FooterActionsViewModel, + qsVisibilityLifecycleOwner: LifecycleOwner + ): View { + throwComposeUnavailableError() + } + + private fun throwComposeUnavailableError(): Nothing { error( "Compose is not available. Make sure to check isComposeAvailable() before calling any" + " other function on ComposeFacade." diff --git a/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt b/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt index 6991ff82c2d1..1ea18fec4abe 100644 --- a/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt +++ b/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt @@ -16,16 +16,24 @@ package com.android.systemui.compose +import android.content.Context +import android.view.View import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.LifecycleOwner import com.android.compose.theme.PlatformTheme import com.android.systemui.people.ui.compose.PeopleScreen import com.android.systemui.people.ui.viewmodel.PeopleViewModel +import com.android.systemui.qs.footer.ui.compose.FooterActions +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel /** The Compose facade, when Compose is available. */ object ComposeFacade : BaseComposeFacade { override fun isComposeAvailable(): Boolean = true + override fun composeInitializer(): ComposeInitializer = ComposeInitializerImpl + override fun setPeopleSpaceActivityContent( activity: ComponentActivity, viewModel: PeopleViewModel, @@ -33,4 +41,14 @@ object ComposeFacade : BaseComposeFacade { ) { activity.setContent { PlatformTheme { PeopleScreen(viewModel, onResult) } } } + + override fun createFooterActionsView( + context: Context, + viewModel: FooterActionsViewModel, + qsVisibilityLifecycleOwner: LifecycleOwner, + ): View { + return ComposeView(context).apply { + setContent { PlatformTheme { FooterActions(viewModel, qsVisibilityLifecycleOwner) } } + } + } } diff --git a/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeInitializerImpl.kt b/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeInitializerImpl.kt new file mode 100644 index 000000000000..772c8918fd2d --- /dev/null +++ b/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeInitializerImpl.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2023 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.compose + +import android.view.View +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import com.android.compose.animation.ViewTreeSavedStateRegistryOwner +import com.android.systemui.lifecycle.ViewLifecycleOwner + +internal object ComposeInitializerImpl : ComposeInitializer { + override fun onAttachedToWindow(root: View) { + if (ViewTreeLifecycleOwner.get(root) != null) { + error("root $root already has a LifecycleOwner") + } + + val parent = root.parent + if (parent is View && parent.id != android.R.id.content) { + error( + "ComposeInitializer.onAttachedToWindow(View) must be called on the content child." + + "Outside of activities and dialogs, this is usually the top-most View of a " + + "window." + ) + } + + // The lifecycle owner, which is STARTED when [root] is visible and RESUMED when [root] is + // both visible and focused. + val lifecycleOwner = ViewLifecycleOwner(root) + + // We create a trivial implementation of [SavedStateRegistryOwner] that does not do any save + // or restore because SystemUI process is always running and top-level windows using this + // initializer are created once, when the process is started. + val savedStateRegistryOwner = + object : SavedStateRegistryOwner { + private val savedStateRegistry = + SavedStateRegistryController.create(this).apply { performRestore(null) } + + override fun getLifecycle(): Lifecycle = lifecycleOwner.lifecycle + + override fun getSavedStateRegistry(): SavedStateRegistry { + return savedStateRegistry.savedStateRegistry + } + } + + // We must call [ViewLifecycleOwner.onCreate] after creating the [SavedStateRegistryOwner] + // because `onCreate` might move the lifecycle state to STARTED which will make + // [SavedStateRegistryController.performRestore] throw. + lifecycleOwner.onCreate() + + // Set the owners on the root. They will be reused by any ComposeView inside the root + // hierarchy. + ViewTreeLifecycleOwner.set(root, lifecycleOwner) + ViewTreeSavedStateRegistryOwner.set(root, savedStateRegistryOwner) + } + + override fun onDetachedFromWindow(root: View) { + (ViewTreeLifecycleOwner.get(root) as ViewLifecycleOwner).onDestroy() + ViewTreeLifecycleOwner.set(root, null) + ViewTreeSavedStateRegistryOwner.set(root, null) + } +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/compose/modifiers/SysuiTestTag.kt b/packages/SystemUI/compose/features/src/com/android/systemui/compose/modifiers/SysuiTestTag.kt new file mode 100644 index 000000000000..9eb78e14ab4e --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/compose/modifiers/SysuiTestTag.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2023 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.compose.modifiers + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId + +/** + * Set a test tag on this node so that it is associated with [resId]. This node will then be + * accessible by integration tests using `sysuiResSelector(resId)`. + */ +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.sysuiResTag(resId: String): Modifier { + return this.semantics { testTagsAsResourceId = true }.testTag("com.android.systemui:id/$resId") +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/people/ui/compose/PeopleScreen.kt b/packages/SystemUI/compose/features/src/com/android/systemui/people/ui/compose/PeopleScreen.kt index 23dacf9946f3..3eeadae5385f 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/people/ui/compose/PeopleScreen.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/people/ui/compose/PeopleScreen.kt @@ -51,6 +51,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle import com.android.compose.theme.LocalAndroidColorScheme import com.android.systemui.R +import com.android.systemui.compose.modifiers.sysuiResTag import com.android.systemui.people.ui.viewmodel.PeopleTileViewModel import com.android.systemui.people.ui.viewmodel.PeopleViewModel @@ -110,7 +111,9 @@ private fun PeopleScreenWithConversations( recentTiles: List<PeopleTileViewModel>, onTileClicked: (PeopleTileViewModel) -> Unit, ) { - Column { + Column( + Modifier.sysuiResTag("top_level_with_conversations"), + ) { Column( Modifier.fillMaxWidth().padding(PeopleSpacePadding), horizontalAlignment = Alignment.CenterHorizontally, @@ -132,7 +135,7 @@ private fun PeopleScreenWithConversations( } LazyColumn( - Modifier.fillMaxWidth(), + Modifier.fillMaxWidth().sysuiResTag("scroll_view"), contentPadding = PaddingValues( top = 16.dp, diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/FooterActions.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/FooterActions.kt index 5c5ceefbd6fb..349f5c333116 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/FooterActions.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/FooterActions.kt @@ -73,6 +73,7 @@ import com.android.systemui.R import com.android.systemui.animation.Expandable import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.ui.compose.Icon +import com.android.systemui.compose.modifiers.sysuiResTag import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsButtonViewModel import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsForegroundServicesButtonViewModel import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsSecurityButtonViewModel @@ -180,9 +181,9 @@ fun FooterActions( security?.let { SecurityButton(it, Modifier.weight(1f)) } foregroundServices?.let { ForegroundServicesButton(it) } - userSwitcher?.let { IconButton(it) } - IconButton(viewModel.settings) - viewModel.power?.let { IconButton(it) } + userSwitcher?.let { IconButton(it, Modifier.sysuiResTag("multi_user_switch")) } + IconButton(viewModel.settings, Modifier.sysuiResTag("settings_button_container")) + viewModel.power?.let { IconButton(it, Modifier.sysuiResTag("pm_lite")) } } } } diff --git a/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt b/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt index e5ec727f0437..c0f854958c41 100644 --- a/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt +++ b/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt @@ -17,8 +17,12 @@ package com.android.systemui.compose +import android.content.Context +import android.view.View import androidx.activity.ComponentActivity +import androidx.lifecycle.LifecycleOwner import com.android.systemui.people.ui.viewmodel.PeopleViewModel +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel /** * A facade to interact with Compose, when it is available. @@ -35,10 +39,22 @@ interface BaseComposeFacade { */ fun isComposeAvailable(): Boolean + /** + * Return the [ComposeInitializer] to make Compose usable in windows outside normal activities. + */ + fun composeInitializer(): ComposeInitializer + /** Bind the content of [activity] to [viewModel]. */ fun setPeopleSpaceActivityContent( activity: ComponentActivity, viewModel: PeopleViewModel, onResult: (PeopleViewModel.Result) -> Unit, ) + + /** Create a [View] to represent [viewModel] on screen. */ + fun createFooterActionsView( + context: Context, + viewModel: FooterActionsViewModel, + qsVisibilityLifecycleOwner: LifecycleOwner, + ): View } diff --git a/packages/SystemUI/src/com/android/systemui/compose/ComposeInitializer.kt b/packages/SystemUI/src/com/android/systemui/compose/ComposeInitializer.kt new file mode 100644 index 000000000000..90dc3a00daa2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/compose/ComposeInitializer.kt @@ -0,0 +1,48 @@ +/* + * 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.compose + +import android.view.View + +/** + * An initializer to use Compose outside of an Activity, e.g. inside a window added directly using + * [android.view.WindowManager.addView] (like the shade or status bar) or inside a dialog. + * + * Example: + * ``` + * windowManager.addView(MyWindowRootView(context), /* layoutParams */) + * + * class MyWindowRootView(context: Context) : FrameLayout(context) { + * override fun onAttachedToWindow() { + * super.onAttachedToWindow() + * ComposeInitializer.onAttachedToWindow(this) + * } + * + * override fun onDetachedFromWindow() { + * super.onDetachedFromWindow() + * ComposeInitializer.onDetachedFromWindow(this) + * } + * } + * ``` + */ +interface ComposeInitializer { + /** Function to be called on your window root view's [View.onAttachedToWindow] function. */ + fun onAttachedToWindow(root: View) + + /** Function to be called on your window root view's [View.onDetachedFromWindow] function. */ + fun onDetachedFromWindow(root: View) +} diff --git a/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt b/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt index e3649187b0a7..d69ac7fe035d 100644 --- a/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt +++ b/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt @@ -145,7 +145,7 @@ private fun createLifecycleOwnerAndRun( * └───────────────┴───────────────────┴──────────────┴─────────────────┘ * ``` */ -private class ViewLifecycleOwner( +class ViewLifecycleOwner( private val view: View, ) : LifecycleOwner { diff --git a/packages/SystemUI/src/com/android/systemui/people/PeopleSpaceActivity.java b/packages/SystemUI/src/com/android/systemui/people/PeopleSpaceActivity.java index fba5f63ea9c7..7f0f89415280 100644 --- a/packages/SystemUI/src/com/android/systemui/people/PeopleSpaceActivity.java +++ b/packages/SystemUI/src/com/android/systemui/people/PeopleSpaceActivity.java @@ -68,8 +68,10 @@ public class PeopleSpaceActivity extends ComponentActivity { }; if (ComposeFacade.INSTANCE.isComposeAvailable()) { + Log.d(TAG, "Using the Compose implementation of the PeopleSpaceActivity"); ComposeFacade.INSTANCE.setPeopleSpaceActivityContent(this, viewModel, onResult); } else { + Log.d(TAG, "Using the View implementation of the PeopleSpaceActivity"); ViewGroup view = PeopleViewBinder.create(this); PeopleViewBinder.bind(view, viewModel, /* lifecycleOwner= */ this, onResult); setContentView(view); diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java b/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java index 774cb3442811..8ad102ece9b3 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java @@ -49,6 +49,7 @@ import com.android.systemui.Dumpable; import com.android.systemui.R; import com.android.systemui.animation.Interpolators; import com.android.systemui.animation.ShadeInterpolation; +import com.android.systemui.compose.ComposeFacade; import com.android.systemui.dump.DumpManager; import com.android.systemui.flags.FeatureFlags; import com.android.systemui.media.controls.ui.MediaHost; @@ -228,9 +229,7 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca mQSFooterActionsViewModel = mFooterActionsViewModelFactory.create(/* lifecycleOwner */ this); - LinearLayout footerActionsView = view.findViewById(R.id.qs_footer_actions); - FooterActionsViewBinder.bind(footerActionsView, mQSFooterActionsViewModel, - mListeningAndVisibilityLifecycleOwner); + bindFooterActionsView(view); mFooterActionsController.init(); mQSPanelScrollView = view.findViewById(R.id.expanded_qs_scroll_view); @@ -291,6 +290,33 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca }); } + private void bindFooterActionsView(View root) { + LinearLayout footerActionsView = root.findViewById(R.id.qs_footer_actions); + + if (!ComposeFacade.INSTANCE.isComposeAvailable()) { + Log.d(TAG, "Binding the View implementation of the QS footer actions"); + FooterActionsViewBinder.bind(footerActionsView, mQSFooterActionsViewModel, + mListeningAndVisibilityLifecycleOwner); + return; + } + + // Compose is available, so let's use the Compose implementation of the footer actions. + Log.d(TAG, "Binding the Compose implementation of the QS footer actions"); + View composeView = ComposeFacade.INSTANCE.createFooterActionsView(root.getContext(), + mQSFooterActionsViewModel, mListeningAndVisibilityLifecycleOwner); + + // The id R.id.qs_footer_actions is used by QSContainerImpl to set the horizontal margin + // to all views except for qs_footer_actions, so we set it to the Compose view. + composeView.setId(R.id.qs_footer_actions); + + // Replace the View by the Compose provided one. + ViewGroup parent = (ViewGroup) footerActionsView.getParent(); + ViewGroup.LayoutParams layoutParams = footerActionsView.getLayoutParams(); + int index = parent.indexOfChild(footerActionsView); + parent.removeViewAt(index); + parent.addView(composeView, index, layoutParams); + } + @Override public void setScrollListener(ScrollListener listener) { mScrollListener = listener; diff --git a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowView.java b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowView.java index 6acf417f0ea6..1f0cbf9af51c 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowView.java +++ b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowView.java @@ -58,6 +58,7 @@ import android.widget.FrameLayout; import com.android.internal.view.FloatingActionMode; import com.android.internal.widget.floatingtoolbar.FloatingToolbar; import com.android.systemui.R; +import com.android.systemui.compose.ComposeFacade; /** * Combined keyguard and notification panel view. Also holding backdrop and scrims. @@ -149,6 +150,18 @@ public class NotificationShadeWindowView extends FrameLayout { protected void onAttachedToWindow() { super.onAttachedToWindow(); setWillNotDraw(!DEBUG); + + if (ComposeFacade.INSTANCE.isComposeAvailable()) { + ComposeFacade.INSTANCE.composeInitializer().onAttachedToWindow(this); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (ComposeFacade.INSTANCE.isComposeAvailable()) { + ComposeFacade.INSTANCE.composeInitializer().onDetachedFromWindow(this); + } } @Override diff --git a/packages/SystemUI/tests/src/com/android/systemui/compose/ComposeInitializerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/compose/ComposeInitializerTest.kt new file mode 100644 index 000000000000..3e6cc3bb4f6b --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/compose/ComposeInitializerTest.kt @@ -0,0 +1,66 @@ +/* + * 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.compose + +import android.content.Context +import android.testing.AndroidTestingRunner +import android.testing.ViewUtils +import android.widget.FrameLayout +import androidx.compose.ui.platform.ComposeView +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class ComposeInitializerTest : SysuiTestCase() { + @Test + fun testCanAddComposeViewInInitializedWindow() { + if (!ComposeFacade.isComposeAvailable()) { + return + } + + val root = TestWindowRoot(context) + try { + runOnMainThreadAndWaitForIdleSync { ViewUtils.attachView(root) } + assertThat(root.isAttachedToWindow).isTrue() + + runOnMainThreadAndWaitForIdleSync { root.addView(ComposeView(context)) } + } finally { + runOnMainThreadAndWaitForIdleSync { ViewUtils.detachView(root) } + } + } + + private fun runOnMainThreadAndWaitForIdleSync(f: () -> Unit) { + mContext.mainExecutor.execute(f) + waitForIdleSync() + } + + class TestWindowRoot(context: Context) : FrameLayout(context) { + override fun onAttachedToWindow() { + super.onAttachedToWindow() + ComposeFacade.composeInitializer().onAttachedToWindow(this) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + ComposeFacade.composeInitializer().onDetachedFromWindow(this) + } + } +} |