├── .gitignore ├── .nvmrc ├── README.md ├── app.json ├── app ├── _layout.tsx └── index.tsx ├── assets ├── demo.mp4 ├── fonts │ └── SpaceMono-Regular.ttf ├── images │ ├── adaptive-icon.png │ ├── icon-transparent.png │ ├── icon.png │ ├── ios-dark.png │ ├── ios-light.png │ ├── ios-tinted.png │ ├── splash-icon-dark.png │ └── splash-icon-light.png └── listings │ ├── app-icon.png │ ├── feature-graphic.png │ ├── screenshot-1.png │ ├── screenshot-2.png │ └── screenshot-3.png ├── components ├── PermissionsModal.tsx ├── WiFiConnectionModal.tsx └── WiFiScanner.tsx ├── eas.json ├── package-lock.json ├── package.json ├── tsconfig.json └── utils ├── permissions.ts ├── textRecognition.ts └── wifi.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Expo development build 13 | android/ 14 | ios/ 15 | 16 | # Native 17 | *.orig.* 18 | *.jks 19 | *.p8 20 | *.p12 21 | *.key 22 | *.mobileprovision 23 | 24 | # Metro 25 | .metro-health-check* 26 | 27 | # debug 28 | npm-debug.* 29 | yarn-debug.* 30 | yarn-error.* 31 | 32 | # macOS 33 | .DS_Store 34 | *.pem 35 | 36 | # local env files 37 | .env*.local 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | 42 | # google play service account 43 | google-play-service-account.json 44 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wify 2 | 3 | A React Native application that scans WiFi text to extract WiFi credentials, matches them with nearby networks, and connects to them. 4 | 5 | ## Why I Built This 6 | 7 | I travel and work remotely a lot. Every new place—hotels, cafes, coworking spaces—comes with a new WiFi network. Sometimes there’s a QR code, which makes things easy, but more often, it’s a hassle: manually searching for the right SSID (especially frustrating when hotels have a different one for each room) and typing long, error-prone passwords. 8 | 9 | To simplify this, I built this app. It uses the phone’s camera to capture WiFi details (network name and password) from printed text and instantly generates a QR code on the screen. With Google Circle to Search or Google Lens, connecting is effortless. An image from the gallery can also be imported instead of using the camera. 10 | 11 | I also built [Wify for Mac](https://github.com/yilinjuang/wify-mac), which scans Wi-Fi QR codes using a Mac’s camera and connects to the corresponding network instantly—perfect for syncing Wi-Fi credentials across Android and Mac without manual entry. 12 | 13 | ## Features 14 | 15 | - **Text Recognition (OCR)**: Extract WiFi credentials from text in images. 16 | - **Image Picker**: Select images from gallery for WiFi credential extraction. 17 | - **WiFi Network Scanning**: Scan for nearby WiFi networks. 18 | - **Fuzzy Matching**: Match extracted WiFi names with available networks using Fuse.js. 19 | - **WiFi Connection**: Connect to matched WiFi networks using react-native-wifi-reborn. 20 | - **Permission Handling**: Proper handling of camera and location permissions with user-friendly prompts. 21 | - **Multi-language Support**: OCR recognition for Latin, Chinese, Japanese, and Korean scripts. 22 | 23 | ## Technology Stack 24 | 25 | - **Framework**: React Native with Expo 26 | - **Router**: Expo Router 27 | - **WiFi Management**: react-native-wifi-reborn 28 | - **Camera**: expo-camera 29 | - **Text Recognition**: @react-native-ml-kit/text-recognition 30 | - **Image Picker**: expo-image-picker 31 | - **Location Services**: expo-location 32 | - **Fuzzy Search**: Fuse.js 33 | - **Icons**: @expo/vector-icons 34 | 35 | ## Installation 36 | 37 | 38 | Get it on Google Play 39 | 40 | 41 | ### Manual Installation 42 | 43 | 1. Clone the repository: 44 | 45 | ```bash 46 | git clone https://github.com/yilinjuang/wify.git 47 | cd wify 48 | ``` 49 | 50 | 2. Install dependencies: 51 | 52 | ```bash 53 | npm install 54 | ``` 55 | 56 | 3. Run the app: 57 | 58 | ```bash 59 | npm start 60 | ``` 61 | 62 | 4. For development on physical devices: 63 | 64 | ```bash 65 | # For Android 66 | npm run android 67 | 68 | # For iOS 69 | npm run ios 70 | ``` 71 | 72 | ## Usage 73 | 74 | 1. Launch the app and grant the required permissions (camera and location). 75 | 2. Point the camera at a WiFi text containing WiFi credentials. 76 | 3. Alternatively, tap the gallery icon to select an image containing WiFi information. 77 | 4. The app will scan for nearby WiFi networks and match them with the extracted credentials. 78 | 5. If multiple networks match, you can select the correct one from a list. 79 | 6. Confirm the connection and edit the password if needed. 80 | 7. Connect to the WiFi network. 81 | 82 | ## Permissions 83 | 84 | The app requires the following permissions: 85 | 86 | - **Camera**: To scan WiFi text. 87 | - **Location**: Required by the WiFi scanning functionality to discover nearby networks. 88 | - **Photo Library**: To access images for WiFi credential extraction. 89 | 90 | ## Project Structure 91 | 92 | - `app/`: Main application code using Expo Router 93 | - `index.tsx`: Main app component 94 | - `_layout.tsx`: App layout configuration 95 | - `components/`: React components 96 | - `WiFiScanner.tsx`: Main component for camera and WiFi text scanning 97 | - `WiFiConnectionModal.tsx`: Modal for connecting to WiFi networks 98 | - `PermissionsModal.tsx`: Modal for handling permission requests 99 | - `utils/`: Utility functions 100 | - `wifi.ts`: WiFi operations including scanning, connecting, and parsing 101 | - `textRecognition.ts`: OCR functionality for extracting WiFi credentials from images 102 | - `permissions.ts`: Permission handling utilities 103 | - `assets/`: Images and other static assets 104 | 105 | ## Key Features Implementation 106 | 107 | - **Text Recognition**: Uses ML Kit to recognize text in multiple languages and extract WiFi credentials. 108 | - **Fuzzy Matching**: Uses Fuse.js to match extracted WiFi names with available networks, even with slight differences. 109 | - **Permission Handling**: Gracefully handles permission requests and provides guidance when permissions are denied. 110 | - **Background App State**: Rechecks permissions when the app returns from background. 111 | 112 | ## Demo 113 | 114 | https://github.com/user-attachments/assets/a0dea5bd-db5a-4051-bb37-3e81c243e905 115 | 116 | ## License 117 | 118 | MIT 119 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "wify", 4 | "slug": "wify", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "scheme": "wify", 8 | "userInterfaceStyle": "automatic", 9 | "newArchEnabled": true, 10 | "ios": { 11 | "supportsTablet": true, 12 | "bundleIdentifier": "com.yilinjuang.wify", 13 | "infoPlist": { 14 | "NSLocalNetworkUsageDescription": "This app requires access to the local network to scan and connect to WiFi networks." 15 | }, 16 | "icon": { 17 | "dark": "./assets/images/ios-dark.png", 18 | "light": "./assets/images/ios-light.png", 19 | "tinted": "./assets/images/ios-tinted.png" 20 | } 21 | }, 22 | "android": { 23 | "package": "com.yilinjuang.wify", 24 | "permissions": [ 25 | "CAMERA", 26 | "ACCESS_FINE_LOCATION", 27 | "ACCESS_COARSE_LOCATION", 28 | "ACCESS_WIFI_STATE", 29 | "CHANGE_WIFI_STATE" 30 | ], 31 | "adaptiveIcon": { 32 | "foregroundImage": "./assets/images/adaptive-icon.png", 33 | "backgroundColor": "#232323" 34 | } 35 | }, 36 | "plugins": [ 37 | "expo-router", 38 | [ 39 | "expo-splash-screen", 40 | { 41 | "backgroundColor": "#232323", 42 | "image": "./assets/images/splash-icon-light.png", 43 | "dark": { 44 | "image": "./assets/images/splash-icon-dark.png", 45 | "backgroundColor": "#000000" 46 | }, 47 | "imageWidth": 200 48 | } 49 | ], 50 | [ 51 | "react-native-wifi-reborn", 52 | { 53 | "fineLocationPermission": true 54 | } 55 | ], 56 | [ 57 | "expo-camera", 58 | { 59 | "cameraPermission": "Allow $(PRODUCT_NAME) to access your camera to scan WiFi credentials.", 60 | "recordAudioAndroid": false 61 | } 62 | ], 63 | [ 64 | "expo-location", 65 | { 66 | "locationWhenInUsePermission": "This app requires location access to scan for nearby WiFi networks." 67 | } 68 | ] 69 | ], 70 | "experiments": { 71 | "typedRoutes": true 72 | }, 73 | "extra": { 74 | "router": { 75 | "origin": false 76 | }, 77 | "eas": { 78 | "projectId": "5eea2e1f-78e0-4f5e-8636-5fafef4be241" 79 | } 80 | }, 81 | "owner": "yilinjuang" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | 3 | export default function RootLayout() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { 3 | Alert, 4 | AppState, 5 | AppStateStatus, 6 | SafeAreaView, 7 | StatusBar, 8 | StyleSheet, 9 | } from "react-native"; 10 | import PermissionsModal from "../components/PermissionsModal"; 11 | import WiFiScanner from "../components/WiFiScanner"; 12 | import { 13 | checkPermissions, 14 | PermissionStatus, 15 | requestPermissions, 16 | } from "../utils/permissions"; 17 | 18 | export default function App() { 19 | const [permissionStatus, setPermissionStatus] = useState({ 20 | camera: false, 21 | location: false, 22 | allGranted: false, 23 | cameraPermanentlyDenied: false, 24 | locationPermanentlyDenied: false, 25 | anyPermanentlyDenied: false, 26 | }); 27 | const [showPermissionsModal, setShowPermissionsModal] = useState(false); 28 | const appStateRef = useRef(AppState.currentState); 29 | const [isReturningFromBackground, setIsReturningFromBackground] = 30 | useState(false); 31 | 32 | // Check permissions when the component mounts 33 | useEffect(() => { 34 | checkInitialPermissions(); 35 | }, []); 36 | 37 | // Listen for app state changes to reload permissions when app is resumed 38 | useEffect(() => { 39 | const subscription = AppState.addEventListener( 40 | "change", 41 | handleAppStateChange 42 | ); 43 | 44 | return () => { 45 | subscription.remove(); 46 | }; 47 | }, []); 48 | 49 | const handleAppStateChange = async (nextAppState: AppStateStatus) => { 50 | const previousAppState = appStateRef.current; 51 | 52 | // When app comes back to active state from background or inactive 53 | if ( 54 | (previousAppState === "background" || previousAppState === "inactive") && 55 | nextAppState === "active" 56 | ) { 57 | setIsReturningFromBackground(true); 58 | await checkInitialPermissions(); 59 | } else { 60 | setIsReturningFromBackground(false); 61 | } 62 | 63 | appStateRef.current = nextAppState; 64 | }; 65 | 66 | const checkInitialPermissions = async () => { 67 | const status = await checkPermissions(); 68 | const previousStatus = permissionStatus; 69 | setPermissionStatus(status); 70 | 71 | // If permissions were granted while in background, show a notification 72 | if ( 73 | !previousStatus.allGranted && 74 | status.allGranted && 75 | isReturningFromBackground 76 | ) { 77 | Alert.alert( 78 | "Permissions Granted", 79 | "All required permissions have been granted. You can now use the app.", 80 | [{ text: "OK" }] 81 | ); 82 | } 83 | 84 | if (!status.allGranted) { 85 | setShowPermissionsModal(true); 86 | } else { 87 | setShowPermissionsModal(false); 88 | } 89 | }; 90 | 91 | const handleRequestPermissions = async () => { 92 | const status = await requestPermissions(); 93 | setPermissionStatus(status); 94 | 95 | if (status.allGranted) { 96 | setShowPermissionsModal(false); 97 | } 98 | }; 99 | 100 | return ( 101 | 102 | 103 | 104 | 105 | 106 | { 111 | if (permissionStatus.allGranted) { 112 | setShowPermissionsModal(false); 113 | } 114 | }} 115 | /> 116 | 117 | ); 118 | } 119 | 120 | const styles = StyleSheet.create({ 121 | container: { 122 | flex: 1, 123 | backgroundColor: "#000", 124 | }, 125 | }); 126 | -------------------------------------------------------------------------------- /assets/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/demo.mp4 -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/icon-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/images/icon-transparent.png -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/ios-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/images/ios-dark.png -------------------------------------------------------------------------------- /assets/images/ios-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/images/ios-light.png -------------------------------------------------------------------------------- /assets/images/ios-tinted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/images/ios-tinted.png -------------------------------------------------------------------------------- /assets/images/splash-icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/images/splash-icon-dark.png -------------------------------------------------------------------------------- /assets/images/splash-icon-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/images/splash-icon-light.png -------------------------------------------------------------------------------- /assets/listings/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/listings/app-icon.png -------------------------------------------------------------------------------- /assets/listings/feature-graphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/listings/feature-graphic.png -------------------------------------------------------------------------------- /assets/listings/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/listings/screenshot-1.png -------------------------------------------------------------------------------- /assets/listings/screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/listings/screenshot-2.png -------------------------------------------------------------------------------- /assets/listings/screenshot-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilinjuang/wify/f7839a0115884d465eb0abcfd05cab51bde0f839/assets/listings/screenshot-3.png -------------------------------------------------------------------------------- /components/PermissionsModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Linking, 4 | Modal, 5 | StyleSheet, 6 | Text, 7 | TouchableOpacity, 8 | View, 9 | } from "react-native"; 10 | import { PermissionStatus } from "../utils/permissions"; 11 | 12 | interface PermissionsModalProps { 13 | visible: boolean; 14 | permissionStatus: PermissionStatus; 15 | onRequestPermissions: () => void; 16 | onClose: () => void; 17 | } 18 | 19 | const PermissionsModal: React.FC = ({ 20 | visible, 21 | permissionStatus, 22 | onRequestPermissions, 23 | onClose, 24 | }) => { 25 | const openSettings = () => { 26 | Linking.openSettings(); 27 | }; 28 | 29 | // Determine if we need to show the settings button instead of request button 30 | const showSettingsInstead = permissionStatus.anyPermanentlyDenied; 31 | 32 | return ( 33 | {}} 38 | hardwareAccelerated={true} 39 | > 40 | 41 | 42 | Permissions Required 43 | 44 | 45 | Camera: 46 | 52 | {permissionStatus.camera ? "Granted" : "Denied"} 53 | 54 | 55 | 56 | 57 | Camera permission is required to capture WiFi credentials. 58 | 59 | 60 | 61 | Location: 62 | 68 | {permissionStatus.location ? "Granted" : "Denied"} 69 | 70 | 71 | 72 | 73 | Location permission is required to scan for nearby WiFi networks. 74 | 75 | 76 | {showSettingsInstead && ( 77 | 78 | Some permissions were denied. Please enable them in your device 79 | settings. 80 | 81 | )} 82 | 83 | 84 | {showSettingsInstead ? ( 85 | 89 | Open Settings 90 | 91 | ) : ( 92 | 96 | Allow Permissions 97 | 98 | )} 99 | 100 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | const styles = StyleSheet.create({ 107 | centeredView: { 108 | flex: 1, 109 | justifyContent: "center", 110 | alignItems: "center", 111 | backgroundColor: "rgba(0, 0, 0, 0.5)", 112 | }, 113 | modalView: { 114 | margin: 20, 115 | backgroundColor: "white", 116 | borderRadius: 20, 117 | padding: 35, 118 | alignItems: "center", 119 | shadowColor: "#000", 120 | shadowOffset: { 121 | width: 0, 122 | height: 2, 123 | }, 124 | shadowOpacity: 0.25, 125 | shadowRadius: 4, 126 | elevation: 5, 127 | width: "80%", 128 | }, 129 | modalTitle: { 130 | fontSize: 20, 131 | fontWeight: "bold", 132 | marginBottom: 15, 133 | textAlign: "center", 134 | }, 135 | permissionItem: { 136 | flexDirection: "row", 137 | alignItems: "center", 138 | marginVertical: 5, 139 | width: "100%", 140 | }, 141 | permissionName: { 142 | fontSize: 16, 143 | fontWeight: "600", 144 | }, 145 | permissionStatus: { 146 | fontSize: 16, 147 | }, 148 | explanationText: { 149 | marginBottom: 15, 150 | textAlign: "left", 151 | width: "100%", 152 | }, 153 | warningText: { 154 | color: "#F44336", 155 | fontWeight: "bold", 156 | marginTop: 15, 157 | marginBottom: 5, 158 | }, 159 | buttonContainer: { 160 | marginTop: 10, 161 | width: "100%", 162 | }, 163 | button: { 164 | borderRadius: 10, 165 | padding: 10, 166 | elevation: 2, 167 | marginVertical: 5, 168 | }, 169 | buttonRequest: { 170 | backgroundColor: "#2196F3", 171 | }, 172 | buttonSettings: { 173 | backgroundColor: "#FF9800", 174 | }, 175 | buttonText: { 176 | color: "white", 177 | fontWeight: "bold", 178 | textAlign: "center", 179 | }, 180 | }); 181 | 182 | export default PermissionsModal; 183 | -------------------------------------------------------------------------------- /components/WiFiConnectionModal.tsx: -------------------------------------------------------------------------------- 1 | import { Ionicons } from "@expo/vector-icons"; 2 | import React, { useEffect, useState } from "react"; 3 | import { 4 | Alert, 5 | Modal, 6 | Platform, 7 | StyleSheet, 8 | Text, 9 | TextInput, 10 | TouchableOpacity, 11 | View, 12 | } from "react-native"; 13 | import QRCode from "react-native-qrcode-svg"; 14 | import { WiFiCredentials, connectToWiFi } from "../utils/wifi"; 15 | 16 | interface WiFiConnectionModalProps { 17 | visible: boolean; 18 | credentials: WiFiCredentials; 19 | onClose: () => void; 20 | } 21 | 22 | const WiFiConnectionModal: React.FC = ({ 23 | visible, 24 | credentials, 25 | onClose, 26 | }) => { 27 | const [ssid, setSsid] = useState(credentials.ssid || ""); 28 | const [password, setPassword] = useState(credentials.password || ""); 29 | const [isConnecting, setIsConnecting] = useState(false); 30 | 31 | // Update state when credentials change 32 | useEffect(() => { 33 | setSsid(credentials.ssid || ""); 34 | setPassword(credentials.password || ""); 35 | }, [credentials]); 36 | 37 | // Generate WiFi connection string for QR code 38 | const getWifiConnectionString = () => { 39 | const authType = credentials.isWPA ? "WPA" : "WEP"; 40 | return `WIFI:S:${ssid};T:${authType};P:${password};;`; 41 | }; 42 | 43 | const handleConnect = async () => { 44 | if (!ssid.trim()) { 45 | Alert.alert("Missing Network Name", "Please enter a WiFi network name.", [ 46 | { text: "OK" }, 47 | ]); 48 | return; 49 | } 50 | 51 | setIsConnecting(true); 52 | 53 | try { 54 | const success = await connectToWiFi(ssid, password, credentials.isWPA); 55 | 56 | if (success) { 57 | Alert.alert("Connected", `Successfully connected to ${ssid}`, [ 58 | { text: "OK", onPress: onClose }, 59 | ]); 60 | } else { 61 | Alert.alert( 62 | "Connection Failed", 63 | "Failed to connect to the WiFi network. Please check the network name and password and try again.", 64 | [{ text: "OK" }] 65 | ); 66 | } 67 | } catch (error) { 68 | console.error("Error connecting to WiFi:", error); 69 | Alert.alert( 70 | "Connection Error", 71 | "An error occurred while connecting to the WiFi network. Please try again.", 72 | [{ text: "OK" }] 73 | ); 74 | } finally { 75 | setIsConnecting(false); 76 | } 77 | }; 78 | 79 | return ( 80 | 86 | 87 | 88 | Connect to WiFi 89 | 90 | 91 | SSID 92 | 101 | 102 | 103 | 104 | Password 105 | 114 | 115 | 116 | {Platform.OS === "android" ? ( 117 | <> 118 | 119 | Use circle to search to scan and connect 120 | 121 | 122 | 128 | 129 | 133 | 134 | 135 | 136 | ) : ( 137 | 138 | 143 | 144 | {isConnecting ? "Connecting..." : "Connect"} 145 | 146 | 147 | 148 | 153 | Cancel 154 | 155 | 156 | )} 157 | 158 | 159 | 160 | ); 161 | }; 162 | 163 | const styles = StyleSheet.create({ 164 | centeredView: { 165 | flex: 1, 166 | justifyContent: "center", 167 | alignItems: "center", 168 | backgroundColor: "rgba(0, 0, 0, 0.5)", 169 | }, 170 | modalView: { 171 | margin: 20, 172 | backgroundColor: "white", 173 | borderRadius: 20, 174 | padding: 35, 175 | alignItems: "center", 176 | shadowColor: "#000", 177 | shadowOffset: { 178 | width: 0, 179 | height: 2, 180 | }, 181 | shadowOpacity: 0.25, 182 | shadowRadius: 4, 183 | elevation: 5, 184 | width: "80%", 185 | }, 186 | modalTitle: { 187 | fontSize: 20, 188 | fontWeight: "bold", 189 | marginBottom: 15, 190 | textAlign: "center", 191 | }, 192 | label: { 193 | fontSize: 16, 194 | fontWeight: "600", 195 | marginRight: 10, 196 | }, 197 | inputContainer: { 198 | width: "100%", 199 | marginBottom: 20, 200 | }, 201 | input: { 202 | borderWidth: 1, 203 | borderColor: "#ddd", 204 | borderRadius: 5, 205 | padding: 10, 206 | marginTop: 5, 207 | width: "100%", 208 | }, 209 | buttonContainer: { 210 | flexDirection: "row", 211 | width: "100%", 212 | }, 213 | button: { 214 | borderRadius: 10, 215 | padding: 10, 216 | elevation: 2, 217 | marginHorizontal: 5, 218 | }, 219 | connectButton: { 220 | flex: 1, 221 | backgroundColor: "#2196F3", 222 | }, 223 | cancelButton: { 224 | backgroundColor: "#757575", 225 | }, 226 | closeButton: { 227 | backgroundColor: "#757575", 228 | }, 229 | buttonText: { 230 | color: "white", 231 | fontWeight: "bold", 232 | textAlign: "center", 233 | }, 234 | loadingContainer: { 235 | flexDirection: "row", 236 | alignItems: "center", 237 | justifyContent: "center", 238 | width: "100%", 239 | }, 240 | loadingText: { 241 | marginLeft: 10, 242 | fontSize: 16, 243 | }, 244 | qrInstructions: { 245 | textAlign: "center", 246 | marginBottom: 30, 247 | fontSize: 14, 248 | color: "#555", 249 | }, 250 | qrContainer: { 251 | marginBottom: 30, 252 | }, 253 | }); 254 | 255 | export default WiFiConnectionModal; 256 | -------------------------------------------------------------------------------- /components/WiFiScanner.tsx: -------------------------------------------------------------------------------- 1 | import { Ionicons } from "@expo/vector-icons"; 2 | import { CameraCapturedPicture, CameraView, FlashMode } from "expo-camera"; 3 | import * as ImagePicker from "expo-image-picker"; 4 | import React, { useEffect, useRef, useState } from "react"; 5 | import { 6 | ActivityIndicator, 7 | Alert, 8 | Linking, 9 | StyleSheet, 10 | Text, 11 | TouchableOpacity, 12 | View, 13 | } from "react-native"; 14 | import { PermissionStatus } from "../utils/permissions"; 15 | import { recognizeWifiFromImage } from "../utils/textRecognition"; 16 | import { 17 | WiFiCredentials, 18 | getSortedNetworksByFuzzyMatch, 19 | scanWiFiNetworks, 20 | } from "../utils/wifi"; 21 | import WiFiConnectionModal from "./WiFiConnectionModal"; 22 | 23 | interface WiFiScannerProps { 24 | permissionStatus?: PermissionStatus; 25 | } 26 | 27 | const WiFiScanner: React.FC = ({ permissionStatus }) => { 28 | const [isProcessing, setIsProcessing] = useState(false); 29 | const [credentials, setCredentials] = useState(null); 30 | const [showConnectionModal, setShowConnectionModal] = useState(false); 31 | const [flashMode, setFlashMode] = useState("off"); 32 | const [isCameraActive, setIsCameraActive] = useState(false); 33 | const [isImagePickerActive, setIsImagePickerActive] = useState(false); 34 | 35 | const cameraRef = useRef(null); 36 | 37 | useEffect(() => { 38 | if (!cameraRef.current || isProcessing) { 39 | return; 40 | } 41 | 42 | const modalOpen = showConnectionModal || isImagePickerActive; 43 | 44 | if (modalOpen && isCameraActive) { 45 | cameraRef.current.pausePreview(); 46 | setIsCameraActive(false); 47 | } else if (!modalOpen && !isCameraActive) { 48 | cameraRef.current.resumePreview(); 49 | setIsCameraActive(true); 50 | } 51 | }, [showConnectionModal, isProcessing, isImagePickerActive]); 52 | 53 | const toggleFlash = () => { 54 | setFlashMode(flashMode === "off" ? "on" : "off"); 55 | }; 56 | 57 | const openImagePicker = async () => { 58 | if (isProcessing) { 59 | return; 60 | } 61 | 62 | try { 63 | setIsImagePickerActive(true); 64 | 65 | const permissionResult = 66 | await ImagePicker.requestMediaLibraryPermissionsAsync(); 67 | 68 | if (!permissionResult.granted) { 69 | // Check if permission is permanently denied 70 | if (permissionResult.canAskAgain === false) { 71 | Alert.alert( 72 | "Permission Required", 73 | "Photo library access has been permanently denied. Please enable it in your device settings.", 74 | [ 75 | { text: "Cancel", style: "cancel" }, 76 | { text: "Open Settings", onPress: () => Linking.openSettings() }, 77 | ] 78 | ); 79 | } else { 80 | Alert.alert( 81 | "Permission Required", 82 | "Photo library access is needed to select photos.", 83 | [{ text: "OK" }] 84 | ); 85 | } 86 | setIsImagePickerActive(false); 87 | return; 88 | } 89 | 90 | const result = await ImagePicker.launchImageLibraryAsync({ 91 | mediaTypes: ["images"], 92 | allowsEditing: false, 93 | quality: 1, 94 | }); 95 | 96 | setIsImagePickerActive(false); 97 | 98 | if (!result.canceled && result.assets && result.assets.length > 0) { 99 | const selectedImage = result.assets[0]; 100 | await processImage(selectedImage); 101 | } 102 | } catch (error) { 103 | console.error("Error picking image:", error); 104 | Alert.alert( 105 | "Error", 106 | "Failed to pick image from library. Please try again." 107 | ); 108 | setIsImagePickerActive(false); 109 | } 110 | }; 111 | 112 | const takePicture = async () => { 113 | if (cameraRef.current && !isProcessing) { 114 | setIsProcessing(true); 115 | try { 116 | const photo = await cameraRef.current.takePictureAsync(); 117 | if (photo) { 118 | await processImage(photo); 119 | } else { 120 | Alert.alert("Error", "Failed to capture image. Please try again."); 121 | setIsProcessing(false); 122 | } 123 | } catch (error) { 124 | console.error("Error taking picture:", error); 125 | Alert.alert("Error", "Failed to capture image. Please try again."); 126 | setIsProcessing(false); 127 | } 128 | } 129 | }; 130 | 131 | const processImage = async ( 132 | photo: CameraCapturedPicture | ImagePicker.ImagePickerAsset 133 | ) => { 134 | try { 135 | setIsProcessing(true); 136 | // Perform text recognition 137 | const extractedCredentials = await recognizeWifiFromImage(photo); 138 | 139 | if (extractedCredentials) { 140 | // Use the credentials found 141 | await processWiFiCredentials(extractedCredentials); 142 | } else { 143 | Alert.alert( 144 | "No WiFi Information Found", 145 | "Could not detect any WiFi credentials in the image. Please try again.", 146 | [{ text: "OK" }] 147 | ); 148 | } 149 | } catch (error) { 150 | console.error("Error processing image:", error); 151 | Alert.alert("Error", "Failed to process image. Please try again."); 152 | } finally { 153 | setIsProcessing(false); 154 | } 155 | }; 156 | 157 | const processWiFiCredentials = async (wifiCredentials: WiFiCredentials) => { 158 | setCredentials(wifiCredentials); 159 | 160 | try { 161 | setIsProcessing(true); 162 | // Scan for networks and update state 163 | const freshNetworks = await scanWiFiNetworks(); 164 | 165 | // Sort networks by fuzzy match similarity 166 | const sorted = getSortedNetworksByFuzzyMatch( 167 | wifiCredentials.ssid, 168 | freshNetworks 169 | ); 170 | 171 | // If we have matches, use the most confident one 172 | if (sorted.length > 0) { 173 | // Update the credentials with the most confident match 174 | const bestMatch = sorted[0]; 175 | setCredentials({ 176 | ...wifiCredentials, 177 | ssid: bestMatch.SSID, 178 | }); 179 | } 180 | 181 | // Show connection modal with either the original or updated SSID 182 | setShowConnectionModal(true); 183 | } catch (error) { 184 | console.error("Error scanning WiFi:", error); 185 | Alert.alert( 186 | "Error", 187 | "Failed to scan WiFi networks. Proceeding with detected credentials.", 188 | [{ text: "OK" }] 189 | ); 190 | // Still show the connection modal with the original credentials 191 | setShowConnectionModal(true); 192 | } finally { 193 | setIsProcessing(false); 194 | } 195 | }; 196 | 197 | const handleCloseConnectionModal = () => { 198 | setShowConnectionModal(false); 199 | setCredentials(null); 200 | }; 201 | 202 | if (!permissionStatus?.allGranted) { 203 | return null; 204 | } 205 | 206 | return ( 207 | 208 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 225 | 226 | 227 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | {isProcessing && ( 242 | 243 | 244 | Processing... 245 | 246 | )} 247 | 248 | {showConnectionModal && credentials && ( 249 | 254 | )} 255 | 256 | ); 257 | }; 258 | 259 | const styles = StyleSheet.create({ 260 | container: { 261 | flex: 1, 262 | backgroundColor: "#000", 263 | }, 264 | camera: { 265 | flex: 1, 266 | }, 267 | overlay: { 268 | flex: 1, 269 | backgroundColor: "transparent", 270 | justifyContent: "center", 271 | alignItems: "center", 272 | }, 273 | scanArea: { 274 | width: 250, 275 | height: 250, 276 | borderWidth: 2, 277 | borderColor: "#FFFFFF", 278 | borderRadius: 10, 279 | }, 280 | controlsContainer: { 281 | position: "absolute", 282 | bottom: 40, 283 | left: 0, 284 | right: 0, 285 | flexDirection: "row", 286 | justifyContent: "space-around", 287 | alignItems: "center", 288 | paddingHorizontal: 20, 289 | }, 290 | controlButton: { 291 | backgroundColor: "rgba(0, 0, 0, 0.6)", 292 | padding: 10, 293 | borderRadius: 10, 294 | width: 100, 295 | alignItems: "center", 296 | }, 297 | iconButton: { 298 | backgroundColor: "rgba(0, 0, 0, 0.6)", 299 | padding: 15, 300 | borderRadius: 50, 301 | alignItems: "center", 302 | justifyContent: "center", 303 | }, 304 | captureButton: { 305 | width: 70, 306 | height: 70, 307 | borderRadius: 35, 308 | backgroundColor: "rgba(255, 255, 255, 0.3)", 309 | justifyContent: "center", 310 | alignItems: "center", 311 | }, 312 | captureButtonInner: { 313 | width: 60, 314 | height: 60, 315 | borderRadius: 30, 316 | backgroundColor: "#FFFFFF", 317 | }, 318 | buttonText: { 319 | color: "#FFFFFF", 320 | fontWeight: "bold", 321 | }, 322 | text: { 323 | color: "#FFFFFF", 324 | fontSize: 16, 325 | marginTop: 10, 326 | textAlign: "center", 327 | }, 328 | processingOverlay: { 329 | ...StyleSheet.absoluteFillObject, 330 | backgroundColor: "rgba(0, 0, 0, 0.7)", 331 | justifyContent: "center", 332 | alignItems: "center", 333 | }, 334 | processingText: { 335 | color: "#FFFFFF", 336 | fontSize: 18, 337 | marginTop: 10, 338 | }, 339 | actionButton: { 340 | backgroundColor: "#2196F3", 341 | padding: 10, 342 | borderRadius: 10, 343 | margin: 20, 344 | }, 345 | spacer: { 346 | width: 54, // Same width as iconButton to maintain layout balance 347 | }, 348 | warningText: { 349 | color: "#F44336", 350 | fontSize: 16, 351 | marginTop: 10, 352 | marginBottom: 10, 353 | textAlign: "center", 354 | paddingHorizontal: 20, 355 | }, 356 | placeholderContainer: { 357 | flex: 1, 358 | justifyContent: "center", 359 | alignItems: "center", 360 | }, 361 | permissionsOverlay: { 362 | ...StyleSheet.absoluteFillObject, 363 | backgroundColor: "rgba(0, 0, 0, 0.9)", 364 | justifyContent: "center", 365 | alignItems: "center", 366 | zIndex: 10, 367 | }, 368 | }); 369 | 370 | export default WiFiScanner; 371 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 15.0.15", 4 | "appVersionSource": "remote" 5 | }, 6 | "build": { 7 | "development": { 8 | "developmentClient": true, 9 | "distribution": "internal" 10 | }, 11 | "preview": { 12 | "distribution": "internal" 13 | }, 14 | "production": { 15 | "autoIncrement": true 16 | } 17 | }, 18 | "submit": { 19 | "production": { 20 | "android": { 21 | "serviceAccountKeyPath": "./google-play-service-account.json", 22 | "track": "production" 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wify", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo run:android", 9 | "ios": "expo run:ios", 10 | "web": "expo start --web", 11 | "test": "jest --watchAll", 12 | "lint": "expo lint" 13 | }, 14 | "jest": { 15 | "preset": "jest-expo" 16 | }, 17 | "dependencies": { 18 | "@expo/vector-icons": "^14.0.2", 19 | "@react-native-ml-kit/text-recognition": "^1.5.2", 20 | "@react-navigation/bottom-tabs": "^7.2.0", 21 | "@react-navigation/native": "^7.0.14", 22 | "expo": "~52.0.37", 23 | "expo-blur": "~14.0.3", 24 | "expo-camera": "~16.0.18", 25 | "expo-constants": "~17.0.7", 26 | "expo-dev-client": "~5.0.12", 27 | "expo-font": "~13.0.4", 28 | "expo-haptics": "~14.0.1", 29 | "expo-image-picker": "~16.0.6", 30 | "expo-linking": "~7.0.5", 31 | "expo-location": "~18.0.7", 32 | "expo-router": "~4.0.17", 33 | "expo-splash-screen": "~0.29.22", 34 | "expo-status-bar": "~2.0.1", 35 | "expo-symbols": "~0.2.2", 36 | "expo-system-ui": "~4.0.8", 37 | "expo-web-browser": "~14.0.2", 38 | "fuse.js": "^7.1.0", 39 | "react": "18.3.1", 40 | "react-dom": "18.3.1", 41 | "react-native": "0.76.7", 42 | "react-native-gesture-handler": "~2.20.2", 43 | "react-native-qrcode-svg": "^6.3.15", 44 | "react-native-reanimated": "~3.16.1", 45 | "react-native-safe-area-context": "4.12.0", 46 | "react-native-screens": "~4.4.0", 47 | "react-native-web": "~0.19.13", 48 | "react-native-webview": "13.12.5", 49 | "react-native-wifi-reborn": "^4.13.4", 50 | "react-native-svg": "15.8.0" 51 | }, 52 | "devDependencies": { 53 | "@babel/core": "^7.25.2", 54 | "@types/jest": "^29.5.12", 55 | "@types/react": "~18.3.12", 56 | "@types/react-test-renderer": "^18.3.0", 57 | "jest": "^29.2.1", 58 | "jest-expo": "~52.0.4", 59 | "react-test-renderer": "18.3.1", 60 | "typescript": "^5.3.3" 61 | }, 62 | "private": true 63 | } 64 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /utils/permissions.ts: -------------------------------------------------------------------------------- 1 | import { Camera } from "expo-camera"; 2 | import * as Location from "expo-location"; 3 | 4 | export interface PermissionStatus { 5 | camera: boolean; 6 | location: boolean; 7 | allGranted: boolean; 8 | cameraPermanentlyDenied: boolean; 9 | locationPermanentlyDenied: boolean; 10 | anyPermanentlyDenied: boolean; 11 | } 12 | 13 | export const requestPermissions = async (): Promise => { 14 | // Request camera permission 15 | const cameraPermission = await Camera.requestCameraPermissionsAsync(); 16 | 17 | // Request location permission 18 | const locationPermission = await Location.requestForegroundPermissionsAsync(); 19 | 20 | // Check if permissions are permanently denied 21 | const cameraPermanentlyDenied = 22 | cameraPermission.status === "denied" && 23 | cameraPermission.canAskAgain === false; 24 | 25 | const locationPermanentlyDenied = 26 | locationPermission.status === "denied" && 27 | locationPermission.canAskAgain === false; 28 | 29 | const permissionStatus: PermissionStatus = { 30 | camera: cameraPermission.status === "granted", 31 | location: locationPermission.status === "granted", 32 | allGranted: 33 | cameraPermission.status === "granted" && 34 | locationPermission.status === "granted", 35 | cameraPermanentlyDenied, 36 | locationPermanentlyDenied, 37 | anyPermanentlyDenied: cameraPermanentlyDenied || locationPermanentlyDenied, 38 | }; 39 | 40 | return permissionStatus; 41 | }; 42 | 43 | export const checkPermissions = async (): Promise => { 44 | // Check camera permission 45 | const cameraPermission = await Camera.getCameraPermissionsAsync(); 46 | 47 | // Check location permission 48 | const locationPermission = await Location.getForegroundPermissionsAsync(); 49 | 50 | // Check if permissions are permanently denied 51 | const cameraPermanentlyDenied = 52 | cameraPermission.status === "denied" && 53 | cameraPermission.canAskAgain === false; 54 | 55 | const locationPermanentlyDenied = 56 | locationPermission.status === "denied" && 57 | locationPermission.canAskAgain === false; 58 | 59 | const permissionStatus: PermissionStatus = { 60 | camera: cameraPermission.status === "granted", 61 | location: locationPermission.status === "granted", 62 | allGranted: 63 | cameraPermission.status === "granted" && 64 | locationPermission.status === "granted", 65 | cameraPermanentlyDenied, 66 | locationPermanentlyDenied, 67 | anyPermanentlyDenied: cameraPermanentlyDenied || locationPermanentlyDenied, 68 | }; 69 | 70 | return permissionStatus; 71 | }; 72 | -------------------------------------------------------------------------------- /utils/textRecognition.ts: -------------------------------------------------------------------------------- 1 | import TextRecognition, { 2 | TextRecognitionScript, 3 | } from "@react-native-ml-kit/text-recognition"; 4 | import { CameraCapturedPicture } from "expo-camera"; 5 | import * as ImagePicker from "expo-image-picker"; 6 | import { extractWiFiFromText, WiFiCredentials } from "./wifi"; 7 | 8 | export const recognizeWifiFromImage = async ( 9 | photo: CameraCapturedPicture | ImagePicker.ImagePickerAsset 10 | ): Promise => { 11 | try { 12 | const imageUri = photo.uri; 13 | 14 | // Initialize combined text 15 | let combinedText = ""; 16 | 17 | // Try with specific scripts for better accuracy on different languages 18 | const scriptTypes = [ 19 | TextRecognitionScript.LATIN, 20 | TextRecognitionScript.CHINESE, 21 | TextRecognitionScript.JAPANESE, 22 | TextRecognitionScript.KOREAN, 23 | ]; 24 | 25 | // Try each script type and collect results 26 | for (const scriptType of scriptTypes) { 27 | try { 28 | const result = await TextRecognition.recognize(imageUri, scriptType); 29 | // Try to extract credentials from each recognition result individually 30 | const credentials = extractWiFiFromText(result.text); 31 | if (credentials) { 32 | return credentials; 33 | } 34 | // Also add to combined text for a final attempt 35 | combinedText += result.text + "\n"; 36 | } catch (e) { 37 | // If a specific script fails, just continue 38 | console.error(`Recognition with script ${scriptType} failed:`, e); 39 | } 40 | } 41 | 42 | // As a fallback, try with the combined text 43 | return extractWiFiFromText(combinedText); 44 | } catch (error) { 45 | console.error("Error recognizing text:", error); 46 | return null; 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /utils/wifi.ts: -------------------------------------------------------------------------------- 1 | import Fuse from "fuse.js"; 2 | import { Platform } from "react-native"; 3 | import WiFiManager from "react-native-wifi-reborn"; 4 | 5 | export interface WiFiNetwork { 6 | SSID: string; 7 | BSSID?: string; 8 | capabilities?: string; 9 | frequency?: number; 10 | level?: number; 11 | timestamp?: number; 12 | } 13 | 14 | export interface WiFiCredentials { 15 | ssid: string; 16 | password: string; 17 | isWPA?: boolean; 18 | } 19 | 20 | // Extract WiFi credentials from OCR text 21 | export const extractWiFiFromText = (text: string): WiFiCredentials | null => { 22 | // Common patterns for WiFi information in multiple languages 23 | const ssidPatterns = [ 24 | // English 25 | /SSID\s*:?\s*(["']?)([^"'\n]+)\1/i, 26 | /Network\s*:?\s*(["']?)([^"'\n]+)\1/i, 27 | /WiFi\s*:?\s*(["']?)([^"'\n]+)\1/i, 28 | /Network name\s*:?\s*(["']?)([^"'\n]+)\1/i, 29 | // Chinese 30 | /网络\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network 31 | /网络名称\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network name 32 | /无线网络\s*:?\s*(["']?)([^"'\n]+)\1/i, // Wireless network 33 | /名称\s*:?\s*(["']?)([^"'\n]+)\1/i, // Name 34 | // Spanish 35 | /Red\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network 36 | /Nombre de red\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network name 37 | // French 38 | /Réseau\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network 39 | /Nom du réseau\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network name 40 | // German 41 | /Netzwerk\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network 42 | /Netzwerkname\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network name 43 | // Japanese 44 | /ネットワーク\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network 45 | /ネットワーク名\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network name 46 | // Korean 47 | /네트워크\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network 48 | /네트워크 이름\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network name 49 | ]; 50 | 51 | const passwordPatterns = [ 52 | // English 53 | /Password\s*:?\s*(["']?)([^"'\n]+)\1/i, 54 | /Pass\s*:?\s*(["']?)([^"'\n]+)\1/i, 55 | /Passphrase\s*:?\s*(["']?)([^"'\n]+)\1/i, 56 | /Network key\s*:?\s*(["']?)([^"'\n]+)\1/i, 57 | // Chinese 58 | /密码\s*:?\s*(["']?)([^"'\n]+)\1/i, // Password 59 | /密碼\s*:?\s*(["']?)([^"'\n]+)\1/i, // Password (Traditional) 60 | /网络密码\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network password 61 | /无线密码\s*:?\s*(["']?)([^"'\n]+)\1/i, // Wireless password 62 | /口令\s*:?\s*(["']?)([^"'\n]+)\1/i, // Passphrase 63 | // Spanish 64 | /Contraseña\s*:?\s*(["']?)([^"'\n]+)\1/i, // Password 65 | /Clave\s*:?\s*(["']?)([^"'\n]+)\1/i, // Key/Password 66 | /Clave de red\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network key 67 | // French 68 | /Mot de passe\s*:?\s*(["']?)([^"'\n]+)\1/i, // Password 69 | /Clé\s*:?\s*(["']?)([^"'\n]+)\1/i, // Key 70 | /Clé réseau\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network key 71 | // German 72 | /Passwort\s*:?\s*(["']?)([^"'\n]+)\1/i, // Password 73 | /Kennwort\s*:?\s*(["']?)([^"'\n]+)\1/i, // Password 74 | /Netzwerkschlüssel\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network key 75 | // Japanese 76 | /パスワード\s*:?\s*(["']?)([^"'\n]+)\1/i, // Password 77 | /暗号キー\s*:?\s*(["']?)([^"'\n]+)\1/i, // Encryption key 78 | // Korean 79 | /비밀번호\s*:?\s*(["']?)([^"'\n]+)\1/i, // Password 80 | /네트워크 키\s*:?\s*(["']?)([^"'\n]+)\1/i, // Network key 81 | ]; 82 | 83 | // Try to find SSID 84 | let ssid = ""; 85 | for (const pattern of ssidPatterns) { 86 | const match = text.match(pattern); 87 | if (match && match[2]) { 88 | ssid = match[2].trim(); 89 | break; 90 | } 91 | } 92 | 93 | // If SSID found, look for password 94 | if (ssid) { 95 | let password = ""; 96 | for (const pattern of passwordPatterns) { 97 | const match = text.match(pattern); 98 | if (match && match[2]) { 99 | password = match[2].trim(); 100 | return { 101 | ssid, 102 | password, 103 | isWPA: true, // Assume WPA by default 104 | }; 105 | } 106 | } 107 | } 108 | 109 | // Try to extract credentials from common formats without explicit labels 110 | // This helps with formats like "Network: MyWiFi / Password: 12345" 111 | // Look for patterns like "something: value1 / something: value2" 112 | const combinedPattern = 113 | /([^:]+):\s*([^\/\n]+)(?:\s*\/\s*|\n)([^:]+):\s*([^\n]+)/i; 114 | const match = text.match(combinedPattern); 115 | 116 | if (match) { 117 | const label1 = match[1].trim().toLowerCase(); 118 | const value1 = match[2].trim(); 119 | const label2 = match[3].trim().toLowerCase(); 120 | const value2 = match[4].trim(); 121 | 122 | // Determine which is SSID and which is password based on labels 123 | let extractedSsid = ""; 124 | let extractedPassword = ""; 125 | 126 | // Check first pair 127 | const ssidLabels = [ 128 | "ssid", 129 | "network", 130 | "wifi", 131 | "name", 132 | "网络", 133 | "名称", 134 | "red", 135 | "réseau", 136 | "netzwerk", 137 | "ネットワーク", 138 | "네트워크", 139 | ]; 140 | const passwordLabels = [ 141 | "password", 142 | "pass", 143 | "key", 144 | "密码", 145 | "密碼", 146 | "contraseña", 147 | "clave", 148 | "mot de passe", 149 | "passwort", 150 | "パスワード", 151 | "비밀번호", 152 | ]; 153 | 154 | if (ssidLabels.some((label) => label1.includes(label))) { 155 | extractedSsid = value1; 156 | } else if (passwordLabels.some((label) => label1.includes(label))) { 157 | extractedPassword = value1; 158 | } 159 | 160 | // Check second pair 161 | if (ssidLabels.some((label) => label2.includes(label))) { 162 | extractedSsid = value2; 163 | } else if (passwordLabels.some((label) => label2.includes(label))) { 164 | extractedPassword = value2; 165 | } 166 | 167 | // If we found both SSID and password, return them 168 | if (extractedSsid && extractedPassword) { 169 | return { 170 | ssid: extractedSsid, 171 | password: extractedPassword, 172 | isWPA: true, 173 | }; 174 | } 175 | } 176 | 177 | // If we couldn't find both SSID and password, return null 178 | return null; 179 | }; 180 | 181 | // Filter out hidden networks (those with empty or placeholder SSIDs) 182 | export const filterOutHiddenNetworks = ( 183 | networks: WiFiNetwork[] 184 | ): WiFiNetwork[] => { 185 | if (!networks || networks.length === 0) return []; 186 | 187 | return networks.filter((network) => { 188 | // Keep only networks with valid SSIDs (not empty, not placeholders) 189 | return ( 190 | network.SSID && 191 | network.SSID.trim() !== "" && 192 | network.SSID !== "" && 193 | network.SSID !== "" 194 | ); 195 | }); 196 | }; 197 | 198 | // Filter out unsecured WiFi networks 199 | export const filterOutUnsecuredNetworks = ( 200 | networks: WiFiNetwork[] 201 | ): WiFiNetwork[] => { 202 | if (!networks || networks.length === 0) return []; 203 | 204 | return networks.filter((network) => { 205 | // Only keep networks with security capabilities 206 | // This checks if capabilities string contains WEP, WPA, WPA2, or WPA3 207 | return ( 208 | network.capabilities && 209 | (network.capabilities.includes("WEP") || 210 | network.capabilities.includes("WPA") || 211 | network.capabilities.includes("PSK") || 212 | network.capabilities.includes("EAP")) 213 | ); 214 | }); 215 | }; 216 | 217 | // Scan for available WiFi networks 218 | export const scanWiFiNetworks = async (): Promise => { 219 | try { 220 | let networks: WiFiNetwork[] = []; 221 | 222 | // On iOS, we need to load the list differently 223 | if (Platform.OS === "ios") { 224 | // iOS doesn't support scanning for networks directly 225 | // We can only get the current connected network 226 | const ssid = await WiFiManager.getCurrentWifiSSID(); 227 | networks = ssid ? [{ SSID: ssid }] : []; 228 | } else { 229 | // On Android, we can scan for networks 230 | networks = await WiFiManager.loadWifiList(); 231 | } 232 | 233 | // Filter out hidden networks first 234 | const visibleNetworks = filterOutHiddenNetworks(networks); 235 | 236 | // Filter out unsecured networks 237 | const securedNetworks = filterOutUnsecuredNetworks(visibleNetworks); 238 | 239 | // Then deduplicate networks by SSID, keeping only the strongest signal one 240 | return deduplicateNetworksBySSID(securedNetworks); 241 | } catch (error) { 242 | console.error("Error scanning WiFi networks:", error); 243 | return []; 244 | } 245 | }; 246 | 247 | // Get networks sorted by fuzzy match similarity to the target SSID 248 | export const getSortedNetworksByFuzzyMatch = ( 249 | targetSSID: string, 250 | networks: WiFiNetwork[] 251 | ): WiFiNetwork[] => { 252 | if (!targetSSID || networks.length === 0) return []; 253 | 254 | // Configure Fuse.js options 255 | const options = { 256 | includeScore: true, 257 | keys: ["SSID"], 258 | threshold: 0.8, // Higher threshold allows more results 259 | }; 260 | 261 | // Create a new Fuse instance 262 | const fuse = new Fuse(networks, options); 263 | 264 | // Search for the target SSID 265 | const results = fuse.search(targetSSID); 266 | 267 | // Map results back to WiFiNetwork objects 268 | return results.map((result) => result.item); 269 | }; 270 | 271 | // Extract security protocol from capabilities string 272 | export const getSecurityProtocol = (capabilities?: string): string => { 273 | if (!capabilities) return "Unknown"; 274 | 275 | if (capabilities.includes("WPA3")) return "WPA3"; 276 | if (capabilities.includes("WPA2")) return "WPA2"; 277 | if (capabilities.includes("WPA")) return "WPA"; 278 | if (capabilities.includes("WEP")) return "WEP"; 279 | if (capabilities.includes("PSK") && !capabilities.includes("WPA")) 280 | return "PSK"; 281 | if (capabilities.includes("EAP") && !capabilities.includes("WPA")) 282 | return "Enterprise"; 283 | 284 | return "Unsecured"; 285 | }; 286 | 287 | // Connect to a WiFi network 288 | export const connectToWiFi = async ( 289 | ssid: string, 290 | password: string, 291 | isWPA: boolean = true 292 | ): Promise => { 293 | try { 294 | if (Platform.OS === "android") { 295 | // On Android, we use system built-in WiFi connection dialog via qr code 296 | return false; 297 | } else if (Platform.OS === "ios") { 298 | // On iOS, we need to use a different method 299 | await WiFiManager.connectToProtectedSSIDPrefix( 300 | ssid, 301 | password, 302 | !isWPA // iOS only 303 | ); 304 | 305 | // Check connection status every second for up to 15 seconds 306 | for (let i = 0; i < 15; i++) { 307 | // Wait 1 second 308 | await new Promise((resolve) => setTimeout(resolve, 1000)); 309 | 310 | // Check if we're connected to the expected network 311 | const connectionStatus = await checkWiFiConnectionStatus(); 312 | 313 | // If connected to the right network, return success 314 | if (connectionStatus.isConnected && connectionStatus.ssid === ssid) { 315 | return true; 316 | } 317 | } 318 | 319 | // If we've checked 15 times and still not connected, return false 320 | return false; 321 | } 322 | return false; 323 | } catch (error) { 324 | console.error("Error connecting to WiFi:", error); 325 | return false; 326 | } 327 | }; 328 | 329 | // Deduplicate networks by SSID, keeping only the strongest signal one 330 | export const deduplicateNetworksBySSID = ( 331 | networks: WiFiNetwork[] 332 | ): WiFiNetwork[] => { 333 | if (!networks || networks.length === 0) return []; 334 | 335 | // First filter out hidden networks 336 | const visibleNetworks = filterOutHiddenNetworks(networks); 337 | 338 | const uniqueNetworks = new Map(); 339 | 340 | // Process each network 341 | for (const network of visibleNetworks) { 342 | // If we haven't seen this SSID before, add it 343 | if (!uniqueNetworks.has(network.SSID)) { 344 | uniqueNetworks.set(network.SSID, network); 345 | continue; 346 | } 347 | 348 | // If we've seen this SSID before, compare signal strength 349 | const existingNetwork = uniqueNetworks.get(network.SSID)!; 350 | 351 | // If the new network has a stronger signal (higher level), replace the existing one 352 | // Note: WiFi signal strength is measured in dBm, where higher (less negative) values 353 | // indicate stronger signals (e.g., -50 dBm is stronger than -80 dBm) 354 | if ( 355 | network.level !== undefined && 356 | (existingNetwork.level === undefined || 357 | network.level > existingNetwork.level) 358 | ) { 359 | uniqueNetworks.set(network.SSID, network); 360 | } 361 | } 362 | 363 | // Convert Map values to array 364 | return Array.from(uniqueNetworks.values()); 365 | }; 366 | 367 | // Check current WiFi connection status 368 | export const checkWiFiConnectionStatus = async (): Promise<{ 369 | isConnected: boolean; 370 | ssid?: string; 371 | bssid?: string; 372 | }> => { 373 | try { 374 | // Check if WiFi is enabled - not all platforms support this 375 | let isEnabled = true; 376 | try { 377 | // Use isEnabled if available 378 | isEnabled = await WiFiManager.isEnabled(); 379 | } catch (enabledError) { 380 | // If isEnabled is not available, assume WiFi is enabled 381 | console.warn("isEnabled method not available, assuming WiFi is enabled"); 382 | } 383 | 384 | if (!isEnabled) { 385 | return { isConnected: false }; 386 | } 387 | 388 | // Get current SSID 389 | try { 390 | const ssid = await WiFiManager.getCurrentWifiSSID(); 391 | 392 | // If we have an SSID, we're connected 393 | // Note: getCurrentBSSID might not be available on all platforms 394 | return { isConnected: true, ssid }; 395 | } catch (ssidError) { 396 | // If we can't get SSID, we're not connected 397 | return { isConnected: false }; 398 | } 399 | } catch (error) { 400 | console.error("Error checking WiFi status:", error); 401 | return { isConnected: false }; 402 | } 403 | }; 404 | --------------------------------------------------------------------------------