Skip to content

Commit

Permalink
Feature flag sync
Browse files Browse the repository at this point in the history
  • Loading branch information
cmonfortep committed Dec 18, 2023
1 parent 333a2c5 commit 53c5f73
Show file tree
Hide file tree
Showing 21 changed files with 1,128 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@

package com.duckduckgo.sync.impl

import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.appbuildconfig.api.isInternalBuild
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.sync.api.DeviceSyncState
import com.squareup.anvil.annotations.ContributesBinding
Expand All @@ -29,14 +27,13 @@ import javax.inject.*
priority = ContributesBinding.Priority.HIGHEST,
)
class AppDeviceSyncState @Inject constructor(
private val appBuildConfig: AppBuildConfig,
private val syncFeature: SyncFeature,
private val syncFeatureToggle: SyncFeatureToggle,
private val syncAccountRepository: SyncAccountRepository,
) : DeviceSyncState {

override fun isUserSignedInOnDevice(): Boolean = syncAccountRepository.isSignedIn()

override fun isFeatureEnabled(): Boolean {
return syncFeature.self().isEnabled() || appBuildConfig.isInternalBuild()
return syncFeatureToggle.showSync()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,22 @@ import com.duckduckgo.feature.toggles.api.Toggle

@ContributesRemoteFeature(
scope = AppScope::class,
featureName = "deviceSync",
featureName = "sync",
)
interface SyncFeature {
@Toggle.DefaultValue(false)
@Toggle.InternalAlwaysEnabled
fun self(): Toggle

@Toggle.DefaultValue(true)
fun level0ShowSync(): Toggle

@Toggle.DefaultValue(true)
fun level1AllowDataSyncing(): Toggle

@Toggle.DefaultValue(false)
fun level2AllowSetupFlows(): Toggle

@Toggle.DefaultValue(true)
fun level3AllowCreateAccount(): Toggle
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* 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.duckduckgo.sync.impl

import android.content.Context
import androidx.core.app.NotificationManagerCompat
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.appbuildconfig.api.isInternalBuild
import com.duckduckgo.common.utils.DefaultDispatcherProvider
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin
import com.duckduckgo.sync.impl.engine.SyncNotificationBuilder
import com.duckduckgo.sync.store.SyncStore
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber

interface SyncFeatureToggle {
fun showSync(): Boolean

fun allowDataSyncing(): Boolean

fun allowDataSyncingOnNewerVersion(): Boolean

fun allowSetupFlows(): Boolean

fun allowSetupFlowsOnNewerVersion(): Boolean

fun allowCreateAccount(): Boolean

fun allowCreateAccountOnNewerVersion(): Boolean
}

@ContributesBinding(
scope = AppScope::class,
boundType = SyncFeatureToggle::class,
)
@ContributesMultibinding(
scope = AppScope::class,
boundType = PrivacyConfigCallbackPlugin::class,
)
@SingleInstanceIn(AppScope::class)
class SyncRemoteFeatureToggle @Inject constructor(
private val context: Context,
private val syncFeature: SyncFeature,
private val appBuildConfig: AppBuildConfig,
private val notificationManager: NotificationManagerCompat,
private val syncNotificationBuilder: SyncNotificationBuilder,
private val syncStore: SyncStore,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val coroutineDispatcher: DispatcherProvider = DefaultDispatcherProvider(),
) : SyncFeatureToggle, PrivacyConfigCallbackPlugin {

private fun isFeatureEnabled(): Boolean {
return syncFeature.self().isEnabled()
}

override fun showSync(): Boolean {
if (appBuildConfig.isInternalBuild()) return syncFeature.level0ShowSync().isEnabled()
return isFeatureEnabled() && syncFeature.level0ShowSync().isEnabled()
}

override fun allowDataSyncing(): Boolean {
if (!showSync()) return false
return syncFeature.level1AllowDataSyncing().isEnabled()
}

override fun allowDataSyncingOnNewerVersion(): Boolean {
return isToggleEnabledOnNewerVersion(syncFeature.level1AllowDataSyncing())
}

override fun allowSetupFlows(): Boolean {
if (!showSync()) return false
if (!syncFeature.level1AllowDataSyncing().isEnabled()) return false
return syncFeature.level2AllowSetupFlows().isEnabled()
}

override fun allowSetupFlowsOnNewerVersion(): Boolean {
return isToggleEnabledOnNewerVersion(syncFeature.level2AllowSetupFlows())
}

override fun allowCreateAccount(): Boolean {
if (!showSync()) return false
if (!syncFeature.level1AllowDataSyncing().isEnabled()) return false
if (!syncFeature.level2AllowSetupFlows().isEnabled()) return false
return syncFeature.level3AllowCreateAccount().isEnabled()
}

override fun allowCreateAccountOnNewerVersion(): Boolean {
return isToggleEnabledOnNewerVersion(syncFeature.level3AllowCreateAccount())
}

private fun isToggleEnabledOnNewerVersion(toggle: Toggle): Boolean {
val rawStoredState = toggle.getRawStoredState()

return rawStoredState?.remoteEnableState == true &&
appBuildConfig.versionCode < (rawStoredState.minSupportedVersion ?: 0)
}

override fun onPrivacyConfigDownloaded() {
appCoroutineScope.launch(coroutineDispatcher.io()) {
val canSyncData = allowDataSyncing()

if (!canSyncData && syncStore.syncingDataEnabled && syncStore.isSignedIn()) {
triggerNotification()
}

if (canSyncData && !syncStore.syncingDataEnabled) {
cancelNotification()
}

syncStore.syncingDataEnabled = canSyncData
}
}

private fun triggerNotification() {
notificationManager.notify(
SYNC_PAUSED_NOTIFICATION_ID,
syncNotificationBuilder.buildSyncPausedNotification(context),
)
}
private fun cancelNotification() {
notificationManager.cancel(SYNC_PAUSED_NOTIFICATION_ID)
}

companion object {
private const val SYNC_PAUSED_NOTIFICATION_ID = 6451
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class RealSyncEngine @Inject constructor(

override fun triggerSync(trigger: SyncTrigger) {
Timber.i("Sync-Engine: petition to sync now trigger: $trigger")
if (syncStore.isSignedIn()) {
if (syncStore.isSignedIn() && syncStore.syncingDataEnabled) {
Timber.d("Sync-Engine: sync enabled, triggering operation: $trigger")
when (trigger) {
BACKGROUND_SYNC -> scheduleSync(trigger)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* 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.duckduckgo.sync.impl.engine

import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat
import androidx.core.app.TaskStackBuilder
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.sync.api.SYNC_NOTIFICATION_CHANNEL_ID
import com.duckduckgo.sync.api.SyncActivityWithEmptyParams
import com.duckduckgo.sync.impl.R
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

interface SyncNotificationBuilder {
fun buildSyncPausedNotification(context: Context): Notification
}

@ContributesBinding(AppScope::class)
class AppCredentialsSyncNotificationBuilder @Inject constructor(
private val globalGlobalActivityStarter: GlobalActivityStarter,
) : SyncNotificationBuilder {
override fun buildSyncPausedNotification(context: Context): Notification {
return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)
.setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo)
.setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setContentIntent(getPendingIntent(context))
.setCustomContentView(RemoteViews(context.packageName, R.layout.notification_sync_paused))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.build()
}

private fun getPendingIntent(context: Context): PendingIntent? = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(
globalGlobalActivityStarter.startIntent(context, SyncActivityWithEmptyParams)!!,
)
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.RecoveryCodePDF
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.ShowRecoveryCode
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.ShowTextCode
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.SyncWithAnotherDevice
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.SetupFlows
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.ViewState
import com.duckduckgo.sync.impl.ui.setup.ConnectFlowContract
import com.duckduckgo.sync.impl.ui.setup.LoginContract
Expand Down Expand Up @@ -320,6 +321,13 @@ class SyncActivity : DuckDuckGoActivity() {
if (viewState.loginQRCode != null) {
binding.viewSyncEnabled.qrCodeImageView.show()
binding.viewSyncEnabled.qrCodeImageView.setImageBitmap(viewState.loginQRCode)
binding.viewSyncEnabled.scanQrCodeItem.isEnabled = !viewState.disabledSetupFlows.contains(SetupFlows.SignInFlow)
}
} else {
with(binding.viewSyncDisabled) {
syncSetupWithAnotherDevice.isEnabled = !viewState.disabledSetupFlows.contains(SetupFlows.CreateAccountFlow)
syncSetupSyncThisDevice.isEnabled = !viewState.disabledSetupFlows.contains(SetupFlows.CreateAccountFlow)
syncSetupRecoverData.isEnabled = !viewState.disabledSetupFlows.contains(SetupFlows.SignInFlow)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ import com.duckduckgo.sync.impl.RecoveryCodePDF
import com.duckduckgo.sync.impl.Result.Error
import com.duckduckgo.sync.impl.Result.Success
import com.duckduckgo.sync.impl.SyncAccountRepository
import com.duckduckgo.sync.impl.SyncFeatureToggle
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.AskDeleteAccount
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.AskEditDevice
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.AskRemoveDevice
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.AskTurnOffSync
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.CheckIfUserHasStoragePermission
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.IntroCreateAccount
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.RecoveryCodePDFSuccess
import com.duckduckgo.sync.impl.ui.SyncActivityViewModel.Command.ShowRecoveryCode
import com.duckduckgo.sync.impl.ui.SyncDeviceListItem.LoadingItem
import com.duckduckgo.sync.impl.ui.SyncDeviceListItem.SyncedDevice
import java.io.File
Expand All @@ -66,6 +66,7 @@ class SyncActivityViewModel @Inject constructor(
private val syncStateMonitor: SyncStateMonitor,
private val syncEngine: SyncEngine,
private val dispatchers: DispatcherProvider,
private val syncFeatureToggle: SyncFeatureToggle,
) : ViewModel() {

private val command = Channel<Command>(1, DROP_OLDEST)
Expand Down Expand Up @@ -109,6 +110,7 @@ class SyncActivityViewModel @Inject constructor(
showAccount = syncAccountRepository.isSignedIn(),
loginQRCode = qrBitmap,
syncedDevices = syncedDevices,
disabledSetupFlows = disabledSetupFlows(),
)
}

Expand All @@ -128,8 +130,14 @@ class SyncActivityViewModel @Inject constructor(
val showAccount: Boolean = false,
val loginQRCode: Bitmap? = null,
val syncedDevices: List<SyncDeviceListItem> = emptyList(),
val disabledSetupFlows: List<SetupFlows> = emptyList(),
)

sealed class SetupFlows {
data object SignInFlow : SetupFlows()
data object CreateAccountFlow : SetupFlows()
}

sealed class Command {
object SyncWithAnotherDevice : Command()
object AddAnotherDevice : Command()
Expand Down Expand Up @@ -320,7 +328,15 @@ class SyncActivityViewModel @Inject constructor(
}
}

private fun signedOutState(): ViewState = ViewState()
private fun disabledSetupFlows(): List<SetupFlows> {
if (!syncFeatureToggle.allowSetupFlows()) return listOf(SetupFlows.SignInFlow, SetupFlows.CreateAccountFlow)
if (!syncFeatureToggle.allowCreateAccount()) return listOf(SetupFlows.CreateAccountFlow)
return emptyList()
}

private fun signedOutState(): ViewState = ViewState(
disabledSetupFlows = disabledSetupFlows(),
)
private fun ViewState.setDevices(devices: List<SyncDeviceListItem>) = copy(syncedDevices = devices)
private fun ViewState.hideDeviceListItemLoading() = copy(syncedDevices = syncedDevices.filterNot { it is LoadingItem })
private fun ViewState.showDeviceListItemLoading() = copy(syncedDevices = syncedDevices + LoadingItem)
Expand Down
Loading

0 comments on commit 53c5f73

Please sign in to comment.