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

Commit 273179e2 authored by Lucas Silva's avatar Lucas Silva Committed by Android (Google) Code Review
Browse files

Merge "Simplify communal widget loading" into main

parents 1446e959 8a5766b3
Loading
Loading
Loading
Loading
+3 −1
Original line number Diff line number Diff line
@@ -1398,6 +1398,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 } }
@@ -1511,7 +1512,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
@@ -172,7 +172,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