summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/browser/menu
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
commitda4c7e7ed675c3bf405668739c3012d140856109 (patch)
treecdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/android-components/components/browser/menu
parentAdding upstream version 125.0.3. (diff)
downloadfirefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz
firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/browser/menu')
-rw-r--r--mobile/android/android-components/components/browser/menu/README.md152
-rw-r--r--mobile/android/android-components/components/browser/menu/build.gradle57
-rw-r--r--mobile/android/android-components/components/browser/menu/lint.xml7
-rw-r--r--mobile/android/android-components/components/browser/menu/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/AndroidManifest.xml6
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenu.kt320
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuAdapter.kt68
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuBuilder.kt29
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuHighlight.kt109
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuItem.kt64
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuPlacement.kt64
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuPositioning.kt143
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/WebExtensionBrowserMenu.kt141
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/WebExtensionBrowserMenuBuilder.kt166
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/ext/BrowserMenuItem.kt35
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/ext/View.kt16
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/facts/BrowserMenuFacts.kt45
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/AbstractParentBrowserMenuItem.kt66
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BackPressMenuItem.kt72
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCategory.kt81
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCheckbox.kt33
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCompoundButton.kt72
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuDivider.kt30
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableItem.kt212
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableSwitch.kt99
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageSwitch.kt59
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageText.kt111
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageTextCheckboxButton.kt111
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuItemToolbar.kt212
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuSwitch.kt33
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/CustomTooltip.kt154
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/ParentBrowserMenuItem.kt105
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/SimpleBrowserMenuHighlightableItem.kt109
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/SimpleBrowserMenuItem.kt84
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/TwoStateBrowserMenuImageText.kt133
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItem.kt168
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionPlaceholderMenuItem.kt36
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/DynamicWidthRecyclerView.kt86
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/ExpandableLayout.kt436
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/MenuButton.kt211
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyFooterLinearLayoutManager.kt74
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyHeaderLinearLayoutManager.kt73
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyItemLayoutManager.kt483
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/anim-ldrtl/menu_enter_bottom.xml22
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/anim-ldrtl/menu_enter_top.xml22
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_enter_bottom.xml22
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_enter_top.xml22
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_exit.xml10
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_browser_menu_notification_icon.xml15
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_menu_indicator.xml29
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_menu_notification.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/drawable/rounded_corner.xml9
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu.xml25
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_button.xml39
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_category.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_highlightable_item.xml92
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_highlightable_switch.xml60
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_checkbox.xml16
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_divider.xml8
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_switch.xml15
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_text.xml32
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_text_checkbox_button.xml69
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_parent_menu.xml48
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_simple.xml15
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_switch.xml10
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_toolbar.xml10
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_web_extension.xml57
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_tooltip_layout.xml27
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-am/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-an/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ar/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ast/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-az/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-azb/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ban/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-be/strings.xml15
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-bg/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-bn/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-br/strings.xml17
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-bs/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ca/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-cak/strings.xml15
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ceb/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ckb/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-co/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-cs/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-cy/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-da/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-de/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-dsb/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-el/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-en-rCA/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-en-rGB/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-eo/strings.xml15
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-es-rAR/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-es-rCL/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-es-rES/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-es-rMX/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-es/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-et/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-eu/strings.xml15
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-fa/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ff/strings.xml7
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-fi/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-fr/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-fur/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-fy-rNL/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-gd/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-gl/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-gn/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-gu-rIN/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-hi-rIN/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-hil/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-hr/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-hsb/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-hu/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-hy-rAM/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ia/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-in/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-is/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-it/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-iw/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ja/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ka/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-kaa/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-kab/strings.xml15
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-kk/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-kmr/strings.xml15
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-kn/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ko/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-kw/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ldrtl/dimens.xml7
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-lij/strings.xml7
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-lo/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-lt/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-mix/strings.xml15
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-mr/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-my/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-nb-rNO/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ne-rNP/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-nl/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-nn-rNO/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-oc/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-or/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-pa-rIN/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-pa-rPK/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-pl/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-pt-rBR/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-pt-rPT/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-rm/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ro/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ru/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-sat/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-sc/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-si/strings.xml17
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-sk/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-skr/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-sl/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-sq/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-sr/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-su/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-sv-rSE/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-szl/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ta/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-te/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-tg/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-th/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-tl/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-tok/strings.xml7
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-tr/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-trs/strings.xml13
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-tt/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-tzm/strings.xml5
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ug/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-uk/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-ur/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-uz/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-vec/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-vi/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-yo/strings.xml11
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-zh-rCN/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values-zh-rTW/strings.xml21
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values/colors.xml8
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values/dimens.xml80
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values/strings.xml24
-rw-r--r--mobile/android/android-components/components/browser/menu/src/main/res/values/style.xml106
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuAdapterTest.kt210
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuBuilderTest.kt44
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuHighlightTest.kt49
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuPositioningTest.kt163
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuTest.kt496
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/WebExtensionBrowserMenuBuilderTest.kt510
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/WebExtensionBrowserMenuTest.kt525
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/ext/BrowserMenuItemTest.kt113
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/AbstractParentBrowserMenuItemTest.kt90
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCategoryTest.kt183
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCheckboxTest.kt38
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCompoundButtonTest.kt190
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuDividerTest.kt47
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableItemTest.kt334
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableSwitchTest.kt124
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageSwitchTest.kt60
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageTextCheckboxButtonTest.kt204
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageTextTest.kt131
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuItemToolbarTest.kt439
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuSwitchTest.kt38
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/ParentBrowserMenuItemTest.kt150
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/SimpleBrowserMenuHighlightableItemTest.kt152
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/SimpleBrowserMenuItemTest.kt122
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/TwoStateBrowserMenuImageTextTest.kt87
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItemTest.kt355
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/DynamicWidthRecyclerViewTest.kt226
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/ExpandableLayoutTest.kt822
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/FakeStickyItemLayoutManager.kt32
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/FakeStickyItemsAdapter.kt23
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/MenuButtonTest.kt172
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyFooterLinearLayoutManagerTest.kt189
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyHeaderLinearLayoutManagerTest.kt155
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyItemsLinearLayoutManagerTest.kt631
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker3
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/resources/mockito-extensions/robolectric.properties1
-rw-r--r--mobile/android/android-components/components/browser/menu/src/test/resources/robolectric.properties1
222 files changed, 14711 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/browser/menu/README.md b/mobile/android/android-components/components/browser/menu/README.md
new file mode 100644
index 0000000000..4d12c5c13d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/README.md
@@ -0,0 +1,152 @@
+# [Android Components](../../../README.md) > Browser > Menu
+
+A generic menu with customizable items primarily for browser toolbars.
+
+## Usage
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
+
+```Groovy
+implementation "org.mozilla.components:browser-menu:{latest-version}"
+```
+
+### BrowserMenu
+Sample code can be found in [Sample Toolbar app](https://github.com/mozilla-mobile/android-components/tree/main/samples/toolbar).
+
+There are multiple properties that you customize of the menu browser by just adding them into your dimens.xml file.
+
+```xml
+<resources xmlns:tools="http://schemas.android.com/tools">
+
+ <!--Change how rounded the corners of the menu should be-->
+ <dimen name="mozac_browser_menu_corner_radius" tools:ignore="UnusedResources">4dp</dimen>
+
+ <!--Change how much shadow the menu should have-->
+ <dimen name="mozac_browser_menu_elevation" tools:ignore="UnusedResources">4dp</dimen>
+
+ <!--Change the width of the menu-->
+ <dimen name="mozac_browser_menu_width" tools:ignore="UnusedResources">250dp</dimen>
+
+ <!--Change the dynamic width of the menu-->
+ <dimen name="mozac_browser_menu_width_min" tools:ignore="UnusedResources">200dp</dimen>
+ <dimen name="mozac_browser_menu_width_max" tools:ignore="UnusedResources">300dp</dimen>
+
+ <!--Change the top and bottom padding of the menu-->
+ <dimen name="mozac_browser_menu_padding_vertical" tools:ignore="UnusedResources">8dp</dimen>
+
+</resources>
+```
+BrowserMenu can have a dynamic width:
+- Using the same value for `mozac_browser_menu_width_min` and `mozac_browser_menu_width_max` means BrowserMenu will have a fixed width - `mozac_browser_menu_width`.
+_This is the default behavior_.
+- Different values for `mozac_browser_menu_width_min` and `mozac_browser_menu_width_max` means BrowserMenu will have a dynamic width depending on the widest BrowserMenuItem and between the aforementioned dimensions also taking into account display width.
+
+
+### BrowserMenuDivider
+```kotlin
+
+ BrowserMenuDivider()
+
+```
+
+To customize the divider you could use a 1. Quick customization or a 2. Full customization:
+
+1) If you just want to change the height of the divider, add this item your ``dimes.xml`` file, and your
+prefer height size.
+
+```xml
+ <dimen name="mozac_browser_menu_item_divider_height" tools:ignore="UnusedResources">YOUR_HEIGHT</dimen>
+```
+2) For full customization, override the default style of the divider by adding this style item in your `style.xml` file, and customize to your liking.
+```xml
+ <style name="Mozac.Browser.Menu.Item.Divider.Horizontal" tools:ignore="UnusedResources">
+ <item name="android:background">YOUR_BACKGROUND</item>
+ <item name="android:layout_width">match_parent</item>
+ <item name="android:layout_height">YOUR_HEIGHT</item>
+ </style>
+```
+
+### BrowserMenuImageText
+```kotlin
+ BrowserMenuImageText(
+ label = "Share",
+ imageResource = R.drawable.mozac_ic_share_android_24,
+ iconTintColorResource = R.color.photonBlue90
+ ) {
+ Toast.makeText(applicationContext, "Share", Toast.LENGTH_SHORT).show()
+ }
+```
+
+To customize the menu you could use separate properties 1 or full access to the style of the menu 2:
+
+1) If you just want to change a specify property, just add one these dimen items to your ``dimes.xml`` file.
+
+```xml
+ <!--Menu Item -->
+ <!--Change the text_size for ALL menu items NOT only for the BrowserMenuImageText -->
+ <dimen name="mozac_browser_menu_item_text_size" tools:ignore="UnusedResources">16sp</dimen>
+ <!--Menu Item -->
+
+ <!--Icon-->
+ <!--Change the icon's width-->
+ <dimen name="mozac_browser_menu_item_image_text_icon_width" tools:ignore="UnusedResources">24dp</dimen> <!--Default value-->
+
+ <!--Change the icon's height-->
+ <dimen name="mozac_browser_menu_item_image_text_icon_height" tools:ignore="UnusedResources">24dp</dimen> <!--Default value-->
+
+ <!--Icon-->
+
+ <!--Label-->
+ <!--Change the separation between the label and the icon-->
+ <dimen name="mozac_browser_menu_item_image_text_label_padding_start" tools:ignore="UnusedResources">20dp</dimen> <!--Default value-->
+
+ <!--Label-->
+```
+
+2) For full customization, override the default style of menu by adding this style item in your `style.xml` file, and customize to your liking.
+
+```xml
+ <!--Change the appearance of all text menu items-->
+ <style name="Mozac.Browser.Menu.Item.Text" parent="@android:style/TextAppearance.Material.Menu" tools:ignore="UnusedResources">
+ <item name="android:background">?android:attr/selectableItemBackground</item>
+ <item name="android:textSize">@dimen/mozac_browser_menu_item_text_size</item>
+ <item name="android:ellipsize">end</item>
+ <item name="android:lines">1</item>
+ <item name="android:focusable">true</item>
+ <item name="android:clickable">true</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.ImageText.Icon" parent="" tools:ignore="UnusedResources">
+ <item name="android:layout_width">@dimen/mozac_browser_menu_item_image_text_icon_width</item>
+ <item name="android:layout_height">@dimen/mozac_browser_menu_item_image_text_icon_height</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.ImageText.Label" parent="Mozac.Browser.Menu.Item.Text" tools:ignore="UnusedResources">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:paddingStart">@dimen/mozac_browser_menu_item_image_text_label_padding_start</item>
+ </style>
+```
+
+## Facts
+
+This component emits the following [Facts](../../support/base/README.md#Facts):
+
+| Action | Item | Extras | Description |
+|--------|-------------------------|-------------------|--------------------------------------|
+| Click | web_extension_menu_item | `menuItemExtras` | Web extension menu item was clicked. |
+
+
+#### `menuItemExtras`
+
+| Key | Type | Value |
+|------|--------|----------------------------------------------------------|
+| "id" | String | Web extension id of the clicked web extension menu item. |
+
+## License
+
+ 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/
diff --git a/mobile/android/android-components/components/browser/menu/build.gradle b/mobile/android/android-components/components/browser/menu/build.gradle
new file mode 100644
index 0000000000..7e305cda83
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/build.gradle
@@ -0,0 +1,57 @@
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-parcelize'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ buildFeatures {
+ viewBinding true
+ }
+
+ namespace 'mozilla.components.browser.menu'
+}
+
+dependencies {
+ implementation project(':concept-engine')
+ implementation project(':concept-menu')
+ implementation project(':browser-state')
+ implementation project(':support-base')
+ implementation project(':support-ktx')
+ implementation project(':ui-colors')
+ implementation project(':ui-icons')
+
+ implementation ComponentsDependencies.androidx_appcompat
+ implementation ComponentsDependencies.androidx_core_ktx
+ implementation ComponentsDependencies.androidx_recyclerview
+ implementation ComponentsDependencies.androidx_cardview
+ implementation ComponentsDependencies.androidx_constraintlayout
+ implementation ComponentsDependencies.androidx_coordinatorlayout
+
+ testImplementation project(':support-test')
+
+ testImplementation ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.testing_coroutines
+
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/browser/menu/lint.xml b/mobile/android/android-components/components/browser/menu/lint.xml
new file mode 100644
index 0000000000..81bcc3bfb8
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/lint.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<lint>
+ <issue id="Overdraw" severity="ignore" />
+</lint> \ No newline at end of file
diff --git a/mobile/android/android-components/components/browser/menu/proguard-rules.pro b/mobile/android/android-components/components/browser/menu/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/mobile/android/android-components/components/browser/menu/src/main/AndroidManifest.xml b/mobile/android/android-components/components/browser/menu/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..feab9bdd95
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/AndroidManifest.xml
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <application android:supportsRtl="true" />
+</manifest> \ No newline at end of file
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenu.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenu.kt
new file mode 100644
index 0000000000..bdafd294ca
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenu.kt
@@ -0,0 +1,320 @@
+/* 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 mozilla.components.browser.menu
+
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.os.Build
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.view.accessibility.AccessibilityNodeInfo
+import android.widget.PopupWindow
+import androidx.annotation.VisibleForTesting
+import androidx.cardview.widget.CardView
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.widget.PopupWindowCompat
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import mozilla.components.browser.menu.BrowserMenu.Orientation.DOWN
+import mozilla.components.browser.menu.BrowserMenu.Orientation.UP
+import mozilla.components.browser.menu.view.DynamicWidthRecyclerView
+import mozilla.components.browser.menu.view.ExpandableLayout
+import mozilla.components.browser.menu.view.StickyItemPlacement
+import mozilla.components.browser.menu.view.StickyItemsLinearLayoutManager
+import mozilla.components.concept.menu.MenuStyle
+import mozilla.components.support.ktx.android.view.isRTL
+import mozilla.components.support.ktx.android.view.onNextGlobalLayout
+
+/**
+ * A popup menu composed of BrowserMenuItem objects.
+ */
+open class BrowserMenu internal constructor(
+ internal val adapter: BrowserMenuAdapter,
+) : View.OnAttachStateChangeListener {
+ protected var currentPopup: PopupWindow? = null
+
+ @VisibleForTesting
+ internal var menuList: RecyclerView? = null
+ internal var currAnchor: View? = null
+ internal var isShown = false
+
+ @VisibleForTesting
+ internal lateinit var menuPositioningData: MenuPositioningData
+ internal var backgroundColor: Int = Color.RED
+
+ /**
+ * @param anchor the view on which to pin the popup window.
+ * @param orientation the preferred orientation to show the popup window.
+ * @param style Custom styling for this menu.
+ * @param endOfMenuAlwaysVisible when is set to true makes sure the bottom of the menu is always visible otherwise,
+ * the top of the menu is always visible.
+ */
+ @Suppress("InflateParams", "ComplexMethod")
+ open fun show(
+ anchor: View,
+ orientation: Orientation = DOWN,
+ style: MenuStyle? = null,
+ endOfMenuAlwaysVisible: Boolean = false,
+ onDismiss: () -> Unit = {},
+ ): PopupWindow {
+ var view = LayoutInflater.from(anchor.context).inflate(R.layout.mozac_browser_menu, null)
+
+ adapter.menu = this
+
+ menuList = view.findViewById<DynamicWidthRecyclerView>(R.id.mozac_browser_menu_recyclerView).apply {
+ layoutManager = StickyItemsLinearLayoutManager.get<BrowserMenuAdapter>(
+ anchor.context,
+ StickyItemPlacement.BOTTOM,
+ false,
+ )
+
+ adapter = this@BrowserMenu.adapter
+ minWidth = style?.minWidth ?: resources.getDimensionPixelSize(R.dimen.mozac_browser_menu_width_min)
+ maxWidth = style?.maxWidth ?: resources.getDimensionPixelSize(R.dimen.mozac_browser_menu_width_max)
+ }
+
+ setColors(view, style)
+
+ menuList?.accessibilityDelegate = object : View.AccessibilityDelegate() {
+ override fun onInitializeAccessibilityNodeInfo(
+ host: View,
+ info: AccessibilityNodeInfo,
+ ) {
+ super.onInitializeAccessibilityNodeInfo(host, info)
+ info.collectionInfo =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ AccessibilityNodeInfo.CollectionInfo(
+ adapter.interactiveCount,
+ 0,
+ false,
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ AccessibilityNodeInfo.CollectionInfo.obtain(
+ adapter.interactiveCount,
+ 0,
+ false,
+ )
+ }
+ }
+ }
+
+ // Data needed to infer whether to show a collapsed menu
+ // And then to properly place it.
+ menuPositioningData = inferMenuPositioningData(
+ view as ViewGroup,
+ anchor,
+ MenuPositioningData(askedOrientation = orientation),
+ )
+
+ view = configureExpandableMenu(view, endOfMenuAlwaysVisible)
+ return getNewPopupWindow(view).apply {
+ setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ isFocusable = true
+ elevation = view.resources.getDimension(R.dimen.mozac_browser_menu_elevation)
+
+ setOnDismissListener {
+ adapter.menu = null
+ currentPopup = null
+ isShown = false
+ onDismiss()
+ }
+
+ displayPopup(menuPositioningData).also {
+ anchor.addOnAttachStateChangeListener(this@BrowserMenu)
+ currAnchor = anchor
+ }
+ }.also {
+ currentPopup = it
+ isShown = true
+ }
+ }
+
+ @VisibleForTesting
+ internal fun configureExpandableMenu(
+ view: ViewGroup,
+ endOfMenuAlwaysVisible: Boolean,
+ ): ViewGroup {
+ // If the menu is placed at the bottom it should start as collapsed.
+ if (menuPositioningData.inferredMenuPlacement is BrowserMenuPlacement.AnchoredToBottom.Dropdown ||
+ menuPositioningData.inferredMenuPlacement is BrowserMenuPlacement.AnchoredToBottom.ManualAnchoring
+ ) {
+ val collapsingMenuIndexLimit = adapter.visibleItems.indexOfFirst { it.isCollapsingMenuLimit }
+ val stickyFooterPosition = adapter.visibleItems.indexOfLast { it.isSticky }
+ if (collapsingMenuIndexLimit > 0) {
+ return ExpandableLayout.wrapContentInExpandableView(
+ view,
+ collapsingMenuIndexLimit,
+ stickyFooterPosition,
+ ) { dismiss() }
+ }
+ } else {
+ // The menu is by default set as a bottom one. Reconfigure it as a top one.
+ menuList?.layoutManager = StickyItemsLinearLayoutManager.get<BrowserMenuAdapter>(
+ view.context,
+ StickyItemPlacement.TOP,
+ )
+
+ // By default the menu is laid out from and scrolled to top - showing the top most items.
+ // For the top menu it may be desired to initially show the bottom most items.
+ menuList?.let { list ->
+ list.setEndOfMenuAlwaysVisibleCompact(
+ endOfMenuAlwaysVisible,
+ list.layoutManager as LinearLayoutManager,
+ )
+ }
+ }
+
+ return view
+ }
+
+ @VisibleForTesting
+ internal fun getNewPopupWindow(view: ViewGroup): PopupWindow {
+ // If the menu is expandable we need to give it all the possible space to expand.
+ // Also, by setting MATCH_PARENT, expanding the menu will not expand the Window
+ // of the PopupWindow which for a bottom anchored menu means glitchy animations.
+ val popupHeight = if (view is ExpandableLayout) {
+ WindowManager.LayoutParams.MATCH_PARENT
+ } else {
+ // Otherwise wrap the menu. Allowing it to be as big as the parent would result in
+ // layout issues if the menu is smaller than the available screen estate.
+ WindowManager.LayoutParams.WRAP_CONTENT
+ }
+
+ return PopupWindow(
+ view,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ popupHeight,
+ )
+ }
+
+ private fun RecyclerView.setEndOfMenuAlwaysVisibleCompact(
+ endOfMenuAlwaysVisible: Boolean,
+ layoutManager: LinearLayoutManager,
+ ) {
+ // In devices with Android 6 and below stackFromEnd is not working properly,
+ // as a result, we have to provided a backwards support.
+ // See: https://github.com/mozilla-mobile/android-components/issues/3211
+ if (endOfMenuAlwaysVisible && Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
+ scrollOnceToTheBottom(this)
+ } else {
+ layoutManager.stackFromEnd = endOfMenuAlwaysVisible
+ }
+ }
+
+ @VisibleForTesting
+ internal fun scrollOnceToTheBottom(recyclerView: RecyclerView) {
+ recyclerView.onNextGlobalLayout {
+ recyclerView.adapter?.let { recyclerView.scrollToPosition(it.itemCount - 1) }
+ }
+ }
+
+ fun dismiss() {
+ currentPopup?.dismiss()
+ }
+
+ fun invalidate() {
+ menuList?.let { adapter.invalidate(it) }
+ }
+
+ @VisibleForTesting
+ internal fun setColors(menuLayout: View, colorState: MenuStyle?) {
+ val listParent: CardView = menuLayout.findViewById(R.id.mozac_browser_menu_menuView)
+ backgroundColor = colorState?.backgroundColor?.let {
+ listParent.setCardBackgroundColor(it)
+ it.defaultColor
+ } ?: listParent.cardBackgroundColor.defaultColor
+ }
+
+ companion object {
+ /**
+ * Determines the orientation to be used for a menu based on the positioning of the [parent] in the layout.
+ */
+ fun determineMenuOrientation(parent: View?): Orientation {
+ if (parent == null) {
+ return DOWN
+ }
+
+ val params = parent.layoutParams
+ return if (params is CoordinatorLayout.LayoutParams) {
+ if ((params.gravity and Gravity.BOTTOM) == Gravity.BOTTOM) {
+ UP
+ } else {
+ DOWN
+ }
+ } else {
+ DOWN
+ }
+ }
+ }
+
+ enum class Orientation(val concept: mozilla.components.concept.menu.Orientation) {
+ UP(mozilla.components.concept.menu.Orientation.UP),
+ DOWN(mozilla.components.concept.menu.Orientation.DOWN),
+ }
+
+ override fun onViewDetachedFromWindow(v: View) {
+ currentPopup?.dismiss()
+ currAnchor?.removeOnAttachStateChangeListener(this)
+ }
+
+ override fun onViewAttachedToWindow(v: View) {
+ // no-op
+ }
+}
+
+@VisibleForTesting
+internal fun PopupWindow.displayPopup(currentData: MenuPositioningData) {
+ // Try to use the preferred orientation, if doesn't fit fallback to the best fit.
+ when (currentData.inferredMenuPlacement) {
+ is BrowserMenuPlacement.AnchoredToTop.Dropdown -> showPopupWithDownOrientation(currentData)
+ is BrowserMenuPlacement.AnchoredToBottom.Dropdown -> showPopupWithUpOrientation(currentData)
+
+ is BrowserMenuPlacement.AnchoredToTop.ManualAnchoring,
+ is BrowserMenuPlacement.AnchoredToBottom.ManualAnchoring,
+ -> showAtAnchorLocation(currentData)
+ else -> {
+ // no-op
+ }
+ }
+}
+
+@VisibleForTesting
+internal fun PopupWindow.showPopupWithUpOrientation(menuPositioningData: MenuPositioningData) {
+ val anchor = menuPositioningData.inferredMenuPlacement!!.anchor
+ val xOffset = if (anchor.isRTL) -anchor.width else 0
+ animationStyle = menuPositioningData.inferredMenuPlacement.animation
+
+ // Positioning the menu above and overlapping the anchor.
+ val yOffset = if (menuPositioningData.availableHeightToBottom < 0) {
+ // The anchor is partially below of the bottom of the screen, let's make the menu completely visible.
+ menuPositioningData.availableHeightToBottom - menuPositioningData.containerViewHeight
+ } else {
+ -menuPositioningData.containerViewHeight
+ }
+ showAsDropDown(anchor, xOffset, yOffset)
+}
+
+private fun PopupWindow.showPopupWithDownOrientation(menuPositioningData: MenuPositioningData) {
+ val anchor = menuPositioningData.inferredMenuPlacement!!.anchor
+ val xOffset = if (anchor.isRTL) -anchor.width else 0
+ animationStyle = menuPositioningData.inferredMenuPlacement.animation
+ // Menu should overlay the anchor.
+ showAsDropDown(anchor, xOffset, -anchor.height)
+}
+
+private fun PopupWindow.showAtAnchorLocation(menuPositioningData: MenuPositioningData) {
+ val anchor = menuPositioningData.inferredMenuPlacement!!.anchor
+ val anchorPosition = IntArray(2)
+ animationStyle = menuPositioningData.inferredMenuPlacement.animation
+
+ anchor.getLocationOnScreen(anchorPosition)
+ val (x, y) = anchorPosition
+ PopupWindowCompat.setOverlapAnchor(this, true)
+ showAtLocation(anchor, Gravity.START or Gravity.TOP, x, y)
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuAdapter.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuAdapter.kt
new file mode 100644
index 0000000000..f2a8ea954e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuAdapter.kt
@@ -0,0 +1,68 @@
+/* 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 mozilla.components.browser.menu
+
+import android.content.Context
+import android.graphics.Color
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import mozilla.components.browser.menu.view.StickyItemsAdapter
+
+/**
+ * Adapter implementation used by the browser menu to display menu items in a RecyclerView.
+ */
+internal class BrowserMenuAdapter(
+ context: Context,
+ items: List<BrowserMenuItem>,
+) : RecyclerView.Adapter<BrowserMenuItemViewHolder>(), StickyItemsAdapter {
+ var menu: BrowserMenu? = null
+
+ internal val visibleItems = items.filter { it.visible() }
+ internal val interactiveCount = visibleItems.sumOf { it.interactiveCount() }
+ private val inflater = LayoutInflater.from(context)
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
+ BrowserMenuItemViewHolder(inflater.inflate(viewType, parent, false))
+
+ override fun getItemCount() = visibleItems.size
+
+ override fun getItemViewType(position: Int): Int = visibleItems[position].getLayoutResource()
+
+ override fun onBindViewHolder(holder: BrowserMenuItemViewHolder, position: Int) {
+ visibleItems[position].bind(menu!!, holder.itemView)
+ }
+
+ fun invalidate(recyclerView: RecyclerView) {
+ visibleItems.withIndex().forEach {
+ val (index, item) = it
+ recyclerView.findViewHolderForAdapterPosition(index)?.apply {
+ item.invalidate(itemView)
+ }
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ override fun isStickyItem(position: Int): Boolean {
+ return try {
+ visibleItems[position].isSticky
+ } catch (e: IndexOutOfBoundsException) {
+ false
+ }
+ }
+
+ override fun setupStickyItem(stickyItem: View) {
+ menu?.let {
+ stickyItem.setBackgroundColor(it.backgroundColor)
+ }
+ }
+
+ override fun tearDownStickyItem(stickyItem: View) {
+ stickyItem.setBackgroundColor(Color.TRANSPARENT)
+ }
+}
+
+class BrowserMenuItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuBuilder.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuBuilder.kt
new file mode 100644
index 0000000000..fb5e917fa5
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuBuilder.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 mozilla.components.browser.menu
+
+import android.content.Context
+
+/**
+ * Helper class for building browser menus.
+ *
+ * @param items List of BrowserMenuItem objects to compose the menu from.
+ * @param extras Map of extra values that are added to emitted facts
+ * @param endOfMenuAlwaysVisible when is set to true makes sure the bottom of the menu is always visible otherwise,
+ * the top of the menu is always visible.
+ */
+open class BrowserMenuBuilder(
+ val items: List<BrowserMenuItem>,
+ val extras: Map<String, Any> = emptyMap(),
+ val endOfMenuAlwaysVisible: Boolean = false,
+) {
+ /**
+ * Builds and returns a browser menu with [items]
+ */
+ open fun build(context: Context): BrowserMenu {
+ val adapter = BrowserMenuAdapter(context, items)
+ return BrowserMenu(adapter)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuHighlight.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuHighlight.kt
new file mode 100644
index 0000000000..881eff83b5
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuHighlight.kt
@@ -0,0 +1,109 @@
+/* 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 mozilla.components.browser.menu
+
+import android.content.Context
+import androidx.annotation.ColorInt
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.core.content.ContextCompat
+import mozilla.components.browser.menu.item.NO_ID
+import mozilla.components.concept.menu.candidate.HighPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.LowPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.MenuEffect
+
+/**
+ * Describes how to display a [mozilla.components.browser.menu.item.BrowserMenuHighlightableItem]
+ * when it is highlighted.
+ */
+sealed class BrowserMenuHighlight {
+ abstract val label: String?
+ abstract val canPropagate: Boolean
+
+ /**
+ * Converts the highlight into a corresponding [MenuEffect] from concept-menu.
+ */
+ abstract fun asEffect(context: Context): MenuEffect
+
+ /**
+ * Displays a notification dot.
+ * Used for highlighting new features to the user, such as what's new or a recommended feature.
+ *
+ * @property notificationTint Tint for the notification dot displayed on the icon and menu button.
+ * @property label Label to override the normal label of the menu item.
+ * @property canPropagate Indicate whether other components should consider this highlight when
+ * displaying their own highlight.
+ */
+ data class LowPriority(
+ @ColorInt val notificationTint: Int,
+ override val label: String? = null,
+ override val canPropagate: Boolean = true,
+ ) : BrowserMenuHighlight() {
+ override fun asEffect(context: Context) = LowPriorityHighlightEffect(
+ notificationTint = notificationTint,
+ )
+ }
+
+ /**
+ * Changes the background of the menu item.
+ * Used for errors that require user attention, like sync errors.
+ *
+ * @property backgroundTint Tint for the menu item background color.
+ * Also used to highlight the menu button.
+ * @property label Label to override the normal label of the menu item.
+ * @property endImageResource Icon to display at the end of the menu item when highlighted.
+ * @property canPropagate Indicate whether other components should consider this highlight when
+ * displaying their own highlight.
+ */
+ data class HighPriority(
+ @ColorInt val backgroundTint: Int,
+ override val label: String? = null,
+ val endImageResource: Int = NO_ID,
+ override val canPropagate: Boolean = true,
+ ) : BrowserMenuHighlight() {
+ override fun asEffect(context: Context) = HighPriorityHighlightEffect(
+ backgroundTint = backgroundTint,
+ )
+ }
+
+ /**
+ * Described how to display a highlightable menu item when it is highlighted.
+ * Replaced by [LowPriority] and [HighPriority] which lets a priority be specified.
+ * This class only exists so that [mozilla.components.browser.menu.item.BrowserMenuHighlightableItem.Highlight]
+ * can subclass it.
+ *
+ * @property canPropagate Indicate whether other components should consider this highlight when
+ * displaying their own highlight.
+ */
+ @Deprecated("Replace with LowPriority or HighPriority highlight")
+ open class ClassicHighlight(
+ @DrawableRes val startImageResource: Int,
+ @DrawableRes val endImageResource: Int,
+ @DrawableRes val backgroundResource: Int,
+ @ColorRes val colorResource: Int,
+ override val canPropagate: Boolean = true,
+ ) : BrowserMenuHighlight() {
+ override val label: String? = null
+
+ override fun asEffect(context: Context) = HighPriorityHighlightEffect(
+ backgroundTint = ContextCompat.getColor(context, colorResource),
+ )
+ }
+}
+
+/**
+ * Indicates that a menu item shows a highlight.
+ */
+interface HighlightableMenuItem {
+ /**
+ * Highlight object representing how the menu item will be displayed when highlighted.
+ */
+ val highlight: BrowserMenuHighlight
+
+ /**
+ * Whether or not to display the highlight
+ */
+ val isHighlighted: () -> Boolean
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuItem.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuItem.kt
new file mode 100644
index 0000000000..b9dd7114d7
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuItem.kt
@@ -0,0 +1,64 @@
+/* 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 mozilla.components.browser.menu
+
+import android.content.Context
+import android.view.View
+import mozilla.components.browser.menu.view.ExpandableLayout
+import mozilla.components.browser.menu.view.StickyItemsLinearLayoutManager
+import mozilla.components.concept.menu.candidate.MenuCandidate
+
+/**
+ * Interface to be implemented by menu items to be shown in the browser menu.
+ */
+interface BrowserMenuItem {
+ /**
+ * Lambda expression that returns true if this item should be shown in the menu. Returns false
+ * if this item should be hidden.
+ */
+ val visible: () -> Boolean
+
+ /**
+ * Lambda expression that returns the number of interactive elements in this menu item.
+ * For example, a simple item will have 1, divider will have 0, and a composite
+ * item, like a tool bar, will have several.
+ */
+ val interactiveCount: () -> Int get() = { 1 }
+
+ /**
+ * Whether this menu item can serve as the limit of a collapsing menu.
+ *
+ * @see [ExpandableLayout]
+ */
+ val isCollapsingMenuLimit: Boolean get() = false
+
+ /**
+ * Whether this menu item should not be scrollable off-screen.
+ *
+ * @see [StickyItemsLinearLayoutManager]
+ */
+ val isSticky: Boolean get() = false
+
+ /**
+ * Returns the layout resource ID of the layout to be inflated for showing a menu item of this
+ * type.
+ */
+ fun getLayoutResource(): Int
+
+ /**
+ * Called by the browser menu to display the data of this item using the passed view.
+ */
+ fun bind(menu: BrowserMenu, view: View)
+
+ /**
+ * Called by the browser menu to update the displayed data of this item using the passed view.
+ */
+ fun invalidate(view: View) = Unit
+
+ /**
+ * Converts the menu item into a menu candidate.
+ */
+ fun asCandidate(context: Context): MenuCandidate? = null
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuPlacement.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuPlacement.kt
new file mode 100644
index 0000000000..4a0c4cc06f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuPlacement.kt
@@ -0,0 +1,64 @@
+/* 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 mozilla.components.browser.menu
+
+import android.view.View
+
+/**
+ * Configuration of where and how a PopupWindow for a menu should be displayed.
+ */
+internal sealed class BrowserMenuPlacement {
+ /**
+ * Android View that the PopupWindow should be anchored to.
+ */
+ abstract val anchor: View
+
+ /**
+ * Menu position specific animation to be used when showing the PopupWindow.
+ */
+ abstract val animation: Int
+
+ /**
+ * Menu placed below the anchor. Anchored to the top.
+ */
+ class AnchoredToTop {
+ /**
+ * The PopupWindow should be anchored to the top and shown as a dropdown.
+ */
+ data class Dropdown(
+ override val anchor: View,
+ override val animation: Int = R.style.Mozac_Browser_Menu_Animation_OverflowMenuTop,
+ ) : BrowserMenuPlacement()
+
+ /**
+ * The PopupWindow should be anchored to the top and placed at a specific location.
+ */
+ data class ManualAnchoring(
+ override val anchor: View,
+ override val animation: Int = R.style.Mozac_Browser_Menu_Animation_OverflowMenuTop,
+ ) : BrowserMenuPlacement()
+ }
+
+ /**
+ * Menu placed above the anchor. Anchored to the bottom.
+ */
+ class AnchoredToBottom {
+ /**
+ * The PopupWindow should be anchored to the bottom and shown as a dropdown.
+ */
+ data class Dropdown(
+ override val anchor: View,
+ override val animation: Int = R.style.Mozac_Browser_Menu_Animation_OverflowMenuBottom,
+ ) : BrowserMenuPlacement()
+
+ /**
+ * The PopupWindow should be anchored to the bottom and placed at a specific location.
+ */
+ data class ManualAnchoring(
+ override val anchor: View,
+ override val animation: Int = R.style.Mozac_Browser_Menu_Animation_OverflowMenuBottom,
+ ) : BrowserMenuPlacement()
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuPositioning.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuPositioning.kt
new file mode 100644
index 0000000000..b6bce41f6b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenuPositioning.kt
@@ -0,0 +1,143 @@
+/* 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/. */
+
+@file:Suppress("MatchingDeclarationName")
+
+package mozilla.components.browser.menu
+
+import android.graphics.Rect
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.Px
+
+/**
+ * All data needed for menu positioning.
+ */
+internal data class MenuPositioningData(
+ /**
+ * Where and how should the menu be placed in relation to the [BrowserMenuPlacement.anchor].
+ */
+ val inferredMenuPlacement: BrowserMenuPlacement? = null,
+
+ /**
+ * The orientation asked by users of this class when initializing it.
+ */
+ val askedOrientation: BrowserMenu.Orientation = BrowserMenu.Orientation.DOWN,
+
+ /**
+ * Whether the menu fits in the space between [display top, anchor] in a top - down layout.
+ */
+ val fitsUp: Boolean = false,
+
+ /**
+ * Whether the menu fits in the space between [anchor, display top] in a top - down layout.
+ */
+ val fitsDown: Boolean = false,
+
+ /**
+ * Distance between [display top, anchor top margin]. Used for better positioning the menu.
+ */
+ @Px val availableHeightToTop: Int = 0,
+
+ /**
+ * Distance between [display bottom, anchor bottom margin]. Used for better positioning the menu.
+ */
+ @Px val availableHeightToBottom: Int = 0,
+
+ /**
+ * [View#measuredHeight] of the menu. May be bigger than the available screen height.
+ */
+ @Px val containerViewHeight: Int = 0,
+)
+
+/**
+ * Measure, calculate, obtain all data needed to know how the menu shown in a PopupWindow should be positioned.
+ *
+ * This method assumes [currentData] already contains the [MenuPositioningData.askedOrientation].
+ *
+ * @param containerView the menu layout that will be wrapped in the PopupWindow.
+ * @param anchor view the PopupWindow will be aligned to.
+ * @param currentData current known data for how the menu should be positioned.
+ *
+ * @return new [MenuPositioningData] containing the current constraints of the PopupWindow.
+ */
+internal fun inferMenuPositioningData(
+ containerView: ViewGroup,
+ anchor: View,
+ currentData: MenuPositioningData,
+): MenuPositioningData {
+ // Measure the menu allowing it to expand entirely.
+ val spec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ containerView.measure(spec, spec)
+
+ val (availableHeightToTop, availableHeightToBottom) = getMaxAvailableHeightToTopAndBottom(anchor)
+ val containerHeight = containerView.measuredHeight
+
+ val fitsUp = availableHeightToTop >= containerHeight || availableHeightToTop > availableHeightToBottom
+ val fitsDown = availableHeightToBottom >= containerHeight || availableHeightToBottom > availableHeightToTop
+
+ return inferMenuPosition(
+ anchor,
+ currentData.copy(
+ fitsUp = fitsUp,
+ fitsDown = fitsDown,
+ availableHeightToTop = availableHeightToTop,
+ availableHeightToBottom = availableHeightToBottom,
+ containerViewHeight = containerHeight,
+ ),
+ )
+}
+
+/**
+ * Infer where and how the PopupWindow should be shown based on the data available in [currentData].
+ * Should be called only once per menu to be shown.
+ *
+ * @param anchor view the PopupWindow will be aligned to.
+ * @param currentData current known data for how the menu should be positioned.
+ *
+ * @return new MenuPositioningData updated to contain the inferred [BrowserMenuPlacement]
+ */
+internal fun inferMenuPosition(anchor: View, currentData: MenuPositioningData): MenuPositioningData {
+ // Try to use the preferred orientation, if doesn't fit fallback to the best fit.
+
+ val menuPlacement: BrowserMenuPlacement =
+ if (currentData.askedOrientation == BrowserMenu.Orientation.DOWN && currentData.fitsDown) {
+ BrowserMenuPlacement.AnchoredToTop.Dropdown(anchor)
+ } else if (currentData.askedOrientation == BrowserMenu.Orientation.UP && currentData.fitsUp) {
+ BrowserMenuPlacement.AnchoredToBottom.Dropdown(anchor)
+ } else {
+ if (!currentData.fitsUp && !currentData.fitsDown) {
+ if (currentData.availableHeightToTop < currentData.availableHeightToBottom) {
+ BrowserMenuPlacement.AnchoredToTop.ManualAnchoring(anchor)
+ } else {
+ BrowserMenuPlacement.AnchoredToBottom.ManualAnchoring(anchor)
+ }
+ } else {
+ if (currentData.fitsDown) {
+ BrowserMenuPlacement.AnchoredToTop.Dropdown(anchor)
+ } else {
+ BrowserMenuPlacement.AnchoredToBottom.Dropdown(anchor)
+ }
+ }
+ }
+
+ return currentData.copy(inferredMenuPlacement = menuPlacement)
+}
+
+private fun getMaxAvailableHeightToTopAndBottom(anchor: View): Pair<Int, Int> {
+ val anchorPosition = IntArray(2)
+ val displayFrame = Rect()
+
+ val appView = anchor.rootView
+ appView.getWindowVisibleDisplayFrame(displayFrame)
+
+ anchor.getLocationOnScreen(anchorPosition)
+
+ val bottomEdge = displayFrame.bottom
+
+ val distanceToBottom = bottomEdge - (anchorPosition[1] + anchor.height)
+ val distanceToTop = anchorPosition[1] - displayFrame.top
+
+ return distanceToTop to distanceToBottom
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/WebExtensionBrowserMenu.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/WebExtensionBrowserMenu.kt
new file mode 100644
index 0000000000..afebfb14dd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/WebExtensionBrowserMenu.kt
@@ -0,0 +1,141 @@
+/* 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 mozilla.components.browser.menu
+
+import android.view.View
+import android.widget.PopupWindow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import mozilla.components.browser.menu.facts.emitOpenMenuItemFact
+import mozilla.components.browser.menu.item.WebExtensionBrowserMenuItem
+import mozilla.components.browser.state.selector.selectedTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.state.WebExtensionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.webextension.Action
+import mozilla.components.concept.menu.MenuStyle
+import mozilla.components.lib.state.ext.flowScoped
+
+/**
+ * A [BrowserMenu] capable of displaying browser and page actions from web extensions.
+ */
+class WebExtensionBrowserMenu internal constructor(
+ adapter: BrowserMenuAdapter,
+ private val store: BrowserStore,
+) : BrowserMenu(adapter) {
+ private var scope: CoroutineScope? = null
+
+ override fun show(
+ anchor: View,
+ orientation: Orientation,
+ style: MenuStyle?,
+ endOfMenuAlwaysVisible: Boolean,
+ onDismiss: () -> Unit,
+ ): PopupWindow {
+ scope = store.flowScoped { flow ->
+ flow.distinctUntilChangedBy { it.selectedTab }
+ .collect { state ->
+ getOrUpdateWebExtensionMenuItems(state, state.selectedTab)
+ invalidate()
+ }
+ }
+
+ return super.show(
+ anchor,
+ orientation,
+ style,
+ endOfMenuAlwaysVisible,
+ onDismiss,
+ ).apply {
+ setOnDismissListener {
+ adapter.menu = null
+ currentPopup = null
+ scope?.cancel()
+ webExtensionBrowserActions.clear()
+ webExtensionPageActions.clear()
+ onDismiss()
+ }
+ }
+ }
+
+ companion object {
+ internal val webExtensionBrowserActions = HashMap<String, WebExtensionBrowserMenuItem>()
+ internal val webExtensionPageActions = HashMap<String, WebExtensionBrowserMenuItem>()
+
+ internal fun getOrUpdateWebExtensionMenuItems(
+ state: BrowserState,
+ tab: SessionState? = null,
+ ): List<WebExtensionBrowserMenuItem> {
+ val menuItems = ArrayList<WebExtensionBrowserMenuItem>()
+ val extensions = state.extensions.values.toList()
+ extensions.filter { it.enabled }.sortedBy { it.name }
+ .forEach { extension ->
+ if (!extension.allowedInPrivateBrowsing && tab?.content?.private == true) {
+ return@forEach
+ }
+
+ extension.browserAction?.let { browserAction ->
+ addOrUpdateAction(
+ extension = extension,
+ globalAction = browserAction,
+ tabAction = tab?.extensionState?.get(extension.id)?.browserAction,
+ menuItems = menuItems,
+ )
+ }
+
+ extension.pageAction?.let { pageAction ->
+ val tabPageAction = tab?.extensionState?.get(extension.id)?.pageAction
+
+ // Unlike browser actions, page actions are not displayed by default (only if enabled):
+ // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/page_action
+ if (pageAction.copyWithOverride(tabPageAction).enabled == true) {
+ addOrUpdateAction(
+ extension = extension,
+ globalAction = pageAction,
+ tabAction = tabPageAction,
+ menuItems = menuItems,
+ isPageAction = true,
+ )
+ }
+ }
+ }
+
+ return menuItems
+ }
+
+ private fun addOrUpdateAction(
+ extension: WebExtensionState,
+ globalAction: Action,
+ tabAction: Action?,
+ menuItems: ArrayList<WebExtensionBrowserMenuItem>,
+ isPageAction: Boolean = false,
+ ): Boolean {
+ val actionMap = if (isPageAction) webExtensionPageActions else webExtensionBrowserActions
+
+ // Add the global browser/page action if it doesn't exist
+ val browserMenuItem = actionMap.getOrPut(extension.id) {
+ val listener = {
+ emitOpenMenuItemFact(extension.id)
+ globalAction.onClick()
+ }
+ val browserMenuItem = WebExtensionBrowserMenuItem(
+ action = globalAction,
+ listener = listener,
+ id = extension.id,
+ )
+ browserMenuItem
+ }
+
+ // Apply tab-specific override of browser/page action
+ tabAction?.let {
+ browserMenuItem.action = globalAction.copyWithOverride(it)
+ }
+
+ return menuItems.add(browserMenuItem)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/WebExtensionBrowserMenuBuilder.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/WebExtensionBrowserMenuBuilder.kt
new file mode 100644
index 0000000000..494014bf66
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/WebExtensionBrowserMenuBuilder.kt
@@ -0,0 +1,166 @@
+/* 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 mozilla.components.browser.menu
+
+import android.content.Context
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import mozilla.components.browser.menu.item.BackPressMenuItem
+import mozilla.components.browser.menu.item.BrowserMenuDivider
+import mozilla.components.browser.menu.item.BrowserMenuImageText
+import mozilla.components.browser.menu.item.NO_ID
+import mozilla.components.browser.menu.item.ParentBrowserMenuItem
+import mozilla.components.browser.menu.item.WebExtensionBrowserMenuItem
+import mozilla.components.browser.menu.item.WebExtensionPlaceholderMenuItem
+import mozilla.components.browser.state.selector.selectedTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.ui.icons.R as iconsR
+
+/**
+ * Browser menu builder with web extension support. It allows [WebExtensionBrowserMenu] to add
+ * web extension browser actions in a nested menu item. If there are no web extensions installed
+ * and @param showAddonsInMenu is true the web extension menu item would return an add-on manager menu item instead.
+ *
+ * @param store [BrowserStore] required to render web extension browser actions
+ * @param style Indicates how items should look like.
+ * @param onAddonsManagerTapped Callback to be invoked when add-ons manager menu item is selected.
+ * @param appendExtensionSubMenuAtStart Used when the menu does not have a [WebExtensionPlaceholderMenuItem]
+ * to specify the place the extensions sub-menu should be inserted. True if web extension sub menu
+ * appear at the top (start) of the menu, false if web extensions appear at the bottom of the menu.
+ * Default to false (bottom). This is also used to decide the back press menu item placement at top or bottom.
+ * @param showAddonsInMenu Whether to show the 'Add-ons' item in menu
+ */
+class WebExtensionBrowserMenuBuilder(
+ items: List<BrowserMenuItem>,
+ extras: Map<String, Any> = emptyMap(),
+ endOfMenuAlwaysVisible: Boolean = false,
+ private val store: BrowserStore,
+ private val style: Style = Style(),
+ private val onAddonsManagerTapped: () -> Unit = {},
+ private val appendExtensionSubMenuAtStart: Boolean = false,
+ private val showAddonsInMenu: Boolean = true,
+) : BrowserMenuBuilder(items, extras, endOfMenuAlwaysVisible) {
+
+ /**
+ * Builds and returns a browser menu with combination of [items] and web extension browser actions.
+ */
+ override fun build(context: Context): BrowserMenu {
+ val extensionMenuItems =
+ WebExtensionBrowserMenu.getOrUpdateWebExtensionMenuItems(store.state, store.state.selectedTab)
+
+ val finalList = items.toMutableList()
+
+ val filteredExtensionMenuItems = extensionMenuItems.filter { webExtensionBrowserMenuItem ->
+ replaceMenuPlaceholderWithExtensions(finalList, webExtensionBrowserMenuItem)
+ }
+
+ val menuItems = if (showAddonsInMenu) {
+ createAddonsMenuItems(context, finalList, filteredExtensionMenuItems)
+ } else {
+ finalList
+ }
+
+ val adapter = BrowserMenuAdapter(context, menuItems)
+ return BrowserMenu(adapter)
+ }
+
+ private fun replaceMenuPlaceholderWithExtensions(
+ items: MutableList<BrowserMenuItem>,
+ menuItem: WebExtensionBrowserMenuItem,
+ ): Boolean {
+ // Check if we have a placeholder
+ val index = items.indexOfFirst { browserMenuItem ->
+ (browserMenuItem as? WebExtensionPlaceholderMenuItem)?.id == menuItem.id
+ }
+ // Replace placeholder with corresponding web extension, and remove it from extensions menu list
+ if (index != -1) {
+ menuItem.setIconTint(
+ (items[index] as? WebExtensionPlaceholderMenuItem)?.iconTintColorResource,
+ )
+ items[index] = menuItem
+ }
+ return index == -1
+ }
+
+ private fun createAddonsMenuItems(
+ context: Context,
+ items: MutableList<BrowserMenuItem>,
+ filteredExtensionMenuItems: List<WebExtensionBrowserMenuItem>,
+ ): List<BrowserMenuItem> {
+ val addonsMenuItem = if (filteredExtensionMenuItems.isNotEmpty()) {
+ val backPressMenuItem = BackPressMenuItem(
+ contentDescription = context.getString(R.string.mozac_browser_menu_extensions_content_description),
+ label = context.getString(R.string.mozac_browser_menu_extensions),
+ imageResource = style.backPressMenuItemDrawableRes,
+ iconTintColorResource = style.webExtIconTintColorResource,
+ )
+
+ val addonsManagerMenuItem = BrowserMenuImageText(
+ label = context.getString(R.string.mozac_browser_menu_extensions_manager),
+ imageResource = style.addonsManagerMenuItemDrawableRes,
+ iconTintColorResource = style.webExtIconTintColorResource,
+ ) {
+ onAddonsManagerTapped.invoke()
+ }
+
+ val webExtSubMenuItems = if (appendExtensionSubMenuAtStart) {
+ listOf(backPressMenuItem) + BrowserMenuDivider() +
+ filteredExtensionMenuItems +
+ BrowserMenuDivider() + addonsManagerMenuItem
+ } else {
+ listOf(addonsManagerMenuItem) + BrowserMenuDivider() +
+ filteredExtensionMenuItems +
+ BrowserMenuDivider() + backPressMenuItem
+ }
+
+ val webExtBrowserMenuAdapter = BrowserMenuAdapter(context, webExtSubMenuItems)
+ val webExtMenu = WebExtensionBrowserMenu(webExtBrowserMenuAdapter, store)
+
+ ParentBrowserMenuItem(
+ label = context.getString(R.string.mozac_browser_menu_extensions),
+ imageResource = style.addonsManagerMenuItemDrawableRes,
+ iconTintColorResource = style.webExtIconTintColorResource,
+ subMenu = webExtMenu,
+ endOfMenuAlwaysVisible = endOfMenuAlwaysVisible,
+ )
+ } else {
+ BrowserMenuImageText(
+ label = context.getString(R.string.mozac_browser_menu_extensions),
+ imageResource = style.addonsManagerMenuItemDrawableRes,
+ iconTintColorResource = style.webExtIconTintColorResource,
+ ) {
+ onAddonsManagerTapped.invoke()
+ }
+ }
+ val mainMenuIndex = items.indexOfFirst { browserMenuItem ->
+ (browserMenuItem as? WebExtensionPlaceholderMenuItem)?.id ==
+ WebExtensionPlaceholderMenuItem.MAIN_EXTENSIONS_MENU_ID
+ }
+
+ return if (mainMenuIndex != -1) {
+ items[mainMenuIndex] = addonsMenuItem
+ items
+ // if we do not have a placeholder we should add the extension submenu at top or bottom
+ } else {
+ if (appendExtensionSubMenuAtStart) {
+ listOf(addonsMenuItem) + items
+ } else {
+ items + addonsMenuItem
+ }
+ }
+ }
+
+ /**
+ * Allows to customize how items should look like.
+ */
+ data class Style(
+ @ColorRes
+ val webExtIconTintColorResource: Int = NO_ID,
+ @DrawableRes
+ val backPressMenuItemDrawableRes: Int = iconsR.drawable.mozac_ic_back_24,
+ @DrawableRes
+ val addonsManagerMenuItemDrawableRes: Int = iconsR.drawable.mozac_ic_extension_24,
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/ext/BrowserMenuItem.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/ext/BrowserMenuItem.kt
new file mode 100644
index 0000000000..2411c19cc8
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/ext/BrowserMenuItem.kt
@@ -0,0 +1,35 @@
+/* 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 mozilla.components.browser.menu.ext
+
+import android.content.Context
+import mozilla.components.browser.menu.BrowserMenuHighlight
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.browser.menu.HighlightableMenuItem
+
+/**
+ * Get the highlight effect present in the list of menu items, if any.
+ */
+@Suppress("Deprecation")
+fun List<BrowserMenuItem>.getHighlight() = asSequence()
+ .filter { it.visible() }
+ .mapNotNull { it as? HighlightableMenuItem }
+ .filter { it.isHighlighted() }
+ .map { it.highlight }
+ .filter { it.canPropagate }
+ .maxByOrNull {
+ // Select the highlight with the highest priority
+ when (it) {
+ is BrowserMenuHighlight.HighPriority -> 2
+ is BrowserMenuHighlight.LowPriority -> 1
+ is BrowserMenuHighlight.ClassicHighlight -> 0
+ }
+ }
+
+/**
+ * Converts the menu items into a menu candidate list.
+ */
+fun List<BrowserMenuItem>.asCandidateList(context: Context) =
+ mapNotNull { it.asCandidate(context) }
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/ext/View.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/ext/View.kt
new file mode 100644
index 0000000000..39e0e647c7
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/ext/View.kt
@@ -0,0 +1,16 @@
+/* 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 mozilla.components.browser.menu.ext
+
+import android.util.TypedValue
+import android.view.View
+
+/**
+ * Adds ripple effect to the view
+ */
+fun View.addRippleEffect() = with(TypedValue()) {
+ context.theme.resolveAttribute(android.R.attr.selectableItemBackground, this, true)
+ setBackgroundResource(resourceId)
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/facts/BrowserMenuFacts.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/facts/BrowserMenuFacts.kt
new file mode 100644
index 0000000000..e35e3840f0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/facts/BrowserMenuFacts.kt
@@ -0,0 +1,45 @@
+/* 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 mozilla.components.browser.menu.facts
+
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.Fact
+import mozilla.components.support.base.facts.collect
+
+/**
+ * Facts emitted for telemetry related to [BrowserMenu].
+ */
+class BrowserMenuFacts {
+ /**
+ * Items that specify which portion of the [BrowserMenu] was interacted with.
+ */
+ object Items {
+ const val WEB_EXTENSION_MENU_ITEM = "web_extension_menu_item"
+ }
+}
+
+private fun emitMenuFact(
+ action: Action,
+ item: String,
+ value: String? = null,
+ metadata: Map<String, Any>? = null,
+) {
+ Fact(
+ Component.BROWSER_MENU,
+ action,
+ item,
+ value,
+ metadata,
+ ).collect()
+}
+
+internal fun emitOpenMenuItemFact(extensionId: String) {
+ emitMenuFact(
+ Action.CLICK,
+ BrowserMenuFacts.Items.WEB_EXTENSION_MENU_ITEM,
+ metadata = mapOf("id" to extensionId),
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/AbstractParentBrowserMenuItem.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/AbstractParentBrowserMenuItem.kt
new file mode 100644
index 0000000000..729cdfdec5
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/AbstractParentBrowserMenuItem.kt
@@ -0,0 +1,66 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.view.View
+import androidx.annotation.VisibleForTesting
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuItem
+
+/**
+ * An abstract menu item for handling nested sub menu items on view click.
+ *
+ * @param subMenu Target sub menu to be shown when this menu item is clicked.
+ * @param endOfMenuAlwaysVisible when is set to true makes sure the bottom of the menu is always visible
+ * otherwise, the top of the menu is always visible.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ */
+abstract class AbstractParentBrowserMenuItem(
+ private val subMenu: BrowserMenu,
+ private val endOfMenuAlwaysVisible: Boolean,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+) : BrowserMenuItem {
+ /**
+ * Listener called when the sub menu is shown.
+ */
+ var onSubMenuShow: () -> Unit = {}
+
+ /**
+ * Listener called when the sub menu is dismissed.
+ */
+ var onSubMenuDismiss: () -> Unit = {}
+ abstract override var visible: () -> Boolean
+ abstract override fun getLayoutResource(): Int
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ view.setOnClickListener {
+ menu.dismiss()
+ subMenu.show(
+ anchor = menu.currAnchor ?: view,
+ orientation = BrowserMenu.determineMenuOrientation(view.parent as? View?),
+ endOfMenuAlwaysVisible = endOfMenuAlwaysVisible,
+ ) {
+ onSubMenuDismiss()
+ }
+ onSubMenuShow()
+ }
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ internal fun onBackPressed(menu: BrowserMenu, view: View) {
+ if (subMenu.isShown) {
+ subMenu.dismiss()
+ onSubMenuDismiss()
+ menu.show(
+ anchor = menu.currAnchor ?: view,
+ orientation = BrowserMenu.determineMenuOrientation(view.parent as? View?),
+ endOfMenuAlwaysVisible = endOfMenuAlwaysVisible,
+ )
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BackPressMenuItem.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BackPressMenuItem.kt
new file mode 100644
index 0000000000..c04a91ecf2
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BackPressMenuItem.kt
@@ -0,0 +1,72 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.View
+import android.view.View.AccessibilityDelegate
+import android.view.accessibility.AccessibilityNodeInfo
+import android.widget.Button
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.concept.menu.candidate.NestedMenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+
+/**
+ * A back press menu item for a nested sub menu entry.
+ *
+ * @param backPressListener Callback to be invoked when the back press menu item is clicked.
+ */
+class BackPressMenuItem(
+ val contentDescription: String,
+ label: String,
+ @DrawableRes
+ imageResource: Int,
+ @ColorRes
+ iconTintColorResource: Int = NO_ID,
+ @ColorRes
+ textColorResource: Int = NO_ID,
+ private var backPressListener: () -> Unit = {},
+) : BrowserMenuImageText(label, imageResource, iconTintColorResource, textColorResource) {
+
+ /**
+ * Binds the view according to its super, but use [backPressListener] for on view clicks.
+ */
+ override fun bind(menu: BrowserMenu, view: View) {
+ super.bind(menu, view)
+
+ view.setOnClickListener {
+ backPressListener.invoke()
+ menu.dismiss()
+ }
+ view.accessibilityDelegate = object : AccessibilityDelegate() {
+ override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {
+ super.onInitializeAccessibilityNodeInfo(host, info)
+ info.className = Button::class.java.name
+ }
+ }
+ view.contentDescription = contentDescription
+ }
+
+ /**
+ * Sets and replaces the existing [backPressListener] for the back press item.
+ */
+ fun setListener(onClickListener: () -> Unit) {
+ backPressListener = onClickListener
+ }
+
+ override fun asCandidate(context: Context): NestedMenuCandidate {
+ val parentCandidate = super.asCandidate(context) as TextMenuCandidate
+ return NestedMenuCandidate(
+ id = hashCode(),
+ text = parentCandidate.text,
+ start = parentCandidate.start,
+ subMenuItems = null,
+ textStyle = parentCandidate.textStyle,
+ containerStyle = parentCandidate.containerStyle,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCategory.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCategory.kt
new file mode 100644
index 0000000000..9c0b29bbee
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCategory.kt
@@ -0,0 +1,81 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.graphics.Typeface
+import android.view.View
+import android.widget.TextView
+import androidx.annotation.ColorRes
+import androidx.core.content.ContextCompat.getColor
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.DecorativeTextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextAlignment
+import mozilla.components.concept.menu.candidate.TextStyle
+import mozilla.components.concept.menu.candidate.TypefaceStyle
+
+/**
+ * A browser menu item displaying styleable text, usable for menu categories
+ *
+ * @param label The visible label of this menu item.
+ * @param textSize: The size of the label.
+ * @param textColorResource: The color resource to apply to the text.
+ * @param backgroundColorResource: The color resource to apply to the item background.
+ * @param textStyle: The style to apply to the text.
+ * @param textAlignment The alignment of text
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ */
+class BrowserMenuCategory(
+ internal val label: String,
+ private val textSize: Float = NO_ID.toFloat(),
+ @ColorRes
+ private val textColorResource: Int = NO_ID,
+ @ColorRes
+ private val backgroundColorResource: Int = NO_ID,
+ @TypefaceStyle private val textStyle: Int = Typeface.BOLD,
+ @TextAlignment private val textAlignment: Int = View.TEXT_ALIGNMENT_VIEW_START,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+) : BrowserMenuItem {
+ override var visible: () -> Boolean = { true }
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_category
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ val textView = view as TextView
+ textView.text = label
+
+ if (textSize != NO_ID.toFloat()) {
+ textView.textSize = textSize
+ }
+
+ if (textColorResource != NO_ID) {
+ textView.setColorResource(textColorResource)
+ }
+
+ textView.setTypeface(textView.typeface, textStyle)
+ textView.textAlignment = textAlignment
+
+ if (backgroundColorResource != NO_ID) {
+ view.setBackgroundResource(backgroundColorResource)
+ }
+ }
+
+ override fun asCandidate(context: Context) = DecorativeTextMenuCandidate(
+ label,
+ textStyle = TextStyle(
+ size = if (textSize == NO_ID.toFloat()) null else textSize,
+ color = if (textColorResource == NO_ID) null else getColor(context, textColorResource),
+ textStyle = textStyle,
+ textAlignment = textAlignment,
+ ),
+ containerStyle = ContainerStyle(isVisible = visible()),
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCheckbox.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCheckbox.kt
new file mode 100644
index 0000000000..e271ecd0bd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCheckbox.kt
@@ -0,0 +1,33 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+
+/**
+ * A simple browser menu checkbox.
+ *
+ * @param label The visible label of this menu item.
+ * @param initialState The initial value the checkbox should have.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param listener Callback to be invoked when this menu item is checked.
+ */
+class BrowserMenuCheckbox(
+ label: String,
+ initialState: () -> Boolean = { false },
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ listener: (Boolean) -> Unit,
+) : BrowserMenuCompoundButton(label, isCollapsingMenuLimit, isSticky, initialState, listener) {
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_item_checkbox
+
+ override fun asCandidate(context: Context) = super.asCandidate(context).copy(
+ end = CompoundMenuCandidate.ButtonType.CHECKBOX,
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCompoundButton.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCompoundButton.kt
new file mode 100644
index 0000000000..09071b4c39
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuCompoundButton.kt
@@ -0,0 +1,72 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.View
+import android.view.ViewTreeObserver
+import android.widget.CompoundButton
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+import mozilla.components.concept.menu.candidate.ContainerStyle
+
+/**
+ * A browser menu compound button. A basic sub-class would only have to provide a layout resource to
+ * satisfy [BrowserMenuItem.getLayoutResource] which contains a [View] that inherits from [CompoundButton].
+ *
+ * @param label The visible label of this menu item.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param initialState The initial value the checkbox should have.
+ * @param listener Callback to be invoked when this menu item is checked.
+ */
+abstract class BrowserMenuCompoundButton(
+ internal val label: String,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ private val initialState: () -> Boolean = { false },
+ private val listener: (Boolean) -> Unit,
+) : BrowserMenuItem {
+ override var visible: () -> Boolean = { true }
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ // A CompoundButton containing CompoundDrawables needs to know where to place them (LTR / RTL).
+ // If the View is not yet attached to Window the direction inference will fail and the menu item
+ // will return from it's onMeasure a width smaller with the size + padding of the compound drawables.
+ // Work around this by setting a valid layout direction and reset it to inherit from parent later.
+ if (!view.isAttachedToWindow) {
+ view.layoutDirection = View.LAYOUT_DIRECTION_LOCALE
+
+ view.viewTreeObserver.addOnPreDrawListener(
+ object : ViewTreeObserver.OnPreDrawListener {
+ override fun onPreDraw(): Boolean {
+ view.viewTreeObserver.removeOnPreDrawListener(this)
+ view.layoutDirection = View.LAYOUT_DIRECTION_INHERIT
+ return true
+ }
+ },
+ )
+ }
+
+ (view as CompoundButton).apply {
+ text = label
+ isChecked = initialState()
+ setOnCheckedChangeListener { _, checked ->
+ listener(checked)
+ menu.dismiss()
+ }
+ }
+ }
+
+ override fun asCandidate(context: Context) = CompoundMenuCandidate(
+ label,
+ isChecked = initialState(),
+ end = CompoundMenuCandidate.ButtonType.CHECKBOX,
+ containerStyle = ContainerStyle(isVisible = visible()),
+ onCheckedChange = listener,
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuDivider.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuDivider.kt
new file mode 100644
index 0000000000..6e16b12f53
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuDivider.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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.View
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.DividerMenuCandidate
+
+/**
+ * A browser menu item to display a horizontal divider.
+ */
+class BrowserMenuDivider : BrowserMenuItem {
+ override var visible: () -> Boolean = { true }
+
+ override val interactiveCount: () -> Int = { 0 }
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_item_divider
+
+ override fun bind(menu: BrowserMenu, view: View) = Unit
+
+ override fun asCandidate(context: Context) = DividerMenuCandidate(
+ containerStyle = ContainerStyle(isVisible = visible()),
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableItem.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableItem.kt
new file mode 100644
index 0000000000..e21dccf827
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableItem.kt
@@ -0,0 +1,212 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.view.View
+import android.widget.TextView
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.appcompat.widget.AppCompatImageView
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuHighlight
+import mozilla.components.browser.menu.HighlightableMenuItem
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.HighPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.LowPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+
+@Suppress("Deprecation")
+private val defaultHighlight = BrowserMenuHighlightableItem.Highlight(0, 0, 0, 0)
+
+/**
+ * A menu item for displaying text with an image icon and a highlight state which sets the
+ * background of the menu item and a second image icon to the right of the text.
+ *
+ * @param label The default visible label of this menu item.
+ * @param startImageResource ID of a drawable resource to be shown as a leftmost icon.
+ * @param iconTintColorResource Optional ID of color resource to tint the icon.
+ * @param textColorResource Optional ID of color resource to tint the text.
+ * @param enabled Sets the enabled status for the view. By default, it is true.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param highlight Highlight object representing how the menu item will be displayed when highlighted.
+ * @param isHighlighted Whether or not to display the highlight
+ * @param listener Callback to be invoked when this menu item is clicked.
+ */
+class BrowserMenuHighlightableItem(
+ private val label: String,
+ @DrawableRes private val startImageResource: Int,
+ @ColorRes iconTintColorResource: Int = NO_ID,
+ @ColorRes private val textColorResource: Int = NO_ID,
+ enabled: Boolean = true,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ override val highlight: BrowserMenuHighlight,
+ override val isHighlighted: () -> Boolean = { true },
+ private val listener: () -> Unit = {},
+) : BrowserMenuImageText(
+ label,
+ startImageResource,
+ iconTintColorResource,
+ textColorResource,
+ enabled,
+ isCollapsingMenuLimit,
+ isSticky,
+ listener,
+),
+ HighlightableMenuItem {
+
+ @Deprecated("Use the new constructor")
+ @Suppress("Deprecation") // Constructor uses old highlight type
+ constructor(
+ label: String,
+ @DrawableRes
+ imageResource: Int,
+ @ColorRes
+ iconTintColorResource: Int = NO_ID,
+ @ColorRes
+ textColorResource: Int = NO_ID,
+ enabled: Boolean = true,
+ isCollapsingMenuLimit: Boolean = false,
+ isSticky: Boolean = false,
+ highlight: Highlight? = null,
+ listener: () -> Unit = {},
+ ) : this(
+ label,
+ imageResource,
+ iconTintColorResource,
+ textColorResource,
+ enabled,
+ isCollapsingMenuLimit,
+ isSticky,
+ highlight ?: defaultHighlight,
+ { highlight != null },
+ listener,
+ )
+
+ private var wasHighlighted = false
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_highlightable_item
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ super.bind(menu, view)
+
+ val endImageView = view.findViewById<AppCompatImageView>(R.id.end_image)
+ endImageView.setTintResource(iconTintColorResource)
+
+ val highlightedTextView = view.findViewById<TextView>(R.id.highlight_text)
+ highlightedTextView.text = highlight.label ?: label
+
+ wasHighlighted = isHighlighted()
+ updateHighlight(view, wasHighlighted)
+ }
+
+ override fun invalidate(view: View) {
+ val isNowHighlighted = isHighlighted()
+ if (isNowHighlighted != wasHighlighted) {
+ wasHighlighted = isNowHighlighted
+ updateHighlight(view, isNowHighlighted)
+ }
+ }
+
+ private fun updateHighlight(view: View, isHighlighted: Boolean) {
+ val startImageView = view.findViewById<AppCompatImageView>(R.id.image)
+ val endImageView = view.findViewById<AppCompatImageView>(R.id.end_image)
+ val notificationDotView = view.findViewById<AppCompatImageView>(R.id.notification_dot)
+ val textView = view.findViewById<TextView>(R.id.text)
+ val highlightedTextView = view.findViewById<TextView>(R.id.highlight_text)
+
+ if (isHighlighted) {
+ @Suppress("Deprecation")
+ when (highlight) {
+ is BrowserMenuHighlight.HighPriority -> {
+ textView.visibility = View.INVISIBLE
+ highlightedTextView.visibility = View.VISIBLE
+ view.setBackgroundColor(highlight.backgroundTint)
+ if (highlight.endImageResource != NO_ID) {
+ endImageView.setImageResource(highlight.endImageResource)
+ }
+ endImageView.visibility = View.VISIBLE
+ }
+ is BrowserMenuHighlight.LowPriority -> {
+ textView.visibility = View.INVISIBLE
+ highlightedTextView.visibility = View.VISIBLE
+ notificationDotView.imageTintList = ColorStateList.valueOf(highlight.notificationTint)
+ notificationDotView.visibility = View.VISIBLE
+ view.contentDescription = "${notificationDotView.contentDescription}, ${textView.text}"
+ }
+ is BrowserMenuHighlight.ClassicHighlight -> {
+ view.setBackgroundResource(highlight.backgroundResource)
+ if (highlight.startImageResource != NO_ID) {
+ startImageView.setImageResource(highlight.startImageResource)
+ }
+ if (highlight.endImageResource != NO_ID) {
+ endImageView.setImageResource(highlight.endImageResource)
+ }
+ endImageView.visibility = View.VISIBLE
+ }
+ }
+ } else {
+ textView.visibility = View.VISIBLE
+ highlightedTextView.visibility = View.INVISIBLE
+ view.background = null
+ endImageView.setImageDrawable(null)
+ endImageView.visibility = View.GONE
+ notificationDotView.visibility = View.GONE
+ }
+ }
+
+ override fun asCandidate(context: Context): TextMenuCandidate {
+ val base = super.asCandidate(context) as TextMenuCandidate
+ if (!isHighlighted()) return base
+
+ @Suppress("Deprecation")
+ return when (highlight) {
+ is BrowserMenuHighlight.HighPriority -> base.copy(
+ text = highlight.label ?: label,
+ end = if (highlight.endImageResource == NO_ID) {
+ null
+ } else {
+ DrawableMenuIcon(
+ context,
+ highlight.endImageResource,
+ )
+ },
+ effect = HighPriorityHighlightEffect(
+ backgroundTint = highlight.backgroundTint,
+ ),
+ )
+ is BrowserMenuHighlight.LowPriority -> base.copy(
+ text = highlight.label ?: label,
+ start = (base.start as? DrawableMenuIcon)?.copy(
+ effect = LowPriorityHighlightEffect(notificationTint = highlight.notificationTint),
+ ),
+ )
+ is BrowserMenuHighlight.ClassicHighlight -> base
+ }
+ }
+
+ /**
+ * Described how to display a [BrowserMenuHighlightableItem] when it is highlighted.
+ * Replaced by [BrowserMenuHighlight] which lets a priority be specified.
+ */
+ @Deprecated("Replace with BrowserMenuHighlight.LowPriority or BrowserMenuHighlight.HighPriority")
+ @Suppress("Deprecation")
+ class Highlight(
+ @DrawableRes startImageResource: Int = NO_ID,
+ @DrawableRes endImageResource: Int = NO_ID,
+ @DrawableRes backgroundResource: Int,
+ @ColorRes colorResource: Int,
+ ) : BrowserMenuHighlight.ClassicHighlight(
+ startImageResource,
+ endImageResource,
+ backgroundResource,
+ colorResource,
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableSwitch.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableSwitch.kt
new file mode 100644
index 0000000000..4eb2c1cb8e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableSwitch.kt
@@ -0,0 +1,99 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.view.View
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.appcompat.widget.SwitchCompat
+import androidx.core.view.isVisible
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuHighlight
+import mozilla.components.browser.menu.HighlightableMenuItem
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.LowPriorityHighlightEffect
+
+/**
+ * A browser menu switch that can show a highlighted icon.
+ *
+ * @param label The visible label of this menu item.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param initialState The initial value the checkbox should have.
+ * @param listener Callback to be invoked when this menu item is checked.
+ */
+class BrowserMenuHighlightableSwitch(
+ label: String,
+ @DrawableRes private val startImageResource: Int,
+ @ColorRes private val iconTintColorResource: Int = NO_ID,
+ @ColorRes private val textColorResource: Int = NO_ID,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ override val highlight: BrowserMenuHighlight.LowPriority,
+ override val isHighlighted: () -> Boolean = { true },
+ initialState: () -> Boolean = { false },
+ listener: (Boolean) -> Unit,
+) : BrowserMenuCompoundButton(label, isCollapsingMenuLimit, isSticky, initialState, listener), HighlightableMenuItem {
+
+ private var wasHighlighted = false
+
+ override fun getLayoutResource(): Int = R.layout.mozac_browser_menu_highlightable_switch
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ super.bind(menu, view.findViewById<SwitchCompat>(R.id.switch_widget))
+ setTints(view)
+
+ val notificationDotView = view.findViewById<AppCompatImageView>(R.id.notification_dot)
+ notificationDotView.imageTintList = ColorStateList.valueOf(highlight.notificationTint)
+
+ wasHighlighted = isHighlighted()
+ updateHighlight(view, wasHighlighted)
+ }
+
+ override fun invalidate(view: View) {
+ val isNowHighlighted = isHighlighted()
+ if (isNowHighlighted != wasHighlighted) {
+ wasHighlighted = isNowHighlighted
+ updateHighlight(view, isNowHighlighted)
+ }
+ }
+
+ private fun setTints(view: View) {
+ val switch = view.findViewById<SwitchCompat>(R.id.switch_widget)
+ switch.setColorResource(textColorResource)
+
+ val imageView = view.findViewById<AppCompatImageView>(R.id.image)
+ imageView.setImageResource(startImageResource)
+ imageView.setTintResource(iconTintColorResource)
+ }
+
+ private fun updateHighlight(view: View, isHighlighted: Boolean) {
+ val notificationDotView = view.findViewById<AppCompatImageView>(R.id.notification_dot)
+ val switch = view.findViewById<SwitchCompat>(R.id.switch_widget)
+
+ notificationDotView.isVisible = isHighlighted
+ switch.text = if (isHighlighted) highlight.label ?: label else label
+ }
+
+ override fun asCandidate(context: Context): CompoundMenuCandidate {
+ val base = super.asCandidate(context)
+ return if (isHighlighted()) {
+ base.copy(
+ text = highlight.label ?: label,
+ start = (base.start as? DrawableMenuIcon)?.copy(
+ effect = LowPriorityHighlightEffect(notificationTint = highlight.notificationTint),
+ ),
+ )
+ } else {
+ base
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageSwitch.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageSwitch.kt
new file mode 100644
index 0000000000..d98afe2bb8
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageSwitch.kt
@@ -0,0 +1,59 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.View
+import androidx.annotation.DrawableRes
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.widget.SwitchCompat
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import java.lang.reflect.Modifier
+
+/**
+ * A simple browser menu switch.
+ *
+ * @param imageResource ID of a drawable resource to be shown as icon.
+ * @param label The visible label of this menu item.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param initialState The initial value the checkbox should have.
+ * @param listener Callback to be invoked when this menu item is checked.
+ */
+class BrowserMenuImageSwitch(
+ @get:VisibleForTesting(otherwise = Modifier.PRIVATE)
+ @DrawableRes
+ val imageResource: Int,
+ label: String,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ initialState: () -> Boolean = { false },
+ listener: (Boolean) -> Unit,
+) : BrowserMenuCompoundButton(label, isCollapsingMenuLimit, isSticky, initialState, listener) {
+ override fun getLayoutResource(): Int = R.layout.mozac_browser_menu_item_image_switch
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ super.bind(menu, view)
+ bindImage(view as SwitchCompat)
+ }
+
+ private fun bindImage(switch: SwitchCompat) {
+ switch.setCompoundDrawablesRelativeWithIntrinsicBounds(
+ imageResource,
+ 0,
+ 0,
+ 0,
+ )
+ }
+
+ override fun asCandidate(context: Context) = super.asCandidate(context).copy(
+ start = DrawableMenuIcon(context, imageResource),
+ end = CompoundMenuCandidate.ButtonType.SWITCH,
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageText.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageText.kt
new file mode 100644
index 0000000000..25542f6a4e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageText.kt
@@ -0,0 +1,111 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.content.ContextCompat
+import androidx.core.content.ContextCompat.getColor
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.MenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextStyle
+
+internal const val NO_ID = -1
+
+internal fun ImageView.setTintResource(@ColorRes tintColorResource: Int) {
+ if (tintColorResource != NO_ID) {
+ imageTintList = ContextCompat.getColorStateList(context, tintColorResource)
+ }
+}
+
+internal fun TextView.setColorResource(@ColorRes textColorResource: Int) {
+ if (textColorResource != NO_ID) {
+ setTextColor(ContextCompat.getColor(context, textColorResource))
+ }
+}
+
+/**
+ * A menu item for displaying text with an image icon.
+ *
+ * @param label The visible label of this menu item.
+ * @param imageResource ID of a drawable resource to be shown as icon.
+ * @param iconTintColorResource Optional ID of color resource to tint the icon.
+ * @param textColorResource Optional ID of color resource to tint the text.
+ * @param enabled Sets the enabled status for the view. By default, it is true.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param listener Callback to be invoked when this menu item is clicked.
+ */
+open class BrowserMenuImageText(
+ private val label: String,
+ @DrawableRes
+ internal val imageResource: Int,
+ @ColorRes
+ open var iconTintColorResource: Int = NO_ID,
+ @ColorRes
+ private val textColorResource: Int = NO_ID,
+ open var enabled: Boolean = true,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ private val listener: () -> Unit = {},
+) : BrowserMenuItem {
+
+ override var visible: () -> Boolean = { true }
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_item_image_text
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ bindText(view)
+
+ bindImage(view)
+
+ view.setOnClickListener {
+ listener.invoke()
+ menu.dismiss()
+ }
+ view.isEnabled = enabled
+ view.contentDescription = label
+ }
+
+ private fun bindText(view: View) {
+ val textView = view.findViewById<TextView>(R.id.text)
+ textView.text = label
+ textView.setColorResource(textColorResource)
+ textView.isEnabled = enabled
+ }
+
+ private fun bindImage(view: View) {
+ val imageView = view.findViewById<AppCompatImageView>(R.id.image)
+ with(imageView) {
+ setImageResource(imageResource)
+ setTintResource(iconTintColorResource)
+ }
+ }
+
+ override fun asCandidate(context: Context): MenuCandidate = TextMenuCandidate(
+ label,
+ start = DrawableMenuIcon(
+ context,
+ resource = imageResource,
+ tint = if (iconTintColorResource == NO_ID) null else getColor(context, iconTintColorResource),
+ ),
+ textStyle = TextStyle(
+ color = if (textColorResource == NO_ID) null else getColor(context, textColorResource),
+ ),
+ containerStyle = ContainerStyle(isVisible = visible()),
+ onClick = listener,
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageTextCheckboxButton.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageTextCheckboxButton.kt
new file mode 100644
index 0000000000..8d75b2a90a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuImageTextCheckboxButton.kt
@@ -0,0 +1,111 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.view.View
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.widget.AppCompatCheckBox
+import androidx.core.content.ContextCompat
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import mozilla.components.support.ktx.android.util.dpToPx
+
+/**
+ * A browser menu item with image and label and a custom checkbox.
+ *
+ * @param imageResource ID of a drawable resource to be shown as icon.
+ * @param iconTintColorResource Optional ID of color resource to tint the icon.
+ * @param label The visible label of this menu item.
+ * @param textColorResource Optional ID of color resource to tint the text.
+ * @param enabled Sets the enabled status for the view. By default, it is true.
+ * @param labelListener Callback to be invoked when this menu item is clicked.
+ * @param primaryStateIconResource ID of a drawable resource for checkbox drawable in primary state.
+ * @param secondaryStateIconResource ID of a drawable resource for checkbox drawable in secondary state.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param iconTintColorResource Optional ID of color resource to tint the checkbox drawable.
+ * @param primaryLabel The visible label of the checkbox in primary state.
+ * @param secondaryLabel The visible label of this menu item in secondary state.
+ * @param isInPrimaryState Lambda to return true/false to indicate checkbox primary or secondary state.
+ * @param onCheckedChangedListener Callback to be invoked when checkbox is clicked.
+ */
+@Suppress("LongParameterList")
+class BrowserMenuImageTextCheckboxButton(
+ @DrawableRes imageResource: Int,
+ private val label: String,
+ @ColorRes iconTintColorResource: Int = NO_ID,
+ @ColorRes internal val textColorResource: Int = NO_ID,
+ enabled: Boolean = true,
+ @get:VisibleForTesting internal val labelListener: () -> Unit,
+ @DrawableRes val primaryStateIconResource: Int,
+ @DrawableRes val secondaryStateIconResource: Int,
+ @ColorRes internal val tintColorResource: Int = NO_ID,
+ private val primaryLabel: String,
+ private val secondaryLabel: String,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ val isInPrimaryState: () -> Boolean = { true },
+ private val onCheckedChangedListener: (Boolean) -> Unit,
+) : BrowserMenuImageText(
+ label,
+ imageResource,
+ iconTintColorResource,
+ textColorResource,
+ enabled,
+ isCollapsingMenuLimit,
+ isSticky,
+ labelListener,
+) {
+ override var visible: () -> Boolean = { true }
+ override fun getLayoutResource(): Int = R.layout.mozac_browser_menu_item_image_text_checkbox_button
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ super.bind(menu, view)
+
+ view.findViewById<View>(R.id.accessibilityRegion).apply {
+ setOnClickListener { labelListener.invoke() }
+ contentDescription = label
+ }
+
+ bindCheckbox(menu, view.findViewById(R.id.checkbox) as AppCompatCheckBox)
+ }
+
+ private fun bindCheckbox(menu: BrowserMenu, button: AppCompatCheckBox) {
+ val buttonText = if (isInPrimaryState()) primaryLabel else secondaryLabel
+ val tintColor = ContextCompat.getColor(button.context, tintColorResource)
+ val buttonDrawableIcon = if (isInPrimaryState()) {
+ ContextCompat.getDrawable(button.context, primaryStateIconResource)
+ } else {
+ ContextCompat.getDrawable(button.context, secondaryStateIconResource)
+ }
+ buttonDrawableIcon?.setTint(tintColor)
+ val displayMetrics = button.context.resources.displayMetrics
+
+ buttonDrawableIcon?.setBounds(
+ 0,
+ 0,
+ CHECKBOX_ICON_SIZE_DP.dpToPx(displayMetrics),
+ CHECKBOX_ICON_SIZE_DP.dpToPx(displayMetrics),
+ )
+
+ button.apply {
+ text = buttonText
+ setTextColor(tintColor)
+ setCompoundDrawables(buttonDrawableIcon, null, null, null)
+
+ setOnCheckedChangeListener { _, isChecked ->
+ onCheckedChangedListener(isChecked)
+ menu.dismiss()
+ }
+ }
+ }
+
+ companion object {
+ private const val CHECKBOX_ICON_SIZE_DP = 19
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuItemToolbar.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuItemToolbar.kt
new file mode 100644
index 0000000000..bd15f5c93b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuItemToolbar.kt
@@ -0,0 +1,212 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.os.Build
+import android.view.View
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.widget.ImageView
+import android.widget.LinearLayout
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.appcompat.widget.AppCompatImageButton
+import androidx.appcompat.widget.TooltipCompat
+import androidx.core.content.ContextCompat.getColor
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.RowMenuCandidate
+import mozilla.components.concept.menu.candidate.SmallMenuCandidate
+import mozilla.components.support.ktx.android.content.res.resolveAttribute
+
+/**
+ * A toolbar of buttons to show inside the browser menu.
+ *
+ * @param items buttons that will be shown in a horizontal layout
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ */
+class BrowserMenuItemToolbar(
+ private val items: List<Button>,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+) : BrowserMenuItem {
+ override var visible: () -> Boolean = { true }
+
+ override val interactiveCount: () -> Int = { items.size }
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_item_toolbar
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ val layout = view as LinearLayout
+ layout.removeAllViews()
+
+ val selectableBackground =
+ layout.context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless)
+
+ for (item in items) {
+ val button = AppCompatImageButton(layout.context)
+ item.bind(button)
+
+ button.setFocusable(true)
+ button.setBackgroundResource(selectableBackground)
+ button.setOnClickListener {
+ item.listener()
+ menu.dismiss()
+ }
+ button.setOnLongClickListener {
+ item.longClickListener?.invoke()
+ menu.dismiss()
+ true
+ }
+ button.isLongClickable = item.longClickListener != null
+
+ layout.addView(button, LinearLayout.LayoutParams(0, MATCH_PARENT, 1f))
+ }
+ }
+
+ override fun invalidate(view: View) {
+ val layout = view as LinearLayout
+ items.withIndex().forEach { (index, item) ->
+ item.invalidate(layout.getChildAt(index) as AppCompatImageButton)
+ }
+ }
+
+ override fun asCandidate(context: Context) = RowMenuCandidate(
+ items = items.map { it.asCandidate(context) },
+ containerStyle = ContainerStyle(isVisible = visible()),
+ )
+
+ /**
+ * A button to be shown in a toolbar inside the browser menu.
+ *
+ * @param imageResource ID of a drawable resource to be shown as icon.
+ * @param contentDescription The button's content description, used for accessibility support.
+ * @param iconTintColorResource Optional ID of color resource to tint the icon.
+ * @param isEnabled Lambda to return true/false to indicate if this button should be enabled or disabled.
+ * @param longClickListener Callback to be invoked when the button is long clicked.
+ * @param listener Callback to be invoked when the button is pressed.
+ */
+ open class Button(
+ @DrawableRes val imageResource: Int,
+ val contentDescription: String,
+ @ColorRes val iconTintColorResource: Int = NO_ID,
+ val isEnabled: () -> Boolean = { true },
+ val longClickListener: (() -> Unit)? = null,
+ val listener: () -> Unit,
+ ) {
+
+ internal open fun bind(view: ImageView) {
+ view.setImageResource(imageResource)
+ view.contentDescription = contentDescription
+ setTooltipTextCompatible(view, contentDescription)
+ view.setTintResource(iconTintColorResource)
+ view.isEnabled = isEnabled()
+ }
+
+ internal open fun invalidate(view: ImageView) {
+ view.isEnabled = isEnabled()
+ }
+
+ internal open fun asCandidate(context: Context) = SmallMenuCandidate(
+ contentDescription,
+ icon = DrawableMenuIcon(
+ context,
+ resource = imageResource,
+ tint = if (iconTintColorResource == NO_ID) null else getColor(context, iconTintColorResource),
+ ),
+ containerStyle = ContainerStyle(isEnabled = isEnabled()),
+ onClick = listener,
+ )
+
+ internal fun setTooltipTextCompatible(view: ImageView, contentDescription: String) {
+ if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) {
+ CustomTooltip.setTooltipText(view, contentDescription)
+ } else {
+ TooltipCompat.setTooltipText(view, contentDescription)
+ }
+ }
+ }
+
+ /**
+ * A button that either shows an primary state or an secondary state based on the provided
+ * <code>isInPrimaryState</code> lambda.
+ *
+ * @param primaryImageResource ID of a drawable resource to be shown as primary icon.
+ * @param primaryContentDescription The button's primary content description, used for accessibility support.
+ * @param primaryImageTintResource Optional ID of color resource to tint the primary icon.
+ * @param secondaryImageResource Optional ID of a different drawable resource to be shown as secondary icon.
+ * @param secondaryContentDescription Optional secondary content description for button, for accessibility support.
+ * @param secondaryImageTintResource Optional ID of secondary color resource to tint the icon.
+ * @param isInPrimaryState Lambda to return true/false to indicate if this button should be primary or secondary.
+ * @param disableInSecondaryState Optional boolean to disable the button when in secondary state.
+ * @param longClickListener Callback to be invoked when the button is long clicked.
+ * @param listener Callback to be invoked when the button is pressed.
+ */
+ open class TwoStateButton(
+ @DrawableRes val primaryImageResource: Int,
+ val primaryContentDescription: String,
+ @ColorRes val primaryImageTintResource: Int = NO_ID,
+ @DrawableRes val secondaryImageResource: Int = primaryImageResource,
+ val secondaryContentDescription: String = primaryContentDescription,
+ @ColorRes val secondaryImageTintResource: Int = primaryImageTintResource,
+ val isInPrimaryState: () -> Boolean = { true },
+ val disableInSecondaryState: Boolean = false,
+ longClickListener: (() -> Unit)? = null,
+ listener: () -> Unit,
+ ) : Button(
+ primaryImageResource,
+ primaryContentDescription,
+ primaryImageTintResource,
+ isInPrimaryState,
+ longClickListener = longClickListener,
+ listener = listener,
+ ) {
+
+ private var wasInPrimaryState = false
+
+ override fun bind(view: ImageView) {
+ if (isInPrimaryState()) {
+ super.bind(view)
+ } else {
+ view.setImageResource(secondaryImageResource)
+ view.contentDescription = secondaryContentDescription
+ setTooltipTextCompatible(view, secondaryContentDescription)
+ view.setTintResource(secondaryImageTintResource)
+ view.isEnabled = !disableInSecondaryState
+ }
+ wasInPrimaryState = isInPrimaryState()
+ }
+
+ override fun invalidate(view: ImageView) {
+ if (isInPrimaryState() != wasInPrimaryState) {
+ bind(view)
+ }
+ }
+
+ override fun asCandidate(context: Context): SmallMenuCandidate = if (isInPrimaryState()) {
+ super.asCandidate(context)
+ } else {
+ SmallMenuCandidate(
+ secondaryContentDescription,
+ icon = DrawableMenuIcon(
+ context,
+ resource = secondaryImageResource,
+ tint = if (secondaryImageTintResource == NO_ID) {
+ null
+ } else {
+ getColor(context, secondaryImageTintResource)
+ },
+ ),
+ containerStyle = ContainerStyle(isEnabled = !disableInSecondaryState),
+ onClick = listener,
+ )
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuSwitch.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuSwitch.kt
new file mode 100644
index 0000000000..abea7b218f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/BrowserMenuSwitch.kt
@@ -0,0 +1,33 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+
+/**
+ * A simple browser menu switch.
+ *
+ * @param label The visible label of this menu item.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param initialState The initial value the checkbox should have.
+ * @param listener Callback to be invoked when this menu item is checked.
+ */
+class BrowserMenuSwitch(
+ label: String,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ initialState: () -> Boolean = { false },
+ listener: (Boolean) -> Unit,
+) : BrowserMenuCompoundButton(label, isCollapsingMenuLimit, isSticky, initialState, listener) {
+ override fun getLayoutResource(): Int = R.layout.mozac_browser_menu_item_switch
+
+ override fun asCandidate(context: Context) = super.asCandidate(context).copy(
+ end = CompoundMenuCandidate.ButtonType.SWITCH,
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/CustomTooltip.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/CustomTooltip.kt
new file mode 100644
index 0000000000..9e7ce8b674
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/CustomTooltip.kt
@@ -0,0 +1,154 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.annotation.SuppressLint
+import android.text.TextUtils
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.OnLongClickListener
+import android.view.WindowManager
+import android.widget.LinearLayout
+import android.widget.PopupWindow
+import android.widget.TextView
+import androidx.core.view.ViewCompat
+import androidx.core.widget.PopupWindowCompat
+import mozilla.components.browser.menu.R
+
+/**
+ * A tooltip shown on long click on an anchor view.
+ * There can be only one tooltip shown at a given moment.
+ */
+internal class CustomTooltip private constructor(
+ private val anchor: View,
+ private val tooltipText: CharSequence,
+) : OnLongClickListener, View.OnAttachStateChangeListener {
+ private val hideTooltipRunnable = Runnable { hide() }
+ private var popupWindow: PopupWindow? = null
+
+ init {
+ anchor.setOnLongClickListener(this)
+ }
+
+ override fun onLongClick(view: View): Boolean {
+ if (ViewCompat.isAttachedToWindow(anchor)) {
+ show()
+ anchor.addOnAttachStateChangeListener(this)
+ }
+ return true
+ }
+
+ private fun computeOffsets(): Offset {
+ // Measure pop-up
+ val spec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ popupWindow?.contentView?.measure(spec, spec)
+
+ val rootView = anchor.rootView
+ val rootPosition = IntArray(2)
+ val anchorPosition = IntArray(2)
+ rootView.getLocationOnScreen(rootPosition)
+ anchor.getLocationOnScreen(anchorPosition)
+
+ val rootY = rootPosition[1]
+ val anchorY = anchorPosition[1]
+
+ val rootHeight = rootView.height
+ val tooltipHeight = popupWindow?.contentView?.measuredHeight ?: 0
+ val tooltipWidth = popupWindow?.contentView?.measuredWidth ?: 0
+
+ val checkY = rootY + rootHeight - (anchorY + anchor.height + tooltipHeight + TOOLTIP_EXTRA_VERTICAL_OFFSET_DP)
+ val belowY = TOOLTIP_EXTRA_VERTICAL_OFFSET_DP
+ val aboveY = -(anchor.height + tooltipHeight + TOOLTIP_EXTRA_VERTICAL_OFFSET_DP)
+
+ // align anchor center with tooltip center
+ val offsetX = anchor.width / 2 - tooltipWidth / 2
+ // make sure tooltip is visible and it's not displayed below, outside the view
+ val offsetY = if (checkY > 0) belowY else aboveY
+ return Offset(offsetX, offsetY)
+ }
+
+ @SuppressLint("InflateParams")
+ fun show() {
+ activeTooltip?.hide()
+ activeTooltip = this
+
+ val layout = LayoutInflater.from(anchor.context)
+ .inflate(R.layout.mozac_browser_tooltip_layout, null)
+
+ layout.findViewById<TextView>(R.id.mozac_browser_tooltip_text).text = tooltipText
+ popupWindow = PopupWindow(
+ layout,
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ false,
+ )
+
+ val offsets = computeOffsets()
+
+ popupWindow?.let {
+ PopupWindowCompat.setWindowLayoutType(it, WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL)
+ it.isTouchable = false
+ it.showAsDropDown(anchor, offsets.x, offsets.y, Gravity.CENTER)
+ }
+
+ anchor.removeCallbacks(hideTooltipRunnable)
+ anchor.postDelayed(hideTooltipRunnable, LONG_CLICK_HIDE_TIMEOUT_MS)
+ }
+
+ fun hide() {
+ if (activeTooltip === this) {
+ activeTooltip = null
+ popupWindow?.let {
+ it.dismiss()
+ popupWindow = null
+ anchor.removeOnAttachStateChangeListener(this)
+ }
+ }
+ }
+
+ override fun onViewAttachedToWindow(v: View) {
+ // no-op
+ }
+
+ override fun onViewDetachedFromWindow(v: View) {
+ hide()
+ anchor.removeCallbacks(hideTooltipRunnable)
+ }
+
+ companion object {
+ private const val LONG_CLICK_HIDE_TIMEOUT_MS: Long = 2500
+ private const val TOOLTIP_EXTRA_VERTICAL_OFFSET_DP = 8
+
+ // The tooltip currently being shown properly disposed in hide() / onViewDetachedFromWindow()
+ @SuppressLint("StaticFieldLeak")
+ private var activeTooltip: CustomTooltip? = null
+
+ /**
+ * Set the tooltip text for the view.
+ * @param view view to set the tooltip for
+ * @param tooltipText the tooltip text
+ */
+ fun setTooltipText(view: View, tooltipText: CharSequence) {
+ // check for dynamic content description
+ if (TextUtils.isEmpty(tooltipText)) {
+ activeTooltip?.let {
+ if (it.anchor === view) {
+ it.hide()
+ }
+ }
+ view.setOnLongClickListener(null)
+ view.isLongClickable = false
+ } else {
+ CustomTooltip(view, tooltipText)
+ }
+ }
+ }
+
+ /**
+ * A data class for storing x and y offsets
+ */
+ data class Offset(val x: Int, val y: Int)
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/ParentBrowserMenuItem.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/ParentBrowserMenuItem.kt
new file mode 100644
index 0000000000..5d910967ac
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/ParentBrowserMenuItem.kt
@@ -0,0 +1,105 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.View
+import android.widget.TextView
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.content.ContextCompat
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import mozilla.components.browser.menu.ext.asCandidateList
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.NestedMenuCandidate
+import mozilla.components.concept.menu.candidate.TextStyle
+
+/**
+ * A parent menu item for displaying text and an image icon with a nested sub menu.
+ * It handles back pressing if the sub menu contains a [BackPressMenuItem].
+ *
+ * @param label The visible label of this menu item.
+ * @param imageResource ID of a drawable resource to be shown as icon.
+ * @param iconTintColorResource Optional ID of color resource to tint the icon.
+ * @param textColorResource Optional ID of color resource to tint the text.
+ * @property subMenu Target sub menu to be shown when this menu item is clicked.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param endOfMenuAlwaysVisible when is set to true makes sure the bottom of the menu is always visible
+ * otherwise, the top of the menu is always visible.
+ */
+class ParentBrowserMenuItem(
+ internal val label: String,
+ @DrawableRes
+ private val imageResource: Int,
+ @ColorRes
+ private val iconTintColorResource: Int = NO_ID,
+ @ColorRes
+ private val textColorResource: Int = NO_ID,
+ internal val subMenu: BrowserMenu,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ endOfMenuAlwaysVisible: Boolean = false,
+) : AbstractParentBrowserMenuItem(subMenu, isCollapsingMenuLimit, endOfMenuAlwaysVisible) {
+
+ override var visible: () -> Boolean = { true }
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_item_parent_menu
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ bindText(view)
+ bindImage(view)
+ bindBackPress(menu, view)
+
+ super.bind(menu, view)
+ }
+
+ private fun bindText(view: View) {
+ val textView = view.findViewById<TextView>(R.id.text)
+ textView.text = label
+ textView.setColorResource(textColorResource)
+ }
+
+ private fun bindImage(view: View) {
+ val imageView = view.findViewById<AppCompatImageView>(R.id.image)
+ with(imageView) {
+ setImageResource(imageResource)
+ setTintResource(iconTintColorResource)
+ }
+ val overflowView = view.findViewById<AppCompatImageView>(R.id.overflowImage)
+ with(overflowView) {
+ visibility = View.VISIBLE
+ setTintResource(iconTintColorResource)
+ }
+ }
+
+ private fun bindBackPress(menu: BrowserMenu, view: View) {
+ val backPressMenuItem =
+ subMenu.adapter.visibleItems.find { it is BackPressMenuItem } as? BackPressMenuItem
+ backPressMenuItem?.let {
+ backPressMenuItem.setListener {
+ onBackPressed(menu, view)
+ }
+ }
+ }
+
+ override fun asCandidate(context: Context) = NestedMenuCandidate(
+ id = hashCode(),
+ text = label,
+ start = DrawableMenuIcon(
+ context,
+ resource = imageResource,
+ tint = if (iconTintColorResource == NO_ID) null else ContextCompat.getColor(context, iconTintColorResource),
+ ),
+ subMenuItems = subMenu.adapter.visibleItems.asCandidateList(context),
+ textStyle = TextStyle(
+ color = if (textColorResource == NO_ID) null else ContextCompat.getColor(context, textColorResource),
+ ),
+ containerStyle = ContainerStyle(isVisible = visible()),
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/SimpleBrowserMenuHighlightableItem.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/SimpleBrowserMenuHighlightableItem.kt
new file mode 100644
index 0000000000..3f7c2803f1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/SimpleBrowserMenuHighlightableItem.kt
@@ -0,0 +1,109 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.View
+import android.widget.TextView
+import androidx.annotation.ColorInt
+import androidx.annotation.ColorRes
+import androidx.core.content.ContextCompat
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.browser.menu.R
+import mozilla.components.browser.menu.ext.addRippleEffect
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.MenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextStyle
+
+/**
+ * A menu item for displaying text with a highlight state which sets the
+ * background of the menu item.
+ *
+ * @param label The default visible label of this menu item.
+ * @param textColorResource Optional ID of color resource to tint the text.
+ * @param textSize The size of the label.
+ * @param backgroundTint Tint for the menu item background color
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param isHighlighted Whether or not to display the highlight
+ * @param listener Callback to be invoked when this menu item is clicked.
+ */
+class SimpleBrowserMenuHighlightableItem(
+ private val label: String,
+ @ColorRes private val textColorResource: Int = NO_ID,
+ private val textSize: Float = NO_ID.toFloat(),
+ @ColorInt val backgroundTint: Int,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ var isHighlighted: () -> Boolean = { true },
+ private val listener: () -> Unit = {},
+) : BrowserMenuItem {
+
+ override var visible: () -> Boolean = { true }
+ private var wasHighlighted = false
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_item_simple
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ bindText(view)
+
+ view.setOnClickListener {
+ listener.invoke()
+ menu.dismiss()
+ }
+
+ wasHighlighted = isHighlighted()
+ updateHighlight(view, wasHighlighted)
+ }
+
+ private fun bindText(view: View) {
+ val textView = view as TextView
+ textView.text = label
+ textView.addRippleEffect()
+
+ if (textColorResource != NO_ID) {
+ textView.setColorResource(textColorResource)
+ }
+
+ if (textSize != NO_ID.toFloat()) {
+ textView.textSize = textSize
+ }
+ }
+
+ override fun invalidate(view: View) {
+ val isNowHighlighted = isHighlighted()
+ if (isNowHighlighted != wasHighlighted) {
+ wasHighlighted = isNowHighlighted
+ updateHighlight(view, isNowHighlighted)
+ }
+ }
+
+ private fun updateHighlight(view: View, isHighlighted: Boolean) {
+ val textView = view as TextView
+
+ if (isHighlighted) {
+ textView.setBackgroundColor(backgroundTint)
+ } else {
+ textView.addRippleEffect()
+ }
+ }
+
+ override fun asCandidate(context: Context): MenuCandidate {
+ val textStyle = TextStyle(
+ size = if (textSize == NO_ID.toFloat()) null else textSize,
+ color = if (textColorResource == NO_ID) null else ContextCompat.getColor(context, textColorResource),
+ )
+ val containerStyle = ContainerStyle(isVisible = visible())
+ return TextMenuCandidate(
+ label,
+ textStyle = textStyle,
+ containerStyle = containerStyle,
+ onClick = listener,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/SimpleBrowserMenuItem.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/SimpleBrowserMenuItem.kt
new file mode 100644
index 0000000000..9a350e3317
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/SimpleBrowserMenuItem.kt
@@ -0,0 +1,84 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.View
+import android.widget.TextView
+import androidx.annotation.ColorRes
+import androidx.core.content.ContextCompat.getColor
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.DecorativeTextMenuCandidate
+import mozilla.components.concept.menu.candidate.MenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextStyle
+
+/**
+ * A simple browser menu item displaying text.
+ *
+ * @param label The visible label of this menu item.
+ * @param textSize: The size of the label.
+ * @param textColorResource: The color resource to apply to the text.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param listener Callback to be invoked when this menu item is clicked.
+ */
+class SimpleBrowserMenuItem(
+ private val label: String,
+ private val textSize: Float = NO_ID.toFloat(),
+ @ColorRes
+ private val textColorResource: Int = NO_ID,
+ override val isCollapsingMenuLimit: Boolean = false,
+ private val listener: (() -> Unit)? = null,
+) : BrowserMenuItem {
+ override var visible: () -> Boolean = { true }
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_item_simple
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ val textView = view as TextView
+ textView.text = label
+
+ if (textSize != NO_ID.toFloat()) {
+ textView.textSize = textSize
+ }
+
+ textView.setColorResource(textColorResource)
+
+ if (listener != null) {
+ textView.setOnClickListener {
+ listener.invoke()
+ menu.dismiss()
+ }
+ } else {
+ // Remove the ripple effect
+ textView.background = null
+ }
+ }
+
+ override fun asCandidate(context: Context): MenuCandidate {
+ val textStyle = TextStyle(
+ size = if (textSize == NO_ID.toFloat()) null else textSize,
+ color = if (textColorResource == NO_ID) null else getColor(context, textColorResource),
+ )
+ val containerStyle = ContainerStyle(isVisible = visible())
+ return if (listener != null) {
+ TextMenuCandidate(
+ label,
+ textStyle = textStyle,
+ containerStyle = containerStyle,
+ onClick = listener,
+ )
+ } else {
+ DecorativeTextMenuCandidate(
+ label,
+ textStyle = textStyle,
+ containerStyle = containerStyle,
+ )
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/TwoStateBrowserMenuImageText.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/TwoStateBrowserMenuImageText.kt
new file mode 100644
index 0000000000..6cb2b70eb2
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/TwoStateBrowserMenuImageText.kt
@@ -0,0 +1,133 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.View
+import android.widget.TextView
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.content.ContextCompat
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.MenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextStyle
+
+/**
+ * A browser menu item with two states, used for displaying text with an image icon
+ *
+ * @param primaryLabel The visible label of the checkbox in primary state.
+ * @param secondaryLabel The visible label of this menu item in secondary state.
+ * @param textColorResource Optional ID of color resource to tint the text.
+ * @param enabled Sets the enabled status for the view. By default, it is true.
+ * @param primaryStateIconResource ID of a drawable resource to be shown as icon in primary state.
+ * @param secondaryStateIconResource ID of a drawable resource to be shown as icon in secondary state.
+ * @param iconTintColorResource Optional ID of color resource to tint the checkbox drawable.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ * @param isInPrimaryState Lambda to return true/false to indicate item is in primary state.
+ * @param isInSecondaryState Lambda to return true/false to indicate item is in secondary state
+ * @param primaryStateAction Callback to be invoked when this menu item is clicked in primary state.
+ * @param secondaryStateAction Callback to be invoked when this menu item is clicked in secondary state.
+ */
+class TwoStateBrowserMenuImageText(
+ private val primaryLabel: String,
+ private val secondaryLabel: String,
+ @ColorRes internal val textColorResource: Int = NO_ID,
+ enabled: Boolean = true,
+ @DrawableRes val primaryStateIconResource: Int,
+ @DrawableRes val secondaryStateIconResource: Int,
+ @ColorRes iconTintColorResource: Int = NO_ID,
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+ val isInPrimaryState: () -> Boolean = { true },
+ val isInSecondaryState: () -> Boolean = { false },
+ private val primaryStateAction: () -> Unit = { },
+ private val secondaryStateAction: () -> Unit = { },
+) : BrowserMenuImageText(
+ primaryLabel,
+ primaryStateIconResource,
+ iconTintColorResource,
+ textColorResource,
+ enabled,
+ isCollapsingMenuLimit,
+ isSticky,
+ primaryStateAction,
+) {
+ override var visible: () -> Boolean = { isInPrimaryState() || isInSecondaryState() }
+
+ override fun getLayoutResource(): Int =
+ R.layout.mozac_browser_menu_item_image_text
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ val isInPrimaryState = isInPrimaryState()
+ bindText(view, isInPrimaryState)
+ bindImage(view, isInPrimaryState)
+
+ val listener = if (isInPrimaryState) primaryStateAction else secondaryStateAction
+ view.setOnClickListener {
+ listener.invoke()
+ menu.dismiss()
+ }
+ }
+
+ private fun bindText(view: View, isInPrimaryState: Boolean) {
+ val textView = view.findViewById<TextView>(R.id.text)
+ textView.text = if (isInPrimaryState) primaryLabel else secondaryLabel
+ textView.setColorResource(textColorResource)
+ }
+
+ private fun bindImage(view: View, isInPrimaryState: Boolean) {
+ val imageView = view.findViewById<AppCompatImageView>(R.id.image)
+ val imageResource =
+ if (isInPrimaryState) primaryStateIconResource else secondaryStateIconResource
+
+ with(imageView) {
+ setImageResource(imageResource)
+ setTintResource(iconTintColorResource)
+ }
+ }
+
+ override fun asCandidate(context: Context): MenuCandidate = TextMenuCandidate(
+ if (isInPrimaryState()) {
+ primaryLabel
+ } else {
+ secondaryLabel
+ },
+ start = DrawableMenuIcon(
+ context,
+ resource = if (isInPrimaryState()) {
+ primaryStateIconResource
+ } else {
+ secondaryStateIconResource
+ },
+ tint = if (iconTintColorResource == NO_ID) {
+ null
+ } else {
+ ContextCompat.getColor(
+ context,
+ iconTintColorResource,
+ )
+ },
+ ),
+ textStyle = TextStyle(
+ color = if (textColorResource == NO_ID) {
+ null
+ } else {
+ ContextCompat.getColor(
+ context,
+ textColorResource,
+ )
+ },
+ ),
+ containerStyle = ContainerStyle(isVisible = visible()),
+ onClick = if (isInPrimaryState()) primaryStateAction else secondaryStateAction,
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItem.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItem.kt
new file mode 100644
index 0000000000..7eed3ac516
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItem.kt
@@ -0,0 +1,168 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.content.res.AppCompatResources.getDrawable
+import androidx.core.graphics.drawable.toDrawable
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.engine.webextension.Action
+import mozilla.components.concept.menu.candidate.AsyncDrawableMenuIcon
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuIcon
+import mozilla.components.concept.menu.candidate.TextStyle
+import mozilla.components.support.base.log.Log
+import mozilla.components.ui.icons.R as iconsR
+
+/**
+ * A browser menu item displaying a web extension action.
+ *
+ * @param action the [Action] to display.
+ * @param listener a callback to be invoked when this menu item is clicked.
+ * @param isCollapsingMenuLimit Whether this menu item can serve as the limit of a collapsing menu.
+ * @param isSticky whether this item menu should not be scrolled offscreen (downwards or upwards
+ * depending on the menu position).
+ */
+class WebExtensionBrowserMenuItem(
+ internal var action: Action,
+ internal val listener: () -> Unit,
+ internal val id: String = "",
+ override val isCollapsingMenuLimit: Boolean = false,
+ override val isSticky: Boolean = false,
+) : BrowserMenuItem {
+ override var visible: () -> Boolean = { true }
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_web_extension
+
+ @VisibleForTesting
+ internal var iconTintColorResource: Int? = null
+
+ @Suppress("TooGenericExceptionCaught")
+ override fun bind(menu: BrowserMenu, view: View) {
+ val container = view.findViewById<View>(R.id.container)
+ updateItem(view)
+ container.setOnClickListener {
+ listener.invoke()
+ menu.dismiss()
+ }
+ }
+
+ override fun invalidate(view: View) {
+ val labelView = view.findViewById<TextView>(R.id.action_label)
+ val badgeView = view.findViewById<TextView>(R.id.badge_text)
+ val imageView = view.findViewById<ImageView>(R.id.action_image)
+
+ updateItem(view)
+
+ labelView.invalidate()
+ badgeView.invalidate()
+ imageView.invalidate()
+ }
+
+ @VisibleForTesting
+ internal fun updateItem(view: View) {
+ val imageView = view.findViewById<ImageView>(R.id.action_image)
+ val labelView = view.findViewById<TextView>(R.id.action_label)
+ val badgeView = view.findViewById<TextView>(R.id.badge_text)
+ val container = view.findViewById<View>(R.id.container)
+
+ container.isEnabled.updateIfChange(new = action.enabled ?: true) {
+ container.isEnabled = it
+ }
+
+ imageView.contentDescription.updateIfChange(action.title) {
+ imageView.contentDescription = it
+ }
+ labelView.text.updateIfChange(action.title) {
+ labelView.text = it
+ }
+ badgeView.setBadgeText(action.badgeText)
+ action.badgeTextColor?.let { badgeView.setTextColor(it) }
+ action.badgeBackgroundColor?.let { badgeView.background?.setTint(it) }
+ setupIcon(view, imageView, iconTintColorResource)
+ }
+
+ private inline fun <T> T.updateIfChange(new: T, setter: (T) -> Unit) {
+ if (this != new) {
+ setter(new)
+ }
+ }
+
+ override fun asCandidate(context: Context) = TextMenuCandidate(
+ action.title.orEmpty(),
+ start = AsyncDrawableMenuIcon(
+ loadDrawable = { _, height -> loadIcon(context, height) },
+ ),
+ end = action.badgeText?.let { badgeText ->
+ TextMenuIcon(
+ badgeText,
+ backgroundTint = action.badgeBackgroundColor,
+ textStyle = TextStyle(
+ color = action.badgeTextColor,
+ ),
+ )
+ },
+ containerStyle = ContainerStyle(
+ isVisible = visible(),
+ isEnabled = action.enabled ?: false,
+ ),
+ onClick = listener,
+ )
+
+ @VisibleForTesting
+ internal fun setupIcon(view: View, imageView: ImageView, iconTintColorResource: Int?) {
+ MainScope().launch {
+ loadIcon(view.context, imageView.measuredHeight)?.let {
+ iconTintColorResource?.let { tint -> imageView.setTintResource(tint) }
+ imageView.setImageDrawable(it)
+ }
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun loadIcon(context: Context, height: Int): Drawable? {
+ return try {
+ action.loadIcon?.invoke(height)?.toDrawable(context.resources)
+ } catch (throwable: Throwable) {
+ Log.log(
+ Log.Priority.ERROR,
+ "mozac-webextensions",
+ throwable,
+ "Failed to load browser action icon, falling back to default.",
+ )
+
+ getDrawable(context, iconsR.drawable.mozac_ic_web_extension_default_icon)
+ }
+ }
+
+ /**
+ * Sets the tint to be applied to the extension icon
+ */
+ fun setIconTint(iconTintColorResource: Int?) {
+ iconTintColorResource?.let { this.iconTintColorResource = it }
+ }
+}
+
+/**
+ * Sets the badgeText and the visibility of the TextView based on empty/nullability of the badgeText.
+ */
+fun TextView.setBadgeText(badgeText: String?) {
+ if (badgeText.isNullOrEmpty()) {
+ visibility = View.INVISIBLE
+ } else {
+ visibility = View.VISIBLE
+ text = badgeText
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionPlaceholderMenuItem.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionPlaceholderMenuItem.kt
new file mode 100644
index 0000000000..b76c55e793
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/WebExtensionPlaceholderMenuItem.kt
@@ -0,0 +1,36 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.view.View
+import androidx.annotation.ColorRes
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.browser.menu.R
+
+/**
+ * A browser menu item that is to be used only as a placeholder for inserting web extensions in main menu.
+ * The id of the web extension to be inserted has to correspond to the id of the browser menu item.
+ *
+ * @param id The id of this menu item.
+ * @param iconTintColorResource Optional ID of color resource to tint the icon.
+ */
+class WebExtensionPlaceholderMenuItem(
+ val id: String,
+ @ColorRes
+ val iconTintColorResource: Int = NO_ID,
+) : BrowserMenuItem {
+ override var visible: () -> Boolean = { false }
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_item_simple
+
+ override fun bind(menu: BrowserMenu, view: View) {
+ // no binding, item not visible.
+ }
+
+ companion object {
+ const val MAIN_EXTENSIONS_MENU_ID = "mainExtensionsMenu"
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/DynamicWidthRecyclerView.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/DynamicWidthRecyclerView.kt
new file mode 100644
index 0000000000..d4d46a9aca
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/DynamicWidthRecyclerView.kt
@@ -0,0 +1,86 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.util.AttributeSet
+import androidx.annotation.Px
+import androidx.annotation.VisibleForTesting
+import androidx.recyclerview.widget.RecyclerView
+import mozilla.components.browser.menu.R
+
+/**
+ * [RecyclerView] with automatically set width between widthMin / widthMax xml attributes.
+ */
+class DynamicWidthRecyclerView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+) : RecyclerView(context, attrs) {
+ @VisibleForTesting
+ @Px
+ internal var maxWidthOfAllChildren: Int = 0
+ set(value) {
+ if (field == 0) field = value
+ }
+
+ @Px var minWidth: Int = -1
+
+ @Px var maxWidth: Int = -1
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ override fun onMeasure(widthSpec: Int, heightSpec: Int) {
+ if (minWidth in 1 until maxWidth) {
+ // Ignore any bounds set in xml. Allow for children to expand entirely.
+ callParentOnMeasure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), heightSpec)
+
+ // First measure will report the width/height for the entire list
+ // The first layout pass will actually remove child views that do not fit the screen
+ // so future onMeasure calls will report skewed values.
+ maxWidthOfAllChildren = measuredWidth
+
+ // Children now have "unspecified" width. Let's set some bounds.
+ setReconciledDimensions(maxWidthOfAllChildren, measuredHeight)
+ } else {
+ // Default behavior. layout_width / layout_height properties will be used for measuring.
+ callParentOnMeasure(widthSpec, heightSpec)
+ }
+ }
+
+ @VisibleForTesting
+ internal fun setReconciledDimensions(
+ desiredWidth: Int,
+ desiredHeight: Int,
+ ) {
+ val minimumTapArea = resources.getDimensionPixelSize(R.dimen.mozac_browser_menu_material_min_tap_area)
+ val minimumItemWidth = resources.getDimensionPixelSize(R.dimen.mozac_browser_menu_material_min_item_width)
+
+ val reconciledWidth = desiredWidth
+ .coerceAtLeast(minWidth)
+ // Follow material guidelines where the minimum width is 112dp.
+ .coerceAtLeast(minimumItemWidth)
+ .coerceAtMost(maxWidth)
+ // Leave at least 48dp as a tappable “exit area” available whenever the menu is open.
+ .coerceAtMost(getScreenWidth() - minimumTapArea)
+
+ callSetMeasuredDimension(reconciledWidth, desiredHeight)
+ }
+
+ @VisibleForTesting
+ internal fun getScreenWidth(): Int = resources.displayMetrics.widthPixels
+
+ @SuppressLint("WrongCall")
+ @VisibleForTesting
+ // Used for testing protected super.onMeasure(..) calls will be executed.
+ internal fun callParentOnMeasure(widthSpec: Int, heightSpec: Int) {
+ super.onMeasure(widthSpec, heightSpec)
+ }
+
+ @VisibleForTesting
+ // Used for testing final protected setMeasuredDimension(..) calls were executed
+ internal fun callSetMeasuredDimension(width: Int, height: Int) {
+ setMeasuredDimension(width, height)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/ExpandableLayout.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/ExpandableLayout.kt
new file mode 100644
index 0000000000..1eb2e56509
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/ExpandableLayout.kt
@@ -0,0 +1,436 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.Rect
+import android.view.MotionEvent
+import android.view.ViewConfiguration
+import android.view.ViewGroup
+import android.view.animation.AccelerateDecelerateInterpolator
+import android.widget.FrameLayout
+import androidx.annotation.VisibleForTesting
+import androidx.core.animation.doOnEnd
+import androidx.core.view.children
+import androidx.core.view.marginBottom
+import androidx.core.view.marginLeft
+import androidx.core.view.marginRight
+import androidx.core.view.marginTop
+import androidx.core.view.updateLayoutParams
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * ViewGroup intended to wrap another to then allow for the following automatic behavior:
+ * - when first laid out on the screen the wrapped view is collapsed.
+ * - informs about touches in the empty space left by the collapsed view through [blankTouchListener].
+ * - when users swipe up it will expand. Once expanded it remains so.
+ */
+@Suppress("TooManyFunctions", "LargeClass")
+internal class ExpandableLayout private constructor(context: Context) : FrameLayout(context) {
+ /**
+ * The wrapped view that needs to be collapsed / expanded.
+ */
+ @VisibleForTesting
+ internal lateinit var wrappedView: ViewGroup
+
+ /**
+ * Listener of touches in the empty space left by the collapsed view.
+ */
+ @VisibleForTesting
+ internal var blankTouchListener: (() -> Unit)? = null
+
+ /**
+ * Index of the last menu item that should be visible when the wrapped view is collapsed.
+ */
+ @VisibleForTesting
+ internal var lastVisibleItemIndexWhenCollapsed: Int = Int.MAX_VALUE
+
+ /**
+ * Index of the sticky footer, if such an item is set.
+ */
+ @VisibleForTesting
+ internal var stickyItemIndex: Int = RecyclerView.NO_POSITION
+
+ /**
+ * Height of wrapped view when collapsed.
+ * Calculated once based on the position of the "isCollapsingMenuLimit" BrowserMenuItem.
+ * Capped by [parentHeight]
+ */
+ @VisibleForTesting
+ internal var collapsedHeight: Int = NOT_CALCULATED_DEFAULT_HEIGHT
+
+ /**
+ * Height of wrapped view when expanded.
+ * Calculated once based on measuredHeighWithMargins().
+ * Capped by [parentHeight]
+ */
+ @VisibleForTesting
+ internal var expandedHeight: Int = NOT_CALCULATED_DEFAULT_HEIGHT
+
+ /**
+ * Available space given by the parent.
+ */
+ @VisibleForTesting
+ internal var parentHeight: Int = NOT_CALCULATED_DEFAULT_HEIGHT
+
+ /**
+ * Whether to intercept touches while the view is collapsed.
+ * If true:
+ * - a swipe up will be intercepted and used to expand the wrapped view.
+ * - a swipe in the empty space left by the collapsed view will be intercepted
+ * and [blankTouchListener] will be called.
+ * - other touches / gestures will be left to pass through to the children.
+ */
+ @VisibleForTesting
+ internal var isCollapsed = true
+
+ /**
+ * Whether to intercept touches while the view is expanding.
+ * If true:
+ * - all touches / gestures will be intercepted.
+ */
+ @VisibleForTesting
+ internal var isExpandInProgress = false
+
+ /**
+ * Distance in pixels a touch can wander before we think the user is scrolling.
+ * (If this would be bigger than that of a child the child will react to the scroll first)
+ */
+ @VisibleForTesting
+ internal var touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
+
+ /**
+ * Y axis coordinate of the [MotionEvent.ACTION_DOWN] event.
+ * Used to calculate the distance scrolled, to know when the view should be expanded.
+ */
+ @VisibleForTesting
+ internal var initialYCoord = NOT_CALCULATED_Y_TOUCH_COORD
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ callParentOnMeasure(widthMeasureSpec, heightMeasureSpec)
+
+ // Avoid new separate measure calls specifically for our usecase. Piggyback on the already requested ones.
+ // Calculate our needed dimensions and collapse the menu when based on them.
+ if (isCollapsed && getOrCalculateCollapsedHeight() > 0 && getOrCalculateExpandedHeight(heightMeasureSpec) > 0) {
+ collapse()
+ }
+ }
+
+ // While this view is collapsed (not fully expanded) we want to intercept all vertical scrolls
+ // that will be used as an indicator to expand the view,
+ // while letting all simple touch events get handled by children's click listeners.
+ //
+ // Also if this view is collapsed (full height but translated) we want to treat any touch in the
+ // invisible space as a dismiss event.
+ @Suppress("ComplexMethod", "ReturnCount")
+ override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
+ if (shouldInterceptTouches()) {
+ return when (ev?.actionMasked) {
+ MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
+ false // Allow click listeners firing for children.
+ }
+ MotionEvent.ACTION_DOWN -> {
+ if (isExpandInProgress) {
+ return true
+ }
+
+ // Check if user clicked in the empty space left by this collapsed View.
+ if (!isTouchingTheWrappedView(ev)) {
+ blankTouchListener?.invoke()
+ }
+
+ initialYCoord = ev.y
+
+ false // Allow click listeners firing for children.
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (isScrollingUp(ev)) {
+ expand()
+ true
+ } else {
+ false
+ }
+ }
+ else -> {
+ // In general, we don't want to intercept touch events.
+ // They should be handled by the child view.
+ return callParentOnInterceptTouchEvent(ev)
+ }
+ }
+ } else {
+ return if (ev != null && !isTouchingTheWrappedView(ev)) {
+ // If the menu is expanded but smaller than the parent height
+ // and the user touches above the menu, in the empty space.
+ blankTouchListener?.invoke()
+ true
+ } else if (isExpandInProgress) {
+ // Swallow all menu touches while the menu is expanding.
+ true
+ } else {
+ callParentOnInterceptTouchEvent(ev)
+ }
+ }
+ }
+
+ @VisibleForTesting
+ internal fun shouldInterceptTouches() = isCollapsed && !isExpandInProgress
+
+ @VisibleForTesting
+ internal fun isTouchingTheWrappedView(ev: MotionEvent): Boolean {
+ val childrenBounds = Rect()
+ wrappedView.getHitRect(childrenBounds)
+ return childrenBounds.contains(ev.x.toInt(), ev.y.toInt())
+ }
+
+ @VisibleForTesting
+ internal fun collapse() {
+ wrappedView.translationY = parentHeight.toFloat() - collapsedHeight
+ wrappedView.updateLayoutParams {
+ height = collapsedHeight
+ }
+ }
+
+ @VisibleForTesting
+ internal fun expand() {
+ isCollapsed = false
+ isExpandInProgress = true
+
+ val initialTranslation = wrappedView.translationY
+ val distanceToExpandedHeight = expandedHeight - collapsedHeight
+ getExpandViewAnimator(distanceToExpandedHeight).apply {
+ doOnEnd {
+ isExpandInProgress = false
+ }
+
+ addUpdateListener {
+ wrappedView.translationY = initialTranslation - it.animatedValue as Int
+ wrappedView.updateLayoutParams {
+ height = collapsedHeight + it.animatedValue as Int
+ }
+ }
+ start()
+ }
+ }
+
+ @VisibleForTesting
+ internal fun getExpandViewAnimator(expandDelta: Int): ValueAnimator {
+ return ValueAnimator.ofInt(0, expandDelta).apply {
+ this.interpolator = AccelerateDecelerateInterpolator()
+ this.duration = DEFAULT_DURATION_EXPAND_ANIMATOR
+ }
+ }
+
+ @VisibleForTesting
+ internal fun getOrCalculateCollapsedHeight(): Int {
+ // Memoize the value.
+ // Method will be called multiple times. Result will always be the same.
+ if (collapsedHeight < 0) {
+ collapsedHeight = calculateCollapsedHeight()
+ }
+
+ return collapsedHeight
+ }
+
+ @VisibleForTesting
+ internal fun getOrCalculateExpandedHeight(heightSpec: Int): Int {
+ if (expandedHeight < 0) {
+ // Value from a measurement done with MeasureSpec.UNSPECIFIED.
+ // May need to be capped by the parent height.
+ expandedHeight = wrappedView.measuredHeight
+ }
+
+ val heightSpecSize = MeasureSpec.getSize(heightSpec)
+ // heightSpecSize can be 0 for a MeasureSpec.UNSPECIFIED.
+ // Ignore that, wait for a heightSpec that will contain parent height.
+ if (parentHeight < 0 && heightSpecSize > 0) {
+ parentHeight = heightSpecSize
+
+ // Ensure a menu with a bigger height than the parent will be correctly laid out.
+ expandedHeight = minOf(expandedHeight, parentHeight)
+
+ // Ensure the collapsedHeight we calculated is not bigger than the expanded height
+ // now capped by parent height.
+ // This might happen if the menu is shown in landscape and there is no space to show
+ // the lastVisibleItemIndexWhenCollapsed.
+ if (collapsedHeight >= expandedHeight) {
+ // If there's no space to show the lastVisibleItemIndexWhenCollapsed even if the
+ // wrappedView is collapsed there's no need to collapse the view.
+ collapsedHeight = expandedHeight
+ isExpandInProgress = false
+ isCollapsed = false
+ }
+ }
+
+ return expandedHeight
+ }
+
+ @Suppress("WrongCall")
+ @VisibleForTesting
+ // Used for testing protected super.onMeasure(..) calls will be executed.
+ internal fun callParentOnMeasure(widthSpec: Int, heightSpec: Int) {
+ super.onMeasure(widthSpec, heightSpec)
+ }
+
+ @Suppress("WrongCall")
+ @VisibleForTesting
+ // Used for testing protected super.onInterceptTouchEvent(..) calls will be executed.
+ internal fun callParentOnInterceptTouchEvent(ev: MotionEvent?): Boolean {
+ return super.onInterceptTouchEvent(ev)
+ }
+
+ /**
+ * Whether based on the previous movements, when considering this [event]
+ * it can be inferred that the user is currently scrolling up.
+ */
+ @VisibleForTesting
+ internal fun isScrollingUp(event: MotionEvent): Boolean {
+ val yDistance = initialYCoord - event.y
+
+ return yDistance >= touchSlop
+ }
+
+ // We need a dynamic way to get the intended collapsed height of this view before it will be laid out on the screen.
+ // This method assumes the following layout:
+ // ____________________________________________________
+ // this -> | ----------------------------------- |
+ // | ViewGroup -> | ---------------- | |
+ // | | RecyclerView-> | View | | |
+ // | | | View | | |
+ // | | | View | | |
+ // | | | SpecialView | | |
+ // | | | View | | |
+ // | | ---------------- | |
+ // | ----------------------------------- |
+ // ----------------------------------------------------
+ // for which we want to measure the distance (height) between [this#top, half of SpecialView].
+ // That distance will be the collapsed height of the ViewGroup used when this will be first shown on the screen.
+ // Users will be able to afterwards expand the ViewGroup to the full height.
+ @VisibleForTesting
+ @Suppress("ReturnCount")
+ internal fun calculateCollapsedHeight(): Int {
+ val listView = (wrappedView.getChildAt(0) as RecyclerView)
+ // Reconcile adapter positions with listView children positions.
+ // Avoid IndexOutOfBounds / NullPointer exceptions.
+ val validLastVisibleItemIndexWhenCollapsed = getChildPositionForAdapterIndex(
+ listView,
+ lastVisibleItemIndexWhenCollapsed,
+ )
+ val validStickyItemIndex = getChildPositionForAdapterIndex(
+ listView,
+ stickyItemIndex,
+ )
+
+ // Simple sanity check
+ if (validLastVisibleItemIndexWhenCollapsed >= listView.childCount ||
+ validLastVisibleItemIndexWhenCollapsed <= 0
+ ) {
+ return measuredHeight
+ }
+
+ var result = 0
+ result += wrappedView.marginTop
+ result += wrappedView.marginBottom
+ result += wrappedView.paddingTop
+ result += wrappedView.paddingBottom
+ result += listView.marginTop
+ result += listView.marginBottom
+ result += listView.paddingTop
+ result += listView.paddingBottom
+
+ run loop@{
+ listView.children.forEachIndexed { index, view ->
+ if (index < validLastVisibleItemIndexWhenCollapsed) {
+ result += view.marginTop
+ result += view.marginBottom
+ result += view.measuredHeight
+ } else if (index == validLastVisibleItemIndexWhenCollapsed) {
+ result += view.marginTop
+
+ // Edgecase: if the same item is the sticky footer and the lastVisibleItemIndexWhenCollapsed
+ // the menu will be collapsed to this item but shown with full height.
+ if (index == validStickyItemIndex) {
+ result += view.measuredHeight
+ return@loop
+ } else {
+ result += view.measuredHeight / 2
+ }
+ } else {
+ // If there is a sticky item below we need to add it's height as an offset.
+ // Otherwise the sticky item will cover the the view of lastVisibleItemIndexWhenCollapsed.
+ if (index <= validStickyItemIndex) {
+ result += listView.getChildAt(validStickyItemIndex).measuredHeight
+ }
+ return@loop
+ }
+ }
+ }
+
+ return result
+ }
+
+ /**
+ * In a dynamic menu - one in which items or their positions may change the adapter position and
+ * the RecyclerView position for the same item may differ.
+ * This method helps reconcile that.
+ *
+ * @return the RecyclerView position for the item at the [adapterIndex] in the adapter or
+ * [RecyclerView.NO_POSITION] if there is no child for the indicated adapter position.
+ */
+ @VisibleForTesting
+ internal fun getChildPositionForAdapterIndex(listView: RecyclerView, adapterIndex: Int): Int {
+ listView.children.forEachIndexed { index, view ->
+ if (listView.getChildAdapterPosition(view) == adapterIndex) {
+ return index
+ }
+ }
+
+ return RecyclerView.NO_POSITION
+ }
+
+ internal companion object {
+ @VisibleForTesting
+ const val NOT_CALCULATED_DEFAULT_HEIGHT = -1
+
+ @VisibleForTesting
+ const val NOT_CALCULATED_Y_TOUCH_COORD = 0f
+
+ /**
+ * Duration of the expand animation. Same value as the one from [R.android.integer.config_shortAnimTime]
+ */
+ @VisibleForTesting
+ const val DEFAULT_DURATION_EXPAND_ANIMATOR = 200L
+
+ /**
+ * Wraps a content view in an [ExpandableLayout].
+ *
+ * @param contentView the content view to wrap.
+ * @return a [ExpandableLayout] that wraps the content view.
+ */
+ internal fun wrapContentInExpandableView(
+ contentView: ViewGroup,
+ lastVisibleItemIndexWhenCollapsed: Int = Int.MAX_VALUE,
+ stickyFooterItemIndex: Int = RecyclerView.NO_POSITION,
+ blankTouchListener: (() -> Unit)? = null,
+ ): ExpandableLayout {
+ val expandableView = ExpandableLayout(contentView.context)
+ val params = MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
+ .apply {
+ leftMargin = contentView.marginLeft
+ topMargin = contentView.marginTop
+ rightMargin = contentView.marginRight
+ bottomMargin = contentView.marginBottom
+ }
+ expandableView.addView(contentView, params)
+
+ expandableView.wrappedView = contentView
+ expandableView.stickyItemIndex = stickyFooterItemIndex
+ expandableView.blankTouchListener = blankTouchListener
+ expandableView.lastVisibleItemIndexWhenCollapsed = lastVisibleItemIndexWhenCollapsed
+
+ return expandableView
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/MenuButton.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/MenuButton.kt
new file mode 100644
index 0000000000..f9b3db51cd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/MenuButton.kt
@@ -0,0 +1,211 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.util.AttributeSet
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.ImageView
+import androidx.annotation.ColorInt
+import androidx.annotation.VisibleForTesting
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenu.Orientation
+import mozilla.components.browser.menu.BrowserMenuBuilder
+import mozilla.components.browser.menu.BrowserMenuHighlight
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.MenuButton
+import mozilla.components.concept.menu.MenuController
+import mozilla.components.concept.menu.candidate.HighPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.LowPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.MenuCandidate
+import mozilla.components.concept.menu.candidate.MenuEffect
+import mozilla.components.concept.menu.ext.effects
+import mozilla.components.concept.menu.ext.max
+import mozilla.components.support.base.observer.Observable
+import mozilla.components.support.base.observer.ObserverRegistry
+import mozilla.components.support.ktx.android.view.hideKeyboard
+
+/**
+ * A `three-dot` button used for expanding menus.
+ *
+ * If you are using a browser toolbar, do not use this class directly.
+ */
+class MenuButton @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : FrameLayout(context, attrs, defStyleAttr),
+ MenuButton,
+ View.OnClickListener,
+ Observable<MenuButton.Observer> by ObserverRegistry() {
+
+ private val menuControllerObserver = object : MenuController.Observer {
+ /**
+ * Change the menu button appearance when the menu list changes.
+ */
+ override fun onMenuListSubmit(list: List<MenuCandidate>) {
+ val effect = list.effects().max()
+
+ // If a highlighted item is found, show the indicator
+ setEffect(effect)
+ }
+
+ override fun onDismiss() = notifyObservers { onDismiss() }
+ }
+
+ /**
+ * Listener called when the menu is shown.
+ */
+ @Deprecated("Use the Observable interface to listen for onShow")
+ var onShow: () -> Unit = {}
+
+ /**
+ * Listener called when the menu is dismissed.
+ */
+ @Deprecated("Use the Observable interface to listen for onDismiss")
+ var onDismiss: () -> Unit = {}
+
+ /**
+ * Callback to get the orientation for the menu.
+ * This is called every time the menu should be displayed.
+ * This has no effect when a [MenuController] is set.
+ */
+ var getOrientation: () -> Orientation = {
+ BrowserMenu.determineMenuOrientation(parent as? View?)
+ }
+
+ /**
+ * Sets a [MenuController] that will be used to create a menu when this button is clicked.
+ * If present, [menuBuilder] will be ignored.
+ */
+ override var menuController: MenuController? = null
+ set(value) {
+ // Clean up old controller
+ field?.dismiss()
+ field?.unregister(menuControllerObserver)
+
+ // Attach new controller
+ field = value
+ value?.register(menuControllerObserver, this)
+ }
+
+ /**
+ * Sets a [BrowserMenuBuilder] that will be used to create a menu when this button is clicked.
+ */
+ var menuBuilder: BrowserMenuBuilder? = null
+ set(value) {
+ field = value
+ menu?.dismiss()
+ if (value == null) menu = null
+ }
+
+ var recordClickEvent: () -> Unit = {}
+
+ @VisibleForTesting internal var menu: BrowserMenu? = null
+
+ private val menuIcon: ImageView
+ private val highlightView: ImageView
+ private val notificationIconView: ImageView
+
+ init {
+ View.inflate(context, R.layout.mozac_browser_menu_button, this)
+ setOnClickListener(this)
+ menuIcon = findViewById(R.id.icon)
+ highlightView = findViewById(R.id.highlight)
+ notificationIconView = findViewById(R.id.notification_dot)
+
+ // Hook up deprecated callbacks using new observer system
+ @Suppress("Deprecation")
+ val internalObserver = object : MenuButton.Observer {
+ override fun onShow() = this@MenuButton.onShow()
+ override fun onDismiss() = this@MenuButton.onDismiss()
+ }
+ register(internalObserver)
+ }
+
+ /**
+ * Shows the menu, or dismisses it if already open.
+ */
+ override fun onClick(v: View) {
+ this.hideKeyboard()
+ recordClickEvent()
+
+ // If a legacy menu is open, dismiss it.
+ if (menu != null) {
+ menu?.dismiss()
+ return
+ }
+
+ val menuController = menuController
+ if (menuController != null) {
+ // Use the newer menu controller if set
+ menuController.show(anchor = this)
+ } else {
+ menu = menuBuilder?.build(context)
+ val endAlwaysVisible = menuBuilder?.endOfMenuAlwaysVisible ?: false
+ menu?.show(
+ anchor = this,
+ orientation = getOrientation(),
+ endOfMenuAlwaysVisible = endAlwaysVisible,
+ ) {
+ menu = null
+ notifyObservers { onDismiss() }
+ }
+ }
+ notifyObservers { onShow() }
+ }
+
+ /**
+ * Show the indicator for a browser menu highlight.
+ */
+ fun setHighlight(highlight: BrowserMenuHighlight?) =
+ setEffect(highlight?.asEffect(context))
+
+ /**
+ * Show the indicator for a browser menu effect.
+ */
+ override fun setEffect(effect: MenuEffect?) {
+ when (effect) {
+ is HighPriorityHighlightEffect -> {
+ highlightView.imageTintList = ColorStateList.valueOf(effect.backgroundTint)
+ highlightView.visibility = View.VISIBLE
+ notificationIconView.visibility = View.GONE
+ }
+ is LowPriorityHighlightEffect -> {
+ notificationIconView.setColorFilter(effect.notificationTint)
+ highlightView.visibility = View.GONE
+ notificationIconView.visibility = View.VISIBLE
+ }
+ null -> {
+ highlightView.visibility = View.GONE
+ notificationIconView.visibility = View.GONE
+ }
+ }
+ }
+
+ /**
+ * Sets the tint of the 3-dot menu icon.
+ */
+ override fun setColorFilter(@ColorInt color: Int) {
+ menuIcon.setColorFilter(color)
+ }
+
+ /**
+ * Dismiss the menu, if open.
+ */
+ fun dismissMenu() {
+ menuController?.dismiss()
+ menu?.dismiss()
+ }
+
+ /**
+ * Invalidates the [BrowserMenu], if open.
+ */
+ fun invalidateBrowserMenu() {
+ menu?.invalidate()
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyFooterLinearLayoutManager.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyFooterLinearLayoutManager.kt
new file mode 100644
index 0000000000..b2c2d6a18a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyFooterLinearLayoutManager.kt
@@ -0,0 +1,74 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.content.Context
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * Vertical LinearLayoutManager that will ensure an item at a position specified through
+ * [StickyItemsAdapter.isStickyItem] will not scroll past the list bottom.
+ *
+ * The list would otherwise scroll normally with the other elements being scrolled beneath the sticky item.
+ *
+ * @param context [Context] needed for various Android interactions.
+ * @param reverseLayout When set to true, layouts from end to start.
+ */
+open class StickyFooterLinearLayoutManager<T> constructor(
+ context: Context,
+ reverseLayout: Boolean = false,
+) : StickyItemsLinearLayoutManager<T>(
+ context,
+ StickyItemPlacement.BOTTOM,
+ reverseLayout,
+) where T : RecyclerView.Adapter<*>, T : StickyItemsAdapter {
+
+ override fun scrollToIndicatedPositionWithOffset(
+ position: Int,
+ offset: Int,
+ actuallyScrollToPositionWithOffset: (Int, Int) -> Unit,
+ ) {
+ // The following scenarios are handled:
+ // - if position is bigger than [stickyItemPosition]
+ // -> the default behavior will have the list scrolled downwards enough to show that.
+ // - if position is the one of the stickyItem
+ // -> the default behavior will scroll to exactly the header. Perfect match.
+ // - if position is before that of the [stickyItem] and does not fit the screen
+ // -> only scenario we need to handle: the sticky footer must be shown and the default implementation
+ // would scroll to show as the last item in the list the item at [position]. But that is where the sticky
+ // item is anchored. Need to scroll to the next position so that that item will be obscured by the sticky
+ // item and not the now above item at [position].
+ //
+ // Providing any offsets with the stickyView shown and the above scenarios handles means they are handled also.
+
+ if (position < stickyItemPosition && getChildAt(position) == null) {
+ actuallyScrollToPositionWithOffset(position + 1, offset)
+ return
+ }
+
+ actuallyScrollToPositionWithOffset(position, offset)
+ }
+
+ override fun shouldStickyItemBeShownForCurrentPosition(): Boolean {
+ if (stickyItemPosition == RecyclerView.NO_POSITION) {
+ return false
+ }
+
+ // The item at [stickyItemPosition] should be anchored to the top if:
+ // - it or a lower indexed item is shown at the bottom of the list
+ // - the last shown item is translated downwards off screen
+ // (happens when [scrollToPositionWithOffset] was called with a big enough offset)
+ val lastVisibleElement = stickyItemView?.let { childCount - 2 } ?: childCount - 1
+ return getAdapterPositionForItemIndex(lastVisibleElement) <= stickyItemPosition
+ }
+
+ override fun getY(itemView: View): Float {
+ return when (reverseLayout) {
+ true -> 0f
+ false -> height - itemView.height.toFloat()
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyHeaderLinearLayoutManager.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyHeaderLinearLayoutManager.kt
new file mode 100644
index 0000000000..27a758a891
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyHeaderLinearLayoutManager.kt
@@ -0,0 +1,73 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.content.Context
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * Vertical LinearLayoutManager that will ensure an item at a position specified through
+ * [StickyItemsAdapter.isStickyItem] will not scroll past the list's top.
+ *
+ * The list would otherwise scroll normally with the other elements being scrolled beneath the sticky item.
+ *
+ * @param context [Context] needed for various Android interactions.
+ * @param reverseLayout When set to true, layouts from end to start.
+ */
+open class StickyHeaderLinearLayoutManager<T> constructor(
+ context: Context,
+ reverseLayout: Boolean = false,
+) : StickyItemsLinearLayoutManager<T>(
+ context,
+ StickyItemPlacement.TOP,
+ reverseLayout,
+) where T : RecyclerView.Adapter<*>, T : StickyItemsAdapter {
+
+ override fun scrollToIndicatedPositionWithOffset(
+ position: Int,
+ offset: Int,
+ actuallyScrollToPositionWithOffset: (Int, Int) -> Unit,
+ ) {
+ // The following scenarios are handled:
+ // - if position is smaller than [stickyItemPosition]
+ // -> the default behavior will have the list scrolled upwards enough to show that.
+ // - if position is the one of the stickyItem
+ // -> the default behavior will scroll to exactly the header. Perfect match.
+ // - if position is bigger than [stickyItemPosition]
+ // -> only scenario we need to handle: default implementation would scroll to show at the top of the list
+ // the item at that position. But that is where the sticky item is anchored. Need to ask for the item at
+ // the before position being shown at the top of the list and let that be obscured by the sticky item.
+ //
+ // Providing any offsets with the stickyView shown and the above scenarios handles means they are handled also.
+
+ if (position + 1 > stickyItemPosition) {
+ actuallyScrollToPositionWithOffset(position - 1, offset)
+ return
+ }
+
+ actuallyScrollToPositionWithOffset(position, offset)
+ }
+
+ override fun shouldStickyItemBeShownForCurrentPosition(): Boolean {
+ if (stickyItemPosition == RecyclerView.NO_POSITION) {
+ return false
+ }
+
+ // The item at [stickyItemPosition] should be anchored to the top if:
+ // - it or a below item is shown at the top of the list
+ // - the first shown item is translated upwards off screen
+ // (happens when [scrollToPositionWithOffset] was called with a big enough offset)
+ return getAdapterPositionForItemIndex(0) >= stickyItemPosition ||
+ getChildAt(0)?.bottom ?: 1 <= 0 // return false if there is no item at index 0
+ }
+
+ override fun getY(itemView: View): Float {
+ return when (reverseLayout) {
+ true -> height - itemView.height.toFloat()
+ false -> 0f
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyItemLayoutManager.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyItemLayoutManager.kt
new file mode 100644
index 0000000000..f49ec0bf2c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/view/StickyItemLayoutManager.kt
@@ -0,0 +1,483 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.PointF
+import android.os.Parcelable
+import android.view.View
+import android.view.ViewTreeObserver
+import androidx.annotation.VisibleForTesting
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import kotlinx.parcelize.Parcelize
+
+// Inspired from
+// https://github.com/qiujayen/sticky-layoutmanager/blob/b1ddb086db5b04ff3c5357dabe1bff47a935dd37/
+// sticky-layoutmanager/src/main/java/com/jay/widget/StickyHeadersLinearLayoutManager.java
+
+/**
+ * Contract needed to be implemented by all [RecyclerView.Adapter]s
+ * that want to display a list with a sticky header / footer.
+ */
+interface StickyItemsAdapter {
+ /**
+ * Whether this should be considered a sticky item.
+ *
+ * All items will be checked. Only the last one presenting as sticky will be used as such.
+ */
+ fun isStickyItem(position: Int): Boolean
+
+ /**
+ * Callback allowing any customization for the view that will become sticky.
+ */
+ fun setupStickyItem(stickyItem: View) {}
+
+ /**
+ * Callback allowing cleanup after the previous sticky view becomes a regular view.
+ */
+ fun tearDownStickyItem(stickyItem: View) {}
+}
+
+/**
+ * Whether the sticky item should be a header or a footer.
+ */
+enum class StickyItemPlacement {
+ /**
+ * The sticky item will be fixed at the top of the list.
+ *
+ * If the list is scrolled down until past the sticky item's position that view
+ * will become a regular view and will be scrolled down as the others.
+ *
+ * If the list is scrolled up past the sticky item's position that view
+ * will be anchored to the top of the list, always being shown as the first item.
+ */
+ TOP,
+
+ /**
+ * The sticky item will be fixed at the bottom of the list.
+ *
+ * If the list is scrolled up until past the sticky item's position that view
+ * will become a regular view and will be scrolled up as the others.
+ *
+ * If the list is scrolled down past the sticky item's position that view
+ * will be anchored to the bottom of the list, always being shown as the last item.
+ */
+ BOTTOM,
+}
+
+/**
+ * Vertical LinearLayoutManager that will prevent certain items from being scrolled off-screen.
+ *
+ * @param context [Context] needed for various Android interactions.
+ * @param stickyItemPlacement whether the sticky item should be blocked from being scrolled off
+ * to the top of the screen or off to the bottom of the screen.
+ * @param reverseLayout When set to true, layouts from end to start.
+ */
+@Suppress("TooManyFunctions")
+abstract class StickyItemsLinearLayoutManager<T> constructor(
+ context: Context,
+ private val stickyItemPlacement: StickyItemPlacement,
+ reverseLayout: Boolean = false,
+) : LinearLayoutManager(context, RecyclerView.VERTICAL, reverseLayout)
+ where T : RecyclerView.Adapter<*>, T : StickyItemsAdapter {
+
+ @VisibleForTesting
+ internal var listAdapter: T? = null
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ internal var stickyItemPosition = RecyclerView.NO_POSITION
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ internal var stickyItemView: View? = null
+
+ // Allows to re-evaluate and display a possibly new sticky item if data / adapter changed.
+ @VisibleForTesting
+ internal var stickyItemPositionsObserver = ItemPositionsAdapterDataObserver()
+
+ // Save / Restore scroll state
+ @VisibleForTesting
+ internal var scrollPosition = RecyclerView.NO_POSITION
+
+ @VisibleForTesting
+ internal var scrollOffset = 0
+
+ /**
+ * @see [LinearLayoutManager.scrollToPositionWithOffset]
+ *
+ * @param position list item index which needs to be shown.
+ * @param offset optional distance offset from the top of the list to be applied after scrolling to [position]
+ * @param actuallyScrollToPositionWithOffset callback to be used for actually scrolling to an updated position
+ * ad offset based on the relation with the sticky item.
+ *
+ * Use [setScrollState] before and after
+ */
+ abstract fun scrollToIndicatedPositionWithOffset(
+ position: Int,
+ offset: Int,
+ actuallyScrollToPositionWithOffset: (Int, Int) -> Unit,
+ )
+
+ /**
+ * Whether the sticky item should be shown.
+ *
+ * Expected to return if the sticky header item is scrolled past the list top or the sticky bottom item
+ * is scrolled past the list bottom.
+ */
+ abstract fun shouldStickyItemBeShownForCurrentPosition(): Boolean
+
+ /**
+ * Returns the position in the Y axis to position the header appropriately,
+ * depending on direction and [android.R.attr.clipToPadding].
+ */
+ abstract fun getY(itemView: View): Float
+
+ override fun onAttachedToWindow(recyclerView: RecyclerView) {
+ super.onAttachedToWindow(recyclerView)
+ setAdapter(recyclerView.adapter)
+ }
+
+ override fun onAdapterChanged(
+ oldAdapter: RecyclerView.Adapter<*>?,
+ newAdapter: RecyclerView.Adapter<*>?,
+ ) {
+ super.onAdapterChanged(oldAdapter, newAdapter)
+ setAdapter(newAdapter)
+ }
+
+ override fun onSaveInstanceState(): Parcelable {
+ return SavedState(
+ superState = super.onSaveInstanceState(),
+ scrollPosition = scrollPosition,
+ scrollOffset = scrollOffset,
+ )
+ }
+
+ override fun onRestoreInstanceState(state: Parcelable?) {
+ (state as? mozilla.components.browser.menu.view.SavedState)?.let {
+ scrollPosition = it.scrollPosition
+ scrollOffset = it.scrollOffset
+ super.onRestoreInstanceState(it.superState)
+ }
+ }
+
+ override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
+ restoreView { super.onLayoutChildren(recycler, state) }
+
+ if (!state.isPreLayout) {
+ updateStickyItem(recycler, true)
+ }
+ }
+
+ override fun scrollVerticallyBy(
+ dy: Int,
+ recycler: RecyclerView.Recycler,
+ state: RecyclerView.State?,
+ ): Int {
+ val distanceScrolled = restoreView { super.scrollVerticallyBy(dy, recycler, state) }
+ if (distanceScrolled != 0) {
+ updateStickyItem(recycler, false)
+ }
+ return distanceScrolled
+ }
+
+ override fun findLastVisibleItemPosition(): Int =
+ restoreView { super.findLastVisibleItemPosition() }
+
+ override fun findFirstVisibleItemPosition(): Int =
+ restoreView { super.findFirstVisibleItemPosition() }
+
+ override fun findFirstCompletelyVisibleItemPosition(): Int =
+ restoreView { super.findFirstCompletelyVisibleItemPosition() }
+
+ override fun findLastCompletelyVisibleItemPosition(): Int =
+ restoreView { super.findLastCompletelyVisibleItemPosition() }
+
+ override fun computeVerticalScrollExtent(state: RecyclerView.State): Int =
+ restoreView { super.computeVerticalScrollExtent(state) }
+
+ override fun computeVerticalScrollOffset(state: RecyclerView.State): Int =
+ restoreView { super.computeVerticalScrollOffset(state) }
+
+ override fun computeVerticalScrollRange(state: RecyclerView.State): Int =
+ restoreView { super.computeVerticalScrollRange(state) }
+
+ override fun computeScrollVectorForPosition(targetPosition: Int): PointF? =
+ restoreView { super.computeScrollVectorForPosition(targetPosition) }
+
+ override fun scrollToPosition(position: Int) {
+ if (stickyItemView != null) {
+ scrollToPositionWithOffset(position, INVALID_OFFSET)
+ } else {
+ super.scrollToPosition(position)
+ }
+ }
+
+ override fun scrollToPositionWithOffset(position: Int, offset: Int) {
+ if (stickyItemView != null) {
+ // Reset pending scroll.
+ setScrollState(RecyclerView.NO_POSITION, INVALID_OFFSET)
+
+ scrollToIndicatedPositionWithOffset(position, offset) { updatedPosition, updatedOffset ->
+ super.scrollToPositionWithOffset(updatedPosition, updatedOffset)
+ }
+
+ // Remember this position and offset and scroll to it to trigger creating the sticky view.
+ setScrollState(position, offset)
+ } else {
+ super.scrollToPositionWithOffset(position, offset)
+ }
+ }
+
+ override fun onFocusSearchFailed(
+ focused: View,
+ focusDirection: Int,
+ recycler: RecyclerView.Recycler,
+ state: RecyclerView.State,
+ ): View? = restoreView { super.onFocusSearchFailed(focused, focusDirection, recycler, state) }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ internal fun getAdapterPositionForItemIndex(index: Int): Int {
+ return (getChildAt(index)?.layoutParams as? RecyclerView.LayoutParams)
+ ?.absoluteAdapterPosition ?: RecyclerView.NO_POSITION
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @VisibleForTesting
+ internal fun setAdapter(newAdapter: RecyclerView.Adapter<*>?) {
+ listAdapter?.unregisterAdapterDataObserver(stickyItemPositionsObserver)
+
+ (newAdapter as? T)?.let {
+ listAdapter = newAdapter
+ listAdapter?.registerAdapterDataObserver(stickyItemPositionsObserver)
+ stickyItemPositionsObserver.onChanged()
+ } ?: run {
+ listAdapter = null
+ stickyItemView = null
+ }
+ }
+
+ /**
+ * Perform any [operation] ignoring the sticky item. Accomplished by:
+ * - detaching the sticky view
+ * - performing the [operation]
+ * - reattaching the sticky view.
+ */
+ @VisibleForTesting
+ internal fun <T> restoreView(operation: () -> T): T {
+ stickyItemView?.let(this::detachView)
+ val result = operation()
+ stickyItemView?.let(this::attachView)
+ return result
+ }
+
+ /**
+ * Updates the sticky item state (creation, binding, display).
+ *
+ * To be called whenever there's a layout or scroll.
+ *
+ * @param recycler [RecyclerView.Recycler] instance handling views recycling
+ * @param layout whether this is called while layout or while scrolling.
+ */
+ @VisibleForTesting
+ internal fun updateStickyItem(recycler: RecyclerView.Recycler, layout: Boolean) {
+ if (shouldStickyItemBeShownForCurrentPosition()) {
+ if (stickyItemView == null) {
+ createStickyView(recycler, stickyItemPosition)
+ }
+
+ if (layout) {
+ bindStickyItem(stickyItemView!!)
+ }
+
+ stickyItemView?.let {
+ it.translationY = getY(it)
+ }
+ } else {
+ stickyItemView?.let {
+ recycleStickyItem(recycler)
+ }
+ }
+ }
+
+ /**
+ * Construct and configure a [RecyclerView.ViewHolder] for [position],
+ * including measure, layout, and data binding and assigns this to [stickyItemView].
+ */
+ @VisibleForTesting
+ internal fun createStickyView(recycler: RecyclerView.Recycler, position: Int) {
+ val stickyItem = recycler.getViewForPosition(position)
+
+ listAdapter?.setupStickyItem(stickyItem)
+
+ // Add sticky item as a child view, to be detached / reattached whenever
+ // LinearLayoutManager#fill() is called, which happens on layout and scroll (see overrides).
+ addView(stickyItem)
+ measureAndLayout(stickyItem)
+
+ // Hide this new sticky item from the parent LayoutManager, as it's fully managed by this LayoutManager.
+ ignoreView(stickyItem)
+
+ stickyItemView = stickyItem
+ }
+
+ /**
+ * Binds a new [stickyItem].
+ */
+ @VisibleForTesting
+ internal fun bindStickyItem(stickyItem: View) {
+ measureAndLayout(stickyItem)
+
+ // If we have a pending scroll wait until the end of layout and scroll again.
+ if (scrollPosition != RecyclerView.NO_POSITION) {
+ stickyItem.viewTreeObserver.addOnGlobalLayoutListener(
+ object :
+ ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ stickyItem.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ if (scrollPosition != RecyclerView.NO_POSITION) {
+ scrollToPositionWithOffset(scrollPosition, scrollOffset)
+ setScrollState(RecyclerView.NO_POSITION, INVALID_OFFSET)
+ }
+ }
+ },
+ )
+ }
+ }
+
+ /**
+ * Measures and lays out [stickyItemView].
+ */
+ @VisibleForTesting
+ internal fun measureAndLayout(stickyItem: View) {
+ measureChildWithMargins(stickyItem, 0, 0)
+ stickyItem.layout(
+ paddingLeft,
+ 0,
+ width - paddingRight,
+ stickyItem.measuredHeight,
+ )
+ }
+
+ /**
+ * Returns a no longer needed [stickyItemView] View to the [RecyclerView]'s [RecyclerView.RecycledViewPool]
+ * allowing it to be recycled and reused later after being re-binded in the Adapter.
+ *
+ * @param recycler [RecyclerView.Recycler] instance handling views recycling.
+ */
+ @VisibleForTesting
+ internal fun recycleStickyItem(recycler: RecyclerView.Recycler?) {
+ val stickyItem = stickyItemView ?: return
+ stickyItemView = null
+
+ stickyItem.translationY = 0f
+
+ listAdapter?.tearDownStickyItem(stickyItem)
+
+ // Stop ignoring sticky header so that it can be recycled.
+ stopIgnoringView(stickyItem)
+
+ removeView(stickyItem)
+ recycler?.recycleView(stickyItem)
+ }
+
+ @VisibleForTesting
+ internal fun setScrollState(position: Int, offset: Int) {
+ scrollPosition = position
+ scrollOffset = offset
+ }
+
+ /**
+ * Observer for any changes in the items displayed or even when the Adapter changes.
+ */
+ @VisibleForTesting
+ internal inner class ItemPositionsAdapterDataObserver : RecyclerView.AdapterDataObserver() {
+ override fun onChanged() = handleChange()
+
+ override fun onItemRangeInserted(positionStart: Int, itemCount: Int) = handleChange()
+
+ override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) = handleChange()
+
+ override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) = handleChange()
+
+ @VisibleForTesting
+ internal fun handleChange() {
+ listAdapter?.let {
+ stickyItemPosition = calculateNewStickyItemPosition(it)
+
+ // Remove sticky header immediately. A layout will follow.
+ if (stickyItemView != null) {
+ recycleStickyItem(null)
+ }
+ }
+ }
+
+ /**
+ * Get the position of the closest to the anchor sticky item.
+ *
+ * @return sticky item's index in the adapter or RecyclerView.NO_POSITION is such an item doesn't exists.
+ */
+ @VisibleForTesting
+ internal fun calculateNewStickyItemPosition(adapter: T): Int {
+ var newStickyItemPosition = RecyclerView.NO_POSITION
+
+ if (stickyItemPlacement == StickyItemPlacement.TOP) {
+ for (i in (itemCount - 1) downTo 0) {
+ if (adapter.isStickyItem(i)) {
+ newStickyItemPosition = i
+ }
+ }
+ } else {
+ for (i in 0 until itemCount) {
+ if (adapter.isStickyItem(i)) {
+ newStickyItemPosition = i
+ }
+ }
+ }
+
+ return newStickyItemPosition
+ }
+ }
+
+ companion object {
+ /**
+ * Get a new instance of a vertical [LinearLayoutManager] that can show one specific item
+ * as a fixed header / footer in the list, be that reversed or not.
+ *
+ * @param stickyItemPlacement whether the sticky item should be anchored to the top or bottom of the list
+ * @param reverseLayout when set to true, layouts from end to start.
+ */
+ fun <T> get(
+ context: Context,
+ stickyItemPlacement: StickyItemPlacement = StickyItemPlacement.TOP,
+ reverseLayout: Boolean = false,
+ ): StickyItemsLinearLayoutManager<T>
+ where T : RecyclerView.Adapter<*>, T : StickyItemsAdapter {
+ return when (stickyItemPlacement) {
+ StickyItemPlacement.TOP -> StickyHeaderLinearLayoutManager(
+ context,
+ reverseLayout,
+ )
+ StickyItemPlacement.BOTTOM -> StickyFooterLinearLayoutManager(
+ context,
+ reverseLayout,
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Save / restore existing [RecyclerView] state and scrolling position and offset.
+ */
+@SuppressLint("ParcelCreator")
+@Parcelize
+@VisibleForTesting
+internal data class SavedState(
+ val superState: Parcelable?,
+ val scrollPosition: Int,
+ val scrollOffset: Int,
+) : Parcelable
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/anim-ldrtl/menu_enter_bottom.xml b/mobile/android/android-components/components/browser/menu/src/main/res/anim-ldrtl/menu_enter_bottom.xml
new file mode 100644
index 0000000000..6d27c410ea
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/anim-ldrtl/menu_enter_bottom.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+ <scale android:interpolator="@android:anim/accelerate_decelerate_interpolator"
+ android:fromXScale="0"
+ android:toXScale="1"
+ android:fromYScale="0"
+ android:toYScale="1"
+ android:pivotX="5%"
+ android:pivotY="100%"
+ android:duration="@android:integer/config_shortAnimTime" />
+ <alpha android:interpolator="@android:anim/linear_interpolator"
+ android:fromAlpha="0"
+ android:toAlpha="1"
+ android:duration="@android:integer/config_shortAnimTime" />
+ <translate android:interpolator="@android:anim/accelerate_decelerate_interpolator"
+ android:fromYDelta="0"
+ android:toYDelta="0"
+ android:duration="@android:integer/config_shortAnimTime" />
+</set>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/anim-ldrtl/menu_enter_top.xml b/mobile/android/android-components/components/browser/menu/src/main/res/anim-ldrtl/menu_enter_top.xml
new file mode 100644
index 0000000000..fc141bdbd0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/anim-ldrtl/menu_enter_top.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+ <scale android:interpolator="@android:anim/accelerate_decelerate_interpolator"
+ android:fromXScale="0"
+ android:toXScale="1"
+ android:fromYScale="0"
+ android:toYScale="1"
+ android:pivotX="5%"
+ android:pivotY="5%"
+ android:duration="@android:integer/config_shortAnimTime" />
+ <alpha android:interpolator="@android:anim/linear_interpolator"
+ android:fromAlpha="0"
+ android:toAlpha="1"
+ android:duration="@android:integer/config_shortAnimTime" />
+ <translate android:interpolator="@android:anim/accelerate_decelerate_interpolator"
+ android:fromYDelta="0"
+ android:toYDelta="0"
+ android:duration="@android:integer/config_shortAnimTime" />
+</set>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_enter_bottom.xml b/mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_enter_bottom.xml
new file mode 100644
index 0000000000..89153ca6e5
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_enter_bottom.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+ <scale android:interpolator="@android:anim/accelerate_decelerate_interpolator"
+ android:fromXScale="0"
+ android:toXScale="1"
+ android:fromYScale="0"
+ android:toYScale="1"
+ android:pivotX="95%"
+ android:pivotY="100%"
+ android:duration="@android:integer/config_shortAnimTime" />
+ <alpha android:interpolator="@android:anim/linear_interpolator"
+ android:fromAlpha="0"
+ android:toAlpha="1"
+ android:duration="@android:integer/config_shortAnimTime" />
+ <translate android:interpolator="@android:anim/accelerate_decelerate_interpolator"
+ android:fromYDelta="0"
+ android:toYDelta="0"
+ android:duration="@android:integer/config_shortAnimTime" />
+</set>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_enter_top.xml b/mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_enter_top.xml
new file mode 100644
index 0000000000..f0485403ef
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_enter_top.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+ <scale android:interpolator="@android:anim/accelerate_decelerate_interpolator"
+ android:fromXScale="0"
+ android:toXScale="1"
+ android:fromYScale="0"
+ android:toYScale="1"
+ android:pivotX="95%"
+ android:pivotY="5%"
+ android:duration="@android:integer/config_shortAnimTime" />
+ <alpha android:interpolator="@android:anim/linear_interpolator"
+ android:fromAlpha="0"
+ android:toAlpha="1"
+ android:duration="@android:integer/config_shortAnimTime" />
+ <translate android:interpolator="@android:anim/accelerate_decelerate_interpolator"
+ android:fromYDelta="0"
+ android:toYDelta="0"
+ android:duration="@android:integer/config_shortAnimTime" />
+</set>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_exit.xml b/mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_exit.xml
new file mode 100644
index 0000000000..226166c109
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/anim/menu_exit.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+ <alpha android:interpolator="@android:anim/linear_interpolator"
+ android:fromAlpha="1"
+ android:toAlpha="0"
+ android:duration="@android:integer/config_shortAnimTime" />
+</set>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_browser_menu_notification_icon.xml b/mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_browser_menu_notification_icon.xml
new file mode 100644
index 0000000000..8afb175afe
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_browser_menu_notification_icon.xml
@@ -0,0 +1,15 @@
+<!-- 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/. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="8dp"
+ android:height="8dp"
+ android:viewportWidth="10"
+ android:viewportHeight="10">
+ <path
+ android:pathData="M1,5a4,4 0 1,0 8,0a4,4 0 1,0 -8,0"
+ android:strokeWidth="1"
+ android:strokeAlpha=".2"
+ android:fillColor="#fff"
+ android:strokeColor="#000" />
+</vector>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_menu_indicator.xml b/mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_menu_indicator.xml
new file mode 100644
index 0000000000..33d8ad19b4
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_menu_indicator.xml
@@ -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/. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="40dp"
+ android:height="40dp"
+ android:viewportWidth="40"
+ android:viewportHeight="40">
+ <group
+ android:scaleX="0.714"
+ android:scaleY="0.714"
+ android:pivotX="20"
+ android:pivotY="20">
+ <path
+ android:pathData="m38.622,27.309a8,8 0,0 0,-11.314 11.314,19.949 19.949,0 0,1 -7.308,1.377c-11.046,0 -20,-8.954 -20,-20s8.954,-20 20,-20 20,8.954 20,20c0,2.58 -0.488,5.045 -1.378,7.309z"
+ android:fillColor="#ffffff"
+ android:fillAlpha=".4" />
+ <path
+ android:pathData="M33,33m-6.4,0a6.4,6.4 0,1 1,12.8 0a6.4,6.4 0,1 1,-12.8 0"
+ android:fillColor="#ffffff"
+ android:fillAlpha=".4" />
+ <path
+ android:pathData="M33,33m-4.3,0a4.3,4.3 0,1 1,8.6 0a4.3,4.3 0,1 1,-8.6 0"
+ android:strokeWidth="1"
+ android:strokeAlpha=".2"
+ android:fillColor="#ffffff"
+ android:strokeColor="#000000" />
+ </group>
+</vector>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_menu_notification.xml b/mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_menu_notification.xml
new file mode 100644
index 0000000000..b7b5ced0c0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/drawable/mozac_menu_notification.xml
@@ -0,0 +1,21 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="40dp"
+ android:height="40dp"
+ android:viewportWidth="40"
+ android:viewportHeight="40">
+ <group
+ android:translateX="25.55"
+ android:translateY="5.55"
+ android:scaleX="0.75"
+ android:scaleY="0.75">
+ <path
+ android:pathData="M1,5a4,4 0 1,0 8,0a4,4 0 1,0 -8,0"
+ android:strokeWidth="1"
+ android:strokeAlpha=".2"
+ android:fillColor="#fff"
+ android:strokeColor="#000" />
+ </group>
+</vector>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/drawable/rounded_corner.xml b/mobile/android/android-components/components/browser/menu/src/main/res/drawable/rounded_corner.xml
new file mode 100644
index 0000000000..892c34799d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/drawable/rounded_corner.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+ <corners android:radius="4dp" />
+</shape> \ No newline at end of file
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu.xml
new file mode 100644
index 0000000000..83e075a2e0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ style="@style/Mozac.Browser.Menu"
+ android:id="@+id/mozac_browser_menu_menuView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:cardCornerRadius="@dimen/mozac_browser_menu_corner_radius"
+ app:cardElevation="@dimen/mozac_browser_menu_elevation"
+ app:cardUseCompatPadding="true">
+
+ <mozilla.components.browser.menu.view.DynamicWidthRecyclerView
+ android:id="@+id/mozac_browser_menu_recyclerView"
+ android:paddingTop="@dimen/mozac_browser_menu_padding_vertical"
+ android:paddingBottom="@dimen/mozac_browser_menu_padding_vertical"
+ android:overScrollMode="never"
+ android:layout_width="@dimen/mozac_browser_menu_width"
+ android:layout_height="wrap_content"
+ tools:listitem="@layout/mozac_browser_menu_item_simple" />
+
+</androidx.cardview.widget.CardView>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_button.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_button.xml
new file mode 100644
index 0000000000..78193dce10
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_button.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<merge xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_height="match_parent"
+ android:layout_width="match_parent"
+ android:clickable="true"
+ android:focusable="true"
+ android:background="?android:selectableItemBackgroundBorderless"
+ tools:parentTag="android.widget.FrameLayout">
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/highlight"
+ app:srcCompat="@drawable/mozac_menu_indicator"
+ android:layout_height="match_parent"
+ android:layout_width="match_parent"
+ android:scaleType="center"
+ android:contentDescription="@string/mozac_browser_menu_highlighted"
+ android:visibility="gone" />
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/icon"
+ app:srcCompat="@drawable/mozac_ic_ellipsis_vertical_24"
+ android:layout_height="match_parent"
+ android:layout_width="match_parent"
+ android:scaleType="center"
+ android:contentDescription="@string/mozac_browser_menu_button" />
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/notification_dot"
+ app:srcCompat="@drawable/mozac_menu_notification"
+ android:layout_height="match_parent"
+ android:layout_width="match_parent"
+ android:scaleType="center"
+ android:contentDescription="@string/mozac_browser_menu_highlighted"
+ android:visibility="gone" />
+
+</merge>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_category.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_category.xml
new file mode 100644
index 0000000000..15591618df
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_category.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/category_text"
+ style="@style/Mozac.Browser.Menu.Item.Category"
+ android:layout_width="match_parent"
+ android:gravity="center_vertical"
+ tools:text="Category" />
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_highlightable_item.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_highlightable_item.xml
new file mode 100644
index 0000000000..b956d85bd7
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_highlightable_item.xml
@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ style="@style/Mozac.Browser.Menu.Item.Container"
+ tools:ignore="UnusedAttribute"
+ android:foreground="?android:attr/selectableItemBackground"
+ android:layout_width="match_parent"
+ android:orientation="horizontal"
+ android:clickable="true"
+ android:focusable="true">
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/image"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Icon"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center"
+ android:background="@android:color/transparent"
+ android:importantForAccessibility="no"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:src="@android:drawable/screen_background_dark" />
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/notification_dot"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Icon"
+ android:layout_width="8dp"
+ android:layout_height="8dp"
+ android:translationX="@dimen/mozac_browser_menu_highlightable_notification_translate_x"
+ android:translationY="@dimen/mozac_browser_menu_highlightable_notification_translate_y"
+ android:background="@android:color/transparent"
+ android:contentDescription="@string/mozac_browser_menu_highlighted"
+ android:visibility="gone"
+ app:layout_constraintTop_toTopOf="@id/image"
+ app:layout_constraintEnd_toEndOf="@id/image"
+ app:srcCompat="@drawable/mozac_browser_menu_notification_icon"
+ tools:visibility="visible" />
+
+ <TextView
+ android:id="@+id/text"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Label"
+ android:layout_width="wrap_content"
+ android:layout_centerVertical="true"
+ android:background="@android:color/transparent"
+ android:clickable="false"
+ android:focusable="false"
+ android:gravity="center_vertical"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/end_image"
+ app:layout_constraintHorizontal_bias="0.0"
+ app:layout_constraintStart_toEndOf="@+id/image"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:text="Item" />
+
+ <TextView
+ android:id="@+id/highlight_text"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Label"
+ android:layout_width="wrap_content"
+ android:layout_centerVertical="true"
+ android:background="@android:color/transparent"
+ android:clickable="false"
+ android:focusable="false"
+ android:gravity="center_vertical"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/end_image"
+ app:layout_constraintHorizontal_bias="0.0"
+ app:layout_constraintStart_toEndOf="@+id/image"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:text="Highlighted Item" />
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/end_image"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Icon"
+ android:layout_alignParentEnd="true"
+ android:layout_centerVertical="true"
+ android:layout_gravity="center_vertical"
+ android:background="@android:color/transparent"
+ android:clickable="false"
+ android:focusable="false"
+ android:importantForAccessibility="no"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:src="@android:drawable/screen_background_dark" />
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_highlightable_switch.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_highlightable_switch.xml
new file mode 100644
index 0000000000..58d6fcef50
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_highlightable_switch.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ style="@style/Mozac.Browser.Menu.Item.Container"
+ tools:ignore="UnusedAttribute"
+ android:foreground="?android:attr/selectableItemBackground"
+ android:layout_width="match_parent"
+ android:orientation="horizontal"
+ android:clickable="true"
+ android:focusable="true">
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/image"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Icon"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center"
+ android:background="@android:color/transparent"
+ android:importantForAccessibility="no"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:src="@android:drawable/screen_background_dark" />
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/notification_dot"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Icon"
+ android:layout_width="8dp"
+ android:layout_height="8dp"
+ android:translationX="@dimen/mozac_browser_menu_highlightable_notification_translate_x"
+ android:translationY="@dimen/mozac_browser_menu_highlightable_notification_translate_y"
+ android:background="@android:color/transparent"
+ android:importantForAccessibility="no"
+ android:visibility="gone"
+ app:layout_constraintTop_toTopOf="@id/image"
+ app:layout_constraintEnd_toEndOf="@id/image"
+ app:srcCompat="@drawable/mozac_browser_menu_notification_icon"
+ tools:visibility="visible" />
+
+ <androidx.appcompat.widget.SwitchCompat
+ android:id="@+id/switch_widget"
+ style="@style/Mozac.Browser.Menu.Item.Text"
+ android:layout_width="0dp"
+ android:layout_height="@dimen/mozac_browser_menu_item_container_layout_height"
+ android:background="?android:attr/selectableItemBackgroundBorderless"
+ android:orientation="vertical"
+ android:paddingStart="@dimen/mozac_browser_menu_item_image_text_label_padding_start"
+ android:paddingEnd="0dp"
+ android:textAlignment="viewStart"
+ tools:text="Item"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toEndOf="@+id/image"
+ app:layout_constraintTop_toTopOf="parent" />
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_checkbox.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_checkbox.xml
new file mode 100644
index 0000000000..ebd0991816
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_checkbox.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<androidx.appcompat.widget.AppCompatCheckBox xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ style="@style/Mozac.Browser.Menu.Item.Text"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/mozac_browser_menu_item_container_layout_height"
+ android:button="@null"
+ android:drawableEnd="?android:attr/listChoiceIndicatorMultiple"
+ android:drawablePadding="@dimen/mozac_browser_menu_checkbox_padding"
+ android:gravity="center_vertical"
+ android:paddingStart="16dp"
+ android:paddingEnd="16dp"
+ tools:text="Item" />
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_divider.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_divider.xml
new file mode 100644
index 0000000000..819ead6066
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_divider.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<View xmlns:android="http://schemas.android.com/apk/res/android"
+ style="@style/Mozac.Browser.Menu.Item.Divider.Horizontal"
+ android:importantForAccessibility="no"
+ android:clickable="false"/>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_switch.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_switch.xml
new file mode 100644
index 0000000000..56a20c16e6
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_switch.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?><!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<androidx.appcompat.widget.SwitchCompat xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/switch_widget"
+ style="@style/Mozac.Browser.Menu.Item.Text"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/mozac_browser_menu_item_container_layout_height"
+ android:drawablePadding="@dimen/mozac_browser_menu_item_image_text_icon_padding"
+ android:paddingStart="@dimen/mozac_browser_menu_item_container_padding_start"
+ android:paddingEnd="@dimen/mozac_browser_menu_item_container_padding_end"
+ android:textAlignment="viewStart"
+ app:drawableStartCompat="@android:drawable/screen_background_dark" />
+
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_text.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_text.xml
new file mode 100644
index 0000000000..fc72b27707
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_text.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ style="@style/Mozac.Browser.Menu.Item.Container"
+ android:layout_width="match_parent"
+ android:orientation="horizontal"
+ android:clickable="true"
+ android:focusable="true"
+ android:gravity="center_vertical"
+ tools:ignore="UseCompoundDrawables">
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/image"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Icon"
+ android:clickable="false"
+ android:background="@android:color/transparent"
+ android:importantForAccessibility="no"
+ tools:src="@android:drawable/screen_background_dark"/>
+
+ <TextView
+ android:id="@+id/text"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Label"
+ android:clickable="false"
+ android:focusable="false"
+ android:background="@android:color/transparent"
+ android:gravity="center_vertical"
+ tools:text="Item"
+ android:importantForAccessibility="no"/>
+</LinearLayout>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_text_checkbox_button.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_text_checkbox_button.xml
new file mode 100644
index 0000000000..062d8efd0b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_image_text_checkbox_button.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?><!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ style="@style/Mozac.Browser.Menu.Item.Checkbox.Container"
+ android:layout_width="match_parent"
+ android:clickable="true"
+ android:focusable="true"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ tools:ignore="UseCompoundDrawables">
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/image"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Icon"
+ android:background="@android:color/transparent"
+ android:importantForAccessibility="no"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:src="@android:drawable/screen_background_dark" />
+
+ <TextView
+ android:id="@+id/text"
+ style="@style/Mozac.Browser.Menu.Item.Checkbox.Label"
+ android:background="@android:color/transparent"
+ android:gravity="center_vertical"
+ android:importantForAccessibility="no"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/checkbox"
+ app:layout_constraintHorizontal_bias="0"
+ app:layout_constraintHorizontal_chainStyle="spread_inside"
+ app:layout_constraintStart_toEndOf="@id/image"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:text="Item" />
+
+ <View
+ android:id="@+id/accessibilityRegion"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:importantForAccessibility="yes"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="@id/text"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <androidx.appcompat.widget.AppCompatCheckBox
+ android:id="@+id/checkbox"
+ style="@style/Mozac.Browser.Menu.Item.Checkbox.Text"
+ android:button="@null"
+ android:drawablePadding="7dp"
+ android:textAlignment="gravity"
+ android:gravity="center_vertical"
+ app:layout_constraintStart_toEndOf="@id/text"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintWidth_default="wrap"
+ tools:drawableStartCompat="@android:drawable/star_big_off"
+ tools:text="Add"
+ tools:textOff="Edit"
+ tools:textOn="Add" />
+</androidx.constraintlayout.widget.ConstraintLayout>
+
+
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_parent_menu.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_parent_menu.xml
new file mode 100644
index 0000000000..3d361dcfbc
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_parent_menu.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ style="@style/Mozac.Browser.Menu.Item.Container"
+ android:layout_width="match_parent"
+ android:orientation="horizontal"
+ android:clickable="true"
+ android:focusable="true"
+ android:gravity="center_vertical"
+ tools:ignore="UseCompoundDrawables">
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/image"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Icon"
+ android:clickable="false"
+ android:background="@android:color/transparent"
+ android:importantForAccessibility="no"
+ tools:src="@android:drawable/screen_background_dark"/>
+
+ <TextView
+ android:id="@+id/text"
+ style="@style/Mozac.Browser.Menu.Item.ImageText.Label"
+ android:clickable="false"
+ android:focusable="false"
+ android:background="@android:color/transparent"
+ android:gravity="center_vertical"
+ tools:text="Item"
+ android:importantForAccessibility="no"/>
+
+ <View
+ android:layout_width="0dp"
+ android:layout_height="1dp"
+ android:layout_weight="1"/>
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/overflowImage"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:clickable="false"
+ android:visibility="gone"
+ android:background="@android:color/transparent"
+ android:importantForAccessibility="no"
+ app:srcCompat="@drawable/mozac_ic_chevron_right_24"/>
+</LinearLayout>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_simple.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_simple.xml
new file mode 100644
index 0000000000..a7afa796fd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_simple.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/simple_text"
+ style="@style/Mozac.Browser.Menu.Item.Text"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/mozac_browser_menu_item_container_layout_height"
+ android:gravity="start|center_vertical"
+ android:paddingStart="16dp"
+ android:paddingEnd="16dp"
+ android:textAlignment="viewStart"
+ tools:text="Item" />
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_switch.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_switch.xml
new file mode 100644
index 0000000000..9d0ee5c56e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_switch.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<androidx.appcompat.widget.SwitchCompat xmlns:android="http://schemas.android.com/apk/res/android"
+ style="@style/Mozac.Browser.Menu.Item.Text"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/mozac_browser_menu_item_container_layout_height"
+ android:paddingStart="16dp"
+ android:paddingEnd="16dp"/>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_toolbar.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_toolbar.xml
new file mode 100644
index 0000000000..00897559de
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_item_toolbar.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ style="@android:style/TextAppearance.Material.Menu"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/mozac_browser_menu_item_toolbar_height"
+ android:gravity="center_vertical"
+ android:orientation="horizontal" />
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_web_extension.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_web_extension.xml
new file mode 100644
index 0000000000..1ccecbf48d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_menu_web_extension.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:orientation="horizontal"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/mozac_browser_menu_item_toolbar_height"
+ android:id="@+id/container"
+ style="@style/Mozac.Browser.Menu.Item.Container"
+ android:gravity="center_vertical">
+
+ <ImageView
+ android:id="@+id/action_image"
+ android:layout_width="@dimen/mozac_browser_menu_item_web_extension_icon_width"
+ android:layout_height="@dimen/mozac_browser_menu_item_web_extension_icon_height"
+ android:layout_gravity="center"
+ android:importantForAccessibility="no"
+ app:srcCompat="@drawable/mozac_browser_menu_notification_icon"/>
+
+ <TextView
+ android:id="@+id/action_label"
+ style="@style/Mozac.Browser.Menu.Item.Text"
+ android:background="@android:color/transparent"
+ android:layout_width="wrap_content"
+ android:layout_height="@dimen/mozac_browser_menu_item_container_layout_height"
+ android:gravity="center_vertical"
+ android:textAlignment="viewStart"
+ android:paddingEnd="16dp"
+ android:paddingStart="16dp"
+ android:clickable="false"
+ android:focusable="false"
+ tools:ignore="RtlCompat"
+ tools:text="uBlock Origin" />
+
+ <View
+ android:layout_width="0dp"
+ android:layout_height="1dp"
+ android:layout_weight="1" />
+
+ <TextView
+ android:id="@+id/badge_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:minWidth="22dp"
+ android:gravity="center"
+ android:textAlignment="center"
+ android:textStyle="bold"
+ android:textColor="@color/photonWhite"
+ android:background="@drawable/rounded_corner"
+ android:visibility="invisible"
+ android:padding="3dp"
+ android:textSize="12sp"
+ tools:text="18" />
+</LinearLayout>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_tooltip_layout.xml b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_tooltip_layout.xml
new file mode 100644
index 0000000000..aa69465b9a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/layout/mozac_browser_tooltip_layout.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/mozac_browser_tooltip_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="@dimen/tooltip_margin"
+ android:background="?attr/tooltipFrameBackground"
+ android:ellipsize="end"
+ android:maxWidth="256dp"
+ android:maxLines="2"
+ android:paddingStart="16dp"
+ android:paddingTop="6dp"
+ android:paddingEnd="16dp"
+ android:paddingBottom="6dp"
+ android:textAppearance="@style/TextAppearance.AppCompat.Tooltip"
+ android:textColor="?attr/tooltipForegroundColor"
+ tools:ignore="PrivateResource" />
+</LinearLayout>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-am/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-am/strings.xml
new file mode 100644
index 0000000000..cec57d8d8a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-am/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">ምናሌ</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">የተተኮረበት</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">ተጨማሪዎች</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">ቅጥያዎች</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">ተጨማሪዎች አስተዳዳሪ</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">የቅጥያዎች ማስተዳደሪያ</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">ወደ ላይ አስስ</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">ተጨማሪዎች፣ ወደ ላይ ዳስስ</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">ቅጥያዎች፣ ወደ ላይ ዳስስ</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-an/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-an/strings.xml
new file mode 100644
index 0000000000..20c8245203
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-an/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menú</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Destacaus</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Complementos</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Chestor de complementos</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ar/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ar/strings.xml
new file mode 100644
index 0000000000..5b80338dce
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ar/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">القائمة</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">عليها الإبراز</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">الإضافات</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">مدير الإضافات</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">انتقل لأعلى</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ast/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ast/strings.xml
new file mode 100644
index 0000000000..d30d41ab85
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ast/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menú</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Rescamplóse</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Complementos</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Xestor de complementos</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-az/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-az/strings.xml
new file mode 100644
index 0000000000..1a5d4fd289
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-az/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menyu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Vurğulanmış</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Əlavələr</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Əlavə idarəçisi</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-azb/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-azb/strings.xml
new file mode 100644
index 0000000000..89ca673be7
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-azb/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">منو</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">هایلایت اولدو</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">تاخیلان</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">اوزانتی‌لار</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">تاخیلان مودیریتی</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">اوزانتی مودیری</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">یوخاریا گئت</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">تاخیلان‌لار، یوخاری گئت</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">اوزانتی‌ْلار، یوخاریْ گئت</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ban/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ban/strings.xml
new file mode 100644
index 0000000000..6955c0d4a9
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ban/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Kasorot</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Pengaya</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Manajer Pengaya</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-be/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-be/strings.xml
new file mode 100644
index 0000000000..f3b3bb1755
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-be/strings.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Меню</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Вылучаны(я)</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Дадаткі</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Пашырэнні</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Менеджар дадаткаў</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Перайсці ўверх</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-bg/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-bg/strings.xml
new file mode 100644
index 0000000000..99bd63bc40
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-bg/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Меню</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Откроено</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Добавки</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Разширения</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Управление на добавки</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Управление на разширения</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Придвижване нагоре</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Добавки, навигиране нагоре</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Разширения, навигиране нагоре</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-bn/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-bn/strings.xml
new file mode 100644
index 0000000000..76d355b1e4
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-bn/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">মেনু</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">হাইলাইট করা হয়েছে</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">অ্যাড-অন</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">অ্যাড-অন ব্যবস্থাপক</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-br/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-br/strings.xml
new file mode 100644
index 0000000000..548cbd7785
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-br/strings.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Lañser</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Usskedet</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Askouezhioù</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Askouezhioù</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Ardoer an askouezhioù</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Merañ an askouezhioù</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Adpignat</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-bs/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-bs/strings.xml
new file mode 100644
index 0000000000..a0c32fdba8
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-bs/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Meni</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Istaknuto</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Add-oni</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Ekstenzije</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Upravnik add-onima</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Menadžer ekstenzija</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Idi prema gore</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Dodaci, idite gore</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Ekstenzije, idi gore</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ca/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ca/strings.xml
new file mode 100644
index 0000000000..85424c3ba1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ca/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menú</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">S’ha ressaltat</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Complements</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Gestor de complements</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navega amunt</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-cak/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-cak/strings.xml
new file mode 100644
index 0000000000..7ccb1be616
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-cak/strings.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">K\'utsamaj</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Ya\'on ruq\'ij</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Taq tz\'aqat</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Kinuk\'samajel taq Tz\'aqat</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Tib\'an okem ajsik</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description">Taq tz\'aqat, tijote\' chi rokem</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ceb/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ceb/strings.xml
new file mode 100644
index 0000000000..e02f99d729
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ceb/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Gipasiugda</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Add-on</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Add-on Manager</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ckb/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ckb/strings.xml
new file mode 100644
index 0000000000..ee5821c024
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ckb/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">پێڕست</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">ئاماژەپێکراو</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">پێوەکراوەکان</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">بەڕێوەبەری پێوەکراوەکان</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-co/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-co/strings.xml
new file mode 100644
index 0000000000..fb805d3142
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-co/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Listinu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Sopralineatu</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Moduli addiziunali</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Estensioni</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Ghjestiunariu di moduli addiziunali</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Ghjestiunariu d’estensioni</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navigà insù</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Moduli addiziunali, ricullà</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Estensioni, ricullà</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-cs/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-cs/strings.xml
new file mode 100644
index 0000000000..450256d049
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-cs/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Nabídka</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Zvýrazněné</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Doplňky</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Rozšíření</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Správce doplňků</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Správce rozšíření</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Přejít nahoru</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Doplňky, přejít nahoru</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Rozšíření, přejít nahoru</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-cy/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-cy/strings.xml
new file mode 100644
index 0000000000..7543b08a9e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-cy/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Dewislen</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Amlygwyd</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Ychwanegion</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Estyniadau</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Rheolwr Ychwanegion</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Rheolwr Estyniadau</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Llywio i fyny</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Ychwanegion, symud i fyny</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Estyniadau, llywio i fyny</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-da/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-da/strings.xml
new file mode 100644
index 0000000000..31fd32a217
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-da/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Fremhævet</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Tilføjelser</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Udvidelser</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Håndtering af tilføjelser</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Håndtering af udvidelser</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Naviger op</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Tilføjelser, naviger op</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Udvidelser, naviger op</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-de/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000000..4d0ec0bccc
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-de/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menü</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Hervorgehoben</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Erweiterungen</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons-Verwaltung</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Erweiterungs-Manager</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Nach oben navigieren</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons, nach oben navigieren</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Erweiterungen, nach oben navigieren</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-dsb/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-dsb/strings.xml
new file mode 100644
index 0000000000..868f5ca76e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-dsb/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Meni</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Wuzwignjony</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Dodanki</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Rozšyrjenja</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Zastojnik dodankow</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Zastojnik rozšyrjenjow</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Górjej</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Dodanki, górjej nawigěrowaś</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Rozšyrjenja, górjej nawigěrowaś</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-el/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-el/strings.xml
new file mode 100644
index 0000000000..37c1c075af
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-el/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Μενού</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Επισημασμένο</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Πρόσθετα</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Επεκτάσεις</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Διαχείριση προσθέτων</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Διαχείριση επεκτάσεων</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Πλοήγηση προς τα πάνω</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Πρόσθετα, πλοήγηση προς τα πάνω</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Επεκτάσεις, πλοήγηση προς τα πάνω</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-en-rCA/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-en-rCA/strings.xml
new file mode 100644
index 0000000000..9e33b4ffcd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-en-rCA/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Highlighted</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensions</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons Manager</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Extensions Manager</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navigate up</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons, navigate up</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensions, navigate up</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-en-rGB/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-en-rGB/strings.xml
new file mode 100644
index 0000000000..9e33b4ffcd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-en-rGB/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Highlighted</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensions</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons Manager</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Extensions Manager</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navigate up</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons, navigate up</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensions, navigate up</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-eo/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-eo/strings.xml
new file mode 100644
index 0000000000..63b30a2150
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-eo/strings.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menuo</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Elstarigitaj</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Aldonaĵoj</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Administrilo de aldonaĵoj</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Iri supren</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description">Aldonaĵoj, supren</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rAR/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rAR/strings.xml
new file mode 100644
index 0000000000..b527c2e9bb
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rAR/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menú</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Resaltado</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Complementos</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Complementos</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Administrador de complementos</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Administrador de complementos</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navegar hacia arriba</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Complementos, navegar hacia arriba</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Complementos, navegar hacia arriba</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rCL/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rCL/strings.xml
new file mode 100644
index 0000000000..954a4a0678
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rCL/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menú</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Destacado</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Complementos</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensiones</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Administrador de complementos</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Administrador de extensiones</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navegar hacia arriba</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Complementos, navegar hacia arriba</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensiones, navegar hacia arriba</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rES/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rES/strings.xml
new file mode 100644
index 0000000000..971fcb13c1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rES/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menú</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Resaltado</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Complementos</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensiones</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Administrador de complementos</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Administrador de extensiones</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navegar hacia arriba</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Complementos, navega hacia arriba</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensiones, navega hacia arriba</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rMX/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rMX/strings.xml
new file mode 100644
index 0000000000..b699871b37
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-es-rMX/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menú</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Destacado</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Complementos</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Administrador de complementos</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navegar hacia arriba</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-es/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-es/strings.xml
new file mode 100644
index 0000000000..971fcb13c1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-es/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menú</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Resaltado</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Complementos</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensiones</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Administrador de complementos</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Administrador de extensiones</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navegar hacia arriba</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Complementos, navega hacia arriba</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensiones, navega hacia arriba</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-et/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-et/strings.xml
new file mode 100644
index 0000000000..e21a050061
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-et/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menüü</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Esiletõstetud</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Lisad</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Lisade haldur</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Liigu üles</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-eu/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-eu/strings.xml
new file mode 100644
index 0000000000..53f1a556a3
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-eu/strings.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menua</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Nabarmendua</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Gehigarriak</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Gehigarrien kudeatzailea</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Nabigatu gora</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description">Gehigarriak, nabigatu gora</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-fa/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-fa/strings.xml
new file mode 100644
index 0000000000..af1bd783a6
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-fa/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">منو</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">برجسته‌شده</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">افزونه‌ها</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">مدیریت افزونه‌ها</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">ناوش به بالا</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ff/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ff/strings.xml
new file mode 100644
index 0000000000..2ad3a89319
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ff/strings.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Ɓeyditte</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Topitorde Ɓeyditte</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-fi/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-fi/strings.xml
new file mode 100644
index 0000000000..e78d8971b6
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-fi/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Valikko</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Korostettu</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Lisäosat</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Laajennukset</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Lisäosien hallinta</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Laajennusten hallinta</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Liiku ylöspäin</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Lisäosat, liiku ylös</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Laajennukset, liiku ylös</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-fr/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000000..00c13d5fe1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-fr/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Sélectionné</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Modules complémentaires</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensions</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Gestionnaire de modules</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Gestionnaire d’extensions</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Remonter</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Modules complémentaires, remonter</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensions, remonter</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-fur/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-fur/strings.xml
new file mode 100644
index 0000000000..211c5d879f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-fur/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menù</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Evidenziât</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Components adizionâi</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Estensions</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Gjestôr comp. adizionâi</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Gjestôr estensions</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navighe in sù</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Components adizionâi, torne indaûr</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Estensions, torne indaûr</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-fy-rNL/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-fy-rNL/strings.xml
new file mode 100644
index 0000000000..39e9c4e032
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-fy-rNL/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Markearre</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Utwreidingen</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Add-onbehearder</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Utwreidingsbehearder</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Omheech</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons, omheech navigearje</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Utwreidingen, omheech navigearje</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-gd/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-gd/strings.xml
new file mode 100644
index 0000000000..89524181f6
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-gd/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">An clàr-taice</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Soillsichte</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Tuilleadain</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Manaidsear nan tuilleadan</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-gl/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-gl/strings.xml
new file mode 100644
index 0000000000..20db3e0dc0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-gl/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menú</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Realzado</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Complementos</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensións</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Xestor de complementos</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Xestor de extensións</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navegar cara arriba</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Complementos, navegar cara arriba</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensións, navega cara arriba</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-gn/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-gn/strings.xml
new file mode 100644
index 0000000000..25fc596f2d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-gn/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Poravorã</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Hechaukaveha</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Moĩmbaha</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Jepysokue</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Moĩmbaha ñangarekohára</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Jepysokue ñangarekoha</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Eikundaha yvate gotyo</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Moĩmbaha, eikundaha yvate gotyo</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Jepysokue, eikundaha yvate gotyo</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-gu-rIN/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-gu-rIN/strings.xml
new file mode 100644
index 0000000000..fde91325c9
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-gu-rIN/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">મેનુ</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">પ્રકાશિત કરેલ</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">ઍડ-ઑન્સ</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">એડ-ઑન્સ સંચાલક</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-hi-rIN/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-hi-rIN/strings.xml
new file mode 100644
index 0000000000..c5eab9cbd7
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-hi-rIN/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">मेन्यू</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">दर्शाए गए</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">ऐड-ऑन</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">ऐड-ऑन प्रबंधक</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-hil/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-hil/strings.xml
new file mode 100644
index 0000000000..c6b11e8350
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-hil/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Highlighted</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Mga Add-on</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Mga add-on sang Manager</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-hr/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-hr/strings.xml
new file mode 100644
index 0000000000..ee6f3922ce
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-hr/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Izbornik</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Istaknuto</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Dodaci</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Upravljač dodataka</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navigiraj gore</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-hsb/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-hsb/strings.xml
new file mode 100644
index 0000000000..efb7ef3879
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-hsb/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Meni</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Wuzběhnjeny</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Přidatki</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Rozšěrjenja</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Zrjadowak přidatkow</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Zrjadowak rozšěrjenjow</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Horje</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Přidatki, horje nawigěrować</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Rozšěrjenja, horje nawigěrować</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-hu/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-hu/strings.xml
new file mode 100644
index 0000000000..5f0c7c32af
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-hu/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menü</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Kiemelt</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Kiegészítők</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Kiegészítők</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Kiegészítőkezelő</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Kiegészítőkezelő</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navigálás fel</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Kiegészítők, navigáció felfelé</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Kiegészítők, navigáció felfelé</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-hy-rAM/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-hy-rAM/strings.xml
new file mode 100644
index 0000000000..1eb1bf7ab9
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-hy-rAM/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Ցանկ</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Գունանշված</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Հավելումներ</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Ընդլայնումներ</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Հավելումների կառավարիչ</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Ընդլայնումների կառավարիչ</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Նավարկել վերև</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Հավելումներ, նավարկեք վերև</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Ընդլայնումներ, նավարկեք վերև</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ia/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ia/strings.xml
new file mode 100644
index 0000000000..97991d144c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ia/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Evidentiate</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Additivos</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensiones</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Gestor de additivos</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Gestor de extensiones</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navigar</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Additivos, navigar retro</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensiones, remontar</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-in/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-in/strings.xml
new file mode 100644
index 0000000000..f23b41f900
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-in/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Tersorot</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Pengaya</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Ekstensi</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Pengelola Pengaya</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Pengelola Ekstensi</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Arahkan ke atas</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Pengaya, navigasikan ke atas</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Ekstensi, navigasikan ke atas</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-is/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-is/strings.xml
new file mode 100644
index 0000000000..3b1d78f3b5
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-is/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Valmynd</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Undirstrikað</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Viðbætur</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Forritsaukar</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Viðbótastjóri</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Umsýsla forritsauka</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Flakka upp</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Viðbætur, fara upp</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Forritsaukar, fara upp</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-it/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-it/strings.xml
new file mode 100644
index 0000000000..f7e7f9720c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-it/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Evidenziato</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Componenti aggiuntivi</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Estensioni</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Gestore comp. aggiuntivi</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Gestione estensioni</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Passa a livello superiore</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Componenti aggiuntivi, torna indietro</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Estensioni, torna indietro</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-iw/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-iw/strings.xml
new file mode 100644
index 0000000000..e164d78059
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-iw/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">תפריט</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">מודגש</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">תוספות</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">הרחבות</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">מנהל התוספות</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">מנהל ההרחבות</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">ניווט למעלה</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">תוספות, ניווט למעלה</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">הרחבות, ניווט למעלה</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ja/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ja/strings.xml
new file mode 100644
index 0000000000..a5f5383eae
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ja/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">メニュー</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">強調</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">アドオン</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">拡張機能</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">アドオンマネージャー</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">拡張機能マネージャー</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">上へ移動します</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">アドオン、上へ移動します</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">拡張機能、上へ移動します</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ka/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ka/strings.xml
new file mode 100644
index 0000000000..5971c5f9fd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ka/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">მენიუ</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">მონიშნული</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">დამატებები</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">დამატებების მმართველი</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">ზემოთ გადასვლა</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-kaa/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-kaa/strings.xml
new file mode 100644
index 0000000000..4a3bd0164c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-kaa/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menyu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Belgilengen</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Qosımshalar</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Qosımshalar menedjeri</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-kab/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-kab/strings.xml
new file mode 100644
index 0000000000..f36a1014b1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-kab/strings.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Umuɣ</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">ittwag deg uqerru</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Izegrar</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Isiɣzaf</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Amsefrak n izegrar</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Inig d asawen</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-kk/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-kk/strings.xml
new file mode 100644
index 0000000000..6a25654110
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-kk/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Мәзір</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Ерекшеленген</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Қосымшалар</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Кеңейтулер</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Қосымшалар басқарушысы</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Кеңейтулер басқарушысы</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Жоғары жылжу</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Қосымшалар, жоғары өту</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Кеңейтулер, жоғары өту</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-kmr/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-kmr/strings.xml
new file mode 100644
index 0000000000..c977c1edc4
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-kmr/strings.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menû</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Berbiçavkirî</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Pêvek</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Rêvebera pêvekan</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Here jorê</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description">Pêvek, bi jorê ve here</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-kn/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-kn/strings.xml
new file mode 100644
index 0000000000..ca1bfb47be
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-kn/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">ಮೆನು</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">ಹೈಲೈಟ್ ಮಾಡಲಾಗಿದೆ</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">ಆಡ್-ಆನ್‌ಗಳು</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">ಆಡ್‌-ಆನ್‌ಗಳ ವ್ಯವಸ್ಥಾಪಕ</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ko/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ko/strings.xml
new file mode 100644
index 0000000000..f661f30b1e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ko/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">메뉴</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">강조 표시됨</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">부가 기능</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">확장 기능</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">부가 기능 관리자</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">확장 기능 관리자</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">위로 이동</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">부가 기능, 위로 이동</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">확장 기능, 위로 이동</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-kw/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-kw/strings.xml
new file mode 100644
index 0000000000..2dc45ab574
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-kw/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Rol</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Golowboyntys</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Keworansow</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Restrer Keworansow</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Brennya war-vann</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ldrtl/dimens.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ldrtl/dimens.xml
new file mode 100644
index 0000000000..10219fc900
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ldrtl/dimens.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources>
+ <dimen name="mozac_browser_menu_highlightable_notification_translate_x">-4dp</dimen>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-lij/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-lij/strings.xml
new file mode 100644
index 0000000000..c07f67427c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-lij/strings.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">In evidensa</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Conponenti azonti</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-lo/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-lo/strings.xml
new file mode 100644
index 0000000000..82a2c4998a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-lo/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">ເມນູ</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">ຈຸດເດັ່ນ</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Add-ons</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">ຕົວຈັດການ Add-ons</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">ນຳທາງຂຶ້ນໄປທາງເທິງ</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-lt/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-lt/strings.xml
new file mode 100644
index 0000000000..4fbc9bf311
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-lt/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Meniu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Paryškinta</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Priedai</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Priedų tvarkytuvė</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-mix/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-mix/strings.xml
new file mode 100644
index 0000000000..228f3e66cf
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-mix/strings.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Katsi</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Tu^un nchichi</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Add-ons</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Komplementos</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Ku\'ntyeé kutyi</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description">Complemento, ku\'ntyeé kutyi</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-mr/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-mr/strings.xml
new file mode 100644
index 0000000000..69f21eed4c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-mr/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">मेनू</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">ठळक</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">ॲड-ऑन</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">अ‍ॅड-ऑन व्यवस्थापक</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-my/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-my/strings.xml
new file mode 100644
index 0000000000..f01b0ddac4
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-my/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">မီနူး</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">အသားပေးအရာ</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">အတ်အွန်များ</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">အတ်အွန် စီမံရေး</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-nb-rNO/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-nb-rNO/strings.xml
new file mode 100644
index 0000000000..d6dbb1debb
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-nb-rNO/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Meny</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Uthevet</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Tillegg</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Utvidelser</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Tilleggsbehandler</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Behandling av utvidelser</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Naviger opp</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Tillegg, naviger opp</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Utvidelser, naviger opp</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ne-rNP/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ne-rNP/strings.xml
new file mode 100644
index 0000000000..d7ca7d7694
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ne-rNP/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">मेनु</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">हाइलाइट गरियो</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">एड-अनहरू</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">एड-अन म्यानेजर</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-nl/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-nl/strings.xml
new file mode 100644
index 0000000000..b11eaaa5e2
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-nl/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Gemarkeerd</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensies</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Add-onbeheerder</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Extensiebeheerder</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Omhoog</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons, omhoog navigeren</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensies, omhoog navigeren</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-nn-rNO/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-nn-rNO/strings.xml
new file mode 100644
index 0000000000..2bdfe10f1c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-nn-rNO/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Meny</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Utheva</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Tillegg</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Utvidingar</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Tilleggshandsamar</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Utvidingshandsamar</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Naviger opp</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Tillegg, naviger opp</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Utvidingar, naviger opp</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-oc/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-oc/strings.xml
new file mode 100644
index 0000000000..ad88a95a37
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-oc/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menú</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Notables</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Moduls complementaris</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensions</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Gestionari de moduls complementaris</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Gestionari d’extensions</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Remontar</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Moduls complementaris, montar</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extension, montar</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-or/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-or/strings.xml
new file mode 100644
index 0000000000..94b86a71fe
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-or/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">ମେନୁ</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">ହାଇଲାଇଟ୍ କରାଯାଇଥିବା</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">ଆଡ଼-ଅନସମୂହ</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">ଆଡ-ଅନ ପରିଚାଳକ</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-pa-rIN/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-pa-rIN/strings.xml
new file mode 100644
index 0000000000..342ab91a44
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-pa-rIN/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">ਮੀਨੂ</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">ਉਘਾੜੇ</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">ਐਡ-ਆਨ</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">ਇਕਸਟੈਨਸ਼ਨਾਂ</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">ਐਡ-ਆਨ ਮੈਨੇਜਰ</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">ਇਕਸਟੈਨਸ਼ਨ ਮੈਨੇਜਰ</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">ਉੱਤੇ ਜਾਓ</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">ਐਡ-ਆਨ, ਉੱਤੇ</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">ਇਕਸਟੈਨਸ਼ਨਾਂ, ਉੱਤੇ ਵੱਲ</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-pa-rPK/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-pa-rPK/strings.xml
new file mode 100644
index 0000000000..295865d138
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-pa-rPK/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">مینو</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">اُگھاڑے</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">وادھے والے</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">وادھیاں والیاں دیاں سیٹنگاں</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">اُتے جاؤ</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-pl/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000000..1929685c94
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-pl/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Wyróżnione</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Dodatki</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Rozszerzenia</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Zarządzaj dodatkami</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Zarządzaj rozszerzeniami</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Przejdź w górę</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Dodatki, przejdź w górę</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Rozszerzenia, przejdź w górę</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-pt-rBR/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-pt-rBR/strings.xml
new file mode 100644
index 0000000000..44cbedefab
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-pt-rBR/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Destacado</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Extensões</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensões</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Gerenciador de extensões</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Gerenciador de extensões</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Ir para o topo</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Extensões, voltar</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensões, voltar</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-pt-rPT/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-pt-rPT/strings.xml
new file mode 100644
index 0000000000..75b9647a29
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-pt-rPT/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Destacado</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Extras</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensões</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Gestor de extras</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Gestor de Extensões</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navegar para cima</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Extras, navegar para cima</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensões, navegar para cima</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-rm/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-rm/strings.xml
new file mode 100644
index 0000000000..8c8152c946
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-rm/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Cun emfasa</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Supplements</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensiuns</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Administraziun da supplements</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Administraziun dad extensiuns</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navigar ensi</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Supplements, returnar ensi</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensiuns, turnar ensi</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ro/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ro/strings.xml
new file mode 100644
index 0000000000..ab9f724ab0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ro/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Meniu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Evidențiat</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Suplimente</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Manager de suplimente</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navighează în sus</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ru/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000000..3a7c4eceab
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ru/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Меню</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Выделено</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Дополнения</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Расширения</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Менеджер дополнений</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Менеджер расширений</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Перейти наверх</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Дополнения, перейти вверх</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Расширения, перейти вверх</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-sat/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-sat/strings.xml
new file mode 100644
index 0000000000..4650592f55
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-sat/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">ᱢᱤᱱᱭᱩ</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">ᱩᱪᱷᱟᱹᱱᱟᱜ</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">ᱮᱰᱼᱚᱱᱥ</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">ᱮᱠᱥᱴᱮᱱᱥᱚᱱᱠᱚ</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">ᱮᱰᱼᱚᱱᱥ ᱵᱮᱵᱚᱥᱛᱟ</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">ᱮᱠᱥᱴᱮᱱᱥᱚᱱ ᱢᱮᱱᱮᱡᱚᱨ</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">ᱪᱮᱛᱟᱱ ᱥᱮᱱ ᱱᱮᱣᱤᱜᱮᱴ ᱢᱮ</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">ᱮᱰ-ᱚᱱᱥ, ᱪᱮᱛᱟᱱ ᱥᱮᱫ</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">ᱮᱠᱥᱴᱮᱱᱥᱚᱱ, ᱪᱮᱛᱟᱱ ᱛᱮ ᱥᱮᱱᱚᱜ ᱢᱮ</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-sc/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-sc/strings.xml
new file mode 100644
index 0000000000..4fd82314b3
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-sc/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menù</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">In evidèntzia</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Cumplementos</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Gestore de cumplementos</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Nàviga in artu</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-si/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-si/strings.xml
new file mode 100644
index 0000000000..08f69d548f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-si/strings.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">වට්ටෝරුව</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">ත්‍රීවාලෝකිත</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">එක්කහු</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">දිගු</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">එක්කහු කළමනාකරු</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">දිගු කළමනාකරු</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">ඉහළට යාත්‍රණය</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-sk/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-sk/strings.xml
new file mode 100644
index 0000000000..3a119e7b06
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-sk/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Ponuka</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Zvýraznené</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Doplnky</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Rozšírenia</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Správca doplnkov</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Správca rozšírení</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Prejsť nahor</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Doplnky, prejsť nahor</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Rozšírenia, prejsť nahor</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-skr/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-skr/strings.xml
new file mode 100644
index 0000000000..cd91a8395b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-skr/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">مینیو</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">نمایاں کیتا ڳیا</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">ایڈ ــ آن</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">ایکسٹینشنز</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">ایڈ ــ آن منیجر</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">ایکسٹنشنز منیجر</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">اُتے ون٘ڄو</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">ایڈ آن, اُتے نیویگیٹ کرو</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">ایکسٹنشناں, اُتے نیویگیٹ کرو</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-sl/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-sl/strings.xml
new file mode 100644
index 0000000000..f08c3d07ce
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-sl/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Meni</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Označeno</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Dodatki</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Razširitve</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Upravitelj dodatkov</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Upravitelj razširitev</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Pojdi gor</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Dodatki, pomakni se gor</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Razširitve, pomakni se gor</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-sq/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-sq/strings.xml
new file mode 100644
index 0000000000..54f6a4112c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-sq/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">E theksuar</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Shtesa</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Zgjerime</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Përgjegjës Shtesash</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Përgjegjës Zgjerimesh</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Lëvizni për sipër</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Shtesa, shkoni sipër</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Zgjerime, shkoni sipër</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-sr/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-sr/strings.xml
new file mode 100644
index 0000000000..5853163b79
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-sr/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Мени</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Истакнуто</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Додаци</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Управник додатака</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Иди нагоре</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-su/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-su/strings.xml
new file mode 100644
index 0000000000..1a786bee7f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-su/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Disorot</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Émboh</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Éksténsi</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Manajer Émboh</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Pangatur éksténsi</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Pindah ka luhur</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Add-ins, tuduhkeun ka luhur</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Éksténsi, tuduhkeun ka luhur</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-sv-rSE/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-sv-rSE/strings.xml
new file mode 100644
index 0000000000..002fa3390f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-sv-rSE/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Meny</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Markerad</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Tillägg</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Tillägg</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Tilläggshanterare</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Tilläggshanterare</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navigera uppåt</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Tillägg, navigera uppåt</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Tillägg, navigera upp</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-szl/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-szl/strings.xml
new file mode 100644
index 0000000000..497be7b9bf
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-szl/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Myni</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Ôbznoczōne</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Rozszyrzynia</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Regiyrowanie rozszyrzyniami</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Zōńdź na wiyrch</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ta/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ta/strings.xml
new file mode 100644
index 0000000000..a00d99cf17
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ta/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">பட்டி</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">மிளிர்ப்புகள்</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">துணை நிரல்கள்</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">துணை நிரல் நிர்வாகி</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-te/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-te/strings.xml
new file mode 100644
index 0000000000..c951083b0a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-te/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">మెనూ</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">హైలైట్ చేసినవి</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">పొడగింతలు</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">పొడగింతల నిర్వాహకి</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-tg/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-tg/strings.xml
new file mode 100644
index 0000000000..e3c7eecde1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-tg/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Меню</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Таъкид</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Ҷузъи иловагӣ</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Васеъшавиҳо</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Мудири ҷузъи иловагӣ</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Мудири васеъшавиҳо</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Ба боло гузаред</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Ҷузъҳои иловагӣ, гузариш ба боло</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Васеъшавиҳо, гузариш ба боло</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-th/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-th/strings.xml
new file mode 100644
index 0000000000..4ab63a3cdd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-th/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">เมนู</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">เน้น</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">ส่วนเสริม</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">ส่วนขยาย</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">ตัวจัดการส่วนเสริม</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">ตัวจัดการส่วนขยาย</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">นำทางขึ้นไปด้านบน</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">ส่วนเสริม, นำทางไปด้านบน</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">ส่วนขยาย, นำทางไปด้านบน</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-tl/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-tl/strings.xml
new file mode 100644
index 0000000000..45b5f15738
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-tl/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Mga naka-highlight</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Mga Add-on</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Add-on Manager</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-tok/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-tok/strings.xml
new file mode 100644
index 0000000000..f946769c8c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-tok/strings.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">ilo pi kepeken sin</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">lawa pi kepeken sin</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-tr/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-tr/strings.xml
new file mode 100644
index 0000000000..7e4c87c3f3
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-tr/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menü</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Vurgulu</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Eklentiler</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Uzantılar</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Eklenti yöneticisi</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Uzantı yöneticisi</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Yukarı git</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Eklentiler, yukarı git</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Uzantılar, yukarı git</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-trs/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-trs/strings.xml
new file mode 100644
index 0000000000..25b10e1df4
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-trs/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menû</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Sa ña\'āan</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Sa gā\'ue nūtò\'</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Sa nīkāj ñu\'ūnj nej sa gā\'ue nūtò\'</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Gāchē nun gan’ānj nāhuīt</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-tt/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-tt/strings.xml
new file mode 100644
index 0000000000..68f7abc358
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-tt/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Меню</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Аерылган</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Кушымчалар</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Кушымчалар менеджеры</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-tzm/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-tzm/strings.xml
new file mode 100644
index 0000000000..0c2827eb8f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-tzm/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Umuɣ</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ug/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ug/strings.xml
new file mode 100644
index 0000000000..df31e6911e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ug/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">تىزىملىك</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">ئالاھىدە</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">قىستۇرما</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">كېڭەيتمە</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">قىستۇرما باشقۇرغۇچ</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">كېڭەيتمە باشقۇرۇش</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">ئۈستىگە يول باشلا</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">قوشۇلما، ئۈستىگە يول باشلا</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">كېڭەيتمە، ئۈستىگە يول باشلا</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-uk/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-uk/strings.xml
new file mode 100644
index 0000000000..3788a0d5c8
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-uk/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Меню</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Виділено</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Додатки</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Розширення</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Керувати додатками</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Менеджер розширень</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Вгору</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Додатки, перейти вгору</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Розширення, перейти вгору</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-ur/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-ur/strings.xml
new file mode 100644
index 0000000000..c6070a6ec2
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-ur/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">مینیو</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">نمایاں کیا گیا</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">ایڈ اون</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">ایڈ اون مینیجر</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-uz/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-uz/strings.xml
new file mode 100644
index 0000000000..940434e75a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-uz/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menyu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Belgilangan</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Qoʻshimcha dasturlar</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Qoʻshimcha dasturlar boshqaruvchisi</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-vec/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-vec/strings.xml
new file mode 100644
index 0000000000..c999198e19
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-vec/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menù</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Evidensià</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Conponenti che se pole xontare</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Gestion Estension</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-vi/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-vi/strings.xml
new file mode 100644
index 0000000000..8100e82a1c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-vi/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Đã tô sáng</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Tiện ích</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Tiện ích</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Quản lí tiện ích</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Quản lý tiện ích</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Điều hướng lên</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Tiện ích, điều hướng lên</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Tiện ích, điều hướng lên</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-yo/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-yo/strings.xml
new file mode 100644
index 0000000000..6cacf9d220
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-yo/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Mẹ́nù</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Afàmìsí</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons">Àfikún</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager">Àsàmójútó Àfikún</string>
+ </resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-zh-rCN/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000000..bd7fd2ad36
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">菜单</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">高亮</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">附加组件</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">扩展</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">附加组件管理器</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">扩展管理器</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">向上导航</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">附加组件,向上导航</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">扩展,向上导航</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-zh-rTW/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 0000000000..e566379f79
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">選單</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">強調</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">附加元件</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">擴充套件</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">附加元件管理員</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">擴充套件管理員</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">向上導航</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">附加元件,向上導航</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">擴充套件,向上導航</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values/colors.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..8b76cc158d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources>
+ <!-- Empty by default, allows others to theme as they see fit -->
+ <color name="mozac_browser_menu_background"></color>
+</resources> \ No newline at end of file
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values/dimens.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..c815522515
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values/dimens.xml
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources>
+ <dimen name="mozac_browser_menu_corner_radius">4dp</dimen>
+ <dimen name="mozac_browser_menu_elevation">8dp</dimen>
+ <dimen name="mozac_browser_menu_width">250dp</dimen>
+ <dimen name="mozac_browser_menu_width_min">250dp</dimen>
+ <dimen name="mozac_browser_menu_width_max">250dp</dimen>
+ <dimen name="mozac_browser_menu_padding_vertical">0dp</dimen>
+
+ <!--Menu Item -->
+ <dimen name="mozac_browser_menu_item_text_size">16sp</dimen>
+ <dimen name="mozac_browser_menu_item_container_layout_height">48dp</dimen>
+ <dimen name="mozac_browser_menu_item_container_padding_start">16dp</dimen>
+ <dimen name="mozac_browser_menu_item_container_padding_end">16dp</dimen>
+ <!--Menu Item -->
+
+ <!--Checkbox Menu Item -->
+ <dimen name="mozac_browser_menu_item_checkbox_container_layout_height">48dp</dimen>
+ <dimen name="mozac_browser_menu_item_checkbox_container_padding_start">16dp</dimen>
+ <dimen name="mozac_browser_menu_item_checkbox_container_padding_end">16dp</dimen>
+ <!--Checkbox Menu Item -->
+
+ <!--DynamicWidthRecyclerView -->
+ <dimen name="mozac_browser_menu_material_min_tap_area">48dp</dimen>
+ <dimen name="mozac_browser_menu_material_min_item_width">112dp</dimen>
+ <!--DynamicWidthRecyclerView -->
+
+ <!--BrowserMenuDivider -->
+ <dimen name="mozac_browser_menu_item_divider_height">1dp</dimen>
+ <!--BrowserMenuDivider -->
+
+ <!--BrowserMenuHighlightableItem -->
+ <dimen name="mozac_browser_menu_highlightable_notification_translate_x">4dp</dimen>
+ <dimen name="mozac_browser_menu_highlightable_notification_translate_y">-4dp</dimen>
+ <dimen name="mozac_browser_menu_highlightable_notification_dot_size">8dp</dimen>
+ <!--BrowserMenuHighlightableItem -->
+
+ <!-- BrowserMenuCategory -->
+ <dimen name="mozac_browser_menu_category_text_size">14sp</dimen>
+ <dimen name="mozac_browser_menu_category_layout_height">40dp</dimen>
+ <dimen name="mozac_browser_menu_category_padding_start">16dp</dimen>
+ <dimen name="mozac_browser_menu_category_padding_end">16dp</dimen>
+ <!-- BrowserMenuCategory -->
+
+ <!--BrowserMenuCheckbox -->
+ <dimen name="mozac_browser_menu_checkbox_padding">12dp</dimen>
+ <!--BrowserMenuCheckbox -->
+
+ <!--WebExtensionBrowserMenuItem -->
+ <dimen name="mozac_browser_menu_item_web_extension_icon_width">24dp</dimen>
+ <dimen name="mozac_browser_menu_item_web_extension_icon_height">24dp</dimen>
+ <!--WebExtensionBrowserMenuItem -->
+
+ <!--BrowserMenuImageText-->
+
+ <!--Icon-->
+ <dimen name="mozac_browser_menu_item_image_text_icon_width">24dp</dimen>
+ <dimen name="mozac_browser_menu_item_image_text_icon_height">24dp</dimen>
+ <dimen name="mozac_browser_menu_item_image_text_icon_padding">20dp</dimen>
+ <!--Icon-->
+
+ <!--Label-->
+ <dimen name="mozac_browser_menu_item_image_text_label_padding_start">20dp</dimen>
+ <dimen name="mozac_browser_menu_item_checkbox_text_label_padding_start">20dp</dimen>
+ <dimen name="mozac_browser_menu_item_checkbox_text_label_padding_end">20dp</dimen>
+ <!--Label-->
+
+ <!--Checkbox-->
+ <dimen name="mozac_browser_menu_item_image_checkbox_padding_start">12dp</dimen>
+ <dimen name="mozac_browser_menu_item_image_checkbox_padding_end">12dp</dimen>
+ <!--Checkbox-->
+
+ <!--BrowserMenuImageText-->
+
+ <!-- BrowserMenuItemToolbar -->
+ <dimen name="mozac_browser_menu_item_toolbar_height">56dp</dimen>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..dae4c81308
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values/strings.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources xmlns:tools="http://schemas.android.com/tools" xmlns:moz="http://mozac.org/tools">
+ <!-- Content description (not visible, for screen readers etc.): Description for the overflow menu button in the browser toolbar. -->
+ <string name="mozac_browser_menu_button">Menu</string>
+ <!-- Content description (not visible, for screen readers etc.): Indicates the overflow menu has a highlight -->
+ <string name="mozac_browser_menu_highlighted">Highlighted</string>
+ <!-- Label for add-ons submenu section -->
+ <string name="mozac_browser_menu_addons" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons</string>
+ <!-- Label for extensions submenu section -->
+ <string name="mozac_browser_menu_extensions">Extensions</string>
+ <!-- Label for add-ons sub menu item for add-ons manager -->
+ <string name="mozac_browser_menu_addons_manager" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons Manager</string>
+ <!-- Label for extensions sub menu item for extensions manager -->
+ <string name="mozac_browser_menu_extensions_manager">Extensions Manager</string>
+ <!-- Content description for the action bar "up" button -->
+ <string name="action_bar_up_description">Navigate up</string>
+ <!-- Content description for the action bar "up" button of the add-ons sub menu item -->
+ <string name="mozac_browser_menu_addons_description" moz:removedIn="126" tools:ignore="UnusedResources">Add-ons, navigate up</string>
+ <!-- Content description for the action bar "up" button of the extensions sub menu item -->
+ <string name="mozac_browser_menu_extensions_content_description">Extensions, navigate up</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values/style.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values/style.xml
new file mode 100644
index 0000000000..078d14e7fa
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values/style.xml
@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources>
+
+ <style name="Mozac.Browser.Menu" parent="">
+ <item name="cardBackgroundColor">@color/mozac_browser_menu_background</item>
+ </style>
+
+ <!-- Item Divider -->
+ <style name="Mozac.Browser.Menu.Item.Divider" parent="">
+ <item name="android:background">?android:attr/listDivider</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.Divider.Horizontal">
+ <item name="android:layout_width">match_parent</item>
+ <item name="android:layout_height">@dimen/mozac_browser_menu_item_divider_height</item>
+ </style>
+ <!-- Item Divider -->
+
+ <style name="Mozac.Browser.Menu.Item.Container" parent="">
+ <item name="android:layout_height">@dimen/mozac_browser_menu_item_container_layout_height</item>
+ <item name="android:paddingStart">@dimen/mozac_browser_menu_item_container_padding_start</item>
+ <item name="android:paddingEnd">@dimen/mozac_browser_menu_item_container_padding_end</item>
+ <item name="android:background">?android:attr/selectableItemBackground</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.CandidateContainer" parent="Mozac.Browser.Menu.Item.Container">
+ <item name="android:paddingStart">16dp</item>
+ <item name="android:paddingEnd">16dp</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.Text" parent="@android:style/TextAppearance.Material.Menu">
+ <item name="android:background">?android:attr/selectableItemBackground</item>
+ <item name="android:textSize">@dimen/mozac_browser_menu_item_text_size</item>
+ <item name="android:ellipsize">end</item>
+ <item name="android:lines">1</item>
+ <item name="android:focusable">true</item>
+ <item name="android:clickable">true</item>
+ </style>
+
+ <!-- BrowserMenuCategory -->
+ <style name="Mozac.Browser.Menu.Item.Category" parent="">
+ <item name="android:layout_height">@dimen/mozac_browser_menu_category_layout_height</item>
+ <item name="android:textSize">@dimen/mozac_browser_menu_category_text_size</item>
+ <item name="android:paddingStart">@dimen/mozac_browser_menu_category_padding_start</item>
+ <item name="android:paddingEnd">@dimen/mozac_browser_menu_category_padding_end</item>
+ <item name="android:background">?android:attr/selectableItemBackground</item>
+ </style>
+ <!-- BrowserMenuCategory -->
+
+ <!-- BrowserMenuImageText -->
+ <style name="Mozac.Browser.Menu.Item.ImageText.Icon" parent="">
+ <item name="android:layout_width">@dimen/mozac_browser_menu_item_image_text_icon_width</item>
+ <item name="android:layout_height">@dimen/mozac_browser_menu_item_image_text_icon_height</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.CandidateIcon" parent="Mozac.Browser.Menu.Item.ImageText.Icon">
+ <item name="android:layout_width">24dp</item>
+ <item name="android:layout_height">24dp</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.ImageText.Label" parent="Mozac.Browser.Menu.Item.Text">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:paddingStart">@dimen/mozac_browser_menu_item_image_text_label_padding_start</item>
+ <item name="android:paddingEnd">@dimen/mozac_browser_menu_item_image_text_label_padding_start</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.Checkbox.Label" parent="Mozac.Browser.Menu.Item.ImageText.Label">
+ <item name="android:paddingStart">@dimen/mozac_browser_menu_item_checkbox_text_label_padding_start</item>
+ <item name="android:paddingEnd">@dimen/mozac_browser_menu_item_checkbox_text_label_padding_end</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.Checkbox.Text" parent="Mozac.Browser.Menu.Item.Text">
+ <item name="android:layout_width">0dp</item>
+ <item name="android:layout_height">0dp</item>
+ <item name="android:paddingStart">@dimen/mozac_browser_menu_item_image_checkbox_padding_start</item>
+ <item name="android:paddingEnd">@dimen/mozac_browser_menu_item_image_checkbox_padding_end</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.Checkbox.Container" parent="Mozac.Browser.Menu.Item.Container">
+ <item name="android:layout_height">@dimen/mozac_browser_menu_item_checkbox_container_layout_height</item>
+ <item name="android:paddingStart">@dimen/mozac_browser_menu_item_checkbox_container_padding_start</item>
+ <item name="android:paddingEnd">@dimen/mozac_browser_menu_item_checkbox_container_padding_end</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Item.CandidateLabel" parent="Mozac.Browser.Menu.Item.Text">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ </style>
+ <!-- BrowserMenuImageText -->
+
+ <!-- Animation -->
+ <style name="Mozac.Browser.Menu.Animation.OverflowMenuTop" parent="">
+ <item name="android:windowEnterAnimation">@anim/menu_enter_top</item>
+ <item name="android:windowExitAnimation">@anim/menu_exit</item>
+ </style>
+
+ <style name="Mozac.Browser.Menu.Animation.OverflowMenuBottom" parent="">
+ <item name="android:windowEnterAnimation">@anim/menu_enter_bottom</item>
+ <item name="android:windowExitAnimation">@anim/menu_exit</item>
+ </style>
+ <!-- Animation -->
+</resources>
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuAdapterTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuAdapterTest.kt
new file mode 100644
index 0000000000..64d7c41260
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuAdapterTest.kt
@@ -0,0 +1,210 @@
+/* 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 mozilla.components.browser.menu
+
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuAdapterTest {
+
+ @Test
+ fun `items that return false from the visible lambda will be filtered out`() {
+ val items = listOf(
+ createMenuItem(1, { true }),
+ createMenuItem(2, { true }),
+ createMenuItem(3, { false }),
+ createMenuItem(4, { false }),
+ createMenuItem(5, { true }),
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ assertEquals(3, adapter.visibleItems.size)
+
+ adapter.visibleItems.assertTrueForOne { it.getLayoutResource() == 1 }
+ adapter.visibleItems.assertTrueForOne { it.getLayoutResource() == 2 }
+ adapter.visibleItems.assertTrueForOne { it.getLayoutResource() == 5 }
+
+ adapter.visibleItems.assertTrueForAll { it.visible() }
+
+ assertEquals(3, adapter.itemCount)
+ }
+
+ @Test
+ fun `layout resource ID is used as view type`() {
+ val items = listOf(
+ createMenuItem(23),
+ createMenuItem(42),
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ assertEquals(2, adapter.itemCount)
+
+ assertEquals(23, adapter.getItemViewType(0))
+ assertEquals(42, adapter.getItemViewType(1))
+ }
+
+ @Test
+ fun `bind will be forwarded to item implementation`() {
+ val item1 = spy(createMenuItem())
+ val item2 = spy(createMenuItem())
+
+ val menu = mock(BrowserMenu::class.java)
+
+ val adapter = BrowserMenuAdapter(testContext, listOf(item1, item2))
+ adapter.menu = menu
+
+ val view = mock(View::class.java)
+ val holder = BrowserMenuItemViewHolder(view)
+
+ adapter.onBindViewHolder(holder, 0)
+
+ verify(item1).bind(menu, view)
+ verify(item2, never()).bind(menu, view)
+
+ reset(item1)
+ reset(item2)
+
+ adapter.onBindViewHolder(holder, 1)
+
+ verify(item1, never()).bind(menu, view)
+ verify(item2).bind(menu, view)
+ }
+
+ @Test
+ fun `invalidate will be forwarded to item implementation`() {
+ val item1 = spy(createMenuItem())
+ val item2 = spy(createMenuItem())
+
+ val menu = mock(BrowserMenu::class.java)
+
+ val adapter = BrowserMenuAdapter(testContext, listOf(item1, item2))
+ adapter.menu = menu
+ val recyclerView = mock(RecyclerView::class.java)
+
+ val view = mock(View::class.java)
+ val holder = BrowserMenuItemViewHolder(view)
+ `when`(recyclerView.findViewHolderForAdapterPosition(0)).thenReturn(holder)
+ `when`(recyclerView.findViewHolderForAdapterPosition(1)).thenReturn(null)
+
+ adapter.invalidate(recyclerView)
+
+ verify(item1).invalidate(view)
+ verify(item2, never()).invalidate(view)
+ }
+
+ @Test
+ fun `total interactive item count is given provided adapter`() {
+ val items = listOf(
+ createMenuItem(1, { true }, { 1 }),
+ createMenuItem(2, { true }, { 0 }),
+ createMenuItem(3, { false }, { 10 }),
+ createMenuItem(4, { true }, { 5 }),
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ assertEquals(6, adapter.interactiveCount)
+ }
+
+ @Test
+ fun `GIVEN a stickyItem exists in the visible items WHEN isStickyItem is called THEN it returns true`() {
+ val items = listOf(
+ createMenuItem(1, { true }, { 1 }),
+ createMenuItem(3, { true }, { 10 }, true),
+ createMenuItem(4, { true }, { 5 }),
+ createMenuItem(3, { false }, { 3 }, true),
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ assertFalse(adapter.isStickyItem(0))
+ assertTrue(adapter.isStickyItem(1))
+ assertFalse(adapter.isStickyItem(2))
+ assertFalse(adapter.isStickyItem(3))
+ assertFalse(adapter.isStickyItem(4))
+ }
+
+ @Test
+ fun `GIVEN a BrowserMenu exists WHEN setupStickyItem is called THEN the item background color is set for the View parameter`() {
+ val adapter = BrowserMenuAdapter(testContext, emptyList())
+ val menu: BrowserMenu = mock()
+ menu.backgroundColor = Color.CYAN
+ adapter.menu = menu
+ val view = View(testContext)
+
+ adapter.setupStickyItem(view)
+
+ assertEquals(menu.backgroundColor, (view.background as ColorDrawable).color)
+ }
+
+ @Test
+ fun `GIVEN BrowserMenuAdapter WHEN tearDownStickyItem is called THEN the item background is reset to transparent`() {
+ val adapter = BrowserMenuAdapter(testContext, emptyList())
+ val view = View(testContext)
+ view.setBackgroundColor(Color.CYAN)
+
+ adapter.tearDownStickyItem(view)
+
+ assertEquals(Color.TRANSPARENT, (view.background as ColorDrawable).color)
+ }
+
+ private fun List<BrowserMenuItem>.assertTrueForOne(predicate: (BrowserMenuItem) -> Boolean) {
+ for (item in this) {
+ if (predicate(item)) {
+ return
+ }
+ }
+ fail("Predicate false for all items")
+ }
+
+ private fun List<BrowserMenuItem>.assertTrueForAll(predicate: (BrowserMenuItem) -> Boolean) {
+ for (item in this) {
+ if (!predicate(item)) {
+ fail("Predicate not true for all items")
+ }
+ }
+ }
+
+ private fun createMenuItem(
+ layout: Int = 0,
+ visible: () -> Boolean = { true },
+ interactiveCount: () -> Int = { 1 },
+ isSticky: Boolean = false,
+ ): BrowserMenuItem {
+ return object : BrowserMenuItem {
+ override val visible = visible
+
+ override val interactiveCount = interactiveCount
+
+ override fun getLayoutResource() = layout
+
+ override fun bind(menu: BrowserMenu, view: View) {}
+
+ override fun invalidate(view: View) {}
+
+ override val isSticky = isSticky
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuBuilderTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuBuilderTest.kt
new file mode 100644
index 0000000000..6f39e7cd64
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuBuilderTest.kt
@@ -0,0 +1,44 @@
+/* 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 mozilla.components.browser.menu
+
+import android.view.View
+import android.widget.ImageButton
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuBuilderTest {
+
+ @Test
+ fun `items are forwarded from builder to menu`() {
+ val builder = BrowserMenuBuilder(listOf(mockMenuItem(), mockMenuItem()))
+
+ val menu = builder.build(testContext)
+
+ val anchor = ImageButton(testContext)
+ val popup = menu.show(anchor)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ assertNotNull(recyclerView)
+
+ val recyclerAdapter = recyclerView.adapter!!
+ assertNotNull(recyclerAdapter)
+ assertEquals(2, recyclerAdapter.itemCount)
+ }
+
+ private fun mockMenuItem() = object : BrowserMenuItem {
+ override val visible: () -> Boolean = { true }
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_item_simple
+
+ override fun bind(menu: BrowserMenu, view: View) {}
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuHighlightTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuHighlightTest.kt
new file mode 100644
index 0000000000..aa4f6d33cd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuHighlightTest.kt
@@ -0,0 +1,49 @@
+/* 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 mozilla.components.browser.menu
+
+import android.graphics.Color
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.concept.menu.candidate.HighPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.LowPriorityHighlightEffect
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import mozilla.components.ui.colors.R as colorsR
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuHighlightTest {
+
+ @Test
+ fun `low priority effect keeps notification tint`() {
+ val highlight = BrowserMenuHighlight.LowPriority(
+ notificationTint = Color.RED,
+ )
+ assertEquals(LowPriorityHighlightEffect(Color.RED), highlight.asEffect(mock()))
+ }
+
+ @Test
+ fun `high priority effect keeps background tint`() {
+ val highlight = BrowserMenuHighlight.HighPriority(
+ backgroundTint = Color.RED,
+ )
+ assertEquals(HighPriorityHighlightEffect(Color.RED), highlight.asEffect(mock()))
+ }
+
+ @Suppress("Deprecation")
+ @Test
+ fun `classic highlight effect converts background tint`() {
+ val colorId = colorsR.color.photonRed50
+ val highlight = BrowserMenuHighlight.ClassicHighlight(
+ startImageResource = 0,
+ endImageResource = 0,
+ backgroundResource = 0,
+ colorResource = colorId,
+ )
+ assertEquals(HighPriorityHighlightEffect(testContext.getColor(colorId)), highlight.asEffect(testContext))
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuPositioningTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuPositioningTest.kt
new file mode 100644
index 0000000000..898733464a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuPositioningTest.kt
@@ -0,0 +1,163 @@
+/* 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 mozilla.components.browser.menu
+
+import android.view.View
+import android.view.ViewGroup
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.whenever
+import org.junit.Assert
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.Mockito.spy
+import org.robolectric.Shadows
+import org.robolectric.shadows.ShadowDisplay
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuPositioningTest {
+
+ @Test
+ fun `GIVEN inferMenuPositioningData WHEN called with the menu layout, anchor and current menu data THEN it returns a new MenuPositioningData populated with all data needed to show a PopupWindow`() {
+ val view: ViewGroup = mock()
+ Mockito.doReturn(70).`when`(view).measuredHeight
+ val anchor = View(testContext)
+ anchor.layoutParams = ViewGroup.LayoutParams(20, 40)
+ setScreenHeight(100)
+
+ val result = inferMenuPositioningData(view, anchor, MenuPositioningData())
+
+ val expected = MenuPositioningData(
+ BrowserMenuPlacement.AnchoredToTop.Dropdown(anchor), // orientation DOWN and fitsDown
+ askedOrientation = BrowserMenu.Orientation.DOWN, // default
+ fitsUp = false, // availableHeightToTop(0) is smaller than containerHeight(70)
+ fitsDown = true, // availableHeightToBottom(470) is bigger than containerHeight(70)
+ availableHeightToTop = 0,
+ availableHeightToBottom = 100, // mocked by us above
+ containerViewHeight = 70, // mocked by us above
+ )
+ Assert.assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN inferMenuPositioningData WHEN availableHeightToBottom is bigger than availableHeightToTop THEN it returns a new MenuPositioningData populated with all data needed to show a PopupWindow that fits down`() {
+ val view: ViewGroup = mock()
+ Mockito.doReturn(70).`when`(view).measuredHeight
+ val anchor = View(testContext)
+ anchor.layoutParams = ViewGroup.LayoutParams(20, 40)
+
+ setScreenHeight(50)
+
+ val result = inferMenuPositioningData(view, anchor, MenuPositioningData())
+
+ val expected = MenuPositioningData(
+ BrowserMenuPlacement.AnchoredToTop.Dropdown(anchor), // orientation DOWN and fitsDown
+ askedOrientation = BrowserMenu.Orientation.DOWN, // default
+ fitsUp = false, // availableHeightToTop(0) is smaller than containerHeight(70) and smaller than availableHeightToBottom(50)
+ fitsDown = true, // availableHeightToBottom(50) is smaller than containerHeight(70) and bigger than availableHeightToTop(0)
+ availableHeightToTop = 0,
+ availableHeightToBottom = 50, // mocked by us above
+ containerViewHeight = 70, // mocked by us above
+ )
+ Assert.assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN inferMenuPositioningData WHEN availableHeightToTop is bigger than availableHeightToBottom THEN it returns a new MenuPositioningData populated with all data needed to show a PopupWindow that fits up`() {
+ val view: ViewGroup = mock()
+ Mockito.doReturn(70).`when`(view).measuredHeight
+ val anchor = spy(View(testContext))
+ anchor.layoutParams = ViewGroup.LayoutParams(20, 40)
+
+ whenever(anchor.getLocationOnScreen(IntArray(2))).thenAnswer { invocation ->
+ val args = invocation.arguments
+ val location = args[0] as IntArray
+ location[0] = 0
+ location[1] = 60
+ location
+ }
+
+ setScreenHeight(100)
+
+ val result = inferMenuPositioningData(view, anchor, MenuPositioningData())
+
+ val expected = MenuPositioningData(
+ BrowserMenuPlacement.AnchoredToBottom.Dropdown(anchor), // orientation UP and fitsUp
+ askedOrientation = BrowserMenu.Orientation.DOWN, // default
+ fitsUp = true, // availableHeightToTop(60) is smaller than containerHeight(70) and bigger than availableHeightToBottom(40)
+ fitsDown = false, // availableHeightToBottom(40) is smaller than containerHeight(70) and smaller than availableHeightToTop(60)
+ availableHeightToTop = 60, // mocked by us above
+ availableHeightToBottom = 40,
+ containerViewHeight = 70, // mocked by us above
+ )
+
+ Assert.assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN inferMenuPosition WHEN called with an anchor and the current menu data THEN it returns a new MenuPositioningData with data about positioning the menu`() {
+ val view: View = mock()
+
+ var data = MenuPositioningData(askedOrientation = BrowserMenu.Orientation.DOWN, fitsDown = true)
+ var result = inferMenuPosition(view, data)
+ Assert.assertEquals(
+ BrowserMenuPlacement.AnchoredToTop.Dropdown(view),
+ result.inferredMenuPlacement,
+ )
+
+ data = MenuPositioningData(askedOrientation = BrowserMenu.Orientation.UP, fitsUp = true)
+ result = inferMenuPosition(view, data)
+ Assert.assertEquals(
+ BrowserMenuPlacement.AnchoredToBottom.Dropdown(view),
+ result.inferredMenuPlacement,
+ )
+
+ data = MenuPositioningData(
+ fitsUp = false,
+ fitsDown = false,
+ availableHeightToTop = 1,
+ availableHeightToBottom = 2,
+ )
+ result = inferMenuPosition(view, data)
+ Assert.assertEquals(
+ BrowserMenuPlacement.AnchoredToTop.ManualAnchoring(view),
+ result.inferredMenuPlacement,
+ )
+
+ data = MenuPositioningData(
+ fitsUp = false,
+ fitsDown = false,
+ availableHeightToTop = 1,
+ availableHeightToBottom = 0,
+ )
+ result = inferMenuPosition(view, data)
+ Assert.assertEquals(
+ BrowserMenuPlacement.AnchoredToBottom.ManualAnchoring(view),
+ result.inferredMenuPlacement,
+ )
+
+ data = MenuPositioningData(askedOrientation = BrowserMenu.Orientation.DOWN, fitsUp = true)
+ result = inferMenuPosition(view, data)
+ Assert.assertEquals(
+ BrowserMenuPlacement.AnchoredToBottom.Dropdown(view),
+ result.inferredMenuPlacement,
+ )
+
+ data = MenuPositioningData(askedOrientation = BrowserMenu.Orientation.UP, fitsDown = true)
+ result = inferMenuPosition(view, data)
+ Assert.assertEquals(
+ BrowserMenuPlacement.AnchoredToTop.Dropdown(view),
+ result.inferredMenuPlacement,
+ )
+ }
+
+ private fun setScreenHeight(value: Int) {
+ val display = ShadowDisplay.getDefaultDisplay()
+ val shadow = Shadows.shadowOf(display)
+ shadow.setHeight(value)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuTest.kt
new file mode 100644
index 0000000000..aff7c709db
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuTest.kt
@@ -0,0 +1,496 @@
+/* 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 mozilla.components.browser.menu
+
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.os.Build
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.widget.Button
+import android.widget.FrameLayout
+import androidx.cardview.widget.CardView
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu.Orientation.DOWN
+import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
+import mozilla.components.browser.menu.view.DynamicWidthRecyclerView
+import mozilla.components.browser.menu.view.ExpandableLayout
+import mozilla.components.browser.menu.view.StickyHeaderLinearLayoutManager
+import mozilla.components.concept.menu.MenuStyle
+import mozilla.components.support.test.any
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.Mockito.doNothing
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowDisplay
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuTest {
+
+ @Test
+ fun `show returns non-null popup window`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ val menu = BrowserMenu(adapter)
+
+ val anchor = Button(testContext)
+ val popup = menu.show(anchor)
+
+ assertNotNull(popup)
+ }
+
+ @Test
+ fun `show assigns currAnchor and isShown`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ val menu = BrowserMenu(adapter)
+
+ val anchor = Button(testContext)
+ val popup = menu.show(anchor)
+
+ assertNotNull(popup)
+ assertEquals(anchor, menu.currAnchor)
+ assertTrue(menu.isShown)
+ }
+
+ @Test
+ fun `show assigns width and background color`() {
+ val items = listOf(SimpleBrowserMenuItem("Hello") {})
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ val menu = spy(BrowserMenu(adapter))
+
+ val anchor = Button(testContext)
+ val menuStyle = MenuStyle(
+ backgroundColor = Color.RED,
+ minWidth = 20,
+ maxWidth = 500,
+ )
+ val popup = menu.show(anchor, style = menuStyle)
+
+ assertNotNull(popup)
+ assertEquals(anchor, menu.currAnchor)
+ assertTrue(menu.isShown)
+
+ val cardView = popup.contentView.findViewById<CardView>(R.id.mozac_browser_menu_menuView)
+ val recyclerView = popup.contentView.findViewById<DynamicWidthRecyclerView>(R.id.mozac_browser_menu_recyclerView)
+
+ verify(menu).setColors(any(), eq(menuStyle))
+ assertEquals(ColorStateList.valueOf(Color.RED), cardView.cardBackgroundColor)
+ assertEquals(20, recyclerView.minWidth)
+ assertEquals(500, recyclerView.maxWidth)
+ }
+
+ @Test
+ fun `dismiss sets isShown to false`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ val menu = BrowserMenu(adapter)
+
+ val anchor = Button(testContext)
+ val popup = menu.show(anchor)
+ popup.dismiss()
+
+ assertFalse(menu.isShown)
+ }
+
+ @Test
+ fun `recyclerview adapter will have items for every menu item`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ val menu = BrowserMenu(adapter)
+
+ val anchor = Button(testContext)
+ val popup = menu.show(anchor)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ assertNotNull(recyclerView)
+
+ val recyclerAdapter = recyclerView.adapter!!
+ assertNotNull(recyclerAdapter)
+ assertEquals(2, recyclerAdapter.itemCount)
+ }
+
+ @Test
+ fun `endOfMenuAlwaysVisible will be forwarded to recyclerview layoutManager`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+
+ val adapter = spy(BrowserMenuAdapter(testContext, items))
+ val menu = BrowserMenu(adapter)
+
+ val anchor = Button(testContext)
+ val popup = menu.show(anchor, endOfMenuAlwaysVisible = true)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ assertNotNull(recyclerView)
+
+ val layoutManager = recyclerView.layoutManager as LinearLayoutManager
+ assertTrue(layoutManager.stackFromEnd)
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.M])
+ fun `endOfMenuAlwaysVisible will be forwarded to scrollOnceToTheBottom on devices with Android M and below`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+ val menu = spy(BrowserMenu(adapter))
+ doNothing().`when`(menu).scrollOnceToTheBottom(any())
+
+ val anchor = Button(testContext)
+ val popup = menu.show(anchor, endOfMenuAlwaysVisible = true)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ val layoutManager = recyclerView.layoutManager as LinearLayoutManager
+
+ assertFalse(layoutManager.stackFromEnd)
+ verify(menu).scrollOnceToTheBottom(any())
+ }
+
+ @Test
+ fun `invalidate will be forwarded to recyclerview adapter`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+
+ val adapter = spy(BrowserMenuAdapter(testContext, items))
+
+ val menu = BrowserMenu(adapter)
+
+ val anchor = Button(testContext)
+ val popup = menu.show(anchor)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ assertNotNull(recyclerView)
+ assertNotNull(recyclerView.adapter)
+
+ menu.invalidate()
+ Mockito.verify(adapter).invalidate(recyclerView)
+ }
+
+ @Test
+ fun `invalidate is a no-op if the menu is closed`() {
+ val items = listOf(SimpleBrowserMenuItem("Hello") {})
+ val menu = BrowserMenu(BrowserMenuAdapter(testContext, items))
+
+ menu.invalidate()
+ }
+
+ @Test
+ fun `created popup window is displayed automatically`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ val menu = BrowserMenu(adapter)
+
+ val anchor = Button(testContext)
+ val popup = menu.show(anchor)
+
+ assertTrue(popup.isShowing)
+ }
+
+ @Test
+ fun `dismissing the browser menu will dismiss the popup`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+
+ val menu = BrowserMenu(adapter)
+
+ val anchor = Button(testContext)
+ val popup = menu.show(anchor)
+
+ assertTrue(popup.isShowing)
+
+ menu.dismiss()
+
+ assertFalse(popup.isShowing)
+ }
+
+ @Test
+ fun `determineMenuOrientation returns Orientation-DOWN by default`() {
+ assertEquals(
+ BrowserMenu.Orientation.DOWN,
+ BrowserMenu.determineMenuOrientation(mock()),
+ )
+ }
+
+ @Test
+ fun `determineMenuOrientation returns Orientation-UP for views with bottom gravity in CoordinatorLayout`() {
+ val params = CoordinatorLayout.LayoutParams(100, 100)
+ params.gravity = Gravity.BOTTOM
+
+ val view = View(testContext)
+ view.layoutParams = params
+
+ assertEquals(
+ BrowserMenu.Orientation.UP,
+ BrowserMenu.determineMenuOrientation(view),
+ )
+ }
+
+ @Test
+ fun `determineMenuOrientation returns Orientation-DOWN for views with top gravity in CoordinatorLayout`() {
+ val params = CoordinatorLayout.LayoutParams(100, 100)
+ params.gravity = Gravity.TOP
+
+ val view = View(testContext)
+ view.layoutParams = params
+
+ assertEquals(
+ BrowserMenu.Orientation.DOWN,
+ BrowserMenu.determineMenuOrientation(view),
+ )
+ }
+
+ @Test
+ fun `Popup#show will initialize the menuPositioningData`() {
+ val adapter = BrowserMenuAdapter(testContext, emptyList())
+ val menu = BrowserMenu(adapter)
+ val anchor = Button(testContext)
+ setScreenHeight(100)
+
+ menu.show(anchor)
+
+ val expected = MenuPositioningData(
+ BrowserMenuPlacement.AnchoredToTop.Dropdown(anchor),
+ DOWN,
+ false,
+ true,
+ 0,
+ 100,
+ 28,
+ )
+ assertEquals(expected, menu.menuPositioningData)
+ }
+
+ @Test
+ fun `configureExpandableMenu will setup a new ExpandabeLayout for a AnchoredToBottom#ManualAnchoring menu`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World", isCollapsingMenuLimit = true) {},
+ )
+ val adapter = BrowserMenuAdapter(testContext, items)
+ val menu = BrowserMenu(adapter)
+ val view = FrameLayout(testContext)
+ val anchor = Button(testContext)
+ menu.menuPositioningData = MenuPositioningData(BrowserMenuPlacement.AnchoredToBottom.Dropdown(anchor))
+
+ val result = menu.configureExpandableMenu(view, true)
+
+ assertTrue(result is ExpandableLayout)
+ assertTrue(result.getChildAt(0) == view)
+ }
+
+ @Test
+ fun `configureExpandableMenu will setup a new ExpandabeLayout for a AnchoredToBottom#Dropdown menu`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World", isCollapsingMenuLimit = true) {},
+ )
+ val adapter = BrowserMenuAdapter(testContext, items)
+ val menu = BrowserMenu(adapter)
+ val view = FrameLayout(testContext)
+ val anchor = Button(testContext)
+ menu.menuPositioningData = MenuPositioningData(BrowserMenuPlacement.AnchoredToBottom.ManualAnchoring(anchor))
+
+ val result = menu.configureExpandableMenu(view, true)
+
+ assertTrue(result is ExpandableLayout)
+ assertTrue(result.getChildAt(0) == view)
+ }
+
+ @Test
+ fun `configureExpandableMenu will not setup a new ExpandableLayout if none of the items can serve as a collapsingMenuLimit`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+ val adapter = BrowserMenuAdapter(testContext, items)
+ val menu = BrowserMenu(adapter)
+ val view = FrameLayout(testContext)
+ val anchor = Button(testContext)
+ menu.menuPositioningData = MenuPositioningData(BrowserMenuPlacement.AnchoredToBottom.ManualAnchoring(anchor))
+
+ val result = menu.configureExpandableMenu(view, true)
+
+ assertFalse(result is ExpandableLayout)
+ assertTrue(result == view)
+ }
+
+ @Test
+ fun `GIVEN a top anchored menu WHEN configureExpandableMenu is called THEN it a new layout manager with sticky item at top is set`() {
+ val menu = spy(BrowserMenu(mock()))
+ // Call show to have a default layout manager set
+ menu.show(View(testContext))
+ val initialLayoutManager = menu.menuList!!.layoutManager
+ menu.menuPositioningData = MenuPositioningData(BrowserMenuPlacement.AnchoredToTop.Dropdown(mock()))
+
+ menu.configureExpandableMenu(menu.menuList!!, false)
+
+ assertNotSame(initialLayoutManager, menu.menuList!!.layoutManager)
+ assertTrue(menu.menuList!!.layoutManager is StickyHeaderLinearLayoutManager<*>)
+ }
+
+ @Test
+ fun `GIVEN a top anchored menu WHEN configureExpandableMenu is called THEN stackFromEnd is false`() {
+ val menu = spy(BrowserMenu(mock()))
+ // Call show to have a default layout manager set
+ menu.show(View(testContext))
+ menu.menuPositioningData = MenuPositioningData(BrowserMenuPlacement.AnchoredToTop.Dropdown(mock()))
+
+ menu.configureExpandableMenu(menu.menuList!!, false)
+
+ assertFalse((menu.menuList!!.layoutManager as LinearLayoutManager).stackFromEnd)
+ }
+
+ @Test
+ fun `GIVEN a top anchored menu WHEN configureExpandableMenu is called THEN stackFromEnd is true`() {
+ val menu = spy(BrowserMenu(mock()))
+ // Call show to have a default layout manager set
+ menu.show(View(testContext))
+ menu.menuPositioningData = MenuPositioningData(BrowserMenuPlacement.AnchoredToTop.Dropdown(mock()))
+
+ menu.configureExpandableMenu(menu.menuList!!, true)
+
+ assertTrue((menu.menuList!!.layoutManager as LinearLayoutManager).stackFromEnd)
+ }
+
+ @Test
+ fun `getNewPopupWindow will return a PopupWindow with MATCH_PARENT height if the view is ExpandableLayout`() {
+ val expandableLayout = ExpandableLayout.wrapContentInExpandableView(FrameLayout(testContext), 0) { }
+
+ val result = BrowserMenu(mock()).getNewPopupWindow(expandableLayout)
+
+ assertSame(expandableLayout, result.contentView)
+ assertTrue(result.height == MATCH_PARENT)
+ assertTrue(result.width == WRAP_CONTENT)
+ }
+
+ @Test
+ fun `getNewPopupWindow will return a PopupWindow with WRAP_CONTENT height if the view is not ExpandableLayout`() {
+ val notExpandableLayout = FrameLayout(testContext)
+
+ val result = BrowserMenu(mock()).getNewPopupWindow(notExpandableLayout)
+
+ assertSame(notExpandableLayout, result.contentView)
+ assertTrue(result.height == WRAP_CONTENT)
+ assertTrue(result.width == WRAP_CONTENT)
+ }
+
+ @Test
+ fun `popup is dismissed when anchor is detached`() {
+ val items = listOf(
+ SimpleBrowserMenuItem("Mock") {},
+ SimpleBrowserMenuItem("Menu") {},
+ )
+ val adapter = BrowserMenuAdapter(testContext, items)
+ val menu = BrowserMenu(adapter)
+ val anchor = Button(testContext)
+ val popupWindow = menu.show(anchor)
+
+ assertTrue(popupWindow.isShowing)
+
+ menu.onViewDetachedFromWindow(anchor)
+
+ assertFalse(popupWindow.isShowing)
+ }
+
+ @Test
+ fun `GIVEN BrowserMenu WHEN setColor is called with a null MenuStyle THEN the color of the menuView is not changed but cached in backgroundColor`() {
+ val menu = BrowserMenu(mock())
+ val menuParent = CardView(testContext).apply {
+ id = R.id.mozac_browser_menu_menuView
+ setCardBackgroundColor(Color.YELLOW)
+ }
+ val menuLayout = FrameLayout(testContext).also { it.addView(menuParent) }
+ assertEquals(Color.RED, menu.backgroundColor)
+
+ menu.setColors(menuLayout, null)
+
+ assertEquals(Color.YELLOW, menuParent.cardBackgroundColor.defaultColor)
+ assertEquals(Color.YELLOW, menu.backgroundColor)
+ }
+
+ @Test
+ fun `GIVEN BrowserMenu WHEN setColor is called with a valid MenuStyle THEN the color of the menuView is changed and cached in backgroundColor`() {
+ val menu = BrowserMenu(mock())
+ val menuParent = CardView(testContext).apply {
+ id = R.id.mozac_browser_menu_menuView
+ setCardBackgroundColor(Color.YELLOW)
+ }
+ val menuLayout = FrameLayout(testContext).also { it.addView(menuParent) }
+ val menuStyle = MenuStyle(
+ backgroundColor = Color.GREEN,
+ minWidth = 20,
+ maxWidth = 500,
+ )
+ assertEquals(Color.RED, menu.backgroundColor)
+
+ menu.setColors(menuLayout, menuStyle)
+
+ assertEquals(menuStyle.backgroundColor!!.defaultColor, menuParent.cardBackgroundColor.defaultColor)
+ assertEquals(menuStyle.backgroundColor!!.defaultColor, menu.backgroundColor)
+ }
+
+ private fun setScreenHeight(value: Int) {
+ val display = ShadowDisplay.getDefaultDisplay()
+ val shadow = Shadows.shadowOf(display)
+ shadow.setHeight(value)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/WebExtensionBrowserMenuBuilderTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/WebExtensionBrowserMenuBuilderTest.kt
new file mode 100644
index 0000000000..0c5feb0d5b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/WebExtensionBrowserMenuBuilderTest.kt
@@ -0,0 +1,510 @@
+/* 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 mozilla.components.browser.menu
+
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.ImageButton
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.item.BackPressMenuItem
+import mozilla.components.browser.menu.item.BrowserMenuImageText
+import mozilla.components.browser.menu.item.ParentBrowserMenuItem
+import mozilla.components.browser.menu.item.WebExtensionBrowserMenuItem
+import mozilla.components.browser.menu.item.WebExtensionPlaceholderMenuItem
+import mozilla.components.browser.menu.item.WebExtensionPlaceholderMenuItem.Companion.MAIN_EXTENSIONS_MENU_ID
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.WebExtensionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.webextension.WebExtensionBrowserAction
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import androidx.appcompat.R as appcompatR
+import mozilla.components.ui.icons.R as iconsR
+
+@RunWith(AndroidJUnit4::class)
+class WebExtensionBrowserMenuBuilderTest {
+
+ private val submenuPlaceholderMenuItem = WebExtensionPlaceholderMenuItem(id = MAIN_EXTENSIONS_MENU_ID)
+
+ @Test
+ fun `WHEN there are no web extension actions THEN add-ons menu item invokes onAddonsManagerTapped`() {
+ var isAddonsManagerTapped = false
+ val store = BrowserStore()
+ val builder = WebExtensionBrowserMenuBuilder(
+ listOf(mockMenuItem(), submenuPlaceholderMenuItem, mockMenuItem()),
+ store = store,
+ onAddonsManagerTapped = { isAddonsManagerTapped = true },
+ appendExtensionSubMenuAtStart = true,
+ )
+
+ val menu = builder.build(testContext)
+
+ val addonsManagerItem = menu.adapter.visibleItems[1] as? BrowserMenuImageText
+ val addonsManagerItemView =
+ LayoutInflater.from(testContext).inflate(addonsManagerItem!!.getLayoutResource(), null)
+ addonsManagerItem.bind(menu, addonsManagerItemView)
+ assertFalse(isAddonsManagerTapped)
+ addonsManagerItemView.performClick()
+ assertTrue(isAddonsManagerTapped)
+ }
+
+ @Test
+ fun `GIVEN style is provided WHEN creating extension menu THEN styles should be applied to items`() {
+ val browserAction = WebExtensionBrowserAction("browser_action", true, mock(), "", 0, 0) {}
+ val extensions = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = browserAction,
+ ),
+ )
+
+ val store = BrowserStore(BrowserState(extensions = extensions))
+ val style = WebExtensionBrowserMenuBuilder.Style(
+ addonsManagerMenuItemDrawableRes = iconsR.drawable.mozac_ic_extension_24,
+ backPressMenuItemDrawableRes = iconsR.drawable.mozac_ic_back_24,
+ )
+ val builder = WebExtensionBrowserMenuBuilder(
+ listOf(mockMenuItem()),
+ store = store,
+ style = style,
+ appendExtensionSubMenuAtStart = true,
+ )
+
+ val menu = builder.build(testContext)
+ val anchor = ImageButton(testContext)
+ menu.show(anchor)
+
+ val parentMenuItem = menu.adapter.visibleItems[0] as ParentBrowserMenuItem
+ val subMenuItemIndex = parentMenuItem.subMenu.adapter.visibleItems.lastIndex
+ val backPressMenuItem = parentMenuItem.subMenu.adapter.visibleItems[0] as BackPressMenuItem
+ val addonsManagerItem = parentMenuItem.subMenu.adapter.visibleItems[subMenuItemIndex] as BrowserMenuImageText
+
+ assertEquals(style.backPressMenuItemDrawableRes, backPressMenuItem.imageResource)
+ assertEquals(style.webExtIconTintColorResource, backPressMenuItem.iconTintColorResource)
+
+ assertEquals(style.webExtIconTintColorResource, addonsManagerItem.iconTintColorResource)
+ assertEquals(style.webExtIconTintColorResource, addonsManagerItem.iconTintColorResource)
+ }
+
+ @Test
+ fun `web extension sub menu add-ons manager sub menu item invokes onAddonsManagerTapped when clicked`() {
+ val browserAction = WebExtensionBrowserAction("browser_action", true, mock(), "", 0, 0) {}
+ val pageAction = WebExtensionBrowserAction("page_action", true, mock(), "", 0, 0) {}
+
+ val extensions = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = browserAction,
+ pageAction = pageAction,
+ ),
+ )
+
+ val store = BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ )
+
+ var isAddonsManagerTapped = false
+ val builder = WebExtensionBrowserMenuBuilder(
+ listOf(mockMenuItem(), submenuPlaceholderMenuItem, mockMenuItem()),
+ store = store,
+ onAddonsManagerTapped = { isAddonsManagerTapped = true },
+ appendExtensionSubMenuAtStart = true,
+ )
+
+ val menu = builder.build(testContext)
+
+ val parentMenuItem = menu.adapter.visibleItems[1] as? ParentBrowserMenuItem
+ val subMenuItemSize = parentMenuItem!!.subMenu.adapter.visibleItems.size
+ assertEquals(6, subMenuItemSize)
+ val addOnsManagerMenuItem =
+ parentMenuItem.subMenu.adapter.visibleItems[subMenuItemSize - 1] as? BrowserMenuImageText
+ val addonsManagerItemView =
+ LayoutInflater.from(testContext).inflate(addOnsManagerMenuItem!!.getLayoutResource(), null)
+ addOnsManagerMenuItem.bind(menu, addonsManagerItemView)
+ assertFalse(isAddonsManagerTapped)
+ addonsManagerItemView.performClick()
+ assertTrue(isAddonsManagerTapped)
+ assertNotNull(addOnsManagerMenuItem)
+ }
+
+ @Test
+ fun `web extension submenu is added at the top when usingBottomToolbar is true with no placeholder for submenu`() {
+ val browserAction = WebExtensionBrowserAction("browser_action", true, mock(), "", 0, 0) {}
+ val pageAction = WebExtensionBrowserAction("page_action", true, mock(), "", 0, 0) {}
+
+ val extensions = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = browserAction,
+ pageAction = pageAction,
+ ),
+ )
+
+ val store = BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ )
+
+ val builder = WebExtensionBrowserMenuBuilder(
+ listOf(mockMenuItem(), mockMenuItem(), mockMenuItem()),
+ store = store,
+ appendExtensionSubMenuAtStart = true,
+ )
+
+ val menu = builder.build(testContext)
+ val anchor = ImageButton(testContext)
+ val popup = menu.show(anchor)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ assertNotNull(recyclerView)
+
+ val recyclerAdapter = recyclerView.adapter!!
+ assertNotNull(recyclerAdapter)
+ assertEquals(4, recyclerAdapter.itemCount)
+
+ val parentMenuItem = menu.adapter.visibleItems[0] as? ParentBrowserMenuItem
+ val subMenuItemSize = parentMenuItem!!.subMenu.adapter.visibleItems.size
+ assertEquals(6, subMenuItemSize)
+ val backMenuItem = parentMenuItem.subMenu.adapter.visibleItems[0] as? BackPressMenuItem
+ val subMenuExtItemBrowserAction = parentMenuItem.subMenu.adapter.visibleItems[2] as? WebExtensionBrowserMenuItem
+ val subMenuExtItemPageAction = parentMenuItem.subMenu.adapter.visibleItems[3] as? WebExtensionBrowserMenuItem
+ val addOnsManagerMenuItem = parentMenuItem.subMenu.adapter.visibleItems[subMenuItemSize - 1] as? BrowserMenuImageText
+ assertNotNull(backMenuItem)
+ assertEquals("browser_action", subMenuExtItemBrowserAction!!.action.title)
+ assertEquals("page_action", subMenuExtItemPageAction!!.action.title)
+ assertNotNull(addOnsManagerMenuItem)
+ }
+
+ @Test
+ fun `web extension submenu is added at the bottom when usingBottomToolbar is false with no placeholder for submenu `() {
+ val browserAction = WebExtensionBrowserAction("browser_action", true, mock(), "", 0, 0) {}
+ val pageAction = WebExtensionBrowserAction("page_action", true, mock(), "", 0, 0) {}
+
+ val extensions = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = browserAction,
+ pageAction = pageAction,
+ ),
+ )
+
+ val store = BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ )
+
+ val builder =
+ WebExtensionBrowserMenuBuilder(
+ listOf(mockMenuItem(), mockMenuItem(), mockMenuItem()),
+ store = store,
+ appendExtensionSubMenuAtStart = false,
+ )
+
+ val menu = builder.build(testContext)
+ val anchor = ImageButton(testContext)
+ val popup = menu.show(anchor)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ assertNotNull(recyclerView)
+
+ val recyclerAdapter = recyclerView.adapter!!
+ assertNotNull(recyclerAdapter)
+ assertEquals(4, recyclerAdapter.itemCount)
+
+ val parentMenuItem = menu.adapter.visibleItems[recyclerAdapter.itemCount - 1] as? ParentBrowserMenuItem
+ val subMenuItemSize = parentMenuItem!!.subMenu.adapter.visibleItems.size
+ assertEquals(6, subMenuItemSize)
+ val backMenuItem = parentMenuItem.subMenu.adapter.visibleItems[subMenuItemSize - 1] as? BackPressMenuItem
+ val subMenuExtItemBrowserAction = parentMenuItem.subMenu.adapter.visibleItems[2] as? WebExtensionBrowserMenuItem
+ val subMenuExtItemPageAction = parentMenuItem.subMenu.adapter.visibleItems[3] as? WebExtensionBrowserMenuItem
+ val addOnsManagerMenuItem = parentMenuItem.subMenu.adapter.visibleItems[0] as? BrowserMenuImageText
+ assertNotNull(backMenuItem)
+ assertEquals("browser_action", subMenuExtItemBrowserAction!!.action.title)
+ assertEquals("page_action", subMenuExtItemPageAction!!.action.title)
+ assertNotNull(addOnsManagerMenuItem)
+ }
+
+ @Test
+ fun `web extension is moved to main menu when extension id equals WebExtensionPlaceholderMenuItem id`() {
+ val promotableWebExtensionId = "promotable extension id"
+ val promotableWebExtensionTitle = "promotable extension action title"
+ val testIconTintColorResource = appcompatR.color.accent_material_dark
+
+ val pageAction = WebExtensionBrowserAction("page_action", true, mock(), "", 0, 0) {}
+ val pageActionPromotableWebExtension = WebExtensionBrowserAction(promotableWebExtensionTitle, true, mock(), "", 0, 0) {}
+
+ // just 2 extensions in the extension menu
+ val extensions = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = null,
+ pageAction = pageAction,
+ ),
+ promotableWebExtensionId to WebExtensionState(
+ promotableWebExtensionId,
+ "url",
+ "name",
+ true,
+ browserAction = null,
+ pageAction = pageActionPromotableWebExtension,
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ )
+
+ // 4 items initially on the main menu
+ val items = listOf(
+ WebExtensionPlaceholderMenuItem(
+ id = promotableWebExtensionId,
+ iconTintColorResource = testIconTintColorResource,
+ ),
+ mockMenuItem(),
+ submenuPlaceholderMenuItem,
+ mockMenuItem(),
+ )
+
+ val builder =
+ WebExtensionBrowserMenuBuilder(
+ items,
+ store = store,
+ appendExtensionSubMenuAtStart = false,
+ )
+
+ val menu = builder.build(testContext)
+ val anchor = ImageButton(testContext)
+ val popup = menu.show(anchor)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ assertNotNull(recyclerView)
+
+ val recyclerAdapter = recyclerView.adapter!! as BrowserMenuAdapter
+ assertNotNull(recyclerAdapter)
+
+ // main menu should have the 4 initial items, one replaced by web extension one replaced by the extensions menu
+ assertEquals(4, recyclerAdapter.itemCount)
+
+ val replacedItem = recyclerAdapter.visibleItems[0]
+ // the replaced item should be a WebExtensionBrowserMenuItem
+ assertEquals("WebExtensionBrowserMenuItem", replacedItem.javaClass.simpleName)
+
+ // the replaced item should have the action title of the WebExtensionBrowserMenuItem
+ assertEquals(promotableWebExtensionTitle, (replacedItem as WebExtensionBrowserMenuItem).action.title)
+
+ // the replaced item should have the icon tint set by placeholder
+ assertEquals(testIconTintColorResource, replacedItem.iconTintColorResource)
+
+ val parentMenuItem = menu.adapter.visibleItems[2] as? ParentBrowserMenuItem
+ val subMenuItemSize = parentMenuItem!!.subMenu.adapter.visibleItems.size
+
+ // add-ons should only have one extension, 2 dividers, 1 add-on manager item and 1 back menu item
+ assertEquals(5, subMenuItemSize)
+
+ val backMenuItem = parentMenuItem.subMenu.adapter.visibleItems[subMenuItemSize - 1] as? BackPressMenuItem
+ val subMenuExtItemPageAction = parentMenuItem.subMenu.adapter.visibleItems[2] as? WebExtensionBrowserMenuItem
+ val addOnsManagerMenuItem = parentMenuItem.subMenu.adapter.visibleItems[0] as? BrowserMenuImageText
+
+ assertNotNull(backMenuItem)
+ assertEquals("page_action", subMenuExtItemPageAction!!.action.title)
+ assertNotNull(addOnsManagerMenuItem)
+ }
+
+ @Test
+ fun `GIVEN a placeholder with the id MAIN_EXTENSIONS_MENU_ID WHEN the menu is built THEN the extensions sub-menu is inserted in its place`() {
+ val pageAction = WebExtensionBrowserAction("page_action", true, mock(), "", 0, 0) {}
+
+ val extensions = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = null,
+ pageAction = pageAction,
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ )
+
+ // 3 items initially on the main menu
+ val items = listOf(
+ mockMenuItem(),
+ submenuPlaceholderMenuItem,
+ mockMenuItem(),
+ )
+
+ val builder =
+ WebExtensionBrowserMenuBuilder(
+ items,
+ store = store,
+ appendExtensionSubMenuAtStart = false,
+ )
+
+ val menu = builder.build(testContext)
+ val anchor = ImageButton(testContext)
+ val popup = menu.show(anchor)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ assertNotNull(recyclerView)
+
+ val recyclerAdapter = recyclerView.adapter!! as BrowserMenuAdapter
+ assertNotNull(recyclerAdapter)
+
+ // main menu should have the 3 initial items, one replaced by extensions sub-menu
+ assertEquals(3, recyclerAdapter.itemCount)
+
+ val parentMenuItem = recyclerAdapter.visibleItems[1] as ParentBrowserMenuItem
+ // the replaced item should be a ParentBrowserMenuItem
+ assertEquals("ParentBrowserMenuItem", parentMenuItem.javaClass.simpleName)
+
+ // the replaced item should have the action title of the WebExtensionBrowserMenuItem
+ assertEquals(testContext.getString(R.string.mozac_browser_menu_extensions), parentMenuItem.label)
+
+ val subMenuItemSize = parentMenuItem.subMenu.adapter.visibleItems.size
+
+ // add-ons should only have one extension, 2 dividers, 1 add-on manager item and 1 back menu item
+ assertEquals(5, subMenuItemSize)
+
+ val backMenuItem = parentMenuItem.subMenu.adapter.visibleItems[subMenuItemSize - 1] as? BackPressMenuItem
+ val subMenuExtItemPageAction = parentMenuItem.subMenu.adapter.visibleItems[2] as? WebExtensionBrowserMenuItem
+ val addOnsManagerMenuItem = parentMenuItem.subMenu.adapter.visibleItems[0] as? BrowserMenuImageText
+
+ assertNotNull(backMenuItem)
+ assertEquals("page_action", subMenuExtItemPageAction!!.action.title)
+ assertNotNull(addOnsManagerMenuItem)
+ }
+
+ @Test
+ fun `GIVEN showAddonsInMenu with value true WHEN the menu is built THEN the Add-ons item is added at the bottom`() {
+ val pageAction = WebExtensionBrowserAction("page_action", true, mock(), "", 0, 0) {}
+
+ val extensions = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = null,
+ pageAction = pageAction,
+ ),
+ )
+ val store = BrowserStore(BrowserState(extensions = extensions))
+
+ // 2 items initially on the main menu
+ val items = listOf(
+ mockMenuItem(),
+ mockMenuItem(),
+ )
+
+ val builder =
+ WebExtensionBrowserMenuBuilder(
+ items,
+ store = store,
+ showAddonsInMenu = true,
+ )
+
+ val menu = builder.build(testContext)
+ val anchor = ImageButton(testContext)
+ val popup = menu.show(anchor)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ assertNotNull(recyclerView)
+
+ val recyclerAdapter = recyclerView.adapter as BrowserMenuAdapter
+ assertNotNull(recyclerAdapter)
+
+ // main menu should have 3 items and the last one should be the "Add-ons" item
+ assertEquals(3, recyclerAdapter.itemCount)
+
+ val lastItem = recyclerAdapter.visibleItems[2]
+ assert(lastItem is ParentBrowserMenuItem && lastItem.label == testContext.getString(R.string.mozac_browser_menu_extensions))
+ }
+
+ @Test
+ fun `GIVEN showAddonsInMenu with value false WHEN the menu is built THEN the Add-ons item is not added`() {
+ val pageAction = WebExtensionBrowserAction("page_action", true, mock(), "", 0, 0) {}
+
+ val extensions = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = null,
+ pageAction = pageAction,
+ ),
+ )
+ val store = BrowserStore(BrowserState(extensions = extensions))
+
+ // 2 items initially on the main menu
+ val items = listOf(
+ mockMenuItem(),
+ mockMenuItem(),
+ )
+
+ val builder =
+ WebExtensionBrowserMenuBuilder(
+ items,
+ store = store,
+ showAddonsInMenu = false,
+ )
+
+ val menu = builder.build(testContext)
+ val anchor = ImageButton(testContext)
+ val popup = menu.show(anchor)
+
+ val recyclerView: RecyclerView = popup.contentView.findViewById(R.id.mozac_browser_menu_recyclerView)
+ assertNotNull(recyclerView)
+
+ val recyclerAdapter = recyclerView.adapter as BrowserMenuAdapter
+ assertNotNull(recyclerAdapter)
+
+ // main menu should have 2 items
+ assertEquals(2, recyclerAdapter.itemCount)
+
+ recyclerAdapter.visibleItems.forEach { item ->
+ assert(item !is ParentBrowserMenuItem)
+ }
+ }
+
+ private fun mockMenuItem() = object : BrowserMenuItem {
+ override val visible: () -> Boolean = { true }
+
+ override fun getLayoutResource() = R.layout.mozac_browser_menu_item_simple
+
+ override fun bind(menu: BrowserMenu, view: View) {}
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/WebExtensionBrowserMenuTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/WebExtensionBrowserMenuTest.kt
new file mode 100644
index 0000000000..77769db9c4
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/WebExtensionBrowserMenuTest.kt
@@ -0,0 +1,525 @@
+/* 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 mozilla.components.browser.menu
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.view.View
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.WebExtensionBrowserMenu.Companion.getOrUpdateWebExtensionMenuItems
+import mozilla.components.browser.menu.WebExtensionBrowserMenu.Companion.webExtensionBrowserActions
+import mozilla.components.browser.menu.WebExtensionBrowserMenu.Companion.webExtensionPageActions
+import mozilla.components.browser.menu.facts.BrowserMenuFacts.Items.WEB_EXTENSION_MENU_ITEM
+import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
+import mozilla.components.browser.state.selector.selectedTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.WebExtensionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.webextension.Action
+import mozilla.components.concept.engine.webextension.WebExtensionBrowserAction
+import mozilla.components.concept.engine.webextension.WebExtensionPageAction
+import mozilla.components.support.base.facts.processor.CollectionProcessor
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import mozilla.components.support.base.facts.Action as FactsAction
+
+@RunWith(AndroidJUnit4::class)
+@kotlinx.coroutines.ExperimentalCoroutinesApi
+class WebExtensionBrowserMenuTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setup() {
+ webExtensionBrowserActions.clear()
+ webExtensionPageActions.clear()
+ }
+
+ @Test
+ fun `actions are only updated when the menu is shown`() {
+ webExtensionBrowserActions.clear()
+ val browserAction = WebExtensionBrowserAction("browser_action", true, mock(), "", 0, 0) {}
+ val pageAction = WebExtensionPageAction("browser_action", true, mock(), "", 0, 0) {}
+ val extensions = mapOf(
+ "browser_action" to WebExtensionState(
+ "browser_action",
+ "url",
+ "name",
+ true,
+ browserAction = browserAction,
+ pageAction = pageAction,
+ ),
+ )
+
+ val store =
+ BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ )
+ val items = listOf(
+ SimpleBrowserMenuItem("Hello") {},
+ SimpleBrowserMenuItem("World") {},
+ )
+
+ val adapter = BrowserMenuAdapter(testContext, items)
+ val menu = WebExtensionBrowserMenu(adapter, store)
+
+ val anchor = Button(testContext)
+ val popup = menu.show(anchor)
+
+ assertNotNull(popup)
+
+ val defaultBrowserAction =
+ WebExtensionBrowserAction("default_title", true, mock(), "", 0, 0) {}
+ val defaultPageAction =
+ WebExtensionPageAction("default_title", true, mock(), "", 0, 0) {}
+ val defaultExtensions: Map<String, WebExtensionState> = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = defaultBrowserAction,
+ pageAction = defaultPageAction,
+ ),
+ )
+
+ createTab(
+ "https://www.example.org",
+ id = "tab1",
+ extensions = defaultExtensions,
+ )
+ assertEquals(1, webExtensionBrowserActions.size)
+ assertEquals(1, webExtensionPageActions.size)
+
+ menu.dismiss()
+ val anotherBrowserAction =
+ WebExtensionBrowserAction("another_title", true, mock(), "", 0, 0) {}
+ val anotherPageAction =
+ WebExtensionBrowserAction("another_title", true, mock(), "", 0, 0) {}
+ val anotherExtension: Map<String, WebExtensionState> = mapOf(
+ "id2" to WebExtensionState(
+ "id2",
+ "url",
+ "name",
+ true,
+ browserAction = anotherBrowserAction,
+ pageAction = anotherPageAction,
+ ),
+ )
+
+ createTab(
+ "https://www.example2.org",
+ id = "tab2",
+ extensions = anotherExtension,
+ )
+ assertEquals(0, webExtensionBrowserActions.size)
+ assertEquals(0, webExtensionPageActions.size)
+ }
+
+ @Test
+ fun `render web extension actions from browser state`() {
+ val defaultBrowserAction =
+ WebExtensionBrowserAction("default_browser_action_title", true, mock(), "", 0, 0) {}
+ val defaultPageAction =
+ WebExtensionPageAction("default_page_action_title", true, mock(), "", 0, 0) {}
+ val overriddenBrowserAction =
+ WebExtensionBrowserAction("overridden_browser_action_title", true, mock(), "", 0, 0) {}
+ val overriddenPageAction =
+ WebExtensionBrowserAction("overridden_page_action_title", true, mock(), "", 0, 0) {}
+
+ val extensions: Map<String, WebExtensionState> = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = defaultBrowserAction,
+ pageAction = defaultPageAction,
+ ),
+ )
+ val overriddenExtensions: Map<String, WebExtensionState> = mapOf(
+ "id" to WebExtensionState(
+ "id",
+ "url",
+ "name",
+ true,
+ browserAction = overriddenBrowserAction,
+ pageAction = overriddenPageAction,
+ ),
+ )
+ val store =
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(
+ "https://www.example.org",
+ id = "tab1",
+ extensions = overriddenExtensions,
+ ),
+ ),
+ selectedTabId = "tab1",
+ extensions = extensions,
+ ),
+ )
+
+ val browserMenuItems =
+ getOrUpdateWebExtensionMenuItems(store.state, store.state.selectedTab)
+ assertEquals(2, browserMenuItems.size)
+
+ var actionMenu = browserMenuItems[0]
+ assertEquals(
+ "overridden_browser_action_title",
+ actionMenu.action.title,
+ )
+
+ actionMenu = browserMenuItems[1]
+ assertEquals(
+ "overridden_page_action_title",
+ actionMenu.action.title,
+ )
+ }
+
+ @Test
+ fun `getOrUpdateWebExtensionMenuItems does not include actions from disabled extensions`() {
+ val enabledPageAction =
+ WebExtensionBrowserAction("enabled_page_action", true, mock(), "", 0, 0) {}
+ val disabledPageAction =
+ WebExtensionBrowserAction("disabled_page_action", true, mock(), "", 0, 0) {}
+ val enabledBrowserAction =
+ WebExtensionBrowserAction("enabled_browser_action", true, mock(), "", 0, 0) {}
+ val disabledBrowserAction =
+ WebExtensionBrowserAction("disabled_browser_action", true, mock(), "", 0, 0) {}
+
+ val extensions = mapOf(
+ "enabled" to WebExtensionState(
+ "enabled",
+ "url",
+ "name",
+ true,
+ browserAction = enabledBrowserAction,
+ pageAction = enabledPageAction,
+ ),
+ "disabled" to WebExtensionState(
+ "disabled",
+ "url",
+ "name",
+ false,
+ browserAction = disabledBrowserAction,
+ pageAction = disabledPageAction,
+ ),
+ )
+
+ val store =
+ BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ )
+
+ val browserMenuItems = getOrUpdateWebExtensionMenuItems(store.state)
+ assertEquals(2, browserMenuItems.size)
+
+ var menuAction = browserMenuItems[0]
+ assertEquals(
+ "enabled_browser_action",
+ menuAction.action.title,
+ )
+ menuAction = browserMenuItems[1]
+ assertEquals(
+ "enabled_page_action",
+ menuAction.action.title,
+ )
+ }
+
+ @Test
+ fun `browser actions can be overridden per tab`() {
+ webExtensionBrowserActions.clear()
+ val loadIcon: (suspend (Int) -> Bitmap?)? = { mock() }
+ val pageAction = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val pageActionOverride = Action(
+ title = "updatedTitle",
+ loadIcon = null,
+ enabled = false,
+ badgeText = "updatedText",
+ badgeTextColor = Color.RED,
+ badgeBackgroundColor = Color.GREEN,
+ ) {}
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val browserActionOverride = Action(
+ title = "updatedTitle",
+ loadIcon = null,
+ enabled = false,
+ badgeText = "updatedText",
+ badgeTextColor = Color.RED,
+ badgeBackgroundColor = Color.GREEN,
+ ) {}
+
+ val browserExtensions = HashMap<String, WebExtensionState>()
+ browserExtensions["1"] =
+ WebExtensionState(id = "1", browserAction = browserAction, pageAction = pageAction)
+
+ val browserState = BrowserState(extensions = browserExtensions)
+ getOrUpdateWebExtensionMenuItems(browserState, mock())
+
+ // Verifying global browser action
+ assertTrue(webExtensionBrowserActions.size == 1)
+ var ext1 = webExtensionBrowserActions["1"]
+ assertTrue(ext1?.action?.enabled!!)
+ assertEquals("badgeText", ext1.action.badgeText!!)
+ assertEquals("title", ext1.action.title!!)
+ assertEquals(loadIcon, ext1.action.loadIcon!!)
+ assertEquals(Color.WHITE, ext1.action.badgeTextColor!!)
+ assertEquals(Color.BLUE, ext1.action.badgeBackgroundColor!!)
+
+ // Verifying global page action
+ assertTrue(webExtensionPageActions.size == 1)
+ ext1 = webExtensionPageActions["1"]!!
+ assertTrue(ext1.action.enabled!!)
+ assertEquals("badgeText", ext1.action.badgeText!!)
+ assertEquals("title", ext1.action.title!!)
+ assertEquals(loadIcon, ext1.action.loadIcon!!)
+ assertEquals(Color.WHITE, ext1.action.badgeTextColor!!)
+ assertEquals(Color.BLUE, ext1.action.badgeBackgroundColor!!)
+
+ val tabExtensions = HashMap<String, WebExtensionState>()
+ tabExtensions["1"] = WebExtensionState(
+ id = "1",
+ browserAction = browserActionOverride,
+ pageAction = pageActionOverride,
+ )
+
+ val tabSessionState = TabSessionState(
+ content = mock(),
+ extensionState = tabExtensions,
+ )
+
+ getOrUpdateWebExtensionMenuItems(browserState, tabSessionState)
+
+ // Verify rendering session-specific browser action override
+ assertTrue(webExtensionBrowserActions.size == 1)
+ var updatedExt1 = webExtensionBrowserActions["1"]
+ assertFalse(updatedExt1?.action?.enabled!!)
+ assertEquals("updatedText", updatedExt1.action.badgeText!!)
+ assertEquals("updatedTitle", updatedExt1.action.title!!)
+ assertEquals(loadIcon, updatedExt1.action.loadIcon!!)
+ assertEquals(Color.RED, updatedExt1.action.badgeTextColor!!)
+ assertEquals(Color.GREEN, updatedExt1.action.badgeBackgroundColor!!)
+
+ // Verify rendering session-specific page action override
+ assertTrue(webExtensionPageActions.size == 1)
+ updatedExt1 = webExtensionBrowserActions["1"]!!
+ assertFalse(updatedExt1.action.enabled!!)
+ assertEquals("updatedText", updatedExt1.action.badgeText!!)
+ assertEquals("updatedTitle", updatedExt1.action.title!!)
+ assertEquals(loadIcon, updatedExt1.action.loadIcon!!)
+ assertEquals(Color.RED, updatedExt1.action.badgeTextColor!!)
+ assertEquals(Color.GREEN, updatedExt1.action.badgeBackgroundColor!!)
+ }
+
+ @Test
+ fun `actions are sorted per extension name`() {
+ val loadIcon: (suspend (Int) -> Bitmap?)? = { mock() }
+
+ val actionExt1 = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val actionExt2 = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val browserExtensions = HashMap<String, WebExtensionState>()
+ browserExtensions["1"] =
+ WebExtensionState(id = "1", name = "extensionA", browserAction = actionExt1)
+ browserExtensions["2"] =
+ WebExtensionState(id = "2", name = "extensionB", browserAction = actionExt2)
+
+ val tabSessionState = TabSessionState(
+ content = mock(),
+ extensionState = emptyMap(),
+ )
+
+ val browserState = BrowserState(extensions = browserExtensions)
+ val actionItems = getOrUpdateWebExtensionMenuItems(browserState, tabSessionState)
+ assertEquals(2, actionItems.size)
+ assertEquals(actionExt1, actionItems[0].action)
+ assertEquals(actionExt2, actionItems[1].action)
+ }
+
+ @Test
+ fun `clicking on the menu item should emit a BrowserMenuFacts with the web extension id`() {
+ val imageView: ImageView = mock()
+ val badgeView: TextView = mock()
+ val labelView = TextView(testContext)
+ val container = View(testContext)
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(badgeView)
+ whenever(view.findViewById<TextView>(R.id.action_label)).thenReturn(labelView)
+ whenever(view.findViewById<View>(R.id.container)).thenReturn(container)
+ whenever(view.context).thenReturn(mock())
+
+ val browserAction =
+ WebExtensionBrowserAction("title", true, mock(), "", 0, 0) {}
+ val pageAction =
+ WebExtensionPageAction("title", true, mock(), "", 0, 0) {}
+ val extensions: Map<String, WebExtensionState> = mapOf(
+ "some_example_id" to WebExtensionState(
+ "some_example_id",
+ "url",
+ "name",
+ true,
+ browserAction = browserAction,
+ pageAction = pageAction,
+ ),
+ )
+
+ val store =
+ BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ )
+
+ val browserMenuItems = getOrUpdateWebExtensionMenuItems(store.state)
+ val menuItem = browserMenuItems[1]
+ val menu: WebExtensionBrowserMenu = mock()
+
+ menuItem.bind(menu, view)
+
+ CollectionProcessor.withFactCollection { facts ->
+ container.performClick()
+
+ val fact = facts[0]
+ assertEquals(FactsAction.CLICK, fact.action)
+ assertEquals(WEB_EXTENSION_MENU_ITEM, fact.item)
+ assertEquals(1, fact.metadata?.size)
+ assertTrue(fact.metadata?.containsKey("id")!!)
+ assertEquals("some_example_id", fact.metadata?.get("id"))
+ }
+ }
+
+ @Test
+ fun `hides browser and page actions in private tabs if extension is not allowed to run`() {
+ val loadIcon: (suspend (Int) -> Bitmap?)? = { mock() }
+
+ val actionExt1 = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val tabSessionState = TabSessionState(
+ content = mock(),
+ extensionState = emptyMap(),
+ )
+ whenever(tabSessionState.content.private).thenReturn(true)
+
+ val browserExtensions = HashMap<String, WebExtensionState>()
+ browserExtensions["1"] =
+ WebExtensionState(id = "1", name = "extensionA", browserAction = actionExt1)
+ val browserState = BrowserState(extensions = browserExtensions)
+ val actionItems = getOrUpdateWebExtensionMenuItems(browserState, tabSessionState)
+ assertEquals(0, actionItems.size)
+
+ val browserExtensionsAllowedInPrivateBrowsing = HashMap<String, WebExtensionState>()
+ browserExtensionsAllowedInPrivateBrowsing["1"] =
+ WebExtensionState(id = "1", allowedInPrivateBrowsing = true, name = "extensionA", browserAction = actionExt1)
+ val browserStateAllowedInPrivateBrowsing = BrowserState(extensions = browserExtensionsAllowedInPrivateBrowsing)
+ val actionItemsAllowedInPrivateBrowsing = getOrUpdateWebExtensionMenuItems(browserStateAllowedInPrivateBrowsing, tabSessionState)
+ assertEquals(1, actionItemsAllowedInPrivateBrowsing.size)
+ assertEquals(actionExt1, actionItemsAllowedInPrivateBrowsing[0].action)
+ }
+
+ @Test
+ fun `does not include menu item for disabled paged actions`() {
+ val enabledPageAction =
+ WebExtensionBrowserAction("enabled_page_action", true, mock(), "", 0, 0) {}
+ val disabledPageAction =
+ WebExtensionBrowserAction("disabled_page_action", false, mock(), "", 0, 0) {}
+
+ val extensions = mapOf(
+ "ext1" to WebExtensionState(
+ "ext1",
+ "url",
+ "name",
+ true,
+ pageAction = enabledPageAction,
+ ),
+ "ext2" to WebExtensionState(
+ "ext2",
+ "url",
+ "name",
+ true,
+ pageAction = disabledPageAction,
+ ),
+ )
+
+ val store =
+ BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ )
+
+ val browserMenuItems = getOrUpdateWebExtensionMenuItems(store.state)
+ assertEquals(1, browserMenuItems.size)
+
+ var menuAction = browserMenuItems[0]
+ assertEquals(
+ "enabled_page_action",
+ menuAction.action.title,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/ext/BrowserMenuItemTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/ext/BrowserMenuItemTest.kt
new file mode 100644
index 0000000000..36e7fb15ba
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/ext/BrowserMenuItemTest.kt
@@ -0,0 +1,113 @@
+/* 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 mozilla.components.browser.menu.ext
+
+import android.graphics.Color
+import mozilla.components.browser.menu.BrowserMenuHighlight
+import mozilla.components.browser.menu.item.BrowserMenuHighlightableItem
+import mozilla.components.browser.menu.item.BrowserMenuImageText
+import org.junit.Assert
+import org.junit.Test
+
+class BrowserMenuItemTest {
+
+ @Test
+ fun `highest prio item gets selected`() {
+ val highlightLow1 = BrowserMenuHighlight.LowPriority(Color.YELLOW)
+ val highlightLow2 = BrowserMenuHighlight.LowPriority(Color.RED)
+ val highlightHigh = BrowserMenuHighlight.HighPriority(Color.GREEN)
+
+ val list = listOf(
+ BrowserMenuHighlightableItem(
+ label = "Test1",
+ startImageResource = 0,
+ highlight = highlightLow1,
+ isHighlighted = { true },
+ ),
+ BrowserMenuHighlightableItem(
+ label = "Test2",
+ startImageResource = 0,
+ highlight = highlightLow2,
+ isHighlighted = { true },
+ ),
+ BrowserMenuImageText(
+ label = "Test3",
+ imageResource = 0,
+ ),
+ BrowserMenuHighlightableItem(
+ label = "Test4",
+ startImageResource = 0,
+ highlight = highlightHigh,
+ isHighlighted = { true },
+ ),
+ )
+ Assert.assertEquals(highlightHigh, list.getHighlight())
+ }
+
+ @Test
+ fun `invisible item does not get selected`() {
+ val highlightedVisible = BrowserMenuHighlightableItem(
+ label = "Test1",
+ startImageResource = 0,
+ highlight = BrowserMenuHighlight.LowPriority(Color.YELLOW),
+ isHighlighted = { true },
+ )
+ val highlightedInvisible = BrowserMenuHighlightableItem(
+ label = "Test2",
+ startImageResource = 0,
+ highlight = BrowserMenuHighlight.HighPriority(Color.GREEN),
+ isHighlighted = { true },
+ )
+ highlightedInvisible.visible = { false }
+
+ val list = listOf(highlightedVisible, highlightedInvisible)
+ Assert.assertEquals(highlightedVisible.highlight, list.getHighlight())
+ }
+
+ @Test
+ fun `non highlightable item does not get selected`() {
+ val highlight = BrowserMenuHighlight.LowPriority(Color.YELLOW)
+ val highlight2 = BrowserMenuHighlight.HighPriority(Color.GREEN)
+ val list = listOf(
+ BrowserMenuHighlightableItem(
+ label = "Test1",
+ startImageResource = 0,
+ highlight = highlight,
+ isHighlighted = { true },
+ ),
+ BrowserMenuHighlightableItem(
+ label = "Test2",
+ startImageResource = 0,
+ highlight = highlight2,
+ isHighlighted = { false },
+ ),
+ )
+ Assert.assertEquals(highlight, list.getHighlight())
+ }
+
+ @Test
+ fun `higher prio highlight which cannot propagate does not get selected`() {
+ val highlight = BrowserMenuHighlight.LowPriority(Color.YELLOW)
+ val highlightNonPropagate = BrowserMenuHighlight.HighPriority(
+ Color.GREEN,
+ canPropagate = false,
+ )
+ val list = listOf(
+ BrowserMenuHighlightableItem(
+ label = "Test1",
+ startImageResource = 0,
+ highlight = highlight,
+ isHighlighted = { true },
+ ),
+ BrowserMenuHighlightableItem(
+ label = "Test2",
+ startImageResource = 0,
+ highlight = highlightNonPropagate,
+ isHighlighted = { true },
+ ),
+ )
+ Assert.assertEquals(highlight, list.getHighlight())
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/AbstractParentBrowserMenuItemTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/AbstractParentBrowserMenuItemTest.kt
new file mode 100644
index 0000000000..3b22a871cc
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/AbstractParentBrowserMenuItemTest.kt
@@ -0,0 +1,90 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuAdapter
+import mozilla.components.browser.menu.R
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AbstractParentBrowserMenuItemTest {
+
+ @Test
+ fun bind() {
+ val view = View(testContext)
+ var subMenuShowCalled = false
+ var subMenuDismissCalled = false
+
+ val subMenuItem = SimpleBrowserMenuItem("test")
+ val subMenuAdapter = BrowserMenuAdapter(testContext, listOf(subMenuItem))
+ val subMenu = BrowserMenu(subMenuAdapter)
+ val parentMenuItem = DummyParentBrowserMenuItem(subMenu)
+ val nestedMenuAdapter = BrowserMenuAdapter(testContext, listOf(parentMenuItem))
+ val nestedMenu = BrowserMenu(nestedMenuAdapter)
+
+ parentMenuItem.onSubMenuShow = {
+ subMenuShowCalled = true
+ }
+ parentMenuItem.onSubMenuDismiss = {
+ subMenuDismissCalled = true
+ }
+
+ parentMenuItem.bind(nestedMenu, view)
+ nestedMenu.show(view)
+ assertTrue(nestedMenu.isShown)
+
+ view.performClick()
+ assertFalse(nestedMenu.isShown)
+ assertTrue(subMenu.isShown)
+ assertTrue(subMenuShowCalled)
+
+ subMenu.dismiss()
+ assertTrue(subMenuDismissCalled)
+ }
+
+ @Test
+ fun onBackPressed() {
+ val view = View(testContext)
+ var subMenuDismissCalled = false
+
+ val subMenuItem = SimpleBrowserMenuItem("test")
+ val subMenuAdapter = BrowserMenuAdapter(testContext, listOf(subMenuItem))
+ val subMenu = BrowserMenu(subMenuAdapter)
+ val parentMenuItem = DummyParentBrowserMenuItem(subMenu)
+ val nestedMenuAdapter = BrowserMenuAdapter(testContext, listOf(parentMenuItem))
+ val nestedMenu = BrowserMenu(nestedMenuAdapter)
+
+ parentMenuItem.onSubMenuDismiss = {
+ subMenuDismissCalled = true
+ }
+
+ parentMenuItem.bind(nestedMenu, view)
+ // verify onBackPressed while sub menu is not shown does nothing.
+ parentMenuItem.onBackPressed(nestedMenu, view)
+ assertFalse(subMenuDismissCalled)
+
+ nestedMenu.show(view)
+ view.performClick()
+ parentMenuItem.onBackPressed(nestedMenu, view)
+ assertTrue(nestedMenu.isShown)
+ assertFalse(subMenu.isShown)
+ assertTrue(subMenuDismissCalled)
+ }
+}
+
+class DummyParentBrowserMenuItem(
+ subMenu: BrowserMenu,
+ endOfMenuAlwaysVisible: Boolean = false,
+) : AbstractParentBrowserMenuItem(subMenu, endOfMenuAlwaysVisible) {
+ override var visible: () -> Boolean = { true }
+ override fun getLayoutResource(): Int = R.layout.mozac_browser_menu_item_simple
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCategoryTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCategoryTest.kt
new file mode 100644
index 0000000000..8c94ffe6e9
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCategoryTest.kt
@@ -0,0 +1,183 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.graphics.Typeface
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.DecorativeTextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextStyle
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuCategoryTest {
+ private lateinit var menuCategory: BrowserMenuCategory
+ private val context: Context get() = ApplicationProvider.getApplicationContext()
+ private val label = "test"
+
+ @Before
+ fun setup() {
+ menuCategory = BrowserMenuCategory(label)
+ }
+
+ @Test
+ fun `menu category uses correct layout`() {
+ assertEquals(R.layout.mozac_browser_menu_category, menuCategory.getLayoutResource())
+ }
+
+ @Test
+ fun `menu category has correct label`() {
+ assertEquals(label, menuCategory.label)
+ }
+
+ @Test
+ fun `menu category should handle initialization with text size`() {
+ val menuCategoryWithTextSize = BrowserMenuCategory(label, 12f)
+
+ val view = inflate(menuCategoryWithTextSize)
+ val textView = view.findViewById<TextView>(R.id.category_text)
+
+ assertEquals(12f, textView.textSize)
+ }
+
+ @Test
+ fun `menu category should handle initialization with text colour resource`() {
+ val menuCategoryWithTextColour = BrowserMenuCategory(label, textColorResource = android.R.color.holo_red_dark)
+
+ val view = inflate(menuCategoryWithTextColour)
+ val textView = view.findViewById<TextView>(R.id.category_text)
+ val expectedColour = ContextCompat.getColor(textView.context, android.R.color.holo_red_dark)
+
+ assertEquals(expectedColour, textView.currentTextColor)
+ }
+
+ @Test
+ fun `GIVEN a BrowserMenuCategory, WHEN backgroundColorResource is provided, THEN the background resource is set to that value`() {
+ val expectedColour = android.R.color.holo_red_dark
+ val menuCategoryWithBackgroundColour = BrowserMenuCategory(label, backgroundColorResource = expectedColour)
+ val view: TextView = mock()
+ val menu: BrowserMenu = mock()
+
+ menuCategoryWithBackgroundColour.bind(menu, view)
+
+ verify(view).setBackgroundResource(expectedColour)
+ }
+
+ @Test
+ fun `GIVEN a BrowserMenuCategory, WHEN backgroundColorResource is not provided, THEN no background is set`() {
+ val menuCategoryWithNoBackgroundColour = BrowserMenuCategory(label)
+ val view: TextView = mock()
+ val menu: BrowserMenu = mock()
+
+ menuCategoryWithNoBackgroundColour.bind(menu, view)
+
+ verify(view, never()).setBackgroundResource(anyInt())
+ }
+
+ @Test
+ fun `menu category should handle initialization with text style`() {
+ val menuCategoryWithTextStyle = BrowserMenuCategory(label, textStyle = Typeface.ITALIC)
+
+ val view = inflate(menuCategoryWithTextStyle)
+ val textView = view.findViewById<TextView>(R.id.category_text)
+
+ assertEquals(Typeface.ITALIC, textView.typeface.style)
+ }
+
+ @Test
+ fun `menu category should handle initialization with text alignment`() {
+ val menuCategoryWithTextAlignment = BrowserMenuCategory(label, textAlignment = View.TEXT_ALIGNMENT_VIEW_END)
+
+ val view = inflate(menuCategoryWithTextAlignment)
+ val textView = view.findViewById<TextView>(R.id.category_text)
+
+ assertEquals(View.TEXT_ALIGNMENT_VIEW_END, textView.textAlignment)
+ }
+
+ @Test
+ fun `menu category can be converted to candidate`() {
+ assertEquals(
+ DecorativeTextMenuCandidate(
+ label,
+ textStyle = TextStyle(
+ textStyle = Typeface.BOLD,
+ textAlignment = View.TEXT_ALIGNMENT_VIEW_START,
+ ),
+ ),
+ BrowserMenuCategory(label).asCandidate(context),
+ )
+
+ assertEquals(
+ DecorativeTextMenuCandidate(
+ label,
+ textStyle = TextStyle(
+ size = 12f,
+ textStyle = Typeface.BOLD,
+ textAlignment = View.TEXT_ALIGNMENT_VIEW_START,
+ ),
+ ),
+ BrowserMenuCategory(label, 12f).asCandidate(context),
+ )
+
+ assertEquals(
+ DecorativeTextMenuCandidate(
+ label,
+ textStyle = TextStyle(
+ color = ContextCompat.getColor(context, android.R.color.holo_red_dark),
+ textStyle = Typeface.BOLD,
+ textAlignment = View.TEXT_ALIGNMENT_VIEW_START,
+ ),
+ ),
+ BrowserMenuCategory(
+ label,
+ textColorResource = android.R.color.holo_red_dark,
+ ).asCandidate(context),
+ )
+
+ assertEquals(
+ DecorativeTextMenuCandidate(
+ label,
+ textStyle = TextStyle(
+ textStyle = Typeface.ITALIC,
+ textAlignment = View.TEXT_ALIGNMENT_VIEW_START,
+ ),
+ ),
+ BrowserMenuCategory(label, textStyle = Typeface.ITALIC).asCandidate(context),
+ )
+
+ assertEquals(
+ DecorativeTextMenuCandidate(
+ label,
+ textStyle = TextStyle(
+ textStyle = Typeface.BOLD,
+ textAlignment = View.TEXT_ALIGNMENT_VIEW_END,
+ ),
+ ),
+ BrowserMenuCategory(label, textAlignment = View.TEXT_ALIGNMENT_VIEW_END).asCandidate(context),
+ )
+ }
+
+ private fun inflate(browserMenuCategory: BrowserMenuCategory): View {
+ val view = LayoutInflater.from(context).inflate(browserMenuCategory.getLayoutResource(), null)
+ val mockMenu = Mockito.mock(BrowserMenu::class.java)
+ browserMenuCategory.bind(mockMenu, view)
+ return view
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCheckboxTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCheckboxTest.kt
new file mode 100644
index 0000000000..38bcb45c89
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCheckboxTest.kt
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.browser.menu.item
+
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BrowserMenuCheckboxTest {
+
+ @Test
+ fun `browser checkbox uses correct layout`() {
+ val item = BrowserMenuCheckbox("Hello") {}
+ assertEquals(R.layout.mozac_browser_menu_item_checkbox, item.getLayoutResource())
+ }
+
+ @Test
+ fun `checkbox can be converted to candidate with correct end type`() {
+ val listener = { _: Boolean -> }
+
+ assertEquals(
+ CompoundMenuCandidate(
+ "Hello",
+ isChecked = false,
+ end = CompoundMenuCandidate.ButtonType.CHECKBOX,
+ onCheckedChange = listener,
+ ),
+ BrowserMenuCheckbox(
+ "Hello",
+ listener = listener,
+ ).asCandidate(mock()),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCompoundButtonTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCompoundButtonTest.kt
new file mode 100644
index 0000000000..25a09bd77c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuCompoundButtonTest.kt
@@ -0,0 +1,190 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewTreeObserver
+import android.widget.CheckBox
+import androidx.appcompat.widget.SwitchCompat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+import mozilla.components.support.test.any
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuCompoundButtonTest {
+
+ @Test
+ fun `simple menu items are always visible by default`() {
+ val item = SimpleTestBrowserCompoundButton("Hello") {
+ // do nothing
+ }
+
+ assertTrue(item.visible())
+ }
+
+ @Test
+ fun `layout resource can be inflated`() {
+ val item = SimpleTestBrowserCompoundButton("Hello") {
+ // do nothing
+ }
+
+ val view = LayoutInflater.from(testContext)
+ .inflate(item.getLayoutResource(), null)
+
+ assertNotNull(view)
+ }
+
+ @Test
+ fun `clicking bound view will invoke callback and dismiss menu`() {
+ var callbackInvoked = false
+
+ val item = SimpleTestBrowserCompoundButton("Hello") { checked ->
+ callbackInvoked = checked
+ }
+
+ val menu = mock(BrowserMenu::class.java)
+ val view = CheckBox(testContext)
+
+ item.bind(menu, view)
+
+ view.isChecked = true
+
+ assertTrue(callbackInvoked)
+ verify(menu).dismiss()
+ }
+
+ @Test
+ fun `initialState is invoked on bind`() {
+ val initialState: () -> Boolean = { true }
+ val item = SimpleTestBrowserCompoundButton("Hello", initialState) {}
+
+ val menu = mock(BrowserMenu::class.java)
+ val view = spy(CheckBox(testContext))
+ item.bind(menu, view)
+
+ verify(view).isChecked = true
+ }
+
+ @Test
+ fun `hitting default methods`() {
+ val item = SimpleTestBrowserCompoundButton("") {}
+ item.invalidate(mock(View::class.java))
+ }
+
+ @Test
+ fun `menu compound button can be converted to candidate`() {
+ val listener = { _: Boolean -> }
+
+ assertEquals(
+ CompoundMenuCandidate(
+ "Hello",
+ isChecked = false,
+ end = CompoundMenuCandidate.ButtonType.CHECKBOX,
+ onCheckedChange = listener,
+ ),
+ SimpleTestBrowserCompoundButton(
+ "Hello",
+ listener = listener,
+ ).asCandidate(testContext),
+ )
+
+ assertEquals(
+ CompoundMenuCandidate(
+ "Hello",
+ isChecked = true,
+ end = CompoundMenuCandidate.ButtonType.CHECKBOX,
+ onCheckedChange = listener,
+ ),
+ SimpleTestBrowserCompoundButton(
+ "Hello",
+ initialState = { true },
+ listener = listener,
+ ).asCandidate(testContext),
+ )
+ }
+
+ @Test
+ fun `GIVEN the View is attached to Window WHEN bind is called THEN the layout direction is not updated`() {
+ val item = SimpleTestBrowserCompoundButton("Hello") {}
+ val menu = mock(BrowserMenu::class.java)
+ val view: SwitchCompat = mock()
+ doReturn(true).`when`(view).isAttachedToWindow
+ doReturn(mock<ViewTreeObserver>()).`when`(view).viewTreeObserver
+
+ item.bind(menu, view)
+
+ verify(view, never()).layoutDirection = ArgumentMatchers.anyInt()
+ }
+
+ @Test
+ fun `GIVEN the View is not attached to Window WHEN bind is called THEN the layout direction is changed to locale`() {
+ val item = SimpleTestBrowserCompoundButton("Hello") {}
+ val menu = mock(BrowserMenu::class.java)
+ val view: SwitchCompat = mock()
+ doReturn(false).`when`(view).isAttachedToWindow
+ doReturn(mock<ViewTreeObserver>()).`when`(view).viewTreeObserver
+
+ item.bind(menu, view)
+
+ verify(view).layoutDirection = View.LAYOUT_DIRECTION_LOCALE
+ }
+
+ @Test
+ fun `GIVEN the View is not attached to Window WHEN bind is called THEN the a viewTreeObserver for preDraw is set`() {
+ val item = SimpleTestBrowserCompoundButton("Hello") {}
+ val menu = mock(BrowserMenu::class.java)
+ val view: SwitchCompat = mock()
+ val viewTreeObserver: ViewTreeObserver = mock()
+ doReturn(false).`when`(view).isAttachedToWindow
+ doReturn(viewTreeObserver).`when`(view).viewTreeObserver
+
+ item.bind(menu, view)
+
+ verify(viewTreeObserver).addOnPreDrawListener(any())
+ }
+
+ @Test
+ fun `GIVEN a view with updated layout direction WHEN it is about to be drawn THEN the layout direction reset`() {
+ val item = SimpleTestBrowserCompoundButton("Hello") {}
+ val menu = mock(BrowserMenu::class.java)
+ val view: SwitchCompat = mock()
+ val viewTreeObserver: ViewTreeObserver = mock()
+ doReturn(false).`when`(view).isAttachedToWindow
+ doReturn(viewTreeObserver).`when`(view).viewTreeObserver
+ val captor = argumentCaptor<ViewTreeObserver.OnPreDrawListener>()
+
+ item.bind(menu, view)
+ verify(viewTreeObserver).addOnPreDrawListener(captor.capture())
+
+ captor.value.onPreDraw()
+ verify(viewTreeObserver).removeOnPreDrawListener(captor.value)
+ verify(view).layoutDirection = View.LAYOUT_DIRECTION_INHERIT
+ }
+
+ class SimpleTestBrowserCompoundButton(
+ label: String,
+ initialState: () -> Boolean = { false },
+ listener: (Boolean) -> Unit,
+ ) : BrowserMenuCompoundButton(label, false, false, initialState, listener) {
+ override fun getLayoutResource(): Int = R.layout.mozac_browser_menu_item_simple
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuDividerTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuDividerTest.kt
new file mode 100644
index 0000000000..1a3979dd1e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuDividerTest.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 mozilla.components.browser.menu.item
+
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.DividerMenuCandidate
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BrowserMenuDividerTest {
+
+ @Test
+ fun `browser divider uses correct layout`() {
+ val item = BrowserMenuDivider()
+ assertEquals(R.layout.mozac_browser_menu_item_divider, item.getLayoutResource())
+ }
+
+ @Test
+ fun `hitting default methods`() {
+ val item = BrowserMenuDivider()
+ assertTrue(item.visible())
+ item.bind(mock(), mock())
+ item.invalidate(mock())
+ }
+
+ @Test
+ fun `menu divider can be converted to candidate`() {
+ assertEquals(
+ DividerMenuCandidate(),
+ BrowserMenuDivider().asCandidate(mock()),
+ )
+
+ assertEquals(
+ DividerMenuCandidate(
+ containerStyle = ContainerStyle(isVisible = true),
+ ),
+ BrowserMenuDivider().apply {
+ visible = { true }
+ }.asCandidate(mock()),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableItemTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableItemTest.kt
new file mode 100644
index 0000000000..49bd310a00
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableItemTest.kt
@@ -0,0 +1,334 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat.getColor
+import androidx.core.view.isVisible
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuHighlight
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.HighPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.LowPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextStyle
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.robolectric.Shadows
+import mozilla.components.ui.colors.R as colorsR
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuHighlightableItemTest {
+
+ private val colorId = colorsR.color.photonRed50
+
+ @Suppress("Deprecation")
+ @Test
+ fun `browser menu highlightable item should be inflated`() {
+ var onClickWasPress = false
+ val item = BrowserMenuHighlightableItem(
+ "label",
+ imageResource = android.R.drawable.ic_menu_report_image,
+ iconTintColorResource = android.R.color.black,
+ textColorResource = android.R.color.black,
+ highlight = BrowserMenuHighlightableItem.Highlight(
+ endImageResource = android.R.drawable.ic_menu_report_image,
+ backgroundResource = colorId,
+ colorResource = colorId,
+ ),
+ ) {
+ onClickWasPress = true
+ }
+
+ val view = inflate(item)
+
+ view.performClick()
+ assertTrue(onClickWasPress)
+ }
+
+ @Suppress("Deprecation")
+ @Test
+ fun `browser menu highlightable item should properly handle classic highlighting`() {
+ var shouldHighlight = false
+ val item = BrowserMenuHighlightableItem(
+ label = "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ iconTintColorResource = android.R.color.black,
+ textColorResource = android.R.color.black,
+ highlight = BrowserMenuHighlightableItem.Highlight(
+ startImageResource = android.R.drawable.ic_menu_camera,
+ endImageResource = android.R.drawable.ic_menu_add,
+ backgroundResource = colorId,
+ colorResource = colorId,
+ ),
+ isHighlighted = { shouldHighlight },
+ )
+
+ val view = inflate(item)
+
+ assertEquals("label", view.textView.text)
+
+ // Highlight should not exist before set
+ val oldDrawable = view.startImageView.drawable
+ assertEquals(android.R.drawable.ic_menu_report_image, Shadows.shadowOf(oldDrawable).createdFromResId)
+ assertFalse(view.endImageView.isVisible)
+
+ shouldHighlight = true
+ item.invalidate(view)
+
+ // Highlight should now exist
+ assertTrue(view.endImageView.isVisible)
+ assertNotEquals(oldDrawable, view.startImageView.drawable)
+ assertEquals(android.R.drawable.ic_menu_camera, Shadows.shadowOf(view.startImageView.drawable).createdFromResId)
+ assertEquals(android.R.drawable.ic_menu_add, Shadows.shadowOf(view.endImageView.drawable).createdFromResId)
+ assertNotNull(view.endImageView.imageTintList)
+ assertEquals(colorId, Shadows.shadowOf(view.background).createdFromResId)
+ }
+
+ @Test
+ fun `browser menu highlightable item should properly handle high priority highlighting`() {
+ var shouldHighlight = false
+ val item = BrowserMenuHighlightableItem(
+ label = "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ iconTintColorResource = android.R.color.black,
+ textColorResource = android.R.color.black,
+ highlight = BrowserMenuHighlight.HighPriority(
+ endImageResource = android.R.drawable.ic_menu_add,
+ backgroundTint = Color.RED,
+ label = "highlight",
+ ),
+ isHighlighted = { shouldHighlight },
+ )
+
+ val view = inflate(item)
+
+ assertEquals("label", view.textView.text)
+ assertEquals("highlight", view.highlightedTextView.text)
+
+ // Highlight should not exist before set
+ assertEquals(android.R.drawable.ic_menu_report_image, Shadows.shadowOf(view.startImageView.drawable).createdFromResId)
+ assertFalse(view.highlightedTextView.isVisible)
+ assertFalse(view.endImageView.isVisible)
+
+ shouldHighlight = true
+ item.invalidate(view)
+
+ // Highlight should now exist
+ assertTrue(view.highlightedTextView.isVisible)
+ assertEquals(android.R.drawable.ic_menu_report_image, Shadows.shadowOf(view.startImageView.drawable).createdFromResId)
+ assertEquals(android.R.drawable.ic_menu_add, Shadows.shadowOf(view.endImageView.drawable).createdFromResId)
+ assertNotNull(view.endImageView.imageTintList)
+ assertTrue(view.endImageView.isVisible)
+ }
+
+ @Test
+ fun `browser menu highlightable item should properly handle low priority highlighting`() {
+ var shouldHighlight = false
+ val item = BrowserMenuHighlightableItem(
+ label = "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ iconTintColorResource = android.R.color.black,
+ textColorResource = android.R.color.black,
+ highlight = BrowserMenuHighlight.LowPriority(
+ notificationTint = Color.RED,
+ label = "highlight",
+ ),
+ isHighlighted = { shouldHighlight },
+ )
+
+ val view = inflate(item)
+
+ assertEquals("label", view.textView.text)
+ assertEquals("highlight", view.highlightedTextView.text)
+
+ val startImageView = view.findViewById<AppCompatImageView>(R.id.image)
+ val highlightImageView = view.findViewById<AppCompatImageView>(R.id.end_image)
+
+ // Highlight should not exist before set
+ assertEquals(android.R.drawable.ic_menu_report_image, Shadows.shadowOf(startImageView.drawable).createdFromResId)
+ assertFalse(view.highlightedTextView.isVisible)
+ assertFalse(highlightImageView.isVisible)
+
+ shouldHighlight = true
+ item.invalidate(view)
+
+ // Highlight should now exist
+ assertTrue(view.findViewById<TextView>(R.id.highlight_text).isVisible)
+ assertFalse(view.findViewById<AppCompatImageView>(R.id.end_image).isVisible)
+ assertNull(view.background)
+ }
+
+ @Test
+ fun `browser menu highlightable item with with no iconTintColorResource must not have a tinted icon`() {
+ val item = BrowserMenuHighlightableItem(
+ "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ highlight = BrowserMenuHighlight.HighPriority(
+ endImageResource = android.R.drawable.ic_menu_report_image,
+ backgroundTint = Color.RED,
+ ),
+ )
+
+ val view = inflate(item)
+
+ assertEquals("label", view.textView.text)
+ assertNull(view.startImageView.drawable!!.colorFilter)
+ assertNull(view.endImageView.imageTintList)
+ }
+
+ @Test
+ fun `bind highlightable item with with default high priority`() {
+ val item = BrowserMenuHighlightableItem(
+ "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ highlight = BrowserMenuHighlight.HighPriority(
+ backgroundTint = Color.RED,
+ ),
+ )
+
+ val view = inflate(item)
+
+ assertEquals("label", view.textView.text)
+ assertEquals("label", view.highlightedTextView.text)
+ assertTrue(view.highlightedTextView.isVisible)
+ assertTrue(view.background is ColorDrawable)
+ assertNull(view.endImageView.drawable)
+ }
+
+ @Suppress("Deprecation")
+ @Test
+ fun `browser menu highlightable item with with no highlight must not have highlightImageView visible`() {
+ val item = BrowserMenuHighlightableItem(
+ "label",
+ android.R.drawable.ic_menu_report_image,
+ highlight = null,
+ )
+
+ val view = inflate(item)
+ val endImageView = view.findViewById<AppCompatImageView>(R.id.end_image)
+ val textView = view.findViewById<TextView>(R.id.text)
+ assertEquals("label", textView.text)
+ assertEquals(endImageView.visibility, View.GONE)
+ }
+
+ @Test
+ fun `menu item can be converted to candidate`() {
+ val listener = {}
+
+ var shouldHighlight = false
+ val highPriorityItem = BrowserMenuHighlightableItem(
+ label = "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ iconTintColorResource = android.R.color.black,
+ textColorResource = android.R.color.black,
+ highlight = BrowserMenuHighlight.HighPriority(
+ endImageResource = android.R.drawable.ic_menu_add,
+ backgroundTint = Color.RED,
+ label = "highlight",
+ ),
+ isHighlighted = { shouldHighlight },
+ listener = listener,
+ )
+
+ assertEquals(
+ TextMenuCandidate(
+ "label",
+ start = DrawableMenuIcon(
+ null,
+ tint = getColor(testContext, android.R.color.black),
+ ),
+ textStyle = TextStyle(
+ color = getColor(testContext, android.R.color.black),
+ ),
+ onClick = listener,
+ ),
+ highPriorityItem.asCandidate(testContext).removeDrawables(),
+ )
+
+ shouldHighlight = true
+ assertEquals(
+ TextMenuCandidate(
+ "highlight",
+ start = DrawableMenuIcon(
+ null,
+ tint = getColor(testContext, android.R.color.black),
+ ),
+ end = DrawableMenuIcon(null),
+ textStyle = TextStyle(
+ color = getColor(testContext, android.R.color.black),
+ ),
+ effect = HighPriorityHighlightEffect(
+ backgroundTint = Color.RED,
+ ),
+ onClick = listener,
+ ),
+ highPriorityItem.asCandidate(testContext).removeDrawables(),
+ )
+
+ assertEquals(
+ TextMenuCandidate(
+ "highlight",
+ start = DrawableMenuIcon(
+ null,
+ tint = getColor(testContext, android.R.color.black),
+ effect = LowPriorityHighlightEffect(
+ notificationTint = Color.RED,
+ ),
+ ),
+ textStyle = TextStyle(
+ color = getColor(testContext, android.R.color.black),
+ ),
+ onClick = listener,
+ ),
+ BrowserMenuHighlightableItem(
+ label = "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ iconTintColorResource = android.R.color.black,
+ textColorResource = android.R.color.black,
+ highlight = BrowserMenuHighlight.LowPriority(
+ notificationTint = Color.RED,
+ label = "highlight",
+ ),
+ isHighlighted = { true },
+ listener = listener,
+ ).asCandidate(testContext).removeDrawables(),
+ )
+ }
+
+ private fun inflate(item: BrowserMenuHighlightableItem): ConstraintLayout {
+ val view = LayoutInflater.from(testContext).inflate(item.getLayoutResource(), null)
+ val mockMenu = mock(BrowserMenu::class.java)
+ item.bind(mockMenu, view)
+ return view as ConstraintLayout
+ }
+
+ private val ConstraintLayout.startImageView: ImageView get() = findViewById(R.id.image)
+ private val ConstraintLayout.endImageView: ImageView get() = findViewById(R.id.end_image)
+ private val ConstraintLayout.textView: TextView get() = findViewById(R.id.text)
+ private val ConstraintLayout.highlightedTextView: TextView get() = findViewById(R.id.highlight_text)
+
+ private fun TextMenuCandidate.removeDrawables() = copy(
+ start = (start as? DrawableMenuIcon)?.copy(drawable = null),
+ end = (end as? DrawableMenuIcon)?.copy(drawable = null),
+ )
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableSwitchTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableSwitchTest.kt
new file mode 100644
index 0000000000..5df2dab9a8
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuHighlightableSwitchTest.kt
@@ -0,0 +1,124 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.graphics.Color
+import android.view.LayoutInflater
+import android.widget.ImageView
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.appcompat.widget.SwitchCompat
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.view.isVisible
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuHighlight
+import mozilla.components.browser.menu.R
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.robolectric.Shadows
+import mozilla.components.ui.colors.R as colorsR
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuHighlightableSwitchTest {
+
+ @Test
+ fun `menu item uses correct layout`() {
+ val item = BrowserMenuHighlightableSwitch(
+ label = "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ highlight = BrowserMenuHighlight.LowPriority(
+ notificationTint = Color.RED,
+ ),
+ ) {}
+
+ assertEquals(R.layout.mozac_browser_menu_highlightable_switch, item.getLayoutResource())
+ }
+
+ @Suppress("Deprecation")
+ @Test
+ fun `browser menu highlightable item should be inflated`() {
+ var onClickWasPress = false
+ val item = BrowserMenuHighlightableSwitch(
+ label = "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ highlight = BrowserMenuHighlight.LowPriority(
+ notificationTint = Color.RED,
+ ),
+ ) {
+ onClickWasPress = true
+ }
+
+ val view = inflate(item)
+
+ view.switch.performClick()
+ assertTrue(onClickWasPress)
+ }
+
+ @Test
+ fun `browser menu highlightable item should properly handle low priority highlighting`() {
+ var shouldHighlight = false
+ val item = BrowserMenuHighlightableSwitch(
+ label = "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ iconTintColorResource = android.R.color.black,
+ textColorResource = android.R.color.black,
+ highlight = BrowserMenuHighlight.LowPriority(
+ notificationTint = colorsR.color.photonRed50,
+ label = "highlight",
+ ),
+ isHighlighted = { shouldHighlight },
+ ) {}
+
+ val view = inflate(item)
+
+ assertEquals("label", view.switch.text)
+
+ val startImageView = view.findViewById<AppCompatImageView>(R.id.image)
+
+ // Highlight should not exist before set
+ assertEquals(android.R.drawable.ic_menu_report_image, Shadows.shadowOf(startImageView.drawable).createdFromResId)
+ assertFalse(view.notificationDot.isVisible)
+
+ shouldHighlight = true
+ item.invalidate(view)
+
+ // Highlight should now exist
+ assertEquals("highlight", view.switch.text)
+ assertTrue(view.notificationDot.isVisible)
+ }
+
+ @Test
+ fun `browser menu highlightable item with with no iconTintColorResource must not have a tinted icon`() {
+ val item = BrowserMenuHighlightableSwitch(
+ "label",
+ startImageResource = android.R.drawable.ic_menu_report_image,
+ highlight = BrowserMenuHighlight.LowPriority(
+ notificationTint = Color.RED,
+ ),
+ ) {}
+
+ val view = inflate(item)
+
+ assertEquals("label", view.switch.text)
+ assertNull(view.startImageView.drawable!!.colorFilter)
+ }
+
+ private fun inflate(item: BrowserMenuHighlightableSwitch): ConstraintLayout {
+ val view = LayoutInflater.from(testContext).inflate(item.getLayoutResource(), null)
+ val mockMenu = mock(BrowserMenu::class.java)
+ item.bind(mockMenu, view)
+ return view as ConstraintLayout
+ }
+
+ private val ConstraintLayout.startImageView: ImageView get() = findViewById(R.id.image)
+ private val ConstraintLayout.notificationDot: ImageView get() = findViewById(R.id.notification_dot)
+ private val ConstraintLayout.switch: SwitchCompat get() = findViewById(R.id.switch_widget)
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageSwitchTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageSwitchTest.kt
new file mode 100644
index 0000000000..ae3c82aa7b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageSwitchTest.kt
@@ -0,0 +1,60 @@
+/* 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 mozilla.components.browser.menu.item
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuImageSwitchTest {
+ private lateinit var menuItem: BrowserMenuImageSwitch
+ private val label = "test"
+ private val icon = android.R.drawable.ic_menu_call
+
+ @Before
+ fun setup() {
+ menuItem = BrowserMenuImageSwitch(icon, label) {}
+ }
+
+ @Test
+ fun `menu item uses correct layout`() {
+ assertEquals(R.layout.mozac_browser_menu_item_image_switch, menuItem.getLayoutResource())
+ }
+
+ @Test
+ fun `menu item has correct label`() {
+ assertEquals(label, menuItem.label)
+ }
+
+ @Test
+ fun `menu item has correct icon`() {
+ assertEquals(icon, menuItem.imageResource)
+ }
+
+ @Test
+ fun `menu switch can be converted to candidate`() {
+ val listener = { _: Boolean -> }
+
+ assertEquals(
+ CompoundMenuCandidate(
+ label,
+ isChecked = false,
+ start = DrawableMenuIcon(null),
+ end = CompoundMenuCandidate.ButtonType.SWITCH,
+ onCheckedChange = listener,
+ ),
+ BrowserMenuImageSwitch(icon, label, listener = listener).asCandidate(testContext).run {
+ copy(start = (start as DrawableMenuIcon?)?.copy(drawable = null))
+ },
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageTextCheckboxButtonTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageTextCheckboxButtonTest.kt
new file mode 100644
index 0000000000..8969fcd94e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageTextCheckboxButtonTest.kt
@@ -0,0 +1,204 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.CheckBox
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import androidx.core.widget.ImageViewCompat.getImageTintList
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuImageTextCheckboxButtonTest {
+
+ private lateinit var item: BrowserMenuImageTextCheckboxButton
+ private lateinit var secondaryItem: BrowserMenuImageTextCheckboxButton
+
+ private val label = "Bookmarks"
+ private val imageResource = android.R.drawable.ic_menu_report_image
+ private val iconTintColorResource = android.R.color.holo_red_dark
+
+ private val tintColorResource = android.R.color.holo_purple
+ private val labelListener = { }
+ private val primaryLabel = "Add"
+ private val secondaryLabel = "Edit"
+ private val primaryStateIconResource = android.R.drawable.star_big_off
+ private val secondaryStateIconResource = android.R.drawable.star_big_on
+ private val isInPrimaryState: () -> Boolean = { true }
+ private val onCheckedChangedListener: (Boolean) -> Unit = { }
+
+ @Before
+ fun setUp() {
+ item = spy(
+ BrowserMenuImageTextCheckboxButton(
+ imageResource = imageResource,
+ label = label,
+ iconTintColorResource = iconTintColorResource,
+ textColorResource = tintColorResource,
+ labelListener = labelListener,
+ primaryLabel = primaryLabel,
+ secondaryLabel = secondaryLabel,
+ primaryStateIconResource = primaryStateIconResource,
+ secondaryStateIconResource = secondaryStateIconResource,
+ tintColorResource = tintColorResource,
+ isInPrimaryState = isInPrimaryState,
+ onCheckedChangedListener = onCheckedChangedListener,
+ ),
+ )
+
+ secondaryItem = spy(
+ BrowserMenuImageTextCheckboxButton(
+ imageResource = imageResource,
+ label = label,
+ iconTintColorResource = iconTintColorResource,
+ textColorResource = tintColorResource,
+ labelListener = labelListener,
+ primaryLabel = primaryLabel,
+ secondaryLabel = secondaryLabel,
+ primaryStateIconResource = primaryStateIconResource,
+ secondaryStateIconResource = secondaryStateIconResource,
+ tintColorResource = tintColorResource,
+ isInPrimaryState = { false },
+ onCheckedChangedListener = onCheckedChangedListener,
+ ),
+ )
+ }
+
+ @Test
+ fun `layout resource can be inflated`() {
+ val view = LayoutInflater.from(testContext)
+ .inflate(item.getLayoutResource(), null)
+
+ assertNotNull(view)
+ }
+
+ @Test
+ fun `item uses correct layout`() {
+ assertEquals(R.layout.mozac_browser_menu_item_image_text_checkbox_button, item.getLayoutResource())
+ }
+
+ @Test
+ fun `item is visible by default`() {
+ assertTrue(item.visible())
+ }
+
+ @Test
+ fun `initialState is invoked on bind and properly sets label`() {
+ val menu = mock(BrowserMenu::class.java)
+ val view = LayoutInflater.from(testContext)
+ .inflate(item.getLayoutResource(), null)
+
+ item.bind(menu, view)
+ val checkBox = view.findViewById<CheckBox>(R.id.checkbox)
+ var expectedLabel = if (item.isInPrimaryState()) primaryLabel else secondaryLabel
+
+ assertEquals(expectedLabel, primaryLabel)
+ assertEquals(expectedLabel, checkBox.text)
+
+ secondaryItem.bind(menu, view)
+ val secondaryCheckBox = view.findViewById<CheckBox>(R.id.checkbox)
+ expectedLabel = if (secondaryItem.isInPrimaryState()) primaryLabel else secondaryLabel
+
+ assertEquals(expectedLabel, secondaryLabel)
+ assertEquals(expectedLabel, secondaryCheckBox.text)
+ }
+
+ @Test
+ fun `item has the correct text color`() {
+ val view = inflate(item)
+
+ val textView = view.findViewById<TextView>(R.id.text)
+ val expectedColour = ContextCompat.getColor(view.context, item.textColorResource)
+
+ assertEquals(textView.text, label)
+ assertEquals(expectedColour, textView.currentTextColor)
+ }
+
+ @Test
+ fun `item has icon with correct resource and tint`() {
+ val view = inflate(item)
+
+ val icon = view.findViewById<ImageView>(R.id.image)
+ val expectedColour = ContextCompat.getColor(view.context, item.iconTintColorResource)
+
+ assertNotNull(icon.drawable)
+ assertEquals(getImageTintList(icon)?.defaultColor, expectedColour)
+ }
+
+ @Test
+ fun `item accessibilityRegion has label text as content description`() {
+ val view = inflate(item)
+
+ val accessibilityRegion = view.findViewById<View>(R.id.accessibilityRegion)
+
+ assertEquals(label, accessibilityRegion.contentDescription)
+ }
+
+ @Test
+ fun `item accessibilityRegion is clickable`() {
+ val view = inflate(item)
+
+ val accessibilityRegion = view.findViewById<View>(R.id.accessibilityRegion)
+
+ assertTrue(accessibilityRegion.isClickable)
+ assertTrue(accessibilityRegion.callOnClick())
+ }
+
+ @Test
+ fun `clicking item dismisses menu`() {
+ val view = inflate(item)
+ val menu = mock(BrowserMenu::class.java)
+
+ item.bind(menu, view)
+ view.callOnClick()
+
+ verify(menu).dismiss()
+ }
+
+ @Test
+ fun `clicking checkbox dismisses menu`() {
+ val view = inflate(item)
+ val menu = mock(BrowserMenu::class.java)
+
+ item.bind(menu, view)
+ val checkBox = view.findViewById<CheckBox>(R.id.checkbox)
+
+ checkBox.performClick()
+
+ verify(menu).dismiss()
+ }
+
+ @Test
+ fun `item checkbox has text with correct tint`() {
+ val view = inflate(item)
+
+ val checkbox = view.findViewById<CheckBox>(R.id.checkbox)
+ val expectedColour = ContextCompat.getColor(view.context, tintColorResource)
+
+ assertEquals(expectedColour, checkbox.currentTextColor)
+ }
+
+ private fun inflate(item: BrowserMenuImageText): View {
+ val view = LayoutInflater.from(testContext).inflate(item.getLayoutResource(), null)
+ val mockMenu = mock(BrowserMenu::class.java)
+ item.bind(mockMenu, view)
+ return view
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageTextTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageTextTest.kt
new file mode 100644
index 0000000000..951a68e2df
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuImageTextTest.kt
@@ -0,0 +1,131 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.TextView
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.content.ContextCompat.getColor
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuImageTextTest {
+
+ private val context: Context get() = ApplicationProvider.getApplicationContext()
+
+ @Test
+ fun `browser menu ImageText should be inflated`() {
+ var onClickWasPress = false
+ val item = BrowserMenuImageText(
+ "label",
+ android.R.drawable.ic_menu_report_image,
+ android.R.color.black,
+ ) {
+ onClickWasPress = true
+ }
+
+ val view = inflate(item)
+
+ view.performClick()
+ assertTrue(onClickWasPress)
+ }
+
+ @Test
+ fun `browser menu ImageText should have the right text, image, and iconTintColorResource`() {
+ val item = BrowserMenuImageText(
+ "label",
+ android.R.drawable.ic_menu_report_image,
+ android.R.color.black,
+ ) {
+ }
+
+ val view = inflate(item)
+
+ val textView = view.findViewById<TextView>(R.id.text)
+ assertEquals(textView.text, "label")
+
+ val imageView = view.findViewById<AppCompatImageView>(R.id.image)
+
+ assertNotNull(imageView.drawable)
+
+ assertNotNull(imageView.imageTintList)
+ }
+
+ @Test
+ fun `browser menu ImageText with with no iconTintColorResource must not have an imageTintList`() {
+ val item = BrowserMenuImageText(
+ "label",
+ android.R.drawable.ic_menu_report_image,
+ )
+
+ val view = inflate(item)
+
+ val imageView = view.findViewById<AppCompatImageView>(R.id.image)
+
+ assertNull(imageView.imageTintList)
+ }
+
+ @Test
+ fun `menu image text item can be converted to candidate`() {
+ val listener = {}
+
+ assertEquals(
+ TextMenuCandidate(
+ "label",
+ start = DrawableMenuIcon(null),
+ onClick = listener,
+ ),
+ BrowserMenuImageText(
+ "label",
+ android.R.drawable.ic_menu_report_image,
+ listener = listener,
+ ).asCandidate(context).let {
+ val text = it as TextMenuCandidate
+ text.copy(start = (text.start as? DrawableMenuIcon)?.copy(drawable = null))
+ },
+ )
+
+ assertEquals(
+ TextMenuCandidate(
+ "label",
+ start = DrawableMenuIcon(
+ null,
+ tint = getColor(context, android.R.color.black),
+ ),
+ onClick = listener,
+ ),
+ BrowserMenuImageText(
+ "label",
+ android.R.drawable.ic_menu_report_image,
+ android.R.color.black,
+ listener = listener,
+ ).asCandidate(context).let {
+ val text = it as TextMenuCandidate
+ text.copy(start = (text.start as? DrawableMenuIcon)?.copy(drawable = null))
+ },
+ )
+ }
+
+ private fun inflate(item: BrowserMenuImageText): View {
+ val view = LayoutInflater.from(context).inflate(item.getLayoutResource(), null)
+ val mockMenu = mock(BrowserMenu::class.java)
+ item.bind(mockMenu, view)
+ return view
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuItemToolbarTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuItemToolbarTest.kt
new file mode 100644
index 0000000000..a5f729a321
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuItemToolbarTest.kt
@@ -0,0 +1,439 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.view.LayoutInflater
+import android.widget.ImageButton
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.appcompat.R
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.content.ContextCompat.getColor
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.RowMenuCandidate
+import mozilla.components.concept.menu.candidate.SmallMenuCandidate
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuItemToolbarTest {
+
+ @Test
+ fun `toolbar is visible by default`() {
+ val toolbar = BrowserMenuItemToolbar(emptyList())
+
+ assertTrue(toolbar.visible())
+ }
+
+ @Test
+ fun `layout resource can be inflated`() {
+ val toolbar = BrowserMenuItemToolbar(emptyList())
+
+ val view = LayoutInflater.from(testContext)
+ .inflate(toolbar.getLayoutResource(), null)
+
+ assertNotNull(view)
+ }
+
+ @Test
+ fun `empty toolbar does not add anything to view group`() {
+ val layout = LinearLayout(testContext)
+
+ val menu = mock(BrowserMenu::class.java)
+
+ val toolbar = BrowserMenuItemToolbar(emptyList())
+ toolbar.bind(menu, layout)
+
+ assertEquals(0, layout.childCount)
+ }
+
+ @Test
+ fun `toolbar removes previously existing child views from view group`() {
+ val layout = LinearLayout(testContext)
+ layout.addView(TextView(testContext))
+ layout.addView(TextView(testContext))
+
+ assertEquals(2, layout.childCount)
+
+ val menu = mock(BrowserMenu::class.java)
+
+ val toolbar = BrowserMenuItemToolbar(emptyList())
+ toolbar.bind(menu, layout)
+
+ assertEquals(0, layout.childCount)
+ }
+
+ @Test
+ fun `items are added as ImageButton to view group`() {
+ val buttons = listOf(
+ BrowserMenuItemToolbar.Button(
+ R.drawable.abc_ic_ab_back_material,
+ "Button01",
+ ) {},
+ BrowserMenuItemToolbar.Button(
+ R.drawable.abc_ic_ab_back_material,
+ "Button02",
+ ) {},
+ BrowserMenuItemToolbar.TwoStateButton(
+ primaryImageResource = R.drawable.abc_ic_go_search_api_material,
+ primaryContentDescription = "TwoStatePrimary",
+ secondaryImageResource = R.drawable.abc_ic_clear_material,
+ secondaryContentDescription = "TwoStateSecondary",
+ ) {},
+ )
+
+ val menu = mock(BrowserMenu::class.java)
+ val layout = LinearLayout(testContext)
+
+ val toolbar = BrowserMenuItemToolbar(buttons)
+ toolbar.bind(menu, layout)
+
+ assertEquals(3, layout.childCount)
+
+ val child1 = layout.getChildAt(0)
+ val child2 = layout.getChildAt(1)
+ val child3 = layout.getChildAt(2)
+
+ assertTrue(child1 is ImageButton)
+ assertTrue(child2 is ImageButton)
+ assertTrue(child3 is ImageButton)
+
+ assertEquals("Button01", child1.contentDescription)
+ assertEquals("Button02", child2.contentDescription)
+ assertEquals("TwoStatePrimary", child3.contentDescription)
+ }
+
+ @Test
+ fun `Disabled Button is not enabled`() {
+ val buttons = listOf(
+ BrowserMenuItemToolbar.Button(
+ imageResource = R.drawable.abc_ic_go_search_api_material,
+ contentDescription = "Button01",
+ iconTintColorResource = R.color.accent_material_light,
+ isEnabled = { false },
+ ) {},
+ )
+
+ val menu = mock(BrowserMenu::class.java)
+ val layout = LinearLayout(testContext)
+
+ val toolbar = BrowserMenuItemToolbar(buttons)
+ toolbar.bind(menu, layout)
+
+ val child1 = layout.getChildAt(0)
+ assertEquals("Button01", child1.contentDescription)
+ assertFalse(child1.isEnabled)
+ }
+
+ @Test
+ fun `Button redraws when invalidate is triggered`() {
+ var isEnabled = false
+ val buttons = listOf(
+ BrowserMenuItemToolbar.Button(
+ imageResource = R.drawable.abc_ic_go_search_api_material,
+ contentDescription = "Button01",
+ isEnabled = { isEnabled },
+ ) {},
+ )
+
+ val menu = mock(BrowserMenu::class.java)
+ val layout = LinearLayout(testContext)
+
+ val toolbar = BrowserMenuItemToolbar(buttons)
+ toolbar.bind(menu, layout)
+
+ val child1 = layout.getChildAt(0)
+ assertEquals("Button01", child1.contentDescription)
+ assertFalse(child1.isEnabled)
+
+ isEnabled = true
+ toolbar.invalidate(layout)
+ assertTrue(child1.isEnabled)
+ }
+
+ @Test
+ fun `Disabled TwoState Button in secondary state is disabled`() {
+ val buttons = listOf(
+ BrowserMenuItemToolbar.TwoStateButton(
+ primaryImageResource = R.drawable.abc_ic_go_search_api_material,
+ primaryContentDescription = "TwoStateEnabled",
+ secondaryImageResource = R.drawable.abc_ic_clear_material,
+ secondaryContentDescription = "TwoStateDisabled",
+ isInPrimaryState = { false },
+ disableInSecondaryState = true,
+ ) {},
+ )
+
+ val menu = mock(BrowserMenu::class.java)
+ val layout = LinearLayout(testContext)
+
+ val toolbar = BrowserMenuItemToolbar(buttons)
+ toolbar.bind(menu, layout)
+
+ val child1 = layout.getChildAt(0)
+ assertEquals("TwoStateDisabled", child1.contentDescription)
+ assertFalse(child1.isEnabled)
+ }
+
+ @Test
+ fun `TwoStateButton has primary and secondary state invoked`() {
+ val primaryResource = R.drawable.abc_ic_go_search_api_material
+ val secondaryResource = R.drawable.abc_ic_clear_material
+
+ var reloadPageAction = BrowserMenuItemToolbar.TwoStateButton(
+ primaryImageResource = primaryResource,
+ primaryContentDescription = "primary",
+ primaryImageTintResource = R.color.accent_material_dark,
+ secondaryImageResource = secondaryResource,
+ secondaryContentDescription = "secondary",
+ secondaryImageTintResource = R.color.accent_material_light,
+ ) {}
+ assertTrue(reloadPageAction.isInPrimaryState.invoke())
+
+ reloadPageAction = BrowserMenuItemToolbar.TwoStateButton(
+ primaryImageResource = primaryResource,
+ primaryContentDescription = "primary",
+ primaryImageTintResource = R.color.accent_material_dark,
+ secondaryImageResource = secondaryResource,
+ secondaryContentDescription = "secondary",
+ secondaryImageTintResource = R.color.accent_material_light,
+ isInPrimaryState = { false },
+ ) {}
+ assertFalse(reloadPageAction.isInPrimaryState.invoke())
+ }
+
+ @Test
+ fun `TwoStateButton redraws when invalidate is triggered`() {
+ var isInPrimaryState = true
+ val buttons = listOf(
+ BrowserMenuItemToolbar.TwoStateButton(
+ primaryImageResource = R.drawable.abc_ic_go_search_api_material,
+ primaryContentDescription = "TwoStateEnabled",
+ secondaryImageResource = R.drawable.abc_ic_clear_material,
+ secondaryContentDescription = "TwoStateDisabled",
+ isInPrimaryState = { isInPrimaryState },
+ ) {},
+ )
+
+ val menu = mock(BrowserMenu::class.java)
+ val layout = LinearLayout(testContext)
+
+ val toolbar = BrowserMenuItemToolbar(buttons)
+ toolbar.bind(menu, layout)
+
+ val child1 = layout.getChildAt(0)
+ assertEquals("TwoStateEnabled", child1.contentDescription)
+
+ isInPrimaryState = false
+ toolbar.invalidate(layout)
+ assertEquals("TwoStateDisabled", child1.contentDescription)
+ }
+
+ @Test
+ fun `TwoStateButton doesn't redraw if state hasn't changed`() {
+ val isInPrimaryState = true
+ val button = BrowserMenuItemToolbar.TwoStateButton(
+ primaryImageResource = R.drawable.abc_ic_go_search_api_material,
+ primaryContentDescription = "TwoStateEnabled",
+ secondaryImageResource = R.drawable.abc_ic_clear_material,
+ secondaryContentDescription = "TwoStateDisabled",
+ isInPrimaryState = { isInPrimaryState },
+ disableInSecondaryState = true,
+ ) {}
+
+ val view = mock(AppCompatImageView::class.java)
+
+ button.bind(view)
+ verify(view).contentDescription = "TwoStateEnabled"
+
+ reset(view)
+
+ button.invalidate(view)
+ verify(view, never()).contentDescription = "TwoStateEnabled"
+ }
+
+ @Test
+ fun `clicking item view invokes callback and dismisses menu`() {
+ var callbackInvoked = false
+
+ val button = BrowserMenuItemToolbar.Button(
+ R.drawable.abc_ic_ab_back_material,
+ "Test",
+ ) {
+ callbackInvoked = true
+ }
+
+ assertFalse(callbackInvoked)
+
+ val menu = mock(BrowserMenu::class.java)
+ val layout = LinearLayout(testContext)
+
+ val toolbar = BrowserMenuItemToolbar(listOf(button))
+ toolbar.bind(menu, layout)
+
+ assertEquals(1, layout.childCount)
+
+ val view = layout.getChildAt(0)
+
+ assertFalse(callbackInvoked)
+ verify(menu, never()).dismiss()
+
+ view.performClick()
+
+ assertTrue(callbackInvoked)
+ verify(menu).dismiss()
+ }
+
+ @Test
+ fun `long clicking item view invokes callback and dismisses menu`() {
+ var callbackInvoked = false
+
+ val button = BrowserMenuItemToolbar.Button(
+ R.drawable.abc_ic_ab_back_material,
+ "Test",
+ longClickListener = {
+ callbackInvoked = true
+ },
+ ) {}
+
+ assertFalse(callbackInvoked)
+
+ val menu = mock(BrowserMenu::class.java)
+ val layout = LinearLayout(testContext)
+
+ val toolbar = BrowserMenuItemToolbar(listOf(button))
+ toolbar.bind(menu, layout)
+
+ assertEquals(1, layout.childCount)
+
+ val view = layout.getChildAt(0)
+
+ assertFalse(callbackInvoked)
+ verify(menu, never()).dismiss()
+
+ view.performLongClick()
+
+ assertTrue(callbackInvoked)
+ verify(menu).dismiss()
+ }
+
+ @Test
+ fun `toolbar can be converted to candidate`() {
+ val listener = {}
+
+ assertEquals(
+ RowMenuCandidate(emptyList()),
+ BrowserMenuItemToolbar(emptyList()).asCandidate(testContext),
+ )
+
+ var isEnabled = false
+ var isInPrimaryState = true
+ val toolbarWithTwoState = BrowserMenuItemToolbar(
+ listOf(
+ BrowserMenuItemToolbar.Button(
+ R.drawable.abc_ic_ab_back_material,
+ "Button01",
+ isEnabled = { isEnabled },
+ listener = listener,
+ ),
+ BrowserMenuItemToolbar.Button(
+ R.drawable.abc_ic_ab_back_material,
+ "Button02",
+ iconTintColorResource = R.color.accent_material_light,
+ listener = listener,
+ ),
+ BrowserMenuItemToolbar.TwoStateButton(
+ primaryImageResource = R.drawable.abc_ic_go_search_api_material,
+ primaryContentDescription = "TwoStatePrimary",
+ secondaryImageResource = R.drawable.abc_ic_clear_material,
+ secondaryContentDescription = "TwoStateSecondary",
+ isInPrimaryState = { isInPrimaryState },
+ listener = listener,
+ ),
+ ),
+ )
+
+ assertEquals(
+ RowMenuCandidate(
+ listOf(
+ SmallMenuCandidate(
+ "Button01",
+ icon = DrawableMenuIcon(null),
+ containerStyle = ContainerStyle(isEnabled = false),
+ onClick = listener,
+ ),
+ SmallMenuCandidate(
+ "Button02",
+ icon = DrawableMenuIcon(
+ null,
+ tint = getColor(testContext, R.color.accent_material_light),
+ ),
+ onClick = listener,
+ ),
+ SmallMenuCandidate(
+ "TwoStatePrimary",
+ icon = DrawableMenuIcon(null),
+ onClick = listener,
+ ),
+ ),
+ ),
+ toolbarWithTwoState.asCandidate(testContext).run {
+ copy(
+ items = items.map {
+ it.copy(icon = it.icon.copy(drawable = null))
+ },
+ )
+ },
+ )
+
+ isEnabled = true
+ isInPrimaryState = false
+
+ assertEquals(
+ RowMenuCandidate(
+ listOf(
+ SmallMenuCandidate(
+ "Button01",
+ icon = DrawableMenuIcon(null),
+ containerStyle = ContainerStyle(isEnabled = true),
+ onClick = listener,
+ ),
+ SmallMenuCandidate(
+ "Button02",
+ icon = DrawableMenuIcon(
+ null,
+ tint = getColor(testContext, R.color.accent_material_light),
+ ),
+ onClick = listener,
+ ),
+ SmallMenuCandidate(
+ "TwoStateSecondary",
+ icon = DrawableMenuIcon(null),
+ onClick = listener,
+ ),
+ ),
+ ),
+ toolbarWithTwoState.asCandidate(testContext).run {
+ copy(
+ items = items.map {
+ it.copy(icon = it.icon.copy(drawable = null))
+ },
+ )
+ },
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuSwitchTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuSwitchTest.kt
new file mode 100644
index 0000000000..9943e11212
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/BrowserMenuSwitchTest.kt
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.browser.menu.item
+
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BrowserMenuSwitchTest {
+
+ @Test
+ fun `browser switch uses correct layout`() {
+ val item = BrowserMenuSwitch("Hello") {}
+ assertEquals(R.layout.mozac_browser_menu_item_switch, item.getLayoutResource())
+ }
+
+ @Test
+ fun `switch can be converted to candidate with correct end type`() {
+ val listener = { _: Boolean -> }
+
+ assertEquals(
+ CompoundMenuCandidate(
+ "Hello",
+ isChecked = false,
+ end = CompoundMenuCandidate.ButtonType.SWITCH,
+ onCheckedChange = listener,
+ ),
+ BrowserMenuSwitch(
+ "Hello",
+ listener = listener,
+ ).asCandidate(mock()),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/ParentBrowserMenuItemTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/ParentBrowserMenuItemTest.kt
new file mode 100644
index 0000000000..2335571a25
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/ParentBrowserMenuItemTest.kt
@@ -0,0 +1,150 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.view.LayoutInflater
+import android.widget.TextView
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuAdapter
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.DecorativeTextMenuCandidate
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.NestedMenuCandidate
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import mozilla.components.ui.icons.R as iconsR
+
+@RunWith(AndroidJUnit4::class)
+class ParentBrowserMenuItemTest {
+
+ @Test
+ fun `menu item ImageText should have the right text, image, and iconTintColorResource`() {
+ val subMenuItem = SimpleBrowserMenuItem("test")
+ val subMenuAdapter = BrowserMenuAdapter(testContext, listOf(subMenuItem))
+ val subMenu = BrowserMenu(subMenuAdapter)
+ val parentMenuItem = ParentBrowserMenuItem(
+ label = "label",
+ imageResource = android.R.drawable.ic_menu_report_image,
+ iconTintColorResource = android.R.color.black,
+ textColorResource = android.R.color.black,
+ subMenu = subMenu,
+ )
+ val view = LayoutInflater.from(testContext).inflate(parentMenuItem.getLayoutResource(), null)
+ val nestedMenuAdapter = BrowserMenuAdapter(testContext, listOf(parentMenuItem))
+ val nestedMenu = BrowserMenu(nestedMenuAdapter)
+
+ parentMenuItem.bind(nestedMenu, view)
+ val textView = view.findViewById<TextView>(R.id.text)
+ val imageView = view.findViewById<AppCompatImageView>(R.id.image)
+ val overflowView = view.findViewById<AppCompatImageView>(R.id.overflowImage)
+
+ assertEquals("label", textView.text)
+ assertNotNull(imageView.drawable)
+ assertNotNull(imageView.imageTintList)
+ assertNotNull(overflowView.imageTintList)
+ }
+
+ @Test
+ fun `menu item ImageText with no iconTintColorResource must not have an imageTintList`() {
+ val subMenuItem = SimpleBrowserMenuItem("test")
+ val subMenuAdapter = BrowserMenuAdapter(testContext, listOf(subMenuItem))
+ val subMenu = BrowserMenu(subMenuAdapter)
+ val parentMenuItem = ParentBrowserMenuItem(
+ label = "label",
+ imageResource = android.R.drawable.ic_menu_report_image,
+ subMenu = subMenu,
+ )
+ val view = LayoutInflater.from(testContext).inflate(parentMenuItem.getLayoutResource(), null)
+ val nestedMenuAdapter = BrowserMenuAdapter(testContext, listOf(parentMenuItem))
+ val nestedMenu = BrowserMenu(nestedMenuAdapter)
+
+ parentMenuItem.bind(nestedMenu, view)
+ val imageView = view.findViewById<AppCompatImageView>(R.id.image)
+
+ assertNull(imageView.imageTintList)
+ }
+
+ @Test
+ fun `onBackPressed after sub menu is shown will dismiss the sub menu`() {
+ val backPressMenuItem = BackPressMenuItem(
+ contentDescription = "Navigate up",
+ label = "back",
+ imageResource = iconsR.drawable.mozac_ic_back_24,
+ )
+ val backPressView = LayoutInflater.from(testContext).inflate(backPressMenuItem.getLayoutResource(), null)
+ val subMenuItem = SimpleBrowserMenuItem("test")
+ val subMenuAdapter = BrowserMenuAdapter(testContext, listOf(backPressMenuItem, subMenuItem))
+ val subMenu = BrowserMenu(subMenuAdapter)
+ backPressMenuItem.bind(subMenu, backPressView)
+
+ val parentMenuItem = ParentBrowserMenuItem(
+ label = "label",
+ imageResource = android.R.drawable.ic_menu_report_image,
+ subMenu = subMenu,
+ )
+ val view = LayoutInflater.from(testContext).inflate(parentMenuItem.getLayoutResource(), null)
+ val nestedMenuAdapter = BrowserMenuAdapter(testContext, listOf(parentMenuItem))
+ val nestedMenu = BrowserMenu(nestedMenuAdapter)
+ parentMenuItem.bind(nestedMenu, view)
+
+ nestedMenu.show(view)
+ view.performClick()
+ assertTrue(subMenu.isShown)
+ assertFalse(nestedMenu.isShown)
+
+ backPressView.performClick()
+ assertFalse(subMenu.isShown)
+ assertTrue(nestedMenu.isShown)
+ }
+
+ @Test
+ fun `menu item image text item can be converted to candidate`() {
+ val backPressMenuItem = BackPressMenuItem(
+ contentDescription = "Navigate up",
+ label = "back",
+ imageResource = iconsR.drawable.mozac_ic_back_24,
+ )
+ val subMenuItem = SimpleBrowserMenuItem("test")
+ val subMenuAdapter = BrowserMenuAdapter(testContext, listOf(backPressMenuItem, subMenuItem))
+ val subMenu = BrowserMenu(subMenuAdapter)
+ val menuItem = ParentBrowserMenuItem(
+ "label",
+ android.R.drawable.ic_menu_report_image,
+ subMenu = subMenu,
+ )
+
+ val candidate = menuItem.asCandidate(testContext)
+
+ assertEquals(menuItem.hashCode(), candidate.id)
+ assertEquals("label", candidate.text)
+ assertEquals(2, candidate.subMenuItems!!.size)
+
+ val backCandidate = candidate.subMenuItems!![0] as NestedMenuCandidate
+ val testCandidate = candidate.subMenuItems!![1] as DecorativeTextMenuCandidate
+ assertEquals(
+ NestedMenuCandidate(
+ id = backPressMenuItem.hashCode(),
+ text = "back",
+ start = DrawableMenuIcon(null),
+ subMenuItems = null,
+ ),
+ backCandidate.run {
+ copy(start = (start as? DrawableMenuIcon)?.copy(drawable = null))
+ },
+ )
+ assertEquals(
+ DecorativeTextMenuCandidate("test"),
+ testCandidate,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/SimpleBrowserMenuHighlightableItemTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/SimpleBrowserMenuHighlightableItemTest.kt
new file mode 100644
index 0000000000..7a2fe33d20
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/SimpleBrowserMenuHighlightableItemTest.kt
@@ -0,0 +1,152 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextStyle
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class SimpleBrowserMenuHighlightableItemTest {
+
+ @Test
+ fun `GIVEN a simple item, WHEN we try to inflate it in the menu, THEN the item should be inflated`() {
+ var onClickWasPress = false
+ val item = SimpleBrowserMenuHighlightableItem(
+ "label",
+ textColorResource = android.R.color.black,
+ backgroundTint = Color.RED,
+ ) {
+ onClickWasPress = true
+ }
+
+ val view = inflate(item)
+ view.performClick()
+
+ assertTrue(onClickWasPress)
+ }
+
+ @Test
+ fun `GIVEN a simple item, WHEN we inflate it, THEN it should be visible by default`() {
+ val listener = {}
+ val item = SimpleBrowserMenuHighlightableItem(
+ label = "label",
+ textColorResource = android.R.color.black,
+ backgroundTint = Color.RED,
+ listener = listener,
+ )
+
+ assertTrue(item.visible())
+ }
+
+ @Test
+ fun `GIVEN a simple item, WHEN clicking bound view, THEN callback is invoked and the menu dismissed`() {
+ var callbackInvoked = false
+
+ val item = SimpleBrowserMenuHighlightableItem(
+ label = "label",
+ textColorResource = android.R.color.black,
+ backgroundTint = Color.RED,
+ ) {
+ callbackInvoked = true
+ }
+
+ val menu = mock(BrowserMenu::class.java)
+ val view = TextView(testContext)
+
+ item.bind(menu, view)
+
+ view.performClick()
+
+ assertTrue(callbackInvoked)
+ verify(menu).dismiss()
+ }
+
+ @Test
+ fun `GIVEN a simple item, WHEN we inflate it, THEN it should have the right properties`() {
+ val listener = {}
+ val item = SimpleBrowserMenuHighlightableItem(
+ label = "label",
+ textSize = 10f,
+ textColorResource = android.R.color.black,
+ backgroundTint = Color.RED,
+ isHighlighted = { false },
+ listener = listener,
+ )
+
+ var view = inflate(item)
+ var textView = view.findViewById<TextView>(R.id.simple_text)
+
+ assertEquals(textView.text, "label")
+ assertEquals(textView.textSize, 10f)
+ assertEquals(textView.currentTextColor, testContext.getColor(android.R.color.black))
+
+ val highlightedItem = SimpleBrowserMenuHighlightableItem(
+ label = "label",
+ textSize = 10f,
+ textColorResource = android.R.color.black,
+ backgroundTint = Color.RED,
+ isHighlighted = { true },
+ listener = listener,
+ )
+
+ view = inflate(highlightedItem)
+ textView = view.findViewById(R.id.simple_text)
+
+ assertEquals(textView.text, "label")
+ assertEquals(textView.textSize, 10f)
+ assertEquals(textView.currentTextColor, testContext.getColor(android.R.color.black))
+ assertEquals((textView.background as ColorDrawable).color, Color.RED)
+ }
+
+ @Test
+ fun `GIVEN a simple item, WHEN it converts to candidate, THEN it should have the correct properties`() {
+ val listener = {}
+ val shouldHighlight = false
+ val item = SimpleBrowserMenuHighlightableItem(
+ label = "label",
+ textColorResource = android.R.color.black,
+ backgroundTint = Color.RED,
+ isHighlighted = { shouldHighlight },
+ listener = listener,
+ )
+
+ assertEquals(
+ TextMenuCandidate(
+ "label",
+ textStyle = TextStyle(
+ color = ContextCompat.getColor(testContext, android.R.color.black),
+ ),
+ containerStyle = ContainerStyle(true),
+ onClick = listener,
+ ),
+ item.asCandidate(testContext),
+ )
+ }
+
+ private fun inflate(item: SimpleBrowserMenuHighlightableItem): View {
+ val view = LayoutInflater.from(testContext).inflate(item.getLayoutResource(), null)
+ val mockMenu = Mockito.mock(BrowserMenu::class.java)
+ item.bind(mockMenu, view)
+ return view
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/SimpleBrowserMenuItemTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/SimpleBrowserMenuItemTest.kt
new file mode 100644
index 0000000000..bd5da09b52
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/SimpleBrowserMenuItemTest.kt
@@ -0,0 +1,122 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.TextView
+import androidx.core.content.ContextCompat.getColor
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import mozilla.components.concept.menu.candidate.DecorativeTextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextStyle
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class SimpleBrowserMenuItemTest {
+
+ @Test
+ fun `simple menu items are always visible by default`() {
+ val item = SimpleBrowserMenuItem("Hello") {
+ // do nothing
+ }
+
+ assertTrue(item.visible())
+ }
+
+ @Test
+ fun `layout resource can be inflated`() {
+ val item = SimpleBrowserMenuItem("Hello") {
+ // do nothing
+ }
+
+ val view = LayoutInflater.from(testContext)
+ .inflate(item.getLayoutResource(), null)
+
+ assertNotNull(view)
+ }
+
+ @Test
+ fun `clicking bound view will invoke callback and dismiss menu`() {
+ var callbackInvoked = false
+
+ val item = SimpleBrowserMenuItem("Hello") {
+ callbackInvoked = true
+ }
+
+ val menu = mock(BrowserMenu::class.java)
+ val view = TextView(testContext)
+
+ item.bind(menu, view)
+
+ view.performClick()
+
+ assertTrue(callbackInvoked)
+ verify(menu).dismiss()
+ }
+
+ @Test
+ fun `simple browser menu item should have the right text, textSize, and textColorResource`() {
+ val item = SimpleBrowserMenuItem(
+ "Powered by Mozilla",
+ 10f,
+ android.R.color.holo_green_dark,
+ )
+
+ val view = inflate(item)
+
+ val textView = view.findViewById<TextView>(R.id.simple_text)
+ assertEquals(textView.text, "Powered by Mozilla")
+ assertEquals(textView.textSize, 10f)
+ assertEquals(textView.currentTextColor, testContext.getColor(android.R.color.holo_green_dark))
+ }
+
+ @Test
+ fun `simple menu item can be converted to candidate`() {
+ val listener = {}
+
+ assertEquals(
+ TextMenuCandidate(
+ "Hello",
+ onClick = listener,
+ ),
+ SimpleBrowserMenuItem(
+ "Hello",
+ listener = listener,
+ ).asCandidate(testContext),
+ )
+
+ assertEquals(
+ DecorativeTextMenuCandidate(
+ "Powered by Mozilla",
+ textStyle = TextStyle(
+ size = 10f,
+ color = getColor(testContext, android.R.color.holo_green_dark),
+ ),
+ ),
+ SimpleBrowserMenuItem(
+ "Powered by Mozilla",
+ 10f,
+ android.R.color.holo_green_dark,
+ ).asCandidate(testContext),
+ )
+ }
+
+ private fun inflate(item: SimpleBrowserMenuItem): View {
+ val view = LayoutInflater.from(testContext).inflate(item.getLayoutResource(), null)
+ val mockMenu = mock(BrowserMenu::class.java)
+ item.bind(mockMenu, view)
+ return view
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/TwoStateBrowserMenuImageTextTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/TwoStateBrowserMenuImageTextTest.kt
new file mode 100644
index 0000000000..c8030091cc
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/TwoStateBrowserMenuImageTextTest.kt
@@ -0,0 +1,87 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.TextView
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.R
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+
+@RunWith(AndroidJUnit4::class)
+class TwoStateBrowserMenuImageTextTest {
+
+ private val context: Context get() = ApplicationProvider.getApplicationContext()
+ private lateinit var menuItemPrimary: TwoStateBrowserMenuImageText
+ private lateinit var menuItemSecondary: TwoStateBrowserMenuImageText
+
+ private var primaryPressed = false
+ private var secondaryPressed = false
+
+ private val primaryLabel: String = "primaryLabel"
+ private val secondaryLabel: String = "secondaryLabel"
+
+ @Before
+ fun setup() {
+ menuItemPrimary = TwoStateBrowserMenuImageText(
+ primaryLabel = primaryLabel,
+ secondaryLabel = secondaryLabel,
+ primaryStateIconResource = android.R.drawable.ic_delete,
+ secondaryStateIconResource = android.R.drawable.ic_input_add,
+ isInPrimaryState = { true },
+ primaryStateAction = { primaryPressed = true },
+ )
+
+ menuItemSecondary = TwoStateBrowserMenuImageText(
+ primaryLabel = primaryLabel,
+ secondaryLabel = secondaryLabel,
+ primaryStateIconResource = android.R.drawable.ic_delete,
+ secondaryStateIconResource = android.R.drawable.ic_input_add,
+ isInPrimaryState = { false },
+ isInSecondaryState = { true },
+ secondaryStateAction = { secondaryPressed = true },
+ )
+ }
+
+ @Test
+ fun `browser menu should be inflated`() {
+ val view = inflate(menuItemPrimary)
+ view.performClick()
+ assertTrue(primaryPressed)
+
+ val secondView = inflate(menuItemSecondary)
+ secondView.performClick()
+ assertTrue(secondaryPressed)
+ }
+
+ @Test
+ fun `browser menu should have the right text`() {
+ val view = inflate(menuItemPrimary)
+ val textView = view.findViewById<TextView>(R.id.text)
+
+ assertEquals(textView.text, primaryLabel)
+
+ val secondView = inflate(menuItemSecondary)
+ val secondTextView = secondView.findViewById<TextView>(R.id.text)
+
+ assertEquals(secondTextView.text, secondaryLabel)
+ }
+
+ private fun inflate(item: BrowserMenuImageText): View {
+ val view = LayoutInflater.from(context).inflate(item.getLayoutResource(), null)
+ val mockMenu = mock(BrowserMenu::class.java)
+ item.bind(mockMenu, view)
+ return view
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItemTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItemTest.kt
new file mode 100644
index 0000000000..60419f4bfb
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/item/WebExtensionBrowserMenuItemTest.kt
@@ -0,0 +1,355 @@
+/* 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 mozilla.components.browser.menu.item
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.menu.R
+import mozilla.components.browser.menu.WebExtensionBrowserMenu
+import mozilla.components.concept.engine.webextension.Action
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.notNull
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import androidx.appcompat.R as appcompatR
+
+@RunWith(AndroidJUnit4::class)
+class WebExtensionBrowserMenuItemTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val dispatcher = coroutinesTestRule.testDispatcher
+
+ @Test
+ fun `web extension menu item is visible by default`() {
+ val webExtMenuItem = WebExtensionBrowserMenuItem(mock(), mock())
+
+ assertTrue(webExtMenuItem.visible())
+ }
+
+ @Test
+ fun `layout resource can be inflated`() {
+ val webExtMenuItem = WebExtensionBrowserMenuItem(mock(), mock())
+
+ val view = LayoutInflater.from(testContext)
+ .inflate(webExtMenuItem.getLayoutResource(), null)
+
+ assertNotNull(view)
+ }
+
+ @Test
+ fun `view is disabled if browser action is disabled`() {
+ val icon: Bitmap = mock()
+ val imageView: ImageView = mock()
+ val badgeView: TextView = mock()
+ val labelView: TextView = mock()
+ val container = View(testContext)
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(badgeView)
+ whenever(view.findViewById<TextView>(R.id.action_label)).thenReturn(labelView)
+ whenever(view.findViewById<View>(R.id.container)).thenReturn(container)
+ whenever(view.context).thenReturn(mock())
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = { icon },
+ enabled = false,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val action = WebExtensionBrowserMenuItem(browserAction, {})
+ action.bind(mock(), view)
+ dispatcher.scheduler.advanceUntilIdle()
+
+ assertFalse(view.isEnabled)
+ }
+
+ @Test
+ fun bind() {
+ val icon: Bitmap = mock()
+ val imageView: ImageView = mock()
+ val badgeView: TextView = mock()
+ val background: Drawable = mock()
+ val labelView: TextView = mock()
+ val container = View(testContext)
+ val view: View = mock()
+
+ whenever(badgeView.background).thenReturn(background)
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(badgeView)
+ whenever(view.findViewById<TextView>(R.id.action_label)).thenReturn(labelView)
+ whenever(view.findViewById<View>(R.id.container)).thenReturn(container)
+ whenever(view.context).thenReturn(testContext)
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = { icon },
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val action = WebExtensionBrowserMenuItem(browserAction, {})
+ action.bind(mock(), view)
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val iconCaptor = argumentCaptor<BitmapDrawable>()
+ verify(imageView).setImageDrawable(iconCaptor.capture())
+ assertEquals(icon, iconCaptor.value.bitmap)
+
+ verify(imageView).contentDescription = "title"
+ verify(labelView).text = "title"
+ verify(badgeView).text = "badgeText"
+ verify(badgeView).setTextColor(Color.WHITE)
+ verify(background).setTint(Color.BLUE)
+ }
+
+ @Test
+ fun `badge text view is invisible if action badge text is empty`() {
+ val icon: Bitmap = mock()
+ val imageView: ImageView = mock()
+ val badgeView: TextView = spy(TextView(testContext))
+ val labelView: TextView = mock()
+ val container = View(testContext)
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(badgeView)
+ whenever(view.findViewById<TextView>(R.id.action_label)).thenReturn(labelView)
+ whenever(view.findViewById<View>(R.id.container)).thenReturn(container)
+ whenever(view.context).thenReturn(testContext)
+
+ val badgeText = ""
+ val browserAction = Action(
+ title = "title",
+ loadIcon = { icon },
+ enabled = true,
+ badgeText = badgeText,
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val action = WebExtensionBrowserMenuItem(browserAction, {})
+ action.bind(mock(), view)
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(badgeView).setBadgeText(badgeText)
+ assertEquals(View.INVISIBLE, badgeView.visibility)
+ }
+
+ @Test
+ fun fallbackToDefaultIcon() {
+ val imageView: ImageView = mock()
+ val badgeView: TextView = mock()
+ val labelView: TextView = mock()
+ val container = View(testContext)
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(badgeView)
+ whenever(view.findViewById<TextView>(R.id.action_label)).thenReturn(labelView)
+ whenever(view.findViewById<View>(R.id.container)).thenReturn(container)
+ whenever(view.context).thenReturn(testContext)
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = { throw IllegalArgumentException() },
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val action = WebExtensionBrowserMenuItem(browserAction, {})
+ action.bind(mock(), view)
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(imageView).setImageDrawable(notNull())
+ }
+
+ @Test
+ fun `clicking item view invokes callback and dismisses menu`() {
+ var callbackInvoked = false
+
+ val icon: Bitmap = mock()
+ val imageView: ImageView = mock()
+ val badgeView: TextView = mock()
+ val labelView = TextView(testContext)
+ val container = View(testContext)
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(badgeView)
+ whenever(view.findViewById<TextView>(R.id.action_label)).thenReturn(labelView)
+ whenever(view.findViewById<View>(R.id.container)).thenReturn(container)
+ whenever(view.context).thenReturn(mock())
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = { icon },
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val item = WebExtensionBrowserMenuItem(browserAction, { callbackInvoked = true })
+
+ val menu: WebExtensionBrowserMenu = mock()
+
+ item.bind(menu, view)
+ dispatcher.scheduler.advanceUntilIdle()
+
+ container.performClick()
+
+ assertTrue(callbackInvoked)
+ verify(menu).dismiss()
+ }
+
+ @Test
+ fun `labelView and badgeView redraws when invalidate is triggered`() {
+ val icon: Bitmap = mock()
+ val imageView: ImageView = mock()
+ val badgeView: TextView = mock()
+ val labelView: TextView = mock()
+ val container = View(testContext)
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(badgeView)
+ whenever(view.findViewById<TextView>(R.id.action_label)).thenReturn(labelView)
+ whenever(view.findViewById<View>(R.id.container)).thenReturn(container)
+ whenever(view.context).thenReturn(mock())
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = { icon },
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val item = WebExtensionBrowserMenuItem(browserAction, {})
+
+ val menu: WebExtensionBrowserMenu = mock()
+
+ item.bind(menu, view)
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(labelView).text = "title"
+ verify(badgeView).text = "badgeText"
+
+ val browserActionOverride = Action(
+ title = "override",
+ loadIcon = { icon },
+ enabled = true,
+ badgeText = "overrideBadge",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ item.action = browserActionOverride
+ item.invalidate(view)
+
+ verify(labelView).text = "override"
+ verify(badgeView).text = "overrideBadge"
+ verify(labelView).invalidate()
+ verify(badgeView).invalidate()
+ }
+
+ @Test
+ fun `GIVEN setIcon was called, WHEN bind is called, icon setup uses the tint set`() = runTest {
+ val webExtMenuItem = spy(WebExtensionBrowserMenuItem(mock(), mock()))
+ val testIconTintColorResource = appcompatR.color.accent_material_dark
+ val menu: WebExtensionBrowserMenu = mock()
+ val imageView: ImageView = mock()
+ val badgeView: TextView = mock()
+ val labelView: TextView = mock()
+ val container = View(testContext)
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(badgeView)
+ whenever(view.findViewById<TextView>(R.id.action_label)).thenReturn(labelView)
+ whenever(view.findViewById<View>(R.id.container)).thenReturn(container)
+ whenever(view.context).thenReturn(mock())
+ whenever(imageView.measuredHeight).thenReturn(2)
+
+ webExtMenuItem.setIconTint(testIconTintColorResource)
+ webExtMenuItem.bind(menu, view)
+
+ val viewCaptor = argumentCaptor<View>()
+ val imageViewCaptor = argumentCaptor<ImageView>()
+ val tintCaptor = argumentCaptor<Int>()
+
+ verify(webExtMenuItem).setupIcon(viewCaptor.capture(), imageViewCaptor.capture(), tintCaptor.capture())
+
+ assertEquals(testIconTintColorResource, tintCaptor.value)
+ assertEquals(view, viewCaptor.value)
+ assertEquals(imageView, imageViewCaptor.value)
+
+ assertEquals(testIconTintColorResource, webExtMenuItem.iconTintColorResource)
+ }
+
+ @Test
+ fun `WHEN invalidate is called THEN setupIcon is called`() = runTest {
+ val webExtMenuItem = spy(WebExtensionBrowserMenuItem(mock(), mock()))
+ val imageView: ImageView = mock()
+ val badgeView: TextView = mock()
+ val labelView: TextView = mock()
+ val container = View(testContext)
+ val testIconTintColorResource = appcompatR.color.accent_material_dark
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(badgeView)
+ whenever(view.findViewById<TextView>(R.id.action_label)).thenReturn(labelView)
+ whenever(view.findViewById<View>(R.id.container)).thenReturn(container)
+ whenever(view.context).thenReturn(mock())
+ whenever(imageView.measuredHeight).thenReturn(2)
+
+ webExtMenuItem.setIconTint(testIconTintColorResource)
+ webExtMenuItem.invalidate(view)
+
+ val viewCaptor = argumentCaptor<View>()
+ val imageViewCaptor = argumentCaptor<ImageView>()
+ val tintCaptor = argumentCaptor<Int>()
+
+ verify(webExtMenuItem).setupIcon(
+ viewCaptor.capture(),
+ imageViewCaptor.capture(),
+ tintCaptor.capture(),
+ )
+
+ assertEquals(view, viewCaptor.value)
+ assertEquals(imageView, imageViewCaptor.value)
+ assertEquals(testIconTintColorResource, webExtMenuItem.iconTintColorResource)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/DynamicWidthRecyclerViewTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/DynamicWidthRecyclerViewTest.kt
new file mode 100644
index 0000000000..48d8cf9ee3
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/DynamicWidthRecyclerViewTest.kt
@@ -0,0 +1,226 @@
+/* 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 mozilla.components.browser.menu.view
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.R
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.robolectric.Robolectric
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class DynamicWidthRecyclerViewTest {
+
+ @Test
+ fun `minWidth and maxWidth should be initialized from xml attributes`() {
+ val dynamicRecyclerView = buildRecyclerView(100)
+ dynamicRecyclerView.minWidth = 123
+ dynamicRecyclerView.maxWidth = 456
+
+ assertEquals(123, dynamicRecyclerView.minWidth)
+ assertEquals(456, dynamicRecyclerView.maxWidth)
+ }
+
+ @Test
+ fun `If minWidth and maxWidth are not provided view should use layout_width`() {
+ val dynamicRecyclerView = buildRecyclerView(100)
+
+ dynamicRecyclerView.measure(100, 100)
+
+ // View should not try calculate/reconcile new dimensions, it should just call super.onMeasure(..)
+ verify(dynamicRecyclerView).callParentOnMeasure(100, 100)
+ verify(dynamicRecyclerView, never()).setReconciledDimensions(anyInt(), anyInt())
+ }
+
+ @Test
+ fun `If only minWidth is provided view should use layout_width`() {
+ val dynamicRecyclerView = buildRecyclerView(100)
+ dynamicRecyclerView.minWidth = 50
+
+ dynamicRecyclerView.measure(100, 100)
+
+ // View should not try calculate/reconcile new dimensions, it should just call super.onMeasure(..)
+ verify(dynamicRecyclerView).callParentOnMeasure(100, 100)
+ verify(dynamicRecyclerView, never()).setReconciledDimensions(anyInt(), anyInt())
+ }
+
+ @Test
+ fun `If only maxWidth is provided view should use layout_width`() {
+ val dynamicRecyclerView = buildRecyclerView(100)
+ dynamicRecyclerView.maxWidth = 300
+
+ dynamicRecyclerView.measure(100, 100)
+
+ // View should not try calculate/reconcile new dimensions, it should just call super.onMeasure(..)
+ verify(dynamicRecyclerView).callParentOnMeasure(100, 100)
+ verify(dynamicRecyclerView, never()).setReconciledDimensions(anyInt(), anyInt())
+ }
+
+ @Test
+ fun `Should only allow for dynamic width if minWidth has a positive value`() {
+ val dynamicRecyclerView = buildRecyclerView(100)
+ dynamicRecyclerView.minWidth = -1
+ dynamicRecyclerView.maxWidth = 100
+
+ dynamicRecyclerView.measure(1, 2)
+
+ // If minWidth has a negative value we should only just call super.onMeasure(..)
+ verify(dynamicRecyclerView).callParentOnMeasure(1, 2)
+ verify(dynamicRecyclerView, never()).setReconciledDimensions(anyInt(), anyInt())
+ }
+
+ @Test
+ fun `Should only allow for dynamic width if minWidth is smaller than maxWidth`() {
+ val dynamicRecyclerView = buildRecyclerView(100)
+ dynamicRecyclerView.minWidth = 100
+ dynamicRecyclerView.maxWidth = 100
+
+ dynamicRecyclerView.measure(1, 2)
+
+ // If minWidth is >= to maxWidth we should only just call super.onMeasure(..)
+ verify(dynamicRecyclerView).callParentOnMeasure(1, 2)
+ verify(dynamicRecyclerView, never()).setReconciledDimensions(anyInt(), anyInt())
+ }
+
+ @Test
+ fun `To allow for dynamic width children can expand entirely between minWidth and maxWidth`() {
+ val dynamicRecyclerView = buildRecyclerView(100)
+ dynamicRecyclerView.minWidth = 50
+ dynamicRecyclerView.maxWidth = 100
+
+ dynamicRecyclerView.measure(10, 10)
+
+ // To allow for children to be as wide as they want widthSpec should be 0
+ verify(dynamicRecyclerView).callParentOnMeasure(0, 10)
+ // Robolectric doesn't do any kind of measuring and always returns 0 for View measurements.
+ verify(dynamicRecyclerView).setReconciledDimensions(0, 0)
+ }
+
+ @Test
+ @Config(qualifiers = "w333dp")
+ fun `getScreenWidth() should return display's width in pixels`() {
+ val dynamicRecyclerView = DynamicWidthRecyclerView(testContext, null)
+
+ assertEquals(333, dynamicRecyclerView.getScreenWidth())
+ }
+
+ @Test
+ fun `setReconciledDimensions() must set material minimum width even if childs are smaller`() {
+ val childrenWidth = 20
+ val materialMinWidth = testContext.resources.getDimensionPixelSize(R.dimen.mozac_browser_menu_material_min_item_width)
+ // Layout width is *2 to allow bigger sizes. minWidth is /2 to verify the material min width is used.
+ val dynamicRecyclerView = buildRecyclerView(materialMinWidth * 2)
+ dynamicRecyclerView.minWidth = materialMinWidth / 2
+ dynamicRecyclerView.maxWidth = 500
+
+ dynamicRecyclerView.setReconciledDimensions(childrenWidth, 500)
+
+ verify(dynamicRecyclerView).callSetMeasuredDimension(materialMinWidth, 500)
+ }
+
+ @Test
+ fun `setReconciledDimensions() must set minWidth even if children width is smaller`() {
+ val childrenWidth = 20
+ // minWidth set in xml. Ensure it is bigger than the default.
+ val minWidth = testContext.resources.getDimensionPixelSize(R.dimen.mozac_browser_menu_material_min_item_width) + 10
+ val dynamicRecyclerView = buildRecyclerView(minWidth * 2)
+ dynamicRecyclerView.minWidth = minWidth
+ dynamicRecyclerView.maxWidth = 500
+
+ dynamicRecyclerView.setReconciledDimensions(childrenWidth, 500)
+
+ verify(dynamicRecyclerView).callSetMeasuredDimension(minWidth, 500)
+ }
+
+ @Test
+ fun `setReconciledDimensions() will set children width if it is bigger than minWidth and smaller than maxWidth`() {
+ val materialMinWidth = testContext.resources.getDimensionPixelSize(R.dimen.mozac_browser_menu_material_min_item_width)
+ val childrenWidth = materialMinWidth + 10
+ // Layout width is *2 to allow bigger sizes. minWidth is /2 to verify the material min width is used.
+ val dynamicRecyclerView = buildRecyclerView(materialMinWidth * 2)
+ dynamicRecyclerView.minWidth = materialMinWidth
+ dynamicRecyclerView.maxWidth = 500
+
+ dynamicRecyclerView.setReconciledDimensions(childrenWidth, 500)
+
+ verify(dynamicRecyclerView).callSetMeasuredDimension(childrenWidth, 500)
+ }
+
+ @Test
+ @Config(qualifiers = "w500dp")
+ @Suppress("UnnecessaryVariable")
+ fun `setReconciledDimensions() must set maxWidth when children width is bigger`() {
+ val materialMaxWidth = 500 - testContext.resources.getDimensionPixelSize(R.dimen.mozac_browser_menu_material_min_tap_area)
+ val childrenWidth = materialMaxWidth
+ val maxWidth = materialMaxWidth - 10
+ val dynamicRecyclerView = buildRecyclerView(1000)
+ dynamicRecyclerView.minWidth = 100
+ dynamicRecyclerView.maxWidth = maxWidth
+
+ dynamicRecyclerView.setReconciledDimensions(childrenWidth, 100)
+
+ verify(dynamicRecyclerView).callSetMeasuredDimension(maxWidth, 100)
+ }
+
+ @Test
+ @Config(qualifiers = "w500dp")
+ fun `setReconciledDimensions must set material maximum width when children width is bigger`() {
+ val materialMaxWidth = 500 - testContext.resources.getDimensionPixelSize(R.dimen.mozac_browser_menu_material_min_tap_area)
+ val maxWidth = 500
+ val childrenWidth = maxWidth + 10
+ val dynamicRecyclerView = buildRecyclerView(1000)
+ dynamicRecyclerView.minWidth = 100
+ dynamicRecyclerView.maxWidth = maxWidth
+
+ dynamicRecyclerView.setReconciledDimensions(childrenWidth, 100)
+
+ verify(dynamicRecyclerView).callSetMeasuredDimension(materialMaxWidth, 100)
+ }
+
+ @Test
+ fun `maxWidthOfAllChildren can only be initialized once with a positive value`() {
+ val dynamicRecyclerView = DynamicWidthRecyclerView(testContext)
+
+ assertEquals(0, dynamicRecyclerView.maxWidthOfAllChildren)
+
+ dynamicRecyclerView.maxWidthOfAllChildren = 42
+ assertEquals(42, dynamicRecyclerView.maxWidthOfAllChildren)
+
+ dynamicRecyclerView.maxWidthOfAllChildren = 24
+ assertEquals(42, dynamicRecyclerView.maxWidthOfAllChildren)
+ }
+
+ @Test
+ fun `onMeasure will call setReconciledDimensions with maxWidthOfAllChildren`() {
+ val dynamicRecyclerView = spy(DynamicWidthRecyclerView(testContext))
+ doReturn(100).`when`(dynamicRecyclerView).measuredHeight
+ doReturn(100).`when`(dynamicRecyclerView).measuredWidth
+ doReturn(100).`when`(dynamicRecyclerView).height
+ dynamicRecyclerView.maxWidthOfAllChildren = 42
+ dynamicRecyclerView.minWidth = 10
+ dynamicRecyclerView.maxWidth = Int.MAX_VALUE
+
+ dynamicRecyclerView.measure(0, 0)
+
+ verify(dynamicRecyclerView).setReconciledDimensions(42, 100)
+ }
+
+ private fun buildRecyclerView(layoutWidth: Int): DynamicWidthRecyclerView {
+ val customAttributeSet = Robolectric.buildAttributeSet().apply {
+ // android.R.attr.layout_width needs to always be set
+ addAttribute(android.R.attr.layout_width, "${layoutWidth}dp")
+ }.build()
+
+ return spy(DynamicWidthRecyclerView(testContext, customAttributeSet))
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/ExpandableLayoutTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/ExpandableLayoutTest.kt
new file mode 100644
index 0000000000..4a5dfa1bd1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/ExpandableLayoutTest.kt
@@ -0,0 +1,822 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.animation.ValueAnimator
+import android.graphics.Rect
+import android.view.MotionEvent
+import android.view.MotionEvent.ACTION_CANCEL
+import android.view.MotionEvent.ACTION_DOWN
+import android.view.MotionEvent.ACTION_MOVE
+import android.view.MotionEvent.ACTION_UP
+import android.view.View
+import android.view.ViewConfiguration
+import android.view.ViewGroup
+import android.view.animation.AccelerateDecelerateInterpolator
+import android.widget.FrameLayout
+import androidx.core.view.marginBottom
+import androidx.core.view.marginLeft
+import androidx.core.view.marginRight
+import androidx.core.view.marginTop
+import androidx.core.view.updateLayoutParams
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.any
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class ExpandableLayoutTest {
+ @Test
+ fun `GIVEN ExpandableLayout WHEN wrapContentInExpandableView is called THEN it should properly setup a new ExpandableLayout`() {
+ val wrappedView = FrameLayout(testContext)
+ val blankTouchListener: (() -> Unit) = mock()
+ wrappedView.layoutParams = ViewGroup.MarginLayoutParams(11, 12).apply {
+ setMargins(13, 14, 15, 16)
+ }
+
+ val result = ExpandableLayout.wrapContentInExpandableView(
+ wrappedView,
+ 42,
+ 33,
+ blankTouchListener,
+ )
+
+ assertEquals(FrameLayout.LayoutParams.WRAP_CONTENT, result.wrappedView.layoutParams.height)
+ assertEquals(FrameLayout.LayoutParams.WRAP_CONTENT, result.wrappedView.layoutParams.width)
+ assertEquals(13, result.wrappedView.marginLeft)
+ assertEquals(14, result.wrappedView.marginTop)
+ assertEquals(15, result.wrappedView.marginRight)
+ assertEquals(16, result.wrappedView.marginBottom)
+ assertEquals(1, result.childCount)
+ assertSame(wrappedView, result.wrappedView)
+ assertSame(blankTouchListener, result.blankTouchListener)
+
+ // Also test the default configuration of a newly built ExpandableLayout.
+ assertEquals(42, result.lastVisibleItemIndexWhenCollapsed)
+ assertEquals(33, result.stickyItemIndex)
+ assertEquals(ExpandableLayout.NOT_CALCULATED_DEFAULT_HEIGHT, result.collapsedHeight)
+ assertEquals(ExpandableLayout.NOT_CALCULATED_DEFAULT_HEIGHT, result.expandedHeight)
+ assertEquals(ExpandableLayout.NOT_CALCULATED_DEFAULT_HEIGHT, result.parentHeight)
+ assertTrue(result.isCollapsed)
+ assertFalse(result.isExpandInProgress)
+ assertEquals(ViewConfiguration.get(testContext).scaledTouchSlop.toFloat(), result.touchSlop)
+ assertEquals(ExpandableLayout.NOT_CALCULATED_Y_TOUCH_COORD, result.initialYCoord)
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout WHEN onMeasure is called THEN it delegates the parent for measuring`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ expandableLayout.isCollapsed = false
+
+ expandableLayout.measure(123, 123)
+
+ verify(expandableLayout).callParentOnMeasure(123, 123)
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout in the collapsed state and the height values available WHEN onMeasure is called THEN it will trigger collapse()`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ expandableLayout.isCollapsed = true
+ doReturn(100).`when`(expandableLayout).getOrCalculateCollapsedHeight()
+ doReturn(100).`when`(expandableLayout).getOrCalculateExpandedHeight(anyInt())
+
+ expandableLayout.measure(123, 123)
+
+ verify(expandableLayout).collapse()
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout not in the collapsed state but all height values known WHEN onMeasure is called THEN collapse() is not called`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ expandableLayout.isCollapsed = false
+ doReturn(100).`when`(expandableLayout).getOrCalculateCollapsedHeight()
+ doReturn(100).`when`(expandableLayout).getOrCalculateExpandedHeight(anyInt())
+
+ expandableLayout.measure(123, 123)
+
+ verify(expandableLayout, never()).collapse()
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout in the collapsed state but with collapsedHeight unknown WHEN onMeasure is called THEN collapse() is be called`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ expandableLayout.isCollapsed = true
+ doReturn(ExpandableLayout.NOT_CALCULATED_DEFAULT_HEIGHT).`when`(expandableLayout).getOrCalculateCollapsedHeight()
+ doReturn(100).`when`(expandableLayout).getOrCalculateExpandedHeight(anyInt())
+
+ expandableLayout.measure(123, 123)
+
+ verify(expandableLayout, never()).collapse()
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout in the collapsed state but with expandedHeight unknown WHEN onMeasure is called THEN collapse() is not be called`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ expandableLayout.isCollapsed = true
+ doReturn(100).`when`(expandableLayout).getOrCalculateCollapsedHeight()
+ doReturn(ExpandableLayout.NOT_CALCULATED_DEFAULT_HEIGHT).`when`(expandableLayout).getOrCalculateExpandedHeight(anyInt())
+
+ expandableLayout.measure(123, 123)
+
+ verify(expandableLayout, never()).collapse()
+ }
+
+ @Test
+ fun `GIVEN an expanded menu WHEN onInterceptTouchEvent is called for a touch on the menu THEN super is called`() {
+ val blankTouchListener = spy {}
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ blankTouchListener = blankTouchListener,
+ ),
+ )
+ val event: MotionEvent = mock()
+ doReturn(false).`when`(expandableLayout).shouldInterceptTouches()
+ doReturn(true).`when`(expandableLayout).isTouchingTheWrappedView(any())
+
+ expandableLayout.onInterceptTouchEvent(event)
+
+ verify(blankTouchListener, never()).invoke()
+ verify(expandableLayout).callParentOnInterceptTouchEvent(event)
+ }
+
+ @Test
+ fun `GIVEN a menu currently expanding WHEN onInterceptTouchEvent is called for a touch on the menu THEN the touch is swallowed`() {
+ val blankTouchListener = spy {}
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ blankTouchListener = blankTouchListener,
+ ),
+ )
+ val event: MotionEvent = mock()
+ doReturn(false).`when`(expandableLayout).shouldInterceptTouches()
+ doReturn(true).`when`(expandableLayout).isTouchingTheWrappedView(any())
+ expandableLayout.isExpandInProgress = true
+
+ expandableLayout.onInterceptTouchEvent(event)
+
+ verify(blankTouchListener, never()).invoke()
+ verify(expandableLayout, never()).callParentOnInterceptTouchEvent(event)
+ }
+
+ @Test
+ fun `GIVEN an expanded menu WHEN onInterceptTouchEvent is called for a outside the menu THEN super blankTouchListener is invoked`() {
+ val blankTouchListener = spy {}
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ blankTouchListener = blankTouchListener,
+ ),
+ )
+ val event: MotionEvent = mock()
+ doReturn(false).`when`(expandableLayout).shouldInterceptTouches()
+ doReturn(false).`when`(expandableLayout).isTouchingTheWrappedView(any())
+
+ expandableLayout.onInterceptTouchEvent(event)
+
+ verify(blankTouchListener).invoke()
+ verify(expandableLayout, never()).callParentOnInterceptTouchEvent(event)
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout WHEN onInterceptTouchEvent is called for ACTION_CANCEL or ACTION_UP THEN the events are not intercepted`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ val actionCancel = MotionEvent.obtain(0, 0, ACTION_UP, 0f, 0f, 0)
+ val actionUp = MotionEvent.obtain(0, 0, ACTION_CANCEL, 0f, 0f, 0)
+ doReturn(true).`when`(expandableLayout).shouldInterceptTouches()
+
+ assertFalse(expandableLayout.onInterceptTouchEvent(actionCancel))
+ assertFalse(expandableLayout.onInterceptTouchEvent(actionUp))
+
+ verify(expandableLayout, never()).callParentOnInterceptTouchEvent(any())
+ }
+
+ @Test
+ fun `GIVEN the wrappedView is in the expand process WHEN onInterceptTouchEvent is called while for new touches THEN they are intercepted`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ val actionDown = MotionEvent.obtain(0, 0, ACTION_DOWN, 0f, 0f, 0)
+ doReturn(true).`when`(expandableLayout).shouldInterceptTouches()
+ expandableLayout.isExpandInProgress = true
+
+ assertTrue(expandableLayout.onInterceptTouchEvent(actionDown))
+ verify(expandableLayout, never()).callParentOnInterceptTouchEvent(any())
+ }
+
+ @Test
+ fun `GIVEN the wrappedView not in the expand process WHEN onInterceptTouchEvent is called for new touches THEN they are not intercepted`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ val actionDown = MotionEvent.obtain(0, 0, ACTION_DOWN, 0f, 0f, 0)
+ doReturn(true).`when`(expandableLayout).shouldInterceptTouches()
+ expandableLayout.isExpandInProgress = false
+
+ assertFalse(expandableLayout.onInterceptTouchEvent(actionDown))
+ verify(expandableLayout, never()).callParentOnInterceptTouchEvent(any())
+ }
+
+ @Test
+ fun `GIVEN blankTouchListener set WHEN onInterceptTouchEvent is called for a touch that does not intersect wrappedView's bounds THEN blankTouchListener is called`() {
+ var listenerCalled = false
+ val listener = spy { listenerCalled = true }
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ blankTouchListener = listener,
+ ),
+ )
+ doReturn(false).`when`(expandableLayout).isTouchingTheWrappedView(any())
+ val actionDown = MotionEvent.obtain(0, 0, ACTION_DOWN, 0f, 0f, 0)
+
+ expandableLayout.onInterceptTouchEvent(actionDown)
+
+ assertTrue(listenerCalled)
+ }
+
+ @Test
+ fun `GIVEN blankTouchListener set WHEN onInterceptTouchEvent is called for a touch that intersects wrappedView's bounds THEN blankTouchListener is not called`() {
+ var listenerCalled = false
+ val listener = spy { listenerCalled = true }
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ blankTouchListener = listener,
+ ),
+ )
+ doReturn(true).`when`(expandableLayout).isTouchingTheWrappedView(any())
+ val actionDown = MotionEvent.obtain(0, 0, ACTION_DOWN, 0f, 0f, 0)
+
+ expandableLayout.onInterceptTouchEvent(actionDown)
+
+ assertFalse(listenerCalled)
+ }
+
+ @Test
+ fun `GIVEN initialYCoord set WHEN onInterceptTouchEvent is called for ACTION_DOWN THEN initialYCoord will be reset to the new value`() {
+ val expandableLayout = ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { }
+
+ val actionDown1 = MotionEvent.obtain(0, 0, ACTION_DOWN, 0f, 22f, 0)
+ expandableLayout.onInterceptTouchEvent(actionDown1)
+ assertEquals(22f, expandableLayout.initialYCoord)
+
+ val actionDown2 = MotionEvent.obtain(0, 0, ACTION_DOWN, 0f, -33f, 0)
+ expandableLayout.onInterceptTouchEvent(actionDown2)
+ assertEquals(-33f, expandableLayout.initialYCoord)
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout is in the expand process WHEN onInterceptTouchEvent is called for scroll events THEN these events are intercepted`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ val actionDown = MotionEvent.obtain(0, 0, ACTION_MOVE, 0f, 0f, 0)
+ doReturn(false).`when`(expandableLayout).shouldInterceptTouches()
+ doReturn(false).`when`(expandableLayout).isTouchingTheWrappedView(any())
+
+ assertTrue(expandableLayout.onInterceptTouchEvent(actionDown))
+ verify(expandableLayout, never()).expand()
+ verify(expandableLayout, never()).callParentOnInterceptTouchEvent(any())
+ }
+
+ @Test
+ fun `GIVEN the wrappedView is not expanding WHEN onInterceptTouchEvent is called for an event that is not a scroll THEN this event is not intercepted`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ val actionDown = MotionEvent.obtain(0, 0, ACTION_MOVE, 0f, 0f, 0)
+ doReturn(true).`when`(expandableLayout).shouldInterceptTouches()
+ doReturn(false).`when`(expandableLayout).isScrollingUp(any())
+
+ assertFalse(expandableLayout.onInterceptTouchEvent(actionDown))
+ verify(expandableLayout, never()).expand()
+ verify(expandableLayout, never()).callParentOnInterceptTouchEvent(any())
+ }
+
+ @Test
+ fun `GIVEN the wrappedView is not expanding WHEN onInterceptTouchEvent is called for scroll up events THEN the events are intercepted and expand() is called`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ val actionDown = MotionEvent.obtain(0, 0, ACTION_MOVE, 0f, 0f, 0)
+ doReturn(true).`when`(expandableLayout).shouldInterceptTouches()
+ doReturn(true).`when`(expandableLayout).isScrollingUp(any())
+
+ assertTrue(expandableLayout.onInterceptTouchEvent(actionDown))
+ verify(expandableLayout).expand()
+ verify(expandableLayout, never()).callParentOnInterceptTouchEvent(any())
+ }
+
+ @Test
+ fun `GIVEN isTouchForWrappedView() WHEN called with a touch event that is within the wrappedView bounds THEN it returns true`() {
+ val wrappedView = spy(FrameLayout(testContext))
+ doAnswer {
+ val rect = it.arguments[0] as Rect
+ rect.set(0, 0, 100, 100)
+ }.`when`(wrappedView).getHitRect(any())
+ val expandableLayout = ExpandableLayout.wrapContentInExpandableView(
+ wrappedView,
+ 1,
+ ) { }
+ val inBoundsEvent = MotionEvent.obtain(0, 0, ACTION_DOWN, 5f, 5f, 0)
+
+ assertTrue(expandableLayout.isTouchingTheWrappedView(inBoundsEvent))
+ }
+
+ @Test
+ fun `GIVEN isTouchForWrappedView WHEN called with a touch event that is not within the wrappedView bounds THEN it returns false`() {
+ val wrappedView = spy(FrameLayout(testContext))
+ doAnswer {
+ val rect = it.arguments[0] as Rect
+ rect.set(0, 0, 100, 100)
+ }.`when`(wrappedView).getHitRect(any())
+ val expandableLayout = ExpandableLayout.wrapContentInExpandableView(
+ wrappedView,
+ 1,
+ ) { }
+ val outOfBoundsEvent = MotionEvent.obtain(0, 0, ACTION_DOWN, 105f, 105f, 0)
+
+ assertFalse(expandableLayout.isTouchingTheWrappedView(outOfBoundsEvent))
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout in the collapsed state WHEN shouldInterceptTouches is called THEN it returns true`() {
+ val expandableLayout = ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { }
+ expandableLayout.isCollapsed = true
+
+ assertTrue(expandableLayout.shouldInterceptTouches())
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout not collapsed WHEN shouldInterceptTouches is called THEN it returns false`() {
+ val expandableLayout = ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { }
+ expandableLayout.isCollapsed = false
+
+ assertFalse(expandableLayout.shouldInterceptTouches())
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout currently expanding WHEN shouldInterceptTouches is called THEN it returns false`() {
+ val expandableLayout = ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { }
+ expandableLayout.isCollapsed = true
+ expandableLayout.isExpandInProgress = false
+
+ assertTrue(expandableLayout.shouldInterceptTouches())
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout WHEN collapse is called THEN it sets a positive translation and a smaller height for the wrappedView`() {
+ val expandableLayout = ExpandableLayout.wrapContentInExpandableView(
+ spy(FrameLayout(testContext)),
+ 1,
+ ) { }
+ expandableLayout.wrappedView.updateLayoutParams {
+ height = 100
+ }
+ expandableLayout.parentHeight = 200
+ expandableLayout.collapsedHeight = 50
+
+ expandableLayout.collapse()
+
+ // If the available height is 200, with the layout starting at 0,0
+ // to properly "anchor" a 50px height wrappedView we need to translate it 150px.
+ verify(expandableLayout.wrappedView).translationY = 150f
+ assertEquals(50, expandableLayout.wrappedView.layoutParams.height)
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout WHEN getExpandViewAnimator is called THEN it returns a new ValueAnimator`() {
+ val expandableLayout = ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { }
+
+ val result = expandableLayout.getExpandViewAnimator(100)
+
+ assertTrue(result.interpolator is AccelerateDecelerateInterpolator)
+ assertEquals(ExpandableLayout.DEFAULT_DURATION_EXPAND_ANIMATOR, result.duration)
+ }
+
+ @Test
+ fun `GIVEN ExpandableLayout WHEN expand is called THEN it updates the translationY and height to show the wrappedView with expandedHeight`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ val animator = ValueAnimator.ofInt(0, 100)
+ doAnswer {
+ animator
+ }.`when`(expandableLayout).getExpandViewAnimator(anyInt())
+ expandableLayout.expandedHeight = 100
+ expandableLayout.collapsedHeight = 50
+ expandableLayout.wrappedView.translationY = 50f
+
+ expandableLayout.expand()
+ animator.end()
+
+ verify(expandableLayout).getExpandViewAnimator(50)
+ assertEquals(-50f, expandableLayout.wrappedView.translationY)
+ assertEquals(150, expandableLayout.wrappedView.layoutParams.height)
+ assertTrue(System.currentTimeMillis() > 0)
+ }
+
+ @Test
+ fun `GIVEN collapsedHeight if already calculated WHEN getOrCalculateCollapsedHeight is called THEN it returns collapsedHeight`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ expandableLayout.collapsedHeight = 123
+
+ val result = expandableLayout.getOrCalculateCollapsedHeight()
+
+ verify(expandableLayout, never()).calculateCollapsedHeight()
+ assertEquals(123, result)
+ }
+
+ @Test
+ fun `GIVEN collapsedHeight is not already calculated WHEN getOrCalculateCollapsedHeight is called THEN it delegates calculateCollapsedHeight and returns the value from that`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ doReturn(42).`when`(expandableLayout).calculateCollapsedHeight()
+
+ val result = expandableLayout.getOrCalculateCollapsedHeight()
+
+ verify(expandableLayout).calculateCollapsedHeight()
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `GIVEN expandedHeight not calculated WHEN getOrCalculateExpandedHeight is called THEN it sets expandedHeight with the value of measuredHeight`() {
+ val wrappedView = spy(FrameLayout(testContext))
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ wrappedView,
+ 1,
+ ) { },
+ )
+
+ doReturn(100).`when`(wrappedView).measuredHeight
+ assertEquals(100, expandableLayout.getOrCalculateExpandedHeight(0))
+ assertEquals(100, expandableLayout.expandedHeight)
+
+ doReturn(200).`when`(wrappedView).measuredHeight
+ assertEquals(100, expandableLayout.getOrCalculateExpandedHeight(0))
+ assertEquals(100, expandableLayout.expandedHeight)
+ }
+
+ @Test
+ fun `GIVEN parentHeight not already calculated WHEN getOrCalculateExpandedHeight is called THEN it sets parentHeight with the value from the heightSpec size`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+
+ expandableLayout.getOrCalculateExpandedHeight(View.MeasureSpec.makeMeasureSpec(123, View.MeasureSpec.EXACTLY))
+ assertEquals(123, expandableLayout.parentHeight)
+
+ expandableLayout.getOrCalculateExpandedHeight(View.MeasureSpec.makeMeasureSpec(321, View.MeasureSpec.EXACTLY))
+ assertEquals(123, expandableLayout.parentHeight)
+ }
+
+ @Test
+ fun `GIVEN parentHeight not calculated WHEN getOrCalculateExpandedHeight is called with a parent height THEN it sets the expandedHeight as the minimum of between expandedHeight and parent height`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ expandableLayout.expandedHeight = 123
+
+ expandableLayout.getOrCalculateExpandedHeight(View.MeasureSpec.makeMeasureSpec(101, View.MeasureSpec.EXACTLY))
+ assertEquals(101, expandableLayout.expandedHeight)
+
+ expandableLayout.getOrCalculateExpandedHeight(View.MeasureSpec.makeMeasureSpec(222, View.MeasureSpec.EXACTLY))
+ assertEquals(101, expandableLayout.expandedHeight)
+ }
+
+ @Test
+ fun `GIVEN getOrCalculateExpandedHeight() WHEN calculating the collapsed height to be bigger than the available screen height THEN it cancels collapsing`() {
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { },
+ )
+ expandableLayout.collapsedHeight = 50
+ expandableLayout.expandedHeight = 100
+
+ var result = expandableLayout.getOrCalculateExpandedHeight(View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY))
+ assertEquals(100, result)
+ assertTrue(expandableLayout.isCollapsed)
+ assertFalse(expandableLayout.isExpandInProgress)
+
+ // Reset parent height. Simulate entirely new calculations to have the code passing an if check
+ expandableLayout.parentHeight = -1
+ expandableLayout.collapsedHeight = 1_000
+ result = expandableLayout.getOrCalculateExpandedHeight(View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY))
+ assertEquals(100, result)
+ assertFalse(expandableLayout.isCollapsed)
+ assertFalse(expandableLayout.isExpandInProgress)
+ }
+
+ @Test
+ fun `GIVEN a set touchSlop WHEN isScrollingUp calculates that is was exceeded THEN it returns true `() {
+ val expandableLayout = ExpandableLayout.wrapContentInExpandableView(
+ FrameLayout(testContext),
+ 1,
+ ) { }
+ expandableLayout.initialYCoord = 0f
+ expandableLayout.touchSlop = 10f
+
+ var distanceScrolledDown = 11f
+ assertFalse(expandableLayout.isScrollingUp(MotionEvent.obtain(0, 0, ACTION_MOVE, 0f, distanceScrolledDown, 0)))
+ distanceScrolledDown = 5f
+ assertFalse(expandableLayout.isScrollingUp(MotionEvent.obtain(0, 0, ACTION_MOVE, 0f, distanceScrolledDown, 0)))
+
+ var distanceScrolledUp = -11f
+ assertTrue(expandableLayout.isScrollingUp(MotionEvent.obtain(0, 0, ACTION_MOVE, 0f, distanceScrolledUp, 0)))
+ distanceScrolledUp = -5f
+ assertFalse(expandableLayout.isScrollingUp(MotionEvent.obtain(0, 0, ACTION_MOVE, 0f, distanceScrolledUp, 0)))
+ }
+
+ @Test
+ fun `GIVEN a list of items WHEN calculateCollapsedHeight is called with an item index not found in the items list THEN it returns the value of measuredHeight`() {
+ val list = RecyclerView(testContext).apply {
+ layoutManager = mock()
+ addView(mock(), mock<RecyclerView.LayoutParams>())
+ addView(mock(), mock<RecyclerView.LayoutParams>())
+ }
+ val wrappedView = FrameLayout(testContext).apply { addView(list) }
+ val measuredHeight = -42
+ val expandableLayout = spy(
+ ExpandableLayout.wrapContentInExpandableView(
+ wrappedView,
+ 0,
+ ) { },
+ )
+
+ doReturn(measuredHeight).`when`(expandableLayout).measuredHeight
+
+ doReturn(0).`when`(expandableLayout).getChildPositionForAdapterIndex(any(), anyInt())
+ assertEquals(measuredHeight, expandableLayout.calculateCollapsedHeight())
+
+ // Here we test the list of two items collapsed to the first.
+ expandableLayout.lastVisibleItemIndexWhenCollapsed = 1
+ doReturn(1).`when`(expandableLayout).getChildPositionForAdapterIndex(any(), anyInt())
+ assertEquals(0, expandableLayout.calculateCollapsedHeight())
+
+ expandableLayout.lastVisibleItemIndexWhenCollapsed = 2
+ doReturn(2).`when`(expandableLayout).getChildPositionForAdapterIndex(any(), anyInt())
+ assertEquals(measuredHeight, expandableLayout.calculateCollapsedHeight())
+ }
+
+ @Test
+ fun `GIVEN calculateCollapsedHeight() WHEN called without a sticky footer index THEN it returns the distance between parent top and half of the SpecialView`() {
+ val viewHeightForEachProperty = 1_000
+ val listHeightForEachProperty = 100
+ val itemHeightForEachProperty = 10
+ val layoutManager = mock<RecyclerView.LayoutManager>()
+ // Although correctly set below (in configureWithHeight) the params get overwritten in RecyclerView when adding items
+ // So we need to fake RecyclerView's LayoutParams response.
+ val layoutParams = mock<RecyclerView.LayoutParams>()
+ .also { it.configureMarginResponse(itemHeightForEachProperty) }
+ doReturn(layoutParams).`when`(layoutManager).generateLayoutParams(any())
+ // Adding Views and creating spies in two stages because otherwise
+ // the addView call for a spy will not get us the expected result.
+ var list = RecyclerView(testContext).apply {
+ this.layoutManager = layoutManager
+ addView(spy(View(testContext)).configureWithHeight(itemHeightForEachProperty))
+ addView(spy(View(testContext)).configureWithHeight(itemHeightForEachProperty))
+ addView(spy(View(testContext)).configureWithHeight(itemHeightForEachProperty))
+ }
+ list = spy(list).configureWithHeight(listHeightForEachProperty)
+ var wrappedView = FrameLayout(testContext).apply { addView(list) }
+ wrappedView = spy(wrappedView).configureWithHeight(viewHeightForEachProperty)
+ val expandableLayout = spy(ExpandableLayout.wrapContentInExpandableView(wrappedView, 1) { })
+ doReturn(1).`when`(expandableLayout).getChildPositionForAdapterIndex(any(), eq(1))
+
+ val result = expandableLayout.calculateCollapsedHeight()
+
+ var expected = 0
+ expected += viewHeightForEachProperty * 4 // marginTop + marginBottom + paddingTop + paddingBottom
+ expected += listHeightForEachProperty * 4 // marginTop + marginBottom + paddingTop + paddingBottom
+ expected += itemHeightForEachProperty * 3 // height + marginTop + marginBottom for the top item shown in entirety
+ expected += itemHeightForEachProperty // marginTop for the special view
+ expected += itemHeightForEachProperty / 2 // as per the specs, show only half of the special view
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN calculateCollapsedHeight() WHEN called with a sticky footer index THEN it returns the distance between parent top and half of the SpecialView + height of sticky`() {
+ val viewHeightForEachProperty = 1_000
+ val listHeightForEachProperty = 100
+ val itemHeightForEachProperty = 10
+ val layoutManager = mock<RecyclerView.LayoutManager>()
+ // Although correctly set below (in configureWithHeight) the params get overwritten in RecyclerView when adding items
+ // So we need to fake RecyclerView's LayoutParams response.
+ val layoutParams = mock<RecyclerView.LayoutParams>()
+ .also { it.configureMarginResponse(itemHeightForEachProperty) }
+ doReturn(layoutParams).`when`(layoutManager).generateLayoutParams(any())
+ // Adding Views and creating spies in two stages because otherwise
+ // the addView call for a spy will not get us the expected result.
+ var list = RecyclerView(testContext).apply {
+ this.layoutManager = layoutManager
+ addView(spy(View(testContext)).configureWithHeight(itemHeightForEachProperty))
+ addView(spy(View(testContext)).configureWithHeight(itemHeightForEachProperty))
+ addView(spy(View(testContext)).configureWithHeight(itemHeightForEachProperty))
+ }
+ list = spy(list).configureWithHeight(listHeightForEachProperty)
+ var wrappedView = FrameLayout(testContext).apply { addView(list) }
+ wrappedView = spy(wrappedView).configureWithHeight(viewHeightForEachProperty)
+ val expandableLayout = spy(ExpandableLayout.wrapContentInExpandableView(wrappedView, 1, 2) { })
+ doReturn(1).`when`(expandableLayout).getChildPositionForAdapterIndex(any(), eq(1))
+ doReturn(2).`when`(expandableLayout).getChildPositionForAdapterIndex(any(), eq(2))
+
+ val result = expandableLayout.calculateCollapsedHeight()
+
+ var expected = 0
+ expected += viewHeightForEachProperty * 4 // marginTop + marginBottom + paddingTop + paddingBottom
+ expected += listHeightForEachProperty * 4 // marginTop + marginBottom + paddingTop + paddingBottom
+ expected += itemHeightForEachProperty * 3 // height + marginTop + marginBottom for the top item shown in entirety
+ expected += itemHeightForEachProperty // marginTop for the special view
+ expected += itemHeightForEachProperty / 2 // as per the specs, show only half of the special view
+ expected += itemHeightForEachProperty // height of the sticky item
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN calculateCollapsedHeight() WHEN called the same item as limit and sticky THEN it returns the distance between parent top and bottom of sticky`() {
+ val viewHeightForEachProperty = 1_000
+ val listHeightForEachProperty = 100
+ val itemHeightForEachProperty = 10
+ val layoutManager = mock<RecyclerView.LayoutManager>()
+ // Although correctly set below (in configureWithHeight) the params get overwritten in RecyclerView when adding items
+ // So we need to fake RecyclerView's LayoutParams response.
+ val layoutParams = mock<RecyclerView.LayoutParams>()
+ .also { it.configureMarginResponse(itemHeightForEachProperty) }
+ doReturn(layoutParams).`when`(layoutManager).generateLayoutParams(any())
+ // Adding Views and creating spies in two stages because otherwise
+ // the addView call for a spy will not get us the expected result.
+ var list = RecyclerView(testContext).apply {
+ this.layoutManager = layoutManager
+ addView(spy(View(testContext)).configureWithHeight(itemHeightForEachProperty))
+ addView(spy(View(testContext)).configureWithHeight(itemHeightForEachProperty))
+ addView(spy(View(testContext)).configureWithHeight(itemHeightForEachProperty))
+ }
+ list = spy(list).configureWithHeight(listHeightForEachProperty)
+ var wrappedView = FrameLayout(testContext).apply { addView(list) }
+ wrappedView = spy(wrappedView).configureWithHeight(viewHeightForEachProperty)
+ val expandableLayout = spy(ExpandableLayout.wrapContentInExpandableView(wrappedView, 1, 1) { })
+ doReturn(1).`when`(expandableLayout).getChildPositionForAdapterIndex(any(), eq(1))
+
+ val result = expandableLayout.calculateCollapsedHeight()
+
+ var expected = 0
+ expected += viewHeightForEachProperty * 4 // marginTop + marginBottom + paddingTop + paddingBottom
+ expected += listHeightForEachProperty * 4 // marginTop + marginBottom + paddingTop + paddingBottom
+ expected += itemHeightForEachProperty * 3 // height + marginTop + marginBottom for the top item shown in entirety
+ expected += itemHeightForEachProperty // marginTop for the special view
+ expected += itemHeightForEachProperty // height of the sticky item
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN a RecyclerView with a set Adapter WHEN getChildPositionForAdapterIndex is called THEN it returns the list position of the item in Adapter`() {
+ val layoutManager = mock<RecyclerView.LayoutManager>()
+ // Although correctly set below (in configureWithHeight) the params get overwritten in RecyclerView when adding items
+ // So we need to fake RecyclerView's LayoutParams response.
+ val layoutParams = mock<RecyclerView.LayoutParams>()
+ doReturn(layoutParams).`when`(layoutManager).generateLayoutParams(any())
+ val list = spy(
+ RecyclerView(testContext).apply {
+ this.layoutManager = layoutManager
+ addView(View(testContext).apply { setLayoutParams(ViewGroup.LayoutParams(10, 10)) })
+ addView(View(testContext).apply { setLayoutParams(ViewGroup.LayoutParams(10, 10)) })
+ addView(View(testContext).apply { setLayoutParams(ViewGroup.LayoutParams(10, 10)) })
+ },
+ )
+ val wrappedView = FrameLayout(testContext).apply { addView(list) }
+ val expandableLayout = spy(ExpandableLayout.wrapContentInExpandableView(wrappedView))
+ doReturn(3).`when`(list).getChildAdapterPosition(any())
+
+ assertEquals(-1, expandableLayout.getChildPositionForAdapterIndex(list, 2))
+ // We'll get a match based on the above "doReturn().." and adapterIndex: 3 for the first child.
+ assertEquals(0, expandableLayout.getChildPositionForAdapterIndex(list, 3))
+ }
+}
+
+/**
+ * Convenience method to set the same value - the received [height] parameter as the
+ * height, margins and paddings values for the current View.
+ */
+fun <V> V.configureWithHeight(height: Int): V where V : View {
+ doReturn(height).`when`(this).measuredHeight
+ layoutParams = ViewGroup.MarginLayoutParams(height, height).apply {
+ setMargins(height, height, height, height)
+ }
+ setPadding(height, height, height, height)
+
+ return this
+}
+
+/**
+ * Convenience method for setting the [margin] value to all LayoutParams margins.
+ */
+fun <T> T.configureMarginResponse(margin: Int) where T : ViewGroup.MarginLayoutParams {
+ this.topMargin = margin
+ this.rightMargin = margin
+ this.bottomMargin = margin
+ this.leftMargin = margin
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/FakeStickyItemLayoutManager.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/FakeStickyItemLayoutManager.kt
new file mode 100644
index 0000000000..50f87d17d9
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/FakeStickyItemLayoutManager.kt
@@ -0,0 +1,32 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.content.Context
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * A default implementation of a the abstract [StickyItemsLinearLayoutManager] to be used in tests.
+ */
+open class FakeStickyItemLayoutManager<T> constructor(
+ context: Context,
+ internal val stickyItemPlacement: StickyItemPlacement = StickyItemPlacement.TOP,
+ reverseLayout: Boolean = false,
+) : StickyItemsLinearLayoutManager<T>(
+ context,
+ stickyItemPlacement,
+ reverseLayout,
+) where T : RecyclerView.Adapter<*>, T : StickyItemsAdapter {
+ override fun scrollToIndicatedPositionWithOffset(
+ position: Int,
+ offset: Int,
+ actuallyScrollToPositionWithOffset: (Int, Int) -> Unit,
+ ) { }
+
+ override fun shouldStickyItemBeShownForCurrentPosition() = true
+
+ override fun getY(itemView: View) = 0f
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/FakeStickyItemsAdapter.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/FakeStickyItemsAdapter.kt
new file mode 100644
index 0000000000..d2187baad3
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/FakeStickyItemsAdapter.kt
@@ -0,0 +1,23 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import mozilla.components.support.test.mock
+
+/**
+ * A default implementation of [StickyItemsAdapter] to be used in tests.
+ */
+class FakeStickyItemsAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>(), StickyItemsAdapter {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
+ mock()
+
+ override fun getItemCount(): Int = 42
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {}
+
+ override fun isStickyItem(position: Int): Boolean = false
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/MenuButtonTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/MenuButtonTest.kt
new file mode 100644
index 0000000000..3d1b74b49a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/MenuButtonTest.kt
@@ -0,0 +1,172 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffColorFilter
+import android.widget.ImageView
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.view.children
+import androidx.core.view.isVisible
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuBuilder
+import mozilla.components.browser.menu.BrowserMenuHighlight
+import mozilla.components.concept.menu.MenuController
+import mozilla.components.support.test.any
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyBoolean
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class MenuButtonTest {
+ private lateinit var menuController: MenuController
+ private lateinit var menuBuilder: BrowserMenuBuilder
+ private lateinit var menu: BrowserMenu
+ private lateinit var menuButton: MenuButton
+ private lateinit var menuIcon: ImageView
+ private lateinit var highlightView: ImageView
+ private lateinit var notificationIconView: ImageView
+
+ @Before
+ fun setup() {
+ menuController = mock()
+ menu = mock()
+ menuBuilder = mock()
+ doReturn(menu).`when`(menuBuilder).build(testContext)
+
+ menuButton = MenuButton(testContext)
+ val images = menuButton.children.mapNotNull { it as? AppCompatImageView }.toList()
+ highlightView = images[0]
+ menuIcon = images[1]
+ notificationIconView = images[2]
+ }
+
+ @Test
+ fun `changing menu controller dismisses old menu`() {
+ menuButton.menuController = menuController
+ menuButton.performClick()
+
+ verify(menuController).show(menuButton)
+
+ menuButton.menuController = mock()
+ verify(menuController).dismiss()
+ }
+
+ @Test
+ fun `changing menu builder dismisses old menu`() {
+ menuButton.menuBuilder = menuBuilder
+ menuButton.performClick()
+
+ verify(menu).show(eq(menuButton), any(), any(), anyBoolean(), any())
+
+ menuButton.menuBuilder = mock()
+ verify(menu).dismiss()
+ }
+
+ @Test
+ fun `opening a new menu will prefer using the controller`() {
+ menuButton.menuController = menuController
+ menuButton.menuBuilder = menuBuilder
+
+ menuButton.performClick()
+
+ verify(menuController).show(menuButton)
+ verify(menuBuilder, never()).build(testContext)
+ verify(menu, never()).show(any(), any(), any(), anyBoolean(), any())
+ }
+
+ @Test
+ fun `trying to open a new menu when we already have one will dismiss the current`() {
+ menuButton.menuBuilder = menuBuilder
+
+ menuButton.performClick()
+ menuButton.performClick()
+
+ verify(menu, times(1)).show(eq(menuButton), any(), any(), anyBoolean(), any())
+ verify(menu, times(1)).dismiss()
+ }
+
+ @Test
+ fun `icon has content description`() {
+ assertEquals("Menu", menuIcon.contentDescription)
+ assertNotNull(menuIcon.drawable)
+ }
+
+ @Test
+ fun `icon color filter can be changed`() {
+ assertNull(menuIcon.colorFilter)
+
+ menuButton.setColorFilter(0xffffff)
+ assertEquals(PorterDuffColorFilter(0xffffff, PorterDuff.Mode.SRC_ATOP), menuIcon.colorFilter)
+ }
+
+ @Test
+ fun `icon can invalidate menu`() {
+ menuButton.menuBuilder = menuBuilder
+ menuButton.performClick()
+
+ verify(menu).show(eq(menuButton), any(), any(), anyBoolean(), any())
+
+ menuButton.invalidateBrowserMenu()
+ verify(menu).invalidate()
+ }
+
+ @Test
+ fun `icon displays high priority highlight`() {
+ assertFalse(highlightView.isVisible)
+ assertFalse(notificationIconView.isVisible)
+
+ menuButton.setHighlight(
+ BrowserMenuHighlight.HighPriority(Color.RED),
+ )
+
+ assertTrue(highlightView.isVisible)
+ assertFalse(notificationIconView.isVisible)
+
+ assertEquals(ColorStateList.valueOf(Color.RED), highlightView.imageTintList)
+ }
+
+ @Test
+ fun `icon displays low priority highlight`() {
+ assertFalse(highlightView.isVisible)
+ assertFalse(notificationIconView.isVisible)
+
+ menuButton.setHighlight(
+ BrowserMenuHighlight.LowPriority(Color.BLUE),
+ )
+
+ assertFalse(highlightView.isVisible)
+ assertTrue(notificationIconView.isVisible)
+
+ assertEquals(PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.SRC_ATOP), notificationIconView.colorFilter)
+ }
+
+ @Test
+ fun `menu can be dismissed`() {
+ menuButton.menuController = menuController
+ menuButton.menu = menu
+
+ menuButton.dismissMenu()
+
+ verify(menuButton.menuController)?.dismiss()
+ verify(menuButton.menu)?.dismiss()
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyFooterLinearLayoutManagerTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyFooterLinearLayoutManagerTest.kt
new file mode 100644
index 0000000000..f4e095d7dd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyFooterLinearLayoutManagerTest.kt
@@ -0,0 +1,189 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import mozilla.components.support.test.mock
+import org.junit.Assert
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+class StickyFooterLinearLayoutManagerTest {
+ private lateinit var manager: StickyFooterLinearLayoutManager<FakeStickyItemsAdapter>
+
+ @Before
+ fun setup() {
+ manager = StickyFooterLinearLayoutManager(mock(), false)
+ }
+
+ @Test
+ fun `GIVEN scrollToIndicatedPositionWithOffset WHEN called with a position smaller than stickyItemPosition THEN will scroll to after that`() {
+ manager.stickyItemPosition = 5
+ var positionToScrollResult = -1
+ var offsetToScrollResult = -1
+ val scrollCallback: (Int, Int) -> Unit = { position, offset ->
+ positionToScrollResult = position
+ offsetToScrollResult = offset
+ }
+
+ manager.scrollToIndicatedPositionWithOffset(4, 22, scrollCallback)
+ Assert.assertEquals(5, positionToScrollResult)
+ Assert.assertEquals(22, offsetToScrollResult)
+
+ manager.scrollToIndicatedPositionWithOffset(0, 22, scrollCallback)
+ Assert.assertEquals(1, positionToScrollResult)
+ Assert.assertEquals(22, offsetToScrollResult)
+ }
+
+ @Test
+ fun `GIVEN scrollToIndicatedPositionWithOffset WHEN called with a position smaller than stickyItemPosition which is displayed THEN will scroll to that`() {
+ manager = spy(manager)
+ doReturn(mock<View>()).`when`(manager).getChildAt(ArgumentMatchers.anyInt())
+ manager.stickyItemPosition = 5
+ var positionToScrollResult = -1
+ var offsetToScrollResult = -1
+ val scrollCallback: (Int, Int) -> Unit = { position, offset ->
+ positionToScrollResult = position
+ offsetToScrollResult = offset
+ }
+
+ manager.scrollToIndicatedPositionWithOffset(4, 22, scrollCallback)
+ Assert.assertEquals(4, positionToScrollResult)
+ Assert.assertEquals(22, offsetToScrollResult)
+
+ manager.scrollToIndicatedPositionWithOffset(0, 22, scrollCallback)
+ Assert.assertEquals(0, positionToScrollResult)
+ Assert.assertEquals(22, offsetToScrollResult)
+ }
+
+ @Test
+ fun `GIVEN scrollToIndicatedPositionWithOffset WHEN called with a position equal to stickyItemPosition THEN will scroll to that position`() {
+ manager.stickyItemPosition = 6
+ var positionToScrollResult = -1
+ var offsetToScrollResult = -1
+ val scrollCallback: (Int, Int) -> Unit = { position, offset ->
+ positionToScrollResult = position
+ offsetToScrollResult = offset
+ }
+
+ manager.scrollToIndicatedPositionWithOffset(6, 22, scrollCallback)
+ Assert.assertEquals(6, positionToScrollResult)
+ Assert.assertEquals(22, offsetToScrollResult)
+ }
+
+ @Test
+ fun `GIVEN scrollToIndicatedPositionWithOffset WHEN called with a position bigger than stickyItemPosition THEN will scroll to that position`() {
+ manager.stickyItemPosition = 6
+ var positionToScrollResult = -1
+ var offsetToScrollResult = -1
+ val scrollCallback: (Int, Int) -> Unit = { position, offset ->
+ positionToScrollResult = position
+ offsetToScrollResult = offset
+ }
+
+ manager.scrollToIndicatedPositionWithOffset(7, 22, scrollCallback)
+ Assert.assertEquals(7, positionToScrollResult)
+ Assert.assertEquals(22, offsetToScrollResult)
+
+ manager.scrollToIndicatedPositionWithOffset(10, 22, scrollCallback)
+ Assert.assertEquals(10, positionToScrollResult)
+ Assert.assertEquals(22, offsetToScrollResult)
+
+ // Negative positions are handled by Android'd LayoutManager. We should pass any to it.
+ manager.scrollToIndicatedPositionWithOffset(3333, 22, scrollCallback)
+ Assert.assertEquals(3333, positionToScrollResult)
+ Assert.assertEquals(22, offsetToScrollResult)
+ }
+
+ @Test
+ fun `GIVEN stickyItemPosition not set WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it returns false`() {
+ manager.stickyItemPosition = RecyclerView.NO_POSITION
+
+ Assert.assertFalse(manager.shouldStickyItemBeShownForCurrentPosition())
+ }
+
+ @Test
+ fun `GIVEN sticky item shown WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it checks the item above the sticky one`() {
+ val manager = spy(manager)
+ manager.stickyItemPosition = 5
+ manager.stickyItemView = mock()
+ doReturn(10).`when`(manager).childCount
+
+ manager.shouldStickyItemBeShownForCurrentPosition()
+
+ verify(manager).getAdapterPositionForItemIndex(8)
+ }
+
+ @Test
+ fun `GIVEN sticky item not shown WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it checks the bottom most item`() {
+ val manager = spy(manager)
+ manager.stickyItemPosition = 5
+ doReturn(10).`when`(manager).childCount
+
+ manager.shouldStickyItemBeShownForCurrentPosition()
+
+ verify(manager).getAdapterPositionForItemIndex(9)
+ }
+
+ @Test
+ fun ` GIVEN sticky item being the last shown item WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it returns true`() {
+ val manager = spy(manager)
+ manager.stickyItemPosition = 5
+ doReturn(5).`when`(manager).getAdapterPositionForItemIndex(ArgumentMatchers.anyInt())
+
+ assertTrue(manager.shouldStickyItemBeShownForCurrentPosition())
+ }
+
+ @Test
+ fun ` GIVEN sticky item being scrolled upwards from the bottom WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it returns false`() {
+ val manager = spy(manager)
+ manager.stickyItemPosition = 5
+
+ doReturn(6).`when`(manager).getAdapterPositionForItemIndex(ArgumentMatchers.anyInt())
+ assertFalse(manager.shouldStickyItemBeShownForCurrentPosition())
+
+ doReturn(60).`when`(manager).getAdapterPositionForItemIndex(ArgumentMatchers.anyInt())
+ assertFalse(manager.shouldStickyItemBeShownForCurrentPosition())
+ }
+
+ @Test
+ fun ` GIVEN sticky item being scrolled downwards offscreen WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it returns true`() {
+ val manager = spy(manager)
+ manager.stickyItemPosition = 5
+
+ doReturn(4).`when`(manager).getAdapterPositionForItemIndex(ArgumentMatchers.anyInt())
+ assertTrue(manager.shouldStickyItemBeShownForCurrentPosition())
+
+ doReturn(0).`when`(manager).getAdapterPositionForItemIndex(ArgumentMatchers.anyInt())
+ assertTrue(manager.shouldStickyItemBeShownForCurrentPosition())
+ }
+
+ @Test
+ fun `GIVEN a default layout menu WHEN getY is called THEN it returns the translation needed to push the sticky item to the top`() {
+ manager = spy(manager)
+ val stickyView: View = mock()
+ doReturn(100).`when`(manager).height
+ doReturn(33).`when`(stickyView).height
+
+ Assert.assertEquals(67f, manager.getY(stickyView))
+ }
+
+ @Test
+ fun `GIVEN a reverseLayout menu WHEN getY is called THEN it returns 0 as the translation to be set for the sticky item`() {
+ manager = spy(StickyFooterLinearLayoutManager(mock(), true))
+ doReturn(100).`when`(manager).height
+ val stickyView: View = mock()
+ doReturn(33).`when`(stickyView).height
+
+ Assert.assertEquals(0f, manager.getY(stickyView))
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyHeaderLinearLayoutManagerTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyHeaderLinearLayoutManagerTest.kt
new file mode 100644
index 0000000000..897a8ca8c1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyHeaderLinearLayoutManagerTest.kt
@@ -0,0 +1,155 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.spy
+
+class StickyHeaderLinearLayoutManagerTest {
+ private lateinit var manager: StickyHeaderLinearLayoutManager<FakeStickyItemsAdapter>
+
+ @Before
+ fun setup() {
+ manager = StickyHeaderLinearLayoutManager(mock(), false)
+ }
+
+ @Test
+ fun `GIVEN scrollToIndicatedPositionWithOffset WHEN called with a position bigger than stickyItemPosition THEN will scroll to before that`() {
+ manager.stickyItemPosition = 5
+ var positionToScrollResult = -1
+ var offsetToScrollResult = -1
+ val scrollCallback: (Int, Int) -> Unit = { position, offset ->
+ positionToScrollResult = position
+ offsetToScrollResult = offset
+ }
+
+ manager.scrollToIndicatedPositionWithOffset(6, 22, scrollCallback)
+ assertEquals(5, positionToScrollResult)
+ assertEquals(22, offsetToScrollResult)
+
+ manager.scrollToIndicatedPositionWithOffset(10, 22, scrollCallback)
+ assertEquals(9, positionToScrollResult)
+ assertEquals(22, offsetToScrollResult)
+ }
+
+ @Test
+ fun `GIVEN scrollToIndicatedPositionWithOffset WHEN called with a position equal to stickyItemPosition THEN will scroll to before that`() {
+ manager.stickyItemPosition = 6
+ var positionToScrollResult = -1
+ var offsetToScrollResult = -1
+ val scrollCallback: (Int, Int) -> Unit = { position, offset ->
+ positionToScrollResult = position
+ offsetToScrollResult = offset
+ }
+
+ manager.scrollToIndicatedPositionWithOffset(6, 22, scrollCallback)
+ assertEquals(5, positionToScrollResult)
+ assertEquals(22, offsetToScrollResult)
+ }
+
+ @Test
+ fun `GIVEN scrollToIndicatedPositionWithOffset WHEN called with a position smaller than stickyItemPosition THEN will scroll to that position`() {
+ manager.stickyItemPosition = 6
+ var positionToScrollResult = -1
+ var offsetToScrollResult = -1
+ val scrollCallback: (Int, Int) -> Unit = { position, offset ->
+ positionToScrollResult = position
+ offsetToScrollResult = offset
+ }
+
+ manager.scrollToIndicatedPositionWithOffset(5, 22, scrollCallback)
+ assertEquals(5, positionToScrollResult)
+ assertEquals(22, offsetToScrollResult)
+
+ manager.scrollToIndicatedPositionWithOffset(0, 22, scrollCallback)
+ assertEquals(0, positionToScrollResult)
+ assertEquals(22, offsetToScrollResult)
+
+ // Negative positions are handled by Android'd LayoutManager. We should pass any to it.
+ manager.scrollToIndicatedPositionWithOffset(-33, 22, scrollCallback)
+ assertEquals(-33, positionToScrollResult)
+ assertEquals(22, offsetToScrollResult)
+ }
+
+ @Test
+ fun `GIVEN stickyItemPosition not set WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it returns false`() {
+ manager.stickyItemPosition = RecyclerView.NO_POSITION
+
+ assertFalse(manager.shouldStickyItemBeShownForCurrentPosition())
+ }
+
+ @Test
+ fun `GIVEN the top item is the sticky one WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it returns true`() {
+ val manager = spy(manager)
+ manager.stickyItemPosition = 3
+ doReturn(3).`when`(manager).getAdapterPositionForItemIndex(0)
+
+ assertTrue(manager.shouldStickyItemBeShownForCurrentPosition())
+ }
+
+ @Test
+ fun `GIVEN items below the sticky item are scrolled upwards offscreen WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it returns true`() {
+ val manager = spy(manager)
+ manager.stickyItemPosition = 3
+
+ doReturn(4).`when`(manager).getAdapterPositionForItemIndex(0)
+ assertTrue(manager.shouldStickyItemBeShownForCurrentPosition())
+
+ doReturn(5).`when`(manager).getAdapterPositionForItemIndex(0)
+ assertTrue(manager.shouldStickyItemBeShownForCurrentPosition())
+ }
+
+ @Test
+ fun `GIVEN items above the sticky item shown at top but ofsetted offscreen WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it returns true`() {
+ val manager = spy(manager)
+ manager.stickyItemPosition = 3
+ doReturn(1).`when`(manager).getAdapterPositionForItemIndex(0)
+ val topMostItem: View = mock()
+ doReturn(topMostItem).`when`(manager).getChildAt(0)
+
+ doReturn(0).`when`(topMostItem).bottom
+ assertTrue(manager.shouldStickyItemBeShownForCurrentPosition())
+
+ doReturn(-5).`when`(topMostItem).bottom
+ assertTrue(manager.shouldStickyItemBeShownForCurrentPosition())
+ }
+
+ @Test
+ fun `GIVEN the sticky item is shown below the top of the list WHEN shouldStickyItemBeShownForCurrentPosition is called THEN it returns false`() {
+ val manager = spy(manager)
+ manager.stickyItemPosition = 3
+ doReturn(1).`when`(manager).getAdapterPositionForItemIndex(0)
+
+ assertFalse(manager.shouldStickyItemBeShownForCurrentPosition())
+ }
+
+ @Test
+ fun `GIVEN a default layout menu WHEN getY is called THEN it returns 0 as the translation to be set for the sticky item`() {
+ manager = spy(manager)
+ val stickyView: View = mock()
+ doReturn(100).`when`(manager).height
+ doReturn(33).`when`(stickyView).height
+
+ assertEquals(0f, manager.getY(stickyView))
+ }
+
+ @Test
+ fun `GIVEN a reverseLayout menu WHEN getY is called THEN it returns the translation needed to push the sticky item to the top`() {
+ manager = spy(StickyHeaderLinearLayoutManager(mock(), true))
+ doReturn(100).`when`(manager).height
+ val stickyView: View = mock()
+ doReturn(33).`when`(stickyView).height
+
+ assertEquals(67f, manager.getY(stickyView))
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyItemsLinearLayoutManagerTest.kt b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyItemsLinearLayoutManagerTest.kt
new file mode 100644
index 0000000000..5e7d00314f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/java/mozilla/components/browser/menu/view/StickyItemsLinearLayoutManagerTest.kt
@@ -0,0 +1,631 @@
+/* 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 mozilla.components.browser.menu.view
+
+import android.graphics.PointF
+import android.view.View
+import android.view.ViewTreeObserver
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.LinearLayoutManager.INVALID_OFFSET
+import androidx.recyclerview.widget.RecyclerView
+import mozilla.components.support.test.any
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito
+import org.mockito.Mockito.doNothing
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+class StickyItemsLinearLayoutManagerTest {
+ // For shorter test names "StickyItemsLinearLayoutManager" is referred to as SILLM.
+
+ private lateinit var manager: FakeStickyItemLayoutManager<FakeStickyItemsAdapter>
+
+ @Before
+ fun setup() {
+ manager = FakeStickyItemLayoutManager(mock())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN a new instance is contructed THEN it has specific default values`() {
+ assertEquals(RecyclerView.NO_POSITION, manager.stickyItemPosition)
+ assertEquals(RecyclerView.NO_POSITION, manager.scrollPosition)
+ assertEquals(0, manager.scrollOffset)
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN onAttachedToWindow called THEN it calls super and sets the new adapter`() {
+ manager = spy(manager)
+ val list = Mockito.mock(RecyclerView::class.java)
+
+ manager.onAttachedToWindow(list)
+
+ verify(manager).setAdapter(list.adapter)
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN onSaveInstanceState called THEN it returns a new SavedState with the scroll data`() {
+ manager.scrollPosition = 42
+ manager.scrollOffset = 422
+
+ val result: SavedState = manager.onSaveInstanceState() as SavedState
+
+ assertTrue(result.superState is LinearLayoutManager.SavedState)
+ assertEquals(42, result.scrollPosition)
+ assertEquals(422, result.scrollOffset)
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN onRestoreInstanceState is called with a new state THEN it updates scrollPosition and scrollOffset`() {
+ val newState = SavedState(
+ null,
+ scrollPosition = 222,
+ scrollOffset = 221,
+ )
+
+ manager.onRestoreInstanceState(newState)
+
+ assertEquals(222, manager.scrollPosition)
+ assertEquals(221, manager.scrollOffset)
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN onRestoreInstanceState is called with a null state THEN scrollPosition and scrollOffset are left unchanged`() {
+ manager.onRestoreInstanceState(null)
+
+ assertEquals(RecyclerView.NO_POSITION, manager.scrollPosition)
+ assertEquals(0, manager.scrollOffset)
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN onLayoutChildren is called while in preLayout THEN it execute the super with the sticky item detached and not updates stickyItem`() {
+ manager = spy(manager)
+ val listState: RecyclerView.State = mock()
+ doReturn(true).`when`(listState).isPreLayout
+
+ manager.onLayoutChildren(mock(), listState)
+
+ verify(manager).restoreView<Unit>(any())
+ verify(manager, never()).updateStickyItem(any(), ArgumentMatchers.anyBoolean())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN onLayoutChildren is called while not in preLayout THEN it execute the super with the sticky item detached and updates stickyItem`() {
+ manager = spy(manager)
+ val recycler: RecyclerView.Recycler = mock()
+ val listState: RecyclerView.State = mock()
+ doReturn(false).`when`(listState).isPreLayout
+ // Prevent side effects following the "manager.onLayoutChildren" call
+ doReturn(false).`when`(manager).shouldStickyItemBeShownForCurrentPosition()
+
+ manager.onLayoutChildren(recycler, listState)
+
+ verify(manager).restoreView<Unit>(any())
+ verify(manager).updateStickyItem(recycler, true)
+ }
+
+ @Test
+ fun `GIVEN A SILLM WHEN scrollVerticallyBy is called THEN it detaches the sticky item to scroll using parent and not updates the sticky item`() {
+ manager = spy(manager)
+
+ val result = manager.scrollVerticallyBy(0, mock(), mock())
+
+ verify(manager).restoreView<Int>(any())
+ verify(manager, never()).updateStickyItem(any(), ArgumentMatchers.anyBoolean())
+ assertEquals(0, result)
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN findLastVisibleItemPosition is called THEN it detaches the sticky item to call the super method`() {
+ manager = spy(manager)
+
+ manager.findLastVisibleItemPosition()
+
+ verify(manager).restoreView<Int>(any())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN findFirstVisibleItemPosition is called THEN it detaches the sticky item to call the super method`() {
+ manager = spy(manager)
+
+ manager.findFirstVisibleItemPosition()
+
+ verify(manager).restoreView<Int>(any())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN findFirstCompletelyVisibleItemPosition is called THEN it detaches the sticky item to call the super method`() {
+ manager = spy(manager)
+
+ manager.findFirstCompletelyVisibleItemPosition()
+
+ verify(manager).restoreView<Int>(any())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN findLastCompletelyVisibleItemPosition is called THEN it detaches the sticky item to call the super method`() {
+ manager = spy(manager)
+
+ manager.findLastCompletelyVisibleItemPosition()
+
+ verify(manager).restoreView<Int>(any())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN computeVerticalScrollExtent is called THEN it detaches the sticky item to call the super method`() {
+ manager = spy(manager)
+
+ manager.computeVerticalScrollExtent(mock())
+
+ verify(manager).restoreView<Int>(any())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN computeVerticalScrollOffset is called THEN it detaches the sticky item to call the super method`() {
+ manager = spy(manager)
+
+ manager.computeVerticalScrollOffset(mock())
+
+ verify(manager).restoreView<Int>(any())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN computeVerticalScrollRange is called THEN it detaches the sticky item to call the super method`() {
+ manager = spy(manager)
+
+ manager.computeVerticalScrollRange(mock())
+
+ verify(manager).restoreView<Int>(any())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN computeScrollVectorForPosition is called THEN it detaches the sticky item to call the super method`() {
+ manager = spy(manager)
+
+ manager.computeScrollVectorForPosition(33)
+
+ verify(manager).restoreView<PointF>(any())
+ }
+
+ @Test
+ fun `GIVEN sticky item is null WHEN scrollToPosition is called THEN scrollToPositionWithOffset is not called`() {
+ manager = spy(manager)
+
+ manager.scrollToPosition(32)
+
+ verify(manager, never()).scrollToPositionWithOffset(ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())
+ }
+
+ @Test
+ fun `GIVEN sticky item is not null WHEN scrollToPosition is called THEN it calls scrollToPositionWithOffset with INVALID_OFFSET`() {
+ manager = spy(manager)
+ manager.stickyItemView = mock()
+
+ manager.scrollToPosition(32)
+
+ verify(manager).scrollToPositionWithOffset(32, INVALID_OFFSET)
+ }
+
+ @Test
+ fun `GIVEN sticky item is not null WHEN scrollToPositionWithOffset is called THEN scrollToIndicatedPositionWithOffset is delegated`() {
+ manager = spy(manager)
+ manager.stickyItemView = mock()
+
+ manager.scrollToPositionWithOffset(23, 9)
+
+ verify(manager).setScrollState(RecyclerView.NO_POSITION, INVALID_OFFSET)
+ verify(manager).scrollToIndicatedPositionWithOffset(eq(23), eq(9), any())
+ verify(manager).setScrollState(23, 9)
+ }
+
+ @Test
+ fun `GIVEN sticky item is null WHEN onFocusSearchFailed is called THEN it detaches the sticky item to call the super method`() {
+ manager = spy(manager)
+
+ manager.onFocusSearchFailed(mock(), 3, mock(), mock())
+
+ verify(manager).restoreView<View?>(any())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN getAdapterPositionForItemIndex is called with a index for which there is no bound view THEN it returns -1`() {
+ assertEquals(RecyclerView.NO_POSITION, manager.getAdapterPositionForItemIndex(22))
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN getAdapterPositionForItemIndex is called with a index of an existing view THEN it returns it's absoluteAdapterPosition`() {
+ manager = spy(manager)
+ val params: RecyclerView.LayoutParams = mock()
+ doReturn(7).`when`(params).absoluteAdapterPosition
+ val view: View = mock()
+ doReturn(params).`when`(view).layoutParams
+ doReturn(view).`when`(manager).getChildAt(ArgumentMatchers.anyInt())
+
+ assertEquals(7, manager.getAdapterPositionForItemIndex(22))
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN setAdapter is called with a null argument THEN the current adapter and stickyItem are set to null`() {
+ val initialAdapter = mock<FakeStickyItemsAdapter>()
+ manager.listAdapter = initialAdapter
+
+ manager.setAdapter(null)
+
+ verify(initialAdapter).unregisterAdapterDataObserver(manager.stickyItemPositionsObserver)
+ assertNull(manager.listAdapter)
+ assertNull(manager.stickyItemView)
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN setAdapter is called with a new valid adapter THEN the current adapter is reset`() {
+ val initialAdapter: FakeStickyItemsAdapter = mock()
+ val newAdapter: FakeStickyItemsAdapter = mock()
+ manager.listAdapter = initialAdapter
+ manager.stickyItemPositionsObserver = spy(manager.stickyItemPositionsObserver)
+
+ manager.setAdapter(newAdapter)
+
+ verify(initialAdapter).unregisterAdapterDataObserver(manager.stickyItemPositionsObserver)
+ assertSame(newAdapter, manager.listAdapter)
+ verify(newAdapter).registerAdapterDataObserver(manager.stickyItemPositionsObserver)
+ verify(manager.stickyItemPositionsObserver).onChanged()
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN restoreView is called with a method parameter THEN the sticky item is detached, method executed, item reattached`() {
+ manager = spy(manager)
+ val stickyView: View = mock()
+ manager.stickyItemView = stickyView
+ doNothing().`when`(manager).detachView(any())
+ doNothing().`when`(manager).attachView(any())
+ val orderVerifier = Mockito.inOrder(manager)
+
+ val result = manager.restoreView { 3 }
+
+ orderVerifier.verify(manager).detachView(stickyView)
+ orderVerifier.verify(manager).attachView(stickyView)
+ assertEquals(3, result)
+ }
+
+ @Test
+ fun `GIVEN sticky item should not be shown WHEN updateStickyItem is called THEN the stickyItemView is recycled`() {
+ manager = spy(manager)
+ manager.stickyItemView = mock()
+ doReturn(false).`when`(manager).shouldStickyItemBeShownForCurrentPosition()
+ doNothing().`when`(manager).recycleStickyItem(any())
+ val recycler: RecyclerView.Recycler = mock()
+
+ manager.updateStickyItem(recycler, true)
+
+ verify(manager).recycleStickyItem(recycler)
+ }
+
+ @Test
+ fun `GIVEN sticky item should be shown and not exists WHEN updateStickyItem is called THEN a new stickyItemView is created`() {
+ manager = spy(manager)
+ doReturn(true).`when`(manager).shouldStickyItemBeShownForCurrentPosition()
+ doReturn(0f).`when`(manager).getY(any())
+ manager.stickyItemPosition = 42
+ val recycler: RecyclerView.Recycler = mock()
+ doNothing().`when`(manager).createStickyView(any(), ArgumentMatchers.anyInt())
+
+ manager.updateStickyItem(recycler, false)
+
+ verify(manager).createStickyView(recycler, 42)
+ verify(manager, never()).recycleStickyItem(any())
+ }
+
+ @Test
+ fun `GIVEN sticky item should be shown and exists WHEN updateStickyItem is called THEN another stickyItemView is not created`() {
+ manager = spy(manager)
+ val stickyView: View = mock()
+ manager.stickyItemView = stickyView
+ doReturn(true).`when`(manager).shouldStickyItemBeShownForCurrentPosition()
+ doReturn(0f).`when`(manager).getY(any())
+ manager.stickyItemPosition = 42
+ val recycler: RecyclerView.Recycler = mock()
+ doNothing().`when`(manager).createStickyView(any(), ArgumentMatchers.anyInt())
+
+ manager.updateStickyItem(recycler, false)
+
+ verify(manager, never()).createStickyView(any(), ArgumentMatchers.anyInt())
+ verify(manager, never()).recycleStickyItem(any())
+ }
+
+ @Test
+ fun `GIVEN sticky item should be shown WHEN updateStickyItem is called while layout THEN bindStickyItem is called`() {
+ manager = spy(manager)
+ val stickyView: View = mock()
+ manager.stickyItemView = stickyView
+ doReturn(true).`when`(manager).shouldStickyItemBeShownForCurrentPosition()
+ doReturn(0f).`when`(manager).getY(any())
+ doNothing().`when`(manager).createStickyView(any(), ArgumentMatchers.anyInt())
+ doNothing().`when`(manager).bindStickyItem(any())
+
+ manager.updateStickyItem(mock(), true)
+
+ verify(manager).bindStickyItem(stickyView)
+ verify(manager, never()).recycleStickyItem(any())
+ }
+
+ @Test
+ fun `GIVEN sticky item should be shown WHEN updateStickyItem is called while not layout THEN bindStickyItem is not called`() {
+ manager = spy(manager)
+ val stickyView: View = mock()
+ manager.stickyItemView = stickyView
+ doReturn(true).`when`(manager).shouldStickyItemBeShownForCurrentPosition()
+ doReturn(0f).`when`(manager).getY(any())
+ doNothing().`when`(manager).createStickyView(any(), ArgumentMatchers.anyInt())
+ doNothing().`when`(manager).bindStickyItem(any())
+
+ manager.updateStickyItem(mock(), false)
+
+ verify(manager, never()).bindStickyItem(any())
+ verify(manager, never()).recycleStickyItem(any())
+ }
+
+ @Test
+ fun `GIVEN sticky item should be shown and it's view exists WHEN updateStickyItem is called THEN the stickyItemView gets set a new Y translation`() {
+ manager = spy(manager)
+ val stickyView: View = mock()
+ manager.stickyItemView = stickyView
+ doReturn(true).`when`(manager).shouldStickyItemBeShownForCurrentPosition()
+ doReturn(44f).`when`(manager).getY(any())
+ doNothing().`when`(manager).createStickyView(any(), ArgumentMatchers.anyInt())
+
+ manager.updateStickyItem(mock(), false)
+
+ verify(manager).getY(stickyView)
+ verify(stickyView).translationY = 44f
+ verify(manager, never()).recycleStickyItem(any())
+ }
+
+ @Test
+ fun `GIVEN SILLM WHEN createStickyView is called THEN a new View is created and cached in stickyItemView`() {
+ manager = spy(manager)
+ val adapter: FakeStickyItemsAdapter = mock()
+ manager.listAdapter = adapter
+ val recycler: RecyclerView.Recycler = mock()
+ val newStickyView: View = mock()
+ doReturn(newStickyView).`when`(recycler).getViewForPosition(ArgumentMatchers.anyInt())
+ doNothing().`when`(manager).addView(any())
+ doNothing().`when`(manager).measureAndLayout(any())
+ doNothing().`when`(manager).ignoreView(any())
+
+ manager.createStickyView(recycler, 22)
+
+ verify(adapter).setupStickyItem(newStickyView)
+ verify(manager).addView(newStickyView)
+ verify(manager).measureAndLayout(newStickyView)
+ verify(manager).ignoreView(newStickyView)
+ assertSame(newStickyView, manager.stickyItemView)
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN bindStickyItem is called for a new View THEN the view is measured and layout`() {
+ manager = spy(manager)
+ val view: View = mock()
+ doNothing().`when`(manager).measureAndLayout(any())
+
+ manager.bindStickyItem(view)
+
+ verify(manager).measureAndLayout(view)
+ }
+
+ @Test
+ fun `GIVEN a pending scroll WHEN bindStickyItem is called for a new View THEN a OnGlobalLayoutListener is set`() {
+ manager = spy(manager)
+ manager.scrollPosition = 22
+ val view: View = mock()
+ val viewObserver: ViewTreeObserver = mock()
+ doReturn(viewObserver).`when`(view).viewTreeObserver
+ doNothing().`when`(manager).measureAndLayout(any())
+
+ manager.bindStickyItem(view)
+
+ verify(manager).measureAndLayout(view)
+ verify(viewObserver).addOnGlobalLayoutListener(any())
+ }
+
+ @Test
+ fun `GIVEN no pending scroll WHEN bindStickyItem is called for a new View THEN no OnGlobalLayoutListener is set`() {
+ manager = spy(manager)
+ val view: View = mock()
+ val viewObserver: ViewTreeObserver = mock()
+ doReturn(viewObserver).`when`(view).viewTreeObserver
+ doNothing().`when`(manager).measureAndLayout(any())
+
+ manager.bindStickyItem(view)
+
+ verify(manager).measureAndLayout(view)
+ verify(viewObserver, never()).addOnGlobalLayoutListener(any())
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN measureAndLayout is called for a new View THEN it is measured and layout`() {
+ manager = spy(manager)
+ val newView: View = mock()
+ doReturn(22).`when`(manager).paddingLeft
+ doReturn(33).`when`(manager).paddingRight
+ doReturn(100).`when`(manager).width
+ doReturn(112).`when`(newView).measuredHeight
+
+ doNothing().`when`(manager).measureChildWithMargins(any(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())
+
+ manager.measureAndLayout(newView)
+
+ verify(manager).measureChildWithMargins(newView, 0, 0)
+ verify(newView).layout(22, 0, (100 - 33), 112)
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN recycleStickyItem is called THEN the view holder is reset and allowed to be recycled`() {
+ manager = spy(manager)
+ val stickyView: View = mock()
+ manager.stickyItemView = stickyView
+ val adapter: FakeStickyItemsAdapter = mock()
+ manager.listAdapter = adapter
+ val recycler: RecyclerView.Recycler = mock()
+ val captor = argumentCaptor<View>()
+ doNothing().`when`(manager).stopIgnoringView(any())
+ doNothing().`when`(manager).removeView(any())
+
+ manager.recycleStickyItem(recycler)
+
+ verify(adapter).tearDownStickyItem(captor.capture())
+ verify(manager).stopIgnoringView(captor.value)
+ verify(manager).removeView(captor.value)
+ verify(recycler).recycleView(captor.value)
+ }
+
+ @Test
+ fun `GIVEN a SILLM WHEN is called with a new position and offset THEN they are cached in scrollPosition and scrollOffset properties`() {
+ manager.setScrollState(222, 333)
+
+ assertEquals(222, manager.scrollPosition)
+ assertEquals(333, manager.scrollOffset)
+ }
+
+ @Test
+ fun `GIVEN an ItemPositionsAdapterDataObserver WHEN onChanged is called THEN handleChange() is delegated`() {
+ val observer = spy(manager.stickyItemPositionsObserver)
+ manager.stickyItemPositionsObserver = observer
+
+ observer.onChanged()
+
+ verify(observer).handleChange()
+ }
+
+ @Test
+ fun `GIVEN an ItemPositionsAdapterDataObserver WHEN onItemRangeInserted is called THEN handleChange() is delegated`() {
+ val observer = spy(manager.stickyItemPositionsObserver)
+ manager.stickyItemPositionsObserver = observer
+
+ observer.onItemRangeInserted(22, 33)
+
+ verify(observer).handleChange()
+ }
+
+ @Test
+ fun `GIVEN an ItemPositionsAdapterDataObserver WHEN onItemRangeRemoved is called THEN handleChange() is delegated`() {
+ val observer = spy(manager.stickyItemPositionsObserver)
+ manager.stickyItemPositionsObserver = observer
+
+ observer.onItemRangeRemoved(22, 33)
+
+ verify(observer).handleChange()
+ }
+
+ @Test
+ fun `GIVEN an ItemPositionsAdapterDataObserver WHEN onItemRangeMoved is called THEN handleChange() is delegated`() {
+ val observer = spy(manager.stickyItemPositionsObserver)
+ manager.stickyItemPositionsObserver = observer
+
+ observer.onItemRangeMoved(11, 22, 33)
+
+ verify(observer).handleChange()
+ }
+
+ @Test
+ fun `GIVEN an ItemPositionsAdapterDataObserver WHEN handleChange is called THEN the sticky item is updated`() {
+ manager = spy(manager)
+ val adapter: FakeStickyItemsAdapter = mock()
+ manager.listAdapter = adapter
+ val stickyView: View = mock()
+ manager.stickyItemView = stickyView
+ val observer = spy(manager.ItemPositionsAdapterDataObserver())
+ manager.stickyItemPositionsObserver = observer
+ doReturn(23).`when`(observer).calculateNewStickyItemPosition(any())
+ doNothing().`when`(manager).recycleStickyItem(any())
+
+ observer.handleChange()
+
+ verify(observer).calculateNewStickyItemPosition(adapter)
+ verify(manager).recycleStickyItem(null)
+ }
+
+ @Test
+ fun `GIVEN an ItemPositionsAdapterDataObserver WHEN calculateNewStickyItemPosition is called for a top item the sticky position is first in adaptor`() {
+ manager = spy(FakeStickyItemLayoutManager(mock(), StickyItemPlacement.TOP))
+ val adapter: FakeStickyItemsAdapter = mock()
+ doReturn(true).`when`(adapter).isStickyItem(3)
+ doReturn(true).`when`(adapter).isStickyItem(5)
+ doReturn(10).`when`(manager).itemCount
+ manager.stickyItemPositionsObserver = manager.ItemPositionsAdapterDataObserver()
+
+ assertEquals(3, manager.stickyItemPositionsObserver.calculateNewStickyItemPosition(adapter))
+ }
+
+ @Test
+ fun `GIVEN an ItemPositionsAdapterDataObserver WHEN calculateNewStickyItemPosition is called for a bottom item the sticky position is last in adaptor`() {
+ manager = spy(FakeStickyItemLayoutManager(mock(), StickyItemPlacement.BOTTOM))
+ val adapter: FakeStickyItemsAdapter = mock()
+ doReturn(true).`when`(adapter).isStickyItem(3)
+ doReturn(true).`when`(adapter).isStickyItem(5)
+ doReturn(10).`when`(manager).itemCount
+ manager.stickyItemPositionsObserver = manager.ItemPositionsAdapterDataObserver()
+
+ assertEquals(5, manager.stickyItemPositionsObserver.calculateNewStickyItemPosition(adapter))
+ }
+
+ @Test
+ fun `WHEN get is called for a reversed StickyItemPlacement#TOP layout manager THEN a StickyHeaderLinearLayoutManager is returned`() {
+ val result = StickyItemsLinearLayoutManager.get<FakeStickyItemsAdapter>(
+ mock(),
+ StickyItemPlacement.TOP,
+ true,
+ )
+
+ assertTrue(result is StickyHeaderLinearLayoutManager)
+ assertTrue(result.reverseLayout)
+ }
+
+ @Test
+ fun `WHEN get is called for a not reversed StickyItemPlacement#TOP layout manager THEN a StickyHeaderLinearLayoutManager is returned`() {
+ val result = StickyItemsLinearLayoutManager.get<FakeStickyItemsAdapter>(
+ mock(),
+ StickyItemPlacement.TOP,
+ false,
+ )
+
+ assertTrue(result is StickyHeaderLinearLayoutManager)
+ assertFalse(result.reverseLayout)
+ }
+
+ @Test
+ fun `WHEN get is called for a reversed StickyItemPlacement#BOTTOM layout manager THEN a StickyFooterLinearLayoutManager is returned`() {
+ val result = StickyItemsLinearLayoutManager.get<FakeStickyItemsAdapter>(
+ mock(),
+ StickyItemPlacement.BOTTOM,
+ true,
+ )
+
+ assertTrue(result is StickyFooterLinearLayoutManager)
+ assertTrue(result.reverseLayout)
+ }
+
+ @Test
+ fun `WHEN get is called for a not reversed StickyItemPlacement#BOTTOM layout manager THEN a StickyFooterLinearLayoutManager is returned`() {
+ val result = StickyItemsLinearLayoutManager.get<FakeStickyItemsAdapter>(
+ mock(),
+ StickyItemPlacement.BOTTOM,
+ false,
+ )
+
+ assertTrue(result is StickyFooterLinearLayoutManager)
+ assertFalse(result.reverseLayout)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/menu/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/browser/menu/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..49324d83c5
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1,3 @@
+mock-maker-inline
+// This allows mocking final classes (classes are final by default in Kotlin)
+
diff --git a/mobile/android/android-components/components/browser/menu/src/test/resources/mockito-extensions/robolectric.properties b/mobile/android/android-components/components/browser/menu/src/test/resources/mockito-extensions/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/resources/mockito-extensions/robolectric.properties
@@ -0,0 +1 @@
+sdk=28
diff --git a/mobile/android/android-components/components/browser/menu/src/test/resources/robolectric.properties b/mobile/android/android-components/components/browser/menu/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/browser/menu/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28