diff --git a/packages/SystemUI/src/com/android/systemui/wallet/controller/WalletContextualSuggestionsController.kt b/packages/SystemUI/src/com/android/systemui/wallet/controller/WalletContextualSuggestionsController.kt new file mode 100644 index 0000000000000..7b8235acb0a7f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/wallet/controller/WalletContextualSuggestionsController.kt @@ -0,0 +1,115 @@ +/* + * 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.wallet.controller + +import android.Manifest +import android.content.Context +import android.content.IntentFilter +import android.service.quickaccesswallet.GetWalletCardsError +import android.service.quickaccesswallet.GetWalletCardsResponse +import android.service.quickaccesswallet.QuickAccessWalletClient +import android.service.quickaccesswallet.WalletCard +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.shareIn + +@SysUISingleton +class WalletContextualSuggestionsController +@Inject +constructor( + @Application private val applicationCoroutineScope: CoroutineScope, + private val walletController: QuickAccessWalletController, + broadcastDispatcher: BroadcastDispatcher, + featureFlags: FeatureFlags +) { + private val allWalletCards: Flow> = + if (featureFlags.isEnabled(Flags.ENABLE_WALLET_CONTEXTUAL_LOYALTY_CARDS)) { + conflatedCallbackFlow { + val callback = + object : QuickAccessWalletClient.OnWalletCardsRetrievedCallback { + override fun onWalletCardsRetrieved(response: GetWalletCardsResponse) { + trySendWithFailureLogging(response.walletCards, TAG) + } + + override fun onWalletCardRetrievalError(error: GetWalletCardsError) { + trySendWithFailureLogging(emptyList(), TAG) + } + } + + walletController.setupWalletChangeObservers( + callback, + QuickAccessWalletController.WalletChangeEvent.WALLET_PREFERENCE_CHANGE, + QuickAccessWalletController.WalletChangeEvent.DEFAULT_PAYMENT_APP_CHANGE + ) + walletController.updateWalletPreference() + walletController.queryWalletCards(callback) + + awaitClose { + walletController.unregisterWalletChangeObservers( + QuickAccessWalletController.WalletChangeEvent.WALLET_PREFERENCE_CHANGE, + QuickAccessWalletController.WalletChangeEvent.DEFAULT_PAYMENT_APP_CHANGE + ) + } + } + } else { + emptyFlow() + } + + private val contextualSuggestionsCardIds: Flow> = + if (featureFlags.isEnabled(Flags.ENABLE_WALLET_CONTEXTUAL_LOYALTY_CARDS)) { + broadcastDispatcher.broadcastFlow( + filter = IntentFilter(ACTION_UPDATE_WALLET_CONTEXTUAL_SUGGESTIONS), + permission = Manifest.permission.BIND_QUICK_ACCESS_WALLET_SERVICE, + flags = Context.RECEIVER_EXPORTED + ) { intent, _ -> + if (intent.hasExtra(UPDATE_CARD_IDS_EXTRA)) { + intent.getStringArrayListExtra(UPDATE_CARD_IDS_EXTRA).toSet() + } else { + emptySet() + } + } + } else { + emptyFlow() + } + + val contextualSuggestionCards: Flow> = + combine(allWalletCards, contextualSuggestionsCardIds) { cards, ids -> + cards.filter { card -> ids.contains(card.cardId) } + } + .shareIn(applicationCoroutineScope, replay = 1, started = SharingStarted.Eagerly) + + companion object { + private const val ACTION_UPDATE_WALLET_CONTEXTUAL_SUGGESTIONS = + "com.android.systemui.wallet.UPDATE_CONTEXTUAL_SUGGESTIONS" + + private const val UPDATE_CARD_IDS_EXTRA = "cardIds" + + private const val TAG = "WalletSuggestions" + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/wallet/controller/WalletContextualSuggestionsControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/wallet/controller/WalletContextualSuggestionsControllerTest.kt new file mode 100644 index 0000000000000..b52786178e717 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/wallet/controller/WalletContextualSuggestionsControllerTest.kt @@ -0,0 +1,243 @@ +/* + * 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.wallet.controller + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.service.quickaccesswallet.GetWalletCardsResponse +import android.service.quickaccesswallet.QuickAccessWalletClient +import android.service.quickaccesswallet.WalletCard +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.capture +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import java.util.ArrayList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.isNull +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class WalletContextualSuggestionsControllerTest : SysuiTestCase() { + + @Mock private lateinit var walletController: QuickAccessWalletController + @Mock private lateinit var broadcastDispatcher: BroadcastDispatcher + @Mock private lateinit var featureFlags: FeatureFlags + @Mock private lateinit var mockContext: Context + @Captor private lateinit var broadcastReceiver: ArgumentCaptor + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + whenever( + broadcastDispatcher.broadcastFlow?>( + any(), + isNull(), + any(), + any(), + any() + ) + ) + .thenCallRealMethod() + + whenever(featureFlags.isEnabled(eq(Flags.ENABLE_WALLET_CONTEXTUAL_LOYALTY_CARDS))) + .thenReturn(true) + + whenever(CARD_1.cardId).thenReturn(ID_1) + whenever(CARD_2.cardId).thenReturn(ID_2) + whenever(CARD_3.cardId).thenReturn(ID_3) + } + + @Test + fun `state - has wallet cards - received contextual cards`() = runTest { + setUpWalletClient(listOf(CARD_1, CARD_2)) + val latest = + collectLastValue( + createWalletContextualSuggestionsController(backgroundScope) + .contextualSuggestionCards, + ) + + runCurrent() + verifyRegistered() + broadcastReceiver.value.onReceive( + mockContext, + createContextualCardsIntent(listOf(ID_1, ID_2)) + ) + + assertThat(latest()).containsExactly(CARD_1, CARD_2) + } + + @Test + fun `state - no wallet cards - received contextual cards`() = runTest { + setUpWalletClient(emptyList()) + val latest = + collectLastValue( + createWalletContextualSuggestionsController(backgroundScope) + .contextualSuggestionCards, + ) + + runCurrent() + verifyRegistered() + broadcastReceiver.value.onReceive( + mockContext, + createContextualCardsIntent(listOf(ID_1, ID_2)) + ) + + assertThat(latest()).isEmpty() + } + + @Test + fun `state - has wallet cards - no contextual cards`() = runTest { + setUpWalletClient(listOf(CARD_1, CARD_2)) + val latest = + collectLastValue( + createWalletContextualSuggestionsController(backgroundScope) + .contextualSuggestionCards, + ) + + runCurrent() + verifyRegistered() + broadcastReceiver.value.onReceive(mockContext, createContextualCardsIntent(emptyList())) + + assertThat(latest()).isEmpty() + } + + @Test + fun `state - wallet cards error`() = runTest { + setUpWalletClient(shouldFail = true) + val latest = + collectLastValue( + createWalletContextualSuggestionsController(backgroundScope) + .contextualSuggestionCards, + ) + + runCurrent() + verifyRegistered() + broadcastReceiver.value.onReceive( + mockContext, + createContextualCardsIntent(listOf(ID_1, ID_2)) + ) + + assertThat(latest()).isEmpty() + } + + @Test + fun `state - no contextual cards extra`() = runTest { + setUpWalletClient(listOf(CARD_1, CARD_2)) + val latest = + collectLastValue( + createWalletContextualSuggestionsController(backgroundScope) + .contextualSuggestionCards, + ) + + runCurrent() + verifyRegistered() + broadcastReceiver.value.onReceive(mockContext, Intent(INTENT_NAME)) + + assertThat(latest()).isEmpty() + } + + @Test + fun `state - has wallet cards - received contextual cards - feature disabled`() = runTest { + whenever(featureFlags.isEnabled(eq(Flags.ENABLE_WALLET_CONTEXTUAL_LOYALTY_CARDS))) + .thenReturn(false) + setUpWalletClient(listOf(CARD_1, CARD_2)) + val latest = + collectLastValue( + createWalletContextualSuggestionsController(backgroundScope) + .contextualSuggestionCards, + ) + + runCurrent() + verify(broadcastDispatcher, never()).broadcastFlow(any(), isNull(), any(), any()) + assertThat(latest()).isNull() + } + + private fun createWalletContextualSuggestionsController( + scope: CoroutineScope + ): WalletContextualSuggestionsController { + return WalletContextualSuggestionsController( + scope, + walletController, + broadcastDispatcher, + featureFlags + ) + } + + private fun verifyRegistered() { + verify(broadcastDispatcher) + .registerReceiver(capture(broadcastReceiver), any(), isNull(), isNull(), any(), any()) + } + + private fun createContextualCardsIntent( + ids: List = emptyList(), + ): Intent { + val intent = Intent(INTENT_NAME) + intent.putStringArrayListExtra("cardIds", ArrayList(ids)) + return intent + } + + private fun setUpWalletClient( + cards: List = emptyList(), + shouldFail: Boolean = false + ) { + whenever(walletController.queryWalletCards(any())).thenAnswer { invocation -> + with( + invocation.arguments[0] as QuickAccessWalletClient.OnWalletCardsRetrievedCallback + ) { + if (shouldFail) { + onWalletCardRetrievalError(mock()) + } else { + onWalletCardsRetrieved(GetWalletCardsResponse(cards, 0)) + } + } + } + } + + companion object { + private const val ID_1: String = "123" + private val CARD_1: WalletCard = mock() + private const val ID_2: String = "456" + private val CARD_2: WalletCard = mock() + private const val ID_3: String = "789" + private val CARD_3: WalletCard = mock() + private val INTENT_NAME: String = "WalletSuggestionsIntent" + } +}