Initial plugin release
This commit is contained in:
commit
9505ff6522
7 files changed
+1420
No files matched your search
@@ -0,0 +1,219 @@
|
||||
# Bluememe Wi-Fi Connector Cordova Plugin
|
||||
|
||||
这个仓库现在是一个可供 OutSystems 11 Android 和 iOS 应用引用的 Cordova 插件仓库。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- Android 9 及以下: 可直接调用旧版 `WifiManager` API 切换到指定 Wi-Fi。
|
||||
- Android 10 及以上: 不能再由第三方应用静默强制切换到任意 Wi-Fi。
|
||||
- Android 10+: 可用 `WifiNetworkSpecifier` 建立应用级本地连接,适合设备配网、IoT、局域网直连。
|
||||
- Android 10+: 可用 `WifiNetworkSuggestion` 向系统提交候选 Wi-Fi,是否连接由系统决定。
|
||||
- Android 11+: 可用 `ACTION_WIFI_ADD_NETWORKS` 弹出系统界面让用户确认保存该 Wi-Fi;这是最接近旧版“添加并连接设备 Wi-Fi”的官方替代方案。
|
||||
- iOS 11+: 可用 `NEHotspotConfigurationManager` 让用户授权加入指定 Wi-Fi。
|
||||
- iOS 11+: 支持持久化保存网络,也支持 `joinOnce` 的临时连接模式。
|
||||
|
||||
## OutSystems 11 接入方式
|
||||
|
||||
在移动应用主模块的 `Extensibility Configurations` 中加入插件地址。常见写法如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": {
|
||||
"url": "https://<your-git-host>/cordova-plugin-bluememe-wifi-connector.git#v1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果你直接使用当前仓库作为插件仓库,把 `<your-git-host>` 换成你的 Git 地址即可。
|
||||
|
||||
## JavaScript 调用
|
||||
|
||||
插件加载后可通过 `cordova.plugins.BluememeWifiConnector` 调用。
|
||||
|
||||
```javascript
|
||||
document.addEventListener("deviceready", function () {
|
||||
cordova.plugins.BluememeWifiConnector.connect({
|
||||
ssid: "Factory_AP_01",
|
||||
passphrase: "12345678",
|
||||
security: "WPA2",
|
||||
mode: "auto"
|
||||
}).then(function (result) {
|
||||
console.log("Wi-Fi success", result);
|
||||
}).catch(function (error) {
|
||||
console.error("Wi-Fi error", error);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 推荐模式
|
||||
|
||||
### 1. 设备级保存网络
|
||||
|
||||
Android 11+ 推荐:
|
||||
|
||||
```javascript
|
||||
cordova.plugins.BluememeWifiConnector.saveNetwork({
|
||||
ssid: "OfficeWifi",
|
||||
passphrase: "OfficePass123",
|
||||
security: "WPA2"
|
||||
});
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 会弹出 Android 系统界面。
|
||||
- 用户确认后,系统保存网络。
|
||||
- 这是 Android 官方给第三方应用的替代方案,最接近旧版 `addNetwork()` 行为。
|
||||
|
||||
### 2. 应用级连接指定 Wi-Fi
|
||||
|
||||
适合连接设备热点、IoT 配网、局域网设备:
|
||||
|
||||
```javascript
|
||||
cordova.plugins.BluememeWifiConnector.connectLocalOnly({
|
||||
ssid: "Device_AP",
|
||||
passphrase: "87654321",
|
||||
security: "WPA2",
|
||||
bindToProcess: true,
|
||||
timeoutMs: 30000
|
||||
});
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 连接范围主要是当前应用。
|
||||
- 如果 `bindToProcess` 为 `true`,当前应用进程网络请求会绑定到该 Wi-Fi。
|
||||
- 不适合作为“静默切换手机整机网络”的通用方案。
|
||||
|
||||
### 3. 提交候选 Wi-Fi
|
||||
|
||||
```javascript
|
||||
cordova.plugins.BluememeWifiConnector.addNetworkSuggestion({
|
||||
ssid: "BranchWifi",
|
||||
passphrase: "BranchPass123",
|
||||
security: "WPA2"
|
||||
});
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 系统会决定是否采用这个候选网络。
|
||||
- 不保证立即切换。
|
||||
|
||||
### 4. iOS 持久化加入指定 Wi-Fi
|
||||
|
||||
```javascript
|
||||
cordova.plugins.BluememeWifiConnector.saveNetwork({
|
||||
ssid: "OfficeWifi",
|
||||
passphrase: "OfficePass123",
|
||||
security: "WPA2"
|
||||
});
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- iOS 会弹出系统授权。
|
||||
- 成功后等价于用户在系统里加入该 Wi-Fi。
|
||||
- 依赖 `Hotspot Configuration` entitlement,插件安装时会向 Cordova iOS 工程注入。
|
||||
|
||||
### 5. iOS 临时加入指定 Wi-Fi
|
||||
|
||||
```javascript
|
||||
cordova.plugins.BluememeWifiConnector.connectLocalOnly({
|
||||
ssid: "Device_AP",
|
||||
passphrase: "87654321",
|
||||
security: "WPA2"
|
||||
});
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 在 iOS 上会映射为 `joinOnce` 临时连接。
|
||||
- 更适合设备热点、IoT 配网、局域网直连。
|
||||
- iOS 不支持像 Android 那样把网络请求绑定到该 Wi-Fi 进程。
|
||||
|
||||
## `connect(options)` 的自动策略
|
||||
|
||||
`connect()` 会按平台自动选模式:
|
||||
|
||||
- Android 9 及以下: `legacy`
|
||||
- Android 10 且 `localOnly = true`: `localOnly`
|
||||
- Android 11 及以上: `save`
|
||||
- Android 10 其他情况: `suggestion`
|
||||
- iOS 且 `localOnly = true`: `localOnly`
|
||||
- iOS 其他情况: `save`
|
||||
|
||||
`options` 字段:
|
||||
|
||||
```javascript
|
||||
{
|
||||
ssid: "Required",
|
||||
passphrase: "Optional for OPEN, required for WPA2/WPA3",
|
||||
security: "OPEN | WPA2 | WPA3",
|
||||
hidden: false,
|
||||
mode: "auto | legacy | localOnly | suggestion | save",
|
||||
localOnly: false,
|
||||
bindToProcess: true,
|
||||
timeoutMs: 30000,
|
||||
appInteractionRequired: false
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- iOS 当前实现支持 `OPEN` 和 `WPA2`。
|
||||
- `WPA3`、`suggestion`、`legacy` 仅在 Android 上支持。
|
||||
|
||||
## OutSystems 页面里的简单调用示例
|
||||
|
||||
如果你在 OutSystems 页面或 JavaScript 节点中直接写 JS,可以用:
|
||||
|
||||
```javascript
|
||||
if (!window.cordova || !cordova.plugins || !cordova.plugins.BluememeWifiConnector) {
|
||||
console.error("Cordova plugin not loaded yet.");
|
||||
} else {
|
||||
cordova.plugins.BluememeWifiConnector.saveNetwork({
|
||||
ssid: "Factory_AP_01",
|
||||
passphrase: "12345678",
|
||||
security: "WPA2"
|
||||
}).then(function (result) {
|
||||
console.log(JSON.stringify(result));
|
||||
}).catch(function (error) {
|
||||
console.error(JSON.stringify(error));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
完整示例见 [examples/outsystems-connect-specific-wifi.js](examples/outsystems-connect-specific-wifi.js)。
|
||||
|
||||
## `disconnect()` 行为
|
||||
|
||||
```javascript
|
||||
cordova.plugins.BluememeWifiConnector.disconnect();
|
||||
|
||||
cordova.plugins.BluememeWifiConnector.disconnect({
|
||||
ssid: "Device_AP"
|
||||
});
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- Android: 释放 `localOnly` 网络;Android 9 及以下还会调用旧版 `WifiManager.disconnect()`。
|
||||
- iOS: 移除当前应用通过 `NEHotspotConfigurationManager` 配置过的 Wi-Fi;可选传入 `ssid` 只移除单个网络配置。
|
||||
|
||||
## 主要文件
|
||||
|
||||
- `plugin.xml`: Cordova 插件声明与 Android 权限。
|
||||
- `src/android/BluememeWifiConnector.java`: Android 原生实现。
|
||||
- `src/ios/BluememeWifiConnector.m`: iOS 原生实现。
|
||||
- `www/BluememeWifiConnector.js`: Cordova JS bridge。
|
||||
|
||||
## 限制
|
||||
|
||||
- Android 10+ 不支持第三方应用静默强制切换整机到任意 Wi-Fi。
|
||||
- `saveNetwork()` 需要用户确认。
|
||||
- `connectLocalOnly()` 更适合配网和设备热点通信,不是通用整机联网替代方案。
|
||||
- WPA Enterprise / EAP 没有在这个版本中实现。
|
||||
- iOS 不支持 Android 的 `WifiNetworkSuggestion` / `legacy` 路径。
|
||||
- iOS `disconnect()` 的本质是移除由当前应用添加的 Wi-Fi 配置,不等价于强制断开任意当前网络。
|
||||
- iOS 需要真机和正确签名;模拟器不能验证这类 Wi-Fi 能力。
|
||||
@@ -0,0 +1,47 @@
|
||||
document.addEventListener("deviceready", function () {
|
||||
var wifi = window.cordova &&
|
||||
window.cordova.plugins &&
|
||||
window.cordova.plugins.BluememeWifiConnector;
|
||||
|
||||
if (!wifi) {
|
||||
console.error("BluememeWifiConnector plugin is not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
wifi.connect({
|
||||
ssid: "YourWifiSSID",
|
||||
passphrase: "YourWifiPassword",
|
||||
security: "WPA2",
|
||||
mode: "auto"
|
||||
}).then(function (result) {
|
||||
console.log("Wi-Fi request succeeded:", result);
|
||||
}).catch(function (error) {
|
||||
console.error("Wi-Fi request failed:", error);
|
||||
});
|
||||
}, false);
|
||||
|
||||
function connectDeviceWideWifi(ssid, passphrase) {
|
||||
return cordova.plugins.BluememeWifiConnector.saveNetwork({
|
||||
ssid: ssid,
|
||||
passphrase: passphrase,
|
||||
security: "WPA2"
|
||||
});
|
||||
}
|
||||
|
||||
function connectAppOnlyWifi(ssid, passphrase) {
|
||||
return cordova.plugins.BluememeWifiConnector.connectLocalOnly({
|
||||
ssid: ssid,
|
||||
passphrase: passphrase,
|
||||
security: "WPA2",
|
||||
bindToProcess: true,
|
||||
timeoutMs: 30000
|
||||
});
|
||||
}
|
||||
|
||||
function suggestWifi(ssid, passphrase) {
|
||||
return cordova.plugins.BluememeWifiConnector.addNetworkSuggestion({
|
||||
ssid: ssid,
|
||||
passphrase: passphrase,
|
||||
security: "WPA2"
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "cordova-plugin-bluememe-wifi-connector",
|
||||
"version": "1.0.0",
|
||||
"description": "Cordova plugin for OutSystems 11 Android and iOS apps to join or manage specific Wi-Fi networks.",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"cordova",
|
||||
"outsystems",
|
||||
"android",
|
||||
"ios",
|
||||
"wifi"
|
||||
],
|
||||
"cordova": {
|
||||
"id": "cordova-plugin-bluememe-wifi-connector",
|
||||
"platforms": [
|
||||
"android",
|
||||
"ios"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"cordovaDependencies": {
|
||||
"1.0.0": {
|
||||
"cordova-android": ">=10.0.0",
|
||||
"cordova-ios": ">=7.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plugin
|
||||
id="cordova-plugin-bluememe-wifi-connector"
|
||||
version="1.0.0"
|
||||
xmlns="http://apache.org/cordova/ns/plugins/1.0"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<name>BluememeWifiConnector</name>
|
||||
<description>Cordova plugin for connecting an OutSystems Android or iOS app to a specific Wi-Fi network.</description>
|
||||
<license>MIT</license>
|
||||
|
||||
<js-module src="www/BluememeWifiConnector.js" name="BluememeWifiConnector">
|
||||
<clobbers target="cordova.plugins.BluememeWifiConnector" />
|
||||
</js-module>
|
||||
|
||||
<platform name="android">
|
||||
<config-file target="res/xml/config.xml" parent="/*">
|
||||
<feature name="BluememeWifiConnector">
|
||||
<param name="android-package" value="com.bluememe.cordova.wifi.BluememeWifiConnector" />
|
||||
</feature>
|
||||
</config-file>
|
||||
|
||||
<config-file target="AndroidManifest.xml" parent="/manifest">
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation" />
|
||||
</config-file>
|
||||
|
||||
<source-file src="src/android/BluememeWifiConnector.java" target-dir="src/com/bluememe/cordova/wifi" />
|
||||
</platform>
|
||||
|
||||
<platform name="ios">
|
||||
<config-file target="config.xml" parent="/*">
|
||||
<feature name="BluememeWifiConnector">
|
||||
<param name="ios-package" value="BluememeWifiConnector" />
|
||||
</feature>
|
||||
</config-file>
|
||||
|
||||
<framework src="NetworkExtension.framework" />
|
||||
|
||||
<config-file target="*/Entitlements-Debug.plist" parent="com.apple.developer.networking.HotspotConfiguration">
|
||||
<true />
|
||||
</config-file>
|
||||
|
||||
<config-file target="*/Entitlements-Release.plist" parent="com.apple.developer.networking.HotspotConfiguration">
|
||||
<true />
|
||||
</config-file>
|
||||
|
||||
<source-file src="src/ios/BluememeWifiConnector.m" />
|
||||
</platform>
|
||||
</plugin>
|
||||
@@ -0,0 +1,696 @@
|
||||
package com.bluememe.cordova.wifi;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.NetworkRequest;
|
||||
import android.net.wifi.WifiConfiguration;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.net.wifi.WifiNetworkSpecifier;
|
||||
import android.net.wifi.WifiNetworkSuggestion;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.apache.cordova.CallbackContext;
|
||||
import org.apache.cordova.CordovaPlugin;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class BluememeWifiConnector extends CordovaPlugin {
|
||||
private static final String ACTION_CONNECT = "connect";
|
||||
private static final String ACTION_CONNECT_LOCAL_ONLY = "connectLocalOnly";
|
||||
private static final String ACTION_ADD_NETWORK_SUGGESTION = "addNetworkSuggestion";
|
||||
private static final String ACTION_REMOVE_NETWORK_SUGGESTION = "removeNetworkSuggestion";
|
||||
private static final String ACTION_SAVE_NETWORK = "saveNetwork";
|
||||
private static final String ACTION_DISCONNECT = "disconnect";
|
||||
|
||||
private static final int REQUEST_CODE_WIFI_PERMISSIONS = 7310;
|
||||
private static final int REQUEST_CODE_SAVE_NETWORK = 7311;
|
||||
private static final int API_LEVEL_33 = 33;
|
||||
|
||||
private static final String PERMISSION_NEARBY_WIFI_DEVICES = "android.permission.NEARBY_WIFI_DEVICES";
|
||||
private static final String INTENT_ACTION_WIFI_ADD_NETWORKS = "android.settings.WIFI_ADD_NETWORKS";
|
||||
private static final String EXTRA_WIFI_NETWORK_LIST = "android.provider.extra.WIFI_NETWORK_LIST";
|
||||
private static final String EXTRA_WIFI_NETWORK_RESULT_LIST = "android.provider.extra.WIFI_NETWORK_RESULT_LIST";
|
||||
|
||||
private CallbackContext permissionCallbackContext;
|
||||
private String pendingPermissionAction;
|
||||
private JSONArray pendingPermissionArgs;
|
||||
|
||||
private CallbackContext saveNetworkCallbackContext;
|
||||
private String pendingSaveSsid;
|
||||
|
||||
private ConnectivityManager.NetworkCallback localOnlyNetworkCallback;
|
||||
private Network currentLocalOnlyNetwork;
|
||||
private boolean currentLocalOnlyBound;
|
||||
|
||||
@Override
|
||||
public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException {
|
||||
if (requiresRuntimePermission(action, args) && !ensurePermissions(action, args, callbackContext)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ACTION_SAVE_NETWORK.equals(action)) {
|
||||
saveNetwork(getOptions(args), callbackContext);
|
||||
return true;
|
||||
}
|
||||
|
||||
cordova.getThreadPool().execute(() -> {
|
||||
try {
|
||||
switch (action) {
|
||||
case ACTION_CONNECT:
|
||||
connect(getOptions(args), callbackContext);
|
||||
break;
|
||||
case ACTION_CONNECT_LOCAL_ONLY:
|
||||
connectLocalOnly(getOptions(args), callbackContext);
|
||||
break;
|
||||
case ACTION_ADD_NETWORK_SUGGESTION:
|
||||
addNetworkSuggestion(getOptions(args), callbackContext);
|
||||
break;
|
||||
case ACTION_REMOVE_NETWORK_SUGGESTION:
|
||||
removeNetworkSuggestion(getOptions(args), callbackContext);
|
||||
break;
|
||||
case ACTION_DISCONNECT:
|
||||
disconnect(callbackContext);
|
||||
break;
|
||||
default:
|
||||
sendError(callbackContext, "UNSUPPORTED_ACTION", "Unsupported action: " + action, null);
|
||||
break;
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
sendError(callbackContext, "UNEXPECTED_ERROR", exception.getMessage(), exception);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionResult(int requestCode, String[] permissions, int[] grantResults) throws JSONException {
|
||||
if (requestCode != REQUEST_CODE_WIFI_PERMISSIONS || permissionCallbackContext == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean allGranted = true;
|
||||
for (int result : grantResults) {
|
||||
if (result != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
allGranted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allGranted) {
|
||||
sendError(permissionCallbackContext, "PERMISSION_DENIED", "Required Wi-Fi permission was denied.", null);
|
||||
clearPendingPermissionRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
String action = pendingPermissionAction;
|
||||
JSONArray args = pendingPermissionArgs;
|
||||
CallbackContext callbackContext = permissionCallbackContext;
|
||||
|
||||
clearPendingPermissionRequest();
|
||||
execute(action, args, callbackContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent intent) {
|
||||
if (requestCode != REQUEST_CODE_SAVE_NETWORK || saveNetworkCallbackContext == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
try {
|
||||
result.put("mode", "save");
|
||||
result.put("ssid", pendingSaveSsid);
|
||||
result.put("resultCode", resultCode);
|
||||
result.put("saved", resultCode == Activity.RESULT_OK);
|
||||
result.put("resultCodes", extractSaveResultCodes(intent));
|
||||
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
saveNetworkCallbackContext.success(result);
|
||||
} else {
|
||||
sendError(saveNetworkCallbackContext, "USER_CANCELLED", "The user cancelled the saved-network flow.", null, result);
|
||||
}
|
||||
} catch (JSONException exception) {
|
||||
sendError(saveNetworkCallbackContext, "SAVE_RESULT_PARSE_FAILED", exception.getMessage(), exception);
|
||||
} finally {
|
||||
saveNetworkCallbackContext = null;
|
||||
pendingSaveSsid = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void connect(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
String requestedMode = normalizeMode(options.optString("mode", "auto"));
|
||||
String resolvedMode = resolveMode(requestedMode, options);
|
||||
|
||||
switch (resolvedMode) {
|
||||
case "legacy":
|
||||
connectLegacy(options, callbackContext);
|
||||
break;
|
||||
case "localOnly":
|
||||
connectLocalOnly(options, callbackContext);
|
||||
break;
|
||||
case "suggestion":
|
||||
addNetworkSuggestion(options, callbackContext);
|
||||
break;
|
||||
case "save":
|
||||
saveNetwork(options, callbackContext);
|
||||
break;
|
||||
default:
|
||||
sendError(callbackContext, "INVALID_MODE", "Unsupported mode: " + resolvedMode, null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void connectLegacy(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "Legacy direct Wi-Fi connection is only supported on Android 9 (API 28) and below.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
String security = normalizeSecurity(options.optString("security", "WPA2"));
|
||||
if ("WPA3".equals(security)) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "WPA3 is not supported by the legacy Wi-Fi API path.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
WifiManager wifiManager = getWifiManager();
|
||||
if (!wifiManager.isWifiEnabled() && !wifiManager.setWifiEnabled(true)) {
|
||||
sendError(callbackContext, "WIFI_ENABLE_FAILED", "Unable to enable Wi-Fi before connecting.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
String ssid = getRequiredString(options, "ssid");
|
||||
WifiConfiguration configuration = new WifiConfiguration();
|
||||
configuration.SSID = quoteValue(ssid);
|
||||
|
||||
if ("OPEN".equals(security)) {
|
||||
configuration.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE);
|
||||
} else {
|
||||
configuration.preSharedKey = quoteValue(getRequiredString(options, "passphrase"));
|
||||
}
|
||||
|
||||
int networkId = findConfiguredNetworkId(wifiManager, configuration.SSID);
|
||||
if (networkId >= 0) {
|
||||
configuration.networkId = networkId;
|
||||
int updatedNetworkId = wifiManager.updateNetwork(configuration);
|
||||
if (updatedNetworkId >= 0) {
|
||||
networkId = updatedNetworkId;
|
||||
}
|
||||
} else {
|
||||
networkId = wifiManager.addNetwork(configuration);
|
||||
}
|
||||
|
||||
if (networkId < 0) {
|
||||
sendError(callbackContext, "NETWORK_ADD_FAILED", "Android failed to add the Wi-Fi configuration.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean disconnected = wifiManager.disconnect();
|
||||
boolean enabled = wifiManager.enableNetwork(networkId, true);
|
||||
boolean reconnected = wifiManager.reconnect();
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("mode", "legacy");
|
||||
result.put("ssid", ssid);
|
||||
result.put("networkId", networkId);
|
||||
result.put("disconnected", disconnected);
|
||||
result.put("enabled", enabled);
|
||||
result.put("reconnected", reconnected);
|
||||
result.put("apiLevel", Build.VERSION.SDK_INT);
|
||||
|
||||
if (enabled && reconnected) {
|
||||
callbackContext.success(result);
|
||||
} else {
|
||||
sendError(callbackContext, "NETWORK_ENABLE_FAILED", "Android failed to switch to the requested Wi-Fi network.", null, result);
|
||||
}
|
||||
}
|
||||
|
||||
private void connectLocalOnly(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "Local-only Wi-Fi requests require Android 10 (API 29) or newer.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
releaseLocalOnlyNetwork();
|
||||
|
||||
if (!getWifiManager().isWifiEnabled()) {
|
||||
sendError(callbackContext, "WIFI_DISABLED", "Wi-Fi is off. Enable Wi-Fi first, then retry the local-only connection.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
String ssid = getRequiredString(options, "ssid");
|
||||
String security = normalizeSecurity(options.optString("security", "WPA2"));
|
||||
boolean bindToProcess = options.optBoolean("bindToProcess", true);
|
||||
int timeoutMs = options.optInt("timeoutMs", 30000);
|
||||
|
||||
WifiNetworkSpecifier.Builder specifierBuilder = new WifiNetworkSpecifier.Builder().setSsid(ssid);
|
||||
if (options.optBoolean("hidden", false)) {
|
||||
specifierBuilder.setIsHiddenSsid(true);
|
||||
}
|
||||
|
||||
if ("WPA2".equals(security)) {
|
||||
specifierBuilder.setWpa2Passphrase(getRequiredString(options, "passphrase"));
|
||||
} else if ("WPA3".equals(security)) {
|
||||
specifierBuilder.setWpa3Passphrase(getRequiredString(options, "passphrase"));
|
||||
} else if (!"OPEN".equals(security)) {
|
||||
sendError(callbackContext, "INVALID_SECURITY", "Unsupported security type: " + security, null);
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectivityManager connectivityManager = getConnectivityManager();
|
||||
NetworkRequest networkRequest = new NetworkRequest.Builder()
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.setNetworkSpecifier(specifierBuilder.build())
|
||||
.build();
|
||||
|
||||
localOnlyNetworkCallback = new ConnectivityManager.NetworkCallback() {
|
||||
@Override
|
||||
public void onAvailable(Network network) {
|
||||
currentLocalOnlyNetwork = network;
|
||||
currentLocalOnlyBound = bindToProcess;
|
||||
|
||||
if (bindToProcess) {
|
||||
connectivityManager.bindProcessToNetwork(network);
|
||||
}
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
try {
|
||||
result.put("mode", "localOnly");
|
||||
result.put("ssid", ssid);
|
||||
result.put("boundToProcess", bindToProcess);
|
||||
result.put("apiLevel", Build.VERSION.SDK_INT);
|
||||
callbackContext.success(result);
|
||||
} catch (JSONException exception) {
|
||||
sendError(callbackContext, "RESULT_BUILD_FAILED", exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnavailable() {
|
||||
sendError(callbackContext, "NETWORK_UNAVAILABLE", "Android could not establish the requested local-only Wi-Fi connection.", null);
|
||||
releaseLocalOnlyNetwork();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLost(Network network) {
|
||||
releaseLocalOnlyNetwork();
|
||||
}
|
||||
};
|
||||
|
||||
connectivityManager.requestNetwork(networkRequest, localOnlyNetworkCallback, timeoutMs);
|
||||
}
|
||||
|
||||
private void addNetworkSuggestion(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "Wi-Fi suggestions require Android 10 (API 29) or newer.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
WifiNetworkSuggestion suggestion = buildSuggestion(options);
|
||||
WifiManager wifiManager = getWifiManager();
|
||||
int status = wifiManager.addNetworkSuggestions(Collections.singletonList(suggestion));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("mode", "suggestion");
|
||||
result.put("ssid", getRequiredString(options, "ssid"));
|
||||
result.put("status", status);
|
||||
result.put("statusText", mapSuggestionStatus(status));
|
||||
result.put("apiLevel", Build.VERSION.SDK_INT);
|
||||
|
||||
if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS) {
|
||||
callbackContext.success(result);
|
||||
} else {
|
||||
sendError(callbackContext, "SUGGESTION_ADD_FAILED", "Android rejected the Wi-Fi suggestion: " + mapSuggestionStatus(status), null, result);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeNetworkSuggestion(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "Wi-Fi suggestions require Android 10 (API 29) or newer.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
WifiNetworkSuggestion suggestion = buildSuggestion(options);
|
||||
WifiManager wifiManager = getWifiManager();
|
||||
int status = wifiManager.removeNetworkSuggestions(Collections.singletonList(suggestion));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("mode", "suggestion");
|
||||
result.put("ssid", getRequiredString(options, "ssid"));
|
||||
result.put("status", status);
|
||||
result.put("statusText", mapSuggestionStatus(status));
|
||||
result.put("apiLevel", Build.VERSION.SDK_INT);
|
||||
|
||||
if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS) {
|
||||
callbackContext.success(result);
|
||||
} else {
|
||||
sendError(callbackContext, "SUGGESTION_REMOVE_FAILED", "Android could not remove the Wi-Fi suggestion: " + mapSuggestionStatus(status), null, result);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveNetwork(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "Saving a Wi-Fi network with user approval requires Android 11 (API 30) or newer.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveNetworkCallbackContext != null) {
|
||||
sendError(callbackContext, "FLOW_IN_PROGRESS", "Another saveNetwork request is already in progress.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
WifiNetworkSuggestion suggestion = buildSuggestion(options);
|
||||
String ssid = getRequiredString(options, "ssid");
|
||||
|
||||
cordova.getActivity().runOnUiThread(() -> {
|
||||
try {
|
||||
Intent intent = new Intent(INTENT_ACTION_WIFI_ADD_NETWORKS);
|
||||
ArrayList<WifiNetworkSuggestion> suggestions = new ArrayList<>();
|
||||
suggestions.add(suggestion);
|
||||
intent.putParcelableArrayListExtra(EXTRA_WIFI_NETWORK_LIST, suggestions);
|
||||
|
||||
saveNetworkCallbackContext = callbackContext;
|
||||
pendingSaveSsid = ssid;
|
||||
|
||||
cordova.setActivityResultCallback(BluememeWifiConnector.this);
|
||||
cordova.startActivityForResult(BluememeWifiConnector.this, intent, REQUEST_CODE_SAVE_NETWORK);
|
||||
} catch (Exception exception) {
|
||||
saveNetworkCallbackContext = null;
|
||||
pendingSaveSsid = null;
|
||||
sendError(callbackContext, "SAVE_NETWORK_START_FAILED", exception.getMessage(), exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void disconnect(CallbackContext callbackContext) throws JSONException {
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("releasedLocalOnly", currentLocalOnlyNetwork != null || localOnlyNetworkCallback != null);
|
||||
|
||||
releaseLocalOnlyNetwork();
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
WifiManager wifiManager = getWifiManager();
|
||||
result.put("legacyDisconnectCalled", wifiManager.disconnect());
|
||||
} else {
|
||||
result.put("legacyDisconnectCalled", false);
|
||||
}
|
||||
|
||||
callbackContext.success(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReset() {
|
||||
releaseLocalOnlyNetwork();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
releaseLocalOnlyNetwork();
|
||||
}
|
||||
|
||||
private boolean ensurePermissions(String action, JSONArray args, CallbackContext callbackContext) {
|
||||
String[] permissions = getRuntimePermissions(action);
|
||||
if (permissions.length == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
List<String> missingPermissions = new ArrayList<>();
|
||||
for (String permission : permissions) {
|
||||
if (!cordova.hasPermission(permission)) {
|
||||
missingPermissions.add(permission);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingPermissions.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
permissionCallbackContext = callbackContext;
|
||||
pendingPermissionAction = action;
|
||||
pendingPermissionArgs = args;
|
||||
cordova.requestPermissions(this, REQUEST_CODE_WIFI_PERMISSIONS, missingPermissions.toArray(new String[0]));
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean requiresRuntimePermission(String action, JSONArray args) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ACTION_CONNECT_LOCAL_ONLY.equals(action)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ACTION_CONNECT.equals(action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
JSONObject options = getOptions(args);
|
||||
String requestedMode = normalizeMode(options.optString("mode", "auto"));
|
||||
String resolvedMode = resolveMode(requestedMode, options);
|
||||
return "legacy".equals(resolvedMode) || "localOnly".equals(resolvedMode);
|
||||
}
|
||||
|
||||
private String[] getRuntimePermissions(String action) {
|
||||
if (ACTION_CONNECT_LOCAL_ONLY.equals(action)) {
|
||||
return getConnectionPermissions();
|
||||
}
|
||||
|
||||
if (!ACTION_CONNECT.equals(action)) {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
return getConnectionPermissions();
|
||||
}
|
||||
|
||||
private String[] getConnectionPermissions() {
|
||||
if (Build.VERSION.SDK_INT >= API_LEVEL_33) {
|
||||
return new String[]{PERMISSION_NEARBY_WIFI_DEVICES};
|
||||
}
|
||||
|
||||
return new String[]{Manifest.permission.ACCESS_FINE_LOCATION};
|
||||
}
|
||||
|
||||
private void clearPendingPermissionRequest() {
|
||||
permissionCallbackContext = null;
|
||||
pendingPermissionAction = null;
|
||||
pendingPermissionArgs = null;
|
||||
}
|
||||
|
||||
private WifiNetworkSuggestion buildSuggestion(JSONObject options) throws JSONException {
|
||||
String ssid = getRequiredString(options, "ssid");
|
||||
String security = normalizeSecurity(options.optString("security", "WPA2"));
|
||||
|
||||
WifiNetworkSuggestion.Builder builder = new WifiNetworkSuggestion.Builder().setSsid(ssid);
|
||||
if (options.optBoolean("hidden", false)) {
|
||||
builder.setIsHiddenSsid(true);
|
||||
}
|
||||
|
||||
if ("WPA2".equals(security)) {
|
||||
builder.setWpa2Passphrase(getRequiredString(options, "passphrase"));
|
||||
} else if ("WPA3".equals(security)) {
|
||||
builder.setWpa3Passphrase(getRequiredString(options, "passphrase"));
|
||||
} else if (!"OPEN".equals(security)) {
|
||||
throw new IllegalArgumentException("Unsupported security type: " + security);
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
builder.setIsAppInteractionRequired(options.optBoolean("appInteractionRequired", false));
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private ConnectivityManager getConnectivityManager() {
|
||||
return (ConnectivityManager) cordova.getActivity().getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
}
|
||||
|
||||
private WifiManager getWifiManager() {
|
||||
Context context = cordova.getActivity().getApplicationContext();
|
||||
return (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
|
||||
}
|
||||
|
||||
private JSONObject getOptions(JSONArray args) {
|
||||
if (args == null || args.length() == 0 || args.isNull(0)) {
|
||||
return new JSONObject();
|
||||
}
|
||||
|
||||
JSONObject options = args.optJSONObject(0);
|
||||
return options != null ? options : new JSONObject();
|
||||
}
|
||||
|
||||
private int findConfiguredNetworkId(WifiManager wifiManager, String quotedSsid) {
|
||||
List<WifiConfiguration> configuredNetworks = wifiManager.getConfiguredNetworks();
|
||||
if (configuredNetworks == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (WifiConfiguration configuredNetwork : configuredNetworks) {
|
||||
if (quotedSsid.equals(configuredNetwork.SSID)) {
|
||||
return configuredNetwork.networkId;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private JSONArray extractSaveResultCodes(Intent intent) throws JSONException {
|
||||
JSONArray resultCodes = new JSONArray();
|
||||
if (intent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
return resultCodes;
|
||||
}
|
||||
|
||||
ArrayList<Integer> arrayList = intent.getIntegerArrayListExtra(EXTRA_WIFI_NETWORK_RESULT_LIST);
|
||||
if (arrayList != null) {
|
||||
for (Integer code : arrayList) {
|
||||
resultCodes.put(code);
|
||||
}
|
||||
return resultCodes;
|
||||
}
|
||||
|
||||
int[] array = intent.getIntArrayExtra(EXTRA_WIFI_NETWORK_RESULT_LIST);
|
||||
if (array != null) {
|
||||
for (int code : array) {
|
||||
resultCodes.put(code);
|
||||
}
|
||||
}
|
||||
|
||||
return resultCodes;
|
||||
}
|
||||
|
||||
private void releaseLocalOnlyNetwork() {
|
||||
ConnectivityManager connectivityManager = getConnectivityManager();
|
||||
|
||||
if (currentLocalOnlyBound) {
|
||||
connectivityManager.bindProcessToNetwork(null);
|
||||
currentLocalOnlyBound = false;
|
||||
}
|
||||
|
||||
if (localOnlyNetworkCallback != null) {
|
||||
try {
|
||||
connectivityManager.unregisterNetworkCallback(localOnlyNetworkCallback);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Android may already have disposed this callback.
|
||||
}
|
||||
localOnlyNetworkCallback = null;
|
||||
}
|
||||
|
||||
currentLocalOnlyNetwork = null;
|
||||
}
|
||||
|
||||
private String resolveMode(String requestedMode, JSONObject options) {
|
||||
if (!"auto".equals(requestedMode)) {
|
||||
return requestedMode;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
if (options.optBoolean("localOnly", false)) {
|
||||
return "localOnly";
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return "save";
|
||||
}
|
||||
|
||||
return "suggestion";
|
||||
}
|
||||
|
||||
private String normalizeMode(String mode) {
|
||||
if (TextUtils.isEmpty(mode)) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
String normalized = mode.trim();
|
||||
if ("saveNetwork".equalsIgnoreCase(normalized) || "saved".equalsIgnoreCase(normalized)) {
|
||||
return "save";
|
||||
}
|
||||
if ("connectLocalOnly".equalsIgnoreCase(normalized) || "local".equalsIgnoreCase(normalized)) {
|
||||
return "localOnly";
|
||||
}
|
||||
if ("addNetworkSuggestion".equalsIgnoreCase(normalized) || "suggest".equalsIgnoreCase(normalized)) {
|
||||
return "suggestion";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeSecurity(String security) {
|
||||
if (TextUtils.isEmpty(security)) {
|
||||
return "WPA2";
|
||||
}
|
||||
|
||||
String normalized = security.trim().toUpperCase();
|
||||
if ("WPA".equals(normalized) || "WPA2_PSK".equals(normalized) || "WPA_PSK".equals(normalized)) {
|
||||
return "WPA2";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String getRequiredString(JSONObject options, String key) {
|
||||
String value = options.optString(key, null);
|
||||
if (TextUtils.isEmpty(value)) {
|
||||
throw new IllegalArgumentException("Missing required option: " + key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String quoteValue(String value) {
|
||||
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
|
||||
private String mapSuggestionStatus(int status) {
|
||||
switch (status) {
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS:
|
||||
return "SUCCESS";
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_INTERNAL:
|
||||
return "ERROR_INTERNAL";
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_APP_DISALLOWED:
|
||||
return "ERROR_APP_DISALLOWED";
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_ADD_DUPLICATE:
|
||||
return "ERROR_ADD_DUPLICATE";
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_ADD_EXCEEDS_MAX_PER_APP:
|
||||
return "ERROR_ADD_EXCEEDS_MAX_PER_APP";
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_REMOVE_INVALID:
|
||||
return "ERROR_REMOVE_INVALID";
|
||||
default:
|
||||
return "UNKNOWN_STATUS_" + status;
|
||||
}
|
||||
}
|
||||
|
||||
private void sendError(CallbackContext callbackContext, String code, String message, Throwable throwable) {
|
||||
sendError(callbackContext, code, message, throwable, null);
|
||||
}
|
||||
|
||||
private void sendError(CallbackContext callbackContext, String code, String message, Throwable throwable, JSONObject extra) {
|
||||
try {
|
||||
JSONObject error = new JSONObject();
|
||||
error.put("code", code);
|
||||
error.put("message", message == null ? "Unknown error" : message);
|
||||
|
||||
if (throwable != null) {
|
||||
error.put("exception", throwable.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
if (extra != null) {
|
||||
error.put("details", extra);
|
||||
}
|
||||
|
||||
callbackContext.error(error.toString());
|
||||
} catch (JSONException jsonException) {
|
||||
callbackContext.error(message == null ? "Unknown error" : message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
#import <Cordova/CDV.h>
|
||||
#import <NetworkExtension/NetworkExtension.h>
|
||||
|
||||
@interface BluememeWifiConnector : CDVPlugin
|
||||
@end
|
||||
|
||||
@implementation BluememeWifiConnector
|
||||
|
||||
- (void)connect:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
NSDictionary *options = [self optionsFromCommand:command];
|
||||
NSString *requestedMode = [self normalizeMode:[self stringForKey:@"mode" inOptions:options]];
|
||||
NSString *resolvedMode = [self resolveMode:requestedMode options:options];
|
||||
|
||||
if ([resolvedMode isEqualToString:@"localOnly"]) {
|
||||
[self applyConfigurationWithOptions:options joinOnce:YES mode:@"localOnly" command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
if ([resolvedMode isEqualToString:@"save"]) {
|
||||
[self applyConfigurationWithOptions:options joinOnce:NO mode:@"save" command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *message = [NSString stringWithFormat:@"Mode '%@' is not supported on iOS. Use 'save' or 'localOnly'.", resolvedMode];
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED" message:message details:nil command:command];
|
||||
}
|
||||
|
||||
- (void)connectLocalOnly:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
[self applyConfigurationWithOptions:[self optionsFromCommand:command] joinOnce:YES mode:@"localOnly" command:command];
|
||||
}
|
||||
|
||||
- (void)addNetworkSuggestion:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED"
|
||||
message:@"iOS does not expose suggestion-based Wi-Fi APIs. Use connect() or saveNetwork() instead."
|
||||
details:nil
|
||||
command:command];
|
||||
}
|
||||
|
||||
- (void)removeNetworkSuggestion:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED"
|
||||
message:@"iOS does not expose suggestion-based Wi-Fi APIs. Use disconnect() to remove configurations added by the app."
|
||||
details:nil
|
||||
command:command];
|
||||
}
|
||||
|
||||
- (void)saveNetwork:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
[self applyConfigurationWithOptions:[self optionsFromCommand:command] joinOnce:NO mode:@"save" command:command];
|
||||
}
|
||||
|
||||
- (void)disconnect:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
if (@available(iOS 11.0, *)) {
|
||||
NSDictionary *options = [self optionsFromCommand:command];
|
||||
NSString *requestedSsid = [self stringForKey:@"ssid" inOptions:options];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NEHotspotConfigurationManager *manager = [NEHotspotConfigurationManager sharedManager];
|
||||
if (requestedSsid.length > 0) {
|
||||
[manager removeConfigurationForSSID:requestedSsid];
|
||||
[self sendSuccess:@{
|
||||
@"platform": @"ios",
|
||||
@"removedSSIDs": @[requestedSsid],
|
||||
@"releasedLocalOnly": @YES,
|
||||
@"legacyDisconnectCalled": @NO
|
||||
} command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
[manager getConfiguredSSIDsWithCompletionHandler:^(NSArray<NSString *> * _Nonnull ssids) {
|
||||
NSArray<NSString *> *configuredSsids = ssids ?: @[];
|
||||
for (NSString *ssid in configuredSsids) {
|
||||
[manager removeConfigurationForSSID:ssid];
|
||||
}
|
||||
|
||||
[self sendSuccess:@{
|
||||
@"platform": @"ios",
|
||||
@"removedSSIDs": configuredSsids,
|
||||
@"releasedLocalOnly": @([configuredSsids count] > 0),
|
||||
@"legacyDisconnectCalled": @NO
|
||||
} command:command];
|
||||
}];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED"
|
||||
message:@"NEHotspotConfigurationManager requires iOS 11 or newer."
|
||||
details:nil
|
||||
command:command];
|
||||
}
|
||||
|
||||
- (void)applyConfigurationWithOptions:(NSDictionary *)options
|
||||
joinOnce:(BOOL)joinOnce
|
||||
mode:(NSString *)mode
|
||||
command:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
if (@available(iOS 11.0, *)) {
|
||||
NSString *ssid = [self stringForKey:@"ssid" inOptions:options];
|
||||
if (ssid.length == 0) {
|
||||
[self sendErrorCode:@"INVALID_OPTIONS"
|
||||
message:@"Missing required option: ssid"
|
||||
details:nil
|
||||
command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *security = [self normalizeSecurity:[self stringForKey:@"security" inOptions:options]];
|
||||
NEHotspotConfiguration *configuration = nil;
|
||||
|
||||
if ([security isEqualToString:@"OPEN"]) {
|
||||
configuration = [[NEHotspotConfiguration alloc] initWithSSID:ssid];
|
||||
} else if ([security isEqualToString:@"WPA2"]) {
|
||||
NSString *passphrase = [self stringForKey:@"passphrase" inOptions:options];
|
||||
if (passphrase.length == 0) {
|
||||
[self sendErrorCode:@"INVALID_OPTIONS"
|
||||
message:@"Missing required option: passphrase"
|
||||
details:nil
|
||||
command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
configuration = [[NEHotspotConfiguration alloc] initWithSSID:ssid passphrase:passphrase isWEP:NO];
|
||||
} else if ([security isEqualToString:@"WPA3"]) {
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED"
|
||||
message:@"iOS hotspot configuration supports OPEN, WEP, WPA, and WPA2. WPA3 is not exposed by this plugin on iOS."
|
||||
details:nil
|
||||
command:command];
|
||||
return;
|
||||
} else {
|
||||
NSString *message = [NSString stringWithFormat:@"Unsupported security type on iOS: %@", security];
|
||||
[self sendErrorCode:@"INVALID_SECURITY" message:message details:nil command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
configuration.joinOnce = joinOnce;
|
||||
configuration.hidden = [self boolForKey:@"hidden" inOptions:options defaultValue:NO];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[NEHotspotConfigurationManager sharedManager] applyConfiguration:configuration completionHandler:^(NSError * _Nullable error) {
|
||||
if (error != nil) {
|
||||
[self sendNativeError:error code:@"IOS_WIFI_CONFIGURATION_FAILED" command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableDictionary *result = [@{
|
||||
@"mode": mode,
|
||||
@"ssid": ssid,
|
||||
@"apiName": @"NEHotspotConfigurationManager",
|
||||
@"platform": @"ios",
|
||||
@"joinOnce": @(joinOnce),
|
||||
@"hidden": @(configuration.hidden),
|
||||
@"saved": @(!joinOnce)
|
||||
} mutableCopy];
|
||||
|
||||
if ([mode isEqualToString:@"localOnly"]) {
|
||||
result[@"boundToProcess"] = @NO;
|
||||
}
|
||||
|
||||
[self sendSuccess:result command:command];
|
||||
}];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED"
|
||||
message:@"NEHotspotConfigurationManager requires iOS 11 or newer."
|
||||
details:nil
|
||||
command:command];
|
||||
}
|
||||
|
||||
- (NSDictionary *)optionsFromCommand:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
if ([command.arguments count] == 0) {
|
||||
return @{};
|
||||
}
|
||||
|
||||
id firstArgument = command.arguments[0];
|
||||
if ([firstArgument isKindOfClass:[NSDictionary class]]) {
|
||||
return firstArgument;
|
||||
}
|
||||
|
||||
return @{};
|
||||
}
|
||||
|
||||
- (NSString *)resolveMode:(NSString *)requestedMode options:(NSDictionary *)options
|
||||
{
|
||||
if (![requestedMode isEqualToString:@"auto"]) {
|
||||
return requestedMode;
|
||||
}
|
||||
|
||||
if ([self boolForKey:@"localOnly" inOptions:options defaultValue:NO]) {
|
||||
return @"localOnly";
|
||||
}
|
||||
|
||||
return @"save";
|
||||
}
|
||||
|
||||
- (NSString *)normalizeMode:(NSString *)mode
|
||||
{
|
||||
if (mode.length == 0) {
|
||||
return @"auto";
|
||||
}
|
||||
|
||||
NSString *normalized = [mode stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if ([normalized caseInsensitiveCompare:@"saveNetwork"] == NSOrderedSame ||
|
||||
[normalized caseInsensitiveCompare:@"saved"] == NSOrderedSame) {
|
||||
return @"save";
|
||||
}
|
||||
if ([normalized caseInsensitiveCompare:@"connectLocalOnly"] == NSOrderedSame ||
|
||||
[normalized caseInsensitiveCompare:@"local"] == NSOrderedSame) {
|
||||
return @"localOnly";
|
||||
}
|
||||
if ([normalized caseInsensitiveCompare:@"addNetworkSuggestion"] == NSOrderedSame ||
|
||||
[normalized caseInsensitiveCompare:@"suggest"] == NSOrderedSame) {
|
||||
return @"suggestion";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
- (NSString *)normalizeSecurity:(NSString *)security
|
||||
{
|
||||
if (security.length == 0) {
|
||||
return @"WPA2";
|
||||
}
|
||||
|
||||
NSString *normalized = [[security stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] uppercaseString];
|
||||
if ([normalized isEqualToString:@"WPA"] ||
|
||||
[normalized isEqualToString:@"WPA2_PSK"] ||
|
||||
[normalized isEqualToString:@"WPA_PSK"]) {
|
||||
return @"WPA2";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
- (NSString *)stringForKey:(NSString *)key inOptions:(NSDictionary *)options
|
||||
{
|
||||
id value = options[key];
|
||||
return [value isKindOfClass:[NSString class]] ? value : nil;
|
||||
}
|
||||
|
||||
- (BOOL)boolForKey:(NSString *)key inOptions:(NSDictionary *)options defaultValue:(BOOL)defaultValue
|
||||
{
|
||||
id value = options[key];
|
||||
if ([value respondsToSelector:@selector(boolValue)]) {
|
||||
return [value boolValue];
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
- (void)sendSuccess:(NSDictionary *)payload command:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:payload];
|
||||
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
|
||||
}
|
||||
|
||||
- (void)sendErrorCode:(NSString *)code
|
||||
message:(NSString *)message
|
||||
details:(NSDictionary *)details
|
||||
command:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
NSMutableDictionary *payload = [@{
|
||||
@"code": code ?: @"UNKNOWN",
|
||||
@"message": message ?: @"Unknown error"
|
||||
} mutableCopy];
|
||||
|
||||
if (details != nil) {
|
||||
payload[@"details"] = details;
|
||||
}
|
||||
|
||||
CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:payload];
|
||||
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
|
||||
}
|
||||
|
||||
- (void)sendNativeError:(NSError *)error code:(NSString *)code command:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
NSMutableDictionary *details = [NSMutableDictionary dictionary];
|
||||
if (error.domain.length > 0) {
|
||||
details[@"domain"] = error.domain;
|
||||
}
|
||||
details[@"nativeCode"] = @(error.code);
|
||||
|
||||
id failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey];
|
||||
if ([failureReason isKindOfClass:[NSString class]] && [failureReason length] > 0) {
|
||||
details[@"failureReason"] = failureReason;
|
||||
}
|
||||
|
||||
[self sendErrorCode:code
|
||||
message:error.localizedDescription
|
||||
details:details
|
||||
command:command];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,75 @@
|
||||
var exec = require("cordova/exec");
|
||||
|
||||
function normalizeError(error) {
|
||||
if (typeof error !== "string") {
|
||||
return error || { code: "UNKNOWN", message: "Unknown error" };
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(error);
|
||||
} catch (parseError) {
|
||||
return {
|
||||
code: "UNKNOWN",
|
||||
message: error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function execWithPromise(action, options, success, error) {
|
||||
if (typeof success === "function" || typeof error === "function") {
|
||||
exec(
|
||||
success,
|
||||
function (nativeError) {
|
||||
if (typeof error === "function") {
|
||||
error(normalizeError(nativeError));
|
||||
}
|
||||
},
|
||||
"BluememeWifiConnector",
|
||||
action,
|
||||
[options || {}]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
exec(
|
||||
resolve,
|
||||
function (nativeError) {
|
||||
reject(normalizeError(nativeError));
|
||||
},
|
||||
"BluememeWifiConnector",
|
||||
action,
|
||||
[options || {}]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
connect: function (options, success, error) {
|
||||
return execWithPromise("connect", options, success, error);
|
||||
},
|
||||
|
||||
connectLocalOnly: function (options, success, error) {
|
||||
return execWithPromise("connectLocalOnly", options, success, error);
|
||||
},
|
||||
|
||||
addNetworkSuggestion: function (options, success, error) {
|
||||
return execWithPromise("addNetworkSuggestion", options, success, error);
|
||||
},
|
||||
|
||||
removeNetworkSuggestion: function (options, success, error) {
|
||||
return execWithPromise("removeNetworkSuggestion", options, success, error);
|
||||
},
|
||||
|
||||
saveNetwork: function (options, success, error) {
|
||||
return execWithPromise("saveNetwork", options, success, error);
|
||||
},
|
||||
|
||||
disconnect: function (options, success, error) {
|
||||
if (typeof options === "function" || typeof options === "undefined") {
|
||||
return execWithPromise("disconnect", {}, options, success);
|
||||
}
|
||||
|
||||
return execWithPromise("disconnect", options || {}, success, error);
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user