For #2165 - Implement pull-to-refresh gesture to sync history.
parent
f163861b47
commit
d14b39a56e
|
@ -10,6 +10,8 @@ import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import mozilla.components.concept.engine.prompt.ShareData
|
import mozilla.components.concept.engine.prompt.ShareData
|
||||||
import org.mozilla.fenix.R
|
import org.mozilla.fenix.R
|
||||||
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
|
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
|
||||||
|
@ -25,6 +27,7 @@ interface HistoryController {
|
||||||
fun handleDeleteSome(items: Set<HistoryItem>)
|
fun handleDeleteSome(items: Set<HistoryItem>)
|
||||||
fun handleCopyUrl(item: HistoryItem)
|
fun handleCopyUrl(item: HistoryItem)
|
||||||
fun handleShare(item: HistoryItem)
|
fun handleShare(item: HistoryItem)
|
||||||
|
fun handleRequestSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
class DefaultHistoryController(
|
class DefaultHistoryController(
|
||||||
|
@ -33,16 +36,21 @@ class DefaultHistoryController(
|
||||||
private val resources: Resources,
|
private val resources: Resources,
|
||||||
private val snackbar: FenixSnackbar,
|
private val snackbar: FenixSnackbar,
|
||||||
private val clipboardManager: ClipboardManager,
|
private val clipboardManager: ClipboardManager,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
private val openToBrowser: (item: HistoryItem, mode: BrowsingMode?) -> Unit,
|
private val openToBrowser: (item: HistoryItem, mode: BrowsingMode?) -> Unit,
|
||||||
private val displayDeleteAll: () -> Unit,
|
private val displayDeleteAll: () -> Unit,
|
||||||
private val invalidateOptionsMenu: () -> Unit,
|
private val invalidateOptionsMenu: () -> Unit,
|
||||||
private val deleteHistoryItems: (Set<HistoryItem>) -> Unit
|
private val deleteHistoryItems: (Set<HistoryItem>) -> Unit,
|
||||||
|
private val syncHistory: suspend () -> Unit
|
||||||
) : HistoryController {
|
) : HistoryController {
|
||||||
override fun handleOpen(item: HistoryItem, mode: BrowsingMode?) {
|
override fun handleOpen(item: HistoryItem, mode: BrowsingMode?) {
|
||||||
openToBrowser(item, mode)
|
openToBrowser(item, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleSelect(item: HistoryItem) {
|
override fun handleSelect(item: HistoryItem) {
|
||||||
|
if (store.state.mode === HistoryFragmentState.Mode.Syncing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
store.dispatch(HistoryFragmentAction.AddItemForRemoval(item))
|
store.dispatch(HistoryFragmentAction.AddItemForRemoval(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,4 +95,12 @@ class DefaultHistoryController(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun handleRequestSync() {
|
||||||
|
scope.launch {
|
||||||
|
store.dispatch(HistoryFragmentAction.StartSync)
|
||||||
|
syncHistory.invoke()
|
||||||
|
store.dispatch(HistoryFragmentAction.FinishSync)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import mozilla.components.concept.engine.prompt.ShareData
|
import mozilla.components.concept.engine.prompt.ShareData
|
||||||
import mozilla.components.lib.state.ext.consumeFrom
|
import mozilla.components.lib.state.ext.consumeFrom
|
||||||
|
import mozilla.components.service.fxa.sync.SyncReason
|
||||||
import mozilla.components.support.base.feature.UserInteractionHandler
|
import mozilla.components.support.base.feature.UserInteractionHandler
|
||||||
import org.mozilla.fenix.BrowserDirection
|
import org.mozilla.fenix.BrowserDirection
|
||||||
import org.mozilla.fenix.HomeActivity
|
import org.mozilla.fenix.HomeActivity
|
||||||
|
@ -72,10 +73,12 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
|
||||||
isDisplayedWithBrowserToolbar = false
|
isDisplayedWithBrowserToolbar = false
|
||||||
),
|
),
|
||||||
activity?.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager,
|
activity?.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager,
|
||||||
|
lifecycleScope,
|
||||||
::openItem,
|
::openItem,
|
||||||
::displayDeleteAllDialog,
|
::displayDeleteAllDialog,
|
||||||
::invalidateOptionsMenu,
|
::invalidateOptionsMenu,
|
||||||
::deleteHistoryItems
|
::deleteHistoryItems,
|
||||||
|
::syncHistory
|
||||||
)
|
)
|
||||||
historyInteractor = HistoryInteractor(
|
historyInteractor = HistoryInteractor(
|
||||||
historyController
|
historyController
|
||||||
|
@ -268,4 +271,10 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
|
||||||
)
|
)
|
||||||
nav(R.id.historyFragment, directions)
|
nav(R.id.historyFragment, directions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun syncHistory() {
|
||||||
|
val accountManager = requireComponents.backgroundServices.accountManager
|
||||||
|
accountManager.syncNowAsync(SyncReason.User).await()
|
||||||
|
viewModel.invalidate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,8 @@ sealed class HistoryFragmentAction : Action {
|
||||||
data class RemoveItemForRemoval(val item: HistoryItem) : HistoryFragmentAction()
|
data class RemoveItemForRemoval(val item: HistoryItem) : HistoryFragmentAction()
|
||||||
object EnterDeletionMode : HistoryFragmentAction()
|
object EnterDeletionMode : HistoryFragmentAction()
|
||||||
object ExitDeletionMode : HistoryFragmentAction()
|
object ExitDeletionMode : HistoryFragmentAction()
|
||||||
|
object StartSync : HistoryFragmentAction()
|
||||||
|
object FinishSync : HistoryFragmentAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -45,6 +47,7 @@ data class HistoryFragmentState(val items: List<HistoryItem>, val mode: Mode) :
|
||||||
|
|
||||||
object Normal : Mode()
|
object Normal : Mode()
|
||||||
object Deleting : Mode()
|
object Deleting : Mode()
|
||||||
|
object Syncing : Mode()
|
||||||
data class Editing(override val selectedItems: Set<HistoryItem>) : Mode()
|
data class Editing(override val selectedItems: Set<HistoryItem>) : Mode()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -72,5 +75,7 @@ private fun historyStateReducer(
|
||||||
is HistoryFragmentAction.ExitEditMode -> state.copy(mode = HistoryFragmentState.Mode.Normal)
|
is HistoryFragmentAction.ExitEditMode -> state.copy(mode = HistoryFragmentState.Mode.Normal)
|
||||||
is HistoryFragmentAction.EnterDeletionMode -> state.copy(mode = HistoryFragmentState.Mode.Deleting)
|
is HistoryFragmentAction.EnterDeletionMode -> state.copy(mode = HistoryFragmentState.Mode.Deleting)
|
||||||
is HistoryFragmentAction.ExitDeletionMode -> state.copy(mode = HistoryFragmentState.Mode.Normal)
|
is HistoryFragmentAction.ExitDeletionMode -> state.copy(mode = HistoryFragmentState.Mode.Normal)
|
||||||
|
is HistoryFragmentAction.StartSync -> state.copy(mode = HistoryFragmentState.Mode.Syncing)
|
||||||
|
is HistoryFragmentAction.FinishSync -> state.copy(mode = HistoryFragmentState.Mode.Normal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,4 +57,8 @@ class HistoryInteractor(
|
||||||
override fun onDeleteSome(items: Set<HistoryItem>) {
|
override fun onDeleteSome(items: Set<HistoryItem>) {
|
||||||
historyController.handleDeleteSome(items)
|
historyController.handleDeleteSome(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRequestSync() {
|
||||||
|
historyController.handleRequestSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import mozilla.components.support.base.feature.UserInteractionHandler
|
||||||
import org.mozilla.fenix.R
|
import org.mozilla.fenix.R
|
||||||
import org.mozilla.fenix.library.LibraryPageView
|
import org.mozilla.fenix.library.LibraryPageView
|
||||||
import org.mozilla.fenix.library.SelectionInteractor
|
import org.mozilla.fenix.library.SelectionInteractor
|
||||||
|
import org.mozilla.fenix.theme.ThemeManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for the HistoryViewInteractor. This interface is implemented by objects that want
|
* Interface for the HistoryViewInteractor. This interface is implemented by objects that want
|
||||||
|
@ -71,6 +72,11 @@ interface HistoryViewInteractor : SelectionInteractor<HistoryItem> {
|
||||||
* @param items the history items to delete
|
* @param items the history items to delete
|
||||||
*/
|
*/
|
||||||
fun onDeleteSome(items: Set<HistoryItem>)
|
fun onDeleteSome(items: Set<HistoryItem>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the user requests a sync of the history
|
||||||
|
*/
|
||||||
|
fun onRequestSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -97,12 +103,23 @@ class HistoryView(
|
||||||
adapter = historyAdapter
|
adapter = historyAdapter
|
||||||
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val primaryTextColor =
|
||||||
|
ThemeManager.resolveAttribute(R.attr.primaryText, context)
|
||||||
|
view.swipe_refresh.setColorSchemeColors(primaryTextColor)
|
||||||
|
view.swipe_refresh.setOnRefreshListener {
|
||||||
|
interactor.onRequestSync()
|
||||||
|
view.history_list.scrollToPosition(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(state: HistoryFragmentState) {
|
fun update(state: HistoryFragmentState) {
|
||||||
val oldMode = mode
|
val oldMode = mode
|
||||||
|
|
||||||
view.progress_bar.isVisible = state.mode === HistoryFragmentState.Mode.Deleting
|
view.progress_bar.isVisible = state.mode === HistoryFragmentState.Mode.Deleting
|
||||||
|
view.swipe_refresh.isRefreshing = state.mode === HistoryFragmentState.Mode.Syncing
|
||||||
|
view.swipe_refresh.isEnabled =
|
||||||
|
state.mode === HistoryFragmentState.Mode.Normal || state.mode === HistoryFragmentState.Mode.Syncing
|
||||||
items = state.items
|
items = state.items
|
||||||
mode = state.mode
|
mode = state.mode
|
||||||
|
|
||||||
|
|
|
@ -33,9 +33,16 @@
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent" />
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipe_refresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" >
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/history_list"
|
android:id="@+id/history_list"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:listitem="@layout/history_list_item"/>
|
tools:listitem="@layout/history_list_item"/>
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
|
@ -9,10 +9,14 @@ import android.content.ClipboardManager
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavDirections
|
import androidx.navigation.NavDirections
|
||||||
|
import io.mockk.coVerifyOrder
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestCoroutineScope
|
||||||
import mozilla.components.concept.engine.prompt.ShareData
|
import mozilla.components.concept.engine.prompt.ShareData
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
@ -26,9 +30,11 @@ import org.mozilla.fenix.components.FenixSnackbar
|
||||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||||
|
|
||||||
// Robolectric needed for `onShareItem()`
|
// Robolectric needed for `onShareItem()`
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
@RunWith(FenixRobolectricTestRunner::class)
|
@RunWith(FenixRobolectricTestRunner::class)
|
||||||
class HistoryControllerTest {
|
class HistoryControllerTest {
|
||||||
private val historyItem = HistoryItem(0, "title", "url", 0.toLong())
|
private val historyItem = HistoryItem(0, "title", "url", 0.toLong())
|
||||||
|
private val scope: CoroutineScope = TestCoroutineScope()
|
||||||
private val store: HistoryFragmentStore = mockk(relaxed = true)
|
private val store: HistoryFragmentStore = mockk(relaxed = true)
|
||||||
private val state: HistoryFragmentState = mockk(relaxed = true)
|
private val state: HistoryFragmentState = mockk(relaxed = true)
|
||||||
private val navController: NavController = mockk(relaxed = true)
|
private val navController: NavController = mockk(relaxed = true)
|
||||||
|
@ -39,16 +45,19 @@ class HistoryControllerTest {
|
||||||
private val displayDeleteAll: () -> Unit = mockk(relaxed = true)
|
private val displayDeleteAll: () -> Unit = mockk(relaxed = true)
|
||||||
private val invalidateOptionsMenu: () -> Unit = mockk(relaxed = true)
|
private val invalidateOptionsMenu: () -> Unit = mockk(relaxed = true)
|
||||||
private val deleteHistoryItems: (Set<HistoryItem>) -> Unit = mockk(relaxed = true)
|
private val deleteHistoryItems: (Set<HistoryItem>) -> Unit = mockk(relaxed = true)
|
||||||
|
private val syncHistory: suspend () -> Unit = mockk(relaxed = true)
|
||||||
private val controller = DefaultHistoryController(
|
private val controller = DefaultHistoryController(
|
||||||
store,
|
store,
|
||||||
navController,
|
navController,
|
||||||
resources,
|
resources,
|
||||||
snackbar,
|
snackbar,
|
||||||
clipboardManager,
|
clipboardManager,
|
||||||
|
scope,
|
||||||
openInBrowser,
|
openInBrowser,
|
||||||
displayDeleteAll,
|
displayDeleteAll,
|
||||||
invalidateOptionsMenu,
|
invalidateOptionsMenu,
|
||||||
deleteHistoryItems
|
deleteHistoryItems,
|
||||||
|
syncHistory
|
||||||
)
|
)
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
|
@ -105,6 +114,17 @@ class HistoryControllerTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onSelectHistoryItemDuringSync() {
|
||||||
|
every { state.mode } returns HistoryFragmentState.Mode.Syncing
|
||||||
|
|
||||||
|
controller.handleSelect(historyItem)
|
||||||
|
|
||||||
|
verify(exactly = 0) {
|
||||||
|
store.dispatch(HistoryFragmentAction.AddItemForRemoval(historyItem))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onBackPressedInNormalMode() {
|
fun onBackPressedInNormalMode() {
|
||||||
every { state.mode } returns HistoryFragmentState.Mode.Normal
|
every { state.mode } returns HistoryFragmentState.Mode.Normal
|
||||||
|
@ -190,4 +210,19 @@ class HistoryControllerTest {
|
||||||
assertEquals(historyItem.title, (directions.captured.arguments["data"] as Array<ShareData>)[0].title)
|
assertEquals(historyItem.title, (directions.captured.arguments["data"] as Array<ShareData>)[0].title)
|
||||||
assertEquals(historyItem.url, (directions.captured.arguments["data"] as Array<ShareData>)[0].url)
|
assertEquals(historyItem.url, (directions.captured.arguments["data"] as Array<ShareData>)[0].url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onRequestSync() {
|
||||||
|
controller.handleRequestSync()
|
||||||
|
|
||||||
|
verify(exactly = 2) {
|
||||||
|
store.dispatch(any())
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerifyOrder {
|
||||||
|
store.dispatch(HistoryFragmentAction.StartSync)
|
||||||
|
syncHistory.invoke()
|
||||||
|
store.dispatch(HistoryFragmentAction.FinishSync)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,6 +46,29 @@ class HistoryFragmentStoreTest {
|
||||||
assertEquals(store.state.mode, HistoryFragmentState.Mode.Editing(setOf(historyItem)))
|
assertEquals(store.state.mode, HistoryFragmentState.Mode.Editing(setOf(historyItem)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startSync() = runBlocking {
|
||||||
|
val initialState = emptyDefaultState()
|
||||||
|
val store = HistoryFragmentStore(initialState)
|
||||||
|
|
||||||
|
store.dispatch(HistoryFragmentAction.StartSync).join()
|
||||||
|
assertNotSame(initialState, store.state)
|
||||||
|
assertEquals(HistoryFragmentState.Mode.Syncing, store.state.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun finishSync() = runBlocking {
|
||||||
|
val initialState = HistoryFragmentState(
|
||||||
|
items = listOf(),
|
||||||
|
mode = HistoryFragmentState.Mode.Syncing
|
||||||
|
)
|
||||||
|
val store = HistoryFragmentStore(initialState)
|
||||||
|
|
||||||
|
store.dispatch(HistoryFragmentAction.FinishSync).join()
|
||||||
|
assertNotSame(initialState, store.state)
|
||||||
|
assertEquals(HistoryFragmentState.Mode.Normal, store.state.mode)
|
||||||
|
}
|
||||||
|
|
||||||
private fun emptyDefaultState(): HistoryFragmentState = HistoryFragmentState(
|
private fun emptyDefaultState(): HistoryFragmentState = HistoryFragmentState(
|
||||||
items = listOf(),
|
items = listOf(),
|
||||||
mode = HistoryFragmentState.Mode.Normal
|
mode = HistoryFragmentState.Mode.Normal
|
||||||
|
|
|
@ -120,4 +120,12 @@ class HistoryInteractorTest {
|
||||||
controller.handleDeleteSome(items)
|
controller.handleDeleteSome(items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onRequestSync() {
|
||||||
|
interactor.onRequestSync()
|
||||||
|
verifyAll {
|
||||||
|
controller.handleRequestSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue