Richard Pospesel pushed to branch android-components-102.0.14-12.5-1 at The Tor Project / Applications / android-components
Commits: 52f79946 by Mugurell at 2023-03-16T12:04:07+00:00 Bug 1783561 - Stop BrowserToolbarBehavior positioning the snackbar
The snackbar is placed depending on the toolbar so the relation should be reversed. The toolbar should not know about and control the snackbar. There could be other siblings that the snackbar wants to position itself by and so removing this responsability from BrowserToolbarBehavior will help better support current and future flows.
Backport of https://github.com/mozilla-mobile/firefox-android/commit/04a464e66a95892d9ac...
- - - - - 4ec68dfa by Mugurell at 2023-03-16T12:04:07+00:00 Bug 1812518 - Allow a custom View for first party downloads
This will allow clients easily implement their own UI.
Backport of https://github.com/mozilla-mobile/firefox-android/commit/e678610bc10df96b854...
- - - - - 9589ff50 by Mugurell at 2023-03-16T12:04:07+00:00 Bug 1783561 - Allow a custom View for 3rd party downloads
This will allow clients easily implement their own UI
Backport of https://github.com/mozilla-mobile/firefox-android/commit/c53dd65718579006e17...
- - - - - ccafd6d1 by Mugurell at 2023-03-16T12:04:07+00:00 Improve prompts UX
Backport of https://github.com/mozilla-mobile/firefox-android/commit/1dc21a3786506200be1...
- - - - -
8 changed files:
- components/browser/toolbar/src/main/java/mozilla/components/browser/toolbar/behavior/BrowserToolbarBehavior.kt - components/browser/toolbar/src/test/java/mozilla/components/browser/toolbar/behavior/BrowserToolbarBehaviorTest.kt - components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadDialogFragment.kt - components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadsFeature.kt - components/feature/downloads/src/main/java/mozilla/components/feature/downloads/ext/DownloadState.kt - components/feature/downloads/src/main/java/mozilla/components/feature/downloads/ui/DownloaderAppAdapter.kt - components/feature/prompts/build.gradle - components/feature/prompts/src/main/java/mozilla/components/feature/prompts/PromptFeature.kt
Changes:
===================================== components/browser/toolbar/src/main/java/mozilla/components/browser/toolbar/behavior/BrowserToolbarBehavior.kt ===================================== @@ -6,19 +6,15 @@ package mozilla.components.browser.toolbar.behavior
import android.content.Context import android.util.AttributeSet -import android.view.Gravity import android.view.MotionEvent import android.view.View import androidx.annotation.VisibleForTesting import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat -import com.google.android.material.snackbar.Snackbar import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.concept.engine.EngineView import mozilla.components.support.ktx.android.view.findViewInHierarchy
-private const val SMALL_ELEVATION_CHANGE = 0.01f - /** * Where the toolbar is placed on the screen. */ @@ -35,7 +31,6 @@ enum class ToolbarPosition { * * This implementation will: * - Show/Hide the [BrowserToolbar] automatically when scrolling vertically. - * - On showing a [Snackbar] position it above the [BrowserToolbar]. * - Snap the [BrowserToolbar] to be hidden or visible when the user stops scrolling. */ class BrowserToolbarBehavior( @@ -128,14 +123,6 @@ class BrowserToolbarBehavior( return false // allow events to be passed to below listeners }
- override fun layoutDependsOn(parent: CoordinatorLayout, child: BrowserToolbar, dependency: View): Boolean { - if (toolbarPosition == ToolbarPosition.BOTTOM && dependency is Snackbar.SnackbarLayout) { - positionSnackbar(child, dependency) - } - - return super.layoutDependsOn(parent, child, dependency) - } - override fun onLayoutChild( parent: CoordinatorLayout, child: BrowserToolbar, @@ -179,23 +166,6 @@ class BrowserToolbarBehavior( isScrollEnabled = false }
- @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun positionSnackbar(child: View, snackbarLayout: Snackbar.SnackbarLayout) { - val params = snackbarLayout.layoutParams as CoordinatorLayout.LayoutParams - - // Position the snackbar above the toolbar so that it doesn't overlay the toolbar. - params.anchorId = child.id - params.anchorGravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL - params.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL - - snackbarLayout.layoutParams = params - - // In order to avoid the snackbar casting a shadow on the toolbar we adjust the elevation of the snackbar here. - // We still place it slightly behind the toolbar so that it will not animate over the toolbar but instead pop - // out from under the toolbar. - snackbarLayout.elevation = child.elevation - SMALL_ELEVATION_CHANGE - } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun tryToScrollVertically(distance: Float) { browserToolbar?.let { toolbar ->
===================================== components/browser/toolbar/src/test/java/mozilla/components/browser/toolbar/behavior/BrowserToolbarBehaviorTest.kt ===================================== @@ -6,14 +6,12 @@ package mozilla.components.browser.toolbar.behavior
import android.content.Context import android.graphics.Bitmap -import android.view.Gravity import android.view.MotionEvent.ACTION_DOWN import android.view.MotionEvent.ACTION_MOVE import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.android.material.snackbar.Snackbar import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.EngineView @@ -474,29 +472,6 @@ class BrowserToolbarBehaviorTest { verify(yTranslator).collapseWithAnimation(toolbar) }
- @Test - fun `Behavior will position snackbar above toolbar`() { - val behavior = BrowserToolbarBehavior(testContext, null, ToolbarPosition.BOTTOM) - - val toolbar: BrowserToolbar = mock() - doReturn(4223).`when`(toolbar).id - - val layoutParams: CoordinatorLayout.LayoutParams = CoordinatorLayout.LayoutParams(0, 0) - - val snackbarLayout: Snackbar.SnackbarLayout = mock() - doReturn(layoutParams).`when`(snackbarLayout).layoutParams - - behavior.layoutDependsOn( - parent = mock(), - child = toolbar, - dependency = snackbarLayout - ) - - assertEquals(4223, layoutParams.anchorId) - assertEquals(Gravity.TOP or Gravity.CENTER_HORIZONTAL, layoutParams.anchorGravity) - assertEquals(Gravity.TOP or Gravity.CENTER_HORIZONTAL, layoutParams.gravity) - } - @Test fun `Behavior will forceExpand when scrolling up and !shouldScroll if the touch was handled in the browser`() { val behavior = spy(BrowserToolbarBehavior(testContext, null, ToolbarPosition.BOTTOM))
===================================== components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadDialogFragment.kt ===================================== @@ -10,7 +10,7 @@ import mozilla.components.browser.state.state.content.DownloadState import mozilla.components.feature.downloads.DownloadDialogFragment.Companion.BYTES_TO_MB_LIMIT import mozilla.components.feature.downloads.DownloadDialogFragment.Companion.KILOBYTE import mozilla.components.feature.downloads.DownloadDialogFragment.Companion.MEGABYTE -import mozilla.components.support.utils.DownloadUtils +import mozilla.components.feature.downloads.ext.realFilenameOrGuessed
/** * This is a general representation of a dialog meant to be used in collaboration with [DownloadsFeature] @@ -34,11 +34,7 @@ abstract class DownloadDialogFragment : AppCompatDialogFragment() { */ fun setDownload(download: DownloadState) { val args = arguments ?: Bundle() - args.putString( - KEY_FILE_NAME, - download.fileName - ?: DownloadUtils.guessFileName(null, download.destinationDirectory, download.url, download.contentType) - ) + args.putString(KEY_FILE_NAME, download.realFilenameOrGuessed) args.putString(KEY_URL, download.url) args.putLong(KEY_CONTENT_LENGTH, download.contentLength ?: 0) arguments = args
===================================== components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadsFeature.kt ===================================== @@ -25,6 +25,7 @@ import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.state.content.DownloadState import mozilla.components.browser.state.store.BrowserStore import mozilla.components.feature.downloads.DownloadDialogFragment.Companion.FRAGMENT_TAG +import mozilla.components.feature.downloads.ext.realFilenameOrGuessed import mozilla.components.feature.downloads.manager.DownloadManager import mozilla.components.feature.downloads.manager.noop import mozilla.components.feature.downloads.manager.onDownloadStopped @@ -41,6 +42,43 @@ import mozilla.components.support.ktx.kotlin.isSameOriginAs import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import mozilla.components.support.utils.Browsers
+/** + * The name of the file to be downloaded. + */ +@JvmInline +value class Filename(val value: String) + +/** + * The size of the file to be downloaded expressed as the number of `bytes`. + * The value will be `0` if the size is unknown. + */ +@JvmInline +value class ContentSize(val value: Long) + +/** + * The list of all applications that can perform a download, including this application. + */ +@JvmInline +value class ThirdPartyDownloaderApps(val value: List<DownloaderApp>) + +/** + * Callback for when the user picked a certain application with which to download the current file. + */ +@JvmInline +value class ThirdPartyDownloaderAppChosenCallback(val value: (DownloaderApp) -> Unit) + +/** + * Callback for when the positive button of a download dialog was tapped. + */ +@JvmInline +value class PositiveActionCallback(val value: () -> Unit) + +/** + * Callback for when the negative button of a download dialog was tapped. + */ +@JvmInline +value class NegativeActionCallback(val value: () -> Unit) + /** * Feature implementation to provide download functionality for the selected * session. The feature will subscribe to the selected session and listen @@ -60,6 +98,10 @@ import mozilla.components.support.utils.Browsers * @property promptsStyling styling properties for the dialog. * @property shouldForwardToThirdParties Indicates if downloads should be forward to third party apps, * if there are multiple apps a chooser dialog will shown. + * @property customFirstPartyDownloadDialog An optional delegate for showing a dialog for a download + * that will be processed by the current application. + * @property customThirdPartyDownloadDialog An optional delegate for showing a dialog for a download + * that can be processed by multiple installed applications including the current one. */ @Suppress("LongParameterList", "LargeClass") class DownloadsFeature( @@ -73,7 +115,11 @@ class DownloadsFeature( private val tabId: String? = null, private val fragmentManager: FragmentManager? = null, private val promptsStyling: PromptsStyling? = null, - private val shouldForwardToThirdParties: () -> Boolean = { false } + private val shouldForwardToThirdParties: () -> Boolean = { false }, + private val customFirstPartyDownloadDialog: + ((Filename, ContentSize, PositiveActionCallback, NegativeActionCallback) -> Unit)? = null, + private val customThirdPartyDownloadDialog: + ((ThirdPartyDownloaderApps, ThirdPartyDownloaderAppChosenCallback, NegativeActionCallback) -> Unit)? = null, ) : LifecycleAwareFeature, PermissionsFeature {
var onDownloadStopped: onDownloadStopped @@ -159,16 +205,45 @@ class DownloadsFeature( val shouldShowAppDownloaderDialog = shouldForwardToThirdParties() && apps.size > 1
return if (shouldShowAppDownloaderDialog) { - showAppDownloaderDialog(tab, download, apps) + when (customThirdPartyDownloadDialog) { + null -> showAppDownloaderDialog(tab, download, apps) + else -> customThirdPartyDownloadDialog.invoke( + ThirdPartyDownloaderApps(apps), + ThirdPartyDownloaderAppChosenCallback { + onDownloaderAppSelected(it, tab, download) + }, + NegativeActionCallback { + useCases.cancelDownloadRequest.invoke(tab.id, download.id) + }, + ) + } + false } else { if (applicationContext.isPermissionGranted(downloadManager.permissions.asIterable())) { - if (fragmentManager != null && !download.skipConfirmation) { - showDownloadDialog(tab, download) - false - } else { - useCases.consumeDownload(tab.id, download.id) - startDownload(download) + when { + customFirstPartyDownloadDialog != null && !download.skipConfirmation -> { + customFirstPartyDownloadDialog.invoke( + Filename(download.realFilenameOrGuessed), + ContentSize(download.contentLength ?: 0), + PositiveActionCallback { + startDownload(download) + useCases.consumeDownload.invoke(tab.id, download.id) + }, + NegativeActionCallback { + useCases.cancelDownloadRequest.invoke(tab.id, download.id) + }, + ) + false + } + fragmentManager != null && !download.skipConfirmation -> { + showDownloadDialog(tab, download) + false + } + else -> { + useCases.consumeDownload(tab.id, download.id) + startDownload(download) + } } } else { onNeedToRequestPermissions(downloadManager.permissions) @@ -264,25 +339,7 @@ class DownloadsFeature( ) { appChooserDialog.setApps(apps) appChooserDialog.onAppSelected = { app -> - if (app.packageName == applicationContext.packageName) { - if (applicationContext.isPermissionGranted(downloadManager.permissions.asIterable())) { - startDownload(download) - useCases.consumeDownload(tab.id, download.id) - } else { - onNeedToRequestPermissions(downloadManager.permissions) - } - } else { - try { - applicationContext.startActivity(app.toIntent()) - } catch (error: ActivityNotFoundException) { - val errorMessage = applicationContext.getString( - R.string.mozac_feature_downloads_unable_to_open_third_party_app, - app.name - ) - Toast.makeText(applicationContext, errorMessage, Toast.LENGTH_SHORT).show() - } - useCases.consumeDownload(tab.id, download.id) - } + onDownloaderAppSelected(app, tab, download) }
appChooserDialog.onDismiss = { @@ -294,6 +351,29 @@ class DownloadsFeature( } }
+ @VisibleForTesting + internal fun onDownloaderAppSelected(app: DownloaderApp, tab: SessionState, download: DownloadState) { + if (app.packageName == applicationContext.packageName) { + if (applicationContext.isPermissionGranted(downloadManager.permissions.asIterable())) { + startDownload(download) + useCases.consumeDownload(tab.id, download.id) + } else { + onNeedToRequestPermissions(downloadManager.permissions) + } + } else { + try { + applicationContext.startActivity(app.toIntent()) + } catch (error: ActivityNotFoundException) { + val errorMessage = applicationContext.getString( + R.string.mozac_feature_downloads_unable_to_open_third_party_app, + app.name, + ) + Toast.makeText(applicationContext, errorMessage, Toast.LENGTH_SHORT).show() + } + useCases.consumeDownload(tab.id, download.id) + } + } + private fun getAppDownloaderDialog() = findPreviousAppDownloaderDialogFragment() ?: DownloadAppChooserDialog.newInstance( promptsStyling?.gravity,
===================================== components/feature/downloads/src/main/java/mozilla/components/feature/downloads/ext/DownloadState.kt ===================================== @@ -47,3 +47,6 @@ internal fun DownloadState.withResponse(headers: Headers, stream: InputStream?): contentLength = contentLength ?: headers[CONTENT_LENGTH]?.toLongOrNull() ) } + +internal val DownloadState.realFilenameOrGuessed + get() = fileName ?: DownloadUtils.guessFileName(null, destinationDirectory, url, contentType)
===================================== components/feature/downloads/src/main/java/mozilla/components/feature/downloads/ui/DownloaderAppAdapter.kt ===================================== @@ -16,10 +16,10 @@ import mozilla.components.feature.downloads.R /** * An adapter for displaying the applications that can perform downloads. */ -internal class DownloaderAppAdapter( +class DownloaderAppAdapter( context: Context, private val apps: List<DownloaderApp>, - val onAppSelected: ((DownloaderApp) -> Unit) + val onAppSelected: ((DownloaderApp) -> Unit), ) : RecyclerView.Adapter<DownloaderAppViewHolder>() {
private val inflater = LayoutInflater.from(context) @@ -49,11 +49,14 @@ internal class DownloaderAppAdapter( /** * View holder for a [DownloaderApp] item. */ -internal class DownloaderAppViewHolder( +class DownloaderAppViewHolder( itemView: View, val nameLabel: TextView, - val iconImage: ImageView + val iconImage: ImageView, ) : RecyclerView.ViewHolder(itemView) { + /** + * Show a certain downloader application in the current View. + */ fun bind(app: DownloaderApp, onAppSelected: ((DownloaderApp) -> Unit)) { itemView.app = app itemView.setOnClickListener {
===================================== components/feature/prompts/build.gradle ===================================== @@ -31,6 +31,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { dependencies { implementation project(':browser-state') implementation project(':concept-engine') + implementation project(':feature-session') implementation project(':lib-state') implementation project(':support-ktx') implementation project(':support-utils') @@ -46,6 +47,7 @@ dependencies { testImplementation Dependencies.testing_coroutines testImplementation Dependencies.testing_robolectric testImplementation Dependencies.testing_mockito + testImplementation project(':feature-session') testImplementation project(':support-test') testImplementation project(':support-test-libstate')
===================================== components/feature/prompts/src/main/java/mozilla/components/feature/prompts/PromptFeature.kt ===================================== @@ -71,6 +71,8 @@ import mozilla.components.feature.prompts.login.LoginExceptions import mozilla.components.feature.prompts.login.LoginPicker import mozilla.components.feature.prompts.share.DefaultShareDelegate import mozilla.components.feature.prompts.share.ShareDelegate +import mozilla.components.feature.session.SessionUseCases +import mozilla.components.feature.session.SessionUseCases.ExitFullScreenUseCase import mozilla.components.lib.state.ext.flowScoped import mozilla.components.support.base.feature.ActivityResultHandler import mozilla.components.support.base.feature.LifecycleAwareFeature @@ -111,6 +113,7 @@ internal const val FRAGMENT_TAG = "mozac_feature_prompt_dialog" * @property fragmentManager The [FragmentManager] to be used when displaying * a dialog (fragment). * @property shareDelegate Delegate used to display share sheet. + * @property exitFullscreenUsecase Usecase allowing to exit browser tabs' fullscreen mode. * @property loginStorageDelegate Delegate used to access login storage. If null, * 'save login'prompts will not be shown. * @property isSaveLoginEnabled A callback invoked when a login prompt is triggered. If false, @@ -144,6 +147,7 @@ class PromptFeature private constructor( private var customTabId: String?, private val fragmentManager: FragmentManager, private val shareDelegate: ShareDelegate, + private val exitFullscreenUsecase: ExitFullScreenUseCase = SessionUseCases(store).exitFullscreen, override val creditCardValidationDelegate: CreditCardValidationDelegate? = null, override val loginValidationDelegate: LoginValidationDelegate? = null, private val isSaveLoginEnabled: () -> Boolean = { false }, @@ -184,6 +188,7 @@ class PromptFeature private constructor( customTabId: String? = null, fragmentManager: FragmentManager, shareDelegate: ShareDelegate = DefaultShareDelegate(), + exitFullscreenUsecase: ExitFullScreenUseCase = SessionUseCases(store).exitFullscreen, creditCardValidationDelegate: CreditCardValidationDelegate? = null, loginValidationDelegate: LoginValidationDelegate? = null, isSaveLoginEnabled: () -> Boolean = { false }, @@ -202,6 +207,7 @@ class PromptFeature private constructor( customTabId = customTabId, fragmentManager = fragmentManager, shareDelegate = shareDelegate, + exitFullscreenUsecase = exitFullscreenUsecase, creditCardValidationDelegate = creditCardValidationDelegate, loginValidationDelegate = loginValidationDelegate, isSaveLoginEnabled = isSaveLoginEnabled, @@ -222,6 +228,7 @@ class PromptFeature private constructor( customTabId: String? = null, fragmentManager: FragmentManager, shareDelegate: ShareDelegate = DefaultShareDelegate(), + exitFullscreenUsecase: ExitFullScreenUseCase = SessionUseCases(store).exitFullscreen, creditCardValidationDelegate: CreditCardValidationDelegate? = null, loginValidationDelegate: LoginValidationDelegate? = null, isSaveLoginEnabled: () -> Boolean = { false }, @@ -240,6 +247,7 @@ class PromptFeature private constructor( customTabId = customTabId, fragmentManager = fragmentManager, shareDelegate = shareDelegate, + exitFullscreenUsecase = exitFullscreenUsecase, creditCardValidationDelegate = creditCardValidationDelegate, loginValidationDelegate = loginValidationDelegate, isSaveLoginEnabled = isSaveLoginEnabled, @@ -420,6 +428,10 @@ class PromptFeature private constructor( internal fun onPromptRequested(session: SessionState) { // Some requests are handle with intents session.content.promptRequests.lastOrNull()?.let { promptRequest -> + store.state.findTabOrCustomTabOrSelectedTab(customTabId)?.let { + exitFullscreenUsecase(it.id) + } + when (promptRequest) { is File -> filePicker.handleFileRequest(promptRequest) is Share -> handleShareRequest(promptRequest, session)
View it on GitLab: https://gitlab.torproject.org/tpo/applications/android-components/-/compare/...