Initial plugin release
This commit is contained in:
commit
9505ff6522
7 files changed
+1420
No files matched your search
@@ -0,0 +1,696 @@
|
||||
package com.bluememe.cordova.wifi;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.NetworkRequest;
|
||||
import android.net.wifi.WifiConfiguration;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.net.wifi.WifiNetworkSpecifier;
|
||||
import android.net.wifi.WifiNetworkSuggestion;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.apache.cordova.CallbackContext;
|
||||
import org.apache.cordova.CordovaPlugin;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class BluememeWifiConnector extends CordovaPlugin {
|
||||
private static final String ACTION_CONNECT = "connect";
|
||||
private static final String ACTION_CONNECT_LOCAL_ONLY = "connectLocalOnly";
|
||||
private static final String ACTION_ADD_NETWORK_SUGGESTION = "addNetworkSuggestion";
|
||||
private static final String ACTION_REMOVE_NETWORK_SUGGESTION = "removeNetworkSuggestion";
|
||||
private static final String ACTION_SAVE_NETWORK = "saveNetwork";
|
||||
private static final String ACTION_DISCONNECT = "disconnect";
|
||||
|
||||
private static final int REQUEST_CODE_WIFI_PERMISSIONS = 7310;
|
||||
private static final int REQUEST_CODE_SAVE_NETWORK = 7311;
|
||||
private static final int API_LEVEL_33 = 33;
|
||||
|
||||
private static final String PERMISSION_NEARBY_WIFI_DEVICES = "android.permission.NEARBY_WIFI_DEVICES";
|
||||
private static final String INTENT_ACTION_WIFI_ADD_NETWORKS = "android.settings.WIFI_ADD_NETWORKS";
|
||||
private static final String EXTRA_WIFI_NETWORK_LIST = "android.provider.extra.WIFI_NETWORK_LIST";
|
||||
private static final String EXTRA_WIFI_NETWORK_RESULT_LIST = "android.provider.extra.WIFI_NETWORK_RESULT_LIST";
|
||||
|
||||
private CallbackContext permissionCallbackContext;
|
||||
private String pendingPermissionAction;
|
||||
private JSONArray pendingPermissionArgs;
|
||||
|
||||
private CallbackContext saveNetworkCallbackContext;
|
||||
private String pendingSaveSsid;
|
||||
|
||||
private ConnectivityManager.NetworkCallback localOnlyNetworkCallback;
|
||||
private Network currentLocalOnlyNetwork;
|
||||
private boolean currentLocalOnlyBound;
|
||||
|
||||
@Override
|
||||
public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException {
|
||||
if (requiresRuntimePermission(action, args) && !ensurePermissions(action, args, callbackContext)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ACTION_SAVE_NETWORK.equals(action)) {
|
||||
saveNetwork(getOptions(args), callbackContext);
|
||||
return true;
|
||||
}
|
||||
|
||||
cordova.getThreadPool().execute(() -> {
|
||||
try {
|
||||
switch (action) {
|
||||
case ACTION_CONNECT:
|
||||
connect(getOptions(args), callbackContext);
|
||||
break;
|
||||
case ACTION_CONNECT_LOCAL_ONLY:
|
||||
connectLocalOnly(getOptions(args), callbackContext);
|
||||
break;
|
||||
case ACTION_ADD_NETWORK_SUGGESTION:
|
||||
addNetworkSuggestion(getOptions(args), callbackContext);
|
||||
break;
|
||||
case ACTION_REMOVE_NETWORK_SUGGESTION:
|
||||
removeNetworkSuggestion(getOptions(args), callbackContext);
|
||||
break;
|
||||
case ACTION_DISCONNECT:
|
||||
disconnect(callbackContext);
|
||||
break;
|
||||
default:
|
||||
sendError(callbackContext, "UNSUPPORTED_ACTION", "Unsupported action: " + action, null);
|
||||
break;
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
sendError(callbackContext, "UNEXPECTED_ERROR", exception.getMessage(), exception);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionResult(int requestCode, String[] permissions, int[] grantResults) throws JSONException {
|
||||
if (requestCode != REQUEST_CODE_WIFI_PERMISSIONS || permissionCallbackContext == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean allGranted = true;
|
||||
for (int result : grantResults) {
|
||||
if (result != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
allGranted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allGranted) {
|
||||
sendError(permissionCallbackContext, "PERMISSION_DENIED", "Required Wi-Fi permission was denied.", null);
|
||||
clearPendingPermissionRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
String action = pendingPermissionAction;
|
||||
JSONArray args = pendingPermissionArgs;
|
||||
CallbackContext callbackContext = permissionCallbackContext;
|
||||
|
||||
clearPendingPermissionRequest();
|
||||
execute(action, args, callbackContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent intent) {
|
||||
if (requestCode != REQUEST_CODE_SAVE_NETWORK || saveNetworkCallbackContext == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
try {
|
||||
result.put("mode", "save");
|
||||
result.put("ssid", pendingSaveSsid);
|
||||
result.put("resultCode", resultCode);
|
||||
result.put("saved", resultCode == Activity.RESULT_OK);
|
||||
result.put("resultCodes", extractSaveResultCodes(intent));
|
||||
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
saveNetworkCallbackContext.success(result);
|
||||
} else {
|
||||
sendError(saveNetworkCallbackContext, "USER_CANCELLED", "The user cancelled the saved-network flow.", null, result);
|
||||
}
|
||||
} catch (JSONException exception) {
|
||||
sendError(saveNetworkCallbackContext, "SAVE_RESULT_PARSE_FAILED", exception.getMessage(), exception);
|
||||
} finally {
|
||||
saveNetworkCallbackContext = null;
|
||||
pendingSaveSsid = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void connect(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
String requestedMode = normalizeMode(options.optString("mode", "auto"));
|
||||
String resolvedMode = resolveMode(requestedMode, options);
|
||||
|
||||
switch (resolvedMode) {
|
||||
case "legacy":
|
||||
connectLegacy(options, callbackContext);
|
||||
break;
|
||||
case "localOnly":
|
||||
connectLocalOnly(options, callbackContext);
|
||||
break;
|
||||
case "suggestion":
|
||||
addNetworkSuggestion(options, callbackContext);
|
||||
break;
|
||||
case "save":
|
||||
saveNetwork(options, callbackContext);
|
||||
break;
|
||||
default:
|
||||
sendError(callbackContext, "INVALID_MODE", "Unsupported mode: " + resolvedMode, null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void connectLegacy(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "Legacy direct Wi-Fi connection is only supported on Android 9 (API 28) and below.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
String security = normalizeSecurity(options.optString("security", "WPA2"));
|
||||
if ("WPA3".equals(security)) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "WPA3 is not supported by the legacy Wi-Fi API path.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
WifiManager wifiManager = getWifiManager();
|
||||
if (!wifiManager.isWifiEnabled() && !wifiManager.setWifiEnabled(true)) {
|
||||
sendError(callbackContext, "WIFI_ENABLE_FAILED", "Unable to enable Wi-Fi before connecting.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
String ssid = getRequiredString(options, "ssid");
|
||||
WifiConfiguration configuration = new WifiConfiguration();
|
||||
configuration.SSID = quoteValue(ssid);
|
||||
|
||||
if ("OPEN".equals(security)) {
|
||||
configuration.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE);
|
||||
} else {
|
||||
configuration.preSharedKey = quoteValue(getRequiredString(options, "passphrase"));
|
||||
}
|
||||
|
||||
int networkId = findConfiguredNetworkId(wifiManager, configuration.SSID);
|
||||
if (networkId >= 0) {
|
||||
configuration.networkId = networkId;
|
||||
int updatedNetworkId = wifiManager.updateNetwork(configuration);
|
||||
if (updatedNetworkId >= 0) {
|
||||
networkId = updatedNetworkId;
|
||||
}
|
||||
} else {
|
||||
networkId = wifiManager.addNetwork(configuration);
|
||||
}
|
||||
|
||||
if (networkId < 0) {
|
||||
sendError(callbackContext, "NETWORK_ADD_FAILED", "Android failed to add the Wi-Fi configuration.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean disconnected = wifiManager.disconnect();
|
||||
boolean enabled = wifiManager.enableNetwork(networkId, true);
|
||||
boolean reconnected = wifiManager.reconnect();
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("mode", "legacy");
|
||||
result.put("ssid", ssid);
|
||||
result.put("networkId", networkId);
|
||||
result.put("disconnected", disconnected);
|
||||
result.put("enabled", enabled);
|
||||
result.put("reconnected", reconnected);
|
||||
result.put("apiLevel", Build.VERSION.SDK_INT);
|
||||
|
||||
if (enabled && reconnected) {
|
||||
callbackContext.success(result);
|
||||
} else {
|
||||
sendError(callbackContext, "NETWORK_ENABLE_FAILED", "Android failed to switch to the requested Wi-Fi network.", null, result);
|
||||
}
|
||||
}
|
||||
|
||||
private void connectLocalOnly(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "Local-only Wi-Fi requests require Android 10 (API 29) or newer.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
releaseLocalOnlyNetwork();
|
||||
|
||||
if (!getWifiManager().isWifiEnabled()) {
|
||||
sendError(callbackContext, "WIFI_DISABLED", "Wi-Fi is off. Enable Wi-Fi first, then retry the local-only connection.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
String ssid = getRequiredString(options, "ssid");
|
||||
String security = normalizeSecurity(options.optString("security", "WPA2"));
|
||||
boolean bindToProcess = options.optBoolean("bindToProcess", true);
|
||||
int timeoutMs = options.optInt("timeoutMs", 30000);
|
||||
|
||||
WifiNetworkSpecifier.Builder specifierBuilder = new WifiNetworkSpecifier.Builder().setSsid(ssid);
|
||||
if (options.optBoolean("hidden", false)) {
|
||||
specifierBuilder.setIsHiddenSsid(true);
|
||||
}
|
||||
|
||||
if ("WPA2".equals(security)) {
|
||||
specifierBuilder.setWpa2Passphrase(getRequiredString(options, "passphrase"));
|
||||
} else if ("WPA3".equals(security)) {
|
||||
specifierBuilder.setWpa3Passphrase(getRequiredString(options, "passphrase"));
|
||||
} else if (!"OPEN".equals(security)) {
|
||||
sendError(callbackContext, "INVALID_SECURITY", "Unsupported security type: " + security, null);
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectivityManager connectivityManager = getConnectivityManager();
|
||||
NetworkRequest networkRequest = new NetworkRequest.Builder()
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.setNetworkSpecifier(specifierBuilder.build())
|
||||
.build();
|
||||
|
||||
localOnlyNetworkCallback = new ConnectivityManager.NetworkCallback() {
|
||||
@Override
|
||||
public void onAvailable(Network network) {
|
||||
currentLocalOnlyNetwork = network;
|
||||
currentLocalOnlyBound = bindToProcess;
|
||||
|
||||
if (bindToProcess) {
|
||||
connectivityManager.bindProcessToNetwork(network);
|
||||
}
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
try {
|
||||
result.put("mode", "localOnly");
|
||||
result.put("ssid", ssid);
|
||||
result.put("boundToProcess", bindToProcess);
|
||||
result.put("apiLevel", Build.VERSION.SDK_INT);
|
||||
callbackContext.success(result);
|
||||
} catch (JSONException exception) {
|
||||
sendError(callbackContext, "RESULT_BUILD_FAILED", exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnavailable() {
|
||||
sendError(callbackContext, "NETWORK_UNAVAILABLE", "Android could not establish the requested local-only Wi-Fi connection.", null);
|
||||
releaseLocalOnlyNetwork();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLost(Network network) {
|
||||
releaseLocalOnlyNetwork();
|
||||
}
|
||||
};
|
||||
|
||||
connectivityManager.requestNetwork(networkRequest, localOnlyNetworkCallback, timeoutMs);
|
||||
}
|
||||
|
||||
private void addNetworkSuggestion(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "Wi-Fi suggestions require Android 10 (API 29) or newer.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
WifiNetworkSuggestion suggestion = buildSuggestion(options);
|
||||
WifiManager wifiManager = getWifiManager();
|
||||
int status = wifiManager.addNetworkSuggestions(Collections.singletonList(suggestion));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("mode", "suggestion");
|
||||
result.put("ssid", getRequiredString(options, "ssid"));
|
||||
result.put("status", status);
|
||||
result.put("statusText", mapSuggestionStatus(status));
|
||||
result.put("apiLevel", Build.VERSION.SDK_INT);
|
||||
|
||||
if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS) {
|
||||
callbackContext.success(result);
|
||||
} else {
|
||||
sendError(callbackContext, "SUGGESTION_ADD_FAILED", "Android rejected the Wi-Fi suggestion: " + mapSuggestionStatus(status), null, result);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeNetworkSuggestion(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "Wi-Fi suggestions require Android 10 (API 29) or newer.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
WifiNetworkSuggestion suggestion = buildSuggestion(options);
|
||||
WifiManager wifiManager = getWifiManager();
|
||||
int status = wifiManager.removeNetworkSuggestions(Collections.singletonList(suggestion));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("mode", "suggestion");
|
||||
result.put("ssid", getRequiredString(options, "ssid"));
|
||||
result.put("status", status);
|
||||
result.put("statusText", mapSuggestionStatus(status));
|
||||
result.put("apiLevel", Build.VERSION.SDK_INT);
|
||||
|
||||
if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS) {
|
||||
callbackContext.success(result);
|
||||
} else {
|
||||
sendError(callbackContext, "SUGGESTION_REMOVE_FAILED", "Android could not remove the Wi-Fi suggestion: " + mapSuggestionStatus(status), null, result);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveNetwork(JSONObject options, CallbackContext callbackContext) throws JSONException {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
sendError(callbackContext, "MODE_NOT_SUPPORTED", "Saving a Wi-Fi network with user approval requires Android 11 (API 30) or newer.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveNetworkCallbackContext != null) {
|
||||
sendError(callbackContext, "FLOW_IN_PROGRESS", "Another saveNetwork request is already in progress.", null);
|
||||
return;
|
||||
}
|
||||
|
||||
WifiNetworkSuggestion suggestion = buildSuggestion(options);
|
||||
String ssid = getRequiredString(options, "ssid");
|
||||
|
||||
cordova.getActivity().runOnUiThread(() -> {
|
||||
try {
|
||||
Intent intent = new Intent(INTENT_ACTION_WIFI_ADD_NETWORKS);
|
||||
ArrayList<WifiNetworkSuggestion> suggestions = new ArrayList<>();
|
||||
suggestions.add(suggestion);
|
||||
intent.putParcelableArrayListExtra(EXTRA_WIFI_NETWORK_LIST, suggestions);
|
||||
|
||||
saveNetworkCallbackContext = callbackContext;
|
||||
pendingSaveSsid = ssid;
|
||||
|
||||
cordova.setActivityResultCallback(BluememeWifiConnector.this);
|
||||
cordova.startActivityForResult(BluememeWifiConnector.this, intent, REQUEST_CODE_SAVE_NETWORK);
|
||||
} catch (Exception exception) {
|
||||
saveNetworkCallbackContext = null;
|
||||
pendingSaveSsid = null;
|
||||
sendError(callbackContext, "SAVE_NETWORK_START_FAILED", exception.getMessage(), exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void disconnect(CallbackContext callbackContext) throws JSONException {
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("releasedLocalOnly", currentLocalOnlyNetwork != null || localOnlyNetworkCallback != null);
|
||||
|
||||
releaseLocalOnlyNetwork();
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
WifiManager wifiManager = getWifiManager();
|
||||
result.put("legacyDisconnectCalled", wifiManager.disconnect());
|
||||
} else {
|
||||
result.put("legacyDisconnectCalled", false);
|
||||
}
|
||||
|
||||
callbackContext.success(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReset() {
|
||||
releaseLocalOnlyNetwork();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
releaseLocalOnlyNetwork();
|
||||
}
|
||||
|
||||
private boolean ensurePermissions(String action, JSONArray args, CallbackContext callbackContext) {
|
||||
String[] permissions = getRuntimePermissions(action);
|
||||
if (permissions.length == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
List<String> missingPermissions = new ArrayList<>();
|
||||
for (String permission : permissions) {
|
||||
if (!cordova.hasPermission(permission)) {
|
||||
missingPermissions.add(permission);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingPermissions.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
permissionCallbackContext = callbackContext;
|
||||
pendingPermissionAction = action;
|
||||
pendingPermissionArgs = args;
|
||||
cordova.requestPermissions(this, REQUEST_CODE_WIFI_PERMISSIONS, missingPermissions.toArray(new String[0]));
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean requiresRuntimePermission(String action, JSONArray args) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ACTION_CONNECT_LOCAL_ONLY.equals(action)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ACTION_CONNECT.equals(action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
JSONObject options = getOptions(args);
|
||||
String requestedMode = normalizeMode(options.optString("mode", "auto"));
|
||||
String resolvedMode = resolveMode(requestedMode, options);
|
||||
return "legacy".equals(resolvedMode) || "localOnly".equals(resolvedMode);
|
||||
}
|
||||
|
||||
private String[] getRuntimePermissions(String action) {
|
||||
if (ACTION_CONNECT_LOCAL_ONLY.equals(action)) {
|
||||
return getConnectionPermissions();
|
||||
}
|
||||
|
||||
if (!ACTION_CONNECT.equals(action)) {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
return getConnectionPermissions();
|
||||
}
|
||||
|
||||
private String[] getConnectionPermissions() {
|
||||
if (Build.VERSION.SDK_INT >= API_LEVEL_33) {
|
||||
return new String[]{PERMISSION_NEARBY_WIFI_DEVICES};
|
||||
}
|
||||
|
||||
return new String[]{Manifest.permission.ACCESS_FINE_LOCATION};
|
||||
}
|
||||
|
||||
private void clearPendingPermissionRequest() {
|
||||
permissionCallbackContext = null;
|
||||
pendingPermissionAction = null;
|
||||
pendingPermissionArgs = null;
|
||||
}
|
||||
|
||||
private WifiNetworkSuggestion buildSuggestion(JSONObject options) throws JSONException {
|
||||
String ssid = getRequiredString(options, "ssid");
|
||||
String security = normalizeSecurity(options.optString("security", "WPA2"));
|
||||
|
||||
WifiNetworkSuggestion.Builder builder = new WifiNetworkSuggestion.Builder().setSsid(ssid);
|
||||
if (options.optBoolean("hidden", false)) {
|
||||
builder.setIsHiddenSsid(true);
|
||||
}
|
||||
|
||||
if ("WPA2".equals(security)) {
|
||||
builder.setWpa2Passphrase(getRequiredString(options, "passphrase"));
|
||||
} else if ("WPA3".equals(security)) {
|
||||
builder.setWpa3Passphrase(getRequiredString(options, "passphrase"));
|
||||
} else if (!"OPEN".equals(security)) {
|
||||
throw new IllegalArgumentException("Unsupported security type: " + security);
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
builder.setIsAppInteractionRequired(options.optBoolean("appInteractionRequired", false));
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private ConnectivityManager getConnectivityManager() {
|
||||
return (ConnectivityManager) cordova.getActivity().getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
}
|
||||
|
||||
private WifiManager getWifiManager() {
|
||||
Context context = cordova.getActivity().getApplicationContext();
|
||||
return (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
|
||||
}
|
||||
|
||||
private JSONObject getOptions(JSONArray args) {
|
||||
if (args == null || args.length() == 0 || args.isNull(0)) {
|
||||
return new JSONObject();
|
||||
}
|
||||
|
||||
JSONObject options = args.optJSONObject(0);
|
||||
return options != null ? options : new JSONObject();
|
||||
}
|
||||
|
||||
private int findConfiguredNetworkId(WifiManager wifiManager, String quotedSsid) {
|
||||
List<WifiConfiguration> configuredNetworks = wifiManager.getConfiguredNetworks();
|
||||
if (configuredNetworks == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (WifiConfiguration configuredNetwork : configuredNetworks) {
|
||||
if (quotedSsid.equals(configuredNetwork.SSID)) {
|
||||
return configuredNetwork.networkId;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private JSONArray extractSaveResultCodes(Intent intent) throws JSONException {
|
||||
JSONArray resultCodes = new JSONArray();
|
||||
if (intent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
return resultCodes;
|
||||
}
|
||||
|
||||
ArrayList<Integer> arrayList = intent.getIntegerArrayListExtra(EXTRA_WIFI_NETWORK_RESULT_LIST);
|
||||
if (arrayList != null) {
|
||||
for (Integer code : arrayList) {
|
||||
resultCodes.put(code);
|
||||
}
|
||||
return resultCodes;
|
||||
}
|
||||
|
||||
int[] array = intent.getIntArrayExtra(EXTRA_WIFI_NETWORK_RESULT_LIST);
|
||||
if (array != null) {
|
||||
for (int code : array) {
|
||||
resultCodes.put(code);
|
||||
}
|
||||
}
|
||||
|
||||
return resultCodes;
|
||||
}
|
||||
|
||||
private void releaseLocalOnlyNetwork() {
|
||||
ConnectivityManager connectivityManager = getConnectivityManager();
|
||||
|
||||
if (currentLocalOnlyBound) {
|
||||
connectivityManager.bindProcessToNetwork(null);
|
||||
currentLocalOnlyBound = false;
|
||||
}
|
||||
|
||||
if (localOnlyNetworkCallback != null) {
|
||||
try {
|
||||
connectivityManager.unregisterNetworkCallback(localOnlyNetworkCallback);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Android may already have disposed this callback.
|
||||
}
|
||||
localOnlyNetworkCallback = null;
|
||||
}
|
||||
|
||||
currentLocalOnlyNetwork = null;
|
||||
}
|
||||
|
||||
private String resolveMode(String requestedMode, JSONObject options) {
|
||||
if (!"auto".equals(requestedMode)) {
|
||||
return requestedMode;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
if (options.optBoolean("localOnly", false)) {
|
||||
return "localOnly";
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return "save";
|
||||
}
|
||||
|
||||
return "suggestion";
|
||||
}
|
||||
|
||||
private String normalizeMode(String mode) {
|
||||
if (TextUtils.isEmpty(mode)) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
String normalized = mode.trim();
|
||||
if ("saveNetwork".equalsIgnoreCase(normalized) || "saved".equalsIgnoreCase(normalized)) {
|
||||
return "save";
|
||||
}
|
||||
if ("connectLocalOnly".equalsIgnoreCase(normalized) || "local".equalsIgnoreCase(normalized)) {
|
||||
return "localOnly";
|
||||
}
|
||||
if ("addNetworkSuggestion".equalsIgnoreCase(normalized) || "suggest".equalsIgnoreCase(normalized)) {
|
||||
return "suggestion";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeSecurity(String security) {
|
||||
if (TextUtils.isEmpty(security)) {
|
||||
return "WPA2";
|
||||
}
|
||||
|
||||
String normalized = security.trim().toUpperCase();
|
||||
if ("WPA".equals(normalized) || "WPA2_PSK".equals(normalized) || "WPA_PSK".equals(normalized)) {
|
||||
return "WPA2";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String getRequiredString(JSONObject options, String key) {
|
||||
String value = options.optString(key, null);
|
||||
if (TextUtils.isEmpty(value)) {
|
||||
throw new IllegalArgumentException("Missing required option: " + key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String quoteValue(String value) {
|
||||
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
|
||||
private String mapSuggestionStatus(int status) {
|
||||
switch (status) {
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS:
|
||||
return "SUCCESS";
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_INTERNAL:
|
||||
return "ERROR_INTERNAL";
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_APP_DISALLOWED:
|
||||
return "ERROR_APP_DISALLOWED";
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_ADD_DUPLICATE:
|
||||
return "ERROR_ADD_DUPLICATE";
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_ADD_EXCEEDS_MAX_PER_APP:
|
||||
return "ERROR_ADD_EXCEEDS_MAX_PER_APP";
|
||||
case WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_REMOVE_INVALID:
|
||||
return "ERROR_REMOVE_INVALID";
|
||||
default:
|
||||
return "UNKNOWN_STATUS_" + status;
|
||||
}
|
||||
}
|
||||
|
||||
private void sendError(CallbackContext callbackContext, String code, String message, Throwable throwable) {
|
||||
sendError(callbackContext, code, message, throwable, null);
|
||||
}
|
||||
|
||||
private void sendError(CallbackContext callbackContext, String code, String message, Throwable throwable, JSONObject extra) {
|
||||
try {
|
||||
JSONObject error = new JSONObject();
|
||||
error.put("code", code);
|
||||
error.put("message", message == null ? "Unknown error" : message);
|
||||
|
||||
if (throwable != null) {
|
||||
error.put("exception", throwable.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
if (extra != null) {
|
||||
error.put("details", extra);
|
||||
}
|
||||
|
||||
callbackContext.error(error.toString());
|
||||
} catch (JSONException jsonException) {
|
||||
callbackContext.error(message == null ? "Unknown error" : message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
#import <Cordova/CDV.h>
|
||||
#import <NetworkExtension/NetworkExtension.h>
|
||||
|
||||
@interface BluememeWifiConnector : CDVPlugin
|
||||
@end
|
||||
|
||||
@implementation BluememeWifiConnector
|
||||
|
||||
- (void)connect:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
NSDictionary *options = [self optionsFromCommand:command];
|
||||
NSString *requestedMode = [self normalizeMode:[self stringForKey:@"mode" inOptions:options]];
|
||||
NSString *resolvedMode = [self resolveMode:requestedMode options:options];
|
||||
|
||||
if ([resolvedMode isEqualToString:@"localOnly"]) {
|
||||
[self applyConfigurationWithOptions:options joinOnce:YES mode:@"localOnly" command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
if ([resolvedMode isEqualToString:@"save"]) {
|
||||
[self applyConfigurationWithOptions:options joinOnce:NO mode:@"save" command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *message = [NSString stringWithFormat:@"Mode '%@' is not supported on iOS. Use 'save' or 'localOnly'.", resolvedMode];
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED" message:message details:nil command:command];
|
||||
}
|
||||
|
||||
- (void)connectLocalOnly:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
[self applyConfigurationWithOptions:[self optionsFromCommand:command] joinOnce:YES mode:@"localOnly" command:command];
|
||||
}
|
||||
|
||||
- (void)addNetworkSuggestion:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED"
|
||||
message:@"iOS does not expose suggestion-based Wi-Fi APIs. Use connect() or saveNetwork() instead."
|
||||
details:nil
|
||||
command:command];
|
||||
}
|
||||
|
||||
- (void)removeNetworkSuggestion:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED"
|
||||
message:@"iOS does not expose suggestion-based Wi-Fi APIs. Use disconnect() to remove configurations added by the app."
|
||||
details:nil
|
||||
command:command];
|
||||
}
|
||||
|
||||
- (void)saveNetwork:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
[self applyConfigurationWithOptions:[self optionsFromCommand:command] joinOnce:NO mode:@"save" command:command];
|
||||
}
|
||||
|
||||
- (void)disconnect:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
if (@available(iOS 11.0, *)) {
|
||||
NSDictionary *options = [self optionsFromCommand:command];
|
||||
NSString *requestedSsid = [self stringForKey:@"ssid" inOptions:options];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NEHotspotConfigurationManager *manager = [NEHotspotConfigurationManager sharedManager];
|
||||
if (requestedSsid.length > 0) {
|
||||
[manager removeConfigurationForSSID:requestedSsid];
|
||||
[self sendSuccess:@{
|
||||
@"platform": @"ios",
|
||||
@"removedSSIDs": @[requestedSsid],
|
||||
@"releasedLocalOnly": @YES,
|
||||
@"legacyDisconnectCalled": @NO
|
||||
} command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
[manager getConfiguredSSIDsWithCompletionHandler:^(NSArray<NSString *> * _Nonnull ssids) {
|
||||
NSArray<NSString *> *configuredSsids = ssids ?: @[];
|
||||
for (NSString *ssid in configuredSsids) {
|
||||
[manager removeConfigurationForSSID:ssid];
|
||||
}
|
||||
|
||||
[self sendSuccess:@{
|
||||
@"platform": @"ios",
|
||||
@"removedSSIDs": configuredSsids,
|
||||
@"releasedLocalOnly": @([configuredSsids count] > 0),
|
||||
@"legacyDisconnectCalled": @NO
|
||||
} command:command];
|
||||
}];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED"
|
||||
message:@"NEHotspotConfigurationManager requires iOS 11 or newer."
|
||||
details:nil
|
||||
command:command];
|
||||
}
|
||||
|
||||
- (void)applyConfigurationWithOptions:(NSDictionary *)options
|
||||
joinOnce:(BOOL)joinOnce
|
||||
mode:(NSString *)mode
|
||||
command:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
if (@available(iOS 11.0, *)) {
|
||||
NSString *ssid = [self stringForKey:@"ssid" inOptions:options];
|
||||
if (ssid.length == 0) {
|
||||
[self sendErrorCode:@"INVALID_OPTIONS"
|
||||
message:@"Missing required option: ssid"
|
||||
details:nil
|
||||
command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *security = [self normalizeSecurity:[self stringForKey:@"security" inOptions:options]];
|
||||
NEHotspotConfiguration *configuration = nil;
|
||||
|
||||
if ([security isEqualToString:@"OPEN"]) {
|
||||
configuration = [[NEHotspotConfiguration alloc] initWithSSID:ssid];
|
||||
} else if ([security isEqualToString:@"WPA2"]) {
|
||||
NSString *passphrase = [self stringForKey:@"passphrase" inOptions:options];
|
||||
if (passphrase.length == 0) {
|
||||
[self sendErrorCode:@"INVALID_OPTIONS"
|
||||
message:@"Missing required option: passphrase"
|
||||
details:nil
|
||||
command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
configuration = [[NEHotspotConfiguration alloc] initWithSSID:ssid passphrase:passphrase isWEP:NO];
|
||||
} else if ([security isEqualToString:@"WPA3"]) {
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED"
|
||||
message:@"iOS hotspot configuration supports OPEN, WEP, WPA, and WPA2. WPA3 is not exposed by this plugin on iOS."
|
||||
details:nil
|
||||
command:command];
|
||||
return;
|
||||
} else {
|
||||
NSString *message = [NSString stringWithFormat:@"Unsupported security type on iOS: %@", security];
|
||||
[self sendErrorCode:@"INVALID_SECURITY" message:message details:nil command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
configuration.joinOnce = joinOnce;
|
||||
configuration.hidden = [self boolForKey:@"hidden" inOptions:options defaultValue:NO];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[NEHotspotConfigurationManager sharedManager] applyConfiguration:configuration completionHandler:^(NSError * _Nullable error) {
|
||||
if (error != nil) {
|
||||
[self sendNativeError:error code:@"IOS_WIFI_CONFIGURATION_FAILED" command:command];
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableDictionary *result = [@{
|
||||
@"mode": mode,
|
||||
@"ssid": ssid,
|
||||
@"apiName": @"NEHotspotConfigurationManager",
|
||||
@"platform": @"ios",
|
||||
@"joinOnce": @(joinOnce),
|
||||
@"hidden": @(configuration.hidden),
|
||||
@"saved": @(!joinOnce)
|
||||
} mutableCopy];
|
||||
|
||||
if ([mode isEqualToString:@"localOnly"]) {
|
||||
result[@"boundToProcess"] = @NO;
|
||||
}
|
||||
|
||||
[self sendSuccess:result command:command];
|
||||
}];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
[self sendErrorCode:@"MODE_NOT_SUPPORTED"
|
||||
message:@"NEHotspotConfigurationManager requires iOS 11 or newer."
|
||||
details:nil
|
||||
command:command];
|
||||
}
|
||||
|
||||
- (NSDictionary *)optionsFromCommand:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
if ([command.arguments count] == 0) {
|
||||
return @{};
|
||||
}
|
||||
|
||||
id firstArgument = command.arguments[0];
|
||||
if ([firstArgument isKindOfClass:[NSDictionary class]]) {
|
||||
return firstArgument;
|
||||
}
|
||||
|
||||
return @{};
|
||||
}
|
||||
|
||||
- (NSString *)resolveMode:(NSString *)requestedMode options:(NSDictionary *)options
|
||||
{
|
||||
if (![requestedMode isEqualToString:@"auto"]) {
|
||||
return requestedMode;
|
||||
}
|
||||
|
||||
if ([self boolForKey:@"localOnly" inOptions:options defaultValue:NO]) {
|
||||
return @"localOnly";
|
||||
}
|
||||
|
||||
return @"save";
|
||||
}
|
||||
|
||||
- (NSString *)normalizeMode:(NSString *)mode
|
||||
{
|
||||
if (mode.length == 0) {
|
||||
return @"auto";
|
||||
}
|
||||
|
||||
NSString *normalized = [mode stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if ([normalized caseInsensitiveCompare:@"saveNetwork"] == NSOrderedSame ||
|
||||
[normalized caseInsensitiveCompare:@"saved"] == NSOrderedSame) {
|
||||
return @"save";
|
||||
}
|
||||
if ([normalized caseInsensitiveCompare:@"connectLocalOnly"] == NSOrderedSame ||
|
||||
[normalized caseInsensitiveCompare:@"local"] == NSOrderedSame) {
|
||||
return @"localOnly";
|
||||
}
|
||||
if ([normalized caseInsensitiveCompare:@"addNetworkSuggestion"] == NSOrderedSame ||
|
||||
[normalized caseInsensitiveCompare:@"suggest"] == NSOrderedSame) {
|
||||
return @"suggestion";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
- (NSString *)normalizeSecurity:(NSString *)security
|
||||
{
|
||||
if (security.length == 0) {
|
||||
return @"WPA2";
|
||||
}
|
||||
|
||||
NSString *normalized = [[security stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] uppercaseString];
|
||||
if ([normalized isEqualToString:@"WPA"] ||
|
||||
[normalized isEqualToString:@"WPA2_PSK"] ||
|
||||
[normalized isEqualToString:@"WPA_PSK"]) {
|
||||
return @"WPA2";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
- (NSString *)stringForKey:(NSString *)key inOptions:(NSDictionary *)options
|
||||
{
|
||||
id value = options[key];
|
||||
return [value isKindOfClass:[NSString class]] ? value : nil;
|
||||
}
|
||||
|
||||
- (BOOL)boolForKey:(NSString *)key inOptions:(NSDictionary *)options defaultValue:(BOOL)defaultValue
|
||||
{
|
||||
id value = options[key];
|
||||
if ([value respondsToSelector:@selector(boolValue)]) {
|
||||
return [value boolValue];
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
- (void)sendSuccess:(NSDictionary *)payload command:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:payload];
|
||||
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
|
||||
}
|
||||
|
||||
- (void)sendErrorCode:(NSString *)code
|
||||
message:(NSString *)message
|
||||
details:(NSDictionary *)details
|
||||
command:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
NSMutableDictionary *payload = [@{
|
||||
@"code": code ?: @"UNKNOWN",
|
||||
@"message": message ?: @"Unknown error"
|
||||
} mutableCopy];
|
||||
|
||||
if (details != nil) {
|
||||
payload[@"details"] = details;
|
||||
}
|
||||
|
||||
CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:payload];
|
||||
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
|
||||
}
|
||||
|
||||
- (void)sendNativeError:(NSError *)error code:(NSString *)code command:(CDVInvokedUrlCommand *)command
|
||||
{
|
||||
NSMutableDictionary *details = [NSMutableDictionary dictionary];
|
||||
if (error.domain.length > 0) {
|
||||
details[@"domain"] = error.domain;
|
||||
}
|
||||
details[@"nativeCode"] = @(error.code);
|
||||
|
||||
id failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey];
|
||||
if ([failureReason isKindOfClass:[NSString class]] && [failureReason length] > 0) {
|
||||
details[@"failureReason"] = failureReason;
|
||||
}
|
||||
|
||||
[self sendErrorCode:code
|
||||
message:error.localizedDescription
|
||||
details:details
|
||||
command:command];
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in new issue
Block a user