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

Commit 5c9d7eeb authored by Lucas Dupin's avatar Lucas Dupin Committed by Android (Google) Code Review
Browse files

Merge "SmartSpace <-> Underlay communication" into main

parents b89e6029 229e143e
Loading
Loading
Loading
Loading
+107 −16
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 The Android Open Source Project
 * Copyright (C) 2025 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.
@@ -16,49 +16,140 @@

package com.android.systemui.underlay.data.repository

import android.content.Intent
import android.app.smartspace.SmartspaceAction
import android.app.smartspace.SmartspaceManager
import android.app.smartspace.SmartspaceSession
import android.app.smartspace.SmartspaceSession.OnTargetsAvailableListener
import android.app.smartspace.SmartspaceTarget
import android.content.testableContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.broadcast.broadcastDispatcher
import com.android.systemui.concurrency.fakeExecutor
import com.android.systemui.kosmos.advanceUntilIdle
import com.android.systemui.kosmos.backgroundScope
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runCurrent
import com.android.systemui.kosmos.runTest
import com.android.systemui.testKosmos
import com.android.systemui.underlay.data.repository.UnderlayRepositoryImpl.Companion.AMBIENT_ACTION_FEATURE
import com.android.systemui.underlay.data.repository.UnderlayRepositoryImpl.Companion.UNDERLAY_SURFACE
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify

@RunWith(AndroidJUnit4::class)
@SmallTest
class UnderlayRepositoryTest : SysuiTestCase() {
    private val kosmos = testKosmos()
    private val smartSpaceSession = mock<SmartspaceSession>()
    private val smartSpaceManager =
        mock<SmartspaceManager>() {
            on { createSmartspaceSession(any()) } doReturn smartSpaceSession
        }
    val onTargetsAvailableListenerCaptor = argumentCaptor<OnTargetsAvailableListener>()
    private val underTest =
        UnderlayRepositoryImpl(
            backgroundScope = kosmos.backgroundScope,
            broadcastDispatcher = kosmos.broadcastDispatcher,
            smartSpaceManager = smartSpaceManager,
            executor = kosmos.fakeExecutor,
            applicationContext = kosmos.testableContext,
        )

    @Test
    fun isUnderlayAttached_whenCreated_true() =
    fun isUnderlayAttached_whenHasActions_true() =
        kosmos.runTest {
            val isUnderlayAttached by collectLastValue(underlayRepository.isUnderlayAttached)
            val isUnderlayAttached by collectLastValue(underTest.isAttached)
            runCurrent()
            verify(smartSpaceSession)
                .addOnTargetsAvailableListener(any(), onTargetsAvailableListenerCaptor.capture())
            onTargetsAvailableListenerCaptor.firstValue.onTargetsAvailable(allTargets)
            advanceUntilIdle()
            assertThat(isUnderlayAttached).isTrue()
        }

            broadcastDispatcher.sendIntentToMatchingReceiversOnly(
                context,
                Intent(UnderlayRepository.ACTION_CREATE_UNDERLAY),
    @Test
    fun isUnderlayAttached_whenNoActions_false() =
        kosmos.runTest {
            val isUnderlayAttached by collectLastValue(underTest.isAttached)
            runCurrent()
            verify(smartSpaceSession)
                .addOnTargetsAvailableListener(any(), onTargetsAvailableListenerCaptor.capture())
            onTargetsAvailableListenerCaptor.firstValue.onTargetsAvailable(
                listOf(invalidTarget1, invalidTarget2)
            )

            assertThat(isUnderlayAttached).isTrue()
            advanceUntilIdle()
            assertThat(isUnderlayAttached).isFalse()
        }

    @Test
    fun isUnderlayAttached_whenDestroyed_false() =
    fun actions_whenHasSmartSpaceAction() =
        kosmos.runTest {
            val isUnderlayAttached by collectLastValue(underlayRepository.isUnderlayAttached)
            val actions by collectLastValue(underTest.actions)
            runCurrent()
            verify(smartSpaceSession)
                .addOnTargetsAvailableListener(any(), onTargetsAvailableListenerCaptor.capture())
            onTargetsAvailableListenerCaptor.firstValue.onTargetsAvailable(allTargets)
            runCurrent()

            broadcastDispatcher.sendIntentToMatchingReceiversOnly(
                context,
                Intent(UnderlayRepository.ACTION_DESTROY_UNDERLAY),
            actions.let {
                requireNotNull(it)
                assertThat(it.size).isEqualTo(2)

                val firstAction = it.first()
                assertThat(firstAction.label).isEqualTo(TITLE_1)
                assertThat(firstAction.attribution).isEqualTo(SUBTITLE_1)

                val lastAction = it.last()
                assertThat(lastAction.label).isEqualTo(TITLE_2)
                assertThat(lastAction.attribution).isEqualTo(SUBTITLE_2)
            }
        }

    companion object {

        private const val TITLE_1 = "title 1"
        private const val TITLE_2 = "title 2"
        private const val SUBTITLE_1 = "subtitle 1"
        private const val SUBTITLE_2 = "subtitle 2"
        private val validTarget =
            mock<SmartspaceTarget> {
                on { featureType } doReturn AMBIENT_ACTION_FEATURE
                on { smartspaceTargetId } doReturn UNDERLAY_SURFACE
                on { actionChips } doReturn
                    listOf(
                        SmartspaceAction.Builder("action1-id", "title 1")
                            .setSubtitle("subtitle 1")
                            .build(),
                        SmartspaceAction.Builder("action2-id", "title 2")
                            .setSubtitle("subtitle 2")
                            .build(),
                    )
            }

            assertThat(isUnderlayAttached).isFalse()
        private val invalidTarget1 =
            mock<SmartspaceTarget> {
                on { featureType } doReturn 1
                on { smartspaceTargetId } doReturn UNDERLAY_SURFACE
                on { actionChips } doReturn
                    listOf(SmartspaceAction.Builder("id", "title").setSubtitle("subtitle").build())
            }

        private val invalidTarget2 =
            mock<SmartspaceTarget> {
                on { featureType } doReturn AMBIENT_ACTION_FEATURE
                on { smartspaceTargetId } doReturn "home"
                on { actionChips } doReturn
                    listOf(SmartspaceAction.Builder("id", "title").setSubtitle("subtitle").build())
            }

        private val allTargets = listOf(validTarget, invalidTarget1, invalidTarget2)
    }
}
+7 −25
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 The Android Open Source Project
 * Copyright (C) 2025 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.
@@ -21,13 +21,12 @@ import android.content.applicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.broadcast.broadcastDispatcher
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runCurrent
import com.android.systemui.kosmos.runTest
import com.android.systemui.res.R
import com.android.systemui.testKosmos
import com.android.systemui.underlay.data.repository.UnderlayRepository
import com.android.systemui.underlay.data.repository.fake
import com.android.systemui.underlay.data.repository.underlayRepository
import com.android.systemui.underlay.shared.model.ActionModel
import com.google.common.truth.Truth.assertThat
import org.junit.Test
@@ -42,13 +41,7 @@ class UnderlayInteractorTest : SysuiTestCase() {
    fun isUnderlayAttached_whenCreated_true() =
        kosmos.runTest {
            val isUnderlayAttached by collectLastValue(underlayInteractor.isUnderlayAttached)
            runCurrent()

            broadcastDispatcher.sendIntentToMatchingReceiversOnly(
                context,
                Intent(UnderlayRepository.ACTION_CREATE_UNDERLAY),
            )

            underlayRepository.fake.setIsUnderlayAttached(true)
            assertThat(isUnderlayAttached).isTrue()
        }

@@ -56,13 +49,7 @@ class UnderlayInteractorTest : SysuiTestCase() {
    fun isUnderlayAttached_whenDestroyed_false() =
        kosmos.runTest {
            val isUnderlayAttached by collectLastValue(underlayInteractor.isUnderlayAttached)
            runCurrent()

            broadcastDispatcher.sendIntentToMatchingReceiversOnly(
                context,
                Intent(UnderlayRepository.ACTION_DESTROY_UNDERLAY),
            )

            underlayRepository.fake.setIsUnderlayAttached(false)
            assertThat(isUnderlayAttached).isFalse()
        }

@@ -70,9 +57,7 @@ class UnderlayInteractorTest : SysuiTestCase() {
    fun isOverlayVisible_setTrue_true() =
        kosmos.runTest {
            val isOverlayVisible by collectLastValue(underlayInteractor.isOverlayVisible)

            underlayInteractor.setIsOverlayVisible(true)

            assertThat(isOverlayVisible).isTrue()
        }

@@ -80,9 +65,7 @@ class UnderlayInteractorTest : SysuiTestCase() {
    fun isOverlayVisible_setFalse_False() =
        kosmos.runTest {
            val isOverlayVisible by collectLastValue(underlayInteractor.isOverlayVisible)

            underlayInteractor.setIsOverlayVisible(false)

            assertThat(isOverlayVisible).isFalse()
        }

@@ -100,11 +83,10 @@ class UnderlayInteractorTest : SysuiTestCase() {
                            ),
                        label = "Sunday Morning",
                        attribution = null,
                        intent = Intent(),
                    )
                )

            underlayInteractor.setActions(testActions)

            underlayRepository.fake.setActions(testActions)
            assertThat(actions).isEqualTo(testActions)
        }
}
+4 −0
Original line number Diff line number Diff line
@@ -17,6 +17,8 @@
package com.android.systemui.underlay

import com.android.systemui.CoreStartable
import com.android.systemui.underlay.data.repository.UnderlayRepository
import com.android.systemui.underlay.data.repository.UnderlayRepositoryImpl
import com.android.systemui.underlay.ui.startable.UnderlayCoreStartable
import dagger.Binds
import dagger.Module
@@ -30,4 +32,6 @@ interface UnderlayModule {
    @IntoMap
    @ClassKey(UnderlayCoreStartable::class)
    fun bindUnderlayCoreStartable(startable: UnderlayCoreStartable): CoreStartable

    @Binds fun bindsUnderlayRepository(impl: UnderlayRepositoryImpl): UnderlayRepository
}
+100 −10
Original line number Diff line number Diff line
@@ -16,28 +16,57 @@

package com.android.systemui.underlay.data.repository

import android.app.smartspace.SmartspaceConfig
import android.app.smartspace.SmartspaceManager
import android.app.smartspace.SmartspaceSession.OnTargetsAvailableListener
import android.content.Context
import android.content.IntentFilter
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.android.systemui.broadcast.BroadcastDispatcher
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.res.R
import com.android.systemui.underlay.shared.model.ActionModel
import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
import java.util.concurrent.Executor
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn

/** Source of truth for ambient actions and visibility of their system space. */
interface UnderlayRepository {
    /** Chips that should be visible on the UI. */
    val actions: StateFlow<List<ActionModel>>

    /** If window should be added to the navbar area or not. */
    val isAttached: StateFlow<Boolean>

    /** If hint (or chips list) should be visible. */
    val isVisible: MutableStateFlow<Boolean>
}

@SysUISingleton
class UnderlayRepository
class UnderlayRepositoryImpl
@Inject
constructor(
    @Background private val backgroundScope: CoroutineScope,
    broadcastDispatcher: BroadcastDispatcher,
) {
    val isUnderlayAttached: StateFlow<Boolean> =
        broadcastDispatcher
            .broadcastFlow(
    private val smartSpaceManager: SmartspaceManager?,
    @Background executor: Executor,
    @Application applicationContext: Context,
) : UnderlayRepository {
    private val debugBroadcastFlow: Flow<Boolean> =
        if (DEBUG) {
            broadcastDispatcher.broadcastFlow(
                filter =
                    IntentFilter().apply {
                        addAction(ACTION_CREATE_UNDERLAY)
@@ -46,18 +75,79 @@ constructor(
            ) { intent, _ ->
                intent.action == ACTION_CREATE_UNDERLAY
            }
        } else {
            MutableStateFlow(false).asStateFlow()
        }

    override val actions: StateFlow<List<ActionModel>> =
        conflatedCallbackFlow {
                if (smartSpaceManager == null) {
                    Log.i(TAG, "Cannot connect to SmartSpaceManager, it's null.")
                    return@conflatedCallbackFlow
                }

                val session =
                    smartSpaceManager.createSmartspaceSession(
                        SmartspaceConfig.Builder(applicationContext, UNDERLAY_SURFACE).build()
                    )

                val smartSpaceListener = OnTargetsAvailableListener { targets ->
                    val actions =
                        targets
                            .filter { target -> target.featureType == AMBIENT_ACTION_FEATURE }
                            .filter { it.smartspaceTargetId == UNDERLAY_SURFACE }
                            .flatMap { target -> target.actionChips }
                            .map { chip ->
                                ActionModel(
                                    icon =
                                        chip.icon?.loadDrawable(applicationContext)
                                            ?: applicationContext.getDrawable(
                                                R.drawable.clipboard
                                            )!!,
                                    intent = chip.intent,
                                    label = chip.title.toString(),
                                    attribution = chip.subtitle.toString(),
                                )
                            }
                    if (DEBUG) {
                        Log.d(TAG, "SmartSpace OnTargetsAvailableListener $targets")
                        Log.d(TAG, "SmartSpace actions $actions")
                    }
                    trySend(actions)
                }

                session.addOnTargetsAvailableListener(executor, smartSpaceListener)
                awaitClose {
                    session.removeOnTargetsAvailableListener(smartSpaceListener)
                    session.close()
                }
            }
            .stateIn(
                scope = backgroundScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = false,
                initialValue = emptyList(),
            )

    val isOverlayVisible: MutableStateFlow<Boolean> = MutableStateFlow(false)
    override val isAttached: StateFlow<Boolean> =
        combine(actions, debugBroadcastFlow) { actions, createdViaBroadcast ->
                actions.isNotEmpty() || createdViaBroadcast
            }
            .stateIn(
                scope = backgroundScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = false,
            )

    val actions: MutableStateFlow<List<ActionModel>> = MutableStateFlow(listOf())
    override val isVisible: MutableStateFlow<Boolean> = MutableStateFlow(false)

    companion object {
        const val ACTION_CREATE_UNDERLAY = "com.systemui.underlay.action.CREATE_UNDERLAY"
        const val ACTION_DESTROY_UNDERLAY = "com.systemui.underlay.action.DESTROY_UNDERLAY"
        // Privately defined card type, exclusive for ambient actions
        @VisibleForTesting const val AMBIENT_ACTION_FEATURE = 72
        // Surface that PCC wants to push cards into
        @VisibleForTesting const val UNDERLAY_SURFACE = "underlay"
        private const val TAG = "underlay"
        private const val DEBUG = false
        private const val ACTION_CREATE_UNDERLAY = "com.systemui.underlay.action.CREATE_UNDERLAY"
        private const val ACTION_DESTROY_UNDERLAY = "com.systemui.underlay.action.DESTROY_UNDERLAY"
    }
}
+3 −7
Original line number Diff line number Diff line
@@ -23,15 +23,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update

class UnderlayInteractor @Inject constructor(private val repository: UnderlayRepository) {
    val isUnderlayAttached: StateFlow<Boolean> = repository.isUnderlayAttached
    val isOverlayVisible: StateFlow<Boolean> = repository.isOverlayVisible
    val isUnderlayAttached: StateFlow<Boolean> = repository.isAttached
    val isOverlayVisible: StateFlow<Boolean> = repository.isVisible
    val actions: StateFlow<List<ActionModel>> = repository.actions

    fun setIsOverlayVisible(visible: Boolean) {
        repository.isOverlayVisible.update { visible }
    }

    fun setActions(actions: List<ActionModel>) {
        repository.actions.update { actions }
        repository.isVisible.update { visible }
    }
}
Loading