For #12287: Add Synced Tabs to Tabs Tray
parent
2e62dd5c87
commit
f614c0b18d
@ -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 org.mozilla.fenix.sync.ext
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType
|
||||||
|
import org.mozilla.fenix.R
|
||||||
|
import org.mozilla.fenix.sync.SyncedTabsAdapter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the error type to the appropriate matching string resource for displaying to the user.
|
||||||
|
*/
|
||||||
|
fun ErrorType.toStringRes() = when (this) {
|
||||||
|
ErrorType.MULTIPLE_DEVICES_UNAVAILABLE -> R.string.synced_tabs_connect_another_device
|
||||||
|
ErrorType.SYNC_ENGINE_UNAVAILABLE -> R.string.synced_tabs_enable_tab_syncing
|
||||||
|
ErrorType.SYNC_UNAVAILABLE -> R.string.synced_tabs_sign_in_message
|
||||||
|
ErrorType.SYNC_NEEDS_REAUTHENTICATION -> R.string.synced_tabs_reauth
|
||||||
|
ErrorType.NO_TABS_AVAILABLE -> R.string.synced_tabs_no_tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an error type to an [SyncedTabsAdapter.AdapterItem.Error].
|
||||||
|
*/
|
||||||
|
fun ErrorType.toAdapterItem(
|
||||||
|
@StringRes stringResId: Int,
|
||||||
|
navController: NavController? = null
|
||||||
|
) = when (this) {
|
||||||
|
ErrorType.MULTIPLE_DEVICES_UNAVAILABLE,
|
||||||
|
ErrorType.SYNC_ENGINE_UNAVAILABLE,
|
||||||
|
ErrorType.SYNC_NEEDS_REAUTHENTICATION,
|
||||||
|
ErrorType.NO_TABS_AVAILABLE -> SyncedTabsAdapter.AdapterItem
|
||||||
|
.Error(descriptionResId = stringResId)
|
||||||
|
ErrorType.SYNC_UNAVAILABLE -> SyncedTabsAdapter.AdapterItem
|
||||||
|
.Error(descriptionResId = stringResId, navController = navController)
|
||||||
|
}
|
@ -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 org.mozilla.fenix.sync.ext
|
||||||
|
|
||||||
|
import mozilla.components.browser.storage.sync.SyncedDeviceTabs
|
||||||
|
import org.mozilla.fenix.sync.SyncedTabsAdapter.AdapterItem
|
||||||
|
|
||||||
|
fun List<SyncedDeviceTabs>.toAdapterList(
|
||||||
|
): MutableList<AdapterItem> {
|
||||||
|
val allDeviceTabs = mutableListOf<AdapterItem>()
|
||||||
|
|
||||||
|
forEach { (device, tabs) ->
|
||||||
|
if (tabs.isNotEmpty()) {
|
||||||
|
allDeviceTabs.add(AdapterItem.Device(device))
|
||||||
|
tabs.mapTo(allDeviceTabs) { AdapterItem.Tab(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allDeviceTabs
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
/* 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 org.mozilla.fenix.tabtray
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.fragment.app.FragmentManager.findFragment
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import mozilla.components.browser.storage.sync.SyncedDeviceTabs
|
||||||
|
import mozilla.components.feature.syncedtabs.view.SyncedTabsView
|
||||||
|
import org.mozilla.fenix.sync.ListenerDelegate
|
||||||
|
import org.mozilla.fenix.sync.SyncedTabsAdapter
|
||||||
|
import org.mozilla.fenix.sync.ext.toAdapterList
|
||||||
|
import org.mozilla.fenix.sync.ext.toAdapterItem
|
||||||
|
import org.mozilla.fenix.sync.ext.toStringRes
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
class SyncedTabsController(
|
||||||
|
private val view: View,
|
||||||
|
coroutineContext: CoroutineContext = Dispatchers.Main
|
||||||
|
) : SyncedTabsView {
|
||||||
|
override var listener: SyncedTabsView.Listener? = null
|
||||||
|
|
||||||
|
val adapter = SyncedTabsAdapter(ListenerDelegate { listener })
|
||||||
|
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(coroutineContext)
|
||||||
|
|
||||||
|
override fun displaySyncedTabs(syncedTabs: List<SyncedDeviceTabs>) {
|
||||||
|
scope.launch {
|
||||||
|
val tabsList = listOf(SyncedTabsAdapter.AdapterItem.Title) + syncedTabs.toAdapterList()
|
||||||
|
// Reverse layout for TabTrayView which does things backwards.
|
||||||
|
adapter.submitList(tabsList.reversed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(error: SyncedTabsView.ErrorType) {
|
||||||
|
scope.launch {
|
||||||
|
val navController: NavController? = try {
|
||||||
|
findFragment<TabTrayDialogFragment>(view).findNavController()
|
||||||
|
} catch (exception: IllegalStateException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val descriptionResId = error.toStringRes()
|
||||||
|
val errorItem = error.toAdapterItem(descriptionResId, navController)
|
||||||
|
|
||||||
|
adapter.submitList(listOf(errorItem))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:duration="275"
|
||||||
|
android:fromDegrees="0"
|
||||||
|
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
|
||||||
|
android:pivotX="50%"
|
||||||
|
android:pivotY="50%"
|
||||||
|
android:toDegrees="360" />
|
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 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/. -->
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
</FrameLayout>
|
@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 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/. -->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
style="@style/Header16TextStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="60dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/synced_tabs" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/refresh_icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="60dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
app:srcCompat="@drawable/mozac_ic_refresh"
|
||||||
|
app:tint="?primaryText" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
@ -0,0 +1,26 @@
|
|||||||
|
/* 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 org.mozilla.fenix.sync
|
||||||
|
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import mozilla.components.feature.syncedtabs.view.SyncedTabsView
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ListenerDelegateTest {
|
||||||
|
@Test
|
||||||
|
fun `delegate invokes nullable listener`() {
|
||||||
|
val listener: SyncedTabsView.Listener? = mockk(relaxed = true)
|
||||||
|
val delegate = ListenerDelegate { listener }
|
||||||
|
|
||||||
|
delegate.onRefresh()
|
||||||
|
|
||||||
|
verify { listener?.onRefresh() }
|
||||||
|
|
||||||
|
delegate.onTabClicked(mockk())
|
||||||
|
|
||||||
|
verify { listener?.onTabClicked(any()) }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,72 @@
|
|||||||
|
package org.mozilla.fenix.sync.ext
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import io.mockk.mockk
|
||||||
|
import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType
|
||||||
|
import org.mozilla.fenix.R
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
|
||||||
|
class ErrorTypeKtTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `string resource for error`() {
|
||||||
|
assertEquals(
|
||||||
|
R.string.synced_tabs_connect_another_device,
|
||||||
|
ErrorType.MULTIPLE_DEVICES_UNAVAILABLE.toStringRes()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
R.string.synced_tabs_enable_tab_syncing,
|
||||||
|
ErrorType.SYNC_ENGINE_UNAVAILABLE.toStringRes()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
R.string.synced_tabs_sign_in_message,
|
||||||
|
ErrorType.SYNC_UNAVAILABLE.toStringRes()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
R.string.synced_tabs_reauth,
|
||||||
|
ErrorType.SYNC_NEEDS_REAUTHENTICATION.toStringRes()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
R.string.synced_tabs_no_tabs,
|
||||||
|
ErrorType.NO_TABS_AVAILABLE.toStringRes()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get error item`() {
|
||||||
|
val navController = mockk<NavController>()
|
||||||
|
|
||||||
|
var errorItem = ErrorType.MULTIPLE_DEVICES_UNAVAILABLE.toAdapterItem(
|
||||||
|
R.string.synced_tabs_connect_another_device, navController
|
||||||
|
)
|
||||||
|
assertNull(errorItem.navController)
|
||||||
|
assertEquals(R.string.synced_tabs_connect_another_device, errorItem.descriptionResId)
|
||||||
|
|
||||||
|
errorItem = ErrorType.SYNC_ENGINE_UNAVAILABLE.toAdapterItem(
|
||||||
|
R.string.synced_tabs_enable_tab_syncing, navController
|
||||||
|
)
|
||||||
|
assertNull(errorItem.navController)
|
||||||
|
assertEquals(R.string.synced_tabs_enable_tab_syncing, errorItem.descriptionResId)
|
||||||
|
|
||||||
|
errorItem = ErrorType.SYNC_NEEDS_REAUTHENTICATION.toAdapterItem(
|
||||||
|
R.string.synced_tabs_reauth, navController
|
||||||
|
)
|
||||||
|
assertNull(errorItem.navController)
|
||||||
|
assertEquals(R.string.synced_tabs_reauth, errorItem.descriptionResId)
|
||||||
|
|
||||||
|
errorItem = ErrorType.NO_TABS_AVAILABLE.toAdapterItem(
|
||||||
|
R.string.synced_tabs_no_tabs, navController
|
||||||
|
)
|
||||||
|
assertNull(errorItem.navController)
|
||||||
|
assertEquals(R.string.synced_tabs_no_tabs, errorItem.descriptionResId)
|
||||||
|
|
||||||
|
errorItem = ErrorType.SYNC_UNAVAILABLE.toAdapterItem(
|
||||||
|
R.string.synced_tabs_sign_in_message, navController
|
||||||
|
)
|
||||||
|
assertNotNull(errorItem.navController)
|
||||||
|
assertEquals(R.string.synced_tabs_sign_in_message, errorItem.descriptionResId)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,92 @@
|
|||||||
|
/* 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 org.mozilla.fenix.sync.ext
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import mozilla.components.browser.storage.sync.SyncedDeviceTabs
|
||||||
|
import mozilla.components.browser.storage.sync.Tab
|
||||||
|
import mozilla.components.browser.storage.sync.TabEntry
|
||||||
|
import mozilla.components.concept.sync.DeviceType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.mozilla.fenix.sync.SyncedTabsAdapter
|
||||||
|
|
||||||
|
class SyncedTabsAdapterKtTest {
|
||||||
|
private val noTabDevice = SyncedDeviceTabs(
|
||||||
|
device = mockk {
|
||||||
|
every { displayName } returns "Charcoal"
|
||||||
|
every { deviceType } returns DeviceType.DESKTOP
|
||||||
|
},
|
||||||
|
tabs = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val oneTabDevice = SyncedDeviceTabs(
|
||||||
|
device = mockk {
|
||||||
|
every { displayName } returns "Charcoal"
|
||||||
|
every { deviceType } returns DeviceType.DESKTOP
|
||||||
|
},
|
||||||
|
tabs = listOf(Tab(
|
||||||
|
history = listOf(TabEntry(
|
||||||
|
title = "Mozilla",
|
||||||
|
url = "https://mozilla.org",
|
||||||
|
iconUrl = null
|
||||||
|
)),
|
||||||
|
active = 0,
|
||||||
|
lastUsed = 0L
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
|
private val twoTabDevice = SyncedDeviceTabs(
|
||||||
|
device = mockk {
|
||||||
|
every { displayName } returns "Emerald"
|
||||||
|
every { deviceType } returns DeviceType.MOBILE
|
||||||
|
},
|
||||||
|
tabs = listOf(
|
||||||
|
Tab(
|
||||||
|
history = listOf(TabEntry(
|
||||||
|
title = "Mozilla",
|
||||||
|
url = "https://mozilla.org",
|
||||||
|
iconUrl = null
|
||||||
|
)),
|
||||||
|
active = 0,
|
||||||
|
lastUsed = 0L
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
history = listOf(
|
||||||
|
TabEntry(
|
||||||
|
title = "Firefox",
|
||||||
|
url = "https://firefox.com",
|
||||||
|
iconUrl = null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
active = 0,
|
||||||
|
lastUsed = 0L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `verify ordering of adapter items`() {
|
||||||
|
val syncedDeviceList = listOf(oneTabDevice, twoTabDevice)
|
||||||
|
val adapterData = syncedDeviceList.toAdapterList()
|
||||||
|
|
||||||
|
assertEquals(5, adapterData.count())
|
||||||
|
assertTrue(adapterData[0] is SyncedTabsAdapter.AdapterItem.Device)
|
||||||
|
assertTrue(adapterData[1] is SyncedTabsAdapter.AdapterItem.Tab)
|
||||||
|
assertTrue(adapterData[2] is SyncedTabsAdapter.AdapterItem.Device)
|
||||||
|
assertTrue(adapterData[3] is SyncedTabsAdapter.AdapterItem.Tab)
|
||||||
|
assertTrue(adapterData[4] is SyncedTabsAdapter.AdapterItem.Tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `verify no tabs displayed`() {
|
||||||
|
val syncedDeviceList = listOf(noTabDevice)
|
||||||
|
val adapterData = syncedDeviceList.toAdapterList()
|
||||||
|
|
||||||
|
assertEquals(0, adapterData.count())
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,78 @@
|
|||||||
|
package org.mozilla.fenix.tabtray
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runBlockingTest
|
||||||
|
import mozilla.components.browser.storage.sync.SyncedDeviceTabs
|
||||||
|
import mozilla.components.feature.syncedtabs.view.SyncedTabsView.ErrorType
|
||||||
|
import mozilla.components.support.test.robolectric.testContext
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.mozilla.fenix.R
|
||||||
|
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||||
|
import org.mozilla.fenix.sync.SyncedTabsViewHolder
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
@RunWith(FenixRobolectricTestRunner::class)
|
||||||
|
class SyncedTabsControllerTest {
|
||||||
|
|
||||||
|
private lateinit var view: View
|
||||||
|
private lateinit var controller: SyncedTabsController
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() = runBlockingTest {
|
||||||
|
view = LayoutInflater.from(testContext).inflate(R.layout.about_list_item, null)
|
||||||
|
controller = SyncedTabsController(view, coroutineContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `display synced tabs in reverse`() {
|
||||||
|
val tabs = listOf(
|
||||||
|
SyncedDeviceTabs(
|
||||||
|
device = mockk(relaxed = true),
|
||||||
|
tabs = listOf(
|
||||||
|
mockk(relaxed = true),
|
||||||
|
mockk(relaxed = true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller.displaySyncedTabs(tabs)
|
||||||
|
|
||||||
|
val itemCount = controller.adapter.itemCount
|
||||||
|
|
||||||
|
// title + device name + 2 tabs
|
||||||
|
assertEquals(4, itemCount)
|
||||||
|
assertEquals(
|
||||||
|
SyncedTabsViewHolder.TitleViewHolder.LAYOUT_ID,
|
||||||
|
controller.adapter.getItemViewType(itemCount - 1)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
SyncedTabsViewHolder.DeviceViewHolder.LAYOUT_ID,
|
||||||
|
controller.adapter.getItemViewType(itemCount - 2)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
SyncedTabsViewHolder.TabViewHolder.LAYOUT_ID,
|
||||||
|
controller.adapter.getItemViewType(itemCount - 3)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
SyncedTabsViewHolder.TabViewHolder.LAYOUT_ID,
|
||||||
|
controller.adapter.getItemViewType(itemCount - 4)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `show error when we go kaput`() {
|
||||||
|
controller.onError(ErrorType.SYNC_NEEDS_REAUTHENTICATION)
|
||||||
|
|
||||||
|
assertEquals(1, controller.adapter.itemCount)
|
||||||
|
assertEquals(
|
||||||
|
SyncedTabsViewHolder.ErrorViewHolder.LAYOUT_ID,
|
||||||
|
controller.adapter.getItemViewType(0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue