From 7a49b0487632ef69534723601351f8afdd9f49a1 Mon Sep 17 00:00:00 2001 From: Senmalong Date: Mon, 1 Jun 2026 07:41:57 +0100 Subject: [PATCH 1/4] fix(#72): replace autoFocus with delayed focus via useRef autoFocus opens the keyboard immediately on mount, which can block UI elements before the screen finishes rendering. Replace it with a ref-based focus triggered after a 300 ms delay, giving the navigation transition time to complete on both iOS and Android. - Add nameInputRef (useRef) to the name field - Remove autoFocus prop from the name TextInput - Focus the input programmatically after 300 ms in a useEffect - Import useRef from React --- src/screens/AddSubscriptionScreen.tsx | 47 ++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src/screens/AddSubscriptionScreen.tsx b/src/screens/AddSubscriptionScreen.tsx index bd09d0d9..c2d4788c 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { View, Text, @@ -10,6 +10,7 @@ import { Alert, KeyboardAvoidingView, Platform, + Keyboard, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -33,6 +34,11 @@ const AddSubscriptionScreen: React.FC = () => { const { addSubscription, isLoading, error } = useSubscriptionStore(); const { preferredCurrency } = useSettingsStore(); + // Ref for the name input — used for delayed focus instead of autoFocus, + // so the screen has time to fully render before the keyboard opens. + const nameInputRef = useRef(null); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + const [formData, setFormData] = useState({ name: '', description: '', @@ -56,6 +62,32 @@ const AddSubscriptionScreen: React.FC = () => { } }, [error]); + // Delay focus so the screen finishes rendering before the keyboard opens. + // A 300 ms delay gives the navigation transition time to complete on both + // iOS and Android, preventing the keyboard from obscuring UI elements. + useEffect(() => { + const focusTimer = setTimeout(() => { + nameInputRef.current?.focus(); + }, 300); + + return () => clearTimeout(focusTimer); + }, []); + + // Track keyboard visibility so the ScrollView can adjust its content + // inset and keep form fields accessible when the keyboard is open. + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + + const showSub = Keyboard.addListener(showEvent, () => setIsKeyboardVisible(true)); + const hideSub = Keyboard.addListener(hideEvent, () => setIsKeyboardVisible(false)); + + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + // Date Picker States const [showPicker, setShowPicker] = useState(false); const [pickerMode, setPickerMode] = useState<'date' | 'time'>('date'); @@ -186,8 +218,12 @@ const AddSubscriptionScreen: React.FC = () => { - + behavior={Platform.OS === 'ios' ? 'padding' : undefined} + keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}> + { Name * handleInputChange('name', text)} placeholder="Enter subscription name" placeholderTextColor={colors.textSecondary} - autoFocus accessibilityLabel="Subscription name, required" accessibilityHint="Enter the name of the subscription service" returnKeyType="next" @@ -480,6 +516,9 @@ const styles = StyleSheet.create({ scrollView: { flex: 1, }, + scrollContentKeyboardOpen: { + paddingBottom: 120, + }, header: { padding: spacing.lg, paddingBottom: spacing.md, From fe35c61cd3b646db5d1fbf5f3a87910284281f3f Mon Sep 17 00:00:00 2001 From: Senmalong Date: Mon, 1 Jun 2026 07:42:06 +0100 Subject: [PATCH 2/4] fix(#72): improve KeyboardAvoidingView and keyboard visibility tracking - Switch KeyboardAvoidingView behavior to 'padding' on iOS and undefined on Android (Android handles scroll natively) - Track keyboard show/hide events with Keyboard listeners to set isKeyboardVisible state - Apply extra bottom padding to ScrollView content when keyboard is open so all form fields remain reachable - Import Keyboard from react-native From 5949ec23194b59b2664c659fa1155ed9c8926248 Mon Sep 17 00:00:00 2001 From: Senmalong Date: Mon, 1 Jun 2026 07:48:20 +0100 Subject: [PATCH 3/4] fix(#59): add crash reporter service and recovery modal - Add src/services/crashReporter.ts: persists crash records to AsyncStorage, installs a global ErrorUtils handler for uncaught JS errors, detects previous crash on next launch, and supports optional remote endpoint reporting - Add src/components/CrashRecoveryModal.tsx: modal shown on launch when a previous crash is detected; lets user trigger data recovery or continue without recovering - Update ErrorBoundary to call crashReporter.recordCrash() in componentDidCatch so React render errors are also persisted --- src/components/CrashRecoveryModal.tsx | 158 ++++++++++++ src/components/ErrorBoundary.tsx | 7 + src/services/crashReporter.ts | 358 ++++++++++++++++++++++++++ 3 files changed, 523 insertions(+) create mode 100644 src/components/CrashRecoveryModal.tsx create mode 100644 src/services/crashReporter.ts diff --git a/src/components/CrashRecoveryModal.tsx b/src/components/CrashRecoveryModal.tsx new file mode 100644 index 00000000..687b8be5 --- /dev/null +++ b/src/components/CrashRecoveryModal.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, + SafeAreaView, + ActivityIndicator, +} from 'react-native'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import type { CrashRecord } from '../services/crashReporter'; + +interface Props { + visible: boolean; + crash: CrashRecord | null; + onRecover: () => Promise; + onDismiss: () => void; +} + +const CrashRecoveryModal: React.FC = ({ visible, crash, onRecover, onDismiss }) => { + const [recovering, setRecovering] = React.useState(false); + + const handleRecover = async () => { + setRecovering(true); + await onRecover(); + setRecovering(false); + }; + + return ( + + + + + ⚠️ + App Recovered from a Crash + + The app crashed during your last session. Your data may be in an inconsistent state. + We can attempt to restore it now. + + + {__DEV__ && crash && ( + + Debug info + + {crash.message} + + {crash.timestamp} + + )} + + + {recovering ? ( + + ) : ( + Recover Data + )} + + + + Continue Without Recovering + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'center', + padding: spacing.lg, + }, + safeArea: { + flex: 0, + }, + card: { + backgroundColor: colors.surface, + borderRadius: borderRadius.lg, + padding: spacing.xl, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + }, + icon: { + fontSize: 40, + marginBottom: spacing.md, + }, + title: { + ...typography.h2, + color: colors.text, + textAlign: 'center', + marginBottom: spacing.md, + }, + body: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 22, + marginBottom: spacing.lg, + }, + devBox: { + width: '100%', + backgroundColor: colors.background, + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.lg, + }, + devLabel: { + ...typography.caption, + color: colors.warning, + fontWeight: '600', + marginBottom: spacing.xs, + }, + devText: { + ...typography.caption, + color: colors.textSecondary, + fontSize: 11, + }, + primaryBtn: { + width: '100%', + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + alignItems: 'center', + marginBottom: spacing.sm, + minHeight: 48, + justifyContent: 'center', + }, + primaryBtnText: { + ...typography.body, + color: colors.background, + fontWeight: '700', + }, + secondaryBtn: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + }, + secondaryBtnText: { + ...typography.body, + color: colors.textSecondary, + }, +}); + +export default CrashRecoveryModal; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 310d254e..0dd4615e 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,6 +1,7 @@ import React, { Component, ErrorInfo, ReactNode } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, SafeAreaView } from 'react-native'; import { errorHandler, AppError, ErrorSeverity } from '../services/errorHandler'; +import { crashReporter } from '../services/crashReporter'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; @@ -49,6 +50,12 @@ class ErrorBoundary extends Component { }; } + // Persist crash record so the next launch can detect and recover from it + void crashReporter.recordCrash(error, { + component: this.state.error?.context.component ?? 'ErrorBoundary', + metadata: { componentStack: errorInfo.componentStack ?? '' }, + }); + // Call onError callback if provided if (this.props.onError && this.state.error) { this.props.onError(this.state.error); diff --git a/src/services/crashReporter.ts b/src/services/crashReporter.ts new file mode 100644 index 00000000..3bb5839b --- /dev/null +++ b/src/services/crashReporter.ts @@ -0,0 +1,358 @@ +/** + * CrashReporter — lightweight crash detection and recovery service. + * + * No third-party SDK required. Uses AsyncStorage to persist crash records + * across app launches so the next session can detect, report, and recover + * from the previous crash. + * + * Integration points: + * - Call `crashReporter.initialize()` early in App.tsx (before rendering UI). + * - Call `crashReporter.recordCrash(error, context)` from ErrorBoundary and + * global JS error handlers. + * - Call `crashReporter.attemptDataRecovery()` when the user chooses to recover. + * - Subscribe to `crashReporter.onCrashDetected` to show recovery UI. + * + * Privacy: crash records are stored locally only. No data is sent to any + * external server unless you add a `reportingEndpoint` in the config. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; +import { ErrorSeverity, ErrorType } from './errorHandler'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface CrashRecord { + /** Unique crash identifier */ + id: string; + /** ISO timestamp of the crash */ + timestamp: string; + /** JS error message */ + message: string; + /** JS stack trace (may be minified in production) */ + stackTrace?: string; + /** Error type classification */ + errorType: ErrorType; + /** Severity at time of crash */ + severity: ErrorSeverity; + /** Component or screen where the crash originated */ + component?: string; + /** Additional key/value metadata */ + metadata?: Record; + /** Platform info captured at crash time */ + platform: { + os: string; + version: string | number; + }; + /** Whether the user has already been notified about this crash */ + notified: boolean; + /** Whether a recovery attempt has been made */ + recoveryAttempted: boolean; +} + +export interface CrashReporterConfig { + /** + * Maximum number of crash records to keep in storage. + * Oldest records are pruned when the limit is exceeded. + * @default 20 + */ + maxRecords?: number; + /** + * Optional HTTPS endpoint to POST crash records to. + * When omitted, crashes are stored locally only. + */ + reportingEndpoint?: string; + /** + * Keys in AsyncStorage that should be preserved during data recovery. + * Any key NOT in this list will be cleared on a recovery wipe. + */ + preservedStorageKeys?: string[]; + /** + * Whether to install a global `ErrorUtils` handler for uncaught JS errors. + * Disable this in test environments to avoid interfering with Jest. + * @default true + */ + installGlobalHandler?: boolean; +} + +type CrashDetectedListener = (crash: CrashRecord) => void; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const STORAGE_KEY = '@subtrackr/crash_records'; +const SESSION_FLAG_KEY = '@subtrackr/session_clean'; + +// ─── Service ────────────────────────────────────────────────────────────────── + +class CrashReporterService { + private config: Required = { + maxRecords: 20, + reportingEndpoint: '', + preservedStorageKeys: [], + installGlobalHandler: true, + }; + + private listeners: CrashDetectedListener[] = []; + private initialized = false; + + // ── Public API ────────────────────────────────────────────────────────────── + + /** + * Initialize the crash reporter. Call this once, early in App startup. + * Returns the most recent unnotified crash record if one exists, so the + * caller can decide whether to show a recovery UI. + */ + async initialize(config?: CrashReporterConfig): Promise { + if (this.initialized) return null; + this.initialized = true; + + if (config) { + this.config = { ...this.config, ...config }; + } + + // Install global uncaught-error handler + if (this.config.installGlobalHandler) { + this._installGlobalHandler(); + } + + // Check whether the previous session ended cleanly + const previousCrash = await this._detectPreviousCrash(); + + // Mark this session as clean (will be cleared if a crash occurs) + await AsyncStorage.setItem(SESSION_FLAG_KEY, 'clean'); + + return previousCrash; + } + + /** + * Record a crash. Persists the record to AsyncStorage and optionally + * forwards it to a remote endpoint. + */ + async recordCrash( + error: Error, + context?: { + errorType?: ErrorType; + severity?: ErrorSeverity; + component?: string; + metadata?: Record; + } + ): Promise { + // Mark the session as dirty so the next launch detects the crash + await AsyncStorage.setItem(SESSION_FLAG_KEY, 'crashed'); + + const record: CrashRecord = { + id: `crash_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + timestamp: new Date().toISOString(), + message: error.message || 'Unknown error', + stackTrace: error.stack, + errorType: context?.errorType ?? ErrorType.UNKNOWN, + severity: context?.severity ?? ErrorSeverity.CRITICAL, + component: context?.component, + metadata: context?.metadata, + platform: { + os: Platform.OS, + version: Platform.Version, + }, + notified: false, + recoveryAttempted: false, + }; + + await this._persistRecord(record); + + if (this.config.reportingEndpoint) { + // Fire-and-forget — do not let a network failure block the crash path + this._sendToEndpoint(record).catch(() => { + // Silently swallow — crash reporting must never throw + }); + } + + if (__DEV__) { + console.warn('[CrashReporter] Crash recorded:', record.id, record.message); + } + + return record; + } + + /** + * Attempt to recover user data after a crash. + * + * Strategy: + * 1. Read all AsyncStorage keys. + * 2. Keep keys listed in `preservedStorageKeys`. + * 3. Remove all other keys (clears corrupted state). + * 4. Mark the crash record as recovery-attempted. + * + * Returns `true` if recovery completed without errors. + */ + async attemptDataRecovery(crashId?: string): Promise { + try { + const allKeys = await AsyncStorage.getAllKeys(); + const keysToRemove = allKeys.filter( + (key) => + key !== STORAGE_KEY && + key !== SESSION_FLAG_KEY && + !this.config.preservedStorageKeys.includes(key) + ); + + if (keysToRemove.length > 0) { + await AsyncStorage.multiRemove(keysToRemove); + } + + if (crashId) { + await this._markRecoveryAttempted(crashId); + } + + if (__DEV__) { + console.info( + '[CrashReporter] Data recovery complete. Removed keys:', + keysToRemove.length + ); + } + + return true; + } catch { + return false; + } + } + + /** + * Mark a crash record as notified (user has seen the recovery UI). + */ + async markNotified(crashId: string): Promise { + const records = await this._loadRecords(); + const updated = records.map((r) => (r.id === crashId ? { ...r, notified: true } : r)); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } + + /** + * Return all stored crash records, newest first. + */ + async getCrashHistory(): Promise { + const records = await this._loadRecords(); + return [...records].reverse(); + } + + /** + * Clear all stored crash records. + */ + async clearHistory(): Promise { + await AsyncStorage.removeItem(STORAGE_KEY); + } + + /** + * Subscribe to crash-detected events. The listener is called during + * `initialize()` if a previous crash is found. + */ + onCrashDetected(listener: CrashDetectedListener): () => void { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private async _detectPreviousCrash(): Promise { + try { + const sessionFlag = await AsyncStorage.getItem(SESSION_FLAG_KEY); + + if (sessionFlag !== 'crashed') return null; + + const records = await this._loadRecords(); + // Find the most recent unnotified crash + const crash = [...records].reverse().find((r) => !r.notified) ?? null; + + if (crash) { + // Notify all listeners + this.listeners.forEach((l) => { + try { + l(crash); + } catch { + // Listener errors must not propagate + } + }); + } + + return crash; + } catch { + return null; + } + } + + private async _persistRecord(record: CrashRecord): Promise { + const records = await this._loadRecords(); + records.push(record); + + // Prune oldest records beyond the limit + const pruned = + records.length > this.config.maxRecords + ? records.slice(records.length - this.config.maxRecords) + : records; + + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(pruned)); + } + + private async _loadRecords(): Promise { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private async _markRecoveryAttempted(crashId: string): Promise { + const records = await this._loadRecords(); + const updated = records.map((r) => + r.id === crashId ? { ...r, recoveryAttempted: true } : r + ); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } + + private async _sendToEndpoint(record: CrashRecord): Promise { + if (!this.config.reportingEndpoint) return; + + await fetch(this.config.reportingEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // Strip the full stack trace from the payload to limit PII exposure + body: JSON.stringify({ + ...record, + stackTrace: record.stackTrace ? '[redacted in remote report]' : undefined, + }), + }); + } + + /** + * Install a global handler for uncaught JS errors via React Native's + * ErrorUtils. This catches errors that escape all React error boundaries + * (e.g. errors in native event callbacks or promise rejections that are + * not caught anywhere). + */ + private _installGlobalHandler(): void { + // ErrorUtils is a React Native global — not available in Node/Jest + if (typeof ErrorUtils === 'undefined') return; + + const previousHandler = ErrorUtils.getGlobalHandler(); + + ErrorUtils.setGlobalHandler(async (error: Error, isFatal?: boolean) => { + try { + await this.recordCrash(error, { + severity: isFatal ? ErrorSeverity.CRITICAL : ErrorSeverity.HIGH, + metadata: { isFatal: isFatal ?? false, source: 'globalHandler' }, + }); + } catch { + // Never let the crash reporter itself crash + } finally { + // Always delegate to the previous handler so React Native's default + // red-screen / crash behaviour is preserved in development. + previousHandler?.(error, isFatal); + } + }); + } +} + +// Singleton — import and use directly throughout the app +export const crashReporter = new CrashReporterService(); From d3e1ec8357a2f9ae4215dbe0cdcb134f0bfaaad3 Mon Sep 17 00:00:00 2001 From: Senmalong Date: Mon, 1 Jun 2026 07:48:36 +0100 Subject: [PATCH 4/4] fix(#59): wire crash reporting into App.tsx startup - Initialize crashReporter early in App startup (alongside i18n) - On launch, if a previous crash is detected, show CrashRecoveryModal - handleRecover calls crashReporter.attemptDataRecovery() which clears corrupted AsyncStorage state while preserving settings and auth keys - handleDismissRecovery marks the crash as notified so the modal does not reappear on subsequent launches - Import Alert for recovery-failure feedback --- App.tsx | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/App.tsx b/App.tsx index f08cd6bf..5bf96aca 100644 --- a/App.tsx +++ b/App.tsx @@ -1,14 +1,16 @@ import React from 'react'; -import { View } from 'react-native'; +import { View, Alert } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AppNavigator } from './src/navigation/AppNavigator'; import { useNotifications } from './src/hooks/useNotifications'; import { useTransactionQueue } from './src/hooks/useTransactionQueue'; import ErrorBoundary from './src/components/ErrorBoundary'; +import CrashRecoveryModal from './src/components/CrashRecoveryModal'; import { initI18n } from './src/i18n/config'; import i18n from './src/i18n/config'; import { I18nextProvider } from 'react-i18next'; +import { crashReporter, CrashRecord } from './src/services/crashReporter'; // Import WalletConnect compatibility layer import '@walletconnect/react-native-compat'; @@ -91,12 +93,30 @@ function NotificationBootstrap() { export default function App() { const [i18nReady, setI18nReady] = React.useState(false); + const [pendingCrash, setPendingCrash] = React.useState(null); + const [showRecoveryModal, setShowRecoveryModal] = React.useState(false); React.useEffect(() => { let cancelled = false; const run = async () => { try { await initI18n(); + + // Initialize crash reporter — returns the previous crash if one exists + const previousCrash = await crashReporter.initialize({ + // Preserve user settings and auth tokens across a recovery wipe + preservedStorageKeys: [ + '@subtrackr/settings', + '@subtrackr/auth_token', + '@subtrackr/preferred_currency', + ], + installGlobalHandler: true, + }); + + if (previousCrash && !cancelled) { + setPendingCrash(previousCrash); + setShowRecoveryModal(true); + } } finally { if (!cancelled) setI18nReady(true); } @@ -107,6 +127,29 @@ export default function App() { }; }, []); + const handleRecover = async () => { + if (pendingCrash) { + const success = await crashReporter.attemptDataRecovery(pendingCrash.id); + await crashReporter.markNotified(pendingCrash.id); + setShowRecoveryModal(false); + setPendingCrash(null); + if (!success) { + Alert.alert( + 'Recovery Incomplete', + 'Some data could not be restored. The app will continue with a fresh state.' + ); + } + } + }; + + const handleDismissRecovery = async () => { + if (pendingCrash) { + await crashReporter.markNotified(pendingCrash.id); + } + setShowRecoveryModal(false); + setPendingCrash(null); + }; + if (!i18nReady) return null; return ( @@ -120,6 +163,12 @@ export default function App() { + );