From 9505ff6522a3f8e860a756f8246293820cfdd322 Mon Sep 17 00:00:00 2001 From: "aria.ark" Date: Sat, 28 Mar 2026 03:43:40 +0900 Subject: [PATCH] Initial plugin release --- README.md | 219 ++++++ examples/outsystems-connect-specific-wifi.js | 47 ++ package.json | 28 + plugin.xml | 54 ++ src/android/BluememeWifiConnector.java | 696 +++++++++++++++++++ src/ios/BluememeWifiConnector.m | 301 ++++++++ www/BluememeWifiConnector.js | 75 ++ 7 files changed, 1420 insertions(+) create mode 100644 README.md create mode 100644 examples/outsystems-connect-specific-wifi.js create mode 100644 package.json create mode 100644 plugin.xml create mode 100644 src/android/BluememeWifiConnector.java create mode 100644 src/ios/BluememeWifiConnector.m create mode 100644 www/BluememeWifiConnector.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f01b76 --- /dev/null +++ b/README.md @@ -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:///cordova-plugin-bluememe-wifi-connector.git#v1.0.0" + } +} +``` + +如果你直接使用当前仓库作为插件仓库,把 `` 换成你的 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 能力。 diff --git a/examples/outsystems-connect-specific-wifi.js b/examples/outsystems-connect-specific-wifi.js new file mode 100644 index 0000000..ac87ee3 --- /dev/null +++ b/examples/outsystems-connect-specific-wifi.js @@ -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" + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f32b29 --- /dev/null +++ b/package.json @@ -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" + } + } + } +} diff --git a/plugin.xml b/plugin.xml new file mode 100644 index 0000000..572d886 --- /dev/null +++ b/plugin.xml @@ -0,0 +1,54 @@ + + + + BluememeWifiConnector + Cordova plugin for connecting an OutSystems Android or iOS app to a specific Wi-Fi network. + MIT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/BluememeWifiConnector.java b/src/android/BluememeWifiConnector.java new file mode 100644 index 0000000..6575c65 --- /dev/null +++ b/src/android/BluememeWifiConnector.java @@ -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 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 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 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 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); + } + } +} diff --git a/src/ios/BluememeWifiConnector.m b/src/ios/BluememeWifiConnector.m new file mode 100644 index 0000000..8f8e913 --- /dev/null +++ b/src/ios/BluememeWifiConnector.m @@ -0,0 +1,301 @@ +#import +#import + +@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 * _Nonnull ssids) { + NSArray *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 diff --git a/www/BluememeWifiConnector.js b/www/BluememeWifiConnector.js new file mode 100644 index 0000000..126d6d5 --- /dev/null +++ b/www/BluememeWifiConnector.js @@ -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); + } +};