Initial plugin release

This commit is contained in:
aria committed 2026-03-28 03:43:40 +09:00
commit 9505ff6522
7 files changed
+1420

No files matched your search

+219
View File
@@ -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"
});
}
+28
View File
@@ -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
View File
@@ -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>
+696
View File
@@ -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);
}
}
}
+301
View File
@@ -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
+75
View File
@@ -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);
}
};