diff --git a/android/brave_java_sources.gni b/android/brave_java_sources.gni index 858214db108..d39ad461917 100644 --- a/android/brave_java_sources.gni +++ b/android/brave_java_sources.gni @@ -100,6 +100,7 @@ brave_java_sources = [ "../../brave/android/java/org/chromium/chrome/browser/browsing_data/BraveClearBrowsingDataFragment.java", "../../brave/android/java/org/chromium/chrome/browser/browsing_data/BraveShortcutsUtils.java", "../../brave/android/java/org/chromium/chrome/browser/compositor/layouts/BraveToolbarSwipeLayout.java", + "../../brave/android/java/org/chromium/chrome/browser/compositor/layouts/phone/BraveNewTabAnimationLayout.java", "../../brave/android/java/org/chromium/chrome/browser/contextmenu/BraveChromeContextMenuPopulator.java", "../../brave/android/java/org/chromium/chrome/browser/cosmetic_filters/BraveCosmeticFiltersUtils.java", "../../brave/android/java/org/chromium/chrome/browser/crash/BravePureJavaExceptionReporter.java", diff --git a/android/java/org/chromium/chrome/browser/compositor/layouts/phone/BraveNewTabAnimationLayout.java b/android/java/org/chromium/chrome/browser/compositor/layouts/phone/BraveNewTabAnimationLayout.java new file mode 100644 index 00000000000..99bf94de69f --- /dev/null +++ b/android/java/org/chromium/chrome/browser/compositor/layouts/phone/BraveNewTabAnimationLayout.java @@ -0,0 +1,88 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * 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 https://mozilla.org/MPL/2.0/. */ + +package org.chromium.chrome.browser.compositor.layouts.phone; + +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import org.chromium.build.annotations.NullMarked; +import org.chromium.chrome.R; +import org.chromium.chrome.browser.toolbar.bottom.BottomToolbarConfiguration; + +/** + * Brave helper for {@link NewTabAnimationLayout}. + * + *
When Brave's bottom toolbar is active the top tab-switcher button may be GONE (empty rect) or + * visible alongside the bottom one. In either case the bottom toolbar's tab-switcher button is the + * correct animation destination. {@link #compute} encapsulates the fallback logic and returns a + * {@link BraveTabSwitcherState} that callers unpack into the four variables that drive {@code + * tabCreatedInBackground}: {@code tabSwitcherRect}, {@code tabSwitcherButtonIsVisible}, {@code + * toolbarHeight}, and {@code isTopToolbar}. + * + *
Both classes share the same Java package so no import is needed in the patch. + */ +@NullMarked +public class BraveNewTabAnimationLayout { + + /** Immutable snapshot of the four variables that drive the background-tab animation. */ + public static class BraveTabSwitcherState { + public final Rect rect; + public final boolean isVisible; + public final int toolbarHeight; + public final boolean isTopToolbar; + + BraveTabSwitcherState( + Rect rect, boolean isVisible, int toolbarHeight, boolean isTopToolbar) { + this.rect = rect; + this.isVisible = isVisible; + this.toolbarHeight = toolbarHeight; + this.isTopToolbar = isTopToolbar; + } + } + + /** + * Returns a {@link BraveTabSwitcherState} for the background-tab animation. + * + *
When Brave's bottom toolbar is active and its tab-switcher button is visible, overrides + * the supplied originals so the animation targets the bottom button. Otherwise returns the + * originals unchanged (e.g. split-screen small-window mode where the bottom toolbar is hidden, + * or when Brave bottom controls are not enabled). + * + * @param animationHostView The host view used to traverse to the root view. + * @param origRect Original {@code tabSwitcherRect} from the top toolbar button. + * @param origIsVisible Original {@code tabSwitcherButtonIsVisible}. + * @param origToolbarHeight Original {@code toolbarHeight} (top-toolbar Y). + * @param origIsTopToolbar Original {@code isTopToolbar} flag. + */ + public static BraveTabSwitcherState compute( + ViewGroup animationHostView, + Rect origRect, + boolean origIsVisible, + int origToolbarHeight, + boolean origIsTopToolbar) { + if (BottomToolbarConfiguration.isBraveBottomControlsEnabled()) { + View bottomToolbar = animationHostView.getRootView().findViewById(R.id.bottom_toolbar); + if (bottomToolbar != null) { + View bottomButton = bottomToolbar.findViewById(R.id.bottom_tab_switcher_button); + if (bottomButton != null) { + Rect braveRect = new Rect(); + if (bottomButton.getGlobalVisibleRect(braveRect)) { + return new BraveTabSwitcherState( + braveRect, + /* isVisible= */ true, + /* toolbarHeight= */ braveRect.top, + /* isTopToolbar= */ false); + } + } + } + } + return new BraveTabSwitcherState( + origRect, origIsVisible, origToolbarHeight, origIsTopToolbar); + } + + private BraveNewTabAnimationLayout() {} +} diff --git a/android/junit/BUILD.gn b/android/junit/BUILD.gn index 03b4932bf0c..f9bc31ddab5 100644 --- a/android/junit/BUILD.gn +++ b/android/junit/BUILD.gn @@ -10,6 +10,14 @@ import("//third_party/jni_zero/jni_zero.gni") testonly = true if (is_android) { + robolectric_library( + "brave_junit_tests_org.chromium.chrome.browser.compositor") { + resources_package = "org.chromium.chrome" + sources = [ "src/org/chromium/chrome/browser/compositor/layouts/phone/BraveNewTabAnimationLayoutUnitTest.java" ] + + deps = [ "//chrome/android/junit:chrome_junit_tests_helper" ] + } + robolectric_library( "brave_junit_tests_org.chromium.chrome.browser.homepage") { sources = [ diff --git a/android/junit/src/org/chromium/chrome/browser/compositor/layouts/phone/BraveNewTabAnimationLayoutUnitTest.java b/android/junit/src/org/chromium/chrome/browser/compositor/layouts/phone/BraveNewTabAnimationLayoutUnitTest.java new file mode 100644 index 00000000000..454b44f2f3f --- /dev/null +++ b/android/junit/src/org/chromium/chrome/browser/compositor/layouts/phone/BraveNewTabAnimationLayoutUnitTest.java @@ -0,0 +1,157 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * 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 https://mozilla.org/MPL/2.0/. */ + +package org.chromium.chrome.browser.compositor.layouts.phone; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import androidx.test.filters.SmallTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.annotation.Config; + +import org.chromium.base.BravePreferenceKeys; +import org.chromium.base.test.BaseRobolectricTestRunner; +import org.chromium.chrome.R; +import org.chromium.chrome.browser.compositor.layouts.phone.BraveNewTabAnimationLayout.BraveTabSwitcherState; +import org.chromium.chrome.browser.preferences.ChromeSharedPreferences; + +/** Unit tests for {@link BraveNewTabAnimationLayout}. */ +@RunWith(BaseRobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class BraveNewTabAnimationLayoutUnitTest { + private static final int ORIG_TOOLBAR_HEIGHT = 150; + private static final int BOTTOM_LEFT = 700; + private static final int BOTTOM_TOP = 1900; + private static final int BOTTOM_RIGHT = 850; + private static final int BOTTOM_BOTTOM = 2000; + + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + + @Mock private ViewGroup mAnimationHostView; + @Mock private View mRootView; + @Mock private View mBottomToolbar; + @Mock private View mBottomTabSwitcherButton; + + private final Rect mOrigRect = new Rect(0, 0, 0, 0); + + @Before + public void setUp() { + // Wire getRootView() -> mRootView -> bottom_toolbar -> bottom_tab_switcher_button. + lenient().when(mAnimationHostView.getRootView()).thenReturn(mRootView); + lenient().when(mRootView.findViewById(R.id.bottom_toolbar)).thenReturn(mBottomToolbar); + lenient() + .when(mBottomToolbar.findViewById(R.id.bottom_tab_switcher_button)) + .thenReturn(mBottomTabSwitcherButton); + lenient() + .when(mBottomTabSwitcherButton.getGlobalVisibleRect(any(Rect.class))) + .thenAnswer( + invocation -> { + Rect r = invocation.getArgument(0); + r.set(BOTTOM_LEFT, BOTTOM_TOP, BOTTOM_RIGHT, BOTTOM_BOTTOM); + return true; + }); + } + + @After + public void tearDown() { + ChromeSharedPreferences.getInstance() + .writeBoolean(BravePreferenceKeys.BRAVE_BOTTOM_TOOLBAR_SET_KEY, false); + } + + private void enableBraveBottomControls() { + ChromeSharedPreferences.getInstance() + .writeBoolean(BravePreferenceKeys.BRAVE_BOTTOM_TOOLBAR_SET_KEY, true); + ChromeSharedPreferences.getInstance() + .writeBoolean(BravePreferenceKeys.BRAVE_BOTTOM_TOOLBAR_ENABLED_KEY, true); + } + + private void disableBraveBottomControls() { + ChromeSharedPreferences.getInstance() + .writeBoolean(BravePreferenceKeys.BRAVE_BOTTOM_TOOLBAR_SET_KEY, true); + ChromeSharedPreferences.getInstance() + .writeBoolean(BravePreferenceKeys.BRAVE_BOTTOM_TOOLBAR_ENABLED_KEY, false); + } + + @Test + @SmallTest + public void testCompute_bottomEnabled_bottomButtonVisible_returnsBottomState() { + enableBraveBottomControls(); + + BraveTabSwitcherState s = + BraveNewTabAnimationLayout.compute( + mAnimationHostView, + mOrigRect, + /* origIsVisible= */ false, + ORIG_TOOLBAR_HEIGHT, + /* origIsTopToolbar= */ true); + + assertFalse("Rect should not be empty", s.rect.isEmpty()); + assertEquals(BOTTOM_LEFT, s.rect.left); + assertEquals(BOTTOM_TOP, s.rect.top); + assertEquals(BOTTOM_RIGHT, s.rect.right); + assertEquals(BOTTOM_BOTTOM, s.rect.bottom); + assertTrue("isVisible should be true", s.isVisible); + assertEquals("toolbarHeight should equal bottom button top", BOTTOM_TOP, s.toolbarHeight); + assertFalse("isTopToolbar should be false", s.isTopToolbar); + } + + @Test + @SmallTest + public void testCompute_bottomDisabled_returnsOriginals() { + // Bottom address bar is enabled + disableBraveBottomControls(); + + BraveTabSwitcherState s = + BraveNewTabAnimationLayout.compute( + mAnimationHostView, + mOrigRect, + /* origIsVisible= */ false, + ORIG_TOOLBAR_HEIGHT, + /* origIsTopToolbar= */ true); + + assertTrue("Rect should be the original empty rect", s.rect.isEmpty()); + assertFalse("isVisible should be the original false", s.isVisible); + assertEquals(ORIG_TOOLBAR_HEIGHT, s.toolbarHeight); + assertTrue("isTopToolbar should be the original true", s.isTopToolbar); + } + + @Test + @SmallTest + public void testCompute_bottomEnabled_bottomButtonNotVisible_returnsOriginals() { + // Split-screen / small-window case + enableBraveBottomControls(); + when(mBottomTabSwitcherButton.getGlobalVisibleRect(any(Rect.class))).thenReturn(false); + + BraveTabSwitcherState s = + BraveNewTabAnimationLayout.compute( + mAnimationHostView, + mOrigRect, + /* origIsVisible= */ false, + ORIG_TOOLBAR_HEIGHT, + /* origIsTopToolbar= */ true); + + assertTrue("Rect should be the original when bottom button not visible", s.rect.isEmpty()); + assertEquals(ORIG_TOOLBAR_HEIGHT, s.toolbarHeight); + assertTrue( + "isTopToolbar should be original when bottom button not visible", s.isTopToolbar); + } +} diff --git a/patches/chrome-android-java-src-org-chromium-chrome-browser-compositor-layouts-phone-NewTabAnimationLayout.java.patch b/patches/chrome-android-java-src-org-chromium-chrome-browser-compositor-layouts-phone-NewTabAnimationLayout.java.patch new file mode 100644 index 00000000000..02cd35fec1a --- /dev/null +++ b/patches/chrome-android-java-src-org-chromium-chrome-browser-compositor-layouts-phone-NewTabAnimationLayout.java.patch @@ -0,0 +1,12 @@ +diff --git a/chrome/android/java/src/org/chromium/chrome/browser/compositor/layouts/phone/NewTabAnimationLayout.java b/chrome/android/java/src/org/chromium/chrome/browser/compositor/layouts/phone/NewTabAnimationLayout.java +index 9433bb6de83f05fe8c9b269a43119418a325578b..37f981bd9f86da89cbef7c89b285bbdf307e6f20 100644 +--- a/chrome/android/java/src/org/chromium/chrome/browser/compositor/layouts/phone/NewTabAnimationLayout.java ++++ b/chrome/android/java/src/org/chromium/chrome/browser/compositor/layouts/phone/NewTabAnimationLayout.java +@@ -700,6 +700,7 @@ public class NewTabAnimationLayout extends Layout { + isRegularNtp || ToolbarPositionController.shouldShowToolbarOnTop(animationTab); + int toolbarHeight = toolbarPosition[1] + getTopInsetIfNeeded(animationTab); + ++ BraveNewTabAnimationLayout.BraveTabSwitcherState braveState = BraveNewTabAnimationLayout.compute(mAnimationHostView, tabSwitcherRect, tabSwitcherButtonIsVisible, toolbarHeight, isTopToolbar); tabSwitcherRect = braveState.rect; tabSwitcherButtonIsVisible = braveState.isVisible; toolbarHeight = braveState.toolbarHeight; isTopToolbar = braveState.isTopToolbar; + Rect compositorViewRect = new Rect(); + mCompositorViewHolder.getGlobalVisibleRect(compositorViewRect); + diff --git a/test/BUILD.gn b/test/BUILD.gn index 9bedfeb5813..b56d6f5ec2a 100644 --- a/test/BUILD.gn +++ b/test/BUILD.gn @@ -1687,6 +1687,7 @@ if (is_android) { "//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.app", "//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.autofill", "//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.brave_origin", + "//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.compositor", "//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.custom_search_engines", "//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.homepage", "//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.ntp",