commit 414092253063dc659e983773acc63f689b0ee6d8 Author: aria.ark Date: Fri Apr 17 10:36:57 2026 +0900 first commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7145d38 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bluememe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..57ebc34 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# bluememe-utils + +Cordova plugin for OutSystems 11 mobile apps. + +功能: + +- 保存或连接指定 WiFi 热点 +- Firebase Cloud Messaging 推送通知 +- Touch ID / Face ID / Android biometric 解锁,并把解锁结果通知给应用 + +插件 ID:`bluememe-utils` + +JS 全局对象: + +- `BluememeUtils` +- `cordova.plugins.bluememeUtils` + +## Install + +在 OutSystems 11 的 extensibility configuration 中添加插件仓库或本地包后,可使用下面的参数覆盖 Firebase 配置。 + +```json +{ + "plugin": { + "url": "bluememe-utils", + "variables": { + "ANDROID_FIREBASE_APP_ID": "1:000000000000:android:xxxxxxxxxxxxxxxx", + "ANDROID_FIREBASE_API_KEY": "AIza...", + "ANDROID_FIREBASE_PROJECT_ID": "your-firebase-project", + "ANDROID_FIREBASE_SENDER_ID": "000000000000", + "ANDROID_FIREBASE_STORAGE_BUCKET": "your-firebase-project.appspot.com", + "IOS_FIREBASE_GOOGLE_APP_ID": "1:000000000000:ios:xxxxxxxxxxxxxxxx", + "IOS_FIREBASE_API_KEY": "AIza...", + "IOS_FIREBASE_PROJECT_ID": "your-firebase-project", + "IOS_FIREBASE_GCM_SENDER_ID": "000000000000", + "IOS_FIREBASE_STORAGE_BUCKET": "your-firebase-project.appspot.com", + "IOS_FACE_ID_USAGE_DESCRIPTION": "Use Face ID to unlock this application." + } + } +} +``` + +也可以让宿主 App 提供并处理标准 Firebase 配置文件: + +- Android:`google-services.json` +- iOS:`GoogleService-Info.plist` + +如果宿主工程已经由 OutSystems、MABS 配置或其他插件初始化 Firebase,本插件会复用已有的 default Firebase app。否则建议使用上面的 plugin variables,让本插件在运行时手动初始化 Firebase。 + +## JavaScript API + +所有方法都使用 Cordova callback 风格,适合在 OutSystems client action 中封装。 + +```js +BluememeUtils.requestWifiPermission(success, error); + +BluememeUtils.saveWifi({ + ssid: "MyHotspot", + password: "password123", + security: "wpa2" +}, success, error); + +BluememeUtils.connectWifi({ + ssid: "MyHotspot", + password: "password123", + security: "wpa2" +}, success, error); + +BluememeUtils.removeWifi({ ssid: "MyHotspot", password: "password123", security: "wpa2" }, success, error); +BluememeUtils.disconnectWifi(success, error); +``` + +WiFi options: + +- `ssid`:必填 +- `password`:开放网络可省略 +- `security`:`open`、`wep`、`wpa2`、`wpa3`,默认按是否存在 password 推断为 `open` 或 `wpa2` +- `hiddenSsid`:可选,Android 支持隐藏 SSID;iOS 当前实现忽略该字段 +- `bssid`:可选,仅 Android 10+ 临时连接使用 +- `joinOnce`:可选,仅 iOS;`connectWifi` 默认 `true`,`saveWifi` 默认 `false` + +## Push Notifications + +```js +BluememeUtils.requestPushPermission(success, error); +BluememeUtils.getFirebaseToken(function (result) { + console.log(result.token); +}, error); + +BluememeUtils.subscribeToTopic("news", success, error); +BluememeUtils.unsubscribeFromTopic("news", success, error); + +BluememeUtils.startNotificationListener(function (event) { + if (event.type === "token") { + console.log(event.token); + } + if (event.type === "notification") { + console.log(event.notification); + } + if (event.type === "notificationOpened") { + console.log(event.notification); + } +}, error); + +BluememeUtils.getInitialNotification(success, error); +BluememeUtils.stopNotificationListener(success, error); +``` + +事件也会派发到 `window`: + +- `bluememeutils.token` +- `bluememeutils.notification` +- `bluememeutils.notificationOpened` +- `bluememeutils.unlock` + +也可以用插件内置监听器: + +```js +BluememeUtils.on("unlock", function (event) { + console.log(event.unlocked); +}); +``` + +## Biometric Unlock + +```js +BluememeUtils.isBiometricAvailable(success, error); + +BluememeUtils.unlockWithBiometrics({ + reason: "Unlock application", + title: "Unlock", + subtitle: "", + description: "", + cancelText: "Cancel", + fallbackTitle: "Use Passcode", + allowDeviceCredential: true +}, function (result) { + console.log(result.unlocked, result.biometryType); +}, error); +``` + +`biometryType` 可能值: + +- iOS:`face`、`touch`、`none`、`biometric` +- Android:`face`、`fingerprint`、`iris`、`biometric` + +## Platform Notes + +Android WiFi: + +- Android 10+ 不允许第三方应用静默保存并强制切换系统 WiFi。 +- `saveWifi` 在 Android 10+ 使用 `WifiNetworkSuggestion`,系统可能要求用户批准。 +- `connectWifi` 在 Android 10+ 使用 `WifiNetworkSpecifier`,这是临时连接,通常只绑定当前应用进程网络。 +- Android 9 及以下使用旧 `WifiConfiguration` API,可保存并启用网络。 +- 调用 WiFi API 前先调用 `requestWifiPermission`,并确保系统位置服务开启。 + +iOS WiFi: + +- 使用 `NEHotspotConfiguration`。 +- 必须在 Apple Developer 账号和 App entitlements 中开启 `Hotspot Configuration` capability。 +- iOS 不提供第三方 App 断开当前 WiFi 的公开 API,因此 `disconnectWifi` 在 iOS 返回错误。 + +Firebase: + +- Android 13+ 需要通知权限,调用 `requestPushPermission`。 +- iOS 需要 Push Notifications capability、Background Modes 中的 Remote notifications,以及 APNs key/certificate 已配置到 Firebase。 +- 如果项目中还有其他推送插件,也可能同时修改 `UNUserNotificationCenterDelegate` 或 Firebase service,需要在真机上确认通知打开事件。 + +Biometric: + +- iOS Face ID 需要 `NSFaceIDUsageDescription`,可通过 `IOS_FACE_ID_USAGE_DESCRIPTION` 设置。 +- Android 本插件使用平台 `BiometricPrompt`,`unlockWithBiometrics` 支持 Android 9+。 + +## Error Shape + +错误 callback 返回对象: + +```json +{ + "code": "ERROR_CODE", + "message": "Human readable message" +} +``` + +常见错误: + +- `PERMISSION_REQUIRED` +- `INVALID_WIFI_OPTIONS` +- `WIFI_CONNECT_FAILED` +- `FIREBASE_NOT_CONFIGURED` +- `FCM_TOKEN_FAILED` +- `BIOMETRIC_UNAVAILABLE` +- `AUTHENTICATION_ERROR` diff --git a/package.json b/package.json new file mode 100644 index 0000000..0859ed6 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "bluememe-utils", + "version": "0.1.0", + "description": "Cordova utilities for WiFi, Firebase Cloud Messaging, and biometric unlock for OutSystems 11 apps.", + "license": "MIT", + "cordova": { + "id": "bluememe-utils", + "platforms": [ + "android", + "ios" + ] + }, + "keywords": [ + "cordova", + "ecosystem:cordova", + "outsystems", + "wifi", + "firebase", + "fcm", + "touchid", + "faceid", + "biometric" + ], + "engines": [ + { + "name": "cordova", + "version": ">=9.0.0" + } + ] +} diff --git a/plugin.xml b/plugin.xml new file mode 100644 index 0000000..0c0e722 --- /dev/null +++ b/plugin.xml @@ -0,0 +1,107 @@ + + + bluememe-utils + Cordova utilities for WiFi, Firebase Cloud Messaging, and biometric unlock. + MIT + cordova,outsystems,wifi,firebase,fcm,touchid,faceid,biometric + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $IOS_FACE_ID_USAGE_DESCRIPTION + + + $IOS_FIREBASE_GOOGLE_APP_ID + + + $IOS_FIREBASE_API_KEY + + + $IOS_FIREBASE_PROJECT_ID + + + $IOS_FIREBASE_GCM_SENDER_ID + + + $IOS_FIREBASE_STORAGE_BUCKET + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6932f5b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "hakuzen" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = [] diff --git a/src/android/BluememeMessagingService.java b/src/android/BluememeMessagingService.java new file mode 100644 index 0000000..3f90354 --- /dev/null +++ b/src/android/BluememeMessagingService.java @@ -0,0 +1,196 @@ +package com.bluememe.utils; + +import android.app.ActivityManager; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; + +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.List; +import java.util.Map; + +public class BluememeMessagingService extends FirebaseMessagingService { + private static final String TAG = "BluememeMessaging"; + private static final String CHANNEL_ID = "bluememe_utils_notifications"; + private static final String EXTRA_PAYLOAD = "bluememe_payload"; + + @Override + public void onNewToken(String token) { + super.onNewToken(token); + BluememeUtils.saveToken(this, token); + try { + JSONObject event = new JSONObject(); + event.put("type", "token"); + event.put("token", token); + BluememeUtils.publishEvent(this, event); + } catch (JSONException ex) { + Log.w(TAG, "Unable to publish token event.", ex); + } + } + + @Override + public void onMessageReceived(RemoteMessage remoteMessage) { + super.onMessageReceived(remoteMessage); + JSONObject notification = remoteMessageToJson(remoteMessage); + try { + JSONObject event = new JSONObject(); + event.put("type", "notification"); + event.put("notification", notification); + BluememeUtils.publishEvent(this, event); + } catch (JSONException ex) { + Log.w(TAG, "Unable to publish notification event.", ex); + } + + if (!isAppInForeground() && shouldShowNotification(notification)) { + showNotification(notification); + } + } + + private JSONObject remoteMessageToJson(RemoteMessage message) { + JSONObject json = new JSONObject(); + try { + json.put("messageId", message.getMessageId()); + json.put("from", message.getFrom()); + json.put("collapseKey", message.getCollapseKey()); + json.put("sentTime", message.getSentTime()); + + JSONObject data = new JSONObject(); + for (Map.Entry entry : message.getData().entrySet()) { + data.put(entry.getKey(), entry.getValue()); + } + json.put("data", data); + + RemoteMessage.Notification remoteNotification = message.getNotification(); + if (remoteNotification != null) { + JSONObject notification = new JSONObject(); + notification.put("title", remoteNotification.getTitle()); + notification.put("body", remoteNotification.getBody()); + notification.put("tag", remoteNotification.getTag()); + notification.put("clickAction", remoteNotification.getClickAction()); + notification.put("channelId", remoteNotification.getChannelId()); + if (remoteNotification.getImageUrl() != null) { + notification.put("imageUrl", remoteNotification.getImageUrl().toString()); + } + json.put("notification", notification); + } + } catch (JSONException ex) { + Log.w(TAG, "Unable to convert RemoteMessage to JSON.", ex); + } + return json; + } + + private boolean shouldShowNotification(JSONObject payload) { + JSONObject notification = payload.optJSONObject("notification"); + if (notification != null) { + return !TextUtils.isEmpty(notification.optString("title", "")) + || !TextUtils.isEmpty(notification.optString("body", "")); + } + JSONObject data = payload.optJSONObject("data"); + return data != null + && (!TextUtils.isEmpty(data.optString("title", "")) + || !TextUtils.isEmpty(data.optString("body", ""))); + } + + private void showNotification(JSONObject payload) { + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager == null) { + return; + } + createNotificationChannel(notificationManager); + + String title = valueFromPayload(payload, "title", appLabel()); + String body = valueFromPayload(payload, "body", ""); + Intent launchIntent = getPackageManager().getLaunchIntentForPackage(getPackageName()); + if (launchIntent == null) { + return; + } + + launchIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + launchIntent.putExtra(EXTRA_PAYLOAD, payload.toString()); + + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + PendingIntent pendingIntent = PendingIntent.getActivity(this, payload.toString().hashCode(), launchIntent, flags); + + int icon = getApplicationInfo().icon == 0 ? android.R.drawable.ic_dialog_info : getApplicationInfo().icon; + Notification.Builder builder = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? new Notification.Builder(this, CHANNEL_ID) + : new Notification.Builder(this); + + builder.setSmallIcon(icon) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setShowWhen(true); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + builder.setStyle(new Notification.BigTextStyle().bigText(body)); + } + + notificationManager.notify(payload.toString().hashCode(), builder.build()); + } + + private String valueFromPayload(JSONObject payload, String key, String defaultValue) { + JSONObject notification = payload.optJSONObject("notification"); + if (notification != null && !TextUtils.isEmpty(notification.optString(key, ""))) { + return notification.optString(key); + } + JSONObject data = payload.optJSONObject("data"); + if (data != null && !TextUtils.isEmpty(data.optString(key, ""))) { + return data.optString(key); + } + return defaultValue; + } + + private String appLabel() { + ApplicationInfo applicationInfo = getApplicationInfo(); + CharSequence label = getPackageManager().getApplicationLabel(applicationInfo); + return label == null ? getPackageName() : label.toString(); + } + + private void createNotificationChannel(NotificationManager notificationManager) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "Application notifications", + NotificationManager.IMPORTANCE_DEFAULT + ); + notificationManager.createNotificationChannel(channel); + } + + private boolean isAppInForeground() { + ActivityManager activityManager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + return false; + } + List processes = activityManager.getRunningAppProcesses(); + if (processes == null) { + return false; + } + String packageName = getPackageName(); + for (ActivityManager.RunningAppProcessInfo process : processes) { + if (process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + && packageName.equals(process.processName)) { + return true; + } + } + return false; + } +} diff --git a/src/android/BluememeUtils.java b/src/android/BluememeUtils.java new file mode 100644 index 0000000..a8c469b --- /dev/null +++ b/src/android/BluememeUtils.java @@ -0,0 +1,1186 @@ +package com.bluememe.utils; + +import android.Manifest; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.KeyguardManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.hardware.biometrics.BiometricManager; +import android.hardware.biometrics.BiometricPrompt; +import android.hardware.fingerprint.FingerprintManager; +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.os.Bundle; +import android.os.CancellationSignal; +import android.text.TextUtils; +import android.util.Log; + +import com.google.android.gms.tasks.OnCompleteListener; +import com.google.android.gms.tasks.Task; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; + +import org.apache.cordova.CallbackContext; +import org.apache.cordova.CordovaPlugin; +import org.apache.cordova.PermissionHelper; +import org.apache.cordova.PluginResult; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Executor; + +public class BluememeUtils extends CordovaPlugin { + private static final String TAG = "BluememeUtils"; + private static final String PREFS = "bluememe_utils"; + private static final String KEY_FCM_TOKEN = "fcm_token"; + private static final String KEY_INITIAL_NOTIFICATION = "initial_notification"; + private static final String ACTION_SUFFIX = ".BLUEMEME_UTILS_EVENT"; + private static final String EXTRA_EVENT = "event"; + private static final String EXTRA_PAYLOAD = "bluememe_payload"; + private static final int REQ_WIFI_PERMISSION = 8101; + private static final int REQ_NOTIFICATION_PERMISSION = 8102; + + private CallbackContext wifiPermissionCallback; + private CallbackContext notificationPermissionCallback; + private CallbackContext notificationCallback; + private ConnectivityManager.NetworkCallback networkCallback; + private BroadcastReceiver eventReceiver; + + @Override + protected void pluginInitialize() { + ensureFirebaseInitialized(false); + registerEventReceiver(); + captureLaunchIntent(cordova.getActivity().getIntent()); + } + + @Override + public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { + if ("saveWifi".equals(action)) { + saveWifi(args.optJSONObject(0), callbackContext); + return true; + } + if ("connectWifi".equals(action)) { + connectWifi(args.optJSONObject(0), callbackContext); + return true; + } + if ("disconnectWifi".equals(action)) { + disconnectWifi(callbackContext); + return true; + } + if ("removeWifi".equals(action)) { + removeWifi(args.optJSONObject(0), callbackContext); + return true; + } + if ("requestWifiPermission".equals(action)) { + requestWifiPermission(callbackContext); + return true; + } + if ("isBiometricAvailable".equals(action)) { + isBiometricAvailable(callbackContext); + return true; + } + if ("unlockWithBiometrics".equals(action)) { + unlockWithBiometrics(args.optJSONObject(0), callbackContext); + return true; + } + if ("requestPushPermission".equals(action)) { + requestPushPermission(callbackContext); + return true; + } + if ("getFirebaseToken".equals(action)) { + getFirebaseToken(callbackContext); + return true; + } + if ("subscribeToTopic".equals(action)) { + subscribeToTopic(args.optString(0, ""), callbackContext); + return true; + } + if ("unsubscribeFromTopic".equals(action)) { + unsubscribeFromTopic(args.optString(0, ""), callbackContext); + return true; + } + if ("getInitialNotification".equals(action)) { + getInitialNotification(callbackContext); + return true; + } + if ("startNotificationListener".equals(action)) { + startNotificationListener(callbackContext); + return true; + } + if ("stopNotificationListener".equals(action)) { + notificationCallback = null; + callbackContext.success(); + return true; + } + return false; + } + + @Override + public void onNewIntent(Intent intent) { + captureLaunchIntent(intent); + } + + @Override + public void onDestroy() { + unregisterEventReceiver(); + stopNetworkRequest(); + super.onDestroy(); + } + + @Override + public void onRequestPermissionResult(int requestCode, String[] permissions, int[] grantResults) throws JSONException { + if (requestCode == REQ_WIFI_PERMISSION) { + CallbackContext callback = wifiPermissionCallback; + wifiPermissionCallback = null; + if (callback != null) { + callback.success(permissionResult("wifi", allGranted(grantResults), missingWifiPermissions())); + } + return; + } + if (requestCode == REQ_NOTIFICATION_PERMISSION) { + CallbackContext callback = notificationPermissionCallback; + notificationPermissionCallback = null; + if (callback != null) { + callback.success(permissionResult("notification", allGranted(grantResults), new JSONArray())); + } + } + } + + private void saveWifi(JSONObject options, CallbackContext callbackContext) { + JSONObject normalized = normalizeWifiOptions(options); + String validationError = validateWifiOptions(normalized); + if (validationError != null) { + sendError(callbackContext, "INVALID_WIFI_OPTIONS", validationError); + return; + } + if (!hasWifiPermissions()) { + sendError(callbackContext, "PERMISSION_REQUIRED", "Call requestWifiPermission before saving a WiFi network."); + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveWifiSuggestion(normalized, callbackContext); + } else { + saveLegacyWifi(normalized, callbackContext); + } + } + + private void connectWifi(JSONObject options, CallbackContext callbackContext) { + JSONObject normalized = normalizeWifiOptions(options); + String validationError = validateWifiOptions(normalized); + if (validationError != null) { + sendError(callbackContext, "INVALID_WIFI_OPTIONS", validationError); + return; + } + if (!hasWifiPermissions()) { + sendError(callbackContext, "PERMISSION_REQUIRED", "Call requestWifiPermission before connecting to a WiFi network."); + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + connectWithSpecifier(normalized, callbackContext); + } else { + connectLegacyWifi(normalized, callbackContext); + } + } + + private void removeWifi(JSONObject options, CallbackContext callbackContext) { + JSONObject normalized = normalizeWifiOptions(options); + String ssid = normalized.optString("ssid", ""); + if (TextUtils.isEmpty(ssid)) { + sendError(callbackContext, "INVALID_WIFI_OPTIONS", "ssid is required."); + return; + } + if (!hasWifiPermissions()) { + sendError(callbackContext, "PERMISSION_REQUIRED", "Call requestWifiPermission before removing a WiFi network."); + return; + } + + WifiManager wifiManager = wifiManager(); + if (wifiManager == null) { + sendError(callbackContext, "WIFI_UNAVAILABLE", "WiFi manager is not available."); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + try { + int status = wifiManager.removeNetworkSuggestions(Collections.singletonList(buildSuggestion(normalized))); + JSONObject result = new JSONObject(); + result.put("ssid", ssid); + result.put("platformMode", "suggestion"); + result.put("status", status); + result.put("removed", status == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS); + callbackContext.success(result); + } catch (Exception ex) { + sendError(callbackContext, "WIFI_REMOVE_FAILED", ex.getMessage()); + } + return; + } + + int networkId = findLegacyNetworkId(wifiManager, ssid); + if (networkId < 0) { + try { + JSONObject result = new JSONObject(); + result.put("ssid", ssid); + result.put("removed", false); + result.put("reason", "not_found"); + callbackContext.success(result); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + return; + } + + boolean removed = wifiManager.removeNetwork(networkId); + wifiManager.saveConfiguration(); + try { + JSONObject result = new JSONObject(); + result.put("ssid", ssid); + result.put("networkId", networkId); + result.put("removed", removed); + callbackContext.success(result); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + } + + private void disconnectWifi(CallbackContext callbackContext) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + stopNetworkRequest(); + try { + JSONObject result = new JSONObject(); + result.put("disconnected", true); + result.put("platformMode", "specifier"); + callbackContext.success(result); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + return; + } + + WifiManager wifiManager = wifiManager(); + if (wifiManager == null) { + sendError(callbackContext, "WIFI_UNAVAILABLE", "WiFi manager is not available."); + return; + } + boolean disconnected = wifiManager.disconnect(); + try { + JSONObject result = new JSONObject(); + result.put("disconnected", disconnected); + result.put("platformMode", "legacy"); + callbackContext.success(result); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + } + + private void requestWifiPermission(CallbackContext callbackContext) { + JSONArray missing = missingWifiPermissions(); + if (missing.length() == 0) { + try { + callbackContext.success(permissionResult("wifi", true, missing)); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + return; + } + wifiPermissionCallback = callbackContext; + PermissionHelper.requestPermissions(this, REQ_WIFI_PERMISSION, jsonArrayToStringArray(missing)); + } + + private void isBiometricAvailable(CallbackContext callbackContext) { + try { + callbackContext.success(biometricAvailability()); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + } + + private void unlockWithBiometrics(JSONObject options, CallbackContext callbackContext) { + try { + JSONObject availability = biometricAvailability(); + if (!availability.optBoolean("available")) { + sendError(callbackContext, availability.optString("code", "BIOMETRIC_UNAVAILABLE"), availability.optString("message", "Biometric authentication is unavailable.")); + return; + } + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + return; + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + sendError(callbackContext, "UNSUPPORTED_ANDROID_VERSION", "Biometric unlock requires Android 9.0 or newer in this plugin."); + return; + } + + final JSONObject safeOptions = options == null ? new JSONObject() : options; + cordova.getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + showBiometricPrompt(safeOptions, callbackContext); + } + }); + } + + private void requestPushPermission(CallbackContext callbackContext) { + if (Build.VERSION.SDK_INT < 33 || PermissionHelper.hasPermission(this, Manifest.permission.POST_NOTIFICATIONS)) { + try { + callbackContext.success(permissionResult("notification", true, new JSONArray())); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + return; + } + notificationPermissionCallback = callbackContext; + PermissionHelper.requestPermission(this, REQ_NOTIFICATION_PERMISSION, Manifest.permission.POST_NOTIFICATIONS); + } + + private void getFirebaseToken(final CallbackContext callbackContext) { + if (!ensureFirebaseInitialized(true)) { + sendError(callbackContext, "FIREBASE_NOT_CONFIGURED", "Firebase is not configured. Add Google services files or pass Firebase plugin preferences."); + return; + } + + FirebaseMessaging.getInstance().getToken().addOnCompleteListener(new OnCompleteListener() { + @Override + public void onComplete(Task task) { + if (!task.isSuccessful()) { + Exception exception = task.getException(); + sendError(callbackContext, "FCM_TOKEN_FAILED", exception == null ? "Failed to get Firebase token." : exception.getMessage()); + return; + } + String token = task.getResult(); + saveToken(cordova.getActivity(), token); + try { + JSONObject result = new JSONObject(); + result.put("token", token); + callbackContext.success(result); + + JSONObject event = new JSONObject(); + event.put("type", "token"); + event.put("token", token); + sendEventToJs(event); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + } + }); + } + + private void subscribeToTopic(String topic, final CallbackContext callbackContext) { + if (TextUtils.isEmpty(topic)) { + sendError(callbackContext, "INVALID_TOPIC", "topic is required."); + return; + } + if (!ensureFirebaseInitialized(true)) { + sendError(callbackContext, "FIREBASE_NOT_CONFIGURED", "Firebase is not configured."); + return; + } + FirebaseMessaging.getInstance().subscribeToTopic(topic).addOnCompleteListener(new OnCompleteListener() { + @Override + public void onComplete(Task task) { + completeFirebaseTask(task, callbackContext); + } + }); + } + + private void unsubscribeFromTopic(String topic, final CallbackContext callbackContext) { + if (TextUtils.isEmpty(topic)) { + sendError(callbackContext, "INVALID_TOPIC", "topic is required."); + return; + } + if (!ensureFirebaseInitialized(true)) { + sendError(callbackContext, "FIREBASE_NOT_CONFIGURED", "Firebase is not configured."); + return; + } + FirebaseMessaging.getInstance().unsubscribeFromTopic(topic).addOnCompleteListener(new OnCompleteListener() { + @Override + public void onComplete(Task task) { + completeFirebaseTask(task, callbackContext); + } + }); + } + + private void getInitialNotification(CallbackContext callbackContext) { + SharedPreferences preferences = prefs(cordova.getActivity()); + String value = preferences.getString(KEY_INITIAL_NOTIFICATION, null); + preferences.edit().remove(KEY_INITIAL_NOTIFICATION).apply(); + if (TextUtils.isEmpty(value)) { + callbackContext.success((JSONObject) null); + return; + } + try { + callbackContext.success(new JSONObject(value)); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + } + + private void startNotificationListener(CallbackContext callbackContext) { + notificationCallback = callbackContext; + PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + + String token = prefs(cordova.getActivity()).getString(KEY_FCM_TOKEN, null); + if (!TextUtils.isEmpty(token)) { + try { + JSONObject event = new JSONObject(); + event.put("type", "token"); + event.put("token", token); + sendEventToJs(event); + } catch (JSONException ignored) { + Log.w(TAG, "Failed to dispatch cached FCM token."); + } + } + } + + @TargetApi(Build.VERSION_CODES.Q) + private void saveWifiSuggestion(JSONObject options, CallbackContext callbackContext) { + WifiManager wifiManager = wifiManager(); + if (wifiManager == null) { + sendError(callbackContext, "WIFI_UNAVAILABLE", "WiFi manager is not available."); + return; + } + + try { + WifiNetworkSuggestion suggestion = buildSuggestion(options); + int status = wifiManager.addNetworkSuggestions(Collections.singletonList(suggestion)); + if (status != WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS + && status != WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_ADD_DUPLICATE) { + sendError(callbackContext, "WIFI_SAVE_FAILED", "addNetworkSuggestions returned status " + status + "."); + return; + } + + JSONObject result = new JSONObject(); + result.put("ssid", options.optString("ssid")); + result.put("platformMode", "suggestion"); + result.put("saved", true); + result.put("status", status); + result.put("requiresUserApproval", true); + callbackContext.success(result); + } catch (Exception ex) { + sendError(callbackContext, "WIFI_SAVE_FAILED", ex.getMessage()); + } + } + + private void saveLegacyWifi(JSONObject options, CallbackContext callbackContext) { + WifiManager wifiManager = wifiManager(); + if (wifiManager == null) { + sendError(callbackContext, "WIFI_UNAVAILABLE", "WiFi manager is not available."); + return; + } + + int networkId = addOrUpdateLegacyNetwork(wifiManager, options); + if (networkId < 0) { + sendError(callbackContext, "WIFI_SAVE_FAILED", "Unable to add WiFi network configuration."); + return; + } + wifiManager.saveConfiguration(); + + try { + JSONObject result = new JSONObject(); + result.put("ssid", options.optString("ssid")); + result.put("platformMode", "legacy"); + result.put("networkId", networkId); + result.put("saved", true); + callbackContext.success(result); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + } + + @TargetApi(Build.VERSION_CODES.Q) + private void connectWithSpecifier(final JSONObject options, final CallbackContext callbackContext) { + final ConnectivityManager connectivityManager = connectivityManager(); + if (connectivityManager == null) { + sendError(callbackContext, "NETWORK_UNAVAILABLE", "Connectivity manager is not available."); + return; + } + + try { + stopNetworkRequest(); + WifiNetworkSpecifier specifier = buildSpecifier(options); + NetworkRequest request = new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .setNetworkSpecifier(specifier) + .build(); + + networkCallback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.bindProcessToNetwork(network); + } else { + ConnectivityManager.setProcessDefaultNetwork(network); + } + try { + JSONObject result = new JSONObject(); + result.put("ssid", options.optString("ssid")); + result.put("platformMode", "specifier"); + result.put("connected", true); + result.put("temporaryConnection", true); + callbackContext.success(result); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + } + + @Override + public void onUnavailable() { + sendError(callbackContext, "WIFI_CONNECT_FAILED", "Requested WiFi network is unavailable or the user rejected the connection."); + } + + @Override + public void onLost(Network network) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.bindProcessToNetwork(null); + } else { + ConnectivityManager.setProcessDefaultNetwork(null); + } + } + }; + + connectivityManager.requestNetwork(request, networkCallback); + } catch (Exception ex) { + sendError(callbackContext, "WIFI_CONNECT_FAILED", ex.getMessage()); + } + } + + private void connectLegacyWifi(JSONObject options, CallbackContext callbackContext) { + WifiManager wifiManager = wifiManager(); + if (wifiManager == null) { + sendError(callbackContext, "WIFI_UNAVAILABLE", "WiFi manager is not available."); + return; + } + + int networkId = addOrUpdateLegacyNetwork(wifiManager, options); + if (networkId < 0) { + sendError(callbackContext, "WIFI_CONNECT_FAILED", "Unable to add WiFi network configuration."); + return; + } + + boolean enabled = wifiManager.enableNetwork(networkId, true); + boolean reconnecting = wifiManager.reconnect(); + + try { + JSONObject result = new JSONObject(); + result.put("ssid", options.optString("ssid")); + result.put("platformMode", "legacy"); + result.put("networkId", networkId); + result.put("enabled", enabled); + result.put("reconnecting", reconnecting); + callbackContext.success(result); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + } + + private void stopNetworkRequest() { + ConnectivityManager connectivityManager = connectivityManager(); + if (connectivityManager != null && networkCallback != null) { + try { + connectivityManager.unregisterNetworkCallback(networkCallback); + } catch (RuntimeException ignored) { + Log.d(TAG, "Network callback was already unregistered."); + } + } + if (connectivityManager != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.bindProcessToNetwork(null); + } else { + ConnectivityManager.setProcessDefaultNetwork(null); + } + } + networkCallback = null; + } + + @TargetApi(Build.VERSION_CODES.Q) + private WifiNetworkSuggestion buildSuggestion(JSONObject options) { + WifiNetworkSuggestion.Builder builder = new WifiNetworkSuggestion.Builder() + .setSsid(options.optString("ssid")); + if (options.optBoolean("hiddenSsid", false)) { + builder.setIsHiddenSsid(true); + } + applySuggestionSecurity(builder, options); + return builder.build(); + } + + @TargetApi(Build.VERSION_CODES.Q) + private WifiNetworkSpecifier buildSpecifier(JSONObject options) { + WifiNetworkSpecifier.Builder builder = new WifiNetworkSpecifier.Builder() + .setSsid(options.optString("ssid")); + if (options.optBoolean("hiddenSsid", false)) { + builder.setIsHiddenSsid(true); + } + String bssid = options.optString("bssid", ""); + if (!TextUtils.isEmpty(bssid)) { + builder.setBssid(android.net.MacAddress.fromString(bssid)); + } + applySpecifierSecurity(builder, options); + return builder.build(); + } + + @TargetApi(Build.VERSION_CODES.Q) + private void applySuggestionSecurity(WifiNetworkSuggestion.Builder builder, JSONObject options) { + String security = options.optString("security", "").toLowerCase(Locale.US); + String password = options.optString("password", ""); + if (TextUtils.isEmpty(password) || "open".equals(security)) { + return; + } + if ("wpa3".equals(security) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setWpa3Passphrase(password); + return; + } + builder.setWpa2Passphrase(password); + } + + @TargetApi(Build.VERSION_CODES.Q) + private void applySpecifierSecurity(WifiNetworkSpecifier.Builder builder, JSONObject options) { + String security = options.optString("security", "").toLowerCase(Locale.US); + String password = options.optString("password", ""); + if (TextUtils.isEmpty(password) || "open".equals(security)) { + return; + } + if ("wpa3".equals(security) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setWpa3Passphrase(password); + return; + } + builder.setWpa2Passphrase(password); + } + + private int addOrUpdateLegacyNetwork(WifiManager wifiManager, JSONObject options) { + String ssid = options.optString("ssid"); + int existingNetworkId = findLegacyNetworkId(wifiManager, ssid); + WifiConfiguration configuration = buildLegacyConfiguration(options); + if (existingNetworkId >= 0) { + configuration.networkId = existingNetworkId; + int updated = wifiManager.updateNetwork(configuration); + return updated >= 0 ? updated : existingNetworkId; + } + return wifiManager.addNetwork(configuration); + } + + private WifiConfiguration buildLegacyConfiguration(JSONObject options) { + String security = options.optString("security", "").toLowerCase(Locale.US); + String password = options.optString("password", ""); + + WifiConfiguration configuration = new WifiConfiguration(); + configuration.SSID = quote(options.optString("ssid")); + configuration.hiddenSSID = options.optBoolean("hiddenSsid", false); + configuration.status = WifiConfiguration.Status.ENABLED; + + if (TextUtils.isEmpty(password) || "open".equals(security)) { + configuration.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE); + return configuration; + } + + if ("wep".equals(security)) { + configuration.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE); + configuration.allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN); + configuration.allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.SHARED); + configuration.wepKeys[0] = quote(password); + configuration.wepTxKeyIndex = 0; + return configuration; + } + + configuration.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK); + configuration.preSharedKey = quote(password); + return configuration; + } + + private int findLegacyNetworkId(WifiManager wifiManager, String ssid) { + try { + List networks = wifiManager.getConfiguredNetworks(); + if (networks == null) { + return -1; + } + String quotedSsid = quote(ssid); + for (WifiConfiguration network : networks) { + if (quotedSsid.equals(network.SSID)) { + return network.networkId; + } + } + } catch (SecurityException ex) { + Log.w(TAG, "Missing permission to read configured networks.", ex); + } + return -1; + } + + private JSONObject normalizeWifiOptions(JSONObject options) { + JSONObject normalized = options == null ? new JSONObject() : options; + try { + if (!normalized.has("security")) { + normalized.put("security", TextUtils.isEmpty(normalized.optString("password", "")) ? "open" : "wpa2"); + } + } catch (JSONException ignored) { + Log.w(TAG, "Unable to normalize WiFi options."); + } + return normalized; + } + + private String validateWifiOptions(JSONObject options) { + if (TextUtils.isEmpty(options.optString("ssid", ""))) { + return "ssid is required."; + } + String security = options.optString("security", "open").toLowerCase(Locale.US); + if (!"open".equals(security) && TextUtils.isEmpty(options.optString("password", ""))) { + return "password is required for secured WiFi networks."; + } + return null; + } + + private boolean hasWifiPermissions() { + return missingWifiPermissions().length() == 0; + } + + private JSONArray missingWifiPermissions() { + JSONArray permissions = new JSONArray(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && !PermissionHelper.hasPermission(this, Manifest.permission.NEARBY_WIFI_DEVICES)) { + permissions.put(Manifest.permission.NEARBY_WIFI_DEVICES); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && !PermissionHelper.hasPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)) { + permissions.put(Manifest.permission.ACCESS_FINE_LOCATION); + } + return permissions; + } + + private JSONObject biometricAvailability() throws JSONException { + JSONObject result = new JSONObject(); + result.put("available", false); + result.put("biometryType", "none"); + + Context context = cordova.getActivity(); + KeyguardManager keyguardManager = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); + if (keyguardManager == null || !keyguardManager.isKeyguardSecure()) { + result.put("code", "NOT_ENROLLED"); + result.put("message", "No secure lock screen or biometric credential is enrolled."); + return result; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + BiometricManager biometricManager = (BiometricManager) context.getSystemService(BiometricManager.class); + int code = biometricManager == null + ? BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE + : biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK); + result.put("platformCode", code); + if (code == BiometricManager.BIOMETRIC_SUCCESS) { + result.put("available", true); + result.put("biometryType", detectAndroidBiometryType(context)); + result.put("code", "OK"); + result.put("message", "Biometric authentication is available."); + } else { + result.put("code", "BIOMETRIC_UNAVAILABLE"); + result.put("message", "Biometric authentication is unavailable. Android code: " + code); + } + return result; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + BiometricManager biometricManager = (BiometricManager) context.getSystemService(BiometricManager.class); + int code = biometricManager == null + ? BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE + : biometricManager.canAuthenticate(); + result.put("platformCode", code); + if (code == BiometricManager.BIOMETRIC_SUCCESS) { + result.put("available", true); + result.put("biometryType", detectAndroidBiometryType(context)); + result.put("code", "OK"); + result.put("message", "Biometric authentication is available."); + } else { + result.put("code", "BIOMETRIC_UNAVAILABLE"); + result.put("message", "Biometric authentication is unavailable. Android code: " + code); + } + return result; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (!hasBiometricFeature(context)) { + result.put("code", "BIOMETRIC_UNAVAILABLE"); + result.put("message", "No biometric hardware is available."); + return result; + } + result.put("available", true); + result.put("biometryType", detectAndroidBiometryType(context)); + result.put("code", "OK"); + result.put("message", "Biometric authentication is available."); + return result; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + FingerprintManager fingerprintManager = (FingerprintManager) context.getSystemService(Context.FINGERPRINT_SERVICE); + if (fingerprintManager != null && fingerprintManager.isHardwareDetected() && fingerprintManager.hasEnrolledFingerprints()) { + result.put("available", true); + result.put("biometryType", "fingerprint"); + result.put("code", "OK"); + result.put("message", "Fingerprint authentication is available."); + } else { + result.put("code", "BIOMETRIC_UNAVAILABLE"); + result.put("message", "Fingerprint authentication is unavailable."); + } + return result; + } + + result.put("code", "UNSUPPORTED_ANDROID_VERSION"); + result.put("message", "Biometric authentication is not supported on this Android version."); + return result; + } + + private boolean hasBiometricFeature(Context context) { + PackageManager packageManager = context.getPackageManager(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && (packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) + || packageManager.hasSystemFeature(PackageManager.FEATURE_IRIS))) { + return true; + } + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT); + } + + @TargetApi(Build.VERSION_CODES.P) + private void showBiometricPrompt(JSONObject options, final CallbackContext callbackContext) { + Activity activity = cordova.getActivity(); + Executor executor = activity.getMainExecutor(); + String title = options.optString("title", "Unlock"); + String subtitle = options.optString("subtitle", ""); + String description = options.optString("description", ""); + String negativeButton = options.optString("cancelText", "Cancel"); + boolean allowDeviceCredential = options.optBoolean("allowDeviceCredential", true); + + BiometricPrompt.Builder builder = new BiometricPrompt.Builder(activity).setTitle(title); + if (!TextUtils.isEmpty(subtitle)) { + builder.setSubtitle(subtitle); + } + if (!TextUtils.isEmpty(description)) { + builder.setDescription(description); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && allowDeviceCredential) { + builder.setDeviceCredentialAllowed(true); + } else { + builder.setNegativeButton(negativeButton, executor, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + sendError(callbackContext, "AUTHENTICATION_CANCELLED", "User cancelled biometric authentication."); + } + }); + } + + BiometricPrompt prompt = builder.build(); + CancellationSignal cancellationSignal = new CancellationSignal(); + prompt.authenticate(cancellationSignal, executor, new BiometricPrompt.AuthenticationCallback() { + @Override + public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult authenticationResult) { + try { + JSONObject result = new JSONObject(); + result.put("unlocked", true); + result.put("biometryType", detectAndroidBiometryType(cordova.getActivity())); + callbackContext.success(result); + + JSONObject event = new JSONObject(result.toString()); + event.put("type", "unlock"); + sendEventToJs(event); + } catch (JSONException ex) { + sendError(callbackContext, "JSON_ERROR", ex.getMessage()); + } + } + + @Override + public void onAuthenticationError(int errorCode, CharSequence errString) { + sendError(callbackContext, "AUTHENTICATION_ERROR", String.valueOf(errString)); + } + + @Override + public void onAuthenticationFailed() { + Log.d(TAG, "Biometric authentication attempt failed."); + } + }); + } + + private String detectAndroidBiometryType(Context context) { + PackageManager packageManager = context.getPackageManager(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (packageManager.hasSystemFeature(PackageManager.FEATURE_FACE)) { + return "face"; + } + if (packageManager.hasSystemFeature(PackageManager.FEATURE_IRIS)) { + return "iris"; + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) { + return "fingerprint"; + } + return "biometric"; + } + + private void completeFirebaseTask(Task task, CallbackContext callbackContext) { + if (!task.isSuccessful()) { + Exception exception = task.getException(); + sendError(callbackContext, "FIREBASE_TASK_FAILED", exception == null ? "Firebase task failed." : exception.getMessage()); + return; + } + callbackContext.success(); + } + + private boolean ensureFirebaseInitialized(boolean logMissingConfig) { + Context context = cordova.getActivity().getApplicationContext(); + if (!FirebaseApp.getApps(context).isEmpty()) { + return true; + } + + try { + ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + Bundle metaData = appInfo.metaData; + String appId = metadataString(metaData, "bluememe.firebase.app_id"); + String apiKey = metadataString(metaData, "bluememe.firebase.api_key"); + String projectId = metadataString(metaData, "bluememe.firebase.project_id"); + String senderId = metadataString(metaData, "bluememe.firebase.sender_id"); + String storageBucket = metadataString(metaData, "bluememe.firebase.storage_bucket"); + + if (TextUtils.isEmpty(appId) || TextUtils.isEmpty(apiKey) || TextUtils.isEmpty(senderId)) { + if (logMissingConfig) { + Log.w(TAG, "Firebase options are missing."); + } + return false; + } + + FirebaseOptions.Builder builder = new FirebaseOptions.Builder() + .setApplicationId(appId) + .setApiKey(apiKey) + .setGcmSenderId(senderId); + if (!TextUtils.isEmpty(projectId)) { + builder.setProjectId(projectId); + } + if (!TextUtils.isEmpty(storageBucket)) { + builder.setStorageBucket(storageBucket); + } + FirebaseApp.initializeApp(context, builder.build()); + return true; + } catch (Exception ex) { + Log.w(TAG, "Unable to initialize Firebase.", ex); + return false; + } + } + + private void registerEventReceiver() { + if (eventReceiver != null) { + return; + } + final Context context = cordova.getActivity().getApplicationContext(); + eventReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context receiverContext, Intent intent) { + String eventJson = intent.getStringExtra(EXTRA_EVENT); + if (TextUtils.isEmpty(eventJson)) { + return; + } + try { + sendEventToJs(new JSONObject(eventJson)); + } catch (JSONException ex) { + Log.w(TAG, "Invalid event payload.", ex); + } + } + }; + IntentFilter filter = new IntentFilter(eventAction(context)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(eventReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + context.registerReceiver(eventReceiver, filter); + } + } + + private void unregisterEventReceiver() { + if (eventReceiver == null) { + return; + } + try { + cordova.getActivity().getApplicationContext().unregisterReceiver(eventReceiver); + } catch (RuntimeException ignored) { + Log.d(TAG, "Event receiver was already unregistered."); + } + eventReceiver = null; + } + + private void captureLaunchIntent(Intent intent) { + JSONObject notification = notificationFromIntent(intent); + if (notification == null) { + return; + } + saveInitialNotification(cordova.getActivity(), notification); + try { + JSONObject event = new JSONObject(); + event.put("type", "notificationOpened"); + event.put("notification", notification); + sendEventToJs(event); + } catch (JSONException ex) { + Log.w(TAG, "Unable to dispatch launch notification.", ex); + } + } + + private JSONObject notificationFromIntent(Intent intent) { + if (intent == null || intent.getExtras() == null) { + return null; + } + Bundle extras = intent.getExtras(); + String payload = extras.getString(EXTRA_PAYLOAD); + if (!TextUtils.isEmpty(payload)) { + try { + return new JSONObject(payload); + } catch (JSONException ex) { + Log.w(TAG, "Invalid notification payload extra.", ex); + } + } + + boolean looksLikeFcm = extras.containsKey("google.message_id") || extras.containsKey("gcm.message_id"); + if (!looksLikeFcm) { + return null; + } + return bundleToJson(extras); + } + + private void sendEventToJs(JSONObject event) { + if (notificationCallback == null) { + return; + } + PluginResult pluginResult = new PluginResult(PluginResult.Status.OK, event); + pluginResult.setKeepCallback(true); + notificationCallback.sendPluginResult(pluginResult); + } + + private WifiManager wifiManager() { + return (WifiManager) cordova.getActivity().getApplicationContext().getSystemService(Context.WIFI_SERVICE); + } + + private ConnectivityManager connectivityManager() { + return (ConnectivityManager) cordova.getActivity().getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); + } + + private static JSONObject permissionResult(String type, boolean granted, JSONArray missing) throws JSONException { + JSONObject result = new JSONObject(); + result.put("type", type); + result.put("granted", granted); + result.put("missing", missing); + return result; + } + + private boolean allGranted(int[] grantResults) { + if (grantResults == null || grantResults.length == 0) { + return false; + } + for (int grantResult : grantResults) { + if (grantResult != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } + + private static String[] jsonArrayToStringArray(JSONArray array) { + List values = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + String value = array.optString(i, null); + if (!TextUtils.isEmpty(value)) { + values.add(value); + } + } + return values.toArray(new String[0]); + } + + private static String quote(String value) { + if (value == null) { + return "\"\""; + } + if (value.startsWith("\"") && value.endsWith("\"")) { + return value; + } + return "\"" + value + "\""; + } + + private static String metadataString(Bundle bundle, String key) { + if (bundle == null || !bundle.containsKey(key)) { + return ""; + } + Object value = bundle.get(key); + return value == null ? "" : String.valueOf(value); + } + + static String eventAction(Context context) { + return context.getPackageName() + ACTION_SUFFIX; + } + + static void publishEvent(Context context, JSONObject event) { + Intent intent = new Intent(eventAction(context)); + intent.setPackage(context.getPackageName()); + intent.putExtra(EXTRA_EVENT, event.toString()); + context.sendBroadcast(intent); + } + + static void saveToken(Context context, String token) { + if (TextUtils.isEmpty(token)) { + return; + } + prefs(context).edit().putString(KEY_FCM_TOKEN, token).apply(); + } + + static void saveInitialNotification(Context context, JSONObject notification) { + if (notification == null) { + return; + } + prefs(context).edit().putString(KEY_INITIAL_NOTIFICATION, notification.toString()).apply(); + } + + static SharedPreferences prefs(Context context) { + return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + static JSONObject bundleToJson(Bundle bundle) { + JSONObject json = new JSONObject(); + if (bundle == null) { + return json; + } + for (String key : bundle.keySet()) { + try { + Object value = bundle.get(key); + if (value instanceof Bundle) { + json.put(key, bundleToJson((Bundle) value)); + } else { + json.put(key, value == null ? JSONObject.NULL : String.valueOf(value)); + } + } catch (JSONException ex) { + Log.w(TAG, "Unable to add bundle value for key " + key + ".", ex); + } + } + return json; + } + + private void sendError(CallbackContext callbackContext, String code, String message) { + try { + JSONObject error = new JSONObject(); + error.put("code", code); + error.put("message", message == null ? "" : message); + callbackContext.error(error); + } catch (JSONException ex) { + callbackContext.error(code + ": " + message); + } + } +} diff --git a/src/ios/CDVBluememeUtils.h b/src/ios/CDVBluememeUtils.h new file mode 100644 index 0000000..1708693 --- /dev/null +++ b/src/ios/CDVBluememeUtils.h @@ -0,0 +1,6 @@ +#import +#import +#import + +@interface CDVBluememeUtils : CDVPlugin +@end diff --git a/src/ios/CDVBluememeUtils.m b/src/ios/CDVBluememeUtils.m new file mode 100644 index 0000000..b1b2401 --- /dev/null +++ b/src/ios/CDVBluememeUtils.m @@ -0,0 +1,490 @@ +#import "CDVBluememeUtils.h" + +#import +#import +#import +#import + +@interface CDVBluememeUtils () +@property (nonatomic, copy) NSString *notificationCallbackId; +@property (nonatomic, strong) NSDictionary *initialNotification; +@property (nonatomic, weak) id previousNotificationDelegate; +@end + +@implementation CDVBluememeUtils + +- (void)pluginInitialize +{ + if ([self ensureFirebaseConfigured:nil]) { + [FIRMessaging messaging].delegate = self; + } + + if (@available(iOS 10.0, *)) { + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + self.previousNotificationDelegate = center.delegate; + center.delegate = self; + } +} + +- (void)saveWifi:(CDVInvokedUrlCommand *)command +{ + [self applyWifiCommand:command defaultJoinOnce:NO]; +} + +- (void)connectWifi:(CDVInvokedUrlCommand *)command +{ + [self applyWifiCommand:command defaultJoinOnce:YES]; +} + +- (void)removeWifi:(CDVInvokedUrlCommand *)command +{ + NSDictionary *options = [self firstDictionaryArgument:command]; + NSString *ssid = [self stringFromDictionary:options key:@"ssid"]; + if (ssid.length == 0) { + [self sendErrorForCommand:command code:@"INVALID_WIFI_OPTIONS" message:@"ssid is required."]; + return; + } + + if (@available(iOS 11.0, *)) { + [[NEHotspotConfigurationManager sharedManager] removeConfigurationForSSID:ssid]; + [self sendSuccessForCommand:command dictionary:@{@"ssid": ssid, @"removed": @YES}]; + } else { + [self sendErrorForCommand:command code:@"UNSUPPORTED_IOS_VERSION" message:@"WiFi configuration requires iOS 11 or newer."]; + } +} + +- (void)disconnectWifi:(CDVInvokedUrlCommand *)command +{ + [self sendErrorForCommand:command code:@"UNSUPPORTED_PLATFORM_ACTION" message:@"iOS does not expose a public API for disconnecting WiFi from third-party apps."]; +} + +- (void)requestWifiPermission:(CDVInvokedUrlCommand *)command +{ + [self sendSuccessForCommand:command dictionary:@{@"type": @"wifi", @"granted": @YES, @"missing": @[]}]; +} + +- (void)isBiometricAvailable:(CDVInvokedUrlCommand *)command +{ + [self sendSuccessForCommand:command dictionary:[self biometricAvailability]]; +} + +- (void)unlockWithBiometrics:(CDVInvokedUrlCommand *)command +{ + NSDictionary *options = [self firstDictionaryArgument:command]; + LAContext *context = [[LAContext alloc] init]; + NSString *fallbackTitle = [self stringFromDictionary:options key:@"fallbackTitle"]; + NSString *cancelTitle = [self stringFromDictionary:options key:@"cancelTitle"]; + if (fallbackTitle.length > 0) { + context.localizedFallbackTitle = fallbackTitle; + } + if (cancelTitle.length > 0) { + context.localizedCancelTitle = cancelTitle; + } + + BOOL allowPasscode = [self boolFromDictionary:options key:@"allowDeviceCredential" defaultValue:YES]; + LAPolicy policy = allowPasscode ? LAPolicyDeviceOwnerAuthentication : LAPolicyDeviceOwnerAuthenticationWithBiometrics; + NSError *availabilityError = nil; + if (![context canEvaluatePolicy:policy error:&availabilityError]) { + [self sendErrorForCommand:command code:@"BIOMETRIC_UNAVAILABLE" message:(availabilityError.localizedDescription ? availabilityError.localizedDescription : @"Biometric authentication is unavailable.")]; + return; + } + + NSString *reason = [self stringFromDictionary:options key:@"reason"]; + if (reason.length == 0) { + reason = @"Unlock"; + } + + [context evaluatePolicy:policy localizedReason:reason reply:^(BOOL success, NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (success) { + NSDictionary *result = @{@"unlocked": @YES, @"biometryType": [self biometryTypeForContext:context]}; + [self sendSuccessForCommand:command dictionary:result]; + NSMutableDictionary *event = [result mutableCopy]; + event[@"type"] = @"unlock"; + [self sendEvent:event]; + } else { + [self sendErrorForCommand:command code:@"AUTHENTICATION_ERROR" message:(error.localizedDescription ? error.localizedDescription : @"Authentication failed.")]; + } + }); + }]; +} + +- (void)requestPushPermission:(CDVInvokedUrlCommand *)command +{ + NSError *firebaseError = nil; + if (![self ensureFirebaseConfigured:&firebaseError]) { + [self sendErrorForCommand:command code:@"FIREBASE_NOT_CONFIGURED" message:(firebaseError.localizedDescription ? firebaseError.localizedDescription : @"Firebase is not configured.")]; + return; + } + [FIRMessaging messaging].delegate = self; + + if (@available(iOS 10.0, *)) { + UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionBadge | UNAuthorizationOptionSound; + [[UNUserNotificationCenter currentNotificationCenter] requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (error != nil) { + [self sendErrorForCommand:command code:@"PUSH_PERMISSION_FAILED" message:error.localizedDescription]; + return; + } + [[UIApplication sharedApplication] registerForRemoteNotifications]; + [self sendSuccessForCommand:command dictionary:@{@"type": @"notification", @"granted": @(granted), @"missing": granted ? @[] : @[@"notification"]}]; + }); + }]; + } else { + UIUserNotificationSettings *settings = [UIUserNotificationSettings settingsForTypes:(UIUserNotificationTypeAlert | UIUserNotificationTypeBadge | UIUserNotificationTypeSound) categories:nil]; + [[UIApplication sharedApplication] registerUserNotificationSettings:settings]; + [[UIApplication sharedApplication] registerForRemoteNotifications]; + [self sendSuccessForCommand:command dictionary:@{@"type": @"notification", @"granted": @YES, @"missing": @[]}]; + } +} + +- (void)getFirebaseToken:(CDVInvokedUrlCommand *)command +{ + NSError *firebaseError = nil; + if (![self ensureFirebaseConfigured:&firebaseError]) { + [self sendErrorForCommand:command code:@"FIREBASE_NOT_CONFIGURED" message:(firebaseError.localizedDescription ? firebaseError.localizedDescription : @"Firebase is not configured.")]; + return; + } + [FIRMessaging messaging].delegate = self; + + [[FIRMessaging messaging] tokenWithCompletion:^(NSString *token, NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (error != nil || token.length == 0) { + [self sendErrorForCommand:command code:@"FCM_TOKEN_FAILED" message:(error.localizedDescription ? error.localizedDescription : @"Failed to get Firebase token.")]; + return; + } + [self sendSuccessForCommand:command dictionary:@{@"token": token}]; + [self sendEvent:@{@"type": @"token", @"token": token}]; + }); + }]; +} + +- (void)subscribeToTopic:(CDVInvokedUrlCommand *)command +{ + NSString *topic = [command.arguments firstObject]; + if (![topic isKindOfClass:[NSString class]] || topic.length == 0) { + [self sendErrorForCommand:command code:@"INVALID_TOPIC" message:@"topic is required."]; + return; + } + NSError *firebaseError = nil; + if (![self ensureFirebaseConfigured:&firebaseError]) { + [self sendErrorForCommand:command code:@"FIREBASE_NOT_CONFIGURED" message:(firebaseError.localizedDescription ? firebaseError.localizedDescription : @"Firebase is not configured.")]; + return; + } + [[FIRMessaging messaging] subscribeToTopic:topic completion:^(NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (error != nil) { + [self sendErrorForCommand:command code:@"FIREBASE_TASK_FAILED" message:error.localizedDescription]; + } else { + [self sendSuccessForCommand:command dictionary:nil]; + } + }); + }]; +} + +- (void)unsubscribeFromTopic:(CDVInvokedUrlCommand *)command +{ + NSString *topic = [command.arguments firstObject]; + if (![topic isKindOfClass:[NSString class]] || topic.length == 0) { + [self sendErrorForCommand:command code:@"INVALID_TOPIC" message:@"topic is required."]; + return; + } + NSError *firebaseError = nil; + if (![self ensureFirebaseConfigured:&firebaseError]) { + [self sendErrorForCommand:command code:@"FIREBASE_NOT_CONFIGURED" message:(firebaseError.localizedDescription ? firebaseError.localizedDescription : @"Firebase is not configured.")]; + return; + } + [[FIRMessaging messaging] unsubscribeFromTopic:topic completion:^(NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (error != nil) { + [self sendErrorForCommand:command code:@"FIREBASE_TASK_FAILED" message:error.localizedDescription]; + } else { + [self sendSuccessForCommand:command dictionary:nil]; + } + }); + }]; +} + +- (void)getInitialNotification:(CDVInvokedUrlCommand *)command +{ + [self sendSuccessForCommand:command dictionary:self.initialNotification]; + self.initialNotification = nil; +} + +- (void)startNotificationListener:(CDVInvokedUrlCommand *)command +{ + self.notificationCallbackId = command.callbackId; + CDVPluginResult *result = [CDVPluginResult resultWithStatus:CDVCommandStatus_NO_RESULT]; + [result setKeepCallbackAsBool:YES]; + [self.commandDelegate sendPluginResult:result callbackId:command.callbackId]; +} + +- (void)stopNotificationListener:(CDVInvokedUrlCommand *)command +{ + self.notificationCallbackId = nil; + [self sendSuccessForCommand:command dictionary:nil]; +} + +- (void)applyWifiCommand:(CDVInvokedUrlCommand *)command defaultJoinOnce:(BOOL)defaultJoinOnce +{ + NSDictionary *options = [self firstDictionaryArgument:command]; + NSString *ssid = [self stringFromDictionary:options key:@"ssid"]; + NSString *password = [self stringFromDictionary:options key:@"password"]; + NSString *security = [[self stringFromDictionary:options key:@"security"] lowercaseString]; + BOOL isWep = [security isEqualToString:@"wep"] || [self boolFromDictionary:options key:@"isWep" defaultValue:NO]; + BOOL joinOnce = [self boolFromDictionary:options key:@"joinOnce" defaultValue:defaultJoinOnce]; + + if (ssid.length == 0) { + [self sendErrorForCommand:command code:@"INVALID_WIFI_OPTIONS" message:@"ssid is required."]; + return; + } + + if (@available(iOS 11.0, *)) { + NEHotspotConfiguration *configuration = nil; + if (password.length == 0 || [security isEqualToString:@"open"]) { + configuration = [[NEHotspotConfiguration alloc] initWithSSID:ssid]; + } else { + configuration = [[NEHotspotConfiguration alloc] initWithSSID:ssid passphrase:password isWEP:isWep]; + } + configuration.joinOnce = joinOnce; + + [[NEHotspotConfigurationManager sharedManager] applyConfiguration:configuration completionHandler:^(NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (error != nil && error.code != NEHotspotConfigurationErrorAlreadyAssociated) { + [self sendErrorForCommand:command code:@"WIFI_CONNECT_FAILED" message:(error.localizedDescription ? error.localizedDescription : @"Unable to apply WiFi configuration.")]; + return; + } + NSDictionary *result = @{ + @"ssid": ssid, + @"platformMode": @"hotspotConfiguration", + @"connected": @YES, + @"saved": @(!joinOnce), + @"alreadyAssociated": @(error != nil && error.code == NEHotspotConfigurationErrorAlreadyAssociated) + }; + [self sendSuccessForCommand:command dictionary:result]; + }); + }]; + } else { + [self sendErrorForCommand:command code:@"UNSUPPORTED_IOS_VERSION" message:@"WiFi configuration requires iOS 11 or newer."]; + } +} + +- (NSDictionary *)biometricAvailability +{ + LAContext *context = [[LAContext alloc] init]; + NSError *error = nil; + BOOL available = [context canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&error]; + NSString *type = [self biometryTypeForContext:context]; + if (available) { + return @{@"available": @YES, @"biometryType": type, @"code": @"OK", @"message": @"Biometric authentication is available."}; + } + + BOOL passcodeAvailable = [context canEvaluatePolicy:LAPolicyDeviceOwnerAuthentication error:nil]; + return @{ + @"available": @NO, + @"biometryType": type, + @"passcodeAvailable": @(passcodeAvailable), + @"code": @"BIOMETRIC_UNAVAILABLE", + @"message": error.localizedDescription ? error.localizedDescription : @"Biometric authentication is unavailable." + }; +} + +- (NSString *)biometryTypeForContext:(LAContext *)context +{ + if (@available(iOS 11.0, *)) { + if (context.biometryType == LABiometryTypeFaceID) { + return @"face"; + } + if (context.biometryType == LABiometryTypeTouchID) { + return @"touch"; + } + if (@available(iOS 11.2, *)) { + if (context.biometryType == LABiometryTypeNone) { + return @"none"; + } + } + return @"biometric"; + } + return @"touch"; +} + +- (BOOL)ensureFirebaseConfigured:(NSError **)error +{ + if ([FIRApp defaultApp] != nil) { + return YES; + } + + NSString *plistPath = [[NSBundle mainBundle] pathForResource:@"GoogleService-Info" ofType:@"plist"]; + if (plistPath.length > 0) { + [FIRApp configure]; + return [FIRApp defaultApp] != nil; + } + + NSString *googleAppId = [self infoStringForKey:@"BLUEMEME_FIREBASE_GOOGLE_APP_ID"]; + NSString *gcmSenderId = [self infoStringForKey:@"BLUEMEME_FIREBASE_GCM_SENDER_ID"]; + NSString *apiKey = [self infoStringForKey:@"BLUEMEME_FIREBASE_API_KEY"]; + NSString *projectId = [self infoStringForKey:@"BLUEMEME_FIREBASE_PROJECT_ID"]; + NSString *storageBucket = [self infoStringForKey:@"BLUEMEME_FIREBASE_STORAGE_BUCKET"]; + + if (googleAppId.length == 0 || gcmSenderId.length == 0) { + if (error != nil) { + *error = [NSError errorWithDomain:@"BluememeUtils" code:1001 userInfo:@{NSLocalizedDescriptionKey: @"Firebase options are missing."}]; + } + return NO; + } + + FIROptions *options = [[FIROptions alloc] initWithGoogleAppID:googleAppId GCMSenderID:gcmSenderId]; + if (apiKey.length > 0) { + options.APIKey = apiKey; + } + if (projectId.length > 0) { + options.projectID = projectId; + } + if (storageBucket.length > 0) { + options.storageBucket = storageBucket; + } + options.bundleID = [[NSBundle mainBundle] bundleIdentifier]; + [FIRApp configureWithOptions:options]; + return [FIRApp defaultApp] != nil; +} + +- (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSString *)fcmToken +{ + if (fcmToken.length == 0) { + return; + } + [self sendEvent:@{@"type": @"token", @"token": fcmToken}]; +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + willPresentNotification:(UNNotification *)notification + withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler API_AVAILABLE(ios(10.0)) +{ + [self sendEvent:@{@"type": @"notification", @"notification": [self payloadFromNotification:notification]}]; + + if (self.previousNotificationDelegate != nil + && self.previousNotificationDelegate != self + && [self.previousNotificationDelegate respondsToSelector:@selector(userNotificationCenter:willPresentNotification:withCompletionHandler:)]) { + [self.previousNotificationDelegate userNotificationCenter:center willPresentNotification:notification withCompletionHandler:completionHandler]; + return; + } + + completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBadge); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center +didReceiveNotificationResponse:(UNNotificationResponse *)response + withCompletionHandler:(void (^)(void))completionHandler API_AVAILABLE(ios(10.0)) +{ + NSDictionary *payload = [self payloadFromNotification:response.notification]; + self.initialNotification = payload; + [self sendEvent:@{@"type": @"notificationOpened", @"notification": payload}]; + + if (self.previousNotificationDelegate != nil + && self.previousNotificationDelegate != self + && [self.previousNotificationDelegate respondsToSelector:@selector(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:)]) { + [self.previousNotificationDelegate userNotificationCenter:center didReceiveNotificationResponse:response withCompletionHandler:completionHandler]; + return; + } + + completionHandler(); +} + +- (NSDictionary *)payloadFromNotification:(UNNotification *)notification API_AVAILABLE(ios(10.0)) +{ + NSMutableDictionary *payload = [NSMutableDictionary dictionary]; + NSDictionary *userInfo = notification.request.content.userInfo ? notification.request.content.userInfo : @{}; + payload[@"data"] = userInfo; + if (notification.request.identifier.length > 0) { + payload[@"identifier"] = notification.request.identifier; + } + + NSMutableDictionary *apsNotification = [NSMutableDictionary dictionary]; + if (notification.request.content.title.length > 0) { + apsNotification[@"title"] = notification.request.content.title; + } + if (notification.request.content.body.length > 0) { + apsNotification[@"body"] = notification.request.content.body; + } + if (notification.request.content.categoryIdentifier.length > 0) { + apsNotification[@"category"] = notification.request.content.categoryIdentifier; + } + if (apsNotification.count > 0) { + payload[@"notification"] = apsNotification; + } + return payload; +} + +- (void)sendEvent:(NSDictionary *)event +{ + if (self.notificationCallbackId.length == 0 || event == nil) { + return; + } + CDVPluginResult *result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:event]; + [result setKeepCallbackAsBool:YES]; + [self.commandDelegate sendPluginResult:result callbackId:self.notificationCallbackId]; +} + +- (NSDictionary *)firstDictionaryArgument:(CDVInvokedUrlCommand *)command +{ + id first = [command.arguments firstObject]; + if ([first isKindOfClass:[NSDictionary class]]) { + return first; + } + return @{}; +} + +- (NSString *)stringFromDictionary:(NSDictionary *)dictionary key:(NSString *)key +{ + id value = dictionary[key]; + if ([value isKindOfClass:[NSString class]]) { + return value; + } + if ([value respondsToSelector:@selector(stringValue)]) { + return [value stringValue]; + } + return @""; +} + +- (BOOL)boolFromDictionary:(NSDictionary *)dictionary key:(NSString *)key defaultValue:(BOOL)defaultValue +{ + id value = dictionary[key]; + if ([value isKindOfClass:[NSNumber class]]) { + return [value boolValue]; + } + if ([value isKindOfClass:[NSString class]]) { + NSString *lowercase = [value lowercaseString]; + if ([lowercase isEqualToString:@"true"] || [lowercase isEqualToString:@"1"] || [lowercase isEqualToString:@"yes"]) { + return YES; + } + if ([lowercase isEqualToString:@"false"] || [lowercase isEqualToString:@"0"] || [lowercase isEqualToString:@"no"]) { + return NO; + } + } + return defaultValue; +} + +- (NSString *)infoStringForKey:(NSString *)key +{ + id value = [[NSBundle mainBundle] objectForInfoDictionaryKey:key]; + if ([value isKindOfClass:[NSString class]]) { + return value; + } + return @""; +} + +- (void)sendSuccessForCommand:(CDVInvokedUrlCommand *)command dictionary:(NSDictionary *)dictionary +{ + CDVPluginResult *result = dictionary == nil + ? [CDVPluginResult resultWithStatus:CDVCommandStatus_OK] + : [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:dictionary]; + [self.commandDelegate sendPluginResult:result callbackId:command.callbackId]; +} + +- (void)sendErrorForCommand:(CDVInvokedUrlCommand *)command code:(NSString *)code message:(NSString *)message +{ + NSDictionary *error = @{@"code": code ? code : @"ERROR", @"message": message ? message : @""}; + CDVPluginResult *result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:error]; + [self.commandDelegate sendPluginResult:result callbackId:command.callbackId]; +} +@end diff --git a/www/BluememeUtils.js b/www/BluememeUtils.js new file mode 100644 index 0000000..31237b5 --- /dev/null +++ b/www/BluememeUtils.js @@ -0,0 +1,128 @@ +'use strict'; + +var exec = require('cordova/exec'); +var SERVICE = 'BluememeUtils'; +var eventHandlers = {}; + +function noop() {} + +function asWifiOptions(input) { + if (typeof input === 'string') { + return { ssid: input }; + } + return input || {}; +} + +function call(action, args, success, error) { + exec(success || noop, error || noop, SERVICE, action, args || []); +} + +function createCustomEvent(name, detail) { + var eventName = 'bluememeutils.' + name; + + if (typeof window.CustomEvent === 'function') { + return new window.CustomEvent(eventName, { detail: detail }); + } + + var event = document.createEvent('CustomEvent'); + event.initCustomEvent(eventName, false, false, detail); + return event; +} + +function dispatchLocalEvent(event) { + if (!event || !event.type) { + return; + } + + var handlers = eventHandlers[event.type] || []; + handlers.slice().forEach(function (handler) { + handler(event); + }); + + if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') { + window.dispatchEvent(createCustomEvent(event.type, event)); + } +} + +var api = { + saveWifi: function (options, success, error) { + call('saveWifi', [asWifiOptions(options)], success, error); + }, + + connectWifi: function (options, success, error) { + call('connectWifi', [asWifiOptions(options)], success, error); + }, + + disconnectWifi: function (success, error) { + call('disconnectWifi', [], success, error); + }, + + removeWifi: function (options, success, error) { + call('removeWifi', [asWifiOptions(options)], success, error); + }, + + requestWifiPermission: function (success, error) { + call('requestWifiPermission', [], success, error); + }, + + isBiometricAvailable: function (success, error) { + call('isBiometricAvailable', [], success, error); + }, + + unlockWithBiometrics: function (options, success, error) { + call('unlockWithBiometrics', [options || {}], success, error); + }, + + requestPushPermission: function (success, error) { + call('requestPushPermission', [], success, error); + }, + + getFirebaseToken: function (success, error) { + call('getFirebaseToken', [], success, error); + }, + + subscribeToTopic: function (topic, success, error) { + call('subscribeToTopic', [topic], success, error); + }, + + unsubscribeFromTopic: function (topic, success, error) { + call('unsubscribeFromTopic', [topic], success, error); + }, + + getInitialNotification: function (success, error) { + call('getInitialNotification', [], success, error); + }, + + startNotificationListener: function (success, error) { + call('startNotificationListener', [], function (event) { + dispatchLocalEvent(event); + if (success) { + success(event); + } + }, error); + }, + + stopNotificationListener: function (success, error) { + call('stopNotificationListener', [], success, error); + }, + + on: function (type, handler) { + if (!eventHandlers[type]) { + eventHandlers[type] = []; + } + eventHandlers[type].push(handler); + }, + + off: function (type, handler) { + var handlers = eventHandlers[type]; + if (!handlers) { + return; + } + var index = handlers.indexOf(handler); + if (index >= 0) { + handlers.splice(index, 1); + } + } +}; + +module.exports = api;