diff --git a/app/build.gradle b/app/build.gradle index 0785a4d60..8df6b0b7e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,6 +7,7 @@ plugins { alias libs.plugins.compose.compiler alias libs.plugins.dependency.analysis alias libs.plugins.kotlin.android + alias libs.plugins.androidx.navigation.safeargs } apply from: "$project.rootDir/automation/gradle/versionCode.gradle" @@ -251,6 +252,8 @@ dependencies { implementation libs.androidx.recyclerview implementation libs.androidx.swiperefreshlayout implementation libs.androidx.work.runtime.ktx + implementation libs.androidx.navigation.fragment + implementation libs.androidx.navigation.ui implementation platform(libs.androidx.compose.bom) implementation libs.androidx.compose.foundation diff --git a/app/src/main/java/org/mozilla/reference/browser/BrowserActivity.kt b/app/src/main/java/org/mozilla/reference/browser/BrowserActivity.kt index b790eacf7..7511afe29 100644 --- a/app/src/main/java/org/mozilla/reference/browser/BrowserActivity.kt +++ b/app/src/main/java/org/mozilla/reference/browser/BrowserActivity.kt @@ -25,6 +25,7 @@ import mozilla.components.support.webextensions.WebExtensionPopupObserver import org.mozilla.reference.browser.addons.WebExtensionActionPopupActivity import org.mozilla.reference.browser.browser.BrowserFragment import org.mozilla.reference.browser.browser.CrashIntegration +import org.mozilla.reference.browser.browser.MainContainerFragment import org.mozilla.reference.browser.ext.components import org.mozilla.reference.browser.ext.isCrashReportActive @@ -33,6 +34,8 @@ import org.mozilla.reference.browser.ext.isCrashReportActive */ open class BrowserActivity : AppCompatActivity() { + private val logger = Logger("BrowserActivity") + private lateinit var crashIntegration: CrashIntegration private val sessionId: String? @@ -46,7 +49,7 @@ open class BrowserActivity : AppCompatActivity() { * Returns a new instance of [BrowserFragment] to display. */ open fun createBrowserFragment(sessionId: String?): Fragment = - BrowserFragment.create(sessionId) + MainContainerFragment.create(sessionId) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -75,7 +78,9 @@ open class BrowserActivity : AppCompatActivity() { @Suppress("MissingSuperCall", "OVERRIDE_DEPRECATION") override fun onBackPressed() { supportFragmentManager.fragments.forEach { + logger.debug("onBackPressed fragment: $it") if (it is UserInteractionHandler && it.onBackPressed()) { + logger.debug("onBackPressed UserInteractionHandler: $it") return } } diff --git a/app/src/main/java/org/mozilla/reference/browser/browser/BaseBrowserFragment.kt b/app/src/main/java/org/mozilla/reference/browser/browser/BaseBrowserFragment.kt index 073bf5f60..65b8b8e32 100644 --- a/app/src/main/java/org/mozilla/reference/browser/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/reference/browser/browser/BaseBrowserFragment.kt @@ -14,14 +14,11 @@ import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.CallSuper -import androidx.compose.ui.platform.ComposeView import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import mozilla.components.browser.state.selector.selectedTab -import mozilla.components.browser.toolbar.BrowserToolbar -import mozilla.components.compose.browser.toolbar.BrowserToolbar import mozilla.components.concept.engine.EngineView import mozilla.components.feature.app.links.AppLinksFeature import mozilla.components.feature.downloads.DownloadsFeature @@ -45,7 +42,6 @@ import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.ktx.android.view.enterImmersiveMode import mozilla.components.support.ktx.android.view.exitImmersiveMode import mozilla.components.ui.widgets.behavior.EngineViewClippingBehavior -import mozilla.components.ui.widgets.behavior.EngineViewScrollingBehavior import org.mozilla.reference.browser.BuildConfig import org.mozilla.reference.browser.R import org.mozilla.reference.browser.addons.WebExtensionPromptFeature @@ -55,7 +51,6 @@ import org.mozilla.reference.browser.ext.requireComponents import org.mozilla.reference.browser.pip.PictureInPictureIntegration import org.mozilla.reference.browser.tabs.LastTabFeature import mozilla.components.ui.widgets.behavior.ToolbarPosition as MozacEngineBehaviorToolbarPosition -import mozilla.components.ui.widgets.behavior.ViewPosition as MozacToolbarBehaviorToolbarPosition /** * Base fragment extended by [BrowserFragment] and [ExternalAppBrowserFragment]. @@ -64,7 +59,6 @@ import mozilla.components.ui.widgets.behavior.ViewPosition as MozacToolbarBehavi */ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, ActivityResultHandler { private val sessionFeature = ViewBoundFeatureWrapper() - private val toolbarIntegration = ViewBoundFeatureWrapper() private val contextMenuIntegration = ViewBoundFeatureWrapper() private val downloadsFeature = ViewBoundFeatureWrapper() private val shareDownloadsFeature = ViewBoundFeatureWrapper() @@ -84,9 +78,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit private val engineView: EngineView get() = requireView().findViewById(R.id.engineView) as EngineView - private val toolbar: BrowserToolbar - get() = requireView().findViewById(R.id.toolbar) - private val findInPageBar: FindInPageBar + protected val findInPageBar: FindInPageBar get() = requireView().findViewById(R.id.findInPageBar) private val swipeRefresh: SwipeRefreshLayout get() = requireView().findViewById(R.id.swipeRefresh) @@ -94,8 +86,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit private val backButtonHandler: List> = listOf( fullScreenFeature, findInPageIntegration, - toolbarIntegration, - +// toolbarIntegration, sessionFeature, lastTabFeature, ) @@ -114,6 +105,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit private lateinit var requestSitePermissionsLauncher: ActivityResultLauncher> private lateinit var requestPromptsPermissionsLauncher: ActivityResultLauncher> + private val logger = Logger("BaseBrowserFragment") + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestDownloadPermissionsLauncher = @@ -161,8 +154,6 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit return inflater.inflate(R.layout.fragment_browser, container, false) } - abstract val shouldUseComposeUI: Boolean - @CallSuper @Suppress("LongMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -180,28 +171,6 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit view = view, ) - (toolbar.layoutParams as? CoordinatorLayout.LayoutParams)?.apply { - behavior = EngineViewScrollingBehavior( - view.context, - null, - MozacToolbarBehaviorToolbarPosition.BOTTOM, - ) - } - toolbarIntegration.set( - feature = ToolbarIntegration( - requireContext(), - toolbar, - requireComponents.core.historyStorage, - requireComponents.core.store, - requireComponents.useCases.sessionUseCases, - requireComponents.useCases.tabsUseCases, - requireComponents.useCases.webAppUseCases, - sessionId, - ), - owner = this, - view = view, - ) - contextMenuIntegration.set( feature = ContextMenuIntegration( requireContext(), @@ -357,7 +326,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit context, null, swipeRefresh, - toolbar.height, + resources.getDimensionPixelSize(R.dimen.browser_toolbar_height), MozacEngineBehaviorToolbarPosition.BOTTOM, ) } @@ -402,28 +371,21 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit view = view, ) } - - val composeView = view.findViewById(R.id.compose_view) - if (shouldUseComposeUI) { - composeView.visibility = View.VISIBLE - composeView.setContent { BrowserToolbar() } - - val params = swipeRefresh.layoutParams as CoordinatorLayout.LayoutParams - params.topMargin = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height) - swipeRefresh.layoutParams = params - } } private fun fullScreenChanged(enabled: Boolean) { if (enabled) { activity?.enterImmersiveMode() - toolbar.visibility = View.GONE engineView.setDynamicToolbarMaxHeight(0) } else { activity?.exitImmersiveMode() - toolbar.visibility = View.VISIBLE engineView.setDynamicToolbarMaxHeight(resources.getDimensionPixelSize(R.dimen.browser_toolbar_height)) } + parentFragment?.parentFragmentManager + ?.setFragmentResult( + BROWSER_TO_MAIN_FRAGMENT_RESULT_KEY, + Bundle().apply { putBoolean(FULL_SCREEN_MODE_CHANGED, enabled) } + ) } private fun viewportFitChanged(viewportFit: Int) { @@ -434,7 +396,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit @CallSuper override fun onBackPressed(): Boolean { - return backButtonHandler.any { it.onBackPressed() } + logger.info("onBackPressed") + return backButtonHandler.any { it.onBackPressed() }.also { + logger.info("Was it handled by back button handlers? $it") + } } final override fun onHomePressed(): Boolean { @@ -452,8 +417,6 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit } companion object { - private const val SESSION_ID = "session_id" - @JvmStatic protected fun Bundle.putSessionId(sessionId: String?) { putString(SESSION_ID, sessionId) @@ -461,7 +424,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit } override fun onActivityResult(requestCode: Int, data: Intent?, resultCode: Int): Boolean { - Logger.info( + logger.info( "Fragment onActivityResult received with " + "requestCode: $requestCode, resultCode: $resultCode, data: $data", ) diff --git a/app/src/main/java/org/mozilla/reference/browser/browser/BrowserFragment.kt b/app/src/main/java/org/mozilla/reference/browser/browser/BrowserFragment.kt index ffbe5f6fa..9d9c95a43 100644 --- a/app/src/main/java/org/mozilla/reference/browser/browser/BrowserFragment.kt +++ b/app/src/main/java/org/mozilla/reference/browser/browser/BrowserFragment.kt @@ -6,96 +6,35 @@ package org.mozilla.reference.browser.browser import android.os.Bundle import android.view.View -import androidx.preference.PreferenceManager -import com.google.android.material.floatingactionbutton.FloatingActionButton +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import mozilla.components.browser.thumbnails.BrowserThumbnails -import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.concept.engine.EngineView -import mozilla.components.feature.awesomebar.AwesomeBarFeature -import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider -import mozilla.components.feature.readerview.view.ReaderViewControlsBar -import mozilla.components.feature.syncedtabs.SyncedTabsStorageSuggestionProvider -import mozilla.components.feature.tabs.WindowFeature -import mozilla.components.feature.tabs.toolbar.TabsToolbarFeature -import mozilla.components.feature.toolbar.WebExtensionToolbarFeature -import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import mozilla.components.support.base.log.logger.Logger import org.mozilla.reference.browser.R -import org.mozilla.reference.browser.ext.components import org.mozilla.reference.browser.ext.requireComponents -import org.mozilla.reference.browser.search.AwesomeBarWrapper -import org.mozilla.reference.browser.tabs.TabsTrayFragment /** * Fragment used for browsing the web within the main app. */ -class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { - private val thumbnailsFeature = ViewBoundFeatureWrapper() - private val readerViewFeature = ViewBoundFeatureWrapper() - private val webExtToolbarFeature = ViewBoundFeatureWrapper() - private val windowFeature = ViewBoundFeatureWrapper() +class BrowserFragment : BaseBrowserFragment() { + + private val logger = Logger("BrowserFragment") + private val mainContainerViewModel: MainContainerViewModel by viewModels( + ownerProducer = { requireParentFragment().requireParentFragment() }, + ) - private val awesomeBar: AwesomeBarWrapper - get() = requireView().findViewById(R.id.awesomeBar) - private val toolbar: BrowserToolbar - get() = requireView().findViewById(R.id.toolbar) + private val thumbnailsFeature = ViewBoundFeatureWrapper() private val engineView: EngineView get() = requireView().findViewById(R.id.engineView) as EngineView - private val readerViewBar: ReaderViewControlsBar - get() = requireView().findViewById(R.id.readerViewBar) - private val readerViewAppearanceButton: FloatingActionButton - get() = requireView().findViewById(R.id.readerViewAppearanceButton) - - override val shouldUseComposeUI: Boolean - get() = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean( - getString(R.string.pref_key_compose_ui), - false, - ) - @Suppress("LongMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - AwesomeBarFeature(awesomeBar, toolbar, engineView) - .addSearchProvider( - requireContext(), - requireComponents.core.store, - requireComponents.useCases.searchUseCases.defaultSearch, - fetchClient = requireComponents.core.client, - mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS, - engine = requireComponents.core.engine, - limit = 5, - filterExactMatch = true, - ) - .addSessionProvider( - resources, - requireComponents.core.store, - requireComponents.useCases.tabsUseCases.selectTab, - ) - .addHistoryProvider( - requireComponents.core.historyStorage, - requireComponents.useCases.sessionUseCases.loadUrl, - ) - .addClipboardProvider(requireContext(), requireComponents.useCases.sessionUseCases.loadUrl) - - // We cannot really add a `addSyncedTabsProvider` to `AwesomeBarFeature` coz that would create - // a dependency on feature-syncedtabs (which depends on Sync). - awesomeBar.addProviders( - SyncedTabsStorageSuggestionProvider( - requireComponents.backgroundServices.syncedTabsStorage, - requireComponents.useCases.tabsUseCases.addTab, - requireComponents.core.icons, - ), - ) - - TabsToolbarFeature( - toolbar = toolbar, - sessionId = sessionId, - store = requireComponents.core.store, - showTabs = ::showTabs, - lifecycleOwner = this, - ) - thumbnailsFeature.set( feature = BrowserThumbnails( requireContext(), @@ -106,56 +45,17 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { view = view, ) - readerViewFeature.set( - feature = ReaderViewIntegration( - requireContext(), - requireComponents.core.engine, - requireComponents.core.store, - toolbar, - readerViewBar, - readerViewAppearanceButton, - ), - owner = this, - view = view, - ) - - webExtToolbarFeature.set( - feature = WebExtensionToolbarFeature( - toolbar, - requireContext().components.core.store, - ), - owner = this, - view = view, - ) - - windowFeature.set( - feature = WindowFeature( - store = requireComponents.core.store, - tabsUseCases = requireComponents.useCases.tabsUseCases, - ), - owner = this, - view = view, - ) - - engineView.setDynamicToolbarMaxHeight(resources.getDimensionPixelSize(R.dimen.browser_toolbar_height)) - } - - private fun showTabs() { - // For now we are performing manual fragment transactions here. Once we can use the new - // navigation support library we may want to pass navigation graphs around. - activity?.supportFragmentManager?.beginTransaction()?.apply { - replace(R.id.container, TabsTrayFragment()) - commit() - } - } + val toolbarMaxHeight: Int = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height) + engineView.setDynamicToolbarMaxHeight(toolbarMaxHeight) - override fun onBackPressed(): Boolean = - readerViewFeature.onBackPressed() || super.onBackPressed() + logger.debug("ToolbarMaxHeight: $toolbarMaxHeight") - companion object { - fun create(sessionId: String? = null) = BrowserFragment().apply { - arguments = Bundle().apply { - putSessionId(sessionId) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mainContainerViewModel.toolbarOffset.collect { + logger.debug("Toolbar offset: $it") + findInPageBar.translationY = -toolbarMaxHeight + it + } } } } diff --git a/app/src/main/java/org/mozilla/reference/browser/browser/ExternalAppBrowserFragment.kt b/app/src/main/java/org/mozilla/reference/browser/browser/ExternalAppBrowserFragment.kt index a6ace260a..8cf2ce106 100644 --- a/app/src/main/java/org/mozilla/reference/browser/browser/ExternalAppBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/reference/browser/browser/ExternalAppBrowserFragment.kt @@ -32,8 +32,6 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler private val windowFeature = ViewBoundFeatureWrapper() private val hideToolbarFeature = ViewBoundFeatureWrapper() - override val shouldUseComposeUI: Boolean = false - private val toolbar: BrowserToolbar get() = requireView().findViewById(R.id.toolbar) private val engineView: EngineView diff --git a/app/src/main/java/org/mozilla/reference/browser/browser/MainContainerBrowserConstants.kt b/app/src/main/java/org/mozilla/reference/browser/browser/MainContainerBrowserConstants.kt new file mode 100644 index 000000000..3a397b661 --- /dev/null +++ b/app/src/main/java/org/mozilla/reference/browser/browser/MainContainerBrowserConstants.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.reference.browser.browser + +internal const val SESSION_ID = "sessionId" +internal const val BROWSER_TO_MAIN_FRAGMENT_RESULT_KEY = "BrowserToMainFragmentResultKey" +internal const val FULL_SCREEN_MODE_CHANGED = "FullScreenModeChanged" diff --git a/app/src/main/java/org/mozilla/reference/browser/browser/MainContainerFragment.kt b/app/src/main/java/org/mozilla/reference/browser/browser/MainContainerFragment.kt new file mode 100644 index 000000000..17e4c4b7b --- /dev/null +++ b/app/src/main/java/org/mozilla/reference/browser/browser/MainContainerFragment.kt @@ -0,0 +1,310 @@ +/* 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.reference.browser.browser + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver.OnDrawListener +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.NavHostFragment +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.launch +import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.feature.awesomebar.AwesomeBarFeature +import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider +import mozilla.components.feature.readerview.view.ReaderViewControlsBar +import mozilla.components.feature.syncedtabs.SyncedTabsStorageSuggestionProvider +import mozilla.components.feature.tabs.toolbar.TabsToolbarFeature +import mozilla.components.feature.toolbar.WebExtensionToolbarFeature +import mozilla.components.support.base.feature.UserInteractionHandler +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.ui.widgets.behavior.EngineViewScrollingBehavior +import mozilla.components.ui.widgets.behavior.ViewPosition +import org.mozilla.reference.browser.R +import org.mozilla.reference.browser.ext.components +import org.mozilla.reference.browser.ext.requireComponents +import org.mozilla.reference.browser.search.AwesomeBarWrapper +import org.mozilla.reference.browser.tabs.TabsTrayFragment + +/** + * The container fragment for the browser. This fragment is responsible for setting up the chrome, + * handling navigation events to transition between BrowserFragment and HomeFragment. + */ +class MainContainerFragment : Fragment(), UserInteractionHandler { + + private val logger = Logger("MainContainerFragment") + + private val viewModel by viewModels( + factoryProducer = { MainContainerViewModel.Factory(requireComponents.core.store) }, + ) + + // Views + private val awesomeBar: AwesomeBarWrapper + get() = requireView().findViewById(R.id.awesomeBar) + private val toolbar: BrowserToolbar + get() = requireView().findViewById(R.id.toolbar) + private val readerViewBar: ReaderViewControlsBar + get() = requireView().findViewById(R.id.readerViewBar) + private val readerViewAppearanceButton: FloatingActionButton + get() = requireView().findViewById(R.id.readerViewAppearanceButton) + + // Features + private val webExtToolbarFeature = ViewBoundFeatureWrapper() + private val toolbarIntegration = ViewBoundFeatureWrapper() + private val readerViewFeature = ViewBoundFeatureWrapper() + + private val backButtonHandlers: List> = listOf( + readerViewFeature, + toolbarIntegration, + ) + + private val translationYOnDrawFetcher by lazy { + TranslationYOnDrawFetcher( + toolbar, + onDraw = { offset -> viewModel.updateToolbarOffset(offset) } + ) + } + + private val sessionId: String? by lazy { arguments?.getString(SESSION_ID) } + + private val navHost by lazy { + childFragmentManager.findFragmentById(R.id.container) as NavHostFragment + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = inflater.inflate(R.layout.fragment_main_container, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupToolbar(view) + setupAwesomeBar() + setupTabsToolbarFeature() + setupWebExtToolbarFeature(view) + setupReaderMode(view) + + setupFragmentResultListener() + observeNavigationEvents() + } + + override fun onBackPressed(): Boolean { + logger.debug("onBackPressed") + // Checks if any of the features handled the back press + // and if any child fragments that are UserInteractionHandlers handled it + return backButtonHandlers.any { it.onBackPressed() } || + navHost.childFragmentManager.fragments + .filterIsInstance() + .any { + it.onBackPressed() + } + } + + override fun onDestroyView() { + toolbar.viewTreeObserver.removeOnDrawListener(translationYOnDrawFetcher) + super.onDestroyView() + } + + private fun setupToolbar(view: View) { + toolbar.viewTreeObserver.addOnDrawListener(translationYOnDrawFetcher) + + // toolbar.viewTreeObserver.addOnPreDrawListener { + // val currentTranslationY = toolbar.translationY + // viewModel.updateToolbarOffset(currentTranslationY) + // logger.debug("onPreDraw: $currentTranslationY") + // true + // } + + (toolbar.layoutParams as? CoordinatorLayout.LayoutParams)?.apply { + behavior = EngineViewScrollingBehavior( + view.context, + null, + ViewPosition.BOTTOM, + ) + } + + toolbarIntegration.set( + feature = ToolbarIntegration( + requireContext(), + toolbar, + requireComponents.core.historyStorage, + requireComponents.core.store, + requireComponents.useCases.sessionUseCases, + requireComponents.useCases.tabsUseCases, + requireComponents.useCases.webAppUseCases, + sessionId, + ), + owner = this, + view = view, + ) + } + + private fun setupAwesomeBar() { + // EngineView was passed to AwesomeBarFeature to update it's visibility, + // but it seems odd that awesome bar needs to know about engine view, think about decoupling this + // maybe using the same approach as Home/BrowserTab + AwesomeBarFeature(awesomeBar, toolbar, null) + .addSearchProvider( + requireContext(), + requireComponents.core.store, + requireComponents.useCases.searchUseCases.defaultSearch, + fetchClient = requireComponents.core.client, + mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS, + engine = requireComponents.core.engine, + limit = 5, + filterExactMatch = true, + ) + .addSessionProvider( + resources, + requireComponents.core.store, + requireComponents.useCases.tabsUseCases.selectTab, + ) + .addHistoryProvider( + requireComponents.core.historyStorage, + requireComponents.useCases.sessionUseCases.loadUrl, + ) + .addClipboardProvider( + requireContext(), + requireComponents.useCases.sessionUseCases.loadUrl + ) + + // We cannot really add a `addSyncedTabsProvider` to `AwesomeBarFeature` coz that would create + // a dependency on feature-syncedtabs (which depends on Sync). + awesomeBar.addProviders( + SyncedTabsStorageSuggestionProvider( + requireComponents.backgroundServices.syncedTabsStorage, + requireComponents.useCases.tabsUseCases.addTab, + requireComponents.core.icons, + ), + ) + } + + private fun setupTabsToolbarFeature() { + TabsToolbarFeature( + toolbar = toolbar, + sessionId = sessionId, + store = requireComponents.core.store, + showTabs = ::showTabs, + lifecycleOwner = this, + ) + } + + private fun setupWebExtToolbarFeature(view: View) { + webExtToolbarFeature.set( + feature = WebExtensionToolbarFeature( + toolbar, + requireContext().components.core.store, + ), + owner = this, + view = view, + ) + } + + private fun setupReaderMode(view: View) { + readerViewFeature.set( + feature = ReaderViewIntegration( + requireContext(), + requireComponents.core.engine, + requireComponents.core.store, + toolbar, + readerViewBar, + readerViewAppearanceButton, + ), + owner = this, + view = view, + ) + } + + private fun observeNavigationEvents() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.navigationEvent.collect { event -> + logger.debug("Navigation event: $event") + logger.debug("Current destination: ${navHost.navController.currentDestination}") + when (event) { + MainContainerViewModel.NavigationEvent.BrowserTab -> { + navHost.navController.navigate(R.id.browserFragment) + } + + MainContainerViewModel.NavigationEvent.Home -> { + navHost.navController.navigate(R.id.homeFragment) + } + } + } + } + } + } + + /** + * Fetches the translationY of the toolbar on every draw event. + * + * @param view The view whose translationY is to be fetched. + * @param onDraw The callback to be called on every draw event with the translationY. + */ + class TranslationYOnDrawFetcher( + private val view: View, + private val onDraw: (Float) -> Unit, + ) : OnDrawListener { + + private val logger: Logger = Logger("ToolbarOffsetListener") + + override fun onDraw() { + val currentTranslationY = view.translationY + logger.debug("onDraw: $currentTranslationY") + onDraw(currentTranslationY) + } + } + + private fun setupFragmentResultListener() { + childFragmentManager.setFragmentResultListener( + BROWSER_TO_MAIN_FRAGMENT_RESULT_KEY, + this, + ) { _, bundle -> + val isFullScreen = bundle.getBoolean(FULL_SCREEN_MODE_CHANGED, false) + toolbar.isVisible = !isFullScreen + } + } + + private fun showTabs() { + // For now we are performing manual fragment transactions here. Once we can use the new + // navigation support library we may want to pass navigation graphs around. + activity?.supportFragmentManager?.beginTransaction()?.apply { + replace(R.id.container, TabsTrayFragment()) + commit() + } + } + + /** + * @see [MainContainerFragment] + */ + companion object { + @JvmStatic + private fun Bundle.putSessionId(sessionId: String?) { + putString(SESSION_ID, sessionId) + } + + /** + * Create a new instance of [MainContainerFragment]. + * + * @param sessionId The session id to use for the browser. + */ + fun create(sessionId: String? = null) = MainContainerFragment().apply { + arguments = Bundle().apply { + putSessionId(sessionId) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/reference/browser/browser/MainContainerViewModel.kt b/app/src/main/java/org/mozilla/reference/browser/browser/MainContainerViewModel.kt new file mode 100644 index 000000000..eebae4df4 --- /dev/null +++ b/app/src/main/java/org/mozilla/reference/browser/browser/MainContainerViewModel.kt @@ -0,0 +1,108 @@ +/* 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.reference.browser.browser + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.lib.state.ext.flow +import mozilla.components.support.base.log.logger.Logger + +/** + * ViewModel for [MainContainerFragment]. Holds state that is shared between MainContainerFragment + * and it's children. + * + * @param browserStore The [BrowserStore] instance user to observe state. + */ +class MainContainerViewModel( + private val browserStore: BrowserStore, +) : ViewModel() { + + private val logger = Logger("MainContainerViewModel") + + init { + logger.debug("MainContainerViewModel created") + observeSelectedTab() + } + + private val _toolbarOffset = MutableStateFlow(0f) + val toolbarOffset: StateFlow = _toolbarOffset.asStateFlow() + + private val _navigationEvent = MutableSharedFlow() + val navigationEvent: SharedFlow = _navigationEvent.asSharedFlow() + + /** + * Update the toolbar offset state. Call when the toolbar offset changes. Children fragments + * can observe this state to update their UI based on the toolbar position. + * + * @param offset The new offset. + */ + fun updateToolbarOffset(offset: Float) { + _toolbarOffset.update { offset } + } + + private fun observeSelectedTab() { + viewModelScope.launch { + browserStore.flow() + .map { it.selectedTab } + .distinctUntilChanged() + .map { it?.content?.url } + .distinctUntilChanged() + .map { + logger.debug("Selected tab url: $it") + when (it) { + null, "about:blank" -> NavigationEvent.Home + else -> NavigationEvent.BrowserTab + } + } + .collect { + logger.debug("Navigation event: $it") + _navigationEvent.emit(it) + } + } + } + + /** + * Navigation events emitted by the ViewModel. + */ + sealed interface NavigationEvent { + /** + * Navigation event for navigating to the browser tab. + */ + data object BrowserTab : NavigationEvent + + /** + * Navigation event for navigating to the home screen. + */ + data object Home : NavigationEvent + } + + /** + * Factory for creating [MainContainerViewModel]. + * + * @param browserStore The [BrowserStore] instance used to create [MainContainerViewModel]. + */ + class Factory( + private val browserStore: BrowserStore, + ) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return MainContainerViewModel(browserStore) as T + } + } +} diff --git a/app/src/main/java/org/mozilla/reference/browser/compose/ComposeFragment.kt b/app/src/main/java/org/mozilla/reference/browser/compose/ComposeFragment.kt new file mode 100644 index 000000000..058d58873 --- /dev/null +++ b/app/src/main/java/org/mozilla/reference/browser/compose/ComposeFragment.kt @@ -0,0 +1,47 @@ +/* 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.reference.browser.compose + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment + +/** + * Base class for fragments that use Compose UI and have a 1:1 relationship with a Composable, i.e + * Fragments that do not have [View]s and [ComposeView]s defined in the XML layout. + * + * The ViewCompositionStrategy is set to [ViewCompositionStrategy.DisposeOnLifecycleDestroyed], + * meaning that the [ComposeView] will be disposed when the Fragment's view is destroyed. + * + * Read more about [ViewCompositionStrategy] here: + * https://medium.com/androiddevelopers/viewcompositionstrategy-demystefied-276427152f34 + * https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views#composition-strategy + */ +abstract class ComposeFragment : Fragment() { + + final override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = ComposeView(requireContext()).apply { + setViewCompositionStrategy( + strategy = ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner), + ) + setContent { + UI() + } + } + + /** + * The Composable UI for this fragment that will be set as the content of the [ComposeView]. + */ + @Composable + abstract fun UI() +} diff --git a/app/src/main/java/org/mozilla/reference/browser/home/HomeFragment.kt b/app/src/main/java/org/mozilla/reference/browser/home/HomeFragment.kt new file mode 100644 index 000000000..e7d2d8d05 --- /dev/null +++ b/app/src/main/java/org/mozilla/reference/browser/home/HomeFragment.kt @@ -0,0 +1,39 @@ +/* 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.reference.browser.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.mozilla.reference.browser.compose.ComposeFragment + +/** + * Fragment containing functionality for Home Tab - Top Sites, Stories, etc. + */ +class HomeFragment : ComposeFragment() { + + @Composable + override fun UI() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "This is the Home Tab", + ) + } + } + } + } +} diff --git a/app/src/main/java/org/mozilla/reference/browser/tabs/TabsTrayFragment.kt b/app/src/main/java/org/mozilla/reference/browser/tabs/TabsTrayFragment.kt index 8cf044c66..f99f8fc53 100644 --- a/app/src/main/java/org/mozilla/reference/browser/tabs/TabsTrayFragment.kt +++ b/app/src/main/java/org/mozilla/reference/browser/tabs/TabsTrayFragment.kt @@ -22,8 +22,8 @@ import mozilla.components.browser.tabstray.ViewHolderProvider import mozilla.components.browser.thumbnails.loader.ThumbnailLoader import mozilla.components.feature.tabs.tabstray.TabsFeature import mozilla.components.support.base.feature.UserInteractionHandler +import org.mozilla.reference.browser.browser.MainContainerFragment import org.mozilla.reference.browser.R -import org.mozilla.reference.browser.browser.BrowserFragment import org.mozilla.reference.browser.ext.components import org.mozilla.reference.browser.ext.requireComponents @@ -73,7 +73,7 @@ class TabsTrayFragment : Fragment(), UserInteractionHandler { private fun closeTabsTray() { activity?.supportFragmentManager?.beginTransaction()?.apply { - replace(R.id.container, BrowserFragment.create()) + replace(R.id.container, MainContainerFragment.create()) commit() } } diff --git a/app/src/main/res/layout/fragment_browser.xml b/app/src/main/res/layout/fragment_browser.xml index 30293e998..90a9ca3e5 100644 --- a/app/src/main/res/layout/fragment_browser.xml +++ b/app/src/main/res/layout/fragment_browser.xml @@ -2,7 +2,7 @@ - - - - - - - - - - - - + diff --git a/app/src/main/res/layout/fragment_main_container.xml b/app/src/main/res/layout/fragment_main_container.xml new file mode 100644 index 000000000..e449bf767 --- /dev/null +++ b/app/src/main/res/layout/fragment_main_container.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/main_container_nav_graph.xml b/app/src/main/res/navigation/main_container_nav_graph.xml new file mode 100644 index 000000000..1bcaea893 --- /dev/null +++ b/app/src/main/res/navigation/main_container_nav_graph.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/build.gradle b/build.gradle index 270950cc0..ca77310e9 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ plugins { alias libs.plugins.dependency.analysis alias libs.plugins.detekt alias libs.plugins.kotlin.android apply false + alias libs.plugins.androidx.navigation.safeargs apply false } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec102ad9d..7977b4374 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ androidx-preference = "1.2.1" androidx-recyclerview = "1.3.2" androidx-swiperefreshlayout = "1.1.0" androidx-work = "2.10.0" +androidx-navigation = "2.8.5" # AndroidX Testing androidx-test-core = "1.6.1" @@ -74,6 +75,8 @@ androidx-preference-ktx = { group = "androidx.preference", name = "preference-kt androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidx-recyclerview" } androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "androidx-swiperefreshlayout" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidx-work" } +androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "androidx-navigation" } # AndroidX Compose androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-composeBom" } @@ -187,3 +190,4 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " dependency-analysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependency-analysis" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +androidx-navigation-safeargs = { id = "androidx.navigation.safeargs", version.ref = "androidx-navigation" }