diff --git a/app/package.json b/app/package.json index 7464099..ec8ccb8 100644 --- a/app/package.json +++ b/app/package.json @@ -13,6 +13,8 @@ "dependencies": { "parksmarter-client": "*", "expo": "~53.0.0", + "expo-file-system": "~18.1.11", + "expo-sharing": "~13.1.5", "expo-secure-store": "~14.0.0", "expo-location": "~18.0.0", "expo-notifications": "~0.29.0", diff --git a/app/src/api/client.ts b/app/src/api/client.ts index 2f4891d..f8f46ca 100644 --- a/app/src/api/client.ts +++ b/app/src/api/client.ts @@ -3,6 +3,7 @@ import * as Localization from 'expo-localization'; import { ParkSmarterClient, type EnvironmentName } from 'parksmarter-client'; import { secureTokenStore } from './secureTokenStore'; import { authBus } from '@/auth/authBus'; +import { logLine } from '@/features/diagnostics/fileLogger'; const env = (Constants.expoConfig?.extra?.psEnvironment as EnvironmentName) ?? 'prodv2'; @@ -17,7 +18,11 @@ export const ps = new ParkSmarterClient({ tokens: secureTokenStore, localeCode: (Localization.getLocales()[0]?.languageCode ?? 'en').toLowerCase(), onUnauthorized: () => authBus.onUnauthorized?.(), - // Redacted request/response logging -> logcat (grep "PS →" / "PS ←"). - // Toggle at runtime with ps.setLogRequests(false), or flip extra.debugHttp. + // Redacted request/response logging -> logcat AND the on-device log file (when + // enabled in Diagnostics). Toggle at runtime with ps.setLogRequests(). logRequests: Boolean(Constants.expoConfig?.extra?.debugHttp), + logSink: (line: string) => { + console.log(line); + logLine(line); + }, }); diff --git a/app/src/features/diagnostics/fileLogger.ts b/app/src/features/diagnostics/fileLogger.ts new file mode 100644 index 0000000..bd1b3d4 --- /dev/null +++ b/app/src/features/diagnostics/fileLogger.ts @@ -0,0 +1,83 @@ +import * as FileSystem from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +/** + * On-device debug log. When enabled, API requests/responses (already redacted by + * the client) and app events are appended to a file the user can share afterward + * — useful for capturing a real parking-session attempt while away from a computer. + */ +const LOG_URI = FileSystem.documentDirectory + 'bbp-debug.log'; +const ENABLED_KEY = 'ps_file_logging'; +const MAX_LINES = 4000; + +let enabled = false; +let buffer: string[] = []; +let flushTimer: ReturnType | null = null; + +export async function initFileLogging(): Promise { + enabled = (await AsyncStorage.getItem(ENABLED_KEY)) === 'true'; + if (enabled) { + // Carry over the previous session's log so a crash mid-test isn't lost. + try { + const info = await FileSystem.getInfoAsync(LOG_URI); + if (info.exists) { + const prev = await FileSystem.readAsStringAsync(LOG_URI); + buffer = prev.split('\n').slice(-MAX_LINES); + } + } catch { + /* ignore */ + } + } + return enabled; +} + +export function isFileLoggingEnabled(): boolean { + return enabled; +} + +export async function setFileLogging(on: boolean): Promise { + enabled = on; + await AsyncStorage.setItem(ENABLED_KEY, String(on)); + if (on) logLine('--- logging enabled ---'); + else await flush(); +} + +export function logLine(line: string): void { + if (!enabled) return; + buffer.push(`${new Date().toISOString()} ${line}`); + if (buffer.length > MAX_LINES) buffer = buffer.slice(-MAX_LINES); + if (flushTimer) clearTimeout(flushTimer); + flushTimer = setTimeout(() => void flush(), 1200); +} + +export async function flush(): Promise { + try { + await FileSystem.writeAsStringAsync(LOG_URI, buffer.join('\n')); + } catch { + /* ignore */ + } +} + +export async function shareLog(): Promise { + await flush(); + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(LOG_URI, { + mimeType: 'text/plain', + dialogTitle: 'BigBrainParking debug log', + }); + } +} + +export async function clearLog(): Promise { + buffer = []; + try { + await FileSystem.deleteAsync(LOG_URI, { idempotent: true }); + } catch { + /* ignore */ + } +} + +export function logStats(): { lines: number } { + return { lines: buffer.length }; +} diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx index bc9356c..21df729 100644 --- a/app/src/navigation/RootNavigator.tsx +++ b/app/src/navigation/RootNavigator.tsx @@ -21,6 +21,7 @@ import { VehiclesScreen } from '@/screens/VehiclesScreen'; import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen'; import { NotificationsScreen } from '@/screens/NotificationsScreen'; import { StartSessionScreen } from '@/screens/StartSessionScreen'; +import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen'; import { useTheme } from '@/theme/ThemeContext'; import type { Zone } from 'parksmarter-client'; @@ -33,6 +34,7 @@ export type RootStackParamList = { Vehicles: undefined; PaymentMethods: undefined; Notifications: undefined; + Diagnostics: undefined; }; export type TabParamList = { @@ -131,6 +133,11 @@ export function RootNavigator() { component={NotificationsScreen} options={{ title: 'Notifications' }} /> + ) : ( diff --git a/app/src/screens/AccountScreen.tsx b/app/src/screens/AccountScreen.tsx index b0e43be..dbcfd90 100644 --- a/app/src/screens/AccountScreen.tsx +++ b/app/src/screens/AccountScreen.tsx @@ -1,10 +1,8 @@ -import React, { useState } from 'react'; +import React from 'react'; import { ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; -import Constants from 'expo-constants'; import { Ionicons } from '@expo/vector-icons'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { ps } from '@/api/client'; import { useAuth } from '@/auth/AuthContext'; import { useTheme } from '@/theme/ThemeContext'; import type { RootStackParamList } from '@/navigation/RootNavigator'; @@ -15,9 +13,6 @@ export function AccountScreen() { const { colors, mode, toggle } = useTheme(); const { logout } = useAuth(); const navigation = useNavigation