Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 5179ae7c authored by Evan Laird's avatar Evan Laird
Browse files

[sb] Basic compose replacement of the fragment

This CL sets up the minimum amount of scaffolding to hook up the
existing PhoneStatusBarView (+ its controller) to the status bar window,
wihtout the use of the CollapsedStatusBarFragment.

There are still some broken things (like event animations), but the
general idea is sound. It also has the advantage of making clear the
list of things that we need to modernize; anything that doesn't look
like native Compose (like setting up icon controllers) needs to be
turned into modernized architecture.

Test: StatusBarInitializerTest
Bug: 364360986
Flag: com.android.systemui.status_bar_simple_fragment
Change-Id: I1f6301f94adbddc8a35599a27a6fd41d5d07ad20
parent 9129d491
Loading
Loading
Loading
Loading
+22 −4
Original line number Diff line number Diff line
@@ -20,12 +20,15 @@ import android.app.FragmentManager
import android.app.FragmentTransaction
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import android.view.ViewGroup
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.Flags
import com.android.systemui.SysuiTestCase
import com.android.systemui.fragments.FragmentHostManager
import com.android.systemui.statusbar.phone.fragment.CollapsedStatusBarFragment
import com.android.systemui.statusbar.phone.fragment.dagger.StatusBarFragmentComponent
import com.android.systemui.statusbar.pipeline.shared.ui.composable.StatusBarRootFactory
import com.android.systemui.statusbar.window.StatusBarWindowController
import com.android.systemui.statusbar.window.StatusBarWindowControllerStore
import com.google.common.truth.Truth.assertThat
@@ -35,6 +38,8 @@ import org.junit.Before
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.kotlin.any
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@SmallTest
@@ -42,27 +47,31 @@ import org.mockito.kotlin.whenever
class StatusBarInitializerTest : SysuiTestCase() {
    private val windowController = mock(StatusBarWindowController::class.java)
    private val windowControllerStore = mock(StatusBarWindowControllerStore::class.java)
    private val transaction = mock(FragmentTransaction::class.java)
    private val fragmentManager = mock(FragmentManager::class.java)
    private val fragmentHostManager = mock(FragmentHostManager::class.java)
    private val backgroundView = mock(ViewGroup::class.java)

    @Before
    fun setup() {
        // TODO(b/364360986) this will go away once the fragment is deprecated. Hence, there is no
        // need right now for moving this to kosmos
        val transaction = mock(FragmentTransaction::class.java)
        val fragmentManager = mock(FragmentManager::class.java)
        val fragmentHostManager = mock(FragmentHostManager::class.java)
        whenever(fragmentHostManager.addTagListener(any(), any())).thenReturn(fragmentHostManager)
        whenever(fragmentHostManager.fragmentManager).thenReturn(fragmentManager)
        whenever(fragmentManager.beginTransaction()).thenReturn(transaction)
        whenever(transaction.replace(any(), any(), any())).thenReturn(transaction)
        whenever(windowControllerStore.defaultDisplay).thenReturn(windowController)
        whenever(windowController.fragmentHostManager).thenReturn(fragmentHostManager)
        whenever(windowController.backgroundView).thenReturn(backgroundView)
    }

    val underTest =
        StatusBarInitializerImpl(
            statusBarWindowController = windowController,
            collapsedStatusBarFragmentProvider = { mock(CollapsedStatusBarFragment::class.java) },
            statusBarRootFactory = mock(StatusBarRootFactory::class.java),
            componentFactory = mock(StatusBarFragmentComponent.Factory::class.java),
            creationListeners = setOf(),
            statusBarWindowController = windowController,
        )

    @Test
@@ -78,6 +87,15 @@ class StatusBarInitializerTest : SysuiTestCase() {
        assertThrows(IllegalStateException::class.java) { underTest.initializeStatusBar() }
    }

    @Test
    @EnableFlags(Flags.FLAG_STATUS_BAR_SIMPLE_FRAGMENT)
    fun simpleFragment_flagEnabled_doesNotCreateFragment() {
        underTest.start()

        verify(fragmentManager, never()).beginTransaction()
        verify(transaction, never()).replace(any(), any(), any())
    }

    @Test
    @DisableFlags(Flags.FLAG_STATUS_BAR_SIMPLE_FRAGMENT)
    fun flagOff_doesNotInitializeViaCoreStartable() {
+45 −4
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@
package com.android.systemui.statusbar.core

import android.app.Fragment
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import com.android.systemui.CoreStartable
import com.android.systemui.fragments.FragmentHostManager
@@ -23,9 +24,11 @@ import com.android.systemui.res.R
import com.android.systemui.statusbar.core.StatusBarInitializer.OnStatusBarViewInitializedListener
import com.android.systemui.statusbar.core.StatusBarInitializer.OnStatusBarViewUpdatedListener
import com.android.systemui.statusbar.phone.PhoneStatusBarTransitions
import com.android.systemui.statusbar.phone.PhoneStatusBarView
import com.android.systemui.statusbar.phone.PhoneStatusBarViewController
import com.android.systemui.statusbar.phone.fragment.CollapsedStatusBarFragment
import com.android.systemui.statusbar.phone.fragment.dagger.StatusBarFragmentComponent
import com.android.systemui.statusbar.pipeline.shared.ui.composable.StatusBarRootFactory
import com.android.systemui.statusbar.window.StatusBarWindowController
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@@ -77,6 +80,8 @@ class StatusBarInitializerImpl
constructor(
    @Assisted private val statusBarWindowController: StatusBarWindowController,
    private val collapsedStatusBarFragmentProvider: Provider<CollapsedStatusBarFragment>,
    private val statusBarRootFactory: StatusBarRootFactory,
    private val componentFactory: StatusBarFragmentComponent.Factory,
    private val creationListeners: Set<@JvmSuppressWildcards OnStatusBarViewInitializedListener>,
) : StatusBarInitializer {
    private var component: StatusBarFragmentComponent? = null
@@ -109,21 +114,57 @@ constructor(
    }

    private fun doStart() {
        if (StatusBarSimpleFragment.isEnabled) doComposeStart() else doLegacyStart()
    }

    /**
     * Stand up the [PhoneStatusBarView] in a compose root. There will be no
     * [CollapsedStatusBarFragment] in this mode
     */
    private fun doComposeStart() {
        initialized = true
        val statusBarRoot =
            statusBarRootFactory.create(statusBarWindowController.backgroundView as ViewGroup) { cv
                ->
                val phoneStatusBarView = cv.findViewById<PhoneStatusBarView>(R.id.status_bar)
                component =
                    componentFactory.create(phoneStatusBarView).also { component ->
                        // CollapsedStatusBarFragment used to be responsible initializting
                        component.init()

                        statusBarViewUpdatedListener?.onStatusBarViewUpdated(
                            component.phoneStatusBarViewController,
                            component.phoneStatusBarTransitions,
                        )

                        creationListeners.forEach { listener ->
                            listener.onStatusBarViewInitialized(component)
                        }
                    }
            }

        // Add the new compose view to the hierarchy because we don't use fragment transactions
        // anymore
        val windowBackgroundView = statusBarWindowController.backgroundView as ViewGroup
        windowBackgroundView.addView(statusBarRoot)
    }

    private fun doLegacyStart() {
        initialized = true
        statusBarWindowController.fragmentHostManager
            .addTagListener(
                CollapsedStatusBarFragment.TAG,
                object : FragmentHostManager.FragmentListener {
                    override fun onFragmentViewCreated(tag: String, fragment: Fragment) {
                        val statusBarFragmentComponent =
                        component =
                            (fragment as CollapsedStatusBarFragment).statusBarFragmentComponent
                                ?: throw IllegalStateException()
                        statusBarViewUpdatedListener?.onStatusBarViewUpdated(
                            statusBarFragmentComponent.phoneStatusBarViewController,
                            statusBarFragmentComponent.phoneStatusBarTransitions,
                            component!!.phoneStatusBarViewController,
                            component!!.phoneStatusBarTransitions,
                        )
                        creationListeners.forEach { listener ->
                            listener.onStatusBarViewInitialized(statusBarFragmentComponent)
                            listener.onStatusBarViewInitialized(component!!)
                        }
                    }

+2 −2
Original line number Diff line number Diff line
@@ -169,11 +169,11 @@ constructor(
    }

    private fun createAndAddWindow() {
        initializeStatusBarFragment()
        initializeStatusBarRootView()
        statusBarWindowController.attach()
    }

    private fun initializeStatusBarFragment() {
    private fun initializeStatusBarRootView() {
        statusBarInitializer.statusBarViewUpdatedListener =
            object : StatusBarInitializer.OnStatusBarViewUpdatedListener {
                override fun onStatusBarViewUpdated(
+66 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.statusbar.pipeline.shared.ui.composable

import androidx.compose.foundation.layout.offset
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

private val retroColors =
    listOf(
        Color(0xFFEADFB4), // beige
        Color(0xFF9BB0C1), // gray-blue
        Color(0xFFF6995C), // orange
        Color(0xFF51829B), // cyan
    )

/** Render a single string multiple times (with offsets) kinda like retro vintage text */
@Composable
fun RetroText(text: String = "") {
    // Render the text for each retroColor, and then once for the foreground
    for (i in retroColors.size downTo 1) {
        val color = retroColors[i - 1]
        RetroTextLayer(text = text, color = color, (-1.5 * i).dp, i.dp)
    }

    RetroTextLayer(text = text, color = Color.Black, ox = 0.dp, oy = 0.dp)
}

@Composable
fun RetroTextLayer(text: String, color: Color, ox: Dp, oy: Dp) {
    Text(
        text = text,
        modifier = Modifier.offset(ox, oy),
        textAlign = TextAlign.Center,
        style =
            TextStyle(
                fontSize = 18.sp,
                fontWeight = FontWeight.Bold,
                fontStyle = FontStyle.Italic,
                color = color,
            ),
    )
}
+195 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.statusbar.pipeline.shared.ui.composable

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.systemui.res.R
import com.android.systemui.statusbar.notification.icon.ui.viewbinder.NotificationIconContainerStatusBarViewBinder
import com.android.systemui.statusbar.phone.NotificationIconContainer
import com.android.systemui.statusbar.phone.PhoneStatusBarView
import com.android.systemui.statusbar.phone.StatusBarLocation
import com.android.systemui.statusbar.phone.StatusIconContainer
import com.android.systemui.statusbar.phone.ongoingcall.OngoingCallController
import com.android.systemui.statusbar.phone.ui.DarkIconManager
import com.android.systemui.statusbar.phone.ui.StatusBarIconController
import com.android.systemui.statusbar.pipeline.shared.ui.binder.CollapsedStatusBarViewBinder
import com.android.systemui.statusbar.pipeline.shared.ui.binder.StatusBarVisibilityChangeListener
import com.android.systemui.statusbar.pipeline.shared.ui.viewmodel.CollapsedStatusBarViewModel
import javax.inject.Inject
import kotlinx.coroutines.launch

/** Factory to simplify the dependency management for [StatusBarRoot] */
class StatusBarRootFactory
@Inject
constructor(
    private val context: Context,
    private val collapsedStatusBarViewModel: CollapsedStatusBarViewModel,
    private val collapsedStatusBarViewBinder: CollapsedStatusBarViewBinder,
    private val notificationIconsBinder: NotificationIconContainerStatusBarViewBinder,
    private val darkIconManagerFactory: DarkIconManager.Factory,
    private val iconController: StatusBarIconController,
    private val ongoingCallController: OngoingCallController,
) {
    fun create(root: ViewGroup, andThen: (ViewGroup) -> Unit): ComposeView {
        val composeView = ComposeView(context)
        composeView.apply {
            setContent {
                StatusBarRoot(
                    parent = root,
                    statusBarViewModel = collapsedStatusBarViewModel,
                    statusBarViewBinder = collapsedStatusBarViewBinder,
                    notificationIconsBinder = notificationIconsBinder,
                    darkIconManagerFactory = darkIconManagerFactory,
                    iconController = iconController,
                    ongoingCallController = ongoingCallController,
                    onViewCreated = andThen,
                )
            }
        }

        return composeView
    }
}

/**
 * For now, this class exists only to replace the former CollapsedStatusBarFragment. We simply stand
 * up the PhoneStatusBarView here (allowing the component to be initialized from the [init] block).
 * This is the place, for now, where we can manually set up lingering dependencies that came from
 * the fragment until we can move them to recommended-arch style repos.
 *
 * @param onViewCreated called immediately after the view is inflated, and takes as a parameter the
 *   newly-inflated PhoneStatusBarView. This lambda is useful for tying together old initialization
 *   logic until it can be replaced.
 */
@Composable
fun StatusBarRoot(
    parent: ViewGroup,
    statusBarViewModel: CollapsedStatusBarViewModel,
    statusBarViewBinder: CollapsedStatusBarViewBinder,
    notificationIconsBinder: NotificationIconContainerStatusBarViewBinder,
    darkIconManagerFactory: DarkIconManager.Factory,
    iconController: StatusBarIconController,
    ongoingCallController: OngoingCallController,
    onViewCreated: (ViewGroup) -> Unit,
) {
    // None of these methods are used when [StatusBarSimpleFragment] is on.
    // This can be deleted once the fragment is gone
    val nopVisibilityChangeListener =
        object : StatusBarVisibilityChangeListener {
            override fun onStatusBarVisibilityMaybeChanged() {}

            override fun onTransitionFromLockscreenToDreamStarted() {}

            override fun onOngoingActivityStatusChanged(
                hasPrimaryOngoingActivity: Boolean,
                hasSecondaryOngoingActivity: Boolean,
                shouldAnimate: Boolean,
            ) {}

            override fun onIsHomeStatusBarAllowedBySceneChanged(
                isHomeStatusBarAllowedByScene: Boolean
            ) {}
        }

    Box(Modifier.fillMaxSize()) {
        // TODO(b/364360986): remove this before rolling the flag forward
        Disambiguation(viewModel = statusBarViewModel)

        Row(Modifier.fillMaxSize()) {
            val scope = rememberCoroutineScope()
            AndroidView(
                factory = { context ->
                    val inflater = LayoutInflater.from(context)
                    val phoneStatusBarView =
                        inflater.inflate(R.layout.status_bar, parent, false) as PhoneStatusBarView

                    // For now, just set up the system icons the same way we used to
                    val statusIconContainer =
                        phoneStatusBarView.requireViewById<StatusIconContainer>(R.id.statusIcons)
                    // TODO(b/364360986): turn this into a repo/intr/viewmodel
                    val darkIconManager =
                        darkIconManagerFactory.create(statusIconContainer, StatusBarLocation.HOME)
                    iconController.addIconGroup(darkIconManager)

                    // TODO(b/372657935): This won't be needed once OngoingCallController is
                    // implemented in recommended architecture
                    ongoingCallController.setChipView(
                        phoneStatusBarView.requireViewById(R.id.ongoing_activity_chip_primary)
                    )

                    // For notifications, first inflate the [NotificationIconContainer]
                    val notificationIconArea =
                        phoneStatusBarView.requireViewById<ViewGroup>(R.id.notification_icon_area)
                    inflater.inflate(R.layout.notification_icon_area, notificationIconArea, true)
                    // Then bind it using the icons binder
                    val notificationIconContainer =
                        phoneStatusBarView.requireViewById<NotificationIconContainer>(
                            R.id.notificationIcons
                        )
                    scope.launch {
                        notificationIconsBinder.bindWhileAttached(notificationIconContainer)
                    }

                    // This binder handles everything else
                    scope.launch {
                        statusBarViewBinder.bind(
                            phoneStatusBarView,
                            statusBarViewModel,
                            nopVisibilityChangeListener,
                        )
                    }
                    onViewCreated(phoneStatusBarView)
                    phoneStatusBarView
                }
            )
        }
    }
}

/**
 * This is our analog of the flexi "ribbon", which just shows some text so we know if the flag is on
 */
@Composable
fun Disambiguation(viewModel: CollapsedStatusBarViewModel) {
    val clockVisibilityModel =
        viewModel.isClockVisible.collectAsStateWithLifecycle(
            initialValue =
                CollapsedStatusBarViewModel.VisibilityModel(
                    visibility = View.GONE,
                    shouldAnimateChange = false,
                )
        )
    if (clockVisibilityModel.value.visibility == View.VISIBLE) {
        Box(modifier = Modifier.fillMaxSize().alpha(0.5f), contentAlignment = Alignment.Center) {
            RetroText(text = "COMPOSE->BAR")
        }
    }
}
Loading