diff --git a/.github/scripts/android-e2e.sh b/.github/scripts/android-e2e.sh index 6c0e96e2..2d1de918 100644 --- a/.github/scripts/android-e2e.sh +++ b/.github/scripts/android-e2e.sh @@ -2,6 +2,7 @@ set -euo pipefail trap 'adb logcat -d > "$GITHUB_WORKSPACE/android-logcat.txt" || true' EXIT +RECOVERY_PHRASE="${RECOVERY_PHRASE:-abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about}" cd "$GITHUB_WORKSPACE/android" ./gradlew :app:assembleRelease -PreactNativeArchitectures=x86_64 --no-daemon @@ -14,6 +15,7 @@ adb install -r "$APK_PATH" cd "$GITHUB_WORKSPACE" bash .maestro/scripts/prepare-device-motion.sh android +bash .maestro/scripts/set-system-clipboard.sh android "$RECOVERY_PHRASE" INVITE_CODE="$( curl --fail --silent --show-error \ @@ -25,4 +27,4 @@ INVITE_CODE_COMPACT="${INVITE_CODE//-/}" echo "::add-mask::$INVITE_CODE" echo "::add-mask::$INVITE_CODE_COMPACT" -maestro --platform=android test -e APP_ID=to.pubky.ring -e INVITE_CODE="$INVITE_CODE" .maestro +maestro --platform=android test -e APP_ID=to.pubky.ring -e INVITE_CODE="$INVITE_CODE" -e RECOVERY_PHRASE="$RECOVERY_PHRASE" .maestro diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index 40deba66..e3df7ce0 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -105,6 +105,9 @@ jobs: - name: Enable reduced motion run: bash .maestro/scripts/prepare-device-motion.sh ios + - name: Set recovery phrase clipboard + run: bash .maestro/scripts/set-system-clipboard.sh ios "${RECOVERY_PHRASE:-abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about}" + - name: Build iOS simulator app run: | cd ios @@ -156,7 +159,7 @@ jobs: - name: Run Maestro flows timeout-minutes: 30 - run: maestro --platform=ios --device "$SIM_UDID" test -e APP_ID=app.pubkyring -e INVITE_CODE="$INVITE_CODE" .maestro + run: maestro --platform=ios --device "$SIM_UDID" test -e APP_ID=app.pubkyring -e INVITE_CODE="$INVITE_CODE" -e RECOVERY_PHRASE="${RECOVERY_PHRASE:-abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about}" .maestro - name: Upload Maestro results uses: actions/upload-artifact@v7 diff --git a/.maestro/README.md b/.maestro/README.md index 49f62277..7f920571 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -50,6 +50,8 @@ HOMESERVER_ADMIN_PASSWORD=... yarn e2e:android The local `yarn e2e:*` scripts prepare the target simulator/emulator for reduced motion before Maestro runs. The app respects that platform accessibility setting, so sheet and navigation animations are disabled without requiring a special e2e build. +The local and CI e2e setup also seeds the iOS native system clipboard with a valid recovery phrase for import flows. Override the iOS clipboard value with `RECOVERY_PHRASE` if a flow needs a different mnemonic. Android currently fills the recovery phrase fields word-by-word because the emulator shell does not expose a portable system clipboard setter. + ## Continuous Integration `.github/workflows/ios-e2e.yml` builds the iOS simulator app, installs Maestro, boots an iPhone 17 simulator, installs the app, and runs all flows in `.maestro`. diff --git a/.maestro/flows/import-pubky-mnemonic.yaml b/.maestro/flows/import-pubky-mnemonic.yaml index ccf7d63c..8c0df143 100644 --- a/.maestro/flows/import-pubky-mnemonic.yaml +++ b/.maestro/flows/import-pubky-mnemonic.yaml @@ -1,5 +1,7 @@ appId: ${APP_ID} name: import-pubky-mnemonic +env: + RECOVERY_PHRASE: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about --- - launchApp: clearState: true @@ -19,43 +21,85 @@ name: import-pubky-mnemonic - waitForAnimationToEnd - assertVisible: id: MnemonicWordInput-1 +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: MnemonicWordInput-1 + - inputText: abandon + - tapOn: + id: MnemonicWordInput-2 + - inputText: ab + - tapOn: ability + - tapOn: + id: MnemonicWordInput-1 + - runFlow: + file: ../shared/clear-input-text.yaml + env: + INPUT_ID: MnemonicWordInput-1 + - runFlow: + file: ../shared/paste-system-clipboard.yaml + env: + INPUT_ID: MnemonicWordInput-1 +- runFlow: + when: + platform: Android + commands: + - tapOn: + id: MnemonicWordInput-1 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-2 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-3 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-4 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-5 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-6 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-7 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-8 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-9 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-10 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-11 + - inputText: ab + - tapOn: abandon + - tapOn: + id: MnemonicWordInput-12 + - inputText: abo + - tapOn: about +- extendedWaitUntil: + visible: + id: MnemonicImportButton + timeout: 5000 - tapOn: - id: MnemonicWordInput-1 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-2 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-3 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-4 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-5 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-6 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-7 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-8 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-9 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-10 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-11 -- inputText: abandon -- tapOn: - id: MnemonicWordInput-12 -- inputText: about -- pressKey: Enter + id: MnemonicImportButton - extendedWaitUntil: visible: id: import-success-title diff --git a/.maestro/scripts/run-local.sh b/.maestro/scripts/run-local.sh index 362ddeb6..b9259f8c 100755 --- a/.maestro/scripts/run-local.sh +++ b/.maestro/scripts/run-local.sh @@ -3,6 +3,7 @@ set -euo pipefail platform="${1:-}" flow="${2:-.maestro}" +recovery_phrase="${RECOVERY_PHRASE:-abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about}" case "$platform" in android) @@ -32,10 +33,12 @@ else fi bash .maestro/scripts/prepare-device-motion.sh "$platform" +bash .maestro/scripts/set-system-clipboard.sh "$platform" "$recovery_phrase" MAESTRO_CLI_NO_ANALYTICS=true \ MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED=true \ maestro --platform="$platform" test \ -e APP_ID="$app_id" \ -e HOMESERVER_ADMIN_PASSWORD="${HOMESERVER_ADMIN_PASSWORD:-}" \ + -e RECOVERY_PHRASE="$recovery_phrase" \ "$flow_target" diff --git a/.maestro/scripts/set-system-clipboard.sh b/.maestro/scripts/set-system-clipboard.sh new file mode 100755 index 00000000..1d59c6ea --- /dev/null +++ b/.maestro/scripts/set-system-clipboard.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +platform="${1:-}" +text="${2:-${RECOVERY_PHRASE:-}}" + +if [ -z "$text" ]; then + echo "Usage: $0 ios|android " >&2 + echo "Or set RECOVERY_PHRASE." >&2 + exit 1 +fi + +case "$platform" in + ios) + device="${SIM_UDID:-${IOS_SIM_UDID:-booted}}" + printf '%s' "$text" | xcrun simctl pbcopy "$device" + ;; + android) + echo "Android system clipboard is not set from adb; flows use inputText fallback." >&2 + ;; + *) + echo "Usage: $0 ios|android [text]" >&2 + exit 1 + ;; +esac diff --git a/.maestro/shared/paste-system-clipboard.yaml b/.maestro/shared/paste-system-clipboard.yaml new file mode 100644 index 00000000..ae7517c9 --- /dev/null +++ b/.maestro/shared/paste-system-clipboard.yaml @@ -0,0 +1,7 @@ +appId: ${APP_ID} +--- +- tapOn: + id: ${INPUT_ID} +- longPressOn: + id: ${INPUT_ID} +- tapOn: Paste diff --git a/src/components/KeyboardAccessory.tsx b/src/components/KeyboardAccessory.tsx new file mode 100644 index 00000000..3221f780 --- /dev/null +++ b/src/components/KeyboardAccessory.tsx @@ -0,0 +1,56 @@ +import React, { memo, ReactElement, ReactNode } from 'react'; +import { InputAccessoryView, Platform, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +interface KeyboardAccessoryProps { + accessoryIds: string[]; + children: ReactNode | (() => ReactNode); + visible: boolean; + containerStyle?: StyleProp; + androidContainerStyle?: StyleProp; +} + +const KeyboardAccessory = ({ + accessoryIds, + children, + visible, + containerStyle, + androidContainerStyle, +}: KeyboardAccessoryProps): ReactElement | null => { + const insets = useSafeAreaInsets(); + const renderContent = (): ReactNode => (typeof children === 'function' ? children() : children); + + if (Platform.OS === 'ios') { + return ( + <> + {accessoryIds.map(accessoryId => ( + + {visible ? renderContent() : null} + + ))} + + ); + } + + if (!visible) { + return null; + } + + return ( + + {renderContent()} + + ); +}; + +const styles = StyleSheet.create({ + androidAccessory: { + position: 'absolute', + left: 0, + right: 0, + zIndex: 2, + elevation: 2, + }, +}); + +export default memo(KeyboardAccessory); diff --git a/src/components/MnemonicForm.tsx b/src/components/MnemonicForm.tsx index 7eaeaa24..9fa82cb5 100644 --- a/src/components/MnemonicForm.tsx +++ b/src/components/MnemonicForm.tsx @@ -1,21 +1,22 @@ -import React, { memo, ReactElement, useCallback, useState, useRef, useMemo, useEffect } from 'react'; +import React, { memo, ReactElement, useCallback, useState, useRef, useMemo, useEffect, useId } from 'react'; import { StyleSheet, TextInput as NativeTextInput, TextInputKeyPressEvent, Keyboard, - KeyboardEvent, Platform, View, ScrollView, } from 'react-native'; +import { useTranslation } from 'react-i18next'; import * as bip39 from 'bip39'; import { TextInput } from '../theme/components.ts'; import Button from '../components/Button.tsx'; import { Result } from '@synonymdev/result'; -import i18n from '../i18n'; import { BodyMText } from '../theme/typography'; -import MnemonicSuggestionPill from './MnemonicSuggestionPill.tsx'; +import MnemonicSuggestionAccessory, { + getMnemonicSuggestionAccessoryId, +} from './MnemonicSuggestionAccessory.tsx'; import SafeAreaInset from './SafeAreaInset.tsx'; interface MnemonicFormProps { @@ -39,15 +40,14 @@ const cleanMnemonicWord = (word: string): string => { }; const MnemonicForm = ({ onCancel, onImport }: MnemonicFormProps): ReactElement => { + const { t } = useTranslation(); const [mnemonicWords, setMnemonicWords] = useState(createEmptyMnemonicWords); const [validWords, setValidWords] = useState(createValidWordState); const [focused, setFocused] = useState(null); const [loading, setLoading] = useState(false); const inputRefs = useRef<(NativeTextInput | null)[]>(Array(MNEMONIC_WORD_COUNT).fill(null)); - const suggestionRef = useRef(null); - const keyboardScreenYRef = useRef(null); - const androidSuggestionInsetRef = useRef(0); - const [androidSuggestionInset, setAndroidSuggestionInset] = useState(0); + const accessoryInstanceId = useId().replace(/:/g, ''); + const keyboardVisible = focused !== null; const suggestions = useMemo(() => { if (focused === null) { @@ -73,53 +73,6 @@ const MnemonicForm = ({ onCancel, onImport }: MnemonicFormProps): ReactElement = return (): void => clearTimeout(timer); }, []); - const measureAndroidSuggestionInset = useCallback(() => { - const keyboardTop = keyboardScreenYRef.current; - if (Platform.OS !== 'android' || keyboardTop === null) { - return; - } - - requestAnimationFrame(() => { - suggestionRef.current?.measureInWindow((_x, y, _width, height) => { - const naturalBottom = y + height + androidSuggestionInsetRef.current; - const nextInset = Math.max(0, naturalBottom - keyboardTop); - if (nextInset === androidSuggestionInsetRef.current) { - return; - } - - androidSuggestionInsetRef.current = nextInset; - setAndroidSuggestionInset(nextInset); - }); - }); - }, []); - - useEffect(() => { - if (Platform.OS !== 'android') { - return; - } - - const showSubscription = Keyboard.addListener('keyboardDidShow', (event: KeyboardEvent) => { - keyboardScreenYRef.current = event.endCoordinates.screenY; - androidSuggestionInsetRef.current = 0; - setAndroidSuggestionInset(0); - measureAndroidSuggestionInset(); - }); - const hideSubscription = Keyboard.addListener('keyboardDidHide', () => { - keyboardScreenYRef.current = null; - androidSuggestionInsetRef.current = 0; - setAndroidSuggestionInset(0); - }); - - return (): void => { - showSubscription.remove(); - hideSubscription.remove(); - }; - }, [measureAndroidSuggestionInset]); - - useEffect(() => { - measureAndroidSuggestionInset(); - }, [measureAndroidSuggestionInset, suggestions]); - // BIP39 word validation using official wordlist const isValidWord = useCallback((word: string): boolean => { if (!word) { @@ -151,6 +104,7 @@ const MnemonicForm = ({ onCancel, onImport }: MnemonicFormProps): ReactElement = const words = text.split(' ').map(cleanMnemonicWord); setMnemonicWords(words); setValidWords(words.map(word => isValidWord(word))); + setFocused(null); Keyboard.dismiss(); return; } @@ -284,6 +238,7 @@ const MnemonicForm = ({ onCancel, onImport }: MnemonicFormProps): ReactElement = if (focused < MNEMONIC_WORD_COUNT - 1) { inputRefs.current[focused + 1]?.focus(); } else { + setFocused(null); Keyboard.dismiss(); } }, @@ -314,6 +269,7 @@ const MnemonicForm = ({ onCancel, onImport }: MnemonicFormProps): ReactElement = autoCorrect={false} autoComplete="off" textContentType="none" + inputAccessoryViewID={getMnemonicSuggestionAccessoryId(index, accessoryInstanceId)} importantForAutofill="no" spellCheck={false} secureTextEntry={false} @@ -330,34 +286,18 @@ const MnemonicForm = ({ onCancel, onImport }: MnemonicFormProps): ReactElement = ); }; - const renderSuggestionContainer = (): ReactElement | null => { - if (suggestions.length === 0) { - return null; - } - - return ( - - {suggestions.map(word => ( - - ))} - - ); - }; - return ( - {i18n.t('addPubky.enterRecoveryWords')} + {t('addPubky.enterRecoveryWords')} @@ -371,46 +311,49 @@ const MnemonicForm = ({ onCancel, onImport }: MnemonicFormProps): ReactElement = - -