diff --git a/.github/pr-screenshots/2343-android-back/1a-chat.png b/.github/pr-screenshots/2343-android-back/1a-chat.png new file mode 100644 index 0000000000..625c013973 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/1a-chat.png differ diff --git a/.github/pr-screenshots/2343-android-back/1b-tray-open.png b/.github/pr-screenshots/2343-android-back/1b-tray-open.png new file mode 100644 index 0000000000..e09ce436fd Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/1b-tray-open.png differ diff --git a/.github/pr-screenshots/2343-android-back/1c-back-tray-closed.png b/.github/pr-screenshots/2343-android-back/1c-back-tray-closed.png new file mode 100644 index 0000000000..625c013973 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/1c-back-tray-closed.png differ diff --git a/.github/pr-screenshots/2343-android-back/1d-back-chats.png b/.github/pr-screenshots/2343-android-back/1d-back-chats.png new file mode 100644 index 0000000000..284dd39f40 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/1d-back-chats.png differ diff --git a/.github/pr-screenshots/2343-android-back/2a-full-client-dm.png b/.github/pr-screenshots/2343-android-back/2a-full-client-dm.png new file mode 100644 index 0000000000..0c981151de Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/2a-full-client-dm.png differ diff --git a/.github/pr-screenshots/2343-android-back/2b-full-client-page2.png b/.github/pr-screenshots/2343-android-back/2b-full-client-page2.png new file mode 100644 index 0000000000..e04095f636 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/2b-full-client-page2.png differ diff --git a/.github/pr-screenshots/2343-android-back/2c-back-previous-web-page.png b/.github/pr-screenshots/2343-android-back/2c-back-previous-web-page.png new file mode 100644 index 0000000000..400dcc7bb0 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/2c-back-previous-web-page.png differ diff --git a/.github/pr-screenshots/2343-android-back/2d-back-leaves.png b/.github/pr-screenshots/2343-android-back/2d-back-leaves.png new file mode 100644 index 0000000000..7be93ac7c8 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/2d-back-leaves.png differ diff --git a/.github/pr-screenshots/2343-android-back/2e-floating-exit-leaves.png b/.github/pr-screenshots/2343-android-back/2e-floating-exit-leaves.png new file mode 100644 index 0000000000..5ff34e2267 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/2e-floating-exit-leaves.png differ diff --git a/.github/pr-screenshots/2343-android-back/4a-drawer-open.png b/.github/pr-screenshots/2343-android-back/4a-drawer-open.png new file mode 100644 index 0000000000..abdf6d866b Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/4a-drawer-open.png differ diff --git a/.github/pr-screenshots/2343-android-back/4b-back-drawer-closed.png b/.github/pr-screenshots/2343-android-back/4b-back-drawer-closed.png new file mode 100644 index 0000000000..89bb8f7d95 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/4b-back-drawer-closed.png differ diff --git a/.github/pr-screenshots/2343-android-back/phone/1a-chat.png b/.github/pr-screenshots/2343-android-back/phone/1a-chat.png new file mode 100644 index 0000000000..e5ca0f60bb Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/phone/1a-chat.png differ diff --git a/.github/pr-screenshots/2343-android-back/phone/1b-tray-open.png b/.github/pr-screenshots/2343-android-back/phone/1b-tray-open.png new file mode 100644 index 0000000000..d8471a47a4 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/phone/1b-tray-open.png differ diff --git a/.github/pr-screenshots/2343-android-back/phone/1c-back-tray-closed.png b/.github/pr-screenshots/2343-android-back/phone/1c-back-tray-closed.png new file mode 100644 index 0000000000..e5ca0f60bb Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/phone/1c-back-tray-closed.png differ diff --git a/.github/pr-screenshots/2343-android-back/phone/1d-back-chats.png b/.github/pr-screenshots/2343-android-back/phone/1d-back-chats.png new file mode 100644 index 0000000000..d7f36ee517 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/phone/1d-back-chats.png differ diff --git a/.github/pr-screenshots/2343-android-back/phone/2a-full-client-dm.png b/.github/pr-screenshots/2343-android-back/phone/2a-full-client-dm.png new file mode 100644 index 0000000000..60b40fb169 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/phone/2a-full-client-dm.png differ diff --git a/.github/pr-screenshots/2343-android-back/phone/2b-full-client-page2.png b/.github/pr-screenshots/2343-android-back/phone/2b-full-client-page2.png new file mode 100644 index 0000000000..a564236d75 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/phone/2b-full-client-page2.png differ diff --git a/.github/pr-screenshots/2343-android-back/phone/2c-back-left-full-client-FAIL.png b/.github/pr-screenshots/2343-android-back/phone/2c-back-left-full-client-FAIL.png new file mode 100644 index 0000000000..70292a7c57 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/phone/2c-back-left-full-client-FAIL.png differ diff --git a/.github/pr-screenshots/2343-android-back/phone/2e-floating-exit-leaves.png b/.github/pr-screenshots/2343-android-back/phone/2e-floating-exit-leaves.png new file mode 100644 index 0000000000..9928f6be1b Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/phone/2e-floating-exit-leaves.png differ diff --git a/.github/pr-screenshots/2343-android-back/phone/4a-drawer-open.png b/.github/pr-screenshots/2343-android-back/phone/4a-drawer-open.png new file mode 100644 index 0000000000..100b14e24c Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/phone/4a-drawer-open.png differ diff --git a/.github/pr-screenshots/2343-android-back/phone/4b-back-drawer-closed.png b/.github/pr-screenshots/2343-android-back/phone/4b-back-drawer-closed.png new file mode 100644 index 0000000000..a1813bf8d1 Binary files /dev/null and b/.github/pr-screenshots/2343-android-back/phone/4b-back-drawer-closed.png differ diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index d972de6c7f..75f1544946 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -21,6 +21,8 @@ On both platforms the system's own design and components are the standard: Apple Older rules below that prescribe a custom surface where a native component exists describe the current implementation, not the target. Change them to the native component when the screen is next touched, and update the rule in the same change. +Back closes the topmost surface before it leaves the screen: an in-screen surface that is not its own route, such as the composer's attach tray, holds the screen with Expo Router's `usePreventRemove` and closes itself; the full client steps back through its web history first, while its floating exit still leaves at once. Do not add `BackHandler` listeners for new surfaces. The predictive back preview does not run yet: React Native 0.86's `ReactActivity` registers an always-enabled `OnBackPressedCallback` on targetSdk 36, so the system has no back-to-home animation to show and JS screens do not follow the gesture progress. Do not claim predictive back until that callback is released when the root route cannot go back. + ## Application dialogs - Application-owned confirmations and choice menus use `showAppDialog` (or `confirmDestructive` / `showActionMenu`) and the root `AppDialogHost`. Do not call React Native `Alert.alert` directly from a screen or feature. Per Native first, iOS shows system alerts (menus follow [iOS menus and native controls](#ios-menus-and-native-controls)), and Android shows the Material 3 `AlertDialog` from `@expo/ui/jetpack-compose`, themed by `seedColor` = accent. Operating-system permission prompts, photo pickers and the share sheet remain system-owned. diff --git a/apps/mobile/app/full-client.tsx b/apps/mobile/app/full-client.tsx index 363879944a..9631dfeb1f 100644 --- a/apps/mobile/app/full-client.tsx +++ b/apps/mobile/app/full-client.tsx @@ -1,13 +1,13 @@ import { Text } from '../src/ui/primitives/system-text'; -import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useLocalSearchParams, useNavigation, usePreventRemove, useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useState } from 'react'; -import { ActivityIndicator, Pressable, View } from 'react-native'; +import { useRef, useState } from 'react'; +import { ActivityIndicator, Platform, Pressable, View } from 'react-native'; import { fullClientSource, fullClientTarget, type FullClientParams } from '../src/host/full-client'; import { useHostSession, useHostSnapshot } from '../src/host/host-session'; import { FloatingExit } from '../src/ui/floating-exit'; -import { FullClientView } from '../src/ui/full-client-view'; +import { FullClientView, type FullClientWeb } from '../src/ui/full-client-view'; import { t } from '../src/ui/strings'; import { useTheme } from '../src/ui/theme'; @@ -26,7 +26,20 @@ export default function FullClientScreen() { snapshot.connection.route !== '' ? snapshot.connection.route : (saved?.routes[0] ?? ''); const revoked = saved === null || state === 'refused' || snapshot.connection.status === 'refused'; - const close = () => (router.canGoBack() ? router.back() : router.replace('/')); + const navigation = useNavigation(); + const web = useRef(null); + const exiting = useRef(false); + const [webBack, setWebBack] = useState(false); + usePreventRemove(Platform.OS === 'android' && webBack, ({ data }) => { + if (exiting.current) navigation.dispatch(data.action); + else web.current?.goBack(); + }); + + const close = () => { + exiting.current = true; + if (router.canGoBack()) router.back(); + else router.replace('/'); + }; const retry = () => { setState('loading'); setAttempt((value) => value + 1); @@ -75,10 +88,12 @@ export default function FullClientScreen() { setState((current) => (current === 'loading' ? 'loaded' : current))} onFailed={setState} + onCanGoBack={setWebBack} /> {state === 'loading' ? ( + setMenu(false), + ); const actions = composerActions({ text: value, files: files.length, diff --git a/apps/mobile/src/ui/full-client-view.tsx b/apps/mobile/src/ui/full-client-view.tsx index 5ac4efde6a..9c440e0cd9 100644 --- a/apps/mobile/src/ui/full-client-view.tsx +++ b/apps/mobile/src/ui/full-client-view.tsx @@ -1,18 +1,31 @@ +import type { Ref } from 'react'; import { Linking } from 'react-native'; import { WebView } from 'react-native-webview'; import { staysInFullClient, type FullClientSource } from '../host/full-client'; +export type FullClientWeb = { goBack(): void }; + export interface FullClientViewProps { route: string; source: FullClientSource; onLoaded(): void; onFailed(kind: 'refused' | 'unreachable'): void; + onCanGoBack?(canGoBack: boolean): void; + ref?: Ref; } -export function FullClientView({ route, source, onLoaded, onFailed }: FullClientViewProps) { +export function FullClientView({ + route, + source, + onLoaded, + onFailed, + onCanGoBack, + ref, +}: FullClientViewProps) { return ( onCanGoBack?.(state.canGoBack)} onHttpError={(event) => { if (event.nativeEvent.statusCode === 401) onFailed('refused'); else if (event.nativeEvent.statusCode >= 500) onFailed('unreachable'); diff --git a/apps/mobile/test/composer-dictation.test.tsx b/apps/mobile/test/composer-dictation.test.tsx index 80aac1b7ac..829b285a0c 100644 --- a/apps/mobile/test/composer-dictation.test.tsx +++ b/apps/mobile/test/composer-dictation.test.tsx @@ -53,6 +53,7 @@ vi.mock('react-native-reanimated', async () => { }); vi.mock('react-native-svg', () => ({ default: () => null, Path: () => null })); vi.mock('expo-haptics', () => import('./expo-haptics-shim')); +vi.mock('expo-router', () => ({ usePreventRemove: () => {} })); vi.mock('expo-speech-recognition', () => ({ ExpoSpeechRecognitionModule: { isRecognitionAvailable: () => speech.available, diff --git a/apps/mobile/test/conversation-navigation.test.tsx b/apps/mobile/test/conversation-navigation.test.tsx index 966779cee3..e8536f0219 100644 --- a/apps/mobile/test/conversation-navigation.test.tsx +++ b/apps/mobile/test/conversation-navigation.test.tsx @@ -28,6 +28,7 @@ vi.mock('../src/ui/shell/recent-chats', () => ({ vi.mock('../src/host/native-source-drafts', () => ({ useSourceDraft: () => ['', () => {}] })); vi.mock('expo-router', () => ({ useIsFocused: () => true, + usePreventRemove: () => {}, Stack: { Screen: ({ options, diff --git a/apps/mobile/test/full-client-back-android.test.tsx b/apps/mobile/test/full-client-back-android.test.tsx new file mode 100644 index 0000000000..bdef8312dd --- /dev/null +++ b/apps/mobile/test/full-client-back-android.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { act, createElement, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; + +const nav = vi.hoisted(() => ({ + prevent: false, + onPrevent: undefined as undefined | ((event: { data: { action: unknown } }) => void), + dispatched: [] as unknown[], + back: vi.fn(), + goBack: vi.fn(), + canGoBack: undefined as undefined | ((value: boolean) => void), + exit: undefined as undefined | (() => void), +})); + +vi.mock('react-native', async () => ({ + ...(await import('./react-native-shim')), + Platform: { OS: 'android' }, + View: ({ children }: { children?: ReactNode }) => createElement('div', {}, children), +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({}), + useRouter: () => ({ canGoBack: () => true, back: nav.back, replace: vi.fn() }), + useNavigation: () => ({ dispatch: (action: unknown) => nav.dispatched.push(action) }), + usePreventRemove: ( + prevent: boolean, + callback: (event: { data: { action: unknown } }) => void, + ) => { + nav.prevent = prevent; + nav.onPrevent = callback; + }, +})); +vi.mock('../src/host/host-session', () => ({ + useHostSession: () => ({ + saved: { hostId: 'h', routes: ['https://host.example'], credential: 'c' }, + client: undefined, + }), + useHostSnapshot: () => ({ connection: { route: 'https://host.example', status: 'online' } }), +})); +vi.mock('../src/ui/floating-exit', () => ({ + FloatingExit: ({ onExit }: { onExit(): void }) => { + nav.exit = onExit; + return null; + }, +})); +vi.mock('../src/ui/full-client-view', () => ({ + FullClientView: (props: { ref: { current: unknown }; onCanGoBack(value: boolean): void }) => { + props.ref.current = { goBack: nav.goBack }; + nav.canGoBack = props.onCanGoBack; + return null; + }, +})); +vi.mock('../src/ui/theme', () => ({ useTheme: () => ({ background: '#fff', ink: '#000' }) })); +vi.mock('../src/ui/primitives/system-text', () => ({ Text: () => null })); + +import FullClientScreen from '../app/full-client'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +let root: Root | undefined; +afterEach(() => act(() => root?.unmount())); + +it('steps back through web history on Android Back, and the exit button still leaves', async () => { + const host = document.createElement('div'); + root = createRoot(host); + await act(async () => root!.render()); + expect(nav.prevent).toBe(false); + + await act(async () => nav.canGoBack!(true)); + expect(nav.prevent).toBe(true); + const action = { type: 'GO_BACK' }; + nav.onPrevent!({ data: { action } }); + expect(nav.goBack).toHaveBeenCalledTimes(1); + expect(nav.dispatched).toEqual([]); + + nav.exit!(); + expect(nav.back).toHaveBeenCalledTimes(1); + nav.onPrevent!({ data: { action } }); + expect(nav.dispatched).toEqual([action]); + expect(nav.goBack).toHaveBeenCalledTimes(1); +}); diff --git a/changes/2343-android-back.md b/changes/2343-android-back.md new file mode 100644 index 0000000000..689032d9ed --- /dev/null +++ b/changes/2343-android-back.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- On Android, Back now closes the composer's attach tray and steps back inside the full client before leaving the screen ([#2343](https://github.com/BotHarness/DeepSeekBot/pull/2343)). + +## zh + +- Android 手机端的返回键现在会先收起输入框的附件栏、在完整客户端里先回到上一页,再离开当前页面([#2343](https://github.com/BotHarness/DeepSeekBot/pull/2343))。