1
0
Fork 0

For #1048 - Add ability to view tab history by long-pressing the back or forward button.

master
Kainalu Hagiwara 2020-07-14 16:20:42 -07:00 committed by Jeff Boek
parent 2a0a11f740
commit 921b16233b
19 changed files with 464 additions and 9 deletions

View File

@ -6,11 +6,14 @@ package org.mozilla.fenix
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.StrictMode
import android.text.format.DateUtils
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.WindowManager
import androidx.annotation.CallSuper
import androidx.annotation.IdRes
@ -30,6 +33,8 @@ import kotlinx.android.synthetic.main.activity_home.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.session.SessionManager
@ -139,6 +144,9 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
)
}
// See onKeyDown for why this is necessary
private var backLongPressJob: Job? = null
private lateinit var navigationToolbar: Toolbar
final override fun onCreate(savedInstanceState: Bundle?) {
@ -349,6 +357,50 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
super.onBackPressed()
}
private fun isAndroidN(): Boolean =
Build.VERSION.SDK_INT == Build.VERSION_CODES.N || Build.VERSION.SDK_INT == Build.VERSION_CODES.N_MR1
private fun handleBackLongPress(): Boolean {
supportFragmentManager.primaryNavigationFragment?.childFragmentManager?.fragments?.forEach {
if (it is OnBackLongPressedListener && it.onBackLongPressed()) {
return true
}
}
return false
}
final override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
// Inspired by https://searchfox.org/mozilla-esr68/source/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java#584-613
// Android N has broken passing onKeyLongPress events for the back button, so we
// instead implement the long press behavior ourselves
// - For short presses, we cancel the callback in onKeyUp
// - For long presses, the normal keypress is marked as cancelled, hence won't be handled elsewhere
// (but Android still provides the haptic feedback), and the long press action is run
if (isAndroidN() && keyCode == KeyEvent.KEYCODE_BACK) {
backLongPressJob = lifecycleScope.launch {
delay(ViewConfiguration.getLongPressTimeout().toLong())
handleBackLongPress()
}
}
return super.onKeyDown(keyCode, event)
}
final override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
if (isAndroidN() && keyCode == KeyEvent.KEYCODE_BACK) {
backLongPressJob?.cancel()
}
return super.onKeyUp(keyCode, event)
}
final override fun onKeyLongPress(keyCode: Int, event: KeyEvent?): Boolean {
// onKeyLongPress is broken in Android N so we don't handle back button long presses here
// for N. The version check ensures we don't handle back button long presses twice.
if (!isAndroidN() && keyCode == KeyEvent.KEYCODE_BACK) {
return handleBackLongPress()
}
return super.onKeyLongPress(keyCode, event)
}
final override fun onUserLeaveHint() {
supportFragmentManager.primaryNavigationFragment?.childFragmentManager?.fragments?.forEach {
if (it is UserInteractionHandler && it.onHomePressed()) {

View File

@ -0,0 +1,21 @@
/* 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
/**
* Interface for features and fragments that want to handle long presses of the system back button
*/
interface OnBackLongPressedListener {
/**
* Called when the system back button is long pressed.
*
* Note: This cannot be called when gesture navigation is enabled on Android 10+ due to system
* limitations.
*
* @return true if the event was handled
*/
fun onBackLongPressed(): Boolean
}

View File

@ -77,6 +77,7 @@ import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.IntentReceiverActivity
import org.mozilla.fenix.NavGraphDirections
import org.mozilla.fenix.OnBackLongPressedListener
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.readermode.DefaultReaderModeController
@ -116,7 +117,8 @@ import java.lang.ref.WeakReference
*/
@ExperimentalCoroutinesApi
@Suppress("TooManyFunctions", "LargeClass")
abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, SessionManager.Observer {
abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, SessionManager.Observer,
OnBackLongPressedListener {
private lateinit var browserFragmentStore: BrowserFragmentStore
private lateinit var browserAnimator: BrowserAnimator
@ -757,6 +759,11 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Session
removeSessionIfNeeded()
}
override fun onBackLongPressed(): Boolean {
findNavController().navigate(R.id.action_global_tabHistoryDialogFragment)
return true
}
/**
* Saves the external app session ID to be restored later in [onViewStateRestored].
*/

View File

@ -170,7 +170,13 @@ class DefaultBrowserToolbarController(
Do exhaustive when (item) {
ToolbarMenu.Item.Back -> sessionUseCases.goBack.invoke(currentSession)
ToolbarMenu.Item.Forward -> sessionUseCases.goForward.invoke(currentSession)
is ToolbarMenu.Item.Forward -> {
if (item.viewHistory) {
navController.navigate(R.id.action_global_tabHistoryDialogFragment)
} else {
sessionUseCases.goForward.invoke(currentSession)
}
}
ToolbarMenu.Item.Reload -> sessionUseCases.reload.invoke(currentSession)
ToolbarMenu.Item.Stop -> sessionUseCases.stopLoading.invoke(currentSession)
ToolbarMenu.Item.Settings -> browserAnimator.captureEngineViewAndDrawStatically {
@ -333,7 +339,7 @@ class DefaultBrowserToolbarController(
private fun trackToolbarItemInteraction(item: ToolbarMenu.Item) {
val eventItem = when (item) {
ToolbarMenu.Item.Back -> Event.BrowserMenuItemTapped.Item.BACK
ToolbarMenu.Item.Forward -> Event.BrowserMenuItemTapped.Item.FORWARD
is ToolbarMenu.Item.Forward -> Event.BrowserMenuItemTapped.Item.FORWARD
ToolbarMenu.Item.Reload -> Event.BrowserMenuItemTapped.Item.RELOAD
ToolbarMenu.Item.Stop -> Event.BrowserMenuItemTapped.Item.STOP
ToolbarMenu.Item.Settings -> Event.BrowserMenuItemTapped.Item.SETTINGS

View File

@ -82,9 +82,10 @@ class DefaultToolbarMenu(
session?.canGoForward ?: true
},
secondaryImageTintResource = ThemeManager.resolveAttribute(R.attr.disabled, context),
disableInSecondaryState = true
disableInSecondaryState = true,
longClickListener = { onItemTapped.invoke(ToolbarMenu.Item.Forward(viewHistory = true)) }
) {
onItemTapped.invoke(ToolbarMenu.Item.Forward)
onItemTapped.invoke(ToolbarMenu.Item.Forward(viewHistory = false))
}
val refresh = BrowserMenuItemToolbar.TwoStateButton(

View File

@ -14,7 +14,7 @@ interface ToolbarMenu {
object FindInPage : Item()
object Share : Item()
object Back : Item()
object Forward : Item()
data class Forward(val viewHistory: Boolean) : Item()
object Reload : Item()
object Stop : Item()
object OpenInFenix : Item()

View File

@ -75,9 +75,10 @@ class CustomTabToolbarMenu(
R.attr.disabled,
context
),
disableInSecondaryState = true
disableInSecondaryState = true,
longClickListener = { onItemTapped.invoke(ToolbarMenu.Item.Forward(viewHistory = true)) }
) {
onItemTapped.invoke(ToolbarMenu.Item.Forward)
onItemTapped.invoke(ToolbarMenu.Item.Forward(viewHistory = false))
}
val refresh = BrowserMenuItemToolbar.TwoStateButton(

View File

@ -0,0 +1,40 @@
/* 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.tabhistory
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
data class TabHistoryItem(
val title: String,
val url: String,
val index: Int,
val isSelected: Boolean
)
class TabHistoryAdapter(
private val interactor: TabHistoryViewInteractor
) : RecyclerView.Adapter<TabHistoryViewHolder>() {
var historyList: List<TabHistoryItem> = emptyList()
set(value) {
field = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabHistoryViewHolder {
val view =
LayoutInflater.from(parent.context).inflate(R.layout.history_list_item, parent, false)
return TabHistoryViewHolder(view, interactor)
}
override fun onBindViewHolder(holder: TabHistoryViewHolder, position: Int) {
holder.bind(historyList[position])
}
override fun getItemCount(): Int = historyList.size
}

View File

@ -0,0 +1,24 @@
/* 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.tabhistory
import androidx.navigation.NavController
import mozilla.components.feature.session.SessionUseCases
import org.mozilla.fenix.R
interface TabHistoryController {
fun handleGoToHistoryItem(item: TabHistoryItem)
}
class DefaultTabHistoryController(
private val navController: NavController,
private val goToHistoryIndexUseCase: SessionUseCases.GoToHistoryIndexUseCase
) : TabHistoryController {
override fun handleGoToHistoryItem(item: TabHistoryItem) {
navController.popBackStack(R.id.browserFragment, false)
goToHistoryIndexUseCase.invoke(item.index)
}
}

View File

@ -0,0 +1,56 @@
/* 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.tabhistory
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.android.synthetic.main.fragment_tab_history_dialog.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.requireComponents
class TabHistoryDialogFragment : BottomSheetDialogFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.BottomSheet)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_tab_history_dialog, container, false)
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val controller = DefaultTabHistoryController(
navController = findNavController(),
goToHistoryIndexUseCase = requireComponents.useCases.sessionUseCases.goToHistoryIndex
)
val tabHistoryView = TabHistoryView(
container = tabHistoryLayout,
expandDialog = ::expand,
interactor = TabHistoryInteractor(controller)
)
consumeFrom(requireComponents.core.store) {
tabHistoryView.updateState(it)
}
}
private fun expand() {
(dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}

View File

@ -0,0 +1,14 @@
/* 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.tabhistory
class TabHistoryInteractor(
private val controller: TabHistoryController
) : TabHistoryViewInteractor {
override fun goToHistoryItem(item: TabHistoryItem) {
controller.handleGoToHistoryItem(item)
}
}

View File

@ -0,0 +1,79 @@
/* 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.tabhistory
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_tabhistory.*
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.state.BrowserState
import org.mozilla.fenix.R
interface TabHistoryViewInteractor {
/**
* Jump to a specific index in the tab's history.
*/
fun goToHistoryItem(item: TabHistoryItem)
}
class TabHistoryView(
private val container: ViewGroup,
private val expandDialog: () -> Unit,
interactor: TabHistoryViewInteractor
) : LayoutContainer {
override val containerView: View?
get() = container
val view: View = LayoutInflater.from(container.context)
.inflate(R.layout.component_tabhistory, container, true)
private val adapter = TabHistoryAdapter(interactor)
private val layoutManager = object : LinearLayoutManager(view.context) {
override fun onLayoutCompleted(state: RecyclerView.State?) {
super.onLayoutCompleted(state)
currentIndex?.let { index ->
// Force expansion of the dialog, otherwise scrolling to the current history item
// won't work when its position is near the bottom of the recyclerview.
expandDialog.invoke()
// Also, attempt to center the current history item.
val itemView = tabHistoryRecyclerView.findViewHolderForLayoutPosition(
findFirstCompletelyVisibleItemPosition()
)?.itemView
val offset = tabHistoryRecyclerView.height / 2 - (itemView?.height ?: 0) / 2
scrollToPositionWithOffset(index, offset)
}
}
}.apply {
reverseLayout = true
}
private var currentIndex: Int? = null
init {
tabHistoryRecyclerView.adapter = adapter
tabHistoryRecyclerView.layoutManager = layoutManager
}
fun updateState(state: BrowserState) {
state.selectedTab?.content?.history?.let { historyState ->
currentIndex = historyState.currentIndex
val items = historyState.items.mapIndexed { index, historyItem ->
TabHistoryItem(
title = historyItem.title,
url = historyItem.uri,
index = index,
isSelected = index == historyState.currentIndex
)
}
adapter.historyList = items
}
}
}

View File

@ -0,0 +1,34 @@
/* 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.tabhistory
import android.view.View
import androidx.core.text.bold
import androidx.core.text.buildSpannedString
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.history_list_item.view.*
class TabHistoryViewHolder(
private val view: View,
private val interactor: TabHistoryViewInteractor
) : RecyclerView.ViewHolder(view) {
fun bind(item: TabHistoryItem) {
view.history_layout.overflowView.isVisible = false
view.history_layout.urlView.text = item.url
view.history_layout.loadFavicon(item.url)
view.history_layout.titleView.text = if (item.isSelected) {
buildSpannedString {
bold { append(item.title) }
}
} else {
item.title
}
view.setOnClickListener { interactor.goToHistoryItem(item) }
}
}

View File

@ -0,0 +1,31 @@
<?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/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/tabHistoryWrapper"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<View
android:id="@+id/handle"
android:layout_width="0dp"
android:layout_height="3dp"
android:layout_marginTop="8dp"
android:background="@color/secondary_text_normal_theme"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.1" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tabHistoryRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="19dp"
tools:listitem="@layout/history_list_item" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,10 @@
<?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:id="@+id/tabHistoryLayout"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@ -102,6 +102,9 @@
<action
android:id="@+id/action_global_savedLoginsAuthFragment"
app:destination="@id/savedLoginsAuthFragment" />
<action
android:id="@+id/action_global_tabHistoryDialogFragment"
app:destination="@id/tabHistoryDialogFragment" />
<dialog
android:id="@+id/tabTrayDialogFragment"
@ -859,4 +862,7 @@
app:argType="string"
app:nullable="true" />
</fragment>
<dialog
android:id="@+id/tabHistoryDialogFragment"
android:name="org.mozilla.fenix.tabhistory.TabHistoryDialogFragment" />
</navigation>

View File

@ -220,7 +220,7 @@ class DefaultBrowserToolbarControllerTest {
@Test
fun handleToolbarForwardPress() = runBlockingTest {
val item = ToolbarMenu.Item.Forward
val item = ToolbarMenu.Item.Forward(false)
val controller = createController(scope = this)
controller.handleToolbarItemInteraction(item)
@ -229,6 +229,17 @@ class DefaultBrowserToolbarControllerTest {
verify { sessionUseCases.goForward(currentSession) }
}
@Test
fun handleToolbarForwardLongPress() = runBlockingTest {
val item = ToolbarMenu.Item.Forward(true)
val controller = createController(scope = this)
controller.handleToolbarItemInteraction(item)
verify { metrics.track(Event.BrowserMenuItemTapped(Event.BrowserMenuItemTapped.Item.FORWARD)) }
verify { navController.navigate(R.id.action_global_tabHistoryDialogFragment) }
}
@Test
fun handleToolbarReloadPress() = runBlockingTest {
val item = ToolbarMenu.Item.Reload

View File

@ -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.tabhistory
import androidx.navigation.NavController
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.browser.session.SessionManager
import mozilla.components.feature.session.SessionUseCases
import org.junit.Test
class TabHistoryControllerTest {
val sessionManager: SessionManager = mockk(relaxed = true)
val navController: NavController = mockk(relaxed = true)
val sessionUseCases = SessionUseCases(sessionManager)
val goToHistoryIndexUseCase = sessionUseCases.goToHistoryIndex
val controller = DefaultTabHistoryController(
navController = navController,
goToHistoryIndexUseCase = goToHistoryIndexUseCase
)
val currentItem = TabHistoryItem(
index = 0,
title = "",
url = "",
isSelected = true
)
@Test
fun handleGoToHistoryIndex() {
controller.handleGoToHistoryItem(currentItem)
verify { goToHistoryIndexUseCase.invoke(currentItem.index) }
}
}

View File

@ -0,0 +1,24 @@
/* 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.tabhistory
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
class TabHistoryInteractorTest {
val controller: TabHistoryController = mockk(relaxed = true)
val interactor = TabHistoryInteractor(controller)
@Test
fun onGoToHistoryItem() {
val item: TabHistoryItem = mockk()
interactor.goToHistoryItem(item)
verify { controller.handleGoToHistoryItem(item) }
}
}