From 4c3fd7ac1ba660e218ca9fe56f41aff617336b73 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Thu, 27 Aug 2026 06:01:49 +0200 Subject: [PATCH] Fix Android composer keyboard docking Android edge-to-edge did not reliably resize the activity, leaving the absolute composer behind the keyboard. Move only the Android composer dock from native keyboard inset animation frames and retain the existing iOS path. --- apps/mobile/app.config.ts | 4 +- apps/mobile/package.json | 3 +- apps/mobile/src/app.tsx | 56 +++++--- .../workspace-screen.integration.test.tsx | 47 ++++++- apps/mobile/src/screens/workspace-screen.tsx | 124 ++++++++++-------- docs/COMPATIBILITY.md | 24 ++++ pnpm-lock.yaml | 17 +++ 7 files changed, 196 insertions(+), 79 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 48c5297..1280f1f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -41,7 +41,7 @@ if (projectId) { const config: ExpoConfig = { name: appName, slug, - version: "0.1.3", + version: "0.1.4", newArchEnabled: true, platforms: ["ios", "android"], icon: "./assets/icon.png", @@ -70,7 +70,7 @@ const config: ExpoConfig = { android: { package: androidPackage, ...(googleServicesFile ? { googleServicesFile } : {}), - versionCode: 5, + versionCode: 6, allowBackup: false, predictiveBackGestureEnabled: false, softwareKeyboardLayoutMode: "resize", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 297c40f..a1e7c5c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@opencode2-mobile/mobile", - "version": "0.1.3", + "version": "0.1.4", "private": true, "main": "index.ts", "scripts": { @@ -45,6 +45,7 @@ "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", + "react-native-keyboard-controller": "1.18.5", "react-native-reanimated": "4.1.7", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.16.0", diff --git a/apps/mobile/src/app.tsx b/apps/mobile/src/app.tsx index 39644ff..c76eccf 100644 --- a/apps/mobile/src/app.tsx +++ b/apps/mobile/src/app.tsx @@ -2,8 +2,9 @@ import { NavigationContainer, type Theme } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { SQLiteProvider } from "expo-sqlite"; import { Component, type ErrorInfo, type ReactNode } from "react"; -import { Pressable, Share, StyleSheet, Text, View } from "react-native"; +import { Platform, Pressable, Share, StyleSheet, Text, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { applicationName } from "./application-name"; @@ -48,28 +49,43 @@ const navigationTheme: Theme = { }; export default function App() { + const application = ( + + + + + + + + + + + + + + + + + + + + ); + return ( - - - - - - - - - - - - - - - - - - - + {Platform.OS === "android" ? ( + + {application} + + ) : ( + application + )} ); diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index d9eacd0..0d3c60a 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -10,7 +10,7 @@ import { import { type InfiniteData, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react-native"; import type { ReactNode } from "react"; -import { Dimensions, FlatList, RefreshControl } from "react-native"; +import { Dimensions, FlatList, Platform, RefreshControl } from "react-native"; import { openCodeQueryKeys } from "../state/open-code-query-keys"; import { WorkspaceSelectionProvider } from "../state/workspace-selection-context"; import { SessionScreen, WorkspaceScreen } from "./workspace-screen"; @@ -220,11 +220,56 @@ jest.mock("react-native-gesture-handler/ReanimatedSwipeable", () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => children, })); +jest.mock("react-native-keyboard-controller", () => { + const React = jest.requireActual("react"); + const { View } = jest.requireActual("react-native"); + return { + KeyboardStickyView: ({ children, ...props }: { children: ReactNode }) => + React.createElement(View, { ...props, testID: "keyboard-sticky-view" }, children), + }; +}); const mockGetSession = jest.mocked(getOpenCodeSession); const mockGetLocation = jest.mocked(getOpenCodeLocation); const mockListMessages = jest.mocked(listOpenCodeMessages); +test("moves only the Android composer dock with the keyboard", async () => { + const platformOS = Platform.OS; + Object.defineProperty(Platform, "OS", { configurable: true, value: "android" }); + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { networkMode: "always" }, + queries: { gcTime: Infinity, retry: false }, + }, + }); + const view = render( + + + , + ); + + try { + await screen.findByLabelText("Keyboard composer dock"); + expect(screen.getByTestId("keyboard-sticky-view")).toBeOnTheScreen(); + expect(screen.getByLabelText("Keyboard-aware session")).toBeOnTheScreen(); + } finally { + view.unmount(); + queryClient.clear(); + Object.defineProperty(Platform, "OS", { configurable: true, value: platformOS }); + } +}); + test("shows a permission blocking the open session and can reply", async () => { mockWorkspacePermissions = [ { diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index d162755..757f181 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -35,6 +35,7 @@ import { View, } from "react-native"; import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable"; +import { KeyboardStickyView } from "react-native-keyboard-controller"; import { useConnections } from "../connections/connections-context"; import type { RootStackParamList } from "../navigation/root-navigation"; import { useConnectionRuntime } from "../state/connection-runtime-context"; @@ -802,6 +803,58 @@ export function SessionScreen({ navigation, route }: SessionProps) { ); } + const composerDockContent = ( + + 0 ? ( + + ) : undefined + } + inbox={execution.inbox} + onAllowRetry={execution.allowRetry} + onCancelInbox={execution.cancelInbox} + onCheckAdmission={execution.reconcileAdmission} + onInterrupt={execution.interrupt} + onQueueInbox={execution.queueInbox} + onReplyPermission={workspaceSelection.replyPermission} + onSteerInbox={execution.steerInbox} + permissionReplyError={workspaceSelection.permissionReplyError} + permissions={sessionPermissions} + projectedMessageIds={execution.projectedMessageIds} + replyingPermissionId={workspaceSelection.replyingPermissionId} + /> + execution.submit(draft.draft)} + /> + + ); + return ( ) : null} - - - 0 ? ( - - ) : undefined - } - inbox={execution.inbox} - onAllowRetry={execution.allowRetry} - onCancelInbox={execution.cancelInbox} - onCheckAdmission={execution.reconcileAdmission} - onInterrupt={execution.interrupt} - onQueueInbox={execution.queueInbox} - onReplyPermission={workspaceSelection.replyPermission} - onSteerInbox={execution.steerInbox} - permissionReplyError={workspaceSelection.permissionReplyError} - permissions={sessionPermissions} - projectedMessageIds={execution.projectedMessageIds} - replyingPermissionId={workspaceSelection.replyingPermissionId} - /> - execution.submit(draft.draft)} - /> + {Platform.OS === "android" ? ( + + {composerDockContent} + + ) : ( + + {composerDockContent} - + )} ); diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 1eaef75..fbca940 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -580,3 +580,27 @@ The signed iPhone applied the preview update and showed no Live or session-list flicker during the controlled command checks. The trace and report retained no command text, address, credential, path, prompt, identifier, event payload, file content, or server content. + +## 2026-08-27: physical Android 17 composer probe + +### Stack + +- Mobile runtime: signed EAS preview build using Hermes +- Device: Pixel 8 Pro running Android 17 +- Expo SDK: 54.0.37 +- React Native: 0.81.5 +- Keyboard controller: 1.18.5 + +### Results + +| Probe | Result | +| --- | --- | +| Install and launch preview version 0.1.4, build 6 | Pass | +| Focus the session composer and show the software keyboard | Pass | +| Keep the composer visible directly above the keyboard | Pass | + +The prior build relied on activity resize and hid the composer behind the +keyboard on this device. The corrected build keeps the transcript container +fixed and moves only the composer dock from native keyboard inset animation +frames. The probe recorded no address, credential, identifier, path, prompt, or +server content. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ba1111..44e671c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,9 @@ importers: react-native-gesture-handler: specifier: ~2.28.0 version: 2.28.0(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0) + react-native-keyboard-controller: + specifier: 1.18.5 + version: 1.18.5(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.7(supports-color@8.1.1))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0)(supports-color@8.1.1))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0) react-native-reanimated: specifier: 4.1.7 version: 4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.7(supports-color@8.1.1))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0)(supports-color@8.1.1))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0) @@ -4097,6 +4100,13 @@ packages: react: '*' react-native: '*' + react-native-keyboard-controller@1.18.5: + resolution: {integrity: sha512-wbYN6Tcu3G5a05dhRYBgjgd74KqoYWuUmroLpigRg9cXy5uYo7prTMIvMgvLtARQtUF7BOtFggUnzgoBOgk0TQ==} + peerDependencies: + react: '*' + react-native: '*' + react-native-reanimated: '>=3.0.0' + react-native-reanimated@4.1.7: resolution: {integrity: sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==} peerDependencies: @@ -9724,6 +9734,13 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1) + react-native-keyboard-controller@1.18.5(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.7(supports-color@8.1.1))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0)(supports-color@8.1.1))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1) + react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0) + react-native-reanimated: 4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.7(supports-color@8.1.1))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0)(supports-color@8.1.1))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0) + react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.7(supports-color@8.1.1))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0)(supports-color@8.1.1))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0): dependencies: react: 19.1.0