├── .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 |
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 |
--------------------------------------------------------------------------------