feat(network): Added handler for feature disabled proton error code.

This commit is contained in:
Damir Mihaljinec
2025-12-04 00:36:30 +01:00
parent 90eff59c91
commit 47da262c5b
14 changed files with 267 additions and 1 deletions
@@ -18,12 +18,18 @@
package me.proton.core.featureflag.dagger
import android.content.Context
import android.os.SystemClock
import dagger.Binds
import dagger.BindsOptionalOf
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import me.proton.core.featureflag.data.FeatureFlagManagerImpl
import me.proton.core.featureflag.data.R
import me.proton.core.featureflag.data.listener.FeatureDisabledListenerImpl
import me.proton.core.featureflag.data.local.FeatureFlagLocalDataSourceImpl
import me.proton.core.featureflag.data.remote.FeatureFlagRemoteDataSourceImpl
import me.proton.core.featureflag.data.remote.worker.FeatureFlagWorkerManagerImpl
@@ -34,11 +40,36 @@ import me.proton.core.featureflag.domain.repository.FeatureFlagContextProvider
import me.proton.core.featureflag.domain.repository.FeatureFlagLocalDataSource
import me.proton.core.featureflag.domain.repository.FeatureFlagRemoteDataSource
import me.proton.core.featureflag.domain.repository.FeatureFlagRepository
import me.proton.core.network.domain.feature.FeatureDisabledListener
import me.proton.core.network.domain.session.SessionProvider
import javax.inject.Provider
import javax.inject.Singleton
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@Module
@InstallIn(SingletonComponent::class)
public interface CoreFeatureFlagModule {
public class CoreFeatureFlagModule {
@Provides
@Singleton
public fun provideFeatureDisabledListener(
@ApplicationContext appContext: Context,
featureFlagRepositoryProvider: Provider<FeatureFlagRepository>,
sessionProvider: SessionProvider,
): FeatureDisabledListener = FeatureDisabledListenerImpl(
featureFlagRepositoryProvider = featureFlagRepositoryProvider,
sessionProvider = sessionProvider,
minimumFetchInterval = appContext.resources.getInteger(
R.integer.core_feature_feature_flag_on_feature_disabled_minimum_fetch_interval_seconds
).toDuration(DurationUnit.SECONDS),
monoClock = { SystemClock.elapsedRealtime() }
)
}
@Module
@InstallIn(SingletonComponent::class)
public interface CoreFeatureFlagBindsModule {
@Binds
@Singleton
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.featureflag.data.listener
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import me.proton.core.featureflag.domain.repository.FeatureFlagRepository
import me.proton.core.network.domain.feature.FeatureDisabledListener
import me.proton.core.network.domain.session.SessionId
import me.proton.core.network.domain.session.SessionProvider
import javax.inject.Inject
import javax.inject.Provider
import kotlin.time.Duration
public class FeatureDisabledListenerImpl @Inject constructor(
private val featureFlagRepositoryProvider: Provider<FeatureFlagRepository>,
private val sessionProvider: SessionProvider,
private val minimumFetchInterval: Duration,
private val monoClock: () -> Long,
) : FeatureDisabledListener {
private var lastFetchTimestampMs: Long? = null
private val mutex = Mutex()
override suspend fun onFeatureDisabled(sessionId: SessionId?) {
if (shouldFetch()) {
featureFlagRepositoryProvider.get()?.getAll(sessionId?.let { sessionProvider.getUserId(sessionId) })
updateLastFetchTimestamp()
}
}
private suspend fun shouldFetch(): Boolean = getLastFetchTimestampMs()?.let { lastFetch ->
monoClock() - lastFetch > minimumFetchInterval.inWholeMilliseconds
} ?: true
private suspend fun getLastFetchTimestampMs(): Long? = mutex.withLock {
lastFetchTimestampMs
}
private suspend fun updateLastFetchTimestamp() {
mutex.withLock {
lastFetchTimestampMs = monoClock()
}
}
}
@@ -18,4 +18,5 @@
<resources>
<integer name="core_feature_feature_flag_worker_repeat_interval_unauth_seconds">86400</integer>
<integer name="core_feature_feature_flag_worker_repeat_interval_auth_seconds">21600</integer>
<integer name="core_feature_feature_flag_on_feature_disabled_minimum_fetch_interval_seconds">600</integer>
</resources>
@@ -2,4 +2,5 @@
<resources>
<public name="core_feature_feature_flag_worker_repeat_interval_unauth_seconds" type="integer" />
<public name="core_feature_feature_flag_worker_repeat_interval_auth_seconds" type="integer" />
<public name="core_feature_feature_flag_on_feature_disabled_minimum_fetch_interval_seconds" type="integer"/>
</resources>
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.featureflag.data.listener
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import me.proton.core.featureflag.domain.repository.FeatureFlagRepository
import me.proton.core.network.domain.feature.FeatureDisabledListener
import me.proton.core.network.domain.session.SessionProvider
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import javax.inject.Provider
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@RunWith(Parameterized::class)
class FeatureDisabledListenerTest(
private val minimumFetchIntervalSeconds: Long,
private val delayBetweenOnFeatureDisabledSeconds: Long,
private val expectedRepositoryCalls: Int,
) {
private lateinit var featureDisabledListener: FeatureDisabledListener
private val sessionProvider = mockk<SessionProvider>(relaxed = true)
private val featureFlagRepository = mockk<FeatureFlagRepository>(relaxed = true)
private val featureFlagRepositoryProvider = Provider { featureFlagRepository }
@Test
fun testTwoCallsOnFeatureDisabled() = runTest {
//Given
featureDisabledListener = FeatureDisabledListenerImpl(
featureFlagRepositoryProvider = featureFlagRepositoryProvider,
sessionProvider = sessionProvider,
minimumFetchInterval = minimumFetchIntervalSeconds.toDuration(DurationUnit.SECONDS),
monoClock = { testScheduler.currentTime },
)
//When
featureDisabledListener.onFeatureDisabled(null)
advanceTimeBy(delayBetweenOnFeatureDisabledSeconds.toDuration(DurationUnit.SECONDS))
featureDisabledListener.onFeatureDisabled(null)
//Then
coVerify(exactly = expectedRepositoryCalls) {
featureFlagRepository.getAll(any())
}
}
companion object {
@get:Parameterized.Parameters(
"minimumFetchIntervalSeconds: {0}, delayBetweenOnFeatureDisabledSeconds: {1}, expectedRepositoryCalls: {2}"
)
@get:JvmStatic
val data = listOf(
arrayOf(3, 2, 1),
arrayOf(3, 4, 2),
)
}
}
@@ -52,6 +52,7 @@ import me.proton.core.network.domain.client.ClientVersionValidator
import me.proton.core.network.domain.client.ExtraHeaderProvider
import me.proton.core.network.domain.deviceverification.DeviceVerificationListener
import me.proton.core.network.domain.deviceverification.DeviceVerificationProvider
import me.proton.core.network.domain.feature.FeatureDisabledListener
import me.proton.core.network.domain.humanverification.HumanVerificationListener
import me.proton.core.network.domain.humanverification.HumanVerificationProvider
import me.proton.core.network.domain.interceptor.InterceptorInfo
@@ -100,6 +101,7 @@ public class CoreNetworkModule {
deviceVerificationProvider: DeviceVerificationProvider,
deviceVerificationListener: DeviceVerificationListener,
missingScopeListener: MissingScopeListener,
featureDisabledListener: FeatureDisabledListener,
extraHeaderProvider: ExtraHeaderProvider,
clientVersionValidator: ClientVersionValidator,
dohAlternativesListener: DohAlternativesListener?,
@@ -124,6 +126,7 @@ public class CoreNetworkModule {
deviceVerificationProvider,
deviceVerificationListener,
missingScopeListener,
featureDisabledListener,
cookieStore,
CoroutineScope(Job() + Dispatchers.Default),
certificatePins,
@@ -40,8 +40,10 @@ import me.proton.core.network.domain.client.ClientVersionValidator
import me.proton.core.network.domain.client.ExtraHeaderProvider
import me.proton.core.network.domain.deviceverification.DeviceVerificationListener
import me.proton.core.network.domain.deviceverification.DeviceVerificationProvider
import me.proton.core.network.domain.feature.FeatureDisabledListener
import me.proton.core.network.domain.handlers.DeviceVerificationNeededHandler
import me.proton.core.network.domain.handlers.DohApiHandler
import me.proton.core.network.domain.handlers.FeatureDisabledHandler
import me.proton.core.network.domain.handlers.HumanVerificationInvalidHandler
import me.proton.core.network.domain.handlers.HumanVerificationNeededHandler
import me.proton.core.network.domain.handlers.MissingScopeHandler
@@ -87,6 +89,7 @@ class ApiManagerFactory(
private val deviceVerificationProvider: DeviceVerificationProvider,
private val deviceVerificationListener: DeviceVerificationListener,
private val missingScopeListener: MissingScopeListener,
private val featureDisabledListener: FeatureDisabledListener,
private val cookieStore: ProtonCookieStore,
scope: CoroutineScope,
private val certificatePins: Array<String> = Constants.DEFAULT_SPKI_PINS,
@@ -157,6 +160,8 @@ class ApiManagerFactory(
HumanVerificationInvalidHandler<Api>(sessionId, clientIdProvider, humanVerificationListener)
val deviceVerificationErrorHandler =
DeviceVerificationNeededHandler<Api>(sessionId, sessionProvider, deviceVerificationListener)
val featureDisabledErrorHandler =
FeatureDisabledHandler<Api>(sessionId, featureDisabledListener)
return listOf(
dohApiHandler,
missingScopeHandler,
@@ -165,6 +170,7 @@ class ApiManagerFactory(
humanVerificationInvalidHandler,
humanVerificationNeededHandler,
deviceVerificationErrorHandler,
featureDisabledErrorHandler,
)
}
@@ -49,6 +49,7 @@ import me.proton.core.network.domain.client.ClientIdProvider
import me.proton.core.network.domain.client.ClientVersionValidator
import me.proton.core.network.domain.deviceverification.DeviceVerificationListener
import me.proton.core.network.domain.deviceverification.DeviceVerificationProvider
import me.proton.core.network.domain.feature.FeatureDisabledListener
import me.proton.core.network.domain.handlers.DohApiHandler
import me.proton.core.network.domain.humanverification.HumanVerificationListener
import me.proton.core.network.domain.humanverification.HumanVerificationProvider
@@ -96,6 +97,7 @@ internal class ApiManagerTests {
private val deviceVerificationProvider = mockk<DeviceVerificationProvider>()
private val deviceVerificationListener = mockk<DeviceVerificationListener>()
private val missingScopeListener = mockk<MissingScopeListener>(relaxed = true)
private val featureDisabledListener = mockk<FeatureDisabledListener>()
private lateinit var apiManagerFactory: ApiManagerFactory
private lateinit var apiManager: ApiManager<TestRetrofitApi>
@@ -163,6 +165,7 @@ internal class ApiManagerTests {
deviceVerificationProvider,
deviceVerificationListener,
missingScopeListener,
featureDisabledListener,
mockk(),
testScope,
cache = { null },
@@ -47,6 +47,7 @@ import me.proton.core.network.domain.client.ClientVersionValidator
import me.proton.core.network.domain.client.CookieSessionId
import me.proton.core.network.domain.deviceverification.DeviceVerificationListener
import me.proton.core.network.domain.deviceverification.DeviceVerificationProvider
import me.proton.core.network.domain.feature.FeatureDisabledListener
import me.proton.core.network.domain.humanverification.HumanVerificationDetails
import me.proton.core.network.domain.humanverification.HumanVerificationListener
import me.proton.core.network.domain.humanverification.HumanVerificationProvider
@@ -132,6 +133,7 @@ internal class HumanVerificationTests {
private val deviceVerificationProvider = mockk<DeviceVerificationProvider>()
private val deviceVerificationListener = mockk<DeviceVerificationListener>()
private val missingScopeListener = mockk<MissingScopeListener>(relaxed = true)
private val featureDisabledListener = mockk<FeatureDisabledListener>()
private val clientVersionValidator = mockk<ClientVersionValidator> {
every { validate(any()) } returns true
}
@@ -178,6 +180,7 @@ internal class HumanVerificationTests {
deviceVerificationProvider,
deviceVerificationListener,
missingScopeListener,
featureDisabledListener,
cookieJar,
scope,
cache = { null },
@@ -58,6 +58,7 @@ import me.proton.core.network.domain.TimeoutOverride
import me.proton.core.network.domain.client.ExtraHeaderProvider
import me.proton.core.network.domain.deviceverification.DeviceVerificationListener
import me.proton.core.network.domain.deviceverification.DeviceVerificationProvider
import me.proton.core.network.domain.feature.FeatureDisabledListener
import me.proton.core.network.domain.humanverification.HumanVerificationListener
import me.proton.core.network.domain.humanverification.HumanVerificationProvider
import me.proton.core.network.domain.scopes.MissingScopeListener
@@ -117,6 +118,9 @@ internal class PinningTests {
@BindValue
internal val humanVerificationProvider: HumanVerificationProvider = mockk(relaxed = true)
@BindValue
internal val featureDisabledListener: FeatureDisabledListener = mockk()
@BindValue
internal val sessionListener: SessionListener = mockk()
@@ -47,6 +47,7 @@ import me.proton.core.network.domain.client.ClientVersionValidator
import me.proton.core.network.domain.client.ExtraHeaderProvider
import me.proton.core.network.domain.deviceverification.DeviceVerificationListener
import me.proton.core.network.domain.deviceverification.DeviceVerificationProvider
import me.proton.core.network.domain.feature.FeatureDisabledListener
import me.proton.core.network.domain.humanverification.HumanVerificationDetails
import me.proton.core.network.domain.humanverification.HumanVerificationListener
import me.proton.core.network.domain.humanverification.HumanVerificationProvider
@@ -97,6 +98,7 @@ internal class ProtonApiBackendTests {
private val deviceVerificationProvider = mockk<DeviceVerificationProvider>()
private val deviceVerificationListener = mockk<DeviceVerificationListener>()
private val missingScopeListener = mockk<MissingScopeListener>(relaxed = true)
private val featureDisabledListener = mockk<FeatureDisabledListener>()
private val clientVersionValidator = mockk<ClientVersionValidator> {
every { validate(any()) } returns true
}
@@ -142,6 +144,7 @@ internal class ProtonApiBackendTests {
deviceVerificationProvider,
deviceVerificationListener,
missingScopeListener,
featureDisabledListener,
cookieJar,
scope,
cache = { null },
@@ -30,6 +30,7 @@ object ResponseCodes {
const val NOT_SAME_AS_FIELD = 2010
const val NOT_ALLOWED = 2011
const val BANNED = 2028
const val FEATURE_DISABLED = 2032
const val CURRENCY_FORMAT = 2053
const val NOT_EXISTS = 2501
const val APP_VERSION_BAD = 5003
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.network.domain.feature
import me.proton.core.network.domain.session.SessionId
interface FeatureDisabledListener {
suspend fun onFeatureDisabled(sessionId: SessionId?)
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.network.domain.handlers
import me.proton.core.network.domain.ApiBackend
import me.proton.core.network.domain.ApiErrorHandler
import me.proton.core.network.domain.ApiManager
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes
import me.proton.core.network.domain.feature.FeatureDisabledListener
import me.proton.core.network.domain.session.SessionId
import me.proton.core.network.domain.session.SessionProvider
class FeatureDisabledHandler<Api>(
private val sessionId: SessionId?,
private val featureDisabledListener: FeatureDisabledListener,
) : ApiErrorHandler<Api> {
override suspend fun <T> invoke(
backend: ApiBackend<Api>,
error: ApiResult.Error,
call: ApiManager.Call<Api, T>
): ApiResult<T> {
if (error is ApiResult.Error.Http && error.proton?.code == ResponseCodes.FEATURE_DISABLED) {
featureDisabledListener.onFeatureDisabled(
sessionId = sessionId
)
}
return error
}
}