Combines the Tab and Session component

nightly-build-test
Jeff Boek 5 years ago committed by Colin Lee
parent cf500ee266
commit e34d163034

@ -62,6 +62,7 @@ import org.mozilla.fenix.ext.asActivity
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.share
import org.mozilla.fenix.lib.Do
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.getAutoDisposeObservable
import org.mozilla.fenix.quickactionsheet.QuickActionAction
@ -474,10 +475,6 @@ class BrowserFragment : Fragment(), BackHandler {
}
}
object Do {
inline infix fun <reified T> exhaustive(any: T?) = any
}
companion object {
private const val REQUEST_CODE_DOWNLOAD_PERMISSIONS = 1
private const val REQUEST_CODE_PROMPT_PERMISSIONS = 2

@ -7,10 +7,11 @@ package org.mozilla.fenix.home
import android.content.res.Resources
import android.graphics.drawable.BitmapDrawable
import android.os.Bundle
import android.text.SpannableString
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.BOTTOM
import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.TOP
import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.START
import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.END
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -28,24 +29,25 @@ import kotlinx.coroutines.launch
import mozilla.components.browser.menu.BrowserMenu
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import org.jetbrains.anko.constraint.layout.applyConstraintSet
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.BrowsingModeManager
import org.mozilla.fenix.DefaultThemeManager
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.utils.ItsNotBrokenSnack
import org.mozilla.fenix.R
import org.mozilla.fenix.utils.ItsNotBrokenSnack
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.archive
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.home.sessions.ArchivedSession
import org.mozilla.fenix.home.sessions.SessionsAction
import org.mozilla.fenix.home.sessions.SessionsChange
import org.mozilla.fenix.home.sessions.SessionsComponent
import org.mozilla.fenix.home.tabs.TabsAction
import org.mozilla.fenix.home.tabs.TabsChange
import org.mozilla.fenix.home.tabs.TabsComponent
import org.mozilla.fenix.home.tabs.TabsState
import org.mozilla.fenix.home.tabs.toSessionViewState
import org.mozilla.fenix.home.sessioncontrol.ArchivedSession
import org.mozilla.fenix.home.sessioncontrol.ArchivedSessionAction
import org.mozilla.fenix.home.sessioncontrol.Mode
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.SessionControlChange
import org.mozilla.fenix.home.sessioncontrol.SessionControlComponent
import org.mozilla.fenix.home.sessioncontrol.SessionControlState
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.lib.Do
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.getAutoDisposeObservable
import org.mozilla.fenix.mvi.getManagedEmitter
@ -58,8 +60,7 @@ class HomeFragment : Fragment(), CoroutineScope {
private val bus = ActionBusFactory.get(this)
private var sessionObserver: SessionManager.Observer? = null
private var homeMenu: HomeMenu? = null
private lateinit var tabsComponent: TabsComponent
private lateinit var sessionsComponent: SessionsComponent
private lateinit var sessionControlComponent: SessionControlComponent
private lateinit var job: Job
override val coroutineContext: CoroutineContext
@ -72,14 +73,23 @@ class HomeFragment : Fragment(), CoroutineScope {
): View? {
job = Job()
val view = inflater.inflate(R.layout.fragment_home, container, false)
val sessionManager = requireComponents.core.sessionManager
tabsComponent = TabsComponent(
view.homeContainer,
val mode = if ((activity as HomeActivity).browsingModeManager.isPrivate) Mode.Private else Mode.Normal
sessionControlComponent = SessionControlComponent(
view.homeLayout,
bus,
(activity as HomeActivity).browsingModeManager.isPrivate,
TabsState(sessionManager.sessions.map { it.toSessionViewState(it == sessionManager.selectedSession) })
SessionControlState(listOf(), listOf(), mode)
)
sessionsComponent = SessionsComponent(view.homeContainer, bus)
view.homeLayout.applyConstraintSet {
sessionControlComponent.view {
connect(
TOP to BOTTOM of view.homeDivider,
START to START of PARENT_ID,
END to END of PARENT_ID,
BOTTOM to BOTTOM of PARENT_ID
)
}
}
ActionBusFactory.get(this).logMergedObservables()
val activity = activity as HomeActivity
@ -92,18 +102,11 @@ class HomeFragment : Fragment(), CoroutineScope {
super.onViewCreated(view, savedInstanceState)
setupHomeMenu()
setupPrivateBrowsingDescription()
updatePrivateSessionDescriptionVisibility()
sessionsComponent.view.visibility = if ((activity as HomeActivity).browsingModeManager.isPrivate)
View.GONE else View.VISIBLE
tabsComponent.tabList.isNestedScrollingEnabled = false
sessionsComponent.view.isNestedScrollingEnabled = false
val bundles = requireComponents.core.sessionStorage.bundles(limit = temporaryNumberOfSessions)
bundles.observe(this, Observer { sessionBundles ->
val archivedSessions = sessionBundles
val sessions = sessionBundles
.filter { it.id != requireComponents.core.sessionStorage.current()?.id }
.mapNotNull { sessionBundle ->
sessionBundle.id?.let {
@ -111,7 +114,7 @@ class HomeFragment : Fragment(), CoroutineScope {
}
}
getManagedEmitter<SessionsChange>().onNext(SessionsChange.Changed(archivedSessions))
getManagedEmitter<SessionControlChange>().onNext(SessionControlChange.ArchivedSessionsChange(sessions))
})
val searchIcon = requireComponents.search.searchEngineManager.getDefaultSearchEngine(
@ -167,66 +170,11 @@ class HomeFragment : Fragment(), CoroutineScope {
override fun onStart() {
super.onStart()
if (isAdded) {
getAutoDisposeObservable<TabsAction>()
.subscribe {
when (it) {
is TabsAction.Archive -> {
launch {
requireComponents.core.sessionStorage.archive(requireComponents.core.sessionManager)
}
}
is TabsAction.MenuTapped -> {
val isPrivate = (activity as HomeActivity).browsingModeManager.isPrivate
val titles = requireComponents.core.sessionManager.sessions
.filter { session -> session.private == isPrivate }
.map { session -> session.title }
val sessionType = if (isPrivate) {
SessionBottomSheetFragment.SessionType.Private(titles)
} else {
SessionBottomSheetFragment.SessionType.Current(titles)
}
openSessionMenu(sessionType)
}
is TabsAction.Select -> {
val session = requireComponents.core.sessionManager.findSessionById(it.sessionId)
requireComponents.core.sessionManager.select(session!!)
val directions = HomeFragmentDirections.actionHomeFragmentToBrowserFragment(it.sessionId)
Navigation.findNavController(view!!).navigate(directions)
}
is TabsAction.Close -> {
requireComponents.core.sessionManager.findSessionById(it.sessionId)?.let { session ->
requireComponents.core.sessionManager.remove(session)
}
}
is TabsAction.CloseAll -> {
requireComponents.useCases.tabsUseCases.removeAllTabsOfType.invoke(it.private)
}
}
}
getAutoDisposeObservable<SessionsAction>()
getAutoDisposeObservable<SessionControlAction>()
.subscribe {
when (it) {
is SessionsAction.Select -> {
launch {
requireComponents.core.sessionStorage.archive(requireComponents.core.sessionManager)
it.archivedSession.bundle.restoreSnapshot()?.apply {
requireComponents.core.sessionManager.restore(this)
homeScrollView.smoothScrollTo(0, 0)
}
}
}
is SessionsAction.Delete -> {
launch(IO) {
requireComponents.core.sessionStorage.remove(it.archivedSession.bundle)
}
}
is SessionsAction.MenuTapped ->
openSessionMenu(SessionBottomSheetFragment.SessionType.Archived(it.archivedSession))
is SessionsAction.ShareTapped ->
ItsNotBrokenSnack(context!!).showSnackbar(issueNumber = "244")
is SessionControlAction.Tab -> handleTabAction(it.action)
is SessionControlAction.Session -> handleSessionAction(it.action)
}
}
}
@ -235,6 +183,77 @@ class HomeFragment : Fragment(), CoroutineScope {
sessionObserver?.onSessionsRestored()
}
@SuppressWarnings("ComplexMethod")
private fun handleTabAction(action: TabAction) {
Do exhaustive when (action) {
is TabAction.Archive -> {
launch {
requireComponents.core.sessionStorage.archive(requireComponents.core.sessionManager)
}
}
is TabAction.MenuTapped -> {
val isPrivate = (activity as HomeActivity).browsingModeManager.isPrivate
val titles = requireComponents.core.sessionManager.sessions
.filter { session -> session.private == isPrivate }
.map { session -> session.title }
val sessionType = if (isPrivate) {
SessionBottomSheetFragment.SessionType.Private(titles)
} else {
SessionBottomSheetFragment.SessionType.Current(titles)
}
openSessionMenu(sessionType)
}
is TabAction.Select -> {
val session = requireComponents.core.sessionManager.findSessionById(action.sessionId)
requireComponents.core.sessionManager.select(session!!)
val directions = HomeFragmentDirections.actionHomeFragmentToBrowserFragment(action.sessionId)
Navigation.findNavController(view!!).navigate(directions)
}
is TabAction.Close -> {
requireComponents.core.sessionManager.findSessionById(action.sessionId)?.let { session ->
requireComponents.core.sessionManager.remove(session)
}
}
is TabAction.CloseAll -> {
requireComponents.useCases.tabsUseCases.removeAllTabsOfType.invoke(action.private)
}
is TabAction.PrivateBrowsingLearnMore -> {
requireComponents.useCases.tabsUseCases.addPrivateTab
.invoke(SupportUtils.getSumoURLForTopic(context!!, SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS))
(activity as HomeActivity).openToBrowser(requireComponents.core.sessionManager.selectedSession?.id,
BrowserDirection.FromHome)
}
is TabAction.Add -> {
val directions = HomeFragmentDirections.actionHomeFragmentToSearchFragment(null)
Navigation.findNavController(view!!).navigate(directions)
}
}
}
private fun handleSessionAction(action: ArchivedSessionAction) {
when (action) {
is ArchivedSessionAction.Select -> {
launch {
requireComponents.core.sessionStorage.archive(requireComponents.core.sessionManager)
action.session.bundle.restoreSnapshot()?.apply {
requireComponents.core.sessionManager.restore(this)
}
}
}
is ArchivedSessionAction.Delete -> {
launch(IO) {
requireComponents.core.sessionStorage.remove(action.session.bundle)
}
}
is ArchivedSessionAction.MenuTapped ->
openSessionMenu(SessionBottomSheetFragment.SessionType.Archived(action.session))
is ArchivedSessionAction.ShareTapped ->
ItsNotBrokenSnack(context!!).showSnackbar(issueNumber = "244")
}
}
override fun onPause() {
super.onPause()
sessionObserver?.let {
@ -262,72 +281,31 @@ class HomeFragment : Fragment(), CoroutineScope {
return getString(resourceId)
}
private fun setupPrivateBrowsingDescription() {
// Format the description text to include a hyperlink
val appName = resources.getString(R.string.app_name)
private_session_description.text = resources.getString(R.string.private_browsing_explanation, appName)
val descriptionText = String
.format(private_session_description.text.toString(), System.getProperty("line.separator"))
val linkStartIndex = descriptionText.indexOf("\n\n") + 2
val linkAction = object : ClickableSpan() {
override fun onClick(widget: View?) {
requireComponents.useCases.tabsUseCases.addPrivateTab
.invoke(SupportUtils.getSumoURLForTopic(context!!, SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS))
(activity as HomeActivity).openToBrowser(requireComponents.core.sessionManager.selectedSession?.id,
BrowserDirection.FromHome)
}
}
val textWithLink = SpannableString(descriptionText).apply {
setSpan(linkAction, linkStartIndex, descriptionText.length, 0)
val colorSpan = ForegroundColorSpan(private_session_description.currentTextColor)
setSpan(colorSpan, linkStartIndex, descriptionText.length, 0)
}
private_session_description.movementMethod = LinkMovementMethod.getInstance()
private_session_description.text = textWithLink
}
private fun updatePrivateSessionDescriptionVisibility() {
val isPrivate = (activity as HomeActivity).browsingModeManager.isPrivate
val hasNoTabs = requireComponents.core.sessionManager.all.none { it.private }
private_session_description_wrapper.visibility = if (isPrivate && hasNoTabs) {
View.VISIBLE
} else {
View.GONE
}
}
private fun subscribeToSessions(): SessionManager.Observer {
val observer = object : SessionManager.Observer {
override fun onSessionAdded(session: Session) {
super.onSessionAdded(session)
emitSessionChanges()
updatePrivateSessionDescriptionVisibility()
}
override fun onSessionRemoved(session: Session) {
super.onSessionRemoved(session)
emitSessionChanges()
updatePrivateSessionDescriptionVisibility()
}
override fun onSessionSelected(session: Session) {
super.onSessionSelected(session)
emitSessionChanges()
updatePrivateSessionDescriptionVisibility()
}
override fun onSessionsRestored() {
super.onSessionsRestored()
emitSessionChanges()
updatePrivateSessionDescriptionVisibility()
}
override fun onAllSessionsRemoved() {
super.onAllSessionsRemoved()
emitSessionChanges()
updatePrivateSessionDescriptionVisibility()
}
}
requireComponents.core.sessionManager.register(observer)
@ -336,11 +314,14 @@ class HomeFragment : Fragment(), CoroutineScope {
private fun emitSessionChanges() {
val sessionManager = requireComponents.core.sessionManager
getManagedEmitter<TabsChange>().onNext(
TabsChange.Changed(
getManagedEmitter<SessionControlChange>().onNext(
SessionControlChange.TabsChange(
sessionManager.sessions
.filter { (activity as HomeActivity).browsingModeManager.isPrivate == it.private }
.map { it.toSessionViewState(it == sessionManager.selectedSession) }
.map {
val selected = it == sessionManager.selectedSession
org.mozilla.fenix.home.sessioncontrol.Tab(it.id, it.url, selected, it.thumbnail)
}
)
)
}
@ -376,11 +357,7 @@ class HomeFragment : Fragment(), CoroutineScope {
}
companion object {
const val addTabButtonIncreaseDps = 8
const val overflowButtonIncreaseDps = 8
const val toolbarPaddingDp = 12f
const val firstKeyTriggerFrame = 55
const val secondKeyTriggerFrame = 90
const val temporaryNumberOfSessions = 25
}
}

@ -16,7 +16,8 @@ import kotlinx.android.synthetic.main.session_bottom_sheet.view.*
import org.mozilla.fenix.DefaultThemeManager
import org.mozilla.fenix.utils.ItsNotBrokenSnack
import org.mozilla.fenix.R
import org.mozilla.fenix.home.sessions.ArchivedSession
import org.mozilla.fenix.home.sessioncontrol.ArchivedSession
import org.mozilla.fenix.home.sessioncontrol.viewholders.formattedSavedAt
class SessionBottomSheetFragment : BottomSheetDialogFragment(), LayoutContainer {
sealed class SessionType {

@ -0,0 +1,94 @@
/* 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.home.sessioncontrol
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.coroutines.Job
import org.mozilla.fenix.home.sessioncontrol.viewholders.ArchiveTabsViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.DeleteTabsViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.PrivateBrowsingDescriptionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.SessionHeaderViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.SessionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.SessionPlaceholderViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.TabHeaderViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.TabViewHolder
import java.lang.IllegalStateException
sealed class AdapterItem {
object TabHeader : AdapterItem()
data class TabItem(val tab: Tab) : AdapterItem()
object PrivateBrowsingDescription : AdapterItem()
object ArchiveTabs : AdapterItem()
object DeleteTabs : AdapterItem()
object SessionHeader : AdapterItem()
object SessionPlaceholder : AdapterItem()
data class SessionItem(val session: ArchivedSession) : AdapterItem()
val viewType: Int
get() = when (this) {
TabHeader -> TabHeaderViewHolder.LAYOUT_ID
is TabItem -> TabViewHolder.LAYOUT_ID
ArchiveTabs -> ArchiveTabsViewHolder.LAYOUT_ID
PrivateBrowsingDescription -> PrivateBrowsingDescriptionViewHolder.LAYOUT_ID
DeleteTabs -> DeleteTabsViewHolder.LAYOUT_ID
SessionHeader -> SessionHeaderViewHolder.LAYOUT_ID
SessionPlaceholder -> SessionPlaceholderViewHolder.LAYOUT_ID
is SessionItem -> SessionViewHolder.LAYOUT_ID
}
}
class SessionControlAdapter(
private val actionEmitter: Observer<SessionControlAction>
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var items: List<AdapterItem> = listOf()
private lateinit var job: Job
fun reloadData(items: List<AdapterItem>) {
this.items = items
notifyDataSetChanged()
}
// This method triggers the ComplexMethod lint error when in fact it's quite simple.
@SuppressWarnings("ComplexMethod")
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return when (viewType) {
TabHeaderViewHolder.LAYOUT_ID -> TabHeaderViewHolder(view, actionEmitter)
TabViewHolder.LAYOUT_ID -> TabViewHolder(view, actionEmitter, job)
ArchiveTabsViewHolder.LAYOUT_ID -> ArchiveTabsViewHolder(view, actionEmitter)
PrivateBrowsingDescriptionViewHolder.LAYOUT_ID -> PrivateBrowsingDescriptionViewHolder(view, actionEmitter)
DeleteTabsViewHolder.LAYOUT_ID -> DeleteTabsViewHolder(view, actionEmitter)
SessionHeaderViewHolder.LAYOUT_ID -> SessionHeaderViewHolder(view)
SessionPlaceholderViewHolder.LAYOUT_ID -> SessionPlaceholderViewHolder(view)
SessionViewHolder.LAYOUT_ID -> SessionViewHolder(view, actionEmitter)
else -> throw IllegalStateException()
}
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
job = Job()
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
job.cancel()
}
override fun getItemViewType(position: Int) = items[position].viewType
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is TabViewHolder -> holder.bindSession((items[position] as AdapterItem.TabItem).tab, position)
is SessionViewHolder -> holder.bind((items[position] as AdapterItem.SessionItem).session)
}
}
}

@ -0,0 +1,93 @@
/* 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.home.sessioncontrol
import android.graphics.Bitmap
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.feature.session.bundling.SessionBundle
import io.reactivex.Observer
import org.mozilla.fenix.mvi.Action
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.ViewState
class SessionControlComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
override var initialState: SessionControlState = SessionControlState(emptyList(), emptyList(), Mode.Normal)
) :
UIComponent<SessionControlState, SessionControlAction, SessionControlChange>(
bus.getManagedEmitter(SessionControlAction::class.java),
bus.getSafeManagedObservable(SessionControlChange::class.java)
) {
override val reducer: (SessionControlState, SessionControlChange) -> SessionControlState = { state, change ->
when (change) {
is SessionControlChange.TabsChange -> state.copy(tabs = change.tabs)
is SessionControlChange.ArchivedSessionsChange ->
state.copy(archivedSessions = change.archivedSessions)
is SessionControlChange.ModeChange -> state.copy(mode = change.mode)
}
}
override fun initView() = SessionControlUIView(container, actionEmitter, changesObservable)
val view: RecyclerView
get() = uiView.view as RecyclerView
init {
render(reducer)
}
}
data class Tab(val sessionId: String, val url: String, val selected: Boolean, val thumbnail: Bitmap? = null)
data class ArchivedSession(val id: Long, val bundle: SessionBundle, val savedAt: Long, val urls: List<String>)
sealed class Mode {
object Normal : Mode()
object Private : Mode()
}
data class SessionControlState(
val tabs: List<Tab>,
val archivedSessions: List<ArchivedSession>,
val mode: Mode
) : ViewState
sealed class ArchivedSessionAction : Action {
data class Select(val session: ArchivedSession) : ArchivedSessionAction()
data class Delete(val session: ArchivedSession) : ArchivedSessionAction()
data class MenuTapped(val session: ArchivedSession) : ArchivedSessionAction()
data class ShareTapped(val session: ArchivedSession) : ArchivedSessionAction()
}
sealed class TabAction : Action {
object Archive : TabAction()
object MenuTapped : TabAction()
object Add : TabAction()
data class CloseAll(val private: Boolean) : TabAction()
data class Select(val sessionId: String) : TabAction()
data class Close(val sessionId: String) : TabAction()
object PrivateBrowsingLearnMore : TabAction()
}
sealed class SessionControlAction : Action {
data class Tab(val action: TabAction) : SessionControlAction()
data class Session(val action: ArchivedSessionAction) : SessionControlAction()
}
fun Observer<SessionControlAction>.onNext(tabAction: TabAction) {
onNext(SessionControlAction.Tab(tabAction))
}
fun Observer<SessionControlAction>.onNext(archivedSessionAction: ArchivedSessionAction) {
onNext(SessionControlAction.Session(archivedSessionAction))
}
sealed class SessionControlChange : Change {
data class ArchivedSessionsChange(val archivedSessions: List<ArchivedSession>) : SessionControlChange()
data class TabsChange(val tabs: List<Tab>) : SessionControlChange()
data class ModeChange(val mode: Mode) : SessionControlChange()
}

@ -0,0 +1,78 @@
/* 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.home.sessioncontrol
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import org.mozilla.fenix.R
import org.mozilla.fenix.mvi.UIView
// Convert HomeState into a data structure HomeAdapter understands
@SuppressWarnings("ComplexMethod")
private fun SessionControlState.toAdapterList(): List<AdapterItem> {
val items = mutableListOf<AdapterItem>()
if (tabs.isNotEmpty()) {
items.add(AdapterItem.TabHeader)
tabs.map(AdapterItem::TabItem).forEach { items.add(it) }
if (mode == Mode.Private) {
items.add(AdapterItem.ArchiveTabs)
}
} else {
if (mode == Mode.Private) {
items.add(AdapterItem.PrivateBrowsingDescription)
}
}
if (mode == Mode.Private) { return items }
if (archivedSessions.isNotEmpty()) {
items.add(AdapterItem.SessionHeader)
archivedSessions.map(AdapterItem::SessionItem).forEach { items.add(it) }
} else {
items.add(AdapterItem.SessionPlaceholder)
}
return items
}
class SessionControlUIView(
container: ViewGroup,
actionEmitter: Observer<SessionControlAction>,
changesObservable: Observable<SessionControlChange>
) :
UIView<SessionControlState, SessionControlAction, SessionControlChange>(
container,
actionEmitter,
changesObservable
) {
override val view: RecyclerView = LayoutInflater.from(container.context)
.inflate(R.layout.component_home, container, true)
.findViewById(R.id.home_component)
private val sessionControlAdapter = SessionControlAdapter(actionEmitter)
init {
view.apply {
adapter = sessionControlAdapter
layoutManager = LinearLayoutManager(container.context)
}
}
override fun updateView() = Consumer<SessionControlState> {
sessionControlAdapter.reloadData(it.toAdapterList())
// There is a current bug in the combination of MotionLayout~alhpa4 and RecyclerView where it doesn't think
// it has to redraw itself. For some reason calling scrollBy forces this to happen every time
// https://stackoverflow.com/a/42549611
view.scrollBy(0, 0)
}
}

@ -0,0 +1,30 @@
/* 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.home.sessioncontrol.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.archive_tabs_button.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.home.sessioncontrol.onNext
class ArchiveTabsViewHolder(
view: View,
private val actionEmitter: Observer<SessionControlAction>
) : RecyclerView.ViewHolder(view) {
init {
view.save_session_button.setOnClickListener {
actionEmitter.onNext(TabAction.Archive)
}
}
companion object {
const val LAYOUT_ID = R.layout.archive_tabs_button
}
}

@ -0,0 +1,29 @@
/* 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.home.sessioncontrol.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.delete_tabs_button.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.home.sessioncontrol.onNext
class DeleteTabsViewHolder(
view: View,
private val actionEmitter: Observer<SessionControlAction>
) : RecyclerView.ViewHolder(view) {
init {
view.delete_session_button.setOnClickListener {
actionEmitter.onNext(TabAction.Archive)
}
}
companion object {
const val LAYOUT_ID = R.layout.delete_tabs_button
}
}

@ -0,0 +1,52 @@
/* 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.home.sessioncontrol.viewholders
import android.text.SpannableString
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.private_browsing_description.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.home.sessioncontrol.onNext
class PrivateBrowsingDescriptionViewHolder(
view: View,
private val actionEmitter: Observer<SessionControlAction>
) : RecyclerView.ViewHolder(view) {
init {
val resources = view.context.resources
// Format the description text to include a hyperlink
val appName = resources.getString(R.string.app_name)
view.private_session_description.text = resources.getString(R.string.private_browsing_explanation, appName)
val descriptionText = String
.format(view.private_session_description.text.toString(), System.getProperty("line.separator"))
val linkStartIndex = descriptionText.indexOf("\n\n") + 2
val linkAction = object : ClickableSpan() {
override fun onClick(widget: View?) {
actionEmitter.onNext(TabAction.PrivateBrowsingLearnMore)
}
}
val textWithLink = SpannableString(descriptionText).apply {
setSpan(linkAction, linkStartIndex, descriptionText.length, 0)
val colorSpan = ForegroundColorSpan(view.private_session_description.currentTextColor)
setSpan(colorSpan, linkStartIndex, descriptionText.length, 0)
}
view.private_session_description.movementMethod = LinkMovementMethod.getInstance()
view.private_session_description.text = textWithLink
}
companion object {
const val LAYOUT_ID = R.layout.private_browsing_description
}
}

@ -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.home.sessioncontrol.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.session_list_header.view.*
import org.mozilla.fenix.R
class SessionHeaderViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val headerText = view.header_text
init {
headerText.text = "Today"
}
companion object {
const val LAYOUT_ID = R.layout.session_list_header
}
}

@ -0,0 +1,11 @@
package org.mozilla.fenix.home.sessioncontrol.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
class SessionPlaceholderViewHolder(view: View) : RecyclerView.ViewHolder(view) {
companion object {
const val LAYOUT_ID = R.layout.session_list_empty
}
}

@ -0,0 +1,128 @@
package org.mozilla.fenix.home.sessioncontrol.viewholders
import android.graphics.Color
import android.graphics.LightingColorFilter
import android.view.View
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.session_item.*
import org.mozilla.fenix.R
import org.mozilla.fenix.home.sessioncontrol.ArchivedSession
import org.mozilla.fenix.home.sessioncontrol.ArchivedSessionAction
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.onNext
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.Calendar
import java.util.Date
private const val NUMBER_OF_URLS_TO_DISPLAY = 5
private const val LONGEST_HOST_ON_INTERNET_LENGTH = 64
private val timeFormatter = SimpleDateFormat("h:mm a", Locale.US)
private val monthFormatter = SimpleDateFormat("M", Locale.US)
private val dayFormatter = SimpleDateFormat("d", Locale.US)
private val dayOfWeekFormatter = SimpleDateFormat("EEEE", Locale.US)
val ArchivedSession.formattedSavedAt: String
get() = {
val isSameDay: (Calendar, Calendar) -> Boolean = { a, b ->
a.get(Calendar.ERA) == b.get(Calendar.ERA) &&
a.get(Calendar.YEAR) == b.get(Calendar.YEAR) &&
a.get(Calendar.DAY_OF_YEAR) == b.get(Calendar.DAY_OF_YEAR)
}
val parse: (Date) -> String = { date ->
val dateCal = Calendar.getInstance().apply { time = date }
val today = Calendar.getInstance()
val yesterday = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) }
val time = timeFormatter.format(date)
val month = monthFormatter.format(date)
val day = dayFormatter.format(date)
val dayOfWeek = dayOfWeekFormatter.format(date)
when {
isSameDay(dateCal, today) -> "Today @ $time"
isSameDay(dateCal, yesterday) -> "Yesterday @ $time"
else -> "$dayOfWeek $month/$day @ $time"
}
}
parse(Date(savedAt))
}()
val ArchivedSession.titles: String
get() = {
// Until we resolve (https://github.com/mozilla-mobile/fenix/issues/532) we
// just want to grab the host from the URL
@SuppressWarnings("TooGenericExceptionCaught")
val urlFormatter: (String) -> String = { url ->
var formattedURL = try {
URL(url).host
} catch (e: Exception) {
url
}
if (formattedURL.length > LONGEST_HOST_ON_INTERNET_LENGTH) {
formattedURL = formattedURL.take(LONGEST_HOST_ON_INTERNET_LENGTH).plus("...")
}
formattedURL
}
urls
.take(NUMBER_OF_URLS_TO_DISPLAY)
.joinToString(", ", transform = urlFormatter)
}()
val ArchivedSession.extrasLabel: Int
get() = maxOf(urls.size - NUMBER_OF_URLS_TO_DISPLAY, 0)
class SessionViewHolder(
view: View,
private val actionEmitter: Observer<SessionControlAction>,
override val containerView: View? = view
) : RecyclerView.ViewHolder(view), LayoutContainer {
private var session: ArchivedSession? = null
init {
session_item.setOnClickListener {
session?.apply { actionEmitter.onNext(ArchivedSessionAction.Select(this)) }
}
session_card_overflow_button.setOnClickListener {
session?.apply { actionEmitter.onNext(ArchivedSessionAction.MenuTapped(this)) }
}
session_card_share_button.setOnClickListener {
session?.apply { actionEmitter.onNext(ArchivedSessionAction.ShareTapped(this)) }
}
}
fun bind(session: ArchivedSession) {
this.session = session
val color = availableColors[(session.id % availableColors.size).toInt()]
session_card_thumbnail.colorFilter =
LightingColorFilter(ContextCompat.getColor(itemView.context, color), Color.BLACK)
session_card_timestamp.text = session.formattedSavedAt
session_card_titles.text = session.titles
session_card_extras.text = if (session.extrasLabel > 0) {
"+${session.extrasLabel} sites..."
} else { "" }
}
companion object {
private val availableColors =
listOf(
R.color.session_placeholder_blue,
R.color.session_placeholder_green,
R.color.session_placeholder_orange,
R.color.session_placeholder_purple,
R.color.session_placeholder_pink
)
const val LAYOUT_ID = R.layout.session_item
}
}

@ -0,0 +1,46 @@
/* 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.home.sessioncontrol.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.tab_header.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.home.sessioncontrol.onNext
class TabHeaderViewHolder(
view: View,
private val actionEmitter: Observer<SessionControlAction>
) : RecyclerView.ViewHolder(view) {
private var isPrivate = false
init {
view.apply {
add_tab_button.increaseTapArea(addTabButtonIncreaseDps)
add_tab_button.setOnClickListener {
actionEmitter.onNext(TabAction.Add)
}
val headerTextResourceId = if (isPrivate) R.string.tabs_header_private_title else R.string.tabs_header_title
header_text.text = context.getString(headerTextResourceId)
tabs_overflow_button.increaseTapArea(overflowButtonIncreaseDps)
tabs_overflow_button.setOnClickListener {
actionEmitter.onNext(TabAction.MenuTapped)
}
}
}
companion object {
const val LAYOUT_ID = R.layout.tab_header
const val addTabButtonIncreaseDps = 8
const val overflowButtonIncreaseDps = 8
}
}

@ -0,0 +1,95 @@
/* 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.home.sessioncontrol.viewholders
import android.view.View
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.tab_list_row.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import mozilla.components.browser.icons.IconRequest
import org.jetbrains.anko.image
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.home.sessioncontrol.SessionControlAction
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabAction
import org.mozilla.fenix.home.sessioncontrol.onNext
import kotlin.coroutines.CoroutineContext
class TabViewHolder(
val view: View,
actionEmitter: Observer<SessionControlAction>,
val job: Job,
override val containerView: View? = view
) :
RecyclerView.ViewHolder(view), LayoutContainer, CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.IO + job
var tab: Tab? = null
init {
item_tab.setOnClickListener {
actionEmitter.onNext(TabAction.Select(tab?.sessionId!!))
}
close_tab_button?.run {
increaseTapArea(closeButtonIncreaseDps)
setOnClickListener {
actionEmitter.onNext(TabAction.Close(tab?.sessionId!!))
}
}
}
fun bindSession(tab: Tab, position: Int) {
this.tab = tab
updateTabBackground(position)
updateUrl(tab.url)
updateSelected(tab.selected)
}
fun updateUrl(url: String) {
text_url.text = url
launch(Dispatchers.IO) {
val bitmap = favicon_image.context.components.utils.icons
.loadIcon(IconRequest(url)).await().bitmap
launch(Dispatchers.Main) {
favicon_image.setImageBitmap(bitmap)
}
}
}
fun updateSelected(selected: Boolean) {
selected_border.visibility = if (selected) View.VISIBLE else View.GONE
}
fun updateTabBackground(id: Int) {
if (tab?.thumbnail != null) {
tab_background.setImageBitmap(tab?.thumbnail)
} else {
val background = availableBackgrounds[id % availableBackgrounds.size]
tab_background.image = ContextCompat.getDrawable(view.context, background)
}
}
companion object {
const val LAYOUT_ID = R.layout.tab_list_row
const val closeButtonIncreaseDps = 12
private val availableBackgrounds = listOf(
R.drawable.sessions_01, R.drawable.sessions_02,
R.drawable.sessions_03, R.drawable.sessions_06,
R.drawable.sessions_07, R.drawable.sessions_08
)
}
}

@ -1,143 +0,0 @@
/* 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.home.sessions
import android.graphics.Color
import android.graphics.LightingColorFilter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.session_item.*
import org.mozilla.fenix.R
class SessionsAdapter(
private val actionEmitter: Observer<SessionsAction>
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
sealed class SessionListState {
data class DisplaySessions(val sessions: List<ArchivedSession>) : SessionListState()
object Empty : SessionListState()
val items: List<ArchivedSession>
get() = when (this) {
is DisplaySessions -> this.sessions
is Empty -> listOf()
}
val size: Int
get() = when (this) {
is DisplaySessions -> this.sessions.size
is Empty -> EMPTY_SIZE
}
companion object {
private const val EMPTY_SIZE = 1
}
}
private var state: SessionListState = SessionListState.Empty
fun reloadData(items: List<ArchivedSession>) {
this.state = if (items.isEmpty()) {
SessionListState.Empty
} else {
SessionListState.DisplaySessions(items)
}
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return when (viewType) {
HeaderViewHolder.LAYOUT_ID -> HeaderViewHolder(view)
EmptyListViewHolder.LAYOUT_ID -> EmptyListViewHolder(view)
SessionItemViewHolder.LAYOUT_ID -> SessionItemViewHolder(view, actionEmitter)
else -> EmptyListViewHolder(view)
}
}
override fun getItemViewType(position: Int) = when (position) {
0 -> HeaderViewHolder.LAYOUT_ID
else -> if (state is SessionListState.DisplaySessions) {
SessionItemViewHolder.LAYOUT_ID
} else {
EmptyListViewHolder.LAYOUT_ID
}
}
override fun getItemCount(): Int = state.size + 1
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderViewHolder -> holder.headerText.text = "Today"
is SessionItemViewHolder -> holder.bind(state.items[position - 1])
}
}
private class HeaderViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val headerText = view.findViewById<TextView>(R.id.header_text)
companion object {
const val LAYOUT_ID = R.layout.session_list_header
}
}
private class SessionItemViewHolder(
view: View,
private val actionEmitter: Observer<SessionsAction>,
override val containerView: View? = view
) : RecyclerView.ViewHolder(view), LayoutContainer {
private var session: ArchivedSession? = null
init {
session_item.setOnClickListener {
session?.apply { actionEmitter.onNext(SessionsAction.Select(this)) }
}
session_card_overflow_button.setOnClickListener {
session?.apply { actionEmitter.onNext(SessionsAction.MenuTapped(this)) }
}
session_card_share_button.setOnClickListener {
session?.apply { actionEmitter.onNext(SessionsAction.ShareTapped(this)) }
}
}
fun bind(session: ArchivedSession) {
this.session = session
val color = availableColors[(session.id % availableColors.size).toInt()]
session_card_thumbnail.colorFilter =
LightingColorFilter(ContextCompat.getColor(itemView.context, color), Color.BLACK)
session_card_timestamp.text = session.formattedSavedAt
session_card_titles.text = session.titles
session_card_extras.text = if (session.extrasLabel > 0) {
"+${session.extrasLabel} sites..."
} else { "" }
}
companion object {
private val availableColors =
listOf(
R.color.session_placeholder_blue,
R.color.session_placeholder_green,
R.color.session_placeholder_orange,
R.color.session_placeholder_purple,
R.color.session_placeholder_pink
)
const val LAYOUT_ID = R.layout.session_item
}
}
private class EmptyListViewHolder(view: View) : RecyclerView.ViewHolder(view) {
companion object {
const val LAYOUT_ID = R.layout.session_list_empty
}
}
}

@ -1,124 +0,0 @@
/* 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.home.sessions
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.feature.session.bundling.SessionBundle
import org.mozilla.fenix.mvi.Action
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.ViewState
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
data class ArchivedSession(
val id: Long,
val bundle: SessionBundle,
private val savedAt: Long,
private val _urls: List<String>
) {
val formattedSavedAt by lazy {
val isSameDay: (Calendar, Calendar) -> Boolean = { a, b ->
a.get(Calendar.ERA) == b.get(Calendar.ERA) &&
a.get(Calendar.YEAR) == b.get(Calendar.YEAR) &&
a.get(Calendar.DAY_OF_YEAR) == b.get(Calendar.DAY_OF_YEAR)
}
val parse: (Date) -> String = { date ->
val dateCal = Calendar.getInstance().apply { time = date }
val today = Calendar.getInstance()
val yesterday = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) }
val time = timeFormatter.format(date)
val month = monthFormatter.format(date)
val day = dayFormatter.format(date)
val dayOfWeek = dayOfWeekFormatter.format(date)
when {
isSameDay(dateCal, today) -> "Today @ $time"
isSameDay(dateCal, yesterday) -> "Yesterday @ $time"
else -> "$dayOfWeek $month/$day @ $time"
}
}
parse(Date(savedAt))
}
val titles by lazy {
// Until we resolve (https://github.com/mozilla-mobile/fenix/issues/532) we
// just want to grab the host from the URL
@SuppressWarnings("TooGenericExceptionCaught")
val urlFormatter: (String) -> String = { url ->
var formattedURL = try {
URL(url).host
} catch (e: Exception) {
url
}
if (formattedURL.length > LONGEST_HOST_ON_INTERNET_LENGTH) {
formattedURL = formattedURL.take(LONGEST_HOST_ON_INTERNET_LENGTH).plus("...")
}
formattedURL
}
_urls
.take(NUMBER_OF_URLS_TO_DISPLAY)
.joinToString(", ", transform = urlFormatter)
}
val extrasLabel = maxOf(_urls.size - NUMBER_OF_URLS_TO_DISPLAY, 0)
private companion object {
private const val NUMBER_OF_URLS_TO_DISPLAY = 5
private const val LONGEST_HOST_ON_INTERNET_LENGTH = 64
private val timeFormatter = SimpleDateFormat("h:mm a", Locale.US)
private val monthFormatter = SimpleDateFormat("M", Locale.US)
private val dayFormatter = SimpleDateFormat("d", Locale.US)
private val dayOfWeekFormatter = SimpleDateFormat("EEEE", Locale.US)
}
}
class SessionsComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
override var initialState: SessionsState = SessionsState(emptyList())
) :
UIComponent<SessionsState, SessionsAction, SessionsChange>(
bus.getManagedEmitter(SessionsAction::class.java),
bus.getSafeManagedObservable(SessionsChange::class.java)
) {
override val reducer: (SessionsState, SessionsChange) -> SessionsState = { state, change ->
when (change) {
is SessionsChange.Changed -> state.copy(archivedSessions = change.archivedSessions)
}
}
override fun initView() = SessionsUIView(container, actionEmitter, changesObservable)
val view: RecyclerView
get() = uiView.view as RecyclerView
init {
render(reducer)
}
}
data class SessionsState(val archivedSessions: List<ArchivedSession>) : ViewState
sealed class SessionsAction : Action {
data class Select(val archivedSession: ArchivedSession) : SessionsAction()
data class Delete(val archivedSession: ArchivedSession) : SessionsAction()
data class MenuTapped(val archivedSession: ArchivedSession) : SessionsAction()
data class ShareTapped(val archivedSession: ArchivedSession) : SessionsAction()
}
sealed class SessionsChange : Change {
data class Changed(val archivedSessions: List<ArchivedSession>) : SessionsChange()
}

@ -1,40 +0,0 @@
/* 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.home.sessions
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import org.mozilla.fenix.R
import org.mozilla.fenix.mvi.UIView
class SessionsUIView(
container: ViewGroup,
actionEmitter: Observer<SessionsAction>,
changesObservable: Observable<SessionsChange>
) :
UIView<SessionsState, SessionsAction, SessionsChange>(container, actionEmitter, changesObservable) {
override val view: RecyclerView = LayoutInflater.from(container.context)
.inflate(R.layout.component_sessions, container, true)
.findViewById(R.id.session_list)
private val sessionsAdapter = SessionsAdapter(actionEmitter)
init {
view.apply {
layoutManager = LinearLayoutManager(container.context)
adapter = sessionsAdapter
}
}
override fun updateView() = Consumer<SessionsState> {
sessionsAdapter.reloadData(it.archivedSessions)
}
}

@ -1,180 +0,0 @@
/* 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.home.tabs
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.tab_list_row.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import mozilla.components.browser.icons.IconRequest
import org.jetbrains.anko.image
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea
import kotlin.coroutines.CoroutineContext
class TabsAdapter(private val actionEmitter: Observer<TabsAction>) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
lateinit var job: Job
var sessions = listOf<SessionViewState>()
set(value) {
val diffResult = DiffUtil.calculateDiff(TabsDiffCallback(field, value), true)
field = value
diffResult.dispatchUpdatesTo(this@TabsAdapter)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return TabViewHolder(view, actionEmitter, job)
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
job = Job()
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
job.cancel()
}
override fun getItemViewType(position: Int) = TabViewHolder.LAYOUT_ID
override fun getItemCount(): Int = sessions.size
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is TabViewHolder -> {
holder.bindSession(sessions[position], position)
}
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList<Any>) {
if (payloads.isEmpty()) onBindViewHolder(holder, position)
else if (holder is TabViewHolder) {
val bundle = payloads[0] as Bundle
bundle.getString(tab_url)?.apply(holder::updateUrl)
bundle.getBoolean(tab_selected).apply(holder::updateSelected)
}
}
private class TabViewHolder(
val view: View,
actionEmitter: Observer<TabsAction>,
val job: Job,
override val containerView: View? = view
) :
RecyclerView.ViewHolder(view), LayoutContainer, CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.IO + job
var session: SessionViewState? = null
init {
item_tab.setOnClickListener {
actionEmitter.onNext(TabsAction.Select(session?.id!!))
}
close_tab_button?.run {
increaseTapArea(closeButtonIncreaseDps)
setOnClickListener {
actionEmitter.onNext(TabsAction.Close(session?.id!!))
}
}
}
fun bindSession(session: SessionViewState, position: Int) {
this.session = session
updateTabBackground(position)
updateUrl(session.url)
updateSelected(session.selected)
}
fun updateUrl(url: String) {
text_url.text = url
launch(IO) {
val bitmap = favicon_image.context.components.utils.icons
.loadIcon(IconRequest(url)).await().bitmap
launch(Main) {
favicon_image.setImageBitmap(bitmap)
}
}
}
fun updateSelected(selected: Boolean) {
selected_border.visibility = if (selected) View.VISIBLE else View.GONE
}
fun updateTabBackground(id: Int) {
if (session?.thumbnail != null) {
tab_background.setImageBitmap(session?.thumbnail)
} else {
val background = availableBackgrounds[id % availableBackgrounds.size]
tab_background.image = ContextCompat.getDrawable(view.context, background)
}
}
companion object {
const val closeButtonIncreaseDps = 12
const val LAYOUT_ID = R.layout.tab_list_row
}
}
companion object {
const val tab_url = "url"
const val tab_selected = "selected"
private val availableBackgrounds = listOf(
R.drawable.sessions_01, R.drawable.sessions_02,
R.drawable.sessions_03, R.drawable.sessions_06,
R.drawable.sessions_07, R.drawable.sessions_08
)
}
}
class TabsDiffCallback(
private val oldList: List<SessionViewState>,
private val newList: List<SessionViewState>
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition].id == newList[newItemPosition].id
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition] == newList[newItemPosition]
}
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
val oldSession = oldList[oldItemPosition]
val newSession = newList[newItemPosition]
val diffBundle = Bundle()
if (oldSession.url != newSession.url) {
diffBundle.putString(TabsAdapter.tab_url, newSession.url)
}
if (oldSession.selected != newSession.selected) {
diffBundle.putBoolean(TabsAdapter.tab_selected, newSession.selected)
}
return if (diffBundle.size() == 0) null else diffBundle
}
}

@ -1,61 +0,0 @@
/* 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.home.tabs
import android.graphics.Bitmap
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.component_tabs.view.*
import mozilla.components.browser.session.Session
import org.mozilla.fenix.mvi.Action
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.ViewState
class TabsComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
private val isPrivate: Boolean,
override var initialState: TabsState = TabsState(listOf())
) :
UIComponent<TabsState, TabsAction, TabsChange>(
bus.getManagedEmitter(TabsAction::class.java),
bus.getSafeManagedObservable(TabsChange::class.java)
) {
override val reducer: (TabsState, TabsChange) -> TabsState = { state, change ->
when (change) {
is TabsChange.Changed -> state.copy(sessions = change.sessions)
}
}
override fun initView() = TabsUIView(container, actionEmitter, changesObservable, isPrivate)
val tabList: RecyclerView
get() = uiView.view.tabs_list as RecyclerView
init {
render(reducer)
}
}
data class TabsState(val sessions: List<SessionViewState>) : ViewState
data class SessionViewState(val id: String, val url: String, val selected: Boolean, val thumbnail: Bitmap? = null)
fun Session.toSessionViewState(selected: Boolean): SessionViewState {
return SessionViewState(this.id, this.url, selected, this.thumbnail)
}
sealed class TabsAction : Action {
object Archive : TabsAction()
object MenuTapped : TabsAction()
data class CloseAll(val private: Boolean) : TabsAction()
data class Select(val sessionId: String) : TabsAction()
data class Close(val sessionId: String) : TabsAction()
}
sealed class TabsChange : Change {
data class Changed(val sessions: List<SessionViewState>) : TabsChange()
}

@ -1,85 +0,0 @@
/* 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.home.tabs
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.navigation.Navigation
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import kotlinx.android.synthetic.main.component_tabs.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.mvi.UIView
class TabsUIView(
container: ViewGroup,
actionEmitter: Observer<TabsAction>,
changesObservable: Observable<TabsChange>,
private val isPrivate: Boolean
) :
UIView<TabsState, TabsAction, TabsChange>(container, actionEmitter, changesObservable) {
override val view: View = LayoutInflater.from(container.context)
.inflate(R.layout.component_tabs, container, true)
private val tabsAdapter = TabsAdapter(actionEmitter)
init {
view.tabs_list.apply {
layoutManager = LinearLayoutManager(container.context)
adapter = tabsAdapter
itemAnimator = DefaultItemAnimator()
}
view.apply {
add_tab_button.increaseTapArea(HomeFragment.addTabButtonIncreaseDps)
add_tab_button.setOnClickListener {
val directions = HomeFragmentDirections.actionHomeFragmentToSearchFragment(null)
Navigation.findNavController(it).navigate(directions)
}
val headerTextResourceId = if (isPrivate) R.string.tabs_header_private_title else R.string.tabs_header_title
header_text.text = context.getString(headerTextResourceId)
tabs_overflow_button.increaseTapArea(HomeFragment.overflowButtonIncreaseDps)
tabs_overflow_button.setOnClickListener {
actionEmitter.onNext(TabsAction.MenuTapped)
}
// Using a color here is fine for now because private browsing does not have this button
save_session_button_text.apply {
val color = ContextCompat.getColor(context, R.color.save_session_button_text_color)
val drawable = ContextCompat.getDrawable(context, R.drawable.ic_archive)
drawable?.setTint(color)
this.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null)
}
delete_session_button.setOnClickListener {
actionEmitter.onNext(TabsAction.CloseAll(true))
}
save_session_button.setOnClickListener {
actionEmitter.onNext(TabsAction.Archive)
}
}
}
override fun updateView() = Consumer<TabsState> {
tabsAdapter.sessions = it.sessions
val sessionButton = if (isPrivate) view.delete_session_button else view.save_session_button
(if (it.sessions.isEmpty()) View.GONE else View.VISIBLE).also { visibility ->
view.tabs_header.visibility = visibility
sessionButton.visibility = visibility
view.tabs_list.visibility = visibility
}
}
}

@ -0,0 +1,9 @@
/* 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.lib
object Do {
inline infix fun <reified T> exhaustive(any: T?) = any
}

@ -0,0 +1,33 @@
<?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/save_session_button"
android:layout_width="match_parent"
android:layout_height="36dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:background="@drawable/button_background"
android:backgroundTint="@color/save_session_button_color"
android:clickable="true"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground"
android:padding="6dp">
<TextView
android:id="@+id/save_session_button_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:clickable="false"
android:drawableTint="@color/save_session_button_text_color"
android:drawableStart="@drawable/ic_archive"
android:drawablePadding="8dp"
android:focusable="false"
android:gravity="center"
android:textStyle="bold"
android:text="@string/session_save"
android:textColor="@color/save_session_button_text_color" />
</FrameLayout>

@ -0,0 +1,13 @@
<?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.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/home_component"
android:layout_width="0dp"
android:layout_height="0dp"
android:padding="16dp"
android:scrollbars="none"
android:clipToPadding="false" />

@ -0,0 +1,33 @@
<?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/delete_session_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:background="@drawable/button_background"
android:backgroundTint="@color/delete_session_button_background"
android:clickable="true"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground"
android:padding="6dp">
<TextView
android:id="@+id/delete_session_button_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:clickable="false"
android:drawableStart="@drawable/ic_delete"
android:drawablePadding="8dp"
android:focusable="false"
android:textStyle="bold"
android:gravity="center"
android:text="@string/session_delete"
android:textColor="@color/color_primary_dark"
android:textSize="16sp" />
</FrameLayout>

@ -89,47 +89,5 @@
android:layout_marginStart="40dp"
android:layout_marginEnd="40dp"/>
<androidx.core.widget.NestedScrollView
android:id="@+id/homeScrollView"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/homeDivider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<LinearLayout
android:id="@+id/homeContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:descendantFocusability="blocksDescendants">
<LinearLayout
android:id="@+id/private_session_description_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone">
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="?attr/toolbarTextColor"
android:text="@string/private_browsing_title"
android:layout_marginBottom="8dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/private_session_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="none"
android:gravity="center_vertical"
android:scrollHorizontally="false"
android:text="@string/private_browsing_explanation"
android:textColor="@color/off_white"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.motion.widget.MotionLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -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"
android:id="@+id/private_session_description_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical">
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="?attr/toolbarTextColor"
android:text="@string/private_browsing_title"
android:layout_marginBottom="8dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/private_session_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="none"
android:gravity="center_vertical"
android:scrollHorizontally="false"
android:text="@string/private_browsing_explanation"
android:textColor="@color/off_white"
android:textSize="14sp" />
</LinearLayout>

@ -26,9 +26,11 @@
android:scrollHorizontally="false"
android:textColor="@color/session_list_empty_fg"
android:textSize="16sp"
android:text="@string/sessions_intro_description"
app:layout_constraintVertical_bias="0"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,46 @@
<?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"
android:id="@+id/tabs_header"
android:layout_marginBottom="12dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/header_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/tabs_header_title"
android:textAppearance="@style/HeaderTextStyle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageButton
android:id="@+id/add_tab_button"
android:layout_width="@dimen/glyph_button_width"
android:layout_height="@dimen/glyph_button_height"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/add_tab"
android:src="@drawable/ic_new"
android:tint="?attr/toolbarTextColor"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/tabs_overflow_button"
app:layout_constraintTop_toTopOf="parent" />
<ImageButton
android:id="@+id/tabs_overflow_button"
android:layout_width="@dimen/glyph_button_width"
android:layout_height="@dimen/glyph_button_height"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/tab_menu"
android:src="@drawable/ic_menu"
android:tint="?attr/toolbarTextColor"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -2,7 +2,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.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/session_card"
android:layout_width="match_parent"
@ -16,12 +17,15 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/item_tab"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="wrap_content">
<ImageView
android:id="@+id/favicon_image"
android:importantForAccessibility="no"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="32dp"
android:minWidth="32dp"
android:layout_margin="10dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@ -57,6 +61,7 @@
<ImageView
android:id="@+id/tab_background"
android:importantForAccessibility="no"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_marginTop="10dp"

@ -10,7 +10,7 @@
<string name="preference_leakcanary" translatable="false">LeakCanary</string>
<!-- Strings not ready for translation -->
<string name="sessions_intro_description" translatable="false">Sessions help you return to the sites you visit often and those you\'ve just discovered. Start browsing and they\'ll begin to appear here.</string>
<!-- About content. Sentences are separated by two new lines. The first and second parameters are the name of the
application. (For example: Fenix) -->
<string name="about_content" translatable="false">

@ -11,7 +11,7 @@
motion:constraintSetEnd="@+id/end">
<OnSwipe
motion:dragDirection="dragUp"
motion:touchAnchorId="@id/homeScrollView"
motion:touchAnchorId="@id/home_component"
motion:touchAnchorSide="top" />
<KeyFrameSet>
@ -21,7 +21,6 @@
motion:framePosition="50"
motion:percentX="0.9" />
<KeyAttribute
motion:motionTarget="@id/wordmark"
motion:framePosition="50"

Loading…
Cancel
Save