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

Commit 8a5766b3 authored by Lucas Silva's avatar Lucas Silva
Browse files

Simplify communal widget loading

We introduce CommunalAppWidgetViewModel to move only the necessary IPCs
to a background thread, while keeping everything else on the main
thread. This also simplifies things by removing the container FrameLayout
we previously used to asynchronously load widgets.

Fixes: 382744855
Test: atest CommunalAppWidgetViewModelTest
Flag: EXEMPT bugfix
Change-Id: Ia2ae8a493467e7ca5d52e6994d4a282acb8ffef0
parent 4f8a4c44
Loading
Loading
Loading
Loading
+3 −1
Original line number Diff line number Diff line
@@ -1352,6 +1352,7 @@ private fun WidgetContent(
    val shrinkWidgetLabel = stringResource(R.string.accessibility_action_label_shrink_widget)
    val expandWidgetLabel = stringResource(R.string.accessibility_action_label_expand_widget)

    val isFocusable by viewModel.isFocusable.collectAsStateWithLifecycle(initialValue = false)
    val selectedKey by viewModel.selectedKey.collectAsStateWithLifecycle()
    val selectedIndex =
        selectedKey?.let { key -> contentListState.list.indexOfFirst { it.key == key } }
@@ -1465,7 +1466,8 @@ private fun WidgetContent(
    ) {
        with(widgetSection) {
            Widget(
                viewModel = viewModel,
                isFocusable = isFocusable,
                openWidgetEditor = { viewModel.onOpenWidgetEditor() },
                model = model,
                size = size,
                modifier = Modifier.fillMaxSize().allowGestures(allowed = !viewModel.isEditMode),
+154 −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.communal.ui.viewmodel

import android.appwidget.AppWidgetHost.AppWidgetHostListener
import android.appwidget.AppWidgetHostView
import android.platform.test.flag.junit.FlagsParameterization
import android.util.SizeF
import android.widget.RemoteViews
import androidx.test.filters.SmallTest
import com.android.systemui.Flags.FLAG_SECONDARY_USER_WIDGET_HOST
import com.android.systemui.SysuiTestCase
import com.android.systemui.communal.shared.model.fakeGlanceableHubMultiUserHelper
import com.android.systemui.communal.widgets.AppWidgetHostListenerDelegate
import com.android.systemui.communal.widgets.CommunalAppWidgetHost
import com.android.systemui.communal.widgets.GlanceableHubWidgetManager
import com.android.systemui.concurrency.fakeExecutor
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.backgroundCoroutineContext
import com.android.systemui.kosmos.runCurrent
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.testKosmos
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import platform.test.runner.parameterized.ParameterizedAndroidJunit4
import platform.test.runner.parameterized.Parameters

@SmallTest
@RunWith(ParameterizedAndroidJunit4::class)
class CommunalAppWidgetViewModelTest(flags: FlagsParameterization) : SysuiTestCase() {
    val kosmos = testKosmos()

    init {
        mSetFlagsRule.setFlagsParameterization(flags)
    }

    private val Kosmos.listenerDelegateFactory by
        Kosmos.Fixture {
            AppWidgetHostListenerDelegate.Factory { listener ->
                AppWidgetHostListenerDelegate(fakeExecutor, listener)
            }
        }

    private val Kosmos.appWidgetHost by
        Kosmos.Fixture {
            mock<CommunalAppWidgetHost> {
                on { setListener(any(), any()) } doAnswer
                    { invocation ->
                        val callback = invocation.arguments[1] as AppWidgetHostListener
                        callback.updateAppWidget(mock<RemoteViews>())
                    }
            }
        }

    private val Kosmos.glanceableHubWidgetManager by
        Kosmos.Fixture {
            mock<GlanceableHubWidgetManager> {
                on { setAppWidgetHostListener(any(), any()) } doAnswer
                    { invocation ->
                        val callback = invocation.arguments[1] as AppWidgetHostListener
                        callback.updateAppWidget(mock<RemoteViews>())
                    }
            }
        }

    private val Kosmos.underTest by
        Kosmos.Fixture {
            CommunalAppWidgetViewModel(
                    backgroundCoroutineContext,
                    { appWidgetHost },
                    listenerDelegateFactory,
                    { glanceableHubWidgetManager },
                    fakeGlanceableHubMultiUserHelper,
                )
                .apply { activateIn(testScope) }
        }

    @Test
    fun setListener() =
        kosmos.runTest {
            val listener = mock<AppWidgetHostListener>()

            underTest.setListener(123, listener)
            runAll()

            verify(listener).updateAppWidget(any())
        }

    @Test
    fun setListener_HSUM() =
        kosmos.runTest {
            fakeGlanceableHubMultiUserHelper.setIsInHeadlessSystemUser(true)
            val listener = mock<AppWidgetHostListener>()

            underTest.setListener(123, listener)
            runAll()

            verify(listener).updateAppWidget(any())
        }

    @Test
    fun updateSize() =
        kosmos.runTest {
            val view = mock<AppWidgetHostView>()
            val size = SizeF(/* width= */ 100f, /* height= */ 200f)

            underTest.updateSize(size, view)
            runAll()

            verify(view)
                .updateAppWidgetSize(
                    /* newOptions = */ any(),
                    /* minWidth = */ eq(100),
                    /* minHeight = */ eq(200),
                    /* maxWidth = */ eq(100),
                    /* maxHeight = */ eq(200),
                    /* ignorePadding = */ eq(true),
                )
        }

    private fun Kosmos.runAll() {
        runCurrent()
        fakeExecutor.runAllReady()
    }

    private companion object {
        @JvmStatic
        @Parameters(name = "{0}")
        fun getParams(): List<FlagsParameterization> {
            return FlagsParameterization.allCombinationsOf(FLAG_SECONDARY_USER_WIDGET_HOST)
        }
    }
}
+0 −1
Original line number Diff line number Diff line
@@ -169,7 +169,6 @@ class CommunalViewModelTest(flags: FlagsParameterization) : SysuiTestCase() {
            kosmos.testDispatcher,
            testScope,
            kosmos.testScope.backgroundScope,
            context.resources,
            kosmos.keyguardTransitionInteractor,
            kosmos.keyguardInteractor,
            mock<KeyguardIndicationController>(),
+1 −1
Original line number Diff line number Diff line
@@ -280,7 +280,7 @@
    <item type="id" name="udfps_accessibility_overlay_top_guideline" />

    <!-- Ids for communal hub widgets -->
    <item type="id" name="communal_widget_disposable_tag"/>
    <item type="id" name="communal_widget_listener_tag"/>

    <!-- snapshot view-binding IDs -->
    <item type="id" name="snapshot_view_binding" />
+0 −110
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.communal.ui.binder

import android.content.Context
import android.os.Bundle
import android.util.SizeF
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.ui.unit.IntSize
import androidx.core.view.doOnLayout
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.systemui.Flags.communalWidgetResizing
import com.android.systemui.common.ui.view.onLayoutChanged
import com.android.systemui.communal.domain.model.CommunalContentModel
import com.android.systemui.communal.util.WidgetViewFactory
import com.android.systemui.util.kotlin.DisposableHandles
import com.android.systemui.util.kotlin.toDp
import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn

object CommunalAppWidgetHostViewBinder {
    private const val TAG = "CommunalAppWidgetHostViewBinder"

    fun bind(
        context: Context,
        applicationScope: CoroutineScope,
        mainContext: CoroutineContext,
        backgroundContext: CoroutineContext,
        container: FrameLayout,
        model: CommunalContentModel.WidgetContent.Widget,
        size: SizeF?,
        factory: WidgetViewFactory,
    ): DisposableHandle {
        val disposables = DisposableHandles()

        val loadingJob =
            applicationScope.launch("$TAG#createWidgetView") {
                val widget = factory.createWidget(context, model, size)
                waitForLayout(container)
                container.post { container.setView(widget) }
                if (communalWidgetResizing()) {
                    // Update the app widget size in the background.
                    launch("$TAG#updateSize", backgroundContext) {
                        container.sizeFlow().flowOn(mainContext).distinctUntilChanged().collect {
                            (width, height) ->
                            widget.updateAppWidgetSize(
                                /* newOptions = */ Bundle(),
                                /* minWidth = */ width,
                                /* minHeight = */ height,
                                /* maxWidth = */ width,
                                /* maxHeight = */ height,
                                /* ignorePadding = */ true,
                            )
                        }
                    }
                }
            }

        disposables += DisposableHandle { loadingJob.cancel() }
        disposables += DisposableHandle { container.removeAllViews() }

        return disposables
    }

    private suspend fun waitForLayout(container: FrameLayout) = suspendCoroutine { cont ->
        container.doOnLayout { cont.resume(Unit) }
    }
}

private fun ViewGroup.setView(view: View) {
    if (view.parent == this) {
        return
    }
    (view.parent as? ViewGroup)?.removeView(view)
    addView(view)
}

private fun View.sizeAsDp(): IntSize = IntSize(width.toDp(context), height.toDp(context))

private fun View.sizeFlow(): Flow<IntSize> = conflatedCallbackFlow {
    if (isLaidOut && !isLayoutRequested) {
        trySend(sizeAsDp())
    }
    val disposable = onLayoutChanged { trySend(sizeAsDp()) }
    awaitClose { disposable.dispose() }
}
Loading