diff --git a/patches/react-native-bottom-tabs@1.4.0.patch b/patches/react-native-bottom-tabs@1.4.0.patch new file mode 100644 index 000000000..e7816536a --- /dev/null +++ b/patches/react-native-bottom-tabs@1.4.0.patch @@ -0,0 +1,74 @@ +diff --git a/android/src/main/java/com/rcttabview/RCTTabView.kt b/android/src/main/java/com/rcttabview/RCTTabView.kt +index 3c3d4bdbe2b070f8cc177f6175429fdcdf2d2304..7e5384366e6b8930ac1b65e99b27de980098f7d6 100644 +--- a/android/src/main/java/com/rcttabview/RCTTabView.kt ++++ b/android/src/main/java/com/rcttabview/RCTTabView.kt +@@ -35,6 +35,8 @@ import coil3.size.Scale + import com.facebook.react.bridge.ReadableArray + import com.facebook.react.common.assets.ReactFontManager + import com.facebook.react.modules.core.ReactChoreographer ++import com.facebook.react.uimanager.PointerEvents ++import com.facebook.react.uimanager.ReactPointerEventsView + import com.facebook.react.views.text.ReactTypefaceUtils + import com.google.android.material.bottomnavigation.BottomNavigationView + import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_AUTO +@@ -42,7 +44,11 @@ import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY + import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_UNLABELED + import com.google.android.material.transition.platform.MaterialFadeThrough + +-class ExtendedBottomNavigationView(context: Context) : BottomNavigationView(context) { ++class ExtendedBottomNavigationView(context: Context) : ++ BottomNavigationView(context), ReactPointerEventsView { ++ override val pointerEvents: PointerEvents ++ get() = if (visibility == VISIBLE) PointerEvents.AUTO else PointerEvents.NONE ++ + override fun getMaxItemCount(): Int { + return 100 + } +@@ -100,25 +106,36 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { + uiModeConfiguration = resources.configuration.uiMode + + post { +- addOnLayoutChangeListener { _, left, top, right, bottom, ++ addOnLayoutChangeListener { _, _, _, _, _, + _, _, _, _ -> +- val newWidth = right - left +- val newHeight = bottom - top +- + // Notify about tab bar height. + onTabBarMeasuredListener?.invoke(Utils.convertPixelsToDp(context, bottomNavigation.height).toInt()) + +- if (newWidth != lastReportedSize?.width || newHeight != lastReportedSize?.height) { +- val dpWidth = Utils.convertPixelsToDp(context, layoutHolder.width) +- val dpHeight = Utils.convertPixelsToDp(context, layoutHolder.height) +- +- onNativeLayoutListener?.invoke(dpWidth, dpHeight) +- lastReportedSize = Size(newWidth, newHeight) +- } ++ reportLayoutHolderSizeIfChanged() ++ } ++ // When only the tab bar visibility changes (tabBarHidden), the container keeps ++ // its bounds so the listener above never fires — only layoutHolder grows or ++ // shrinks. Observe layoutHolder itself so the new content size reaches JS. ++ layoutHolder.addOnLayoutChangeListener { _, _, _, _, _, ++ _, _, _, _ -> ++ reportLayoutHolderSizeIfChanged() + } + } + } + ++ private fun reportLayoutHolderSizeIfChanged() { ++ val newWidth = layoutHolder.width ++ val newHeight = layoutHolder.height ++ ++ if (newWidth != lastReportedSize?.width || newHeight != lastReportedSize?.height) { ++ val dpWidth = Utils.convertPixelsToDp(context, newWidth) ++ val dpHeight = Utils.convertPixelsToDp(context, newHeight) ++ ++ onNativeLayoutListener?.invoke(dpWidth, dpHeight) ++ lastReportedSize = Size(newWidth, newHeight) ++ } ++ } ++ + private val layoutCallback = Choreographer.FrameCallback { + isLayoutEnqueued = false + refreshLayout() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7cdb1531..7f1a353e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,7 @@ patchedDependencies: metro@0.84.4: bc3c69aec12bccf391b5180caffd62a4043c81bcf5848949a4b50e79184be6f7 ollama-ai-provider-v2@3.3.1: 4cb5921e69158814eeeed8d71c80621bd261f33d769b6afabba0db40931155eb react-native-keyboard-controller@1.21.13: 6f5bed7a836aae55a44a2b0a97532e6d73d9dfb87032457499dfe3ff2c239186 + react-native-bottom-tabs@1.4.0: a674adebbffa129d31142129bd5e4357f8ebe0f62438002f156c2c3358b9404d react-native-nitro-healthkit@1.0.0: c1231cd559844fba1fa13c5d09d4f9a84ecd4211707055a1ea2c3ba9930108e7 react-native-reanimated@4.5.0: 98a5ad9821aaded8cb131bcc4d0b77f31aa2489850cccfd2f13db70259fe1bd1 uniwind@1.6.5: 791bd6e5e63c234764db0263b41a5f7aa5c6259270634d6fc7405845de987eeb @@ -105,7 +106,7 @@ importers: version: 7.29.7 '@bottom-tabs/react-navigation': specifier: 1.4.0 - version: 1.4.0(patch_hash=3b020e5c2082d73190742b6c8fbdf6712b3db373d93c237c4ee935a92118a61e)(expo-router@57.0.6)(react-native-bottom-tabs@1.4.0(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) + version: 1.4.0(patch_hash=3b020e5c2082d73190742b6c8fbdf6712b3db373d93c237c4ee935a92118a61e)(expo-router@57.0.6)(react-native-bottom-tabs@1.4.0(patch_hash=a674adebbffa129d31142129bd5e4357f8ebe0f62438002f156c2c3358b9404d)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) '@cherrystudio/ai-core': specifier: workspace:* version: link:packages/ai-core @@ -306,7 +307,7 @@ importers: version: 0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) react-native-bottom-tabs: specifier: 1.4.0 - version: 1.4.0(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) + version: 1.4.0(patch_hash=a674adebbffa129d31142129bd5e4357f8ebe0f62438002f156c2c3358b9404d)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) react-native-chart-kit: specifier: 7.0.2 version: 7.0.2(react-native-svg@15.15.4(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) @@ -10472,13 +10473,13 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@bottom-tabs/react-navigation@1.4.0(patch_hash=3b020e5c2082d73190742b6c8fbdf6712b3db373d93c237c4ee935a92118a61e)(expo-router@57.0.6)(react-native-bottom-tabs@1.4.0(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)': + '@bottom-tabs/react-navigation@1.4.0(patch_hash=3b020e5c2082d73190742b6c8fbdf6712b3db373d93c237c4ee935a92118a61e)(expo-router@57.0.6)(react-native-bottom-tabs@1.4.0(patch_hash=a674adebbffa129d31142129bd5e4357f8ebe0f62438002f156c2c3358b9404d)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)': dependencies: color: 5.0.3 expo-router: 57.0.6(99a60ce696d3161db83ad1be3337f823) react: 19.2.3 react-native: 0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) - react-native-bottom-tabs: 1.4.0(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) + react-native-bottom-tabs: 1.4.0(patch_hash=a674adebbffa129d31142129bd5e4357f8ebe0f62438002f156c2c3358b9404d)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) '@drizzle-team/brocli@0.10.2': {} @@ -17348,7 +17349,7 @@ snapshots: react-is@19.2.6: {} - react-native-bottom-tabs@1.4.0(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): + react-native-bottom-tabs@1.4.0(patch_hash=a674adebbffa129d31142129bd5e4357f8ebe0f62438002f156c2c3358b9404d)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c2e04ab38..be928f4e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -70,6 +70,7 @@ patchedDependencies: react-native-nitro-healthkit@1.0.0: patches/react-native-nitro-healthkit@1.0.0.patch expo-widgets@57.0.8: patches/expo-widgets@57.0.8.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch + react-native-bottom-tabs@1.4.0: patches/react-native-bottom-tabs@1.4.0.patch minimumReleaseAgeExclude: - '@expo/cli@57.0.3' diff --git a/src/frontend/components/navigation/__tests__/reactNativeBottomTabsPatch.test.ts b/src/frontend/components/navigation/__tests__/reactNativeBottomTabsPatch.test.ts new file mode 100644 index 000000000..6933fcb03 --- /dev/null +++ b/src/frontend/components/navigation/__tests__/reactNativeBottomTabsPatch.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const androidTabViewSource = readFileSync( + path.join( + process.cwd(), + 'node_modules/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt', + ), + 'utf8', +); + +// Guards patches/react-native-bottom-tabs@1.4.0.patch. The patch keeps the +// holder scene size synchronized and excludes the hidden Material bottom bar +// from React Native touch target traversal. +describe('patched react-native-bottom-tabs Android hidden bar behavior', () => { + it('mirrors the upstream layoutHolder reporting fix for issue 557', () => { + expect(androidTabViewSource).toContain('private fun reportLayoutHolderSizeIfChanged()'); + expect(androidTabViewSource).toMatch( + /addOnLayoutChangeListener\s*\{[^}]*onTabBarMeasuredListener[^}]*reportLayoutHolderSizeIfChanged\(\)/, + ); + expect(androidTabViewSource).toMatch( + /layoutHolder\.addOnLayoutChangeListener\s*\{[^}]*reportLayoutHolderSizeIfChanged\(\)/, + ); + expect(androidTabViewSource).toContain('val newWidth = layoutHolder.width'); + expect(androidTabViewSource).toContain('val newHeight = layoutHolder.height'); + }); + + it('relies on Android visibility changes to request layout', () => { + const setTabBarHiddenSource = androidTabViewSource.match( + /fun setTabBarHidden\(isHidden: Boolean\) \{[\s\S]*?\n \}/, + )?.[0]; + + expect(setTabBarHiddenSource).toBeDefined(); + expect(setTabBarHiddenSource).not.toContain('requestLayout()'); + expect(setTabBarHiddenSource).toContain('bottomNavigation.visibility = GONE'); + expect(setTabBarHiddenSource).toContain('bottomNavigation.visibility = VISIBLE'); + }); + + it('removes the hidden native tab bar from React Native touch target traversal', () => { + expect(androidTabViewSource).toMatch( + /class ExtendedBottomNavigationView\(context: Context\)\s*:\s*BottomNavigationView\(context\), ReactPointerEventsView/, + ); + expect(androidTabViewSource).toContain('override val pointerEvents: PointerEvents'); + expect(androidTabViewSource).toContain( + 'get() = if (visibility == VISIBLE) PointerEvents.AUTO else PointerEvents.NONE', + ); + }); +});