diff --git a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx index fa3b3b7..00a96ba 100644 --- a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx +++ b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx @@ -15,9 +15,9 @@ import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router, useLocalSearchParams } from 'expo-router'; import * as Device from 'expo-device'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { useBLE } from '@/contexts/BLEContext'; import { api } from '@/services/api'; +import * as wifiPasswordStore from '@/services/wifiPasswordStore'; import type { WiFiNetwork } from '@/services/ble'; import type { SensorSetupState, @@ -114,24 +114,28 @@ export default function SetupWiFiScreen() { const shouldCancelRef = useRef(false); // Saved WiFi passwords map (SSID -> password) + // Using useState to trigger re-renders when passwords are loaded + const [savedPasswords, setSavedPasswords] = useState>({}); + const [passwordsLoaded, setPasswordsLoaded] = useState(false); + // Also keep ref for saving (to avoid stale closures) const savedPasswordsRef = useRef>({}); // Load saved WiFi passwords on mount useEffect(() => { const loadSavedPasswords = async () => { try { - const saved = await AsyncStorage.getItem('WIFI_PASSWORDS'); - if (saved) { - savedPasswordsRef.current = JSON.parse(saved); - console.log('[SetupWiFi] Loaded saved passwords for', Object.keys(savedPasswordsRef.current).length, 'networks'); - } - // Also load legacy single password - const legacyPassword = await AsyncStorage.getItem('LAST_WIFI_PASSWORD'); - if (legacyPassword && !saved) { - setPassword(legacyPassword); - } + // Migrate from AsyncStorage to SecureStore if needed + await wifiPasswordStore.migrateFromAsyncStorage(); + + // Load all saved passwords from SecureStore + const passwords = await wifiPasswordStore.getAllWiFiPasswords(); + savedPasswordsRef.current = passwords; + setSavedPasswords(passwords); + console.log('[SetupWiFi] Loaded saved passwords for', Object.keys(passwords).length, 'networks'); } catch (error) { console.log('[SetupWiFi] Failed to load saved passwords:', error); + } finally { + setPasswordsLoaded(true); } }; loadSavedPasswords(); @@ -173,11 +177,27 @@ export default function SetupWiFiScreen() { const handleSelectNetwork = (network: WiFiNetwork) => { setSelectedNetwork(network); - // Auto-fill saved password for this network - const savedPwd = savedPasswordsRef.current[network.ssid]; - setPassword(savedPwd || ''); + // Auto-fill saved password for this network (use state, not ref) + const savedPwd = savedPasswords[network.ssid]; + if (savedPwd) { + setPassword(savedPwd); + console.log('[SetupWiFi] Auto-filled password for network:', network.ssid); + } else { + setPassword(''); + } }; + // Auto-fill password when passwords finish loading (if network already selected) + useEffect(() => { + if (passwordsLoaded && selectedNetwork && !password) { + const savedPwd = savedPasswords[selectedNetwork.ssid]; + if (savedPwd) { + setPassword(savedPwd); + console.log('[SetupWiFi] Auto-filled password after load for network:', selectedNetwork.ssid); + } + } + }, [passwordsLoaded, savedPasswords, selectedNetwork]); + // Update a specific step for a sensor const updateSensorStep = useCallback(( deviceId: string, @@ -282,12 +302,21 @@ export default function SetupWiFiScreen() { if (shouldCancelRef.current) return false; // Step 5: Reboot + // The reboot command will cause the sensor to disconnect (this is expected!) + // We send the command and immediately mark it as success - no need to wait for response updateSensorStep(deviceId, 'reboot', 'in_progress'); updateSensorStatus(deviceId, 'rebooting'); - await rebootDevice(deviceId); + + try { + await rebootDevice(deviceId); + } catch (rebootError: any) { + // Ignore BLE errors during reboot - the device disconnects on purpose + console.log('[SetupWiFi] Reboot command sent (device will disconnect):', rebootError?.message); + } + updateSensorStep(deviceId, 'reboot', 'completed'); - // Success! + // Success! The sensor is now rebooting and will connect to WiFi updateSensorStatus(deviceId, 'success'); return true; @@ -408,14 +437,19 @@ export default function SetupWiFiScreen() { return; } - // Save password for this network (by SSID) + // Save password for this network (by SSID) to SecureStore try { - savedPasswordsRef.current[selectedNetwork.ssid] = password; - await AsyncStorage.setItem('WIFI_PASSWORDS', JSON.stringify(savedPasswordsRef.current)); - await AsyncStorage.setItem('LAST_WIFI_PASSWORD', password); // legacy compat - console.log('[SetupWiFi] Password saved for network:', selectedNetwork.ssid); + await wifiPasswordStore.saveWiFiPassword(selectedNetwork.ssid, password); + + // Update local state + const updatedPasswords = { ...savedPasswords, [selectedNetwork.ssid]: password }; + savedPasswordsRef.current = updatedPasswords; + setSavedPasswords(updatedPasswords); + + console.log('[SetupWiFi] Password saved securely for network:', selectedNetwork.ssid); } catch (error) { console.log('[SetupWiFi] Failed to save password:', error); + // Continue with setup even if save fails } // Initialize sensor states @@ -650,6 +684,7 @@ export default function SetupWiFiScreen() { {networks.map((network, index) => { const isSelected = selectedNetwork?.ssid === network.ssid; + const hasSavedPassword = passwordsLoaded && savedPasswords[network.ssid]; return ( - {network.ssid} + + {network.ssid} + {hasSavedPassword && ( + + )} + {getSignalStrength(network.rssi)} ({network.rssi} dBm) @@ -909,12 +949,20 @@ const styles = StyleSheet.create({ networkDetails: { flex: 1, }, + networkNameRow: { + flexDirection: 'row', + alignItems: 'center', + }, networkName: { fontSize: FontSizes.base, fontWeight: FontWeights.semibold, color: AppColors.textPrimary, marginBottom: 2, }, + savedPasswordIcon: { + marginLeft: Spacing.xs, + marginBottom: 2, + }, signalText: { fontSize: FontSizes.xs, fontWeight: FontWeights.medium, diff --git a/services/__tests__/api.logout.test.ts b/services/__tests__/api.logout.test.ts index cb9f761..da72af9 100644 --- a/services/__tests__/api.logout.test.ts +++ b/services/__tests__/api.logout.test.ts @@ -1,15 +1,17 @@ /** * API Logout Tests - * Tests for logout functionality including BLE cleanup + * Tests for logout functionality including BLE cleanup and WiFi password cleanup */ import { api, setOnLogoutBLECleanupCallback } from '../api'; import * as SecureStore from 'expo-secure-store'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as wifiPasswordStore from '../wifiPasswordStore'; // Mock dependencies jest.mock('expo-secure-store'); jest.mock('@react-native-async-storage/async-storage'); +jest.mock('../wifiPasswordStore'); describe('API logout with BLE cleanup', () => { let bleCleanupCallback: jest.Mock; @@ -26,6 +28,9 @@ describe('API logout with BLE cleanup', () => { it('should clear all auth tokens and data', async () => { await api.logout(); + // Verify WiFi passwords are cleared + expect(wifiPasswordStore.clearAllWiFiPasswords).toHaveBeenCalledTimes(1); + // Verify SecureStore items are deleted expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('accessToken'); expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('userId'); @@ -44,6 +49,23 @@ describe('API logout with BLE cleanup', () => { // Should not throw await expect(api.logout()).resolves.not.toThrow(); }); + + it('should continue logout even if WiFi password cleanup fails', async () => { + // Make WiFi password cleanup fail + (wifiPasswordStore.clearAllWiFiPasswords as jest.Mock).mockRejectedValue( + new Error('WiFi cleanup failed') + ); + + // Logout should still complete + await expect(api.logout()).resolves.not.toThrow(); + + // Verify WiFi cleanup was attempted + expect(wifiPasswordStore.clearAllWiFiPasswords).toHaveBeenCalled(); + + // Verify auth data was still cleared despite WiFi cleanup failure + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('accessToken'); + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('userId'); + }); }); describe('logout with BLE cleanup callback', () => { diff --git a/services/__tests__/wifiPasswordStore.test.ts b/services/__tests__/wifiPasswordStore.test.ts new file mode 100644 index 0000000..2368e16 --- /dev/null +++ b/services/__tests__/wifiPasswordStore.test.ts @@ -0,0 +1,317 @@ +/** + * Tests for WiFi Password Secure Storage Service + */ + +import * as SecureStore from 'expo-secure-store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + saveWiFiPassword, + getWiFiPassword, + getAllWiFiPasswords, + removeWiFiPassword, + clearAllWiFiPasswords, + migrateFromAsyncStorage, +} from '../wifiPasswordStore'; + +// Mock SecureStore +jest.mock('expo-secure-store', () => ({ + setItemAsync: jest.fn(), + getItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), +})); + +// Mock AsyncStorage +jest.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +describe('wifiPasswordStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('saveWiFiPassword', () => { + it('should save a new WiFi password', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null); + + await saveWiFiPassword('MyNetwork', 'password123'); + + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'WIFI_PASSWORDS', + JSON.stringify({ MyNetwork: 'password123' }) + ); + }); + + it('should update an existing WiFi password', async () => { + const existingPasswords = { Network1: 'pass1', Network2: 'pass2' }; + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce( + JSON.stringify(existingPasswords) + ); + + await saveWiFiPassword('Network1', 'newpass1'); + + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'WIFI_PASSWORDS', + JSON.stringify({ Network1: 'newpass1', Network2: 'pass2' }) + ); + }); + + it('should add password to existing map', async () => { + const existingPasswords = { Network1: 'pass1' }; + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce( + JSON.stringify(existingPasswords) + ); + + await saveWiFiPassword('Network2', 'pass2'); + + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'WIFI_PASSWORDS', + JSON.stringify({ Network1: 'pass1', Network2: 'pass2' }) + ); + }); + }); + + describe('getWiFiPassword', () => { + it('should return password for existing network', async () => { + const passwords = { MyNetwork: 'password123', OtherNetwork: 'pass456' }; + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce( + JSON.stringify(passwords) + ); + + const password = await getWiFiPassword('MyNetwork'); + + expect(password).toBe('password123'); + }); + + it('should return undefined for non-existing network', async () => { + const passwords = { MyNetwork: 'password123' }; + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce( + JSON.stringify(passwords) + ); + + const password = await getWiFiPassword('NonExistent'); + + expect(password).toBeUndefined(); + }); + + it('should return undefined when no passwords saved', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null); + + const password = await getWiFiPassword('MyNetwork'); + + expect(password).toBeUndefined(); + }); + + it('should handle SecureStore errors gracefully', async () => { + (SecureStore.getItemAsync as jest.Mock).mockRejectedValueOnce( + new Error('SecureStore error') + ); + + const password = await getWiFiPassword('MyNetwork'); + + expect(password).toBeUndefined(); + }); + }); + + describe('getAllWiFiPasswords', () => { + it('should return all saved passwords', async () => { + const passwords = { Network1: 'pass1', Network2: 'pass2', Network3: 'pass3' }; + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce( + JSON.stringify(passwords) + ); + + const result = await getAllWiFiPasswords(); + + expect(result).toEqual(passwords); + }); + + it('should return empty object when no passwords saved', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null); + + const result = await getAllWiFiPasswords(); + + expect(result).toEqual({}); + }); + + it('should handle SecureStore errors gracefully', async () => { + (SecureStore.getItemAsync as jest.Mock).mockRejectedValueOnce( + new Error('SecureStore error') + ); + + const result = await getAllWiFiPasswords(); + + expect(result).toEqual({}); + }); + }); + + describe('removeWiFiPassword', () => { + it('should remove specific network password', async () => { + const passwords = { Network1: 'pass1', Network2: 'pass2', Network3: 'pass3' }; + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce( + JSON.stringify(passwords) + ); + + await removeWiFiPassword('Network2'); + + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'WIFI_PASSWORDS', + JSON.stringify({ Network1: 'pass1', Network3: 'pass3' }) + ); + }); + + it('should delete key when removing last password', async () => { + const passwords = { Network1: 'pass1' }; + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce( + JSON.stringify(passwords) + ); + + await removeWiFiPassword('Network1'); + + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('WIFI_PASSWORDS'); + expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); + }); + + it('should handle removal of non-existent network', async () => { + const passwords = { Network1: 'pass1', Network2: 'pass2' }; + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce( + JSON.stringify(passwords) + ); + + await removeWiFiPassword('NonExistent'); + + // Should still update the store with unchanged passwords + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'WIFI_PASSWORDS', + JSON.stringify(passwords) + ); + }); + }); + + describe('clearAllWiFiPasswords', () => { + it('should delete all WiFi passwords', async () => { + await clearAllWiFiPasswords(); + + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('WIFI_PASSWORDS'); + }); + + it('should handle errors gracefully', async () => { + (SecureStore.deleteItemAsync as jest.Mock).mockRejectedValueOnce( + new Error('Delete failed') + ); + + await expect(clearAllWiFiPasswords()).rejects.toThrow('Delete failed'); + }); + }); + + describe('migrateFromAsyncStorage', () => { + it('should migrate passwords from AsyncStorage to SecureStore', async () => { + const oldPasswords = { Network1: 'pass1', Network2: 'pass2' }; + + // No existing data in SecureStore + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null); + + // Old data in AsyncStorage + (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce( + JSON.stringify(oldPasswords) + ); + + await migrateFromAsyncStorage(); + + // Should save to SecureStore + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'WIFI_PASSWORDS', + JSON.stringify(oldPasswords) + ); + + // Should remove from AsyncStorage + expect(AsyncStorage.removeItem).toHaveBeenCalledWith('WIFI_PASSWORDS'); + expect(AsyncStorage.removeItem).toHaveBeenCalledWith('LAST_WIFI_PASSWORD'); + }); + + it('should skip migration if data already exists in SecureStore', async () => { + const existingPasswords = { Network1: 'pass1' }; + + // Data already exists in SecureStore + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce( + JSON.stringify(existingPasswords) + ); + + await migrateFromAsyncStorage(); + + // Should not call AsyncStorage + expect(AsyncStorage.getItem).not.toHaveBeenCalled(); + expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); + }); + + it('should skip migration if no data in AsyncStorage', async () => { + // No data in SecureStore + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null); + + // No data in AsyncStorage + (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(null); + + await migrateFromAsyncStorage(); + + // Should not migrate anything + expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); + expect(AsyncStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should not throw error on migration failure', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null); + (AsyncStorage.getItem as jest.Mock).mockRejectedValueOnce( + new Error('AsyncStorage error') + ); + + // Should not throw + await expect(migrateFromAsyncStorage()).resolves.toBeUndefined(); + }); + }); + + describe('Special characters in SSID and password', () => { + it('should handle special characters in SSID', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null); + + const ssid = "Network's-Name!@#$%^&*()"; + const password = 'password123'; + + await saveWiFiPassword(ssid, password); + + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'WIFI_PASSWORDS', + JSON.stringify({ [ssid]: password }) + ); + }); + + it('should handle special characters in password', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null); + + const ssid = 'MyNetwork'; + const password = "P@ssw0rd!#$%^&*()_+-=[]{}|;':,.<>?"; + + await saveWiFiPassword(ssid, password); + + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'WIFI_PASSWORDS', + JSON.stringify({ [ssid]: password }) + ); + }); + + it('should handle unicode characters', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null); + + const ssid = '我的网络'; + const password = 'пароль123'; + + await saveWiFiPassword(ssid, password); + + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'WIFI_PASSWORDS', + JSON.stringify({ [ssid]: password }) + ); + }); + }); +}); diff --git a/services/api.ts b/services/api.ts index b3a8c8e..600d011 100644 --- a/services/api.ts +++ b/services/api.ts @@ -2,6 +2,7 @@ import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashb import { File } from 'expo-file-system'; import * as SecureStore from 'expo-secure-store'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as wifiPasswordStore from './wifiPasswordStore'; import 'react-native-get-random-values'; // Polyfill for crypto.getRandomValues // Callback for handling unauthorized responses (401) @@ -211,6 +212,14 @@ class ApiService { } } + // Clear WiFi passwords from SecureStore + try { + await wifiPasswordStore.clearAllWiFiPasswords(); + } catch (error) { + console.error('[API] WiFi password cleanup failed during logout:', error); + // Continue with logout even if cleanup fails + } + // Clear WellNuo API auth data await SecureStore.deleteItemAsync('accessToken'); await SecureStore.deleteItemAsync('userId'); @@ -1610,14 +1619,43 @@ class ApiService { // Check if credentials exist AND token is valid JWT (contains dots) const isValidToken = token && typeof token === 'string' && token.includes('.'); - if (!isValidToken || !userName || !userId) { - // Clear any invalid cached credentials - if (token && !isValidToken) { - await SecureStore.deleteItemAsync('legacyAccessToken'); - await SecureStore.deleteItemAsync('legacyUserName'); - await SecureStore.deleteItemAsync('legacyUserId'); + // Additional check: verify token is not expired and userName matches expected + let needsRefresh = false; + if (isValidToken && userName && userId) { + // Check if userName matches expected demo user + if (userName !== this.DEMO_LEGACY_USER) { + console.log('[API] Legacy credentials mismatch: stored userName', userName, 'expected', this.DEMO_LEGACY_USER); + needsRefresh = true; } + // Check if token is expired + if (!needsRefresh) { + try { + const parts = token.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(atob(parts[1])); + const exp = payload.exp; + if (exp) { + const now = Math.floor(Date.now() / 1000); + if (now >= exp) { + console.log('[API] Legacy token expired'); + needsRefresh = true; + } + } + } + } catch (e) { + console.log('[API] Failed to decode legacy token, refreshing'); + needsRefresh = true; + } + } + } + + if (!isValidToken || !userName || !userId || needsRefresh) { + // Clear any invalid/stale cached credentials + await SecureStore.deleteItemAsync('legacyAccessToken'); + await SecureStore.deleteItemAsync('legacyUserName'); + await SecureStore.deleteItemAsync('legacyUserId'); + const loginResult = await this.loginToLegacyDashboard(); if (!loginResult.ok) return null; @@ -1867,7 +1905,7 @@ class ApiService { console.error('[API] No Legacy API credentials'); throw new Error('Not authenticated with Legacy API'); } - console.log('[API] Got Legacy credentials for user:', creds.userName); + console.log('[API] Got Legacy credentials for user:', creds.userName, 'token prefix:', creds.token.substring(0, 30)); // Use device_form to attach device to deployment // Note: set_deployment now requires beneficiary_photo and email which we don't have diff --git a/services/wifiPasswordStore.ts b/services/wifiPasswordStore.ts new file mode 100644 index 0000000..147d7d6 --- /dev/null +++ b/services/wifiPasswordStore.ts @@ -0,0 +1,148 @@ +/** + * WiFi Password Secure Storage Service + * + * Provides secure storage for WiFi passwords using expo-secure-store. + * Replaces AsyncStorage for improved security. + */ + +import * as SecureStore from 'expo-secure-store'; + +const WIFI_PASSWORDS_KEY = 'WIFI_PASSWORDS'; +const LEGACY_SINGLE_PASSWORD_KEY = 'LAST_WIFI_PASSWORD'; + +export interface WiFiPasswordMap { + [ssid: string]: string; +} + +/** + * Save WiFi password for a specific network + * @param ssid Network SSID + * @param password Network password + */ +export async function saveWiFiPassword(ssid: string, password: string): Promise { + try { + // Get existing passwords + const existing = await getAllWiFiPasswords(); + + // Add/update the password + existing[ssid] = password; + + // Save back to SecureStore + await SecureStore.setItemAsync(WIFI_PASSWORDS_KEY, JSON.stringify(existing)); + + console.log('[WiFiPasswordStore] Password saved for network:', ssid); + } catch (error) { + console.error('[WiFiPasswordStore] Failed to save password:', error); + throw error; + } +} + +/** + * Get WiFi password for a specific network + * @param ssid Network SSID + * @returns Password or undefined if not found + */ +export async function getWiFiPassword(ssid: string): Promise { + try { + const passwords = await getAllWiFiPasswords(); + return passwords[ssid]; + } catch (error) { + console.error('[WiFiPasswordStore] Failed to get password:', error); + return undefined; + } +} + +/** + * Get all saved WiFi passwords + * @returns Map of SSID to password + */ +export async function getAllWiFiPasswords(): Promise { + try { + const stored = await SecureStore.getItemAsync(WIFI_PASSWORDS_KEY); + + if (stored) { + return JSON.parse(stored); + } + + return {}; + } catch (error) { + console.error('[WiFiPasswordStore] Failed to get all passwords:', error); + return {}; + } +} + +/** + * Remove WiFi password for a specific network + * @param ssid Network SSID + */ +export async function removeWiFiPassword(ssid: string): Promise { + try { + const existing = await getAllWiFiPasswords(); + + // Remove the password + delete existing[ssid]; + + // Save back to SecureStore + if (Object.keys(existing).length > 0) { + await SecureStore.setItemAsync(WIFI_PASSWORDS_KEY, JSON.stringify(existing)); + } else { + // If no passwords left, remove the key entirely + await SecureStore.deleteItemAsync(WIFI_PASSWORDS_KEY); + } + + console.log('[WiFiPasswordStore] Password removed for network:', ssid); + } catch (error) { + console.error('[WiFiPasswordStore] Failed to remove password:', error); + throw error; + } +} + +/** + * Clear all saved WiFi passwords + * Should be called on logout + */ +export async function clearAllWiFiPasswords(): Promise { + try { + await SecureStore.deleteItemAsync(WIFI_PASSWORDS_KEY); + console.log('[WiFiPasswordStore] All WiFi passwords cleared'); + } catch (error) { + console.error('[WiFiPasswordStore] Failed to clear passwords:', error); + throw error; + } +} + +/** + * Migrate WiFi passwords from AsyncStorage to SecureStore + * This function should be called once during app startup to migrate existing data + */ +export async function migrateFromAsyncStorage(): Promise { + try { + const AsyncStorage = require('@react-native-async-storage/async-storage').default; + + // Check if migration already done + const existing = await SecureStore.getItemAsync(WIFI_PASSWORDS_KEY); + if (existing) { + console.log('[WiFiPasswordStore] Migration already completed'); + return; + } + + // Try to get old data from AsyncStorage + const oldPasswords = await AsyncStorage.getItem('WIFI_PASSWORDS'); + + if (oldPasswords) { + // Migrate to SecureStore + await SecureStore.setItemAsync(WIFI_PASSWORDS_KEY, oldPasswords); + + // Remove from AsyncStorage + await AsyncStorage.removeItem('WIFI_PASSWORDS'); + await AsyncStorage.removeItem(LEGACY_SINGLE_PASSWORD_KEY); + + console.log('[WiFiPasswordStore] Successfully migrated passwords from AsyncStorage'); + } else { + console.log('[WiFiPasswordStore] No passwords to migrate'); + } + } catch (error) { + console.error('[WiFiPasswordStore] Migration failed:', error); + // Don't throw - migration failure shouldn't break the app + } +}