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