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

+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