first commit

This commit is contained in:
aria committed 2026-04-17 10:36:57 +09:00
commit 4140922530
10 files changed
+2362

No files matched your search

+21
View File
@@ -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.
+193
View File
@@ -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`
+30
View File
@@ -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"
}
]
}
+107
View File
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<plugin xmlns="http://apache.org/cordova/ns/plugins/1.0"
xmlns:android="http://schemas.android.com/apk/res/android"
id="bluememe-utils"
version="0.1.0">
<name>bluememe-utils</name>
<description>Cordova utilities for WiFi, Firebase Cloud Messaging, and biometric unlock.</description>
<license>MIT</license>
<keywords>cordova,outsystems,wifi,firebase,fcm,touchid,faceid,biometric</keywords>
<js-module src="www/BluememeUtils.js" name="BluememeUtils">
<clobbers target="BluememeUtils" />
<clobbers target="cordova.plugins.bluememeUtils" />
</js-module>
<preference name="ANDROID_FIREBASE_MESSAGING_VERSION" default="24.1.1" />
<preference name="ANDROID_FIREBASE_APP_ID" default="" />
<preference name="ANDROID_FIREBASE_API_KEY" default="" />
<preference name="ANDROID_FIREBASE_PROJECT_ID" default="" />
<preference name="ANDROID_FIREBASE_SENDER_ID" default="" />
<preference name="ANDROID_FIREBASE_STORAGE_BUCKET" default="" />
<preference name="IOS_FIREBASE_MESSAGING_VERSION" default="~> 10.29.0" />
<preference name="IOS_FIREBASE_GOOGLE_APP_ID" default="" />
<preference name="IOS_FIREBASE_API_KEY" default="" />
<preference name="IOS_FIREBASE_PROJECT_ID" default="" />
<preference name="IOS_FIREBASE_GCM_SENDER_ID" default="" />
<preference name="IOS_FIREBASE_STORAGE_BUCKET" default="" />
<preference name="IOS_FACE_ID_USAGE_DESCRIPTION" default="Use Face ID to unlock this application." />
<platform name="android">
<config-file target="res/xml/config.xml" parent="/*">
<feature name="BluememeUtils">
<param name="android-package" value="com.bluememe.utils.BluememeUtils" />
</feature>
</config-file>
<config-file target="AndroidManifest.xml" parent="/manifest">
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
</config-file>
<config-file target="AndroidManifest.xml" parent="/manifest/application">
<service
android:name="com.bluememe.utils.BluememeMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data android:name="bluememe.firebase.app_id" android:value="$ANDROID_FIREBASE_APP_ID" />
<meta-data android:name="bluememe.firebase.api_key" android:value="$ANDROID_FIREBASE_API_KEY" />
<meta-data android:name="bluememe.firebase.project_id" android:value="$ANDROID_FIREBASE_PROJECT_ID" />
<meta-data android:name="bluememe.firebase.sender_id" android:value="$ANDROID_FIREBASE_SENDER_ID" />
<meta-data android:name="bluememe.firebase.storage_bucket" android:value="$ANDROID_FIREBASE_STORAGE_BUCKET" />
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bluememe_utils_notifications" />
</config-file>
<source-file src="src/android/BluememeUtils.java" target-dir="src/com/bluememe/utils" />
<source-file src="src/android/BluememeMessagingService.java" target-dir="src/com/bluememe/utils" />
<framework src="com.google.firebase:firebase-messaging:$ANDROID_FIREBASE_MESSAGING_VERSION" />
</platform>
<platform name="ios">
<config-file target="config.xml" parent="/*">
<feature name="BluememeUtils">
<param name="ios-package" value="CDVBluememeUtils" />
</feature>
</config-file>
<config-file target="*-Info.plist" parent="NSFaceIDUsageDescription">
<string>$IOS_FACE_ID_USAGE_DESCRIPTION</string>
</config-file>
<config-file target="*-Info.plist" parent="BLUEMEME_FIREBASE_GOOGLE_APP_ID">
<string>$IOS_FIREBASE_GOOGLE_APP_ID</string>
</config-file>
<config-file target="*-Info.plist" parent="BLUEMEME_FIREBASE_API_KEY">
<string>$IOS_FIREBASE_API_KEY</string>
</config-file>
<config-file target="*-Info.plist" parent="BLUEMEME_FIREBASE_PROJECT_ID">
<string>$IOS_FIREBASE_PROJECT_ID</string>
</config-file>
<config-file target="*-Info.plist" parent="BLUEMEME_FIREBASE_GCM_SENDER_ID">
<string>$IOS_FIREBASE_GCM_SENDER_ID</string>
</config-file>
<config-file target="*-Info.plist" parent="BLUEMEME_FIREBASE_STORAGE_BUCKET">
<string>$IOS_FIREBASE_STORAGE_BUCKET</string>
</config-file>
<header-file src="src/ios/CDVBluememeUtils.h" />
<source-file src="src/ios/CDVBluememeUtils.m" />
<framework src="NetworkExtension.framework" />
<framework src="LocalAuthentication.framework" />
<framework src="UserNotifications.framework" />
<framework src="Firebase/Messaging" type="podspec" spec="$IOS_FIREBASE_MESSAGING_VERSION" />
</platform>
</plugin>
+5
View File
@@ -0,0 +1,5 @@
[project]
name = "hakuzen"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = []
+196
View File
@@ -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<String, String> 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<ActivityManager.RunningAppProcessInfo> 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;
}
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
#import <Cordova/CDV.h>
#import <FirebaseMessaging/FirebaseMessaging.h>
#import <UserNotifications/UserNotifications.h>
@interface CDVBluememeUtils : CDVPlugin <FIRMessagingDelegate, UNUserNotificationCenterDelegate>
@end
+490
View File
@@ -0,0 +1,490 @@
#import "CDVBluememeUtils.h"
#import <FirebaseCore/FirebaseCore.h>
#import <LocalAuthentication/LocalAuthentication.h>
#import <NetworkExtension/NetworkExtension.h>
#import <UIKit/UIKit.h>
@interface CDVBluememeUtils ()
@property (nonatomic, copy) NSString *notificationCallbackId;
@property (nonatomic, strong) NSDictionary *initialNotification;
@property (nonatomic, weak) id<UNUserNotificationCenterDelegate> 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
+128
View File
@@ -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;