From 0962b5e35b2e1d7a12288c9e380f5b3b84c9e279 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sat, 31 Jan 2026 18:23:31 -0800 Subject: [PATCH] Add comprehensive WiFi credential validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add real-time validation for WiFi credentials with password strength indicator - Add validateRealTime() for immediate UI feedback as user types - Add parseWiFiErrorResponse() for user-friendly error messages - Add prepareCredentialsForDevice() for pre-transmission validation - Block BLE protocol delimiter characters (| and ,) in credentials - Create WiFiPasswordInput component with strength bar and validation hints - Update BLEManager with improved error messages and pre-validation - Update MockBLEManager with simulation scenarios for testing - Add 62 comprehensive tests for all validation functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/WiFiPasswordInput.tsx | 310 +++++++++++++++++++++++++ services/ble/BLEManager.ts | 46 +++- services/ble/MockBLEManager.ts | 44 ++++ utils/__tests__/wifiValidation.test.ts | 171 ++++++++++++++ utils/wifiValidation.ts | 177 ++++++++++++++ 5 files changed, 740 insertions(+), 8 deletions(-) create mode 100644 components/WiFiPasswordInput.tsx diff --git a/components/WiFiPasswordInput.tsx b/components/WiFiPasswordInput.tsx new file mode 100644 index 0000000..c907f0e --- /dev/null +++ b/components/WiFiPasswordInput.tsx @@ -0,0 +1,310 @@ +/** + * WiFi Password Input Component + * + * Provides real-time validation feedback as user types password. + * Shows password strength indicator and validation errors. + */ + +import React, { useState, useCallback, useEffect } from 'react'; +import { + View, + TextInput, + Text, + TouchableOpacity, + StyleSheet, + Animated, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { + AppColors, + BorderRadius, + FontSizes, + FontWeights, + Spacing, +} from '@/constants/theme'; +import { + validateRealTime, + type RealTimeValidationState, +} from '@/utils/wifiValidation'; + +interface WiFiPasswordInputProps { + value: string; + onChangeText: (text: string) => void; + ssid: string; + authType?: string; + onValidationChange?: (isValid: boolean) => void; + editable?: boolean; + placeholder?: string; + autoFocus?: boolean; +} + +export function WiFiPasswordInput({ + value, + onChangeText, + ssid, + authType, + onValidationChange, + editable = true, + placeholder = 'Enter WiFi password', + autoFocus = false, +}: WiFiPasswordInputProps) { + const [showPassword, setShowPassword] = useState(false); + const [validation, setValidation] = useState(null); + const [isFocused, setIsFocused] = useState(false); + + // Animated value for password strength bar + const strengthAnim = useState(new Animated.Value(0))[0]; + + // Perform real-time validation as user types + const handleTextChange = useCallback((text: string) => { + onChangeText(text); + + // Only validate if there's input + if (text.length > 0 || ssid.length > 0) { + const result = validateRealTime(ssid, text, authType); + setValidation(result); + + // Animate strength bar + let strengthValue = 0; + if (result.passwordStrength === 'weak') strengthValue = 0.33; + else if (result.passwordStrength === 'medium') strengthValue = 0.66; + else if (result.passwordStrength === 'strong') strengthValue = 1; + + Animated.timing(strengthAnim, { + toValue: strengthValue, + duration: 200, + useNativeDriver: false, + }).start(); + + // Notify parent of validation state + onValidationChange?.(result.canSubmit); + } else { + setValidation(null); + onValidationChange?.(false); + } + }, [ssid, authType, onValidationChange, strengthAnim, onChangeText]); + + // Re-validate when ssid or authType changes + useEffect(() => { + if (value.length > 0) { + const result = validateRealTime(ssid, value, authType); + setValidation(result); + onValidationChange?.(result.canSubmit); + } + }, [ssid, authType]); + + // Get strength bar color + const getStrengthColor = () => { + if (!validation?.passwordStrength) return AppColors.border; + switch (validation.passwordStrength) { + case 'weak': return AppColors.error; + case 'medium': return AppColors.warning; + case 'strong': return AppColors.success; + default: return AppColors.border; + } + }; + + // Get strength label + const getStrengthLabel = () => { + if (!validation?.passwordStrength) return null; + switch (validation.passwordStrength) { + case 'weak': return 'Weak'; + case 'medium': return 'Medium'; + case 'strong': return 'Strong'; + default: return null; + } + }; + + const isOpenNetwork = authType === 'Open'; + + return ( + + {/* Password Input */} + + + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> + setShowPassword(!showPassword)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + + + {/* Password Strength Bar (only for secured networks) */} + {!isOpenNetwork && value.length > 0 && ( + + + + + {getStrengthLabel() && ( + + {getStrengthLabel()} + + )} + + )} + + {/* Validation Error */} + {validation?.passwordError && ( + + + {validation.passwordError} + + )} + + {/* Validation Warning (first warning only) */} + {!validation?.passwordError && validation?.warnings && validation.warnings.length > 0 && ( + + + {validation.warnings[0]} + + )} + + {/* Password Requirements Hint */} + {!isOpenNetwork && value.length === 0 && ( + + Password must be 8-63 characters + + )} + + {/* Character Count */} + {!isOpenNetwork && value.length > 0 && value.length < 8 && ( + + {value.length}/8 characters minimum + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + width: '100%', + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: AppColors.background, + borderRadius: BorderRadius.md, + borderWidth: 1, + borderColor: AppColors.border, + }, + inputContainerFocused: { + borderColor: AppColors.primary, + borderWidth: 2, + }, + inputContainerError: { + borderColor: AppColors.error, + }, + inputIcon: { + marginLeft: Spacing.md, + }, + input: { + flex: 1, + paddingVertical: Spacing.sm, + paddingHorizontal: Spacing.sm, + fontSize: FontSizes.base, + color: AppColors.textPrimary, + }, + toggleButton: { + padding: Spacing.md, + }, + strengthContainer: { + flexDirection: 'row', + alignItems: 'center', + marginTop: Spacing.xs, + gap: Spacing.sm, + }, + strengthBarBackground: { + flex: 1, + height: 4, + backgroundColor: AppColors.border, + borderRadius: 2, + overflow: 'hidden', + }, + strengthBar: { + height: '100%', + borderRadius: 2, + }, + strengthLabel: { + fontSize: FontSizes.xs, + fontWeight: FontWeights.medium, + minWidth: 50, + }, + errorContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xs, + marginTop: Spacing.xs, + }, + errorText: { + fontSize: FontSizes.sm, + color: AppColors.error, + flex: 1, + }, + warningContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xs, + marginTop: Spacing.xs, + }, + warningText: { + fontSize: FontSizes.sm, + color: AppColors.warning, + flex: 1, + }, + hintText: { + fontSize: FontSizes.xs, + color: AppColors.textMuted, + marginTop: Spacing.xs, + fontStyle: 'italic', + }, + charCount: { + fontSize: FontSizes.xs, + color: AppColors.textMuted, + marginTop: Spacing.xs, + }, +}); + +export default WiFiPasswordInput; diff --git a/services/ble/BLEManager.ts b/services/ble/BLEManager.ts index 1c28915..e7c17a4 100644 --- a/services/ble/BLEManager.ts +++ b/services/ble/BLEManager.ts @@ -518,11 +518,28 @@ export class RealBLEManager implements IBLEManager { } async setWiFi(deviceId: string, ssid: string, password: string): Promise { + // Pre-validate credentials before BLE transmission + // Check for characters that would break BLE protocol parsing + if (ssid.includes('|') || ssid.includes(',')) { + throw new Error('Network name contains invalid characters. Please select a different network.'); + } + if (password.includes('|')) { + throw new Error('Password contains an invalid character (|). Please use a different password.'); + } // Step 1: Unlock device - const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + let unlockResponse: string; + try { + unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + } catch (err: any) { + if (err.message?.includes('timeout')) { + throw new Error('Sensor not responding. Please move closer and try again.'); + } + throw new Error(`Cannot communicate with sensor: ${err.message}`); + } + if (!unlockResponse.includes('ok')) { - throw new Error(`Device unlock failed: ${unlockResponse}`); + throw new Error('Sensor authentication failed. Please try reconnecting.'); } // Step 1.5: Check if already connected to the target WiFi @@ -544,7 +561,15 @@ export class RealBLEManager implements IBLEManager { // Step 2: Set WiFi credentials const command = `${BLE_COMMANDS.SET_WIFI}|${ssid},${password}`; - const setResponse = await this.sendCommand(deviceId, command); + let setResponse: string; + try { + setResponse = await this.sendCommand(deviceId, command); + } catch (err: any) { + if (err.message?.includes('timeout')) { + throw new Error('Sensor did not respond to WiFi config. Please try again.'); + } + throw new Error(`WiFi configuration failed: ${err.message}`); + } // Parse response: "mac,XXXXXX|W|ok" or "mac,XXXXXX|W|fail" or other errors if (setResponse.includes('|W|ok')) { @@ -553,7 +578,6 @@ export class RealBLEManager implements IBLEManager { // WiFi config failed - check if sensor is still connected (using old credentials) if (setResponse.includes('|W|fail')) { - try { const recheckResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS); const parts = recheckResponse.split('|'); @@ -570,15 +594,21 @@ export class RealBLEManager implements IBLEManager { // Ignore recheck errors - password was rejected } - throw new Error('WiFi credentials rejected by sensor. Check password.'); + // Password was definitely wrong + throw new Error('WiFi password is incorrect. Please check and try again.'); } if (setResponse.includes('timeout') || setResponse.includes('Timeout')) { - throw new Error('Sensor did not respond to WiFi config. Try again.'); + throw new Error('Sensor did not respond to WiFi config. Please try again.'); } - // Unknown error - include raw response for debugging - throw new Error(`WiFi config failed: ${setResponse.substring(0, 100)}`); + // Check for specific error patterns + if (setResponse.includes('not found') || setResponse.includes('no network')) { + throw new Error('WiFi network not found. Make sure the sensor is within range of your router.'); + } + + // Unknown error - provide helpful message + throw new Error('WiFi configuration failed. Please try again or contact support.'); } async getCurrentWiFi(deviceId: string): Promise { diff --git a/services/ble/MockBLEManager.ts b/services/ble/MockBLEManager.ts index 720d50d..9fc5b19 100644 --- a/services/ble/MockBLEManager.ts +++ b/services/ble/MockBLEManager.ts @@ -224,6 +224,50 @@ export class MockBLEManager implements IBLEManager { password: string ): Promise { await delay(2000); + + // Pre-validate credentials (same as real BLEManager) + if (ssid.includes('|') || ssid.includes(',')) { + throw new Error('Network name contains invalid characters. Please select a different network.'); + } + if (password.includes('|')) { + throw new Error('Password contains an invalid character (|). Please use a different password.'); + } + + // Simulate various failure scenarios for testing + // Use special SSIDs/passwords to trigger specific errors + const lowerSsid = ssid.toLowerCase(); + const lowerPassword = password.toLowerCase(); + + // Simulate wrong password + if (lowerPassword === 'wrongpass' || lowerPassword === 'wrong') { + throw new Error('WiFi password is incorrect. Please check and try again.'); + } + + // Simulate network not found + if (lowerSsid.includes('notfound') || lowerSsid === 'hidden_network') { + throw new Error('WiFi network not found. Make sure the sensor is within range of your router.'); + } + + // Simulate timeout + if (lowerSsid.includes('timeout') || lowerPassword === 'timeout') { + throw new Error('Sensor did not respond to WiFi config. Please try again.'); + } + + // Simulate sensor not responding + if (lowerSsid.includes('offline')) { + throw new Error('Sensor not responding. Please move closer and try again.'); + } + + // Validate password length (WPA/WPA2 requirement) + if (password.length < 8) { + throw new Error('Password must be at least 8 characters for WPA/WPA2 networks.'); + } + + if (password.length > 63) { + throw new Error('Password cannot exceed 63 characters.'); + } + + // Success for all other cases return true; } diff --git a/utils/__tests__/wifiValidation.test.ts b/utils/__tests__/wifiValidation.test.ts index 9ed7bc2..584baff 100644 --- a/utils/__tests__/wifiValidation.test.ts +++ b/utils/__tests__/wifiValidation.test.ts @@ -10,6 +10,9 @@ import { sanitizeWiFiCredentials, getValidationErrorMessage, getValidationMessage, + validateRealTime, + parseWiFiErrorResponse, + prepareCredentialsForDevice, } from '../wifiValidation'; describe('WiFi Validation', () => { @@ -352,4 +355,172 @@ describe('WiFi Validation', () => { expect(getValidationMessage(result)).toBe('Error 1\nWarning 1'); }); }); + + describe('validateRealTime', () => { + it('should return valid state for valid credentials', () => { + const result = validateRealTime('MyWiFi', 'SecurePass123', 'WPA2 PSK'); + expect(result.ssidValid).toBe(true); + expect(result.passwordValid).toBe(true); + expect(result.canSubmit).toBe(true); + expect(result.ssidError).toBeNull(); + expect(result.passwordError).toBeNull(); + }); + + it('should indicate password strength', () => { + // Weak password (8-9 chars) + const weak = validateRealTime('MyWiFi', 'password', 'WPA2 PSK'); + expect(weak.passwordStrength).toBe('weak'); + + // Medium password (10+ chars but not complex) + const medium = validateRealTime('MyWiFi', 'mediumpassword', 'WPA2 PSK'); + expect(medium.passwordStrength).toBe('medium'); + + // Strong password (12+ chars with variety) + const strong = validateRealTime('MyWiFi', 'Str0ng!Pass123', 'WPA2 PSK'); + expect(strong.passwordStrength).toBe('strong'); + }); + + it('should handle open networks', () => { + const result = validateRealTime('GuestNetwork', '', 'Open'); + expect(result.passwordValid).toBe(true); + expect(result.passwordStrength).toBeNull(); + expect(result.canSubmit).toBe(true); + }); + + it('should not allow submit with short password', () => { + const result = validateRealTime('MyWiFi', '1234567', 'WPA2 PSK'); + expect(result.canSubmit).toBe(false); + expect(result.passwordValid).toBe(false); + }); + + it('should not allow submit with empty SSID', () => { + const result = validateRealTime('', 'password123', 'WPA2 PSK'); + expect(result.canSubmit).toBe(false); + }); + + it('should collect warnings', () => { + const result = validateRealTime(' MyWiFi', 'password', 'WPA2 PSK'); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it('should return null strength for empty password', () => { + const result = validateRealTime('MyWiFi', '', 'WPA2 PSK'); + expect(result.passwordStrength).toBeNull(); + }); + }); + + describe('parseWiFiErrorResponse', () => { + it('should detect wrong password errors', () => { + const errors = [ + 'WiFi credentials rejected by sensor', + 'wrong password', + 'check password', + 'W|fail response', + ]; + + errors.forEach((error) => { + const result = parseWiFiErrorResponse(error); + expect(result.type).toBe('password_wrong'); + expect(result.suggestion).toContain('check'); + }); + }); + + it('should detect network not found errors', () => { + const errors = [ + 'Network not found', + 'no network available', + 'SSID not available', + ]; + + errors.forEach((error) => { + const result = parseWiFiErrorResponse(error); + expect(result.type).toBe('network_not_found'); + expect(result.suggestion).toContain('range'); + }); + }); + + it('should detect timeout errors', () => { + const errors = [ + 'Command timeout', + 'Request timed out', + 'Sensor did not respond in time', + ]; + + errors.forEach((error) => { + const result = parseWiFiErrorResponse(error); + expect(result.type).toBe('timeout'); + expect(result.suggestion).toContain('closer'); + }); + }); + + it('should detect connection errors', () => { + const errors = [ + 'BLE connection failed', + 'Bluetooth error', + 'Could not connect to device', + ]; + + errors.forEach((error) => { + const result = parseWiFiErrorResponse(error); + expect(result.type).toBe('connection_failed'); + expect(result.suggestion).toContain('try again'); + }); + }); + + it('should return unknown for unrecognized errors', () => { + const result = parseWiFiErrorResponse('Some random error message'); + expect(result.type).toBe('unknown'); + expect(result.message).toBe('WiFi configuration failed'); + }); + }); + + describe('prepareCredentialsForDevice', () => { + it('should return sanitized credentials for valid input', () => { + const result = prepareCredentialsForDevice(' MyWiFi ', 'password123', 'WPA2 PSK'); + expect(result.ssid).toBe('MyWiFi'); + expect(result.password).toBe('password123'); + }); + + it('should throw for invalid SSID with pipe character', () => { + expect(() => { + prepareCredentialsForDevice('My|WiFi', 'password123', 'WPA2 PSK'); + }).toThrow('Network name contains invalid characters'); + }); + + it('should throw for invalid SSID with comma character', () => { + expect(() => { + prepareCredentialsForDevice('My,WiFi', 'password123', 'WPA2 PSK'); + }).toThrow('Network name contains invalid characters'); + }); + + it('should throw for password with pipe character', () => { + expect(() => { + prepareCredentialsForDevice('MyWiFi', 'pass|word', 'WPA2 PSK'); + }).toThrow('Password contains invalid character'); + }); + + it('should allow password with comma character', () => { + // Comma is allowed in password (only pipe is delimiter between command and payload) + const result = prepareCredentialsForDevice('MyWiFi', 'pass,word123', 'WPA2 PSK'); + expect(result.password).toBe('pass,word123'); + }); + + it('should throw for empty SSID', () => { + expect(() => { + prepareCredentialsForDevice('', 'password123', 'WPA2 PSK'); + }).toThrow('Network name (SSID) cannot be empty'); + }); + + it('should throw for short password', () => { + expect(() => { + prepareCredentialsForDevice('MyWiFi', 'short', 'WPA2 PSK'); + }).toThrow('at least 8 characters'); + }); + + it('should allow empty password for open networks', () => { + const result = prepareCredentialsForDevice('GuestNetwork', '', 'Open'); + expect(result.ssid).toBe('GuestNetwork'); + expect(result.password).toBe(''); + }); + }); }); diff --git a/utils/wifiValidation.ts b/utils/wifiValidation.ts index f4a4827..e3d1a99 100644 --- a/utils/wifiValidation.ts +++ b/utils/wifiValidation.ts @@ -233,3 +233,180 @@ export function getValidationMessage(result: WiFiValidationResult): string { return messages.join('\n'); } + +/** + * Real-time validation result for UI feedback + */ +export interface RealTimeValidationState { + ssidValid: boolean; + ssidError: string | null; + passwordValid: boolean; + passwordError: string | null; + passwordStrength: 'weak' | 'medium' | 'strong' | null; + canSubmit: boolean; + warnings: string[]; +} + +/** + * Perform real-time validation as user types + * Returns a simplified state for UI updates + */ +export function validateRealTime( + ssid: string, + password: string, + authType?: string +): RealTimeValidationState { + const result: RealTimeValidationState = { + ssidValid: true, + ssidError: null, + passwordValid: true, + passwordError: null, + passwordStrength: null, + canSubmit: false, + warnings: [], + }; + + // SSID validation (only show error if user has started typing) + if (ssid.length > 0) { + const ssidResult = validateSSID(ssid); + result.ssidValid = ssidResult.valid; + result.ssidError = ssidResult.errors.length > 0 ? ssidResult.errors[0] : null; + result.warnings.push(...ssidResult.warnings); + } + + // Password validation + if (authType === 'Open') { + // Open networks don't need password + result.passwordValid = true; + result.passwordStrength = null; + } else if (password.length > 0) { + const passwordResult = validatePassword(password, authType); + result.passwordValid = passwordResult.valid; + result.passwordError = passwordResult.errors.length > 0 ? passwordResult.errors[0] : null; + result.warnings.push(...passwordResult.warnings); + + // Calculate password strength for visual feedback + if (password.length >= 8) { + if (isStrongPassword(password)) { + result.passwordStrength = 'strong'; + } else if (password.length >= 10) { + result.passwordStrength = 'medium'; + } else { + result.passwordStrength = 'weak'; + } + } + } + + // Can submit if both are valid (or password not required for open networks) + const ssidOk = ssid.length > 0 && result.ssidValid; + const passwordOk = authType === 'Open' || (password.length >= 8 && result.passwordValid); + result.canSubmit = ssidOk && passwordOk; + + return result; +} + +/** + * Parse BLE WiFi error response and return user-friendly message + */ +export function parseWiFiErrorResponse(errorMessage: string): { + type: 'password_wrong' | 'network_not_found' | 'timeout' | 'connection_failed' | 'unknown'; + message: string; + suggestion: string; +} { + const lowerError = errorMessage.toLowerCase(); + + // Password rejection + if ( + lowerError.includes('credentials rejected') || + lowerError.includes('wrong password') || + lowerError.includes('check password') || + lowerError.includes('w|fail') + ) { + return { + type: 'password_wrong', + message: 'WiFi password is incorrect', + suggestion: 'Please double-check the password and try again. Make sure caps lock is off.', + }; + } + + // Network not found + if ( + lowerError.includes('not found') || + lowerError.includes('no network') || + lowerError.includes('ssid not available') + ) { + return { + type: 'network_not_found', + message: 'WiFi network not found', + suggestion: 'Make sure the sensor is within range of your WiFi router.', + }; + } + + // Timeout + if ( + lowerError.includes('timeout') || + lowerError.includes('timed out') || + lowerError.includes('did not respond') + ) { + return { + type: 'timeout', + message: 'Sensor did not respond in time', + suggestion: 'Move closer to the sensor and try again. Make sure it is powered on.', + }; + } + + // Connection failed + if ( + lowerError.includes('connect') || + lowerError.includes('ble') || + lowerError.includes('bluetooth') + ) { + return { + type: 'connection_failed', + message: 'Lost connection to sensor', + suggestion: 'Please try again. If the problem persists, restart the sensor.', + }; + } + + // Unknown error + return { + type: 'unknown', + message: 'WiFi configuration failed', + suggestion: 'Please try again. If the problem persists, contact support.', + }; +} + +/** + * Validate WiFi credentials and prepare for BLE transmission + * Returns sanitized credentials or throws with detailed error + */ +export function prepareCredentialsForDevice( + ssid: string, + password: string, + authType?: string +): { ssid: string; password: string } { + // Full validation + const validation = validateWiFiCredentials(ssid, password, authType); + + if (!validation.valid) { + const errorMsg = getValidationErrorMessage(validation); + throw new Error(errorMsg); + } + + // Sanitize + const sanitized = sanitizeWiFiCredentials({ ssid, password }); + + // Additional safety checks for BLE transmission + // Check for characters that might cause parsing issues in BLE protocol + // Protocol uses | and , as delimiters: W|SSID,PASSWORD + if (sanitized.ssid.includes('|') || sanitized.ssid.includes(',')) { + throw new Error('Network name contains invalid characters (| or ,)'); + } + + if (sanitized.password.includes('|')) { + // Password can contain comma but not pipe (delimiter) + throw new Error('Password contains invalid character (|)'); + } + + return sanitized; +}