Skip to content

Commit

Permalink
Sync Bookmarks limit, handle and surface errors
Browse files Browse the repository at this point in the history
  • Loading branch information
cmonfortep committed Nov 27, 2023
1 parent 6ca7b18 commit 3e772cd
Show file tree
Hide file tree
Showing 28 changed files with 650 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class CredentialsSyncDataPersister @Inject constructor(
private val strategies: DaggerMap<SyncConflictResolution, CredentialsMergeStrategy>,
private val appBuildConfig: AppBuildConfig,
) : SyncableDataPersister {
override fun persist(
override fun onSuccess(
changes: SyncChangesResponse,
conflictResolution: SyncConflictResolution,
): SyncMergeResult {
Expand All @@ -63,7 +63,7 @@ class CredentialsSyncDataPersister @Inject constructor(
}

override fun onError(error: SyncErrorResponse) {
TODO("Not yet implemented")
// TODO: implement
}

private fun process(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ internal class CredentialsSyncDataPersisterTest {
fun whenValidatingCorruptedDataThenResultIsError() {
val updatesJSON = FileUtilities.loadText(javaClass.classLoader!!, "json/sync/merger_invalid_data.json")
val corruptedChanges = SyncChangesResponse(CREDENTIALS, updatesJSON)
val result = syncPersister.persist(corruptedChanges, TIMESTAMP)
val result = syncPersister.onSuccess(corruptedChanges, TIMESTAMP)

assertTrue(result is Error)
}
Expand All @@ -91,7 +91,7 @@ internal class CredentialsSyncDataPersisterTest {
fun whenValidatingNullEntriesThenResultIsError() {
val updatesJSON = FileUtilities.loadText(javaClass.classLoader!!, "json/sync/merger_null_entries.json")
val corruptedChanges = SyncChangesResponse(CREDENTIALS, updatesJSON)
val result = syncPersister.persist(corruptedChanges, TIMESTAMP)
val result = syncPersister.onSuccess(corruptedChanges, TIMESTAMP)

assertTrue(result is Error)
}
Expand All @@ -100,7 +100,7 @@ internal class CredentialsSyncDataPersisterTest {
fun whenProcessingDataInEmptyDBThenResultIsSuccess() {
val updatesJSON = FileUtilities.loadText(javaClass.classLoader!!, "json/sync/merger_first_get.json")
val validChanges = SyncChangesResponse(CREDENTIALS, updatesJSON)
val result = syncPersister.persist(validChanges, DEDUPLICATION)
val result = syncPersister.onSuccess(validChanges, DEDUPLICATION)

assertTrue(result is Success)
}
Expand All @@ -109,7 +109,7 @@ internal class CredentialsSyncDataPersisterTest {
fun whenMergingEmptyEntriesThenResultIsSuccess() {
val updatesJSON = FileUtilities.loadText(javaClass.classLoader!!, "json/sync/merger_empty_entries.json")
val corruptedChanges = SyncChangesResponse(CREDENTIALS, updatesJSON)
val result = syncPersister.persist(corruptedChanges, TIMESTAMP)
val result = syncPersister.onSuccess(corruptedChanges, TIMESTAMP)

assertTrue(result is Success)
}
Expand All @@ -118,14 +118,14 @@ internal class CredentialsSyncDataPersisterTest {
fun whenMergingWithDeletedDataThenResultIsSuccess() {
val updatesJSON = FileUtilities.loadText(javaClass.classLoader!!, "json/sync/merger_deleted_entries.json")
val deletedChanges = SyncChangesResponse(CREDENTIALS, updatesJSON)
val result = syncPersister.persist(deletedChanges, TIMESTAMP)
val result = syncPersister.onSuccess(deletedChanges, TIMESTAMP)

assertTrue(result is Success)
}

@Test
fun whenPersistWithAnotherTypeThenReturnFalse() {
val result = syncPersister.persist(
val result = syncPersister.onSuccess(
SyncChangesResponse(BOOKMARKS, ""),
DEDUPLICATION,
)
Expand All @@ -140,7 +140,7 @@ internal class CredentialsSyncDataPersisterTest {

val updatesJSON = FileUtilities.loadText(javaClass.classLoader!!, "json/sync/merger_first_get.json")
val validChanges = SyncChangesResponse(CREDENTIALS, updatesJSON)
val result = syncPersister.persist(validChanges, DEDUPLICATION)
val result = syncPersister.onSuccess(validChanges, DEDUPLICATION)

assertTrue(result is Success)
assertNull(dao.getSyncMetadata(1L))
Expand Down
1 change: 1 addition & 0 deletions saved-sites/saved-sites-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies {
implementation project(path: ':sync-api')
implementation project(path: ':sync-settings-api')
implementation project(path: ':browser-api')
implementation project(path: ':navigation-api')
api project(path: ':saved-sites-store')

anvil project(path: ':anvil-compiler')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,40 @@ package com.duckduckgo.savedsites.impl.sync
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch

interface SavedSitesSyncStore {
var serverModifiedSince: String
var startTimeStamp: String
var clientModifiedSince: String
var limitExceeded: Boolean
var isSyncPaused: Boolean
fun isSyncPausedFlow(): Flow<Boolean>
}

@ContributesBinding(AppScope::class)
@SingleInstanceIn(AppScope::class)
class RealSavedSitesSyncStore @Inject constructor(private val context: Context) : SavedSitesSyncStore {
class RealSavedSitesSyncStore @Inject constructor(
private val context: Context,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
) : SavedSitesSyncStore {

private val syncPausedSharedFlow = MutableSharedFlow<Boolean>(replay = 1, onBufferOverflow = DROP_OLDEST)
init {
appCoroutineScope.launch(dispatcherProvider.io()) {
syncPausedSharedFlow.emit(isSyncPaused)
}
}
override var serverModifiedSince: String
get() = preferences.getString(KEY_SERVER_MODIFIED_SINCE, "0") ?: "0"
set(value) = preferences.edit(true) { putString(KEY_SERVER_MODIFIED_SINCE, value) }
Expand All @@ -44,9 +62,20 @@ class RealSavedSitesSyncStore @Inject constructor(private val context: Context)
override var clientModifiedSince: String
get() = preferences.getString(KEY_CLIENT_MODIFIED_SINCE, "0") ?: "0"
set(value) = preferences.edit(true) { putString(KEY_CLIENT_MODIFIED_SINCE, value) }
override var limitExceeded: Boolean
override var isSyncPaused: Boolean
get() = preferences.getBoolean(KEY_CLIENT_LIMIT_EXCEEDED, false) ?: false
set(value) = preferences.edit(true) { putBoolean(KEY_CLIENT_LIMIT_EXCEEDED, value) }
set(value) {
preferences.edit(true) { putBoolean(KEY_CLIENT_LIMIT_EXCEEDED, value) }
emitNewValue()
}

override fun isSyncPausedFlow(): Flow<Boolean> = syncPausedSharedFlow

private fun emitNewValue() {
appCoroutineScope.launch(dispatcherProvider.io()) {
syncPausedSharedFlow.emit(isSyncPaused)
}
}

private val preferences: SharedPreferences
get() = context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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.savedsites.impl.sync

import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.browser.api.ui.BrowserScreens.BookmarksScreenNoParams
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.saved.sites.impl.R
import com.duckduckgo.saved.sites.impl.databinding.ViewSaveSiteRateLimitWarningBinding
import com.duckduckgo.savedsites.impl.sync.SavedSiteRateLimitViewModel.Command
import com.duckduckgo.savedsites.impl.sync.SavedSiteRateLimitViewModel.Command.NavigateToBookmarks
import com.duckduckgo.savedsites.impl.sync.SavedSiteRateLimitViewModel.ViewState
import dagger.android.support.AndroidSupportInjection
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

@InjectWith(ViewScope::class)
class SaveSiteRateLimitView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : FrameLayout(context, attrs, defStyle) {

@Inject
lateinit var globalActivityStarter: GlobalActivityStarter

@Inject
lateinit var viewModelFactory: SavedSiteRateLimitViewModel.Factory

private var coroutineScope: CoroutineScope? = null

private var job: ConflatedJob = ConflatedJob()

private val binding: ViewSaveSiteRateLimitWarningBinding by viewBinding()

private val viewModel: SavedSiteRateLimitViewModel by lazy {
ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[SavedSiteRateLimitViewModel::class.java]
}

override fun onAttachedToWindow() {
AndroidSupportInjection.inject(this)
super.onAttachedToWindow()

ViewTreeLifecycleOwner.get(this)?.lifecycle?.addObserver(viewModel)

configureViewListeners()

@SuppressLint("NoHardcodedCoroutineDispatcher")
coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

viewModel.viewState()
.onEach { render(it) }
.launchIn(coroutineScope!!)

job += viewModel.commands()
.onEach { processCommands(it) }
.launchIn(coroutineScope!!)
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
ViewTreeLifecycleOwner.get(this)?.lifecycle?.removeObserver(viewModel)
coroutineScope?.cancel()
job.cancel()
coroutineScope = null
}

private fun processCommands(command: Command) {
when (command) {
NavigateToBookmarks -> navigateToBookmarks()
}
}

private fun render(viewState: ViewState) {
this.isVisible = viewState.warningVisible
}

private fun configureViewListeners() {
binding.saveSiteRateLimitWarning.setClickableLink(
"manage_bookmarks",
context.getText(R.string.saved_site_limit_warning),
onClick = {
viewModel.onWarningActionClicked()
},
)
}

private fun navigateToBookmarks() {
globalActivityStarter.start(this.context, BookmarksScreenNoParams)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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.savedsites.impl.sync

import android.content.Context
import android.view.View
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.sync.api.SyncMessagePlugin
import com.squareup.anvil.annotations.ContributesMultibinding
import javax.inject.Inject

@ContributesMultibinding(scope = ActivityScope::class)
class SaveSitesRateLimitSyncMessagePlugin @Inject constructor() : SyncMessagePlugin {
override fun getView(context: Context): View {
return SaveSiteRateLimitView(context)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* 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.savedsites.impl.sync

import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.duckduckgo.common.ui.notifyme.NotifyMeViewModel.Command
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.savedsites.impl.sync.DisplayModeViewModel.ViewState
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class SavedSiteRateLimitViewModel(
private val savedSitesSyncStore: SavedSitesSyncStore,
private val dispatcherProvider: DispatcherProvider,
) : ViewModel(), DefaultLifecycleObserver {

data class ViewState(
val warningVisible: Boolean = false,
)

sealed class Command {
data object NavigateToBookmarks : Command()
}

private val command = Channel<Command>(1, DROP_OLDEST)

fun viewState(): Flow<ViewState> = savedSitesSyncStore.isSyncPausedFlow()
.map { syncPaused ->
ViewState(
warningVisible = syncPaused,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ViewState())

fun commands(): Flow<Command> = command.receiveAsFlow()

fun onWarningActionClicked() {
viewModelScope.launch {
command.send(Command.NavigateToBookmarks)
}
}

@Suppress("UNCHECKED_CAST")
class Factory @Inject constructor(
private val savedSitesSyncStore: SavedSitesSyncStore,
private val dispatcherProvider: DispatcherProvider,
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return with(modelClass) {
when {
isAssignableFrom(SavedSiteRateLimitViewModel::class.java) -> SavedSiteRateLimitViewModel(
savedSitesSyncStore,
dispatcherProvider,
)
else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
} as T
}
}
}
Loading

0 comments on commit 3e772cd

Please sign in to comment.