diff --git a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt index da0f2ed62..58ffc82d1 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt @@ -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 exhaustive(any: T?) = any - } - companion object { private const val REQUEST_CODE_DOWNLOAD_PERMISSIONS = 1 private const val REQUEST_CODE_PROMPT_PERMISSIONS = 2 diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index ab68ea259..a527dd55a 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -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().onNext(SessionsChange.Changed(archivedSessions)) + getManagedEmitter().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() - .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() + getAutoDisposeObservable() .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().onNext( - TabsChange.Changed( + getManagedEmitter().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 } } diff --git a/app/src/main/java/org/mozilla/fenix/home/SessionBottomSheetFragment.kt b/app/src/main/java/org/mozilla/fenix/home/SessionBottomSheetFragment.kt index 73f476d2a..1e2612120 100644 --- a/app/src/main/java/org/mozilla/fenix/home/SessionBottomSheetFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/SessionBottomSheetFragment.kt @@ -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 { diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt new file mode 100644 index 000000000..535f8af5b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt @@ -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 +) : RecyclerView.Adapter() { + + var items: List = listOf() + private lateinit var job: Job + + fun reloadData(items: List) { + 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) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlComponent.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlComponent.kt new file mode 100644 index 000000000..c78e86cdd --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlComponent.kt @@ -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( + 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) +sealed class Mode { + object Normal : Mode() + object Private : Mode() +} + +data class SessionControlState( + val tabs: List, + val archivedSessions: List, + 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.onNext(tabAction: TabAction) { + onNext(SessionControlAction.Tab(tabAction)) +} + +fun Observer.onNext(archivedSessionAction: ArchivedSessionAction) { + onNext(SessionControlAction.Session(archivedSessionAction)) +} + +sealed class SessionControlChange : Change { + data class ArchivedSessionsChange(val archivedSessions: List) : SessionControlChange() + data class TabsChange(val tabs: List) : SessionControlChange() + data class ModeChange(val mode: Mode) : SessionControlChange() +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlUIView.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlUIView.kt new file mode 100644 index 000000000..b918db006 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlUIView.kt @@ -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 { + val items = mutableListOf() + + 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, + changesObservable: Observable +) : + UIView( + 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 { + 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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/ArchiveTabsViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/ArchiveTabsViewHolder.kt new file mode 100644 index 000000000..b20e13a6c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/ArchiveTabsViewHolder.kt @@ -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 +) : RecyclerView.ViewHolder(view) { + + init { + view.save_session_button.setOnClickListener { + actionEmitter.onNext(TabAction.Archive) + } + } + + companion object { + const val LAYOUT_ID = R.layout.archive_tabs_button + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/DeleteTabsViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/DeleteTabsViewHolder.kt new file mode 100644 index 000000000..62e8f5ac1 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/DeleteTabsViewHolder.kt @@ -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 +) : RecyclerView.ViewHolder(view) { + + init { + view.delete_session_button.setOnClickListener { + actionEmitter.onNext(TabAction.Archive) + } + } + companion object { + const val LAYOUT_ID = R.layout.delete_tabs_button + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/PrivateBrowsingDescriptionViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/PrivateBrowsingDescriptionViewHolder.kt new file mode 100644 index 000000000..efd3fa22e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/PrivateBrowsingDescriptionViewHolder.kt @@ -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 +) : 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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/SessionHeaderViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/SessionHeaderViewHolder.kt new file mode 100644 index 000000000..5bc76571b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/SessionHeaderViewHolder.kt @@ -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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/SessionPlaceholderViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/SessionPlaceholderViewHolder.kt new file mode 100644 index 000000000..ae93c577e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/SessionPlaceholderViewHolder.kt @@ -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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/SessionViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/SessionViewHolder.kt new file mode 100644 index 000000000..d2de9c47c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/SessionViewHolder.kt @@ -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, + 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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/TabHeaderViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/TabHeaderViewHolder.kt new file mode 100644 index 000000000..49a497674 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/TabHeaderViewHolder.kt @@ -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 +) : 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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/TabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/TabViewHolder.kt new file mode 100644 index 000000000..d4d508170 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/TabViewHolder.kt @@ -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, + 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 + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsAdapter.kt b/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsAdapter.kt deleted file mode 100644 index 8982cde72..000000000 --- a/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsAdapter.kt +++ /dev/null @@ -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 -) : RecyclerView.Adapter() { - sealed class SessionListState { - data class DisplaySessions(val sessions: List) : SessionListState() - object Empty : SessionListState() - - val items: List - 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) { - 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(R.id.header_text) - companion object { - const val LAYOUT_ID = R.layout.session_list_header - } - } - - private class SessionItemViewHolder( - view: View, - private val actionEmitter: Observer, - 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 - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsComponent.kt b/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsComponent.kt deleted file mode 100644 index 7e6ceeb63..000000000 --- a/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsComponent.kt +++ /dev/null @@ -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 -) { - 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( - 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) : 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) : SessionsChange() -} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsUIView.kt b/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsUIView.kt deleted file mode 100644 index 8fe1742a5..000000000 --- a/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsUIView.kt +++ /dev/null @@ -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, - changesObservable: Observable -) : - UIView(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 { - sessionsAdapter.reloadData(it.archivedSessions) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/home/tabs/TabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/home/tabs/TabsAdapter.kt deleted file mode 100644 index 8fcbf7f8e..000000000 --- a/app/src/main/java/org/mozilla/fenix/home/tabs/TabsAdapter.kt +++ /dev/null @@ -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) : - RecyclerView.Adapter() { - - lateinit var job: Job - - var sessions = listOf() - 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) { - 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, - 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, - private val newList: List -) : 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 - } -} diff --git a/app/src/main/java/org/mozilla/fenix/home/tabs/TabsComponent.kt b/app/src/main/java/org/mozilla/fenix/home/tabs/TabsComponent.kt deleted file mode 100644 index 9c5ecb4ca..000000000 --- a/app/src/main/java/org/mozilla/fenix/home/tabs/TabsComponent.kt +++ /dev/null @@ -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( - 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) : 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) : TabsChange() -} diff --git a/app/src/main/java/org/mozilla/fenix/home/tabs/TabsUIView.kt b/app/src/main/java/org/mozilla/fenix/home/tabs/TabsUIView.kt deleted file mode 100644 index bd16b5884..000000000 --- a/app/src/main/java/org/mozilla/fenix/home/tabs/TabsUIView.kt +++ /dev/null @@ -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, - changesObservable: Observable, - private val isPrivate: Boolean -) : - UIView(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 { - 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 - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/lib/Do.kt b/app/src/main/java/org/mozilla/fenix/lib/Do.kt new file mode 100644 index 000000000..11bd789cf --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/lib/Do.kt @@ -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 exhaustive(any: T?) = any +} diff --git a/app/src/main/res/layout/archive_tabs_button.xml b/app/src/main/res/layout/archive_tabs_button.xml new file mode 100644 index 000000000..0a86f6842 --- /dev/null +++ b/app/src/main/res/layout/archive_tabs_button.xml @@ -0,0 +1,33 @@ + + + + + + diff --git a/app/src/main/res/layout/component_home.xml b/app/src/main/res/layout/component_home.xml new file mode 100644 index 000000000..36941dea9 --- /dev/null +++ b/app/src/main/res/layout/component_home.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/delete_tabs_button.xml b/app/src/main/res/layout/delete_tabs_button.xml new file mode 100644 index 000000000..21c2d250b --- /dev/null +++ b/app/src/main/res/layout/delete_tabs_button.xml @@ -0,0 +1,33 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 0b623fe7f..d9779c86f 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -89,47 +89,5 @@ android:layout_marginStart="40dp" android:layout_marginEnd="40dp"/> - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/private_browsing_description.xml b/app/src/main/res/layout/private_browsing_description.xml new file mode 100644 index 000000000..9fc348e99 --- /dev/null +++ b/app/src/main/res/layout/private_browsing_description.xml @@ -0,0 +1,29 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/session_list_empty.xml b/app/src/main/res/layout/session_list_empty.xml index 19e88c5aa..c1c6f3901 100644 --- a/app/src/main/res/layout/session_list_empty.xml +++ b/app/src/main/res/layout/session_list_empty.xml @@ -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" /> + \ No newline at end of file diff --git a/app/src/main/res/layout/tab_header.xml b/app/src/main/res/layout/tab_header.xml new file mode 100644 index 000000000..6c0c454cc --- /dev/null +++ b/app/src/main/res/layout/tab_header.xml @@ -0,0 +1,46 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/tab_list_row.xml b/app/src/main/res/layout/tab_list_row.xml index 5193d1a2b..efc359174 100644 --- a/app/src/main/res/layout/tab_list_row.xml +++ b/app/src/main/res/layout/tab_list_row.xml @@ -2,7 +2,8 @@ - + android:layout_height="wrap_content"> @@ -57,6 +61,7 @@ LeakCanary - + 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. diff --git a/app/src/main/res/xml/home_scene.xml b/app/src/main/res/xml/home_scene.xml index b694dd895..deca9d0fb 100644 --- a/app/src/main/res/xml/home_scene.xml +++ b/app/src/main/res/xml/home_scene.xml @@ -11,7 +11,7 @@ motion:constraintSetEnd="@+id/end"> @@ -21,7 +21,6 @@ motion:framePosition="50" motion:percentX="0.9" /> -