commit 90f9d2b12fe5a6e2408b0caf5bfb3109f6cd101e parent 2dfcbafdab537e8bcc8f088dff1bd39cac446f2f Author: gela <gela.malekpour@gmail.com> Date: Wed, 17 Dec 2025 23:59:22 +0000 Bug 1996713 - Implement Relay eligibility state machine r=android-reviewers,jonalmeida,twhite,mavduevskiy Differential Revision: https://phabricator.services.mozilla.com/D270771 Diffstat:
15 files changed, 630 insertions(+), 53 deletions(-)
diff --git a/mobile/android/android-components/.buildconfig.yml b/mobile/android/android-components/.buildconfig.yml @@ -1788,7 +1788,6 @@ projects: - components:lib-publicsuffixlist - components:lib-state - components:service-digitalassetlinks - - components:service-firefox-relay - components:service-glean - components:service-location - components:service-nimbus @@ -2085,7 +2084,12 @@ projects: - components:compose-base - components:concept-base - components:concept-fetch + - components:concept-storage + - components:concept-sync + - components:lib-dataprotect - components:lib-publicsuffixlist + - components:lib-state + - components:service-firefox-accounts - components:support-base - components:support-ktx - components:support-utils diff --git a/mobile/android/android-components/components/service/firefox-relay/build.gradle b/mobile/android/android-components/components/service/firefox-relay/build.gradle @@ -38,15 +38,21 @@ android { dependencies { implementation ComponentsDependencies.mozilla_appservices_fxrelay - implementation project(':components:support-base') implementation project(':components:compose-base') implementation project(":components:ui-icons") + implementation project(':components:support-ktx') + implementation project(":components:lib-state") + implementation project(':components:service-firefox-accounts') - implementation libs.kotlinx.coroutines.core - implementation libs.androidx.compose.foundation implementation libs.androidx.compose.material3 implementation libs.androidx.compose.ui implementation libs.androidx.compose.ui.tooling.preview + + testImplementation libs.androidx.test.core + testImplementation libs.androidx.test.junit + testImplementation libs.androidx.work.testing + testImplementation libs.kotlinx.coroutines.test + } apply from: '../../../common-config.gradle' diff --git a/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/FxRelay.kt b/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/FxRelay.kt @@ -9,24 +9,28 @@ import kotlinx.coroutines.withContext import mozilla.appservices.relay.RelayApiException import mozilla.appservices.relay.RelayClient import mozilla.appservices.relay.RelayProfile +import mozilla.components.concept.sync.OAuthAccount import mozilla.components.support.base.log.logger.Logger +const val RELAY_SCOPE_URL = "https://identity.mozilla.com/apps/relay" +const val RELAY_BASE_URL = "https://relay.firefox.com" + /** * Service wrapper for Firefox Relay APIs. * - * @param serverUrl The base URL of the Firefox Relay service (for example, - * `https://relay.firefox.com`). This defines the endpoint - * that the [RelayClient] will connect to. - * @param authToken An optional authentication token used to authorize API - * requests. If `null` or invalid, calls that require - * authentication will fail gracefully via [handleRelayExceptions]. + * @param account An [OAuthAccount] used to obtain and manage FxA access tokens scoped for Firefox Relay. */ class FxRelay( - serverUrl: String, - authToken: String? = null, + private val account: OAuthAccount, ) { private val logger = Logger("FxRelay") - private val client: RelayClient = RelayClient(serverUrl, authToken) + + /** + * Cache for RelayClient so we don't recreate the Rust client on every call. + * We tie it to the access token it was built with. + */ + private var cachedClient: RelayClient? = null + private var cachedToken: String? = null /** * Defines supported Relay operations for logging and error handling. @@ -39,6 +43,23 @@ class FxRelay( } /** + * Build or reuse a [RelayClient] with a fresh token. + * If no token is available, fail fast with an error. + * + * @throws RelayApiException.Other if no FxA access token is available. + */ + private suspend fun getOrCreateClient(): RelayClient { + val token = account.getAccessToken(RELAY_SCOPE_URL)?.token + ?: throw RelayApiException.Other("No FxA access token available for Relay") + + return cachedClient.takeIf { cachedToken == token } + ?: RelayClient(RELAY_BASE_URL, token).also { + cachedClient = it + cachedToken = token + } + } + + /** * Runs a provided [block], handling known [RelayApiException] variants gracefully. * * @param operation The [RelayOperation] being performed, included in log output. @@ -60,8 +81,7 @@ class FxRelay( when (e) { is RelayApiException.Api -> { logger.error( - "Relay API error during $operation " + - "(status=${e.status}, code=${e.code}): ${e.detail}", + "Relay API error during $operation: (status=${e.status}, code=${e.code}): ${e.detail}", e, ) } @@ -83,36 +103,23 @@ class FxRelay( */ suspend fun acceptTerms() = withContext(Dispatchers.IO) { handleRelayExceptions(RelayOperation.ACCEPT_TERMS, { false }) { + val client = getOrCreateClient() client.acceptTerms() true } } /** - * Create a new Relay address. - * - * @param description description for the address - * @param generatedFor where this alias is generated for - * @param usedOn where this alias will be used - */ - suspend fun createAddress( - description: String, - generatedFor: String, - usedOn: String, - ): RelayAddress? = withContext(Dispatchers.IO) { - handleRelayExceptions(RelayOperation.CREATE_ADDRESS, { null }) { - client.createAddress(description, generatedFor, usedOn).into() - } - } - - /** * Fetch all Relay addresses. + * + * This returns `null` when the operation failed with a known Relay API error. */ suspend fun fetchAllAddresses(): List<RelayAddress> = withContext(Dispatchers.IO) { handleRelayExceptions( RelayOperation.FETCH_ALL_ADDRESSES, { emptyList() }, ) { + val client = getOrCreateClient() client.fetchAddresses().map { it.into() } } } @@ -127,6 +134,7 @@ class FxRelay( RelayOperation.FETCH_PROFILE, { null }, ) { + val client = getOrCreateClient() client.fetchProfile() } } diff --git a/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/AppServicesRelayStatusFetcher.kt b/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/AppServicesRelayStatusFetcher.kt @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.fxrelay.eligibility + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.appservices.relay.RelayProfile +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.service.fxrelay.FxRelay +import mozilla.components.support.base.log.logger.Logger + +private const val FREE_MAX_MASKS = 5 + +/** + * Fetches Relay subscription status via AppServices for eligibility decisions. + */ +class AppServicesRelayStatusFetcher( + private val account: OAuthAccount, +) : RelayStatusFetcher { + + private val logger = Logger("AppServicesRelayStatusFetcher") + + override suspend fun fetch(): Result<RelayAccountDetails> = withContext(Dispatchers.IO) { + runCatching { + val client = FxRelay(account) + + val profile = client.fetchProfile() + ?: return@runCatching RelayAccountDetails(RelayPlanTier.NONE, 0) + + mapProfileToDetails(profile) + }.onFailure { + logger.warn("Failed to fetch Relay status", it) + } + } + + private fun mapProfileToDetails( + profile: RelayProfile, + ): RelayAccountDetails { + val relayPlanTier = when { + profile.hasPremium || profile.hasMegabundle -> RelayPlanTier.PREMIUM + else -> RelayPlanTier.FREE + } + + val remainingMasks = when (relayPlanTier) { + RelayPlanTier.PREMIUM -> null + RelayPlanTier.FREE -> FREE_MAX_MASKS + else -> 0 + } + + return RelayAccountDetails( + relayPlanTier = relayPlanTier, + remainingMasksForFreeUsers = remainingMasks, + ) + } +} diff --git a/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/EligibilityState.kt b/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/EligibilityState.kt @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.fxrelay.eligibility + +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State + +/** + * Sentinel value indicating that the Relay entitlement cache + * has never been established (i.e., eligibility has never been checked). + */ +internal const val NO_ENTITLEMENT_CHECK_YET_MS = 0L + +/** + * Relay eligibility states. + */ +sealed interface EligibilityState + +/** + * The user is not eligible to use Firefox Relay. + */ +sealed interface Ineligible : EligibilityState { + /** + * The user is not logged into FxA and cannot access any Firefox Relay features. + */ + data object FirefoxAccountNotLoggedIn : Ineligible + + /** + * The user is logged into FxA but does not have a Relay-enabled FxA account. + */ + data object NoRelay : Ineligible +} + +/** + * The user is eligible to use Firefox Relay. + */ +sealed interface Eligible : EligibilityState { + /** + * The user has access to the free tier of Firefox Relay. + * + * @property remaining The number of free Relay email masks the user has left. + */ + data class Free(val remaining: Int) : Eligible + + /** + * The user has an active Firefox Relay Premium subscription, with + * access to unlimited Relay masks. + */ + data object Premium : Eligible +} + +/** + * State stored by the feature to drive UI and decisions. + */ +data class RelayState( + val eligibilityState: EligibilityState = Ineligible.FirefoxAccountNotLoggedIn, + val lastEntitlementCheckMs: Long = NO_ENTITLEMENT_CHECK_YET_MS, +) : State + +/** + * Actions that can trigger Relay state transitions. + */ +sealed interface RelayEligibilityAction : Action { + /** + * Re-evaluate eligibility after the app enters the foreground. + */ + data class AccountLoginStatusChanged(val isLoggedIn: Boolean) : RelayEligibilityAction + + /** + * Fired when the user's Firefox Account profile information has been updated. + */ + data object AccountProfileUpdated : RelayEligibilityAction + + /** + * Eligibility cache TTL has expired and should be refreshed. + * + * @param nowMs Current system time in milliseconds when TTL expired. + */ + data class TtlExpired(val nowMs: Long) : RelayEligibilityAction + + /** + * Result of a Relay status fetch. + * + * @param fetchSucceeded Whether the fetch succeeded. + * @param relayPlanTier The user’s plan, or NONE if unavailable. + * @param remaining Remaining free aliases for FREE users. + * @param lastCheckedMs Stores the timestamp for when the last check was performed. + */ + data class RelayStatusResult( + val fetchSucceeded: Boolean, + val relayPlanTier: RelayPlanTier?, + val remaining: Int, + val lastCheckedMs: Long, + ) : RelayEligibilityAction +} + +/** + * Relay subscription plan tier. + */ +enum class RelayPlanTier { NONE, FREE, PREMIUM } diff --git a/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/RelayEligibilityFeature.kt b/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/RelayEligibilityFeature.kt @@ -0,0 +1,87 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.fxrelay.eligibility + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.concept.sync.Profile +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged + +private const val FETCH_TIMEOUT_MS: Long = 300_000L + +/** + * Coordinates when and how Relay eligibility is (re)evaluated. + */ +class RelayEligibilityFeature( + private val accountManager: FxaAccountManager, + private val store: RelayEligibilityStore, + private val relayStatusFetcher: RelayStatusFetcher, + private val fetchTimeoutMs: Long = FETCH_TIMEOUT_MS, +) : LifecycleAwareFeature { + + private var scope: CoroutineScope? = null + private val accountObserver = RelayAccountObserver() + + override fun start() { + accountManager.register(accountObserver) + + val isLoggedIn = accountManager.authenticatedAccount() != null + store.dispatch(RelayEligibilityAction.AccountLoginStatusChanged(isLoggedIn)) + + scope = store.flowScoped { flow -> + flow + .ifAnyChanged { arrayOf(it.eligibilityState, it.lastEntitlementCheckMs) } + .collect { state -> + checkRelayStatus(state) + } + } + } + + override fun stop() { + accountManager.unregister(accountObserver) + scope?.cancel().also { scope = null } + } + + private suspend fun checkRelayStatus(state: RelayState) { + val loggedIn = state.eligibilityState !is Ineligible.FirefoxAccountNotLoggedIn + val lastCheck = state.lastEntitlementCheckMs + val now = System.currentTimeMillis() + val ttlExpired = lastCheck == NO_ENTITLEMENT_CHECK_YET_MS || now - lastCheck >= fetchTimeoutMs + + if (loggedIn && ttlExpired) { + val relayStatus = relayStatusFetcher.fetch() + val relayStatusResult = relayStatus.getOrNull() + + store.dispatch( + RelayEligibilityAction.RelayStatusResult( + fetchSucceeded = relayStatus.isSuccess, + relayPlanTier = relayStatusResult?.relayPlanTier, + remaining = relayStatusResult?.remainingMasksForFreeUsers ?: 0, + lastCheckedMs = System.currentTimeMillis(), + ), + ) + } + } + + private inner class RelayAccountObserver : AccountObserver { + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { + store.dispatch(RelayEligibilityAction.AccountLoginStatusChanged(true)) + } + + override fun onProfileUpdated(profile: Profile) { + store.dispatch(RelayEligibilityAction.AccountProfileUpdated) + } + + override fun onLoggedOut() { + store.dispatch(RelayEligibilityAction.AccountLoginStatusChanged(false)) + } + } +} diff --git a/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/RelayEligibilityReducer.kt b/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/RelayEligibilityReducer.kt @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.fxrelay.eligibility + +/** + * Function for reducing the Relay eligibility state based on the received action. + * + * Given a [RelayState] and an [RelayEligibilityAction], returns the next [RelayState]. + */ +internal fun relayEligibilityReducer( + relayState: RelayState, + action: RelayEligibilityAction, +): RelayState = + when (action) { + is RelayEligibilityAction.AccountLoginStatusChanged -> + relayState.copy( + eligibilityState = if (action.isLoggedIn) Ineligible.NoRelay else Ineligible.FirefoxAccountNotLoggedIn, + lastEntitlementCheckMs = NO_ENTITLEMENT_CHECK_YET_MS, + ) + + is RelayEligibilityAction.RelayStatusResult -> { + val eligibility = when { + !action.fetchSucceeded -> Ineligible.NoRelay + action.relayPlanTier == RelayPlanTier.NONE -> Ineligible.NoRelay + action.relayPlanTier == RelayPlanTier.FREE -> Eligible.Free(action.remaining) + action.relayPlanTier == RelayPlanTier.PREMIUM -> Eligible.Premium + else -> return relayState + } + + relayState.copy( + eligibilityState = eligibility, + lastEntitlementCheckMs = action.lastCheckedMs, + ) + } + + is RelayEligibilityAction.AccountProfileUpdated, + is RelayEligibilityAction.TtlExpired, + -> relayState + } diff --git a/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/RelayEligibilityStore.kt b/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/RelayEligibilityStore.kt @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.fxrelay.eligibility + +import mozilla.components.lib.state.Reducer +import mozilla.components.lib.state.Store + +/** + * Store for handling [RelayState] and dispatching [RelayEligibilityAction]. + */ +class RelayEligibilityStore( + initialState: RelayState = RelayState(), + reducer: Reducer<RelayState, RelayEligibilityAction> = ::relayEligibilityReducer, +) : Store<RelayState, RelayEligibilityAction>( + initialState = initialState, + reducer = reducer, +) diff --git a/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/RelayStatusFetcher.kt b/mobile/android/android-components/components/service/firefox-relay/src/main/java/mozilla/components/service/fxrelay/eligibility/RelayStatusFetcher.kt @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.fxrelay.eligibility + +/** + * Provides a mechanism to fetch the current Firefox Relay account status. + */ +interface RelayStatusFetcher { + /** + * Fetch the latest [RelayAccountDetails] for the current user. + * + * @return a [Result] containing [RelayAccountDetails] on success, or a failure + * if the request could not be completed. + */ + suspend fun fetch(): Result<RelayAccountDetails> +} + +/** + * Represents the Relay account details for the currently signed-in user. + * + * @param relayPlanTier The user’s current Relay plan (e.g., FREE or PREMIUM). + * @param remainingMasksForFreeUsers The number of remaining free aliases for FREE users. + */ +data class RelayAccountDetails( + val relayPlanTier: RelayPlanTier, + val remainingMasksForFreeUsers: Int?, +) diff --git a/mobile/android/android-components/components/service/firefox-relay/src/test/java/mozilla/components/service/fxrelay/RelayEligibilityReducerTest.kt b/mobile/android/android-components/components/service/firefox-relay/src/test/java/mozilla/components/service/fxrelay/RelayEligibilityReducerTest.kt @@ -0,0 +1,174 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.fxrelay + +import mozilla.components.service.fxrelay.eligibility.Eligible +import mozilla.components.service.fxrelay.eligibility.Ineligible +import mozilla.components.service.fxrelay.eligibility.NO_ENTITLEMENT_CHECK_YET_MS +import mozilla.components.service.fxrelay.eligibility.RelayEligibilityAction +import mozilla.components.service.fxrelay.eligibility.RelayPlanTier +import mozilla.components.service.fxrelay.eligibility.RelayState +import mozilla.components.service.fxrelay.eligibility.relayEligibilityReducer +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelayEligibilityReducerTest { + + @Test + fun `GIVEN AccountLoginStatusChanged WHEN isLoggedIn is false THEN FirefoxAccountNotLoggedIn and resets lastEntitlementCheckMs`() { + val initial = RelayState( + eligibilityState = Ineligible.NoRelay, + lastEntitlementCheckMs = 123L, + ) + + val result = relayEligibilityReducer( + initial, + RelayEligibilityAction.AccountLoginStatusChanged(isLoggedIn = false), + ) + + assertEquals(Ineligible.FirefoxAccountNotLoggedIn, result.eligibilityState) + assertEquals(NO_ENTITLEMENT_CHECK_YET_MS, result.lastEntitlementCheckMs) + } + + @Test + fun `GIVEN AccountLoginStatusChanged WHEN isLoggedIn is true THEN NoRelay and resets lastEntitlementCheckMs`() { + val initial = RelayState( + eligibilityState = Ineligible.FirefoxAccountNotLoggedIn, + lastEntitlementCheckMs = 999L, + ) + + val result = relayEligibilityReducer( + initial, + RelayEligibilityAction.AccountLoginStatusChanged(isLoggedIn = true), + ) + + assertEquals(Ineligible.NoRelay, result.eligibilityState) + assertEquals(NO_ENTITLEMENT_CHECK_YET_MS, result.lastEntitlementCheckMs) + } + + @Test + fun `GIVEN RelayStatusResult WHEN fetch fails THEN falls back to NoRelay and updates lastEntitlementCheckMs`() { + val initial = RelayState( + eligibilityState = Ineligible.FirefoxAccountNotLoggedIn, + lastEntitlementCheckMs = 0L, + ) + + val result = relayEligibilityReducer( + initial, + RelayEligibilityAction.RelayStatusResult( + fetchSucceeded = false, + relayPlanTier = RelayPlanTier.FREE, + remaining = 10, + lastCheckedMs = 42L, + ), + ) + + assertEquals(Ineligible.NoRelay, result.eligibilityState) + assertEquals(42L, result.lastEntitlementCheckMs) + } + + @Test + fun `GIVEN RelayStatusResult WHEN status is NONE THEN maps to NoRelay`() { + val initial = RelayState( + eligibilityState = Ineligible.FirefoxAccountNotLoggedIn, + lastEntitlementCheckMs = 0L, + ) + + val result = relayEligibilityReducer( + initial, + RelayEligibilityAction.RelayStatusResult( + fetchSucceeded = true, + relayPlanTier = RelayPlanTier.NONE, + remaining = 5, + lastCheckedMs = 123L, + ), + ) + + assertEquals(Ineligible.NoRelay, result.eligibilityState) + assertEquals(123L, result.lastEntitlementCheckMs) + } + + @Test + fun `GIVEN RelayStatusResult WHEN status is FREE THEN maps to Free with remaining masks`() { + val initial = RelayState( + eligibilityState = Ineligible.FirefoxAccountNotLoggedIn, + lastEntitlementCheckMs = 0L, + ) + + val result = relayEligibilityReducer( + initial, + RelayEligibilityAction.RelayStatusResult( + fetchSucceeded = true, + relayPlanTier = RelayPlanTier.FREE, + remaining = 3, + lastCheckedMs = 999L, + ), + ) + + assertEquals(Eligible.Free(3), result.eligibilityState) + assertEquals(999L, result.lastEntitlementCheckMs) + } + + @Test + fun `GIVEN RelayStatusResult WHEN status is PREMIUM THEN maps to Premium`() { + val initial = RelayState( + eligibilityState = Ineligible.FirefoxAccountNotLoggedIn, + lastEntitlementCheckMs = 0L, + ) + + val result = relayEligibilityReducer( + initial, + RelayEligibilityAction.RelayStatusResult( + fetchSucceeded = true, + relayPlanTier = RelayPlanTier.PREMIUM, + remaining = 0, + lastCheckedMs = 555L, + ), + ) + + assertEquals(Eligible.Premium, result.eligibilityState) + assertEquals(555L, result.lastEntitlementCheckMs) + } + + @Test + fun `GIVEN RelayStatusResult WHEN relayStatus is null THEN returns same state`() { + val initial = RelayState( + eligibilityState = Eligible.Free(3), + lastEntitlementCheckMs = 123L, + ) + + val result = relayEligibilityReducer( + initial, + RelayEligibilityAction.RelayStatusResult( + fetchSucceeded = true, + relayPlanTier = null, + remaining = 99, + lastCheckedMs = 999L, + ), + ) + assertEquals(initial, result) + } + + @Test + fun `GIVEN no-op actions WHEN reduced THEN state remains unchanged`() { + val initial = RelayState( + eligibilityState = Eligible.Premium, + lastEntitlementCheckMs = 10L, + ) + + val afterProfileUpdated = relayEligibilityReducer( + initial, + RelayEligibilityAction.AccountProfileUpdated, + ) + + val afterTtlExpired = relayEligibilityReducer( + initial, + RelayEligibilityAction.TtlExpired(nowMs = 123L), + ) + + assertEquals(initial, afterProfileUpdated) + assertEquals(initial, afterTtlExpired) + } +} diff --git a/mobile/android/android-components/components/service/firefox-relay/src/test/java/mozilla/components/service/fxrelay/RelayEligibilityStoreTest.kt b/mobile/android/android-components/components/service/firefox-relay/src/test/java/mozilla/components/service/fxrelay/RelayEligibilityStoreTest.kt @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.fxrelay + +import mozilla.components.service.fxrelay.eligibility.Ineligible +import mozilla.components.service.fxrelay.eligibility.NO_ENTITLEMENT_CHECK_YET_MS +import mozilla.components.service.fxrelay.eligibility.RelayEligibilityAction +import mozilla.components.service.fxrelay.eligibility.RelayEligibilityStore +import mozilla.components.service.fxrelay.eligibility.RelayState +import mozilla.components.service.fxrelay.eligibility.relayEligibilityReducer +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelayEligibilityStoreTest { + + @Test + fun `GIVEN RelayEligibilityStore WHEN dispatching AccountChanged THEN reducer updates state`() { + val initialState = RelayState( + eligibilityState = Ineligible.FirefoxAccountNotLoggedIn, + lastEntitlementCheckMs = 0L, + ) + + val store = RelayEligibilityStore( + initialState = initialState, + reducer = ::relayEligibilityReducer, + ) + + assertEquals(initialState, store.state) + + store.dispatch(RelayEligibilityAction.AccountLoginStatusChanged(isLoggedIn = true)) + + val newState = store.state + assertEquals(Ineligible.NoRelay, newState.eligibilityState) + assertEquals(NO_ENTITLEMENT_CHECK_YET_MS, newState.lastEntitlementCheckMs) + } +} diff --git a/mobile/android/android-components/components/service/firefox-relay/src/test/java/mozilla/components/service/fxrelay/RelayStateTest.kt b/mobile/android/android-components/components/service/firefox-relay/src/test/java/mozilla/components/service/fxrelay/RelayStateTest.kt @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.fxrelay + +import mozilla.components.service.fxrelay.eligibility.Ineligible +import mozilla.components.service.fxrelay.eligibility.NO_ENTITLEMENT_CHECK_YET_MS +import mozilla.components.service.fxrelay.eligibility.RelayState +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelayStateTest { + + @Test + fun `GIVEN default RelayState WHEN constructed THEN FirefoxAccountNotLoggedIn AND timestamp is unset`() { + val state = RelayState() + + assertEquals(Ineligible.FirefoxAccountNotLoggedIn, state.eligibilityState) + assertEquals(NO_ENTITLEMENT_CHECK_YET_MS, state.lastEntitlementCheckMs) + } +} diff --git a/mobile/android/android-components/samples/browser/build.gradle b/mobile/android/android-components/samples/browser/build.gradle @@ -117,7 +117,6 @@ dependencies { implementation project(':components:lib-fetch-httpurlconnection') implementation project(":components:lib-publicsuffixlist") implementation project(':components:service-digitalassetlinks') - implementation project(":components:service-firefox-relay") // Add a dependency on glean to simplify the testing workflow // for engineers that want to test Gecko metrics exfiltrated via the Glean // SDK. See bug 1592935 for more context. diff --git a/mobile/android/android-components/samples/browser/src/main/java/org/mozilla/samples/browser/DefaultComponents.kt b/mobile/android/android-components/samples/browser/src/main/java/org/mozilla/samples/browser/DefaultComponents.kt @@ -84,7 +84,6 @@ import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient import mozilla.components.lib.publicsuffixlist.PublicSuffixList import mozilla.components.service.digitalassetlinks.local.StatementApi import mozilla.components.service.digitalassetlinks.local.StatementRelationChecker -import mozilla.components.service.fxrelay.FxRelay import mozilla.components.service.location.LocationService import mozilla.components.service.sync.logins.SyncableLoginsStorage import mozilla.components.support.base.android.NotificationsDelegate @@ -272,10 +271,6 @@ open class DefaultComponents(private val applicationContext: Context) { StatementRelationChecker(StatementApi(client)) } - val relayService by lazy { - FxRelay("https://relay.firefox.com") - } - // Intent val tabIntentProcessor by lazy { TabIntentProcessor(tabsUseCases, searchUseCases.newTabSearch) @@ -353,16 +348,6 @@ open class DefaultComponents(private val applicationContext: Context) { SimpleBrowserMenuItem("Restore after crash") { sessionUseCases.crashRecovery.invoke() }, - SimpleBrowserMenuItem("Relay") { - MainScope().launch { - val addressList = relayService.fetchAllAddresses() - Toast.makeText( - applicationContext, - "Fetched ${addressList.size} addresses", - Toast.LENGTH_SHORT, - ).show() - } - }, BrowserMenuDivider(), ) diff --git a/mobile/android/android-components/samples/firefox-relay/src/main/java/org/mozilla/samples/relay/MainActivity.kt b/mobile/android/android-components/samples/firefox-relay/src/main/java/org/mozilla/samples/relay/MainActivity.kt @@ -135,11 +135,17 @@ open class MainActivity : AppCompatActivity(), LoginFragment.OnLoginCompleteList findViewById<View>(R.id.buttonRelayAddresses).setOnClickListener { lifecycleScope.launch { val account = accountManager.authenticatedAccount() - val relay = FxRelay( - serverUrl = RELAY_URL, - authToken = account?.getAccessToken(SCOPE_RELAY)?.token, - ) - val addressList = relay.fetchAllAddresses() + ?: run { + Toast.makeText( + applicationContext, + "Account is null.", + Toast.LENGTH_SHORT, + ).show() + return@launch + } + + val addressList = FxRelay(account).fetchAllAddresses() + Toast.makeText( applicationContext, "Fetched ${addressList.size} addresses",