feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,53 @@
apply plugin: 'com.android.library'
group = 'host.exp.exponent'
version = '0.28.19'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useDefaultAndroidSdkVersions()
useExpoPublishing()
android {
namespace "expo.modules.notifications"
defaultConfig {
versionCode 21
versionName '0.28.19'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
buildConfig true
}
testOptions {
unitTests.all { test ->
testLogging {
outputs.upToDateWhen { false }
events "passed", "failed", "skipped", "standardError"
showCauses true
showExceptions true
showStandardStreams true
}
}
}
}
dependencies {
implementation 'androidx.core:core:1.6.0'
implementation 'androidx.lifecycle:lifecycle-runtime:2.2.0'
implementation 'androidx.lifecycle:lifecycle-process:2.2.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.2.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// release notes in https://firebase.google.com/support/release-notes/android - cmd + f "Cloud Messaging version"
implementation 'com.google.firebase:firebase-messaging:24.0.1'
implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
if (project.findProject(':expo-modules-test-core')) {
testImplementation project(':expo-modules-test-core')
androidTestImplementation project(':expo-modules-test-core')
}
}

View File

@@ -0,0 +1,39 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application>
<service
android:name=".service.ExpoFirebaseMessagingService"
android:exported="false">
<intent-filter android:priority="-1">
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".service.NotificationsService"
android:enabled="true"
android:exported="false">
<intent-filter android:priority="-1">
<action android:name="expo.modules.notifications.NOTIFICATION_EVENT" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.REBOOT" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<activity android:name=".service.NotificationForwarderActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:launchMode="standard"
android:taskAffinity=""
/>
</application>
</manifest>

View File

@@ -0,0 +1,9 @@
package expo.modules.notifications
import expo.modules.kotlin.exception.CodedException
import kotlin.reflect.KClass
class ModuleNotFoundException(moduleClass: KClass<*>) :
CodedException(message = "$moduleClass module not found")
class NotificationWasAlreadyHandledException(val id: String) : CodedException("Failed to handle notification $id, it has already been handled.")

View File

@@ -0,0 +1,48 @@
package expo.modules.notifications;
import android.content.Context;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import expo.modules.core.BasePackage;
import expo.modules.core.interfaces.InternalModule;
import expo.modules.core.interfaces.ReactActivityLifecycleListener;
import expo.modules.core.interfaces.SingletonModule;
import expo.modules.notifications.notifications.NotificationManager;
import expo.modules.notifications.notifications.categories.serializers.ExpoNotificationsCategoriesSerializer;
import expo.modules.notifications.notifications.channels.AndroidXNotificationsChannelsProvider;
import expo.modules.notifications.service.delegates.ExpoNotificationLifecycleListener;
import expo.modules.notifications.tokens.PushTokenManager;
public class NotificationsPackage extends BasePackage {
private NotificationManager mNotificationManager;
public NotificationsPackage() {
mNotificationManager = new NotificationManager();
}
@Override
public List<SingletonModule> createSingletonModules(Context context) {
return Arrays.asList(
new PushTokenManager(),
mNotificationManager
);
}
@Override
public List<InternalModule> createInternalModules(Context context) {
return Arrays.asList(
new AndroidXNotificationsChannelsProvider(context),
new ExpoNotificationsCategoriesSerializer()
);
}
@Override
public List<ReactActivityLifecycleListener> createReactActivityLifecycleListeners(Context activityContext) {
return Collections.singletonList(new ExpoNotificationLifecycleListener(activityContext, mNotificationManager));
}
}

View File

@@ -0,0 +1,108 @@
package expo.modules.notifications
import android.os.Bundle
import android.os.Handler
import android.os.ResultReceiver
import expo.modules.kotlin.types.JSTypeConverter
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
typealias ResultReceiverBody = (resultCode: Int, resultData: Bundle?) -> Unit
typealias BundleConversionTester = (bundle: Bundle) -> Boolean
internal fun createDefaultResultReceiver(
handler: Handler?,
body: ResultReceiverBody
): ResultReceiver {
return object : ResultReceiver(handler) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
super.onReceiveResult(resultCode, resultData)
body(resultCode, resultData)
}
}
}
/**
* Given an input bundle, creates a new bundle with non-convertible objects removed
*/
internal fun filteredBundleForJSTypeConverter(bundle: Bundle): Bundle {
return filteredBundleForJSTypeConverter(bundle, isBundleConvertibleToJSValue)
}
internal fun filteredBundleForJSTypeConverter(bundle: Bundle, testBundle: BundleConversionTester): Bundle {
return when (testBundle(bundle)) {
true -> bundle
else -> {
// Store keys whose values are convertible
val goodKeys: MutableSet<String> = mutableSetOf()
// Do first pass to filter any values that are bundles
bundle.keySet().forEach { key: String ->
val value = bundle[key]
if (value is Bundle) {
bundle.putBundle(key, filteredBundleForJSTypeConverter(value, testBundle))
goodKeys.add(key)
}
}
// Second pass: create a bundle with just the value for that key, and see if it converts
// There is no generic put() method for bundles, so we putAll() and then remove values
// other than the one we are testing
bundle.keySet().forEach { key: String ->
if (!goodKeys.contains(key)) {
val test = Bundle()
test.putAll(bundle)
bundle.keySet().forEach { otherKey: String ->
if (!otherKey.equals(key)) {
test.remove(otherKey)
}
}
if (testBundle(test)) {
goodKeys.add(key)
}
}
}
// Now create a new bundle, remove keys that are not good, and return
val result = Bundle()
result.putAll(bundle)
bundle.keySet().forEach { key: String ->
if (!goodKeys.contains(key)) {
result.remove(key)
}
}
result
}
}
}
internal val isBundleConvertibleToJSValue: BundleConversionTester = { bundle: Bundle ->
try {
JSTypeConverter.convertToJSValue(bundle)
true
} catch (e: Throwable) {
false
}
}
/**
* Returns true if the argument is a valid JSON string, false otherwise
*/
internal fun isValidJSONString(test: Any?): Boolean {
when (test is String) {
true -> {
try {
JSONObject(test as String)
return true
} catch (objectEx: JSONException) {
try {
JSONArray(test as String)
return true
} catch (arrayEx: JSONException) {
return false
}
}
}
else -> {
return false
}
}
}

View File

@@ -0,0 +1,29 @@
package expo.modules.notifications.badge
import android.content.Context
import android.util.Log
import me.leolin.shortcutbadger.ShortcutBadgeException
import me.leolin.shortcutbadger.ShortcutBadger
object BadgeHelper {
var badgeCount = 0
get() = synchronized(this) { field }
private set(value) = synchronized(this) { field = value }
fun setBadgeCount(context: Context, badgeCount: Int): Boolean {
return try {
if (badgeCount == 0) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
notificationManager.cancelAll()
} else {
ShortcutBadger.applyCountOrThrow(context.applicationContext, badgeCount)
}
BadgeHelper.badgeCount = badgeCount
true
} catch (e: ShortcutBadgeException) {
Log.d("expo-notifications", "Could not have set badge count: ${e.message}", e)
e.printStackTrace()
false
}
}
}

View File

@@ -0,0 +1,22 @@
package expo.modules.notifications.badge
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class BadgeModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoBadgeModule")
AsyncFunction<Int>("getBadgeCountAsync") {
BadgeHelper.badgeCount
}
AsyncFunction("setBadgeCountAsync") { badgeCount: Int ->
BadgeHelper.setBadgeCount(
appContext.reactContext ?: throw Exceptions.ReactContextLost(),
badgeCount
)
}
}
}

View File

@@ -0,0 +1,149 @@
package expo.modules.notifications.notifications;
import android.content.Context;
import android.graphics.Color;
import android.net.Uri;
import android.util.Log;
import org.json.JSONObject;
import expo.modules.core.arguments.ReadableArguments;
import java.util.List;
import java.util.Map;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.channels.InvalidVibrationPatternException;
import expo.modules.notifications.notifications.enums.NotificationPriority;
import expo.modules.notifications.notifications.model.NotificationContent;
public class ArgumentsNotificationContentBuilder extends NotificationContent.Builder {
private static final String TITLE_KEY = "title";
private static final String SUBTITLE_KEY = "subtitle";
private static final String TEXT_KEY = "body";
private static final String BODY_KEY = "data";
private static final String SOUND_KEY = "sound";
private static final String VIBRATE_KEY = "vibrate";
private static final String PRIORITY_KEY = "priority";
private static final String BADGE_KEY = "badge";
private static final String COLOR_KEY = "color";
private static final String AUTO_DISMISS_KEY = "autoDismiss";
private static final String CATEGORY_IDENTIFIER_KEY = "categoryIdentifier";
private static final String STICKY_KEY = "sticky";
private SoundResolver mSoundResolver;
public ArgumentsNotificationContentBuilder(Context context) {
mSoundResolver = new SoundResolver(context);
}
public NotificationContent.Builder setPayload(ReadableArguments payload) {
this.setTitle(payload.getString(TITLE_KEY))
.setSubtitle(payload.getString(SUBTITLE_KEY))
.setText(payload.getString(TEXT_KEY))
.setBody(getBody(payload))
.setPriority(getPriority(payload))
.setBadgeCount(getBadgeCount(payload))
.setColor(getColor(payload))
.setAutoDismiss(getAutoDismiss(payload))
.setCategoryId(getCategoryId(payload))
.setSticky(getSticky(payload));
if (shouldPlayDefaultSound(payload)) {
useDefaultSound();
} else {
setSound(getSound(payload));
}
if (shouldUseDefaultVibrationPattern(payload)) {
useDefaultVibrationPattern();
} else {
setVibrationPattern(getVibrationPattern(payload));
}
return this;
}
protected Number getBadgeCount(ReadableArguments payload) {
return payload.containsKey(BADGE_KEY) ? payload.getInt(BADGE_KEY) : null;
}
protected Number getColor(ReadableArguments payload) {
try {
return payload.containsKey(COLOR_KEY) ? Color.parseColor(payload.getString(COLOR_KEY)) : null;
} catch (IllegalArgumentException e) {
Log.e("expo-notifications", "Could not have parsed color passed in notification.");
return null;
}
}
protected boolean shouldPlayDefaultSound(ReadableArguments payload) {
if (payload.get(SOUND_KEY) instanceof Boolean) {
return payload.getBoolean(SOUND_KEY);
}
// do not play a default sound only if the value is a valid Uri
return getSound(payload) == null;
}
protected Uri getSound(ReadableArguments payload) {
String soundValue = payload.getString(SOUND_KEY);
return mSoundResolver.resolve(soundValue);
}
@Nullable
protected JSONObject getBody(ReadableArguments payload) {
try {
Map body = payload.getMap(BODY_KEY);
if (body != null) {
return new JSONObject(body);
}
return null;
} catch (NullPointerException e) {
return null;
}
}
protected boolean shouldUseDefaultVibrationPattern(ReadableArguments payload) {
return !payload.getBoolean(VIBRATE_KEY, true);
}
protected long[] getVibrationPattern(ReadableArguments payload) {
try {
List<?> vibrateJsonArray = payload.getList(VIBRATE_KEY);
if (vibrateJsonArray != null) {
long[] pattern = new long[vibrateJsonArray.size()];
for (int i = 0; i < vibrateJsonArray.size(); i++) {
if (vibrateJsonArray.get(i) instanceof Number) {
pattern[i] = ((Number) vibrateJsonArray.get(i)).longValue();
} else {
throw new InvalidVibrationPatternException(i, vibrateJsonArray.get(i));
}
}
return pattern;
}
} catch (InvalidVibrationPatternException e) {
Log.w("expo-notifications", "Failed to set custom vibration pattern from the notification: " + e.getMessage());
}
return null;
}
protected NotificationPriority getPriority(ReadableArguments payload) {
String priorityString = payload.getString(PRIORITY_KEY);
return NotificationPriority.fromEnumValue(priorityString);
}
protected boolean getAutoDismiss(ReadableArguments payload) {
// TODO(sjchmiela): the default value should be determined by NotificationContent.Builder
return payload.getBoolean(AUTO_DISMISS_KEY, true);
}
@Nullable
protected String getCategoryId(ReadableArguments payload) {
return payload.getString(CATEGORY_IDENTIFIER_KEY, null);
}
protected boolean getSticky(ReadableArguments payload) {
// TODO: the default value should be determined by NotificationContent.Builder
return payload.getBoolean(STICKY_KEY, false);
}
}

View File

@@ -0,0 +1,152 @@
package expo.modules.notifications.notifications;
import android.os.Bundle;
import android.util.Log;
import expo.modules.core.interfaces.SingletonModule;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.WeakHashMap;
import expo.modules.notifications.notifications.interfaces.NotificationListener;
import expo.modules.notifications.notifications.model.Notification;
import expo.modules.notifications.notifications.model.NotificationResponse;
import expo.modules.notifications.service.delegates.ExpoHandlingDelegate;
public class NotificationManager implements SingletonModule, expo.modules.notifications.notifications.interfaces.NotificationManager {
private static final String SINGLETON_NAME = "NotificationManager";
/**
* A weak map of listeners -> reference. Used to check quickly whether given listener
* is already registered and to iterate over on new token.
*/
private WeakHashMap<NotificationListener, WeakReference<NotificationListener>> mListenerReferenceMap;
private Collection<NotificationResponse> mPendingNotificationResponses = new ArrayList<>();
private Collection<Bundle> mPendingNotificationResponsesFromExtras = new ArrayList<>();
public NotificationManager() {
mListenerReferenceMap = new WeakHashMap<>();
// Registers this singleton instance in static ExpoHandlingDelegate listeners collection.
// Since it doesn't hold strong reference to the object this should be safe.
ExpoHandlingDelegate.Companion.addListener(this);
}
@Override
public String getName() {
return SINGLETON_NAME;
}
/**
* Registers a {@link NotificationListener} by adding a {@link WeakReference} to
* the {@link NotificationManager#mListenerReferenceMap} map.
*
* @param listener Listener to be notified of new messages.
*/
@Override
public void addListener(NotificationListener listener) {
// Check if the listener is already registered
if (!mListenerReferenceMap.containsKey(listener)) {
WeakReference<NotificationListener> listenerReference = new WeakReference<>(listener);
mListenerReferenceMap.put(listener, listenerReference);
if (!mPendingNotificationResponses.isEmpty()) {
for (NotificationResponse pendingResponse : mPendingNotificationResponses) {
listener.onNotificationResponseReceived(pendingResponse);
}
}
if (!mPendingNotificationResponsesFromExtras.isEmpty()) {
for (Bundle extras : mPendingNotificationResponsesFromExtras) {
listener.onNotificationResponseIntentReceived(extras);
}
}
}
}
/**
* Unregisters a {@link NotificationListener} by removing the {@link WeakReference} to the listener
* from the {@link NotificationManager#mListenerReferenceMap} map.
*
* @param listener Listener previously registered with {@link NotificationManager#addListener(NotificationListener)}.
*/
@Override
public void removeListener(NotificationListener listener) {
mListenerReferenceMap.remove(listener);
}
/**
* Used by {@link expo.modules.notifications.service.delegates.ExpoSchedulingDelegate} to notify of new messages.
* Calls {@link NotificationListener#onNotificationReceived(Notification)} on all values
* of {@link NotificationManager#mListenerReferenceMap}.
*
* @param notification Notification received
*/
public void onNotificationReceived(Notification notification) {
for (WeakReference<NotificationListener> listenerReference : mListenerReferenceMap.values()) {
NotificationListener listener = listenerReference.get();
if (listener != null) {
listener.onNotificationReceived(notification);
}
}
}
/**
* Used by {@link expo.modules.notifications.service.delegates.ExpoSchedulingDelegate} to notify of new notification responses.
* Calls {@link NotificationListener#onNotificationResponseReceived(NotificationResponse)} on all values
* of {@link NotificationManager#mListenerReferenceMap}.
*
* @param response Notification response received
*/
public void onNotificationResponseReceived(NotificationResponse response) {
if (mListenerReferenceMap.isEmpty()) {
mPendingNotificationResponses.add(response);
} else {
for (WeakReference<NotificationListener> listenerReference : mListenerReferenceMap.values()) {
NotificationListener listener = listenerReference.get();
if (listener != null) {
listener.onNotificationResponseReceived(response);
}
}
}
}
/**
* Used by {@link expo.modules.notifications.service.delegates.ExpoSchedulingDelegate} to notify of message deletion event.
* Calls {@link NotificationListener#onNotificationsDropped()} on all values
* of {@link NotificationManager#mListenerReferenceMap}.
*/
public void onNotificationsDropped() {
for (WeakReference<NotificationListener> listenerReference : mListenerReferenceMap.values()) {
NotificationListener listener = listenerReference.get();
if (listener != null) {
listener.onNotificationsDropped();
}
}
}
public void onNotificationResponseFromExtras(Bundle extras) {
// We're going to be passed in extras from either
// a killed state (ExpoNotificationLifecycleListener::onCreate)
// OR a background state (ExpoNotificationLifecycleListener::onNewIntent)
// If we've just come from a background state, we'll have listeners set up
// pass on the notification to them
if (!mListenerReferenceMap.isEmpty()) {
for (WeakReference<NotificationListener> listenerReference : mListenerReferenceMap.values()) {
NotificationListener listener = listenerReference.get();
if (listener != null) {
listener.onNotificationResponseIntentReceived(extras);
}
}
} else {
// Otherwise, the app has been launched from a killed state, and our listeners
// haven't yet been setup. We'll add this to a list of pending notifications
// for them to process once they've been initialized.
if (mPendingNotificationResponsesFromExtras.isEmpty()) {
mPendingNotificationResponsesFromExtras.add(extras);
}
}
}
}

View File

@@ -0,0 +1,271 @@
package expo.modules.notifications.notifications;
import static expo.modules.notifications.UtilsKt.filteredBundleForJSTypeConverter;
import static expo.modules.notifications.UtilsKt.isValidJSONString;
import android.os.Bundle;
import androidx.annotation.Nullable;
import com.google.firebase.messaging.RemoteMessage;
import org.jetbrains.annotations.NotNull;
import org.json.JSONArray;
import org.json.JSONObject;
import expo.modules.core.arguments.MapArguments;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import expo.modules.notifications.notifications.interfaces.INotificationContent;
import expo.modules.notifications.notifications.interfaces.NotificationTrigger;
import expo.modules.notifications.notifications.interfaces.SchedulableNotificationTrigger;
import expo.modules.notifications.notifications.model.Notification;
import expo.modules.notifications.notifications.model.NotificationRequest;
import expo.modules.notifications.notifications.model.NotificationResponse;
import expo.modules.notifications.notifications.model.TextInputNotificationResponse;
import expo.modules.notifications.notifications.model.triggers.FirebaseNotificationTrigger;
import expo.modules.notifications.notifications.triggers.DailyTrigger;
import expo.modules.notifications.notifications.triggers.DateTrigger;
import expo.modules.notifications.notifications.triggers.TimeIntervalTrigger;
import expo.modules.notifications.notifications.triggers.WeeklyTrigger;
import expo.modules.notifications.notifications.triggers.YearlyTrigger;
public class NotificationSerializer {
public static Bundle toBundle(NotificationResponse response) {
Bundle serializedResponse = new Bundle();
serializedResponse.putString("actionIdentifier", response.getActionIdentifier());
serializedResponse.putBundle("notification", toBundle(response.getNotification()));
if (response instanceof TextInputNotificationResponse) {
serializedResponse.putString("userText", ((TextInputNotificationResponse) response).getUserText());
}
return serializedResponse;
}
public static Bundle toBundle(Notification notification) {
Bundle serializedNotification = new Bundle();
serializedNotification.putBundle("request", toBundle(notification.getNotificationRequest()));
serializedNotification.putLong("date", notification.getOriginDate().getTime());
return serializedNotification;
}
public static Bundle toBundle(NotificationRequest request) {
Bundle serializedRequest = new Bundle();
serializedRequest.putString("identifier", request.getIdentifier());
serializedRequest.putBundle("trigger", toBundle(request.getTrigger()));
Bundle content = toBundle(request.getContent());
Bundle existingContentData = content.getBundle("data");
if (existingContentData == null) {
if(request.getTrigger() instanceof FirebaseNotificationTrigger trigger) {
RemoteMessage message = trigger.getRemoteMessage();
RemoteMessage.Notification notification = message.getNotification();
Map<String, String> data = message.getData();
String dataBody = data.get("body");
String notificationBody = notification != null ? notification.getBody() : null;
if (isValidJSONString(dataBody) && notificationBody != null && notificationBody.equals(data.get("message"))) {
// Expo sends notification.body as data.message, and JSON stringifies data.body
content.putString("dataString", dataBody);
} else {
// The message was sent directly from Firebase or some other service,
// and we copy the data as is
content.putBundle("data", toBundle(data));
}
} else if(
request.getTrigger() instanceof SchedulableNotificationTrigger ||
request.getTrigger() == null
) {
JSONObject body = request.getContent().getBody();
if (body != null) {
// Expo sends notification.body as data.message, and JSON stringifies data.body
content.putString("dataString", body.toString());
}
}
}
serializedRequest.putBundle("content", content);
return serializedRequest;
}
public static Bundle toBundle(Map<String, String> map) {
Bundle result = new Bundle();
for (String key: map.keySet()) {
result.putString(key, map.get(key));
}
return result;
}
public static Bundle toBundle(INotificationContent content) {
Bundle serializedContent = new Bundle();
serializedContent.putString("title", content.getTitle());
serializedContent.putString("subtitle", content.getSubtitle());
serializedContent.putString("body", content.getText());
if (content.getColor() != null) {
serializedContent.putString("color", String.format("#%08X", content.getColor().intValue()));
}
if (content.getBadgeCount() != null) {
serializedContent.putInt("badge", content.getBadgeCount().intValue());
} else {
serializedContent.putString("badge", null);
}
if (content.getShouldPlayDefaultSound()) {
serializedContent.putString("sound", "default");
} else if (content.getSoundName() != null) {
serializedContent.putString("sound", "custom");
} else {
serializedContent.putString("sound", null);
}
if (content.getPriority() != null) {
serializedContent.putString("priority", content.getPriority().getEnumValue());
}
if (content.getVibrationPattern() != null) {
serializedContent.putIntArray("vibrationPattern", RemoteMessageSerializer.intArrayFromLongArray(content.getVibrationPattern()));
}
serializedContent.putBoolean("autoDismiss", content.isAutoDismiss());
if (content.getCategoryId() != null) {
serializedContent.putString("categoryIdentifier", content.getCategoryId());
}
serializedContent.putBoolean("sticky", content.isSticky());
return serializedContent;
}
public static Bundle toBundle(@Nullable JSONObject notification) {
if (notification == null) {
return null;
}
Map<String, Object> notificationMap = new HashMap<>(notification.length());
Iterator<String> keyIterator = notification.keys();
while (keyIterator.hasNext()) {
String key = keyIterator.next();
Object value = notification.opt(key);
if (value instanceof JSONObject) {
notificationMap.put(key, toBundle((JSONObject) value));
} else if (value instanceof JSONArray) {
notificationMap.put(key, toList((JSONArray) value));
} else if (JSONObject.NULL.equals(value)) {
notificationMap.put(key, null);
} else {
notificationMap.put(key, value);
}
}
try {
return new MapArguments(notificationMap).toBundle();
} catch (NullPointerException e) {
// If a NullPointerException was thrown it most probably means
// that @unimodules/core is at < 5.1.1 where we introduced
// support for null values in MapArguments' map). Let's go through
// the map and remove the null values to be backwards compatible.
Set<String> keySet = notificationMap.keySet();
for (String key : keySet) {
if (notificationMap.get(key) == null) {
notificationMap.remove(key);
}
}
return new MapArguments(notificationMap).toBundle();
}
}
private static List<Object> toList(JSONArray array) {
List<Object> result = new ArrayList<>(array.length());
for (int i = 0; i < array.length(); i++) {
if (array.isNull(i)) {
result.add(null);
} else if (array.optJSONObject(i) != null) {
result.add(toBundle(array.optJSONObject(i)));
} else if (array.optJSONArray(i) != null) {
result.add(toList(array.optJSONArray(i)));
} else {
result.add(array.opt(i));
}
}
return result;
}
private static Bundle toBundle(@Nullable NotificationTrigger trigger) {
if (trigger == null) {
return null;
}
Bundle bundle = new Bundle();
if (trigger instanceof FirebaseNotificationTrigger) {
bundle.putString("type", "push");
bundle.putBundle("remoteMessage", RemoteMessageSerializer.toBundle(((FirebaseNotificationTrigger) trigger).getRemoteMessage()));
} else if (trigger instanceof TimeIntervalTrigger) {
bundle.putString("type", "timeInterval");
bundle.putBoolean("repeats", ((TimeIntervalTrigger) trigger).isRepeating());
bundle.putLong("seconds", ((TimeIntervalTrigger) trigger).getTimeInterval());
} else if (trigger instanceof DateTrigger) {
bundle.putString("type", "date");
bundle.putBoolean("repeats", false);
bundle.putLong("value", ((DateTrigger) trigger).getTriggerDate().getTime());
} else if (trigger instanceof DailyTrigger) {
bundle.putString("type", "daily");
bundle.putInt("hour", ((DailyTrigger) trigger).getHour());
bundle.putInt("minute", ((DailyTrigger) trigger).getMinute());
} else if (trigger instanceof WeeklyTrigger) {
bundle.putString("type", "weekly");
bundle.putInt("weekday", ((WeeklyTrigger) trigger).getWeekday());
bundle.putInt("hour", ((WeeklyTrigger) trigger).getHour());
bundle.putInt("minute", ((WeeklyTrigger) trigger).getMinute());
} else if (trigger instanceof YearlyTrigger) {
bundle.putString("type", "yearly");
bundle.putInt("day", ((YearlyTrigger) trigger).getDay());
bundle.putInt("month", ((YearlyTrigger) trigger).getMonth());
bundle.putInt("hour", ((YearlyTrigger) trigger).getHour());
bundle.putInt("minute", ((YearlyTrigger) trigger).getMinute());
} else {
bundle.putString("type", "unknown");
}
bundle.putString("channelId", getChannelId(trigger));
return bundle;
}
@Nullable
private static String getChannelId(NotificationTrigger trigger) {
return trigger.getNotificationChannel();
}
@NotNull
public static Bundle toResponseBundleFromExtras(Bundle extras) {
Bundle serializedContent = new Bundle();
serializedContent.putString("title", extras.getString("title"));
String body = extras.getString("body");
if (isValidJSONString(body) ) {
// If the body is a JSON string,
// the notification was sent by the Expo notification service,
// so we do the expected remapping of fields
serializedContent.putString("dataString", body);
serializedContent.putString("body", extras.getString("message"));
} else {
// The notification came directly from Firebase or some other service,
// so we copy the data as is from the extras bundle, after
// ensuring it can be converted for emitting to JS
serializedContent.putBundle("data", filteredBundleForJSTypeConverter(extras));
}
Bundle serializedTrigger = new Bundle();
serializedTrigger.putString("type", "push");
serializedTrigger.putString("channelId", extras.getString("channelId"));
Bundle serializedRequest = new Bundle();
serializedRequest.putString("identifier", extras.getString("google.message_id"));
serializedRequest.putBundle("trigger", serializedTrigger);
serializedRequest.putBundle("content", serializedContent);
Bundle serializedNotification = new Bundle();
serializedNotification.putLong("date", extras.getLong("google.sent_time"));
serializedNotification.putBundle("request", serializedRequest);
Bundle serializedResponse = new Bundle();
serializedResponse.putString("actionIdentifier", "expo.modules.notifications.actions.DEFAULT");
serializedResponse.putBundle("notification", serializedNotification);
return serializedResponse;
}
}

View File

@@ -0,0 +1,114 @@
package expo.modules.notifications.notifications;
import android.os.Bundle;
import com.google.firebase.messaging.RemoteMessage;
import java.util.Map;
import androidx.annotation.Nullable;
/**
* Serializes all the information available in {@link RemoteMessage}
* to {@link Bundle}.
*/
public class RemoteMessageSerializer {
/**
* Serializes all the information available in {@link RemoteMessage}
*
* @param message {@link RemoteMessage} to serialize
* @return Serialized message
*/
public static Bundle toBundle(RemoteMessage message) {
Bundle serializedMessage = new Bundle();
serializedMessage.putString("collapseKey", message.getCollapseKey());
serializedMessage.putBundle("data", toBundle(message.getData()));
serializedMessage.putString("from", message.getFrom());
serializedMessage.putString("messageId", message.getMessageId());
serializedMessage.putString("messageType", message.getMessageType());
serializedMessage.putBundle("notification", toBundle(message.getNotification()));
serializedMessage.putInt("originalPriority", message.getOriginalPriority());
serializedMessage.putInt("priority", message.getPriority());
serializedMessage.putLong("sentTime", message.getSentTime());
serializedMessage.putString("to", message.getTo());
serializedMessage.putInt("ttl", message.getTtl());
return serializedMessage;
}
private static Bundle toBundle(Map<String, String> data) {
Bundle serializedData = new Bundle();
for (Map.Entry<String, String> dataEntry : data.entrySet()) {
serializedData.putString(dataEntry.getKey(), dataEntry.getValue());
}
return serializedData;
}
private static Bundle toBundle(@Nullable RemoteMessage.Notification notification) {
if (notification == null) {
return null;
}
Bundle serializedNotification = new Bundle();
serializedNotification.putString("body", notification.getBody());
serializedNotification.putStringArray("bodyLocalizationArgs", notification.getBodyLocalizationArgs());
serializedNotification.putString("bodyLocalizationKey", notification.getBodyLocalizationKey());
serializedNotification.putString("channelId", notification.getChannelId());
serializedNotification.putString("clickAction", notification.getClickAction());
serializedNotification.putString("color", notification.getColor());
serializedNotification.putBoolean("usesDefaultLightSettings", notification.getDefaultLightSettings());
serializedNotification.putBoolean("usesDefaultSound", notification.getDefaultSound());
serializedNotification.putBoolean("usesDefaultVibrateSettings", notification.getDefaultVibrateSettings());
if (notification.getEventTime() != null) {
serializedNotification.putLong("eventTime", notification.getEventTime());
} else {
serializedNotification.putString("eventTime", null);
}
serializedNotification.putString("icon", notification.getIcon());
if (notification.getImageUrl() != null) {
serializedNotification.putString("imageUrl", notification.getImageUrl().toString());
} else {
serializedNotification.putString("imageUrl", null);
}
serializedNotification.putIntArray("lightSettings", notification.getLightSettings());
if (notification.getLink() != null) {
serializedNotification.putString("link", notification.getLink().toString());
} else {
serializedNotification.putString("link", null);
}
serializedNotification.putBoolean("localOnly", notification.getLocalOnly());
if (notification.getNotificationCount() != null) {
serializedNotification.putInt("notificationCount", notification.getNotificationCount());
} else {
serializedNotification.putString("notificationCount", null);
}
if (notification.getNotificationPriority() != null) {
serializedNotification.putInt("notificationPriority", notification.getNotificationPriority());
} else {
serializedNotification.putString("notificationPriority", null);
}
serializedNotification.putString("sound", notification.getSound());
serializedNotification.putBoolean("sticky", notification.getSticky());
serializedNotification.putString("tag", notification.getTag());
serializedNotification.putString("ticker", notification.getTicker());
serializedNotification.putString("title", notification.getTitle());
serializedNotification.putStringArray("titleLocalizationArgs", notification.getTitleLocalizationArgs());
serializedNotification.putString("titleLocalizationKey", notification.getTitleLocalizationKey());
if (notification.getVibrateTimings() != null) {
serializedNotification.putIntArray("vibrateTimings", intArrayFromLongArray(notification.getVibrateTimings()));
}
if (notification.getVisibility() != null) {
serializedNotification.putInt("visibility", notification.getVisibility());
} else {
serializedNotification.putString("visibility", null);
}
return serializedNotification;
}
public static int[] intArrayFromLongArray(long[] longArray) {
int[] intArray = new int[longArray.length];
for (int i = 0; i < longArray.length; i++) {
intArray[i] = (int)(longArray[i]);
}
return intArray;
}
}

View File

@@ -0,0 +1,58 @@
package expo.modules.notifications.notifications;
import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import android.provider.Settings;
import androidx.annotation.Nullable;
/**
* A shared logic between ContentBuilders ({@link ArgumentsNotificationContentBuilder}
* and {@link RemoteNotificationContent}) for resolving sounds based on the "soundName" property.
*/
public class SoundResolver {
private Context mContext;
public SoundResolver(Context context) {
mContext = context;
}
/**
* For given filename tries to resolve a raw resource by basename.
*
* @param filename A sound's filename
* @return null if there was no sound found for the filename or a {@link Uri} to the raw resource
* if one could be found.
*/
@Nullable
public Uri resolve(@Nullable String filename) {
if (filename == null || filename.length() == 0) {
return null;
}
String packageName = mContext.getPackageName();
String resourceName = filenameToBasename(filename);
int resourceId = mContext.getResources().getIdentifier(resourceName, "raw", packageName);
// If resourceId is 0, then the resource does not exist.
// Returning null falls back to using a default sound.
if (resourceId != 0) {
return new Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(packageName)
.appendPath("raw")
.appendPath(resourceName)
.build();
}
return Settings.System.DEFAULT_NOTIFICATION_URI;
}
private String filenameToBasename(String filename) {
if (!filename.contains(".")) {
return filename;
}
return filename.substring(0, filename.lastIndexOf('.'));
}
}

View File

@@ -0,0 +1,123 @@
package expo.modules.notifications.notifications.background;
import android.app.job.JobParameters;
import android.app.job.JobService;
import android.content.Context;
import android.os.Bundle;
import android.os.PersistableBundle;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import expo.modules.notifications.notifications.NotificationSerializer;
import expo.modules.notifications.service.delegates.FirebaseMessagingDelegate;
import expo.modules.interfaces.taskManager.TaskConsumer;
import expo.modules.interfaces.taskManager.TaskConsumerInterface;
import expo.modules.interfaces.taskManager.TaskExecutionCallback;
import expo.modules.interfaces.taskManager.TaskInterface;
import expo.modules.interfaces.taskManager.TaskManagerUtilsInterface;
/**
* Represents a task to be run when the app is receives a remote push
* notification. Map of current tasks is maintained in {@link FirebaseMessagingDelegate}.
*/
public class BackgroundRemoteNotificationTaskConsumer extends TaskConsumer implements TaskConsumerInterface {
private static final String TAG = BackgroundRemoteNotificationTaskConsumer.class.getSimpleName();
private static final String NOTIFICATION_KEY = "notification";
private TaskInterface mTask;
public BackgroundRemoteNotificationTaskConsumer(Context context, TaskManagerUtilsInterface taskManagerUtils) {
super(context, taskManagerUtils);
FirebaseMessagingDelegate.Companion.addBackgroundTaskConsumer(this);
}
//region TaskConsumerInterface
@Override
public String taskType() {
return "remote-notification";
}
@Override
public void didRegister(TaskInterface task) {
mTask = task;
}
@Override
public void didUnregister() {
mTask = null;
}
public void scheduleJob(Bundle bundle) {
Context context = getContext();
if (context != null && mTask != null) {
PersistableBundle data = new PersistableBundle();
// Bundles are not persistable, so let's convert to a JSON string
data.putString(NOTIFICATION_KEY, bundleToJson(bundle).toString());
getTaskManagerUtils().scheduleJob(context, mTask, Collections.singletonList(data));
}
}
@Override
public boolean didExecuteJob(final JobService jobService, final JobParameters params) {
if (mTask == null) {
return false;
}
List<PersistableBundle> data = getTaskManagerUtils().extractDataFromJobParams(params);
for (PersistableBundle item : data) {
Bundle bundle = new Bundle();
bundle.putBundle(NOTIFICATION_KEY, jsonStringToBundle(item.getString(NOTIFICATION_KEY)));
mTask.execute(bundle, null, new TaskExecutionCallback() {
@Override
public void onFinished(Map<String, Object> response) {
jobService.jobFinished(params, false);
}
});
}
// Returning `true` indicates that the job is still running, but in async mode.
// In that case we're obligated to call `jobService.jobFinished` as soon as the async block finishes.
return true;
}
//endregion
//region private methods
private static JSONObject bundleToJson(Bundle bundle) {
JSONObject json = new JSONObject();
for (String key : bundle.keySet()) {
try {
if (bundle.get(key) instanceof Bundle) {
json.put(key, bundleToJson((Bundle) bundle.get(key)));
} else {
json.put(key, JSONObject.wrap(bundle.get(key)));
}
} catch(JSONException e) {
Log.e("expo-notifications", "Could not create JSON object from notification bundle. " + e.getMessage());
}
}
return json;
}
private static Bundle jsonStringToBundle(String jsonString) {
Bundle bundle = new Bundle();
try {
JSONObject jsonObject = new JSONObject(jsonString);
bundle = NotificationSerializer.toBundle(jsonObject);
} catch (JSONException e) {
Log.e("expo-notifications", "Could not parse notification from JSON string. " + e.getMessage());
}
return bundle;
}
//endregion
}

View File

@@ -0,0 +1,32 @@
package expo.modules.notifications.notifications.background
import expo.modules.interfaces.taskManager.TaskManagerInterface
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.notifications.ModuleNotFoundException
class ExpoBackgroundNotificationTasksModule : Module() {
private val taskManager: TaskManagerInterface by lazy {
return@lazy appContext.legacyModule()
?: throw ModuleNotFoundException(TaskManagerInterface::class)
}
override fun definition() = ModuleDefinition {
Name("ExpoBackgroundNotificationTasksModule")
AsyncFunction("registerTaskAsync") { taskName: String ->
taskManager.registerTask(
taskName,
BackgroundRemoteNotificationTaskConsumer::class.java,
emptyMap()
)
}
AsyncFunction("unregisterTaskAsync") { taskName: String ->
taskManager.unregisterTask(
taskName,
BackgroundRemoteNotificationTaskConsumer::class.java
)
}
}
}

View File

@@ -0,0 +1,149 @@
package expo.modules.notifications.notifications.categories
import android.content.Context
import android.os.Bundle
import expo.modules.core.errors.InvalidArgumentException
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.records.Required
import expo.modules.notifications.ModuleNotFoundException
import expo.modules.notifications.ResultReceiverBody
import expo.modules.notifications.createDefaultResultReceiver
import expo.modules.notifications.notifications.categories.serializers.NotificationsCategoriesSerializer
import expo.modules.notifications.notifications.model.NotificationAction
import expo.modules.notifications.notifications.model.NotificationCategory
import expo.modules.notifications.notifications.model.TextInputNotificationAction
import expo.modules.notifications.service.NotificationsService
import expo.modules.notifications.service.NotificationsService.Companion.deleteCategory
import expo.modules.notifications.service.NotificationsService.Companion.getCategories
import expo.modules.notifications.service.NotificationsService.Companion.setCategory
class NotificationActionRecord : Record {
@Field
@Required
val identifier: String = ""
@Field
@Required
val buttonTitle: String = ""
@Field
val textInput: TextInput? = null
@Field
val options = Options()
class TextInput : Record {
@Field
@Required
val placeholder: String = ""
}
class Options : Record {
@Field
val opensAppToForeground = true
}
}
open class ExpoNotificationCategoriesModule : Module() {
protected val serializer by lazy {
appContext.legacyModule<NotificationsCategoriesSerializer>()
?: throw ModuleNotFoundException(NotificationsCategoriesSerializer::class)
}
private val context: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
override fun definition() = ModuleDefinition {
Name("ExpoNotificationCategoriesModule")
AsyncFunction("getNotificationCategoriesAsync") { promise: Promise ->
getCategories(
context,
createResultReceiver { resultCode: Int, resultData: Bundle? ->
val categories = resultData?.getParcelableArrayList<NotificationCategory>(NotificationsService.NOTIFICATION_CATEGORIES_KEY)
if (resultCode == NotificationsService.SUCCESS_CODE && categories != null) {
promise.resolve(serializeCategories(categories))
} else {
promise.reject("ERR_CATEGORIES_FETCH_FAILED", "A list of notification categories could not be fetched.", null)
}
}
)
}
AsyncFunction("setNotificationCategoryAsync", this@ExpoNotificationCategoriesModule::setNotificationCategoryAsync)
AsyncFunction("deleteNotificationCategoryAsync", this@ExpoNotificationCategoriesModule::deleteNotificationCategoryAsync)
}
private fun createResultReceiver(body: ResultReceiverBody) =
createDefaultResultReceiver(null, body)
open fun setNotificationCategoryAsync(
identifier: String,
actionArguments: List<NotificationActionRecord>,
categoryOptions: Map<String, Any?>?,
promise: Promise
) {
val actions = mutableListOf<NotificationAction>()
for (actionMap in actionArguments) {
val textInputOptions = actionMap.textInput
if (textInputOptions != null) {
actions.add(
TextInputNotificationAction(
actionMap.identifier,
actionMap.buttonTitle,
actionMap.options.opensAppToForeground,
textInputOptions.placeholder
)
)
} else {
actions.add(
NotificationAction(
actionMap.identifier,
actionMap.buttonTitle,
actionMap.options.opensAppToForeground
)
)
}
}
if (actions.isEmpty()) {
throw InvalidArgumentException("Invalid arguments provided for notification category. Must provide at least one action.")
}
setCategory(
context,
NotificationCategory(identifier, actions),
createResultReceiver { resultCode: Int, resultData: Bundle? ->
val category = resultData?.getParcelable<NotificationCategory>(NotificationsService.NOTIFICATION_CATEGORY_KEY)
if (resultCode == NotificationsService.SUCCESS_CODE && category != null) {
promise.resolve(serializer.toBundle(category))
} else {
promise.reject("ERR_CATEGORY_SET_FAILED", "The provided category could not be set.", null)
}
}
)
}
open fun deleteNotificationCategoryAsync(identifier: String, promise: Promise) {
deleteCategory(
context,
identifier,
createResultReceiver { resultCode: Int, resultData: Bundle? ->
if (resultCode == NotificationsService.SUCCESS_CODE) {
promise.resolve(resultData?.getBoolean(NotificationsService.SUCCEEDED_KEY))
} else {
promise.reject("ERR_CATEGORY_DELETE_FAILED", "The category could not be deleted.", null)
}
}
)
}
protected open fun serializeCategories(categories: Collection<NotificationCategory>): List<Bundle?> {
return categories.map(serializer::toBundle)
}
}

View File

@@ -0,0 +1,67 @@
package expo.modules.notifications.notifications.categories.serializers;
import android.os.Bundle;
import expo.modules.core.interfaces.InternalModule;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.model.NotificationAction;
import expo.modules.notifications.notifications.model.NotificationCategory;
import expo.modules.notifications.notifications.model.TextInputNotificationAction;
public class ExpoNotificationsCategoriesSerializer implements NotificationsCategoriesSerializer, InternalModule {
@Override
public List<? extends Class> getExportedInterfaces() {
return Collections.singletonList(NotificationsCategoriesSerializer.class);
}
@Nullable
@Override
public Bundle toBundle(@Nullable NotificationCategory category) {
if (category == null) {
return null;
}
Bundle serializedCategory = new Bundle();
serializedCategory.putString("identifier", getIdentifier(category));
serializedCategory.putParcelableArrayList("actions", toBundleList(category.getActions()));
// Android doesn't support any category options
serializedCategory.putBundle("options", new Bundle());
return serializedCategory;
}
protected String getIdentifier(@NonNull NotificationCategory category) {
return category.getIdentifier();
}
private ArrayList<Bundle> toBundleList(List<NotificationAction> actions) {
ArrayList<Bundle> result = new ArrayList<>();
for (NotificationAction action : actions) {
result.add(toBundle(action));
}
return result;
}
private Bundle toBundle(NotificationAction action) {
// First we bundle up the options
Bundle serializedActionOptions = new Bundle();
serializedActionOptions.putBoolean("opensAppToForeground", action.opensAppToForeground());
Bundle serializedAction = new Bundle();
serializedAction.putString("identifier", action.getIdentifier());
serializedAction.putString("buttonTitle", action.getTitle());
serializedAction.putBundle("options", serializedActionOptions);
if (action instanceof TextInputNotificationAction) {
Bundle serializedTextInputOptions = new Bundle();
serializedTextInputOptions.putString("placeholder", ((TextInputNotificationAction) action).getPlaceholder());
serializedAction.putBundle("textInput", serializedTextInputOptions);
}
return serializedAction;
}
}

View File

@@ -0,0 +1,11 @@
package expo.modules.notifications.notifications.categories.serializers;
import android.os.Bundle;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.model.NotificationCategory;
public interface NotificationsCategoriesSerializer {
@Nullable
Bundle toBundle(@Nullable NotificationCategory category);
}

View File

@@ -0,0 +1,81 @@
package expo.modules.notifications.notifications.channels;
import android.content.Context;
import expo.modules.core.ModuleRegistry;
import expo.modules.core.interfaces.InternalModule;
import java.util.Collections;
import java.util.List;
import expo.modules.notifications.notifications.channels.managers.NotificationsChannelGroupManager;
import expo.modules.notifications.notifications.channels.managers.NotificationsChannelManager;
import expo.modules.notifications.notifications.channels.serializers.NotificationsChannelGroupSerializer;
import expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer;
public abstract class AbstractNotificationsChannelsProvider implements NotificationsChannelsProvider, InternalModule {
protected final Context mContext;
private NotificationsChannelManager mChannelManager;
private NotificationsChannelGroupManager mChannelGroupManager;
private NotificationsChannelSerializer mChannelSerializer;
private NotificationsChannelGroupSerializer mChannelGroupSerializer;
private ModuleRegistry mModuleRegistry;
public AbstractNotificationsChannelsProvider(Context context) {
mContext = context;
}
public List<? extends Class> getExportedInterfaces() {
return Collections.singletonList(NotificationsChannelsProvider.class);
}
@Override
public void onCreate(ModuleRegistry moduleRegistry) {
mModuleRegistry = moduleRegistry;
}
public final ModuleRegistry getModuleRegistry() {
return mModuleRegistry;
}
@Override
public final NotificationsChannelManager getChannelManager() {
if (mChannelManager == null) {
mChannelManager = createChannelManager();
}
return mChannelManager;
}
@Override
public final NotificationsChannelGroupManager getGroupManager() {
if (mChannelGroupManager == null) {
mChannelGroupManager = createChannelGroupManager();
}
return mChannelGroupManager;
}
@Override
public final NotificationsChannelSerializer getChannelSerializer() {
if (mChannelSerializer == null) {
mChannelSerializer = createChannelSerializer();
}
return mChannelSerializer;
}
@Override
public final NotificationsChannelGroupSerializer getGroupSerializer() {
if (mChannelGroupSerializer == null) {
mChannelGroupSerializer = createChannelGroupSerializer();
}
return mChannelGroupSerializer;
}
protected abstract NotificationsChannelManager createChannelManager();
protected abstract NotificationsChannelGroupManager createChannelGroupManager();
protected abstract NotificationsChannelSerializer createChannelSerializer();
protected abstract NotificationsChannelGroupSerializer createChannelGroupSerializer();
}

View File

@@ -0,0 +1,38 @@
package expo.modules.notifications.notifications.channels;
import android.content.Context;
import expo.modules.notifications.notifications.channels.managers.AndroidXNotificationsChannelGroupManager;
import expo.modules.notifications.notifications.channels.managers.AndroidXNotificationsChannelManager;
import expo.modules.notifications.notifications.channels.managers.NotificationsChannelGroupManager;
import expo.modules.notifications.notifications.channels.managers.NotificationsChannelManager;
import expo.modules.notifications.notifications.channels.serializers.ExpoNotificationsChannelGroupSerializer;
import expo.modules.notifications.notifications.channels.serializers.ExpoNotificationsChannelSerializer;
import expo.modules.notifications.notifications.channels.serializers.NotificationsChannelGroupSerializer;
import expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer;
public class AndroidXNotificationsChannelsProvider extends AbstractNotificationsChannelsProvider {
public AndroidXNotificationsChannelsProvider(Context context) {
super(context);
}
@Override
protected NotificationsChannelManager createChannelManager() {
return new AndroidXNotificationsChannelManager(mContext, getGroupManager());
}
@Override
protected NotificationsChannelGroupManager createChannelGroupManager() {
return new AndroidXNotificationsChannelGroupManager(mContext);
}
@Override
protected NotificationsChannelSerializer createChannelSerializer() {
return new ExpoNotificationsChannelSerializer();
}
@Override
protected NotificationsChannelGroupSerializer createChannelGroupSerializer() {
return new ExpoNotificationsChannelGroupSerializer(getChannelSerializer());
}
}

View File

@@ -0,0 +1,14 @@
package expo.modules.notifications.notifications.channels;
import expo.modules.core.errors.CodedRuntimeException;
public class InvalidVibrationPatternException extends CodedRuntimeException {
public InvalidVibrationPatternException(int invalidValueKey, Object invalidValue) {
super("Invalid value in vibration pattern, expected all elements to be numbers, got: " + invalidValue + " under " + invalidValueKey);
}
@Override
public String getCode() {
return "ERR_INVALID_VIBRATION_PATTERN";
}
}

View File

@@ -0,0 +1,71 @@
package expo.modules.notifications.notifications.channels
import android.os.Build
import android.os.Bundle
import expo.modules.core.arguments.ReadableArguments
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.notifications.ModuleNotFoundException
import expo.modules.notifications.notifications.channels.managers.NotificationsChannelGroupManager
import expo.modules.notifications.notifications.channels.serializers.NotificationsChannelGroupSerializer
/**
* An exported module responsible for exposing methods for managing notification channel groups.
*/
class NotificationChannelGroupManagerModule : Module() {
private lateinit var groupManager: NotificationsChannelGroupManager
private lateinit var groupSerializer: NotificationsChannelGroupSerializer
override fun definition() = ModuleDefinition {
Name("ExpoNotificationChannelGroupManager")
OnCreate {
val provider = appContext.legacyModule<NotificationsChannelsProvider>()
?: throw ModuleNotFoundException(NotificationsChannelsProvider::class)
groupManager = provider.groupManager
groupSerializer = provider.groupSerializer
}
AsyncFunction("getNotificationChannelGroupAsync") { groupId: String ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val group = groupManager.getNotificationChannelGroup(groupId)
groupSerializer.toBundle(group)
} else {
null
}
}
AsyncFunction<List<Bundle?>?>("getNotificationChannelGroupsAsync") {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
groupManager
.notificationChannelGroups
.map(groupSerializer::toBundle)
} else {
null
}
}
AsyncFunction("setNotificationChannelGroupAsync") { groupId: String, groupOptions: ReadableArguments ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val group = groupManager.createNotificationChannelGroup(
groupId,
getNameFromOptions(groupOptions),
groupOptions
)
groupSerializer.toBundle(group)
} else {
null
}
}
AsyncFunction("deleteNotificationChannelGroupAsync") { groupId: String ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
groupManager.deleteNotificationChannelGroup(groupId)
}
}
}
private fun getNameFromOptions(groupOptions: ReadableArguments): String {
return groupOptions.getString(NotificationsChannelGroupSerializer.NAME_KEY)
}
}

View File

@@ -0,0 +1,83 @@
package expo.modules.notifications.notifications.channels
import android.os.Build
import android.os.Bundle
import androidx.annotation.RequiresApi
import expo.modules.core.arguments.ReadableArguments
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.notifications.ModuleNotFoundException
import expo.modules.notifications.notifications.channels.managers.NotificationsChannelManager
import expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer
import expo.modules.notifications.notifications.enums.NotificationImportance
import java.util.Objects
/**
* An exported module responsible for exposing methods for managing notification channels.
*/
open class NotificationChannelManagerModule : Module() {
private lateinit var channelManager: NotificationsChannelManager
private lateinit var channelSerializer: NotificationsChannelSerializer
override fun definition() = ModuleDefinition {
Name("ExpoNotificationChannelManager")
OnCreate {
val provider = appContext.legacyModule<NotificationsChannelsProvider>()
?: throw ModuleNotFoundException(NotificationsChannelsProvider::class)
channelManager = provider.channelManager
channelSerializer = provider.channelSerializer
}
AsyncFunction<List<Bundle?>>("getNotificationChannelsAsync") {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return@AsyncFunction emptyList<Bundle>()
}
return@AsyncFunction channelManager
.notificationChannels
.map(channelSerializer::toBundle)
}
AsyncFunction("getNotificationChannelAsync") { channelId: String ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = channelManager.getNotificationChannel(channelId)
channelSerializer.toBundle(notificationChannel)
} else {
null
}
}
AsyncFunction("setNotificationChannelAsync") { channelId: String, channelOptions: ReadableArguments ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = channelManager.createNotificationChannel(
channelId,
getNameFromOptions(channelOptions),
getImportanceFromOptions(channelOptions),
channelOptions
)
channelSerializer.toBundle(channel)
} else {
null
}
}
AsyncFunction("deleteNotificationChannelAsync") { channelId: String ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
channelManager.deleteNotificationChannel(channelId)
}
}
}
private fun getNameFromOptions(channelOptions: ReadableArguments): CharSequence {
return channelOptions.getString(NotificationsChannelSerializer.NAME_KEY)
}
@RequiresApi(api = Build.VERSION_CODES.N)
private fun getImportanceFromOptions(channelOptions: ReadableArguments): Int {
val enumValue = channelOptions.getInt(NotificationsChannelSerializer.IMPORTANCE_KEY, NotificationImportance.DEFAULT.enumValue)
val importance = Objects.requireNonNull(NotificationImportance.fromEnumValue(enumValue))
return importance.nativeValue
}
}

View File

@@ -0,0 +1,13 @@
package expo.modules.notifications.notifications.channels;
import expo.modules.notifications.notifications.channels.managers.NotificationsChannelGroupManager;
import expo.modules.notifications.notifications.channels.managers.NotificationsChannelManager;
import expo.modules.notifications.notifications.channels.serializers.NotificationsChannelGroupSerializer;
import expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer;
public interface NotificationsChannelsProvider {
NotificationsChannelManager getChannelManager();
NotificationsChannelGroupManager getGroupManager();
NotificationsChannelSerializer getChannelSerializer();
NotificationsChannelGroupSerializer getGroupSerializer();
}

View File

@@ -0,0 +1,67 @@
package expo.modules.notifications.notifications.channels.managers;
import android.app.NotificationChannelGroup;
import android.content.Context;
import android.os.Build;
import expo.modules.core.arguments.ReadableArguments;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationManagerCompat;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelGroupSerializer.DESCRIPTION_KEY;
public class AndroidXNotificationsChannelGroupManager implements NotificationsChannelGroupManager {
private final NotificationManagerCompat mNotificationManager;
public AndroidXNotificationsChannelGroupManager(Context context) {
mNotificationManager = NotificationManagerCompat.from(context);
}
@Nullable
@Override
@RequiresApi(api = Build.VERSION_CODES.O)
public NotificationChannelGroup getNotificationChannelGroup(@NonNull String channelGroupId) {
return mNotificationManager.getNotificationChannelGroup(channelGroupId);
}
@NonNull
@Override
@RequiresApi(api = Build.VERSION_CODES.O)
public List<NotificationChannelGroup> getNotificationChannelGroups() {
return mNotificationManager.getNotificationChannelGroups();
}
@Override
@RequiresApi(api = Build.VERSION_CODES.O)
public NotificationChannelGroup createNotificationChannelGroup(@NonNull String groupId, @NonNull CharSequence name, ReadableArguments groupOptions) {
NotificationChannelGroup group = new NotificationChannelGroup(groupId, name);
configureGroupWithOptions(group, groupOptions);
mNotificationManager.createNotificationChannelGroup(group);
return group;
}
@Override
@RequiresApi(api = Build.VERSION_CODES.O)
public void deleteNotificationChannelGroup(@NonNull String groupId) {
mNotificationManager.deleteNotificationChannelGroup(groupId);
}
// Processing options
@RequiresApi(api = Build.VERSION_CODES.O)
protected void configureGroupWithOptions(Object maybeGroup, ReadableArguments groupOptions) {
if (!(maybeGroup instanceof NotificationChannelGroup)) {
return;
}
NotificationChannelGroup group = (NotificationChannelGroup) maybeGroup;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (groupOptions.containsKey(DESCRIPTION_KEY)) {
group.setDescription(groupOptions.getString(DESCRIPTION_KEY));
}
}
}
}

View File

@@ -0,0 +1,196 @@
package expo.modules.notifications.notifications.channels.managers;
import android.app.NotificationChannel;
import android.app.NotificationChannelGroup;
import android.content.Context;
import android.graphics.Color;
import android.media.AudioAttributes;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import expo.modules.core.arguments.MapArguments;
import expo.modules.core.arguments.ReadableArguments;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationManagerCompat;
import expo.modules.notifications.notifications.SoundResolver;
import expo.modules.notifications.notifications.channels.InvalidVibrationPatternException;
import expo.modules.notifications.notifications.enums.AudioContentType;
import expo.modules.notifications.notifications.enums.AudioUsage;
import expo.modules.notifications.notifications.enums.NotificationVisibility;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.AUDIO_ATTRIBUTES_CONTENT_TYPE_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.AUDIO_ATTRIBUTES_FLAGS_ENFORCE_AUDIBILITY_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.AUDIO_ATTRIBUTES_FLAGS_HW_AV_SYNC_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.AUDIO_ATTRIBUTES_FLAGS_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.AUDIO_ATTRIBUTES_USAGE_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.BYPASS_DND_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.DESCRIPTION_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.ENABLE_LIGHTS_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.ENABLE_VIBRATE_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.GROUP_ID_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.LIGHT_COLOR_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.LOCKSCREEN_VISIBILITY_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.SHOW_BADGE_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.SOUND_AUDIO_ATTRIBUTES_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.SOUND_KEY;
import static expo.modules.notifications.notifications.channels.serializers.NotificationsChannelSerializer.VIBRATION_PATTERN_KEY;
public class AndroidXNotificationsChannelManager implements NotificationsChannelManager {
private final NotificationManagerCompat mNotificationManager;
private NotificationsChannelGroupManager mNotificationsChannelGroupManager;
private final SoundResolver mSoundResolver;
public AndroidXNotificationsChannelManager(Context context, NotificationsChannelGroupManager groupManager) {
mNotificationManager = NotificationManagerCompat.from(context);
mSoundResolver = new SoundResolver(context);
mNotificationsChannelGroupManager = groupManager;
}
@Nullable
@Override
@RequiresApi(api = Build.VERSION_CODES.O)
public NotificationChannel getNotificationChannel(@NonNull String channelId) {
return mNotificationManager.getNotificationChannel(channelId);
}
@NonNull
@Override
@RequiresApi(api = Build.VERSION_CODES.O)
public List<NotificationChannel> getNotificationChannels() {
return mNotificationManager.getNotificationChannels();
}
@Override
@RequiresApi(api = Build.VERSION_CODES.O)
public void deleteNotificationChannel(@NonNull String channelId) {
mNotificationManager.deleteNotificationChannel(channelId);
}
@Override
@RequiresApi(api = Build.VERSION_CODES.O)
public NotificationChannel createNotificationChannel(@NonNull String channelId, CharSequence name, int importance, ReadableArguments channelOptions) {
NotificationChannel channel = new NotificationChannel(channelId, name, importance);
configureChannelWithOptions(channel, channelOptions);
mNotificationManager.createNotificationChannel(channel);
return channel;
}
// Processing options
@RequiresApi(api = Build.VERSION_CODES.O)
protected void configureChannelWithOptions(Object maybeChannel, ReadableArguments args) {
// We cannot use NotificationChannel in the signature of the method
// since it's a class available only on newer OSes and the adapter iterates
// through all the methods and triggers the NoClassDefFoundError.
if (!(maybeChannel instanceof NotificationChannel)) {
return;
}
NotificationChannel channel = (NotificationChannel) maybeChannel;
if (args.containsKey(BYPASS_DND_KEY)) {
channel.setBypassDnd(args.getBoolean(BYPASS_DND_KEY));
}
if (args.containsKey(DESCRIPTION_KEY)) {
channel.setDescription(args.getString(DESCRIPTION_KEY));
}
if (args.containsKey(LIGHT_COLOR_KEY)) {
channel.setLightColor(Color.parseColor(args.getString(LIGHT_COLOR_KEY)));
}
if (args.containsKey(GROUP_ID_KEY)) {
String groupId = args.getString(GROUP_ID_KEY);
NotificationChannelGroup group = mNotificationsChannelGroupManager.getNotificationChannelGroup(groupId);
if (group == null) {
group = mNotificationsChannelGroupManager.createNotificationChannelGroup(groupId, groupId, new MapArguments());
}
channel.setGroup(group.getId());
}
if (args.containsKey(LOCKSCREEN_VISIBILITY_KEY)) {
NotificationVisibility visibility = NotificationVisibility.fromEnumValue(args.getInt(LOCKSCREEN_VISIBILITY_KEY));
if (visibility != null) {
channel.setLockscreenVisibility(visibility.getNativeValue());
}
}
if (args.containsKey(SHOW_BADGE_KEY)) {
channel.setShowBadge(args.getBoolean(SHOW_BADGE_KEY));
}
if (args.containsKey(SOUND_KEY) || args.containsKey(SOUND_AUDIO_ATTRIBUTES_KEY)) {
Uri soundUri = createSoundUriFromArguments(args);
AudioAttributes soundAttributes = createAttributesFromArguments(args.getArguments(SOUND_AUDIO_ATTRIBUTES_KEY));
channel.setSound(soundUri, soundAttributes);
}
if (args.containsKey(VIBRATION_PATTERN_KEY)) {
channel.setVibrationPattern(createVibrationPatternFromList(args.getList(VIBRATION_PATTERN_KEY)));
}
if (args.containsKey(ENABLE_LIGHTS_KEY)) {
channel.enableLights(args.getBoolean(ENABLE_LIGHTS_KEY));
}
if (args.containsKey(ENABLE_VIBRATE_KEY)) {
channel.enableVibration(args.getBoolean(ENABLE_VIBRATE_KEY));
}
}
@Nullable
protected Uri createSoundUriFromArguments(ReadableArguments args) {
// The default is... the default sound.
if (!args.containsKey(SOUND_KEY)) {
return Settings.System.DEFAULT_NOTIFICATION_URI;
}
// "null" means "no sound"
String filename = args.getString(SOUND_KEY);
if (filename == null) {
return null;
}
// Otherwise it should be a sound filename
return mSoundResolver.resolve(filename);
}
@Nullable
protected long[] createVibrationPatternFromList(@Nullable List patternRequest) throws InvalidVibrationPatternException {
if (patternRequest == null) {
return null;
}
long[] pattern = new long[patternRequest.size()];
for (int i = 0; i < patternRequest.size(); i++) {
if (patternRequest.get(i) instanceof Number) {
pattern[i] = ((Number) patternRequest.get(i)).longValue();
} else {
throw new InvalidVibrationPatternException(i, patternRequest.get(i));
}
}
return pattern;
}
@Nullable
protected AudioAttributes createAttributesFromArguments(@Nullable ReadableArguments args) {
if (args == null) {
return null;
}
AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder();
if (args.containsKey(AUDIO_ATTRIBUTES_USAGE_KEY)) {
attributesBuilder.setUsage(AudioUsage.fromEnumValue(args.getInt(AUDIO_ATTRIBUTES_USAGE_KEY)).getNativeValue());
}
if (args.containsKey(AUDIO_ATTRIBUTES_CONTENT_TYPE_KEY)) {
attributesBuilder.setContentType(AudioContentType.fromEnumValue(args.getInt(AUDIO_ATTRIBUTES_CONTENT_TYPE_KEY)).getNativeValue());
}
if (args.containsKey(AUDIO_ATTRIBUTES_FLAGS_KEY)) {
int flags = 0;
ReadableArguments flagsArgs = args.getArguments(AUDIO_ATTRIBUTES_FLAGS_KEY);
if (flagsArgs.getBoolean(AUDIO_ATTRIBUTES_FLAGS_ENFORCE_AUDIBILITY_KEY)) {
flags |= AudioAttributes.FLAG_AUDIBILITY_ENFORCED;
}
if (flagsArgs.getBoolean(AUDIO_ATTRIBUTES_FLAGS_HW_AV_SYNC_KEY)) {
flags |= AudioAttributes.FLAG_HW_AV_SYNC;
}
attributesBuilder.setFlags(flags);
}
return attributesBuilder.build();
}
}

View File

@@ -0,0 +1,28 @@
package expo.modules.notifications.notifications.channels.managers;
import android.app.NotificationChannelGroup;
import android.os.Build;
import expo.modules.core.arguments.ReadableArguments;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
public interface NotificationsChannelGroupManager {
@Nullable
@RequiresApi(api = Build.VERSION_CODES.O)
NotificationChannelGroup getNotificationChannelGroup(@NonNull String channelGroupId);
@NonNull
@RequiresApi(api = Build.VERSION_CODES.O)
List<NotificationChannelGroup> getNotificationChannelGroups();
@RequiresApi(api = Build.VERSION_CODES.O)
NotificationChannelGroup createNotificationChannelGroup(@NonNull String id, @NonNull CharSequence name, ReadableArguments groupOptions);
@RequiresApi(api = Build.VERSION_CODES.O)
void deleteNotificationChannelGroup(@NonNull String groupId);
}

View File

@@ -0,0 +1,28 @@
package expo.modules.notifications.notifications.channels.managers;
import android.app.NotificationChannel;
import android.os.Build;
import expo.modules.core.arguments.ReadableArguments;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
public interface NotificationsChannelManager {
@Nullable
@RequiresApi(api = Build.VERSION_CODES.O)
NotificationChannel getNotificationChannel(@NonNull String channelId);
@NonNull
@RequiresApi(api = Build.VERSION_CODES.O)
List<NotificationChannel> getNotificationChannels();
@RequiresApi(api = Build.VERSION_CODES.O)
void deleteNotificationChannel(@NonNull String channelId);
@RequiresApi(api = Build.VERSION_CODES.O)
NotificationChannel createNotificationChannel(@NonNull String channelId, CharSequence name, int importance, ReadableArguments channelOptions);
}

View File

@@ -0,0 +1,55 @@
package expo.modules.notifications.notifications.channels.serializers;
import android.app.NotificationChannel;
import android.app.NotificationChannelGroup;
import android.os.Build;
import android.os.Bundle;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
public class ExpoNotificationsChannelGroupSerializer implements NotificationsChannelGroupSerializer {
private NotificationsChannelSerializer mChannelSerializer;
public ExpoNotificationsChannelGroupSerializer(NotificationsChannelSerializer channelSerializer) {
mChannelSerializer = channelSerializer;
}
@Override
@Nullable
@RequiresApi(api = Build.VERSION_CODES.O)
public Bundle toBundle(@Nullable NotificationChannelGroup group) {
if (group == null) {
return null;
}
Bundle result = new Bundle();
result.putString(ID_KEY, getId(group));
result.putString(NAME_KEY, group.getName().toString());
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
result.putString(DESCRIPTION_KEY, group.getDescription());
result.putBoolean(IS_BLOCKED_KEY, group.isBlocked());
}
result.putParcelableArrayList(CHANNELS_KEY, toList(group.getChannels()));
return result;
}
@Nullable
@RequiresApi(api = Build.VERSION_CODES.O)
protected String getId(@NonNull NotificationChannelGroup channel) {
return channel.getId();
}
@RequiresApi(api = Build.VERSION_CODES.O)
private ArrayList<Bundle> toList(List<NotificationChannel> channels) {
ArrayList<Bundle> results = new ArrayList<>(channels.size());
for (NotificationChannel channel : channels) {
results.add(mChannelSerializer.toBundle(channel));
}
return results;
}
}

View File

@@ -0,0 +1,101 @@
package expo.modules.notifications.notifications.channels.serializers;
import android.app.NotificationChannel;
import android.graphics.Color;
import android.media.AudioAttributes;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import expo.modules.notifications.notifications.enums.AudioContentType;
import expo.modules.notifications.notifications.enums.AudioUsage;
import expo.modules.notifications.notifications.enums.NotificationImportance;
import expo.modules.notifications.notifications.enums.NotificationVisibility;
public class ExpoNotificationsChannelSerializer implements NotificationsChannelSerializer {
@Override
@Nullable
@RequiresApi(api = Build.VERSION_CODES.O)
public Bundle toBundle(@Nullable NotificationChannel channel) {
if (channel == null) {
return null;
}
Bundle result = new Bundle();
result.putString(ID_KEY, getChannelId(channel));
result.putString(NAME_KEY, channel.getName().toString());
result.putInt(IMPORTANCE_KEY, NotificationImportance.fromNativeValue(channel.getImportance()).getEnumValue());
result.putBoolean(BYPASS_DND_KEY, channel.canBypassDnd());
result.putString(DESCRIPTION_KEY, channel.getDescription());
result.putString(GROUP_ID_KEY, getGroupId(channel));
result.putString(LIGHT_COLOR_KEY, String.format("#%08x", Color.valueOf(channel.getLightColor()).toArgb()).toUpperCase());
result.putInt(LOCKSCREEN_VISIBILITY_KEY, NotificationVisibility.fromNativeValue(channel.getLockscreenVisibility()).getEnumValue());
result.putBoolean(SHOW_BADGE_KEY, channel.canShowBadge());
result.putString(SOUND_KEY, toString(channel.getSound()));
result.putBundle(SOUND_AUDIO_ATTRIBUTES_KEY, toBundle(channel.getAudioAttributes()));
result.putDoubleArray(VIBRATION_PATTERN_KEY, toArray(channel.getVibrationPattern()));
result.putBoolean(ENABLE_LIGHTS_KEY, channel.shouldShowLights());
result.putBoolean(ENABLE_VIBRATE_KEY, channel.shouldVibrate());
return result;
}
@Nullable
@RequiresApi(api = Build.VERSION_CODES.O)
protected String getChannelId(@NonNull NotificationChannel channel) {
return channel.getId();
}
@Nullable
@RequiresApi(api = Build.VERSION_CODES.O)
protected String getGroupId(@NonNull NotificationChannel channel) {
return channel.getGroup();
}
@Nullable
private String toString(@Nullable Uri uri) {
if (uri == null) {
return null;
}
if (Settings.System.DEFAULT_NOTIFICATION_URI.equals(uri)) {
return "default";
}
return "custom";
}
private Bundle toBundle(@Nullable AudioAttributes attributes) {
if (attributes == null) {
return null;
}
Bundle result = new Bundle();
result.putInt(AUDIO_ATTRIBUTES_USAGE_KEY, AudioUsage.fromNativeValue(attributes.getUsage()).getEnumValue());
result.putInt(AUDIO_ATTRIBUTES_CONTENT_TYPE_KEY, AudioContentType.fromNativeValue(attributes.getContentType()).getEnumValue());
Bundle flags = new Bundle();
flags.putBoolean(AUDIO_ATTRIBUTES_FLAGS_HW_AV_SYNC_KEY, (attributes.getFlags() & AudioAttributes.FLAG_HW_AV_SYNC) > 0);
flags.putBoolean(AUDIO_ATTRIBUTES_FLAGS_ENFORCE_AUDIBILITY_KEY, (attributes.getFlags() & AudioAttributes.FLAG_AUDIBILITY_ENFORCED) > 0);
result.putBundle(AUDIO_ATTRIBUTES_FLAGS_KEY, flags);
return result;
}
@Nullable
private double[] toArray(@Nullable long[] array) {
if (array == null) {
return null;
}
double[] result = new double[array.length];
for (int i = 0; i < array.length; i++) {
result[i] = array[i];
}
return result;
}
}

View File

@@ -0,0 +1,20 @@
package expo.modules.notifications.notifications.channels.serializers;
import android.app.NotificationChannelGroup;
import android.os.Build;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
public interface NotificationsChannelGroupSerializer {
String ID_KEY = "id";
String NAME_KEY = "name";
String DESCRIPTION_KEY = "description";
String IS_BLOCKED_KEY = "isBlocked";
String CHANNELS_KEY = "channels";
@Nullable
@RequiresApi(api = Build.VERSION_CODES.O)
Bundle toBundle(@Nullable NotificationChannelGroup group);
}

View File

@@ -0,0 +1,35 @@
package expo.modules.notifications.notifications.channels.serializers;
import android.app.NotificationChannel;
import android.os.Build;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
public interface NotificationsChannelSerializer {
String ID_KEY = "id";
String NAME_KEY = "name";
String IMPORTANCE_KEY = "importance";
String BYPASS_DND_KEY = "bypassDnd";
String DESCRIPTION_KEY = "description";
String GROUP_ID_KEY = "groupId";
String LIGHT_COLOR_KEY = "lightColor";
String LOCKSCREEN_VISIBILITY_KEY = "lockscreenVisibility";
String SHOW_BADGE_KEY = "showBadge";
String SOUND_KEY = "sound";
String SOUND_AUDIO_ATTRIBUTES_KEY = "audioAttributes";
String VIBRATION_PATTERN_KEY = "vibrationPattern";
String ENABLE_LIGHTS_KEY = "enableLights";
String ENABLE_VIBRATE_KEY = "enableVibrate";
String AUDIO_ATTRIBUTES_USAGE_KEY = "usage";
String AUDIO_ATTRIBUTES_CONTENT_TYPE_KEY = "contentType";
String AUDIO_ATTRIBUTES_FLAGS_KEY = "flags";
String AUDIO_ATTRIBUTES_FLAGS_ENFORCE_AUDIBILITY_KEY = "enforceAudibility";
String AUDIO_ATTRIBUTES_FLAGS_HW_AV_SYNC_KEY = "requestHardwareAudioVideoSynchronization";
@Nullable
@RequiresApi(api = Build.VERSION_CODES.O)
Bundle toBundle(@Nullable NotificationChannel channel);
}

View File

@@ -0,0 +1,85 @@
package expo.modules.notifications.notifications.debug
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.annotation.RequiresApi
import com.google.firebase.messaging.RemoteMessage
import expo.modules.notifications.BuildConfig
import expo.modules.notifications.notifications.model.Notification
import java.util.function.Consumer
object DebugLogging {
@JvmStatic
fun logBundle(caller: String, bundleToLog: Bundle) {
if (!BuildConfig.DEBUG) {
// Do not log in release/production builds
return
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return
}
Log.i("expo-notifications", "$caller:\n${bundleString(caller, bundleToLog, 0)}")
}
@RequiresApi(Build.VERSION_CODES.N)
private fun bundleString(ignoredCaller: String, bundleToLog: Bundle, indent: Int): String {
return buildString {
bundleToLog.keySet().forEach(
Consumer { key: String ->
val value = bundleToLog[key]
if (value is Bundle) {
append("${" ".repeat(indent)}${key}\n")
append(bundleString(ignoredCaller, value, indent + 2))
} else {
val stringValue = value?.toString() ?: "(null)"
append("${" ".repeat(indent)}$key: $stringValue\n")
}
}
)
}
}
fun logRemoteMessage(caller: String, message: RemoteMessage) {
if (!BuildConfig.DEBUG) {
// Do not log for release/production builds
return
}
val logMessage =
"""
$caller:
notification.channelId: ${message.notification?.channelId}
notification.vibrateTimings: ${message.notification?.vibrateTimings?.contentToString()}
notification.body: ${message.notification?.body}
notification.color: ${message.notification?.color}
notification.sound: ${message.notification?.sound}
notification.title: ${message.notification?.title}
notification.collapseKey: ${message.collapseKey}
data: ${message.data}
""".trimIndent()
Log.i("expo-notifications", logMessage)
}
fun logNotification(caller: String, notification: Notification) {
if (!BuildConfig.DEBUG) {
// Do not log for release/production builds
return
}
val logMessage =
"""
$caller:
notification.notificationRequest.content.title: ${notification.notificationRequest.content.title}
notification.notificationRequest.content.subtitle: ${notification.notificationRequest.content.subtitle}
notification.notificationRequest.content.text: ${notification.notificationRequest.content.text}
notification.notificationRequest.content.sound: ${notification.notificationRequest.content.soundName}
notification.notificationRequest.content.channelID: ${notification.notificationRequest.trigger.notificationChannel}
notification.notificationRequest.content.body: ${notification.notificationRequest.content.body}
notification.notificationRequest.content.color: ${notification.notificationRequest.content.color}
notification.notificationRequest.content.vibrationPattern: ${notification.notificationRequest.content.vibrationPattern?.contentToString()}
notification.notificationRequest.identifier: ${notification.notificationRequest.identifier}
""".trimIndent()
Log.i("expo-notifications", logMessage)
}
}

View File

@@ -0,0 +1,92 @@
package expo.modules.notifications.notifications.emitting
import android.os.Bundle
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.notifications.notifications.NotificationSerializer
import expo.modules.notifications.notifications.debug.DebugLogging
import expo.modules.notifications.notifications.interfaces.NotificationListener
import expo.modules.notifications.notifications.interfaces.NotificationManager
import expo.modules.notifications.notifications.model.Notification
import expo.modules.notifications.notifications.model.NotificationResponse
private const val NEW_MESSAGE_EVENT_NAME = "onDidReceiveNotification"
private const val NEW_RESPONSE_EVENT_NAME = "onDidReceiveNotificationResponse"
private const val MESSAGES_DELETED_EVENT_NAME = "onNotificationsDeleted"
open class NotificationsEmitter : Module(), NotificationListener {
private lateinit var notificationManager: NotificationManager
private var lastNotificationResponseBundle: Bundle? = null
override fun definition() = ModuleDefinition {
Name("ExpoNotificationsEmitter")
Events(
"onDidReceiveNotification",
"onNotificationsDeleted",
"onDidReceiveNotificationResponse"
)
OnCreate {
// Register the module as a listener in NotificationManager singleton module.
// Deregistration happens in onDestroy callback.
notificationManager = requireNotNull(appContext.legacyModuleRegistry.getSingletonModule("NotificationManager", NotificationManager::class.java))
notificationManager.addListener(this@NotificationsEmitter)
}
OnDestroy {
notificationManager.removeListener(this@NotificationsEmitter)
}
AsyncFunction<Bundle?>("getLastNotificationResponseAsync") {
lastNotificationResponseBundle
}
AsyncFunction("clearLastNotificationResponseAsync") {
lastNotificationResponseBundle = null
null
}
}
/**
* Callback called when [NotificationManager] gets notified of a new notification.
* Emits a [NEW_MESSAGE_EVENT_NAME] event.
*
* @param notification Notification received
*/
override fun onNotificationReceived(notification: Notification) {
val bundle = NotificationSerializer.toBundle(notification)
DebugLogging.logBundle("NotificationsEmitter.onNotificationReceived", bundle)
sendEvent(NEW_MESSAGE_EVENT_NAME, bundle)
}
/**
* Callback called when [NotificationManager] gets notified of a new notification response.
* Emits a [NEW_RESPONSE_EVENT_NAME] event.
*
* @param response Notification response received
* @return Whether notification has been handled
*/
override fun onNotificationResponseReceived(response: NotificationResponse): Boolean {
val bundle = NotificationSerializer.toBundle(response)
DebugLogging.logBundle("NotificationsEmitter.onNotificationResponseReceived", bundle)
lastNotificationResponseBundle = bundle
sendEvent(NEW_RESPONSE_EVENT_NAME, lastNotificationResponseBundle)
return true
}
override fun onNotificationResponseIntentReceived(extras: Bundle?) {
val bundle = NotificationSerializer.toResponseBundleFromExtras(extras)
DebugLogging.logBundle("NotificationsEmitter.onNotificationResponseIntentReceived", bundle)
lastNotificationResponseBundle = bundle
sendEvent(NEW_RESPONSE_EVENT_NAME, lastNotificationResponseBundle)
}
/**
* Callback called when [NotificationManager] gets informed of the fact of message dropping.
* Emits a [MESSAGES_DELETED_EVENT_NAME] event.
*/
override fun onNotificationsDropped() {
sendEvent(MESSAGES_DELETED_EVENT_NAME, Bundle.EMPTY)
}
}

View File

@@ -0,0 +1,45 @@
package expo.modules.notifications.notifications.enums;
import android.media.AudioAttributes;
public enum AudioContentType {
UNKNOWN(AudioAttributes.CONTENT_TYPE_UNKNOWN, 0),
SPEECH(AudioAttributes.CONTENT_TYPE_SPEECH, 1),
MUSIC(AudioAttributes.CONTENT_TYPE_MUSIC, 2),
MOVIE(AudioAttributes.CONTENT_TYPE_MOVIE, 3),
SONIFICIATION(AudioAttributes.CONTENT_TYPE_SONIFICATION, 4);
private final int mNativeVisibility;
private final int mEnumValue;
AudioContentType(int nativeVisibility, int enumValue) {
mNativeVisibility = nativeVisibility;
mEnumValue = enumValue;
}
public int getNativeValue() {
return mNativeVisibility;
}
public int getEnumValue() {
return mEnumValue;
}
public static AudioContentType fromEnumValue(int value) {
for (AudioContentType visibility : AudioContentType.values()) {
if (visibility.getEnumValue() == value) {
return visibility;
}
}
return AudioContentType.UNKNOWN;
}
public static AudioContentType fromNativeValue(int value) {
for (AudioContentType visibility : AudioContentType.values()) {
if (visibility.getEnumValue() == value) {
return visibility;
}
}
return AudioContentType.UNKNOWN;
}
}

View File

@@ -0,0 +1,55 @@
package expo.modules.notifications.notifications.enums;
import android.media.AudioAttributes;
public enum AudioUsage {
UNKNOWN(AudioAttributes.USAGE_UNKNOWN, 0),
MEDIA(AudioAttributes.USAGE_MEDIA, 1),
VOICE_COMMUNICATION(AudioAttributes.USAGE_VOICE_COMMUNICATION, 2),
VOICE_COMMUNICATION_SIGNALLING(AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING, 3),
ALARM(AudioAttributes.USAGE_ALARM, 4),
NOTIFICATION(AudioAttributes.USAGE_NOTIFICATION, 5),
NOTIFICATION_RINGTONE(AudioAttributes.USAGE_NOTIFICATION_RINGTONE, 6),
NOTIFICATION_COMMUNICATION_REQUEST(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_REQUEST, 7),
NOTIFICATION_COMMUNICATION_INSTANT(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT, 8),
NOTIFICATION_COMMUNICATION_DELAYED(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_DELAYED, 9),
NOTIFICATION_EVENT(AudioAttributes.USAGE_NOTIFICATION_EVENT, 10),
ASSISTANCE_ACCESSIBILITY(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY, 11),
ASSISTANCE_NAVIGATION_GUIDANCE(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE, 12),
ASSISTANCE_SONIFICATION(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION, 13),
GAME(AudioAttributes.USAGE_GAME, 14);
private final int mNativeVisibility;
private final int mEnumValue;
AudioUsage(int nativeVisibility, int enumValue) {
mNativeVisibility = nativeVisibility;
mEnumValue = enumValue;
}
public int getNativeValue() {
return mNativeVisibility;
}
public int getEnumValue() {
return mEnumValue;
}
public static AudioUsage fromEnumValue(int value) {
for (AudioUsage usage : AudioUsage.values()) {
if (usage.getEnumValue() == value) {
return usage;
}
}
return AudioUsage.UNKNOWN;
}
public static AudioUsage fromNativeValue(int value) {
for (AudioUsage usage : AudioUsage.values()) {
if (usage.getEnumValue() == value) {
return usage;
}
}
return AudioUsage.UNKNOWN;
}
}

View File

@@ -0,0 +1,48 @@
package expo.modules.notifications.notifications.enums;
import androidx.core.app.NotificationManagerCompat;
public enum NotificationImportance {
UNSPECIFIED(NotificationManagerCompat.IMPORTANCE_UNSPECIFIED, 1),
NONE(NotificationManagerCompat.IMPORTANCE_NONE, 2),
MIN(NotificationManagerCompat.IMPORTANCE_MIN, 3),
LOW(NotificationManagerCompat.IMPORTANCE_LOW, 4),
DEFAULT(NotificationManagerCompat.IMPORTANCE_DEFAULT, 5),
HIGH(NotificationManagerCompat.IMPORTANCE_HIGH, 6),
MAX(NotificationManagerCompat.IMPORTANCE_MAX, 7),
UNKNOWN(NotificationManagerCompat.IMPORTANCE_DEFAULT, 0);
private final int mNativeImportance;
private final int mEnumValue;
NotificationImportance(int nativeImportance, int enumValue) {
mNativeImportance = nativeImportance;
mEnumValue = enumValue;
}
public int getNativeValue() {
return mNativeImportance;
}
public int getEnumValue() {
return mEnumValue;
}
public static NotificationImportance fromEnumValue(int value) {
for (NotificationImportance importance : NotificationImportance.values()) {
if (importance.getEnumValue() == value) {
return importance;
}
}
return NotificationImportance.UNKNOWN;
}
public static NotificationImportance fromNativeValue(int value) {
for (NotificationImportance importance : NotificationImportance.values()) {
if (importance.getNativeValue() == value) {
return importance;
}
}
return NotificationImportance.UNKNOWN;
}
}

View File

@@ -0,0 +1,51 @@
package expo.modules.notifications.notifications.enums;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
/**
* An enum allowing easy conversion between platform enum and native priority.
*/
public enum NotificationPriority {
MIN(NotificationCompat.PRIORITY_MIN, "min"),
LOW(NotificationCompat.PRIORITY_LOW, "low"),
DEFAULT(NotificationCompat.PRIORITY_DEFAULT, "default"),
HIGH(NotificationCompat.PRIORITY_HIGH, "high"),
MAX(NotificationCompat.PRIORITY_MAX, "max");
private final int mNativePriority;
private final String mEnumValue;
NotificationPriority(int nativePriority, String enumValue) {
mNativePriority = nativePriority;
mEnumValue = enumValue;
}
public int getNativeValue() {
return mNativePriority;
}
public String getEnumValue() {
return mEnumValue;
}
@Nullable
public static NotificationPriority fromEnumValue(String value) {
for (NotificationPriority priority : NotificationPriority.values()) {
if (priority.getEnumValue().equalsIgnoreCase(value)) {
return priority;
}
}
return null;
}
@Nullable
public static NotificationPriority fromNativeValue(int value) {
for (NotificationPriority priority : NotificationPriority.values()) {
if (priority.getNativeValue() == value) {
return priority;
}
}
return null;
}
}

View File

@@ -0,0 +1,44 @@
package expo.modules.notifications.notifications.enums;
import android.app.Notification;
public enum NotificationVisibility {
PUBLIC(Notification.VISIBILITY_PUBLIC, 1),
PRIVATE(Notification.VISIBILITY_PRIVATE, 2),
SECRET(Notification.VISIBILITY_SECRET, 3),
UNKNOWN(Notification.VISIBILITY_PUBLIC, 0);
private final int mNativeVisibility;
private final int mEnumValue;
NotificationVisibility(int nativeVisibility, int enumValue) {
mNativeVisibility = nativeVisibility;
mEnumValue = enumValue;
}
public int getNativeValue() {
return mNativeVisibility;
}
public int getEnumValue() {
return mEnumValue;
}
public static NotificationVisibility fromEnumValue(int value) {
for (NotificationVisibility visibility : NotificationVisibility.values()) {
if (visibility.getEnumValue() == value) {
return visibility;
}
}
return NotificationVisibility.UNKNOWN;
}
public static NotificationVisibility fromNativeValue(int value) {
for (NotificationVisibility visibility : NotificationVisibility.values()) {
if (visibility.getNativeValue() == value) {
return visibility;
}
}
return NotificationVisibility.UNKNOWN;
}
}

View File

@@ -0,0 +1,137 @@
package expo.modules.notifications.notifications.handling
import android.os.Handler
import android.os.HandlerThread
import expo.modules.core.ModuleRegistry
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.notifications.NotificationWasAlreadyHandledException
import expo.modules.notifications.notifications.interfaces.NotificationListener
import expo.modules.notifications.notifications.interfaces.NotificationManager
import expo.modules.notifications.notifications.model.Notification
import expo.modules.notifications.notifications.model.NotificationBehavior
class NotificationBehaviourRecord : Record {
@Field
val shouldShowAlert: Boolean = false
@Field
val shouldPlaySound: Boolean = false
@Field
val shouldSetBadge: Boolean = false
@Field
val priority: String? = null
}
/**
* [NotificationListener] responsible for managing app's reaction to incoming
* notification.
*
*
* It is responsible for managing lifecycles of [SingleNotificationHandlerTask]s
* which are responsible: one for each notification. This module serves as holder
* for all of them and a proxy through which app responds with the behavior.
*/
open class NotificationsHandler : Module(), NotificationListener {
private lateinit var notificationManager: NotificationManager
private lateinit var moduleRegistry: ModuleRegistry
/**
* [HandlerThread] which is the host to the notifications handler.
*/
private lateinit var notificationsHandlerThread: HandlerThread
/**
* [Handler] on which lifecycle events are executed.
*/
private lateinit var handler: Handler
private val tasksMap = mutableMapOf<String, SingleNotificationHandlerTask>()
override fun definition() = ModuleDefinition {
Name("ExpoNotificationsHandlerModule")
Events(
"onHandleNotification",
"onHandleNotificationTimeout"
)
OnCreate {
moduleRegistry = appContext.legacyModuleRegistry
// Register the module as a listener in NotificationManager singleton module.
// Deregistration happens in onDestroy callback.
notificationManager = requireNotNull(moduleRegistry.getSingletonModule("NotificationManager", NotificationManager::class.java))
notificationManager.addListener(this@NotificationsHandler)
notificationsHandlerThread = HandlerThread("NotificationsHandlerThread - " + this.javaClass.toString())
notificationsHandlerThread.start()
handler = Handler(notificationsHandlerThread.looper)
}
OnDestroy {
notificationManager.removeListener(this@NotificationsHandler)
tasksMap.values.forEach(SingleNotificationHandlerTask::stop)
// We don't have to use `quitSafely` here, cause all tasks were stopped
notificationsHandlerThread.quit()
}
AsyncFunction("handleNotificationAsync", this@NotificationsHandler::handleNotificationAsync)
}
/**
* Called by the app with [NotificationBehaviourRecord] representing requested behavior
* that should be applied to the notification.
*
* @param identifier Identifier of the task which asked for behavior.
* @param behavior Behavior to apply to the notification.
* @param promise Promise to resolve once the notification is successfully presented
* or fails to be presented.
*/
private fun handleNotificationAsync(identifier: String, behavior: NotificationBehaviourRecord, promise: Promise) {
val task = tasksMap[identifier]
?: throw NotificationWasAlreadyHandledException(identifier)
with(behavior) {
task.handleResponse(
NotificationBehavior(shouldShowAlert, shouldPlaySound, shouldSetBadge, priority),
promise
)
}
}
/**
* Callback called by [NotificationManager] to inform its listeners of new messages.
* Starts up a new [SingleNotificationHandlerTask] which will take it on from here.
*
* @param notification Notification received
*/
override fun onNotificationReceived(notification: Notification) {
val context = appContext.reactContext ?: return
val task = SingleNotificationHandlerTask(
context,
appContext.eventEmitter(this),
handler,
notification,
this
)
tasksMap[task.identifier] = task
task.start()
}
/**
* Callback called once [SingleNotificationHandlerTask] finishes.
* A cue for removal of the task.
*
* @param task Task that just fulfilled its responsibility.
*/
fun onTaskFinished(task: SingleNotificationHandlerTask) {
tasksMap.remove(task.identifier)
}
}

View File

@@ -0,0 +1,135 @@
package expo.modules.notifications.notifications.handling;
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.os.ResultReceiver;
import expo.modules.kotlin.Promise;
import expo.modules.core.interfaces.services.EventEmitter;
import expo.modules.notifications.notifications.NotificationSerializer;
import expo.modules.notifications.notifications.model.Notification;
import expo.modules.notifications.notifications.model.NotificationBehavior;
import expo.modules.notifications.service.NotificationsService;
/**
* A "task" responsible for managing response to a single notification.
*/
public class SingleNotificationHandlerTask {
/**
* Name of the event asking the delegate for behavior.
*/
private final static String HANDLE_NOTIFICATION_EVENT_NAME = "onHandleNotification";
/**
* Name of the event emitted if the delegate doesn't respond in time.
*/
private final static String HANDLE_NOTIFICATION_TIMEOUT_EVENT_NAME = "onHandleNotificationTimeout";
/**
* Seconds since sending the {@link #HANDLE_NOTIFICATION_EVENT_NAME} until the task
* is considered timed out.
*/
private final static int SECONDS_TO_TIMEOUT = 3;
private Handler mHandler;
private EventEmitter mEventEmitter;
private Notification mNotification;
private NotificationBehavior mBehavior;
private Context mContext;
private NotificationsHandler mDelegate;
private Runnable mTimeoutRunnable = SingleNotificationHandlerTask.this::handleTimeout;
/* package */ SingleNotificationHandlerTask(
Context context,
EventEmitter eventEmitter,
Handler handler,
Notification notification,
NotificationsHandler delegate
) {
mContext = context;
mHandler = handler;
mEventEmitter = eventEmitter;
mNotification = notification;
mDelegate = delegate;
}
/**
* @return Identifier of the task.
*/
/* package */ String getIdentifier() {
return mNotification.getNotificationRequest().getIdentifier();
}
/**
* Starts the task, i.e. sends an event to the app's delegate and starts a timeout
* after which the task finishes itself.
*/
/* package */ void start() {
Bundle eventBody = new Bundle();
eventBody.putString("id", getIdentifier());
eventBody.putBundle("notification", NotificationSerializer.toBundle(mNotification));
mEventEmitter.emit(HANDLE_NOTIFICATION_EVENT_NAME, eventBody);
mHandler.postDelayed(mTimeoutRunnable, SECONDS_TO_TIMEOUT * 1000);
}
/**
* Stops the task abruptly (in case the app is being destroyed and there is no reason
* to wait for the response anymore).
*/
/* package */ void stop() {
finish();
}
/**
* Informs the task of a response - behavior requested by the app.
*
* @param behavior Behavior requested by the app
* @param promise Promise to fulfill once the behavior is applied to the notification.
*/
/* package */ void handleResponse(NotificationBehavior behavior, final Promise promise) {
mBehavior = behavior;
mHandler.post(new Runnable() {
@Override
public void run() {
NotificationsService.Companion.present(mContext, mNotification, mBehavior, new ResultReceiver(mHandler) {
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
super.onReceiveResult(resultCode, resultData);
if (resultCode == NotificationsService.SUCCESS_CODE) {
promise.resolve();
} else {
Exception e = (Exception) resultData.getSerializable(NotificationsService.EXCEPTION_KEY);
promise.reject("ERR_NOTIFICATION_PRESENTATION_FAILED", "Notification presentation failed.", e);
}
}
});
finish();
}
});
}
/**
* Callback called by {@link #mTimeoutRunnable} after timeout time elapses.
* <p>
* Sends a timeout event to the app.
*/
private void handleTimeout() {
Bundle eventBody = new Bundle();
eventBody.putString("id", getIdentifier());
eventBody.putBundle("notification", NotificationSerializer.toBundle(mNotification));
mEventEmitter.emit(HANDLE_NOTIFICATION_TIMEOUT_EVENT_NAME, eventBody);
finish();
}
/**
* Callback called when the task fulfills its responsibility. Clears up {@link #mHandler}
* and informs {@link #mDelegate} of the task's state.
*/
private void finish() {
mHandler.removeCallbacks(mTimeoutRunnable);
mDelegate.onTaskFinished(this);
}
}

View File

@@ -0,0 +1,38 @@
package expo.modules.notifications.notifications.interfaces
import android.content.Context
import android.graphics.Bitmap
import android.os.Parcelable
import expo.modules.notifications.notifications.enums.NotificationPriority
import org.json.JSONObject
/**
* This interface is implemented by classes representing notification content.
* I.e. local notifications [NotificationContent] and remote notifications [RemoteNotificationContent].
*
* The reason the two classes exist is that one is persisted locally in SharedPreferences, and the other is not.
* The first is therefore a bit "fragile" and harder to refactor, while the second is easier to change.
* This interface exists to provide a common API for both classes.
* */
interface INotificationContent : Parcelable {
val title: String?
val text: String?
val subtitle: String?
val badgeCount: Number?
val shouldPlayDefaultSound: Boolean
// soundName is better off as a string (was an Uri) because in RemoteNotification we can obtain the sound name
// in local notification we store the uri and derive the sound name from it
val soundName: String?
val shouldUseDefaultVibrationPattern: Boolean
val vibrationPattern: LongArray?
val body: JSONObject?
val priority: NotificationPriority
val color: Number?
val isAutoDismiss: Boolean
val categoryId: String?
val isSticky: Boolean
fun containsImage(): Boolean
suspend fun getImage(context: Context): Bitmap?
}

View File

@@ -0,0 +1,34 @@
package expo.modules.notifications.notifications.interfaces
import expo.modules.notifications.notifications.model.Notification
import expo.modules.notifications.notifications.model.NotificationBehavior
/**
* An object capable of building a [Notification] based
* on a [NotificationContent] spec.
*/
interface NotificationBuilder {
/**
* Pass in a [Notification] based on which the Android notification should be based.
*
* @param notification [Notification] on which the notification should be based.
* @return The same instance of [NotificationBuilder] updated with the notification.
*/
fun setNotification(notification: Notification?): NotificationBuilder?
/**
* Pass in a [NotificationBehavior] if you want to override the behavior
* of the notification, i.e. whether it should show a heads-up alert, set badge, etc.
*
* @param behavior [NotificationBehavior] to which the presentation effect should conform.
* @return The same instance of [NotificationBuilder] updated with the remote message.
*/
fun setAllowedBehavior(behavior: NotificationBehavior?): NotificationBuilder?
/**
* Builds the Android notification based on passed in data.
*
* @return Built notification.
*/
suspend fun build(): android.app.Notification
}

View File

@@ -0,0 +1,47 @@
package expo.modules.notifications.notifications.interfaces;
import android.os.Bundle;
import com.google.firebase.messaging.FirebaseMessagingService;
import expo.modules.notifications.notifications.model.Notification;
import expo.modules.notifications.notifications.model.NotificationResponse;
/**
* Interface used to register in {@link NotificationManager}
* and be notified of new message events.
*/
public interface NotificationListener {
/**
* Callback called when new notification is received.
*
* @param notification Notification received
*/
default void onNotificationReceived(Notification notification) {
}
/**
* Callback called when new notification response is received.
*
* @param response Notification response received
* @return Whether the notification response has been handled
*/
default boolean onNotificationResponseReceived(NotificationResponse response) {
return false;
}
/**
* Callback called when notification response is received through package lifecycle listeners
*
* @param extras Bundle of extras from the lifecycle method
*/
default void onNotificationResponseIntentReceived(Bundle extras) {
}
/**
* Callback called when some notifications are dropped.
* See {@link FirebaseMessagingService#onDeletedMessages()}
*/
default void onNotificationsDropped() {
}
}

View File

@@ -0,0 +1,22 @@
package expo.modules.notifications.notifications.interfaces;
/**
* Interface of a singleton module responsible
* for dispatching new remote message events to {@link NotificationListener}s.
*/
public interface NotificationManager {
/**
* Registers a {@link NotificationListener}.
*
* @param listener Listener to be notified of new message events.
*/
void addListener(NotificationListener listener);
/**
* Unregisters a {@link NotificationListener}.
*
* @param listener Listener previously registered
* with {@link NotificationManager#addListener(NotificationListener)}.
*/
void removeListener(NotificationListener listener);
}

View File

@@ -0,0 +1,16 @@
package expo.modules.notifications.notifications.interfaces;
import android.os.Parcelable;
import androidx.annotation.Nullable;
/**
* An interface specifying source of the notification, to be implemented
* by concrete classes.
*/
public interface NotificationTrigger extends Parcelable {
@Nullable
default String getNotificationChannel() {
return null;
}
}

View File

@@ -0,0 +1,20 @@
package expo.modules.notifications.notifications.interfaces;
import java.io.Serializable;
import java.util.Date;
import androidx.annotation.Nullable;
import expo.modules.notifications.service.delegates.SharedPreferencesNotificationsStore;
/**
* A notification trigger that is serializable - this ensures {@link SharedPreferencesNotificationsStore}
* is capable of storing it in the device's memory.
*/
public interface SchedulableNotificationTrigger extends NotificationTrigger, Serializable {
/**
* @return Next date at which the notification should be triggered. Returns `null`
* if the notification will not trigger in the future (it can be removed then).
*/
@Nullable
Date nextTriggerDate();
}

View File

@@ -0,0 +1,59 @@
package expo.modules.notifications.notifications.model;
import android.os.Parcel;
import android.os.Parcelable;
import java.util.Date;
/**
* A class representing a single notification. Origin date ({@link #mOriginDate}) is time when it was sent (remote) or when it was posted (local).
*/
public class Notification implements Parcelable {
private NotificationRequest mRequest;
private Date mOriginDate;
public Notification(NotificationRequest request) {
this(request, new Date());
}
public Notification(NotificationRequest request, Date date) {
mRequest = request;
mOriginDate = date;
}
protected Notification(Parcel in) {
mRequest = in.readParcelable(getClass().getClassLoader());
mOriginDate = new Date(in.readLong());
}
public Date getOriginDate() {
return mOriginDate;
}
public NotificationRequest getNotificationRequest() {
return mRequest;
}
public static final Creator<Notification> CREATOR = new Creator<Notification>() {
@Override
public Notification createFromParcel(Parcel in) {
return new Notification(in);
}
@Override
public Notification[] newArray(int size) {
return new Notification[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeParcelable(mRequest, 0);
dest.writeLong(mOriginDate.getTime());
}
}

View File

@@ -0,0 +1,63 @@
package expo.modules.notifications.notifications.model;
import android.os.Parcel;
import android.os.Parcelable;
import java.io.Serializable;
/**
* A class representing a single notification action button.
*/
public class NotificationAction implements Parcelable, Serializable {
private final String mIdentifier;
private final String mTitle;
private final boolean mOpensAppToForeground;
public NotificationAction(String identifier, String title, boolean opensAppToForeground) {
mIdentifier = identifier;
mTitle = title;
mOpensAppToForeground = opensAppToForeground;
}
protected NotificationAction(Parcel in) {
mIdentifier = in.readString();
mTitle = in.readString();
mOpensAppToForeground = in.readByte() != 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(mIdentifier);
dest.writeString(mTitle);
dest.writeByte((byte) (mOpensAppToForeground ? 1 : 0));
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<NotificationAction> CREATOR = new Creator<NotificationAction>() {
@Override
public NotificationAction createFromParcel(Parcel in) {
return new NotificationAction(in);
}
@Override
public NotificationAction[] newArray(int size) {
return new NotificationAction[size];
}
};
public String getIdentifier() {
return mIdentifier;
}
public String getTitle() {
return mTitle;
}
public boolean opensAppToForeground() {
return mOpensAppToForeground;
}
}

View File

@@ -0,0 +1,84 @@
package expo.modules.notifications.notifications.model;
import android.os.Parcel;
import android.os.Parcelable;
import expo.modules.core.Promise;
import expo.modules.core.arguments.ReadableArguments;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.enums.NotificationPriority;
import expo.modules.notifications.notifications.handling.NotificationsHandler;
/**
* A POJO representing behavior with which the notification should be presented.
* <p>
* Used in {@link NotificationsHandler#handleNotificationAsync(String, ReadableArguments, Promise)}.
*/
public class NotificationBehavior implements Parcelable {
private final boolean mShouldShowAlert;
private final boolean mShouldPlaySound;
private final boolean mShouldSetBadge;
@Nullable
private final String mPriorityOverride;
public NotificationBehavior(boolean shouldShowAlert, boolean shouldPlaySound, boolean shouldSetBadge, @Nullable String priorityOverride) {
mShouldShowAlert = shouldShowAlert;
mShouldPlaySound = shouldPlaySound;
mShouldSetBadge = shouldSetBadge;
mPriorityOverride = priorityOverride;
}
private NotificationBehavior(Parcel in) {
mShouldShowAlert = in.readByte() != 0;
mShouldPlaySound = in.readByte() != 0;
mShouldSetBadge = in.readByte() != 0;
mPriorityOverride = in.readString();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeByte((byte) (mShouldShowAlert ? 1 : 0));
dest.writeByte((byte) (mShouldPlaySound ? 1 : 0));
dest.writeByte((byte) (mShouldSetBadge ? 1 : 0));
dest.writeString(mPriorityOverride);
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<NotificationBehavior> CREATOR = new Creator<NotificationBehavior>() {
@Override
public NotificationBehavior createFromParcel(Parcel in) {
return new NotificationBehavior(in);
}
@Override
public NotificationBehavior[] newArray(int size) {
return new NotificationBehavior[size];
}
};
@Nullable
public NotificationPriority getPriorityOverride() {
if (mPriorityOverride == null) {
return null;
}
return NotificationPriority.fromEnumValue(mPriorityOverride);
}
public boolean shouldShowAlert() {
return mShouldShowAlert;
}
public boolean shouldPlaySound() {
return mShouldPlaySound;
}
public boolean shouldSetBadge() {
return mShouldSetBadge;
}
}

View File

@@ -0,0 +1,61 @@
package expo.modules.notifications.notifications.model;
import android.os.Parcel;
import android.os.Parcelable;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
/**
* A class representing a collection of notification actions.
*/
public class NotificationCategory implements Parcelable, Serializable {
private final String mIdentifier;
private final List<NotificationAction> mActions;
public NotificationCategory(String identifier, List<NotificationAction> actions) {
mIdentifier = identifier;
mActions = actions;
}
private NotificationCategory(Parcel in) {
mIdentifier = in.readString();
mActions = in.readArrayList(NotificationAction.class.getClassLoader());
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(mIdentifier);
dest.writeList(mActions);
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<NotificationCategory> CREATOR = new Creator<NotificationCategory>() {
@Override
public NotificationCategory createFromParcel(Parcel in) {
return new NotificationCategory(in);
}
@Override
public NotificationCategory[] newArray(int size) {
return new NotificationCategory[size];
}
};
public String getIdentifier() {
return mIdentifier;
}
public List<NotificationAction> getActions() {
if (mActions == null) {
return Collections.emptyList();
}
return mActions;
}
}

View File

@@ -0,0 +1,392 @@
package expo.modules.notifications.notifications.model;
import static expo.modules.notifications.notifications.presentation.builders.ExpoNotificationBuilder.META_DATA_LARGE_ICON_KEY;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.io.ObjectStreamException;
import java.io.Serializable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.enums.NotificationPriority;
import expo.modules.notifications.notifications.interfaces.INotificationContent;
import kotlin.coroutines.Continuation;
/**
* A POJO representing a local notification content: title, message, body, etc. Instances
* should be created using {@link NotificationContent.Builder}.
*
* Note that it implements {@link Serializable} interfaces to store the object in the SharedPreferences.
* Refactoring this class may require a migration strategy for the data stored in SharedPreferences.
*/
public class NotificationContent implements Parcelable, Serializable, INotificationContent {
private String mTitle;
private String mText;
private String mSubtitle;
private Number mBadgeCount;
private boolean mShouldPlayDefaultSound;
private Uri mSound;
private boolean mShouldUseDefaultVibrationPattern;
private long[] mVibrationPattern;
private JSONObject mBody;
private NotificationPriority mPriority;
private Number mColor;
private boolean mAutoDismiss;
private String mCategoryId;
private boolean mSticky;
protected NotificationContent() {
}
public static final Creator<NotificationContent> CREATOR = new Creator<NotificationContent>() {
@Override
public NotificationContent createFromParcel(Parcel in) {
return new NotificationContent(in);
}
@Override
public NotificationContent[] newArray(int size) {
return new NotificationContent[size];
}
};
@Nullable
public String getTitle() {
return mTitle;
}
@Nullable
public String getText() {
return mText;
}
@Nullable
public String getSubtitle() {
return mSubtitle;
}
@Nullable
public Number getBadgeCount() {
return mBadgeCount;
}
@Override
public boolean getShouldPlayDefaultSound() {
return mShouldPlayDefaultSound;
}
@Override
public boolean getShouldUseDefaultVibrationPattern() {
return mShouldUseDefaultVibrationPattern;
}
@Nullable
public String getSoundName() {
return mSound != null ? mSound.getLastPathSegment() : null;
}
@Nullable
@Override
public Object getImage(@NonNull Context context, @NonNull Continuation<? super Bitmap> $completion) {
try {
ApplicationInfo ai = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA);
if (ai.metaData.containsKey(META_DATA_LARGE_ICON_KEY)) {
int resourceId = ai.metaData.getInt(META_DATA_LARGE_ICON_KEY);
return BitmapFactory.decodeResource(context.getResources(), resourceId);
}
} catch (PackageManager.NameNotFoundException | ClassCastException e) {
Log.e("expo-notifications", "Could not have fetched large notification icon.", e);
}
return null;
}
@Override
public boolean containsImage() {
return true;
}
@Nullable
public long[] getVibrationPattern() {
return mVibrationPattern;
}
@Nullable
public JSONObject getBody() {
return mBody;
}
@Nullable
public NotificationPriority getPriority() {
return mPriority;
}
@Nullable
public Number getColor() {
return mColor;
}
public boolean isAutoDismiss() {
return mAutoDismiss;
}
public String getCategoryId() {
return mCategoryId;
}
public boolean isSticky() {
return mSticky;
}
@Override
public int describeContents() {
return 0;
}
protected NotificationContent(Parcel in) {
mTitle = in.readString();
mText = in.readString();
mSubtitle = in.readString();
mBadgeCount = (Number) in.readSerializable();
mShouldPlayDefaultSound = in.readByte() != 0;
mSound = in.readParcelable(getClass().getClassLoader());
mShouldUseDefaultVibrationPattern = in.readByte() != 0;
mVibrationPattern = in.createLongArray();
try {
mBody = new JSONObject(in.readString());
} catch (JSONException | NullPointerException e) {
// do nothing
}
Number priorityNumber = (Number) in.readSerializable();
if (priorityNumber != null) {
mPriority = NotificationPriority.fromNativeValue(priorityNumber.intValue());
}
mColor = (Number) in.readSerializable();
mAutoDismiss = in.readByte() == 1;
mCategoryId = in.readString();
mSticky = in.readByte() == 1;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(mTitle);
dest.writeString(mText);
dest.writeString(mSubtitle);
dest.writeSerializable(mBadgeCount);
dest.writeByte((byte) (mShouldPlayDefaultSound ? 1 : 0));
dest.writeParcelable(mSound, 0);
dest.writeByte((byte) (mShouldUseDefaultVibrationPattern ? 1 : 0));
dest.writeLongArray(mVibrationPattern);
dest.writeString(mBody != null ? mBody.toString() : null);
dest.writeSerializable(mPriority != null ? mPriority.getNativeValue() : null);
dest.writeSerializable(mColor);
dest.writeByte((byte) (mAutoDismiss ? 1 : 0));
dest.writeString(mCategoryId);
dest.writeByte((byte) (mSticky ? 1 : 0));
}
// EXPONOTIFCONTENT02
private static final long serialVersionUID = 397666843266836802L;
private void writeObject(java.io.ObjectOutputStream out) throws IOException {
out.writeObject(mTitle);
out.writeObject(mText);
out.writeObject(mSubtitle);
out.writeObject(mBadgeCount);
out.writeByte(mShouldPlayDefaultSound ? 1 : 0);
out.writeObject(mSound == null ? null : mSound.toString());
out.writeByte(mShouldUseDefaultVibrationPattern ? 1 : 0);
if (mVibrationPattern == null) {
out.writeInt(-1);
} else {
out.writeInt(mVibrationPattern.length);
for (long duration : mVibrationPattern) {
out.writeLong(duration);
}
}
out.writeObject(mBody != null ? mBody.toString() : null);
out.writeObject(mPriority != null ? mPriority.getNativeValue() : null);
out.writeObject(mColor);
out.writeByte(mAutoDismiss ? 1 : 0);
out.writeObject(mCategoryId != null ? mCategoryId.toString() : null);
out.writeByte(mSticky ? 1 : 0);
}
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
mTitle = (String) in.readObject();
mText = (String) in.readObject();
mSubtitle = (String) in.readObject();
mBadgeCount = (Number) in.readObject();
mShouldPlayDefaultSound = in.readByte() == 1;
String soundUri = (String) in.readObject();
if (soundUri == null) {
mSound = null;
} else {
mSound = Uri.parse(soundUri);
}
mShouldUseDefaultVibrationPattern = in.readByte() == 1;
int vibrationPatternLength = in.readInt();
if (vibrationPatternLength < 0) {
mVibrationPattern = null;
} else {
mVibrationPattern = new long[vibrationPatternLength];
for (int i = 0; i < vibrationPatternLength; i++) {
mVibrationPattern[i] = in.readLong();
}
}
String bodyString = (String) in.readObject();
if (bodyString == null) {
mBody = null;
} else {
try {
mBody = new JSONObject(bodyString);
} catch (JSONException | NullPointerException e) {
// do nothing
}
}
Number priorityNumber = (Number) in.readObject();
if (priorityNumber != null) {
mPriority = NotificationPriority.fromNativeValue(priorityNumber.intValue());
}
mColor = (Number) in.readObject();
mAutoDismiss = in.readByte() == 1;
String categoryIdString = (String) in.readObject();
if (categoryIdString == null) {
mCategoryId = null;
} else {
mCategoryId = new String(categoryIdString);
}
mSticky = in.readByte() == 1;
}
private void readObjectNoData() throws ObjectStreamException {
}
public static class Builder {
private String mTitle;
private String mText;
private String mSubtitle;
private Number mBadgeCount;
private boolean mShouldPlayDefaultSound;
private Uri mSound;
private boolean mShouldUseDefaultVibrationPattern;
private long[] mVibrationPattern;
private JSONObject mBody;
private NotificationPriority mPriority;
private Number mColor;
private boolean mAutoDismiss;
private String mCategoryId;
private boolean mSticky;
public Builder() {
useDefaultSound();
useDefaultVibrationPattern();
}
public Builder setTitle(String title) {
mTitle = title;
return this;
}
public Builder setSubtitle(String subtitle) {
mSubtitle = subtitle;
return this;
}
public Builder setText(String text) {
mText = text;
return this;
}
public Builder setBody(JSONObject body) {
mBody = body;
return this;
}
public Builder setPriority(NotificationPriority priority) {
mPriority = priority;
return this;
}
public Builder setBadgeCount(Number badgeCount) {
mBadgeCount = badgeCount;
return this;
}
public Builder useDefaultVibrationPattern() {
mShouldUseDefaultVibrationPattern = true;
mVibrationPattern = null;
return this;
}
public Builder setVibrationPattern(long[] vibrationPattern) {
mShouldUseDefaultVibrationPattern = false;
mVibrationPattern = vibrationPattern;
return this;
}
public Builder useDefaultSound() {
mShouldPlayDefaultSound = true;
mSound = null;
return this;
}
public Builder setSound(Uri sound) {
mShouldPlayDefaultSound = false;
mSound = sound;
return this;
}
public Builder setColor(Number color) {
mColor = color;
return this;
}
public Builder setAutoDismiss(boolean autoDismiss) {
mAutoDismiss = autoDismiss;
return this;
}
public Builder setCategoryId(String categoryId) {
mCategoryId = categoryId;
return this;
}
public Builder setSticky(boolean sticky) {
mSticky = sticky;
return this;
}
public NotificationContent build() {
NotificationContent content = new NotificationContent();
content.mTitle = mTitle;
content.mSubtitle = mSubtitle;
content.mText = mText;
content.mBadgeCount = mBadgeCount;
content.mShouldUseDefaultVibrationPattern = mShouldUseDefaultVibrationPattern;
content.mVibrationPattern = mVibrationPattern;
content.mShouldPlayDefaultSound = mShouldPlayDefaultSound;
content.mSound = mSound;
content.mBody = mBody;
content.mPriority = mPriority;
content.mColor = mColor;
content.mAutoDismiss = mAutoDismiss;
content.mCategoryId = mCategoryId;
content.mSticky = mSticky;
return content;
}
}
}

View File

@@ -0,0 +1,69 @@
package expo.modules.notifications.notifications.model
import org.json.JSONArray
import org.json.JSONObject
/*
* In some scenarios, data-only push notifications are, in fact, presented.
* The presentation preferences are taken from the data payload.
*
* https://docs.expo.dev/versions/latest/sdk/notifications/#android-push-notification-payload-specification
* */
@JvmInline
value class NotificationData(private val data: Map<String, String>) {
val title: String?
get() = data["title"]
val message: String?
get() = data["message"]
val body: JSONObject?
get() = try {
data["body"]?.let { JSONObject(it) }
} catch (e: Exception) {
null
}
val sound: String?
get() = data["sound"]
val shouldPlayDefaultSound: Boolean
get() = sound == null
val shouldUseDefaultVibrationPattern: Boolean
get() = data["vibrate"]?.toBoolean() == true
val isSticky: Boolean
get() = data["sticky"]?.toBoolean() ?: false
val vibrationPattern: LongArray?
get() = try {
data["vibrate"]?.let { vibrateString ->
JSONArray(vibrateString).let { jsonArray ->
LongArray(jsonArray.length()) { i ->
jsonArray.getLong(i)
}
}
}
} catch (e: Exception) {
// most likely a boolean value that cannot be converted to a longArray
null
}
val color: String? get() = data["color"]
val autoDismiss: Boolean
get() = data["autoDismiss"]?.toBoolean() ?: true
val categoryId: String?
get() = data["categoryId"]
val sticky: Boolean
get() = data["sticky"]?.toBoolean() ?: false
val subText: String?
get() = data["subtitle"]
val badge: Int?
get() = data["badge"]?.toIntOrNull()
}

View File

@@ -0,0 +1,67 @@
package expo.modules.notifications.notifications.model;
import android.os.Parcel;
import android.os.Parcelable;
import java.io.Serializable;
import expo.modules.notifications.notifications.interfaces.INotificationContent;
import expo.modules.notifications.notifications.interfaces.NotificationTrigger;
/**
* A class representing notification request. A notification request has some content {@link #mContent},
* is triggered by some {@link #mTrigger} and is identifiable by {@link #mIdentifier}.
*/
public class NotificationRequest implements Parcelable, Serializable {
private String mIdentifier;
private INotificationContent mContent;
private NotificationTrigger mTrigger;
public NotificationRequest(String identifier, INotificationContent content, NotificationTrigger trigger) {
mIdentifier = identifier;
mContent = content;
mTrigger = trigger;
}
public INotificationContent getContent() {
return mContent;
}
public String getIdentifier() {
return mIdentifier;
}
public NotificationTrigger getTrigger() {
return mTrigger;
}
protected NotificationRequest(Parcel in) {
mIdentifier = in.readString();
mContent = in.readParcelable(getClass().getClassLoader());
mTrigger = in.readParcelable(getClass().getClassLoader());
}
public static final Creator<NotificationRequest> CREATOR = new Creator<NotificationRequest>() {
@Override
public NotificationRequest createFromParcel(Parcel in) {
return new NotificationRequest(in);
}
@Override
public NotificationRequest[] newArray(int size) {
return new NotificationRequest[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(mIdentifier);
dest.writeParcelable(mContent, 0);
dest.writeParcelable(mTrigger, 0);
}
}

View File

@@ -0,0 +1,60 @@
package expo.modules.notifications.notifications.model;
import android.os.Parcel;
import android.os.Parcelable;
/**
* A POJO representing user's response to a notification. It may be a default action,
* i.e. a tap on the notification ({@link #DEFAULT_ACTION_IDENTIFIER}).
*/
public class NotificationResponse implements Parcelable {
public static final String DEFAULT_ACTION_IDENTIFIER = "expo.modules.notifications.actions.DEFAULT";
private NotificationAction mAction;
private Notification mNotification;
public NotificationResponse(NotificationAction action, Notification notification) {
mAction = action;
mNotification = notification;
}
public NotificationAction getAction() {
return mAction;
}
public String getActionIdentifier() {
return mAction.getIdentifier();
}
public Notification getNotification() {
return mNotification;
}
public static final Creator<NotificationResponse> CREATOR = new Creator<NotificationResponse>() {
@Override
public NotificationResponse createFromParcel(Parcel in) {
return new NotificationResponse(in);
}
@Override
public NotificationResponse[] newArray(int size) {
return new NotificationResponse[size];
}
};
@Override
public int describeContents() {
return 0;
}
protected NotificationResponse(Parcel in) {
mAction = in.readParcelable(getClass().getClassLoader());
mNotification = in.readParcelable(getClass().getClassLoader());
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeParcelable(mAction, 0);
dest.writeParcelable(mNotification, 0);
}
}

View File

@@ -0,0 +1,89 @@
package expo.modules.notifications.notifications.model
import android.content.Context
import android.graphics.Bitmap
import android.os.Parcel
import android.os.Parcelable
import com.google.firebase.messaging.RemoteMessage
import expo.modules.notifications.notifications.enums.NotificationPriority
import expo.modules.notifications.notifications.interfaces.INotificationContent
import expo.modules.notifications.notifications.presentation.builders.downloadImage
/**
* A POJO representing a remote notification content: title, message, body, etc.
* The content originates in a RemoteMessage object.
*
* Instances of this class are not persisted in SharedPreferences (unlike {@link NotificationContent}. This class does
* not implement Serializable, but Parcelable ensures we can pass instances between different parts of the application.
*/
class RemoteNotificationContent(private val remoteMessage: RemoteMessage) : INotificationContent {
constructor(parcel: Parcel) : this(parcel.readParcelable<RemoteMessage>(RemoteMessage::class.java.classLoader)!!)
private val notificationData = NotificationData(remoteMessage.data)
override suspend fun getImage(context: Context): Bitmap? {
val uri = remoteMessage.notification?.imageUrl
return uri?.let { downloadImage(it) }
}
override fun containsImage(): Boolean {
return remoteMessage.notification?.imageUrl != null
}
override val title = remoteMessage.notification?.title ?: notificationData.title
override val text = remoteMessage.notification?.body ?: notificationData.message
override val shouldPlayDefaultSound = remoteMessage.notification?.sound == null && notificationData.shouldPlayDefaultSound
override val soundName = remoteMessage.notification?.sound ?: notificationData.sound
override val shouldUseDefaultVibrationPattern: Boolean
get() = remoteMessage.notification?.defaultVibrateSettings ?: notificationData.shouldUseDefaultVibrationPattern
override val vibrationPattern = remoteMessage.notification?.vibrateTimings ?: notificationData.vibrationPattern
override val body = notificationData.body
override val priority: NotificationPriority
get() = when (remoteMessage.priority) {
RemoteMessage.PRIORITY_HIGH -> NotificationPriority.HIGH
else -> NotificationPriority.DEFAULT
}
override val color: Number?
get() {
val colorSource = remoteMessage.notification?.color ?: notificationData.color
return colorSource?.let { android.graphics.Color.parseColor(it) }
}
// NOTE the following getter functions are here because the local notification content class has them
// and this class conforms to the same interface.
// They are not supported by FCM but were previously implemented by JSONNotificationContentBuilder.java.
override val isAutoDismiss = notificationData.autoDismiss
override val categoryId = notificationData.categoryId
override val isSticky = notificationData.isSticky
override val subtitle: String? = notificationData.subText
override val badgeCount = notificationData.badge
override fun describeContents(): Int = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeParcelable(remoteMessage, flags)
}
companion object CREATOR : Parcelable.Creator<RemoteNotificationContent> {
override fun createFromParcel(parcel: Parcel): RemoteNotificationContent {
return RemoteNotificationContent(parcel)
}
override fun newArray(size: Int): Array<RemoteNotificationContent?> {
return arrayOfNulls(size)
}
}
}

View File

@@ -0,0 +1,42 @@
package expo.modules.notifications.notifications.model;
import android.os.Parcel;
/**
* A class representing a single direct reply notification action.
*/
public class TextInputNotificationAction extends NotificationAction {
private final String mPlaceholder;
public TextInputNotificationAction(String identifier, String title, boolean opensAppToForeground, String placeholder) {
super(identifier, title, opensAppToForeground);
mPlaceholder = placeholder;
}
private TextInputNotificationAction(Parcel in) {
super(in);
mPlaceholder = in.readString();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeString(mPlaceholder);
}
public static final Creator<TextInputNotificationAction> CREATOR = new Creator<TextInputNotificationAction>() {
@Override
public TextInputNotificationAction createFromParcel(Parcel in) {
return new TextInputNotificationAction(in);
}
@Override
public TextInputNotificationAction[] newArray(int size) {
return new TextInputNotificationAction[size];
}
};
public String getPlaceholder() {
return mPlaceholder;
}
}

View File

@@ -0,0 +1,42 @@
package expo.modules.notifications.notifications.model;
import android.os.Parcel;
/**
* A POJO representing user's response to a text input notification action
*/
public class TextInputNotificationResponse extends NotificationResponse {
private String mUserText;
public TextInputNotificationResponse(NotificationAction action, Notification notification, String userText) {
super(action, notification);
mUserText = userText;
}
public String getUserText() {
return mUserText;
}
public static final Creator<TextInputNotificationResponse> CREATOR = new Creator<TextInputNotificationResponse>() {
@Override
public TextInputNotificationResponse createFromParcel(Parcel in) {
return new TextInputNotificationResponse(in);
}
@Override
public TextInputNotificationResponse[] newArray(int size) {
return new TextInputNotificationResponse[size];
}
};
protected TextInputNotificationResponse(Parcel in) {
super(in);
mUserText = in.readString();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeString(mUserText);
}
}

View File

@@ -0,0 +1,43 @@
package expo.modules.notifications.notifications.model.triggers
import android.os.Build
import android.os.Parcel
import android.os.Parcelable
import androidx.annotation.RequiresApi
import com.google.firebase.messaging.RemoteMessage
import expo.modules.notifications.notifications.interfaces.NotificationTrigger
/**
* A trigger representing an incoming remote Firebase notification.
*/
class FirebaseNotificationTrigger(private val remoteMessage: RemoteMessage) : NotificationTrigger {
private constructor(parcel: Parcel) : this(
parcel.readParcelable(FirebaseNotificationTrigger::class.java.classLoader)
?: throw IllegalArgumentException("RemoteMessage from readParcelable must not be null")
)
fun getRemoteMessage(): RemoteMessage = remoteMessage
@RequiresApi(api = Build.VERSION_CODES.O)
override fun getNotificationChannel(): String? {
val channelId = remoteMessage.notification?.channelId ?: remoteMessage.data["channelId"]
return channelId ?: super.getNotificationChannel()
}
override fun describeContents(): Int {
return 0
}
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeParcelable(remoteMessage, 0)
}
companion object {
@JvmField
val CREATOR = object : Parcelable.Creator<FirebaseNotificationTrigger> {
override fun createFromParcel(parcel: Parcel) = FirebaseNotificationTrigger(parcel)
override fun newArray(size: Int) = arrayOfNulls<FirebaseNotificationTrigger>(size)
}
}
}

View File

@@ -0,0 +1,103 @@
package expo.modules.notifications.notifications.presentation
import android.content.Context
import android.os.Bundle
import expo.modules.core.arguments.ReadableArguments
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.notifications.ResultReceiverBody
import expo.modules.notifications.createDefaultResultReceiver
import expo.modules.notifications.notifications.ArgumentsNotificationContentBuilder
import expo.modules.notifications.notifications.NotificationSerializer
import expo.modules.notifications.notifications.model.Notification
import expo.modules.notifications.notifications.model.NotificationRequest
import expo.modules.notifications.service.NotificationsService
import expo.modules.notifications.service.NotificationsService.Companion.dismiss
import expo.modules.notifications.service.NotificationsService.Companion.dismissAll
import expo.modules.notifications.service.NotificationsService.Companion.getAllPresented
import expo.modules.notifications.service.NotificationsService.Companion.present
open class ExpoNotificationPresentationModule : Module() {
private val context: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
protected fun createResultReceiver(body: ResultReceiverBody) =
createDefaultResultReceiver(null, body)
override fun definition() = ModuleDefinition {
Name("ExpoNotificationPresenter")
AsyncFunction("presentNotificationAsync") { identifier: String, payload: ReadableArguments, promise: Promise ->
val content = ArgumentsNotificationContentBuilder(context).setPayload(payload).build()
val request = NotificationRequest(identifier, content, null)
val notification = Notification(request)
present(
context,
notification,
null,
createResultReceiver { resultCode: Int, resultData: Bundle? ->
if (resultCode == NotificationsService.SUCCESS_CODE) {
promise.resolve(identifier)
} else {
val e = resultData?.getSerializable(NotificationsService.EXCEPTION_KEY) as? Exception
promise.reject("ERR_NOTIFICATION_PRESENTATION_FAILED", "Notification could not be presented.", e)
}
}
)
}
AsyncFunction("getPresentedNotificationsAsync") { promise: Promise ->
getAllPresented(
context,
createResultReceiver { resultCode: Int, resultData: Bundle? ->
val notifications = resultData?.getParcelableArrayList<Notification>(NotificationsService.NOTIFICATIONS_KEY)
if (resultCode == NotificationsService.SUCCESS_CODE && notifications != null) {
promise.resolve(serializeNotifications(notifications))
} else {
val e = resultData?.getSerializable(NotificationsService.EXCEPTION_KEY) as? Exception
promise.reject("ERR_NOTIFICATIONS_FETCH_FAILED", "A list of displayed notifications could not be fetched.", e)
}
}
)
}
AsyncFunction("dismissNotificationAsync", this@ExpoNotificationPresentationModule::dismissNotificationAsync)
AsyncFunction("dismissAllNotificationsAsync", this@ExpoNotificationPresentationModule::dismissAllNotificationsAsync)
}
protected open fun dismissNotificationAsync(identifier: String, promise: Promise) {
dismiss(
context,
arrayOf(identifier),
createResultReceiver { resultCode: Int, resultData: Bundle? ->
if (resultCode == NotificationsService.SUCCESS_CODE) {
promise.resolve(null)
} else {
val e = resultData?.getSerializable(NotificationsService.EXCEPTION_KEY) as? Exception
promise.reject("ERR_NOTIFICATION_DISMISSAL_FAILED", "Notification could not be dismissed.", e)
}
}
)
}
protected open fun dismissAllNotificationsAsync(promise: Promise) {
dismissAll(
context,
createResultReceiver { resultCode: Int, resultData: Bundle? ->
if (resultCode == NotificationsService.SUCCESS_CODE) {
promise.resolve(null)
} else {
val e = resultData?.getSerializable(NotificationsService.EXCEPTION_KEY) as? Exception
promise.reject("ERR_NOTIFICATIONS_DISMISSAL_FAILED", "Notifications could not be dismissed.", e)
}
}
)
}
protected open fun serializeNotifications(notifications: Collection<Notification>): List<Bundle> {
return notifications.map(NotificationSerializer::toBundle)
}
}

View File

@@ -0,0 +1,50 @@
package expo.modules.notifications.notifications.presentation.builders;
import android.content.Context;
import expo.modules.notifications.notifications.interfaces.NotificationBuilder;
import expo.modules.notifications.notifications.interfaces.INotificationContent;
import expo.modules.notifications.notifications.model.Notification;
import expo.modules.notifications.notifications.model.NotificationBehavior;
/**
* A foundation class for {@link NotificationBuilder} implementations. Takes care
* of accepting {@link #mNotification} and {@link #mNotificationBehavior}.
*/
public abstract class BaseNotificationBuilder implements NotificationBuilder {
private Notification mNotification;
private NotificationBehavior mNotificationBehavior;
private Context mContext;
protected BaseNotificationBuilder(Context context) {
mContext = context;
}
@Override
public NotificationBuilder setNotification(Notification notification) {
mNotification = notification;
return this;
}
@Override
public NotificationBuilder setAllowedBehavior(NotificationBehavior behavior) {
mNotificationBehavior = behavior;
return this;
}
protected Context getContext() {
return mContext;
}
protected Notification getNotification() {
return mNotification;
}
protected INotificationContent getNotificationContent() {
return getNotification().getNotificationRequest().getContent();
}
protected NotificationBehavior getNotificationBehavior() {
return mNotificationBehavior;
}
}

View File

@@ -0,0 +1,81 @@
package expo.modules.notifications.notifications.presentation.builders;
import android.app.PendingIntent;
import android.content.Context;
import android.util.Log;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.RemoteInput;
import expo.modules.notifications.notifications.interfaces.INotificationContent;
import expo.modules.notifications.notifications.model.NotificationAction;
import expo.modules.notifications.notifications.model.NotificationCategory;
import expo.modules.notifications.notifications.model.TextInputNotificationAction;
import expo.modules.notifications.service.NotificationsService;
import expo.modules.notifications.service.delegates.SharedPreferencesNotificationCategoriesStore;
public class CategoryAwareNotificationBuilder extends ExpoNotificationBuilder {
protected SharedPreferencesNotificationCategoriesStore mStore;
public CategoryAwareNotificationBuilder(Context context, @NonNull SharedPreferencesNotificationCategoriesStore store) {
super(context);
mStore = store;
}
@Override
protected NotificationCompat.Builder createBuilder() {
NotificationCompat.Builder builder = super.createBuilder();
INotificationContent content = getNotificationContent();
String categoryIdentifier = content.getCategoryId();
if (categoryIdentifier != null) {
addActionsToBuilder(builder, categoryIdentifier);
}
if (content.getBadgeCount() != null) {
builder.setNumber(content.getBadgeCount().intValue());
}
return builder;
}
protected void addActionsToBuilder(NotificationCompat.Builder builder, @NonNull String categoryIdentifier) {
List<NotificationAction> actions = Collections.emptyList();
try {
NotificationCategory category = mStore.getNotificationCategory(categoryIdentifier);
if (category != null) {
actions = category.getActions();
}
} catch (ClassNotFoundException | IOException e) {
Log.e("expo-notifications", String.format("Could not read category with identifier: %s. %s", categoryIdentifier, e.getMessage()));
e.printStackTrace();
}
for (NotificationAction action : actions) {
if (action instanceof TextInputNotificationAction) {
builder.addAction(buildTextInputAction((TextInputNotificationAction) action));
} else {
builder.addAction(buildButtonAction(action));
}
}
}
protected NotificationCompat.Action buildButtonAction(@NonNull NotificationAction action) {
PendingIntent intent = NotificationsService.Companion.createNotificationResponseIntent(getContext(), getNotification(), action);
return new NotificationCompat.Action.Builder(super.getIcon(), action.getTitle(), intent).build();
}
protected NotificationCompat.Action buildTextInputAction(@NonNull TextInputNotificationAction action) {
PendingIntent intent = NotificationsService.Companion.createNotificationResponseIntent(getContext(), getNotification(), action);
RemoteInput remoteInput = new RemoteInput.Builder(NotificationsService.USER_TEXT_RESPONSE_KEY)
.setLabel(action.getPlaceholder())
.build();
return new NotificationCompat.Action.Builder(super.getIcon(), action.getTitle(), intent).addRemoteInput(remoteInput).build();
}
}

View File

@@ -0,0 +1,134 @@
package expo.modules.notifications.notifications.presentation.builders;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Build;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import expo.modules.notifications.R;
import expo.modules.notifications.notifications.channels.managers.AndroidXNotificationsChannelGroupManager;
import expo.modules.notifications.notifications.channels.managers.AndroidXNotificationsChannelManager;
import expo.modules.notifications.notifications.channels.managers.NotificationsChannelManager;
import expo.modules.notifications.notifications.interfaces.NotificationTrigger;
import expo.modules.notifications.notifications.model.NotificationRequest;
/**
* A notification builder foundation capable of fetching and/or creating
* a notification channel to which the notification request should be posted.
*/
public abstract class ChannelAwareNotificationBuilder extends BaseNotificationBuilder {
private final static String FALLBACK_CHANNEL_ID = "expo_notifications_fallback_notification_channel";
// Behaviors we will want to impose on received notifications include
// being displayed as a heads-up notification. For that we will need
// a channel of high importance.
@RequiresApi(api = Build.VERSION_CODES.N)
private final static int FALLBACK_CHANNEL_IMPORTANCE = NotificationManager.IMPORTANCE_HIGH;
public ChannelAwareNotificationBuilder(Context context) {
super(context);
}
protected NotificationCompat.Builder createBuilder() {
return new NotificationCompat.Builder(getContext(), getChannelId());
}
/**
* @return A {@link NotificationChannel}'s identifier to use for the notification.
*/
protected String getChannelId() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// Returning null on incompatible platforms won't be an error.
return null;
}
NotificationTrigger trigger = getTrigger();
if (trigger == null) {
Log.e("notifications", String.format("Couldn't get channel for the notifications - trigger is 'null'. Fallback to '%s' channel", FALLBACK_CHANNEL_ID));
return getFallbackNotificationChannel().getId();
}
String requestedChannelId = trigger.getNotificationChannel();
if (requestedChannelId == null) {
return getFallbackNotificationChannel().getId();
}
NotificationChannel channelForRequestedId = getNotificationsChannelManager().getNotificationChannel(requestedChannelId);
if (channelForRequestedId == null) {
Log.e("notifications", String.format("Channel '%s' doesn't exists. Fallback to '%s' channel", requestedChannelId, FALLBACK_CHANNEL_ID));
return getFallbackNotificationChannel().getId();
}
return channelForRequestedId.getId();
}
@NonNull
protected NotificationsChannelManager getNotificationsChannelManager() {
return new AndroidXNotificationsChannelManager(getContext(), new AndroidXNotificationsChannelGroupManager(getContext()));
}
/**
* Fetches the fallback notification channel, and if it doesn't exist yet - creates it.
* <p>
* Returns null on {@link NotificationChannel}-incompatible platforms.
*
* @return Fallback {@link NotificationChannel} or null.
*/
public NotificationChannel getFallbackNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return null;
}
NotificationChannel channel = getNotificationManager().getNotificationChannel(FALLBACK_CHANNEL_ID);
if (channel != null) {
return channel;
}
return createFallbackChannel();
}
/**
* Creates a fallback channel of {@link #FALLBACK_CHANNEL_ID} ID, name fetched
* from Android resources ({@link #getFallbackChannelName()}
* and importance set by {@link #FALLBACK_CHANNEL_IMPORTANCE}.
*
* @return Newly created channel.
*/
@RequiresApi(api = Build.VERSION_CODES.O)
protected NotificationChannel createFallbackChannel() {
NotificationChannel channel = new NotificationChannel(FALLBACK_CHANNEL_ID, getFallbackChannelName(), FALLBACK_CHANNEL_IMPORTANCE);
channel.setShowBadge(true);
channel.enableVibration(true);
getNotificationManager().createNotificationChannel(channel);
return channel;
}
/**
* Fetches fallback channel name from Android resources. Overridable by Android resources system
* or subclassing.
*
* @return Name of the fallback channel
*/
protected String getFallbackChannelName() {
return getContext().getString(R.string.expo_notifications_fallback_channel_name);
}
private NotificationManager getNotificationManager() {
return (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE);
}
@Nullable
private NotificationTrigger getTrigger() {
NotificationRequest request = getNotification().getNotificationRequest();
if (request == null) {
return null;
}
return request.getTrigger();
}
}

View File

@@ -0,0 +1,23 @@
package expo.modules.notifications.notifications.presentation.builders
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.net.URL
suspend fun downloadImage(imageUrl: Uri, connectTimeout: Long = 8000, readTimeout: Long = 8000): Bitmap? {
return runCatching {
withTimeout(connectTimeout + readTimeout) {
withContext(Dispatchers.IO) {
val url = URL(imageUrl.toString())
val connection = url.openConnection()
connection.connectTimeout = connectTimeout.toInt()
connection.readTimeout = readTimeout.toInt()
BitmapFactory.decodeStream(connection.inputStream)
}
}
}.getOrNull()
}

View File

@@ -0,0 +1,326 @@
package expo.modules.notifications.notifications.presentation.builders
import android.app.Notification
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.os.Parcel
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import expo.modules.notifications.notifications.SoundResolver
import expo.modules.notifications.notifications.enums.NotificationPriority
import expo.modules.notifications.notifications.model.NotificationAction
import expo.modules.notifications.notifications.model.NotificationRequest
import expo.modules.notifications.notifications.model.NotificationResponse
import expo.modules.notifications.service.NotificationsService.Companion.createNotificationResponseIntent
import kotlin.math.max
import kotlin.math.min
/**
* [NotificationBuilder] interpreting a JSON request object.
*/
open class ExpoNotificationBuilder(context: Context?) : ChannelAwareNotificationBuilder(context) {
override suspend fun build(): Notification {
val builder = createBuilder()
builder.setSmallIcon(icon)
builder.setPriority(priority)
val content = notificationContent
builder.setAutoCancel(content.isAutoDismiss)
builder.setOngoing(content.isSticky)
builder.setContentTitle(content.title)
builder.setContentText(content.text)
builder.setSubText(content.subtitle)
// Sets the text/contentText as the bigText to allow the notification to be expanded and the
// entire text to be viewed.
builder.setStyle(NotificationCompat.BigTextStyle().bigText(content.text))
color?.let { builder.color = it.toInt() }
val shouldPlayDefaultSound = shouldPlaySound() && content.shouldPlayDefaultSound
if (shouldPlayDefaultSound && shouldVibrate()) {
builder.setDefaults(NotificationCompat.DEFAULT_ALL) // set sound, vibration and lights
} else if (shouldVibrate()) {
builder.setDefaults(NotificationCompat.DEFAULT_VIBRATE)
} else if (shouldPlayDefaultSound) {
builder.setDefaults(NotificationCompat.DEFAULT_SOUND)
} else {
// Notification will not vibrate or play sound, regardless of channel
builder.setSilent(true)
}
if (shouldPlaySound() && content.soundName != null) {
content.soundName?.let { soundName ->
val soundUri = SoundResolver(context).resolve(soundName)
builder.setSound(soundUri)
}
} else if (shouldPlayDefaultSound) {
builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI)
}
val vibrationPatternOverride = content.vibrationPattern
if (shouldVibrate() && vibrationPatternOverride != null) {
builder.setVibrate(vibrationPatternOverride)
}
if (content.body != null) {
// Add body - JSON data - to extras
val extras = builder.extras
extras.putString(EXTRAS_BODY_KEY, content.body.toString())
builder.setExtras(extras)
}
// Save the notification request in extras for later usage
// eg. in ExpoPresentationDelegate when we fetch active notifications.
// Otherwise we'd have to create expo.Notification from android.Notification
// and deal with two-way interpreting.
val requestExtras = Bundle()
// Class loader used in BaseBundle when unmarshalling notification extras
// cannot handle expo.modules.notifications.….NotificationRequest
// so we go around it by marshalling and unmarshalling the object ourselves.
requestExtras.putByteArray(
EXTRAS_MARSHALLED_NOTIFICATION_REQUEST_KEY,
marshallNotificationRequest(
notification.notificationRequest
)
)
builder.addExtras(requestExtras)
val defaultAction =
NotificationAction(NotificationResponse.DEFAULT_ACTION_IDENTIFIER, null, true)
builder.setContentIntent(
createNotificationResponseIntent(
context,
notification,
defaultAction
)
)
if (notificationContent.containsImage()) {
val bitmap = notificationContent.getImage(context)
bitmap?.let { builder.setLargeIcon(it) }
} else {
builder.setLargeIcon(largeIcon)
}
return builder.build()
}
/**
* Marshalls [NotificationRequest] into to a byte array.
*
* @param request Notification request to marshall
* @return Given request marshalled to a byte array or null if the process failed.
*/
protected fun marshallNotificationRequest(request: NotificationRequest): ByteArray? {
try {
val parcel = Parcel.obtain()
request.writeToParcel(parcel, 0)
val bytes = parcel.marshall()
parcel.recycle()
return bytes
} catch (e: Exception) {
// If we couldn't marshall the request, let's not fail the whole build process.
// The request is only used to extract source request when fetching displayed notifications.
Log.e(
"expo-notifications",
"Could not marshalled notification request: ${request.identifier}.",
e
)
return null
}
}
/**
* Notification should play a sound if and only if:
* - behavior is not set or allows sound AND
* - notification request doesn't explicitly set "sound" to false.
*
*
* This way a notification can set "sound" to false to disable sound,
* and we always honor the allowedBehavior, if set.
*
* @return Whether the notification should play a sound.
*/
private fun shouldPlaySound(): Boolean {
val behaviorAllowsSound =
notificationBehavior == null || notificationBehavior.shouldPlaySound()
val contentAllowsSound = notificationContent.shouldPlayDefaultSound || notificationContent.soundName != null
return behaviorAllowsSound && contentAllowsSound
}
/**
* Notification should vibrate if and only if:
* - behavior is not set or allows sound AND
* - notification request doesn't explicitly set "vibrate" to false.
*
*
* This way a notification can set "vibrate" to false to disable vibration.
*
* @return Whether the notification should vibrate.
*/
private fun shouldVibrate(): Boolean {
val behaviorAllowsVibration =
notificationBehavior == null || notificationBehavior.shouldPlaySound()
val contentAllowsVibration =
notificationContent.shouldUseDefaultVibrationPattern || notificationContent.vibrationPattern != null
return behaviorAllowsVibration && contentAllowsVibration
}
private val priority: Int
/**
* When setting the priority we want to honor both behavior set by the current
* notification handler and the preset priority (in that order of significance).
*
*
* We do this by returning:
* - if behavior defines a priority: the priority,
* - if the notification should be shown: high priority (or max, if requested in the notification),
* - if the notification should not be shown: default priority (or lower, if requested in the notification).
*
*
* This way we allow full customization to the developers.
*
* @return Priority of the notification, one of NotificationCompat.PRIORITY_*
*/
get() {
val requestPriority = notificationContent.priority
// If we know of a behavior guideline, let's honor it...
if (notificationBehavior != null) {
// ...by using the priority override...
val priorityOverride = notificationBehavior.priorityOverride
if (priorityOverride != null) {
return priorityOverride.nativeValue
}
// ...or by setting min/max values for priority:
// If the notification has no priority set, let's pick a neutral value and depend solely on the behavior.
val requestPriorityValue =
requestPriority?.nativeValue ?: NotificationPriority.DEFAULT.nativeValue
// TODO (barthap): This is going to be a dead code upon removing presentNotificationAsync()
// shouldShowAlert() will always be false here.
return if (notificationBehavior.shouldShowAlert()) {
// Display as a heads-up notification, as per the behavior
// while also allowing making the priority higher.
max(
NotificationCompat.PRIORITY_HIGH.toDouble(),
requestPriorityValue.toDouble()
).toInt()
} else {
// Do not display as a heads-up notification, but show in the notification tray
// as per the behavior, while also allowing making the priority lower.
min(
NotificationCompat.PRIORITY_DEFAULT.toDouble(),
requestPriorityValue.toDouble()
).toInt()
}
}
// No behavior is set, the only source of priority can be the request.
if (requestPriority != null) {
return requestPriority.nativeValue
}
// By default let's show the notification
return NotificationCompat.PRIORITY_HIGH
}
protected val largeIcon: Bitmap?
/**
* The method first tries to get the large icon from the manifest's meta-data [.META_DATA_DEFAULT_ICON_KEY].
* If a custom setting is not found, the method falls back to null.
*
* @return Bitmap containing larger icon or null if a custom settings was not provided.
*/
get() {
try {
val ai = context.packageManager.getApplicationInfo(
context.packageName,
PackageManager.GET_META_DATA
)
if (ai.metaData.containsKey(META_DATA_LARGE_ICON_KEY)) {
val resourceId = ai.metaData.getInt(META_DATA_LARGE_ICON_KEY)
return BitmapFactory.decodeResource(context.resources, resourceId)
}
} catch (e: Exception) {
Log.e("expo-notifications", "Could not have fetched large notification icon.", e)
}
return null
}
protected val icon: Int
/**
* The method first tries to get the icon from the manifest's meta-data [.META_DATA_DEFAULT_ICON_KEY].
* If a custom setting is not found, the method falls back to using app icon.
*
* @return Resource ID for icon that should be used as a notification icon.
*/
get() {
try {
val ai = context.packageManager.getApplicationInfo(
context.packageName,
PackageManager.GET_META_DATA
)
if (ai.metaData.containsKey(META_DATA_DEFAULT_ICON_KEY)) {
return ai.metaData.getInt(META_DATA_DEFAULT_ICON_KEY)
}
} catch (e: Exception) {
Log.e("expo-notifications", "Could not have fetched default notification icon.", e)
}
return context.applicationInfo.icon
}
protected val color: Number?
/**
* The method responsible for finding and returning a custom color used to color the notification icon.
* It first tries to use a custom color defined in notification content, then it tries to fetch color
* from resources (based on manifest's meta-data). If not found, returns null.
*
* @return A [Number], if a custom color should be used for notification icon
* or null if the default should be used.
*/
get() {
if (notificationContent.color != null) {
return notificationContent.color
}
try {
val ai = context.packageManager.getApplicationInfo(
context.packageName,
PackageManager.GET_META_DATA
)
if (ai.metaData.containsKey(META_DATA_DEFAULT_COLOR_KEY)) {
return context.resources.getColor(
ai.metaData.getInt(META_DATA_DEFAULT_COLOR_KEY),
null
)
}
} catch (e: Exception) {
Log.e("expo-notifications", "Could not have fetched default notification color.", e)
}
// No custom color
return null
}
companion object {
const val META_DATA_DEFAULT_ICON_KEY: String =
"expo.modules.notifications.default_notification_icon"
const val META_DATA_LARGE_ICON_KEY: String =
"expo.modules.notifications.large_notification_icon"
const val META_DATA_DEFAULT_COLOR_KEY: String =
"expo.modules.notifications.default_notification_color"
const val EXTRAS_MARSHALLED_NOTIFICATION_REQUEST_KEY: String = "expo.notification_request"
const val EXTRAS_BODY_KEY = "body"
}
}

View File

@@ -0,0 +1,235 @@
package expo.modules.notifications.notifications.scheduling
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import expo.modules.core.arguments.ReadableArguments
import expo.modules.core.errors.InvalidArgumentException
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.notifications.ResultReceiverBody
import expo.modules.notifications.createDefaultResultReceiver
import expo.modules.notifications.notifications.ArgumentsNotificationContentBuilder
import expo.modules.notifications.notifications.NotificationSerializer
import expo.modules.notifications.notifications.interfaces.NotificationTrigger
import expo.modules.notifications.notifications.interfaces.SchedulableNotificationTrigger
import expo.modules.notifications.notifications.model.NotificationContent
import expo.modules.notifications.notifications.model.NotificationRequest
import expo.modules.notifications.notifications.triggers.ChannelAwareTrigger
import expo.modules.notifications.notifications.triggers.DailyTrigger
import expo.modules.notifications.notifications.triggers.DateTrigger
import expo.modules.notifications.notifications.triggers.TimeIntervalTrigger
import expo.modules.notifications.notifications.triggers.WeeklyTrigger
import expo.modules.notifications.notifications.triggers.YearlyTrigger
import expo.modules.notifications.service.NotificationsService
import expo.modules.notifications.service.NotificationsService.Companion.getAllScheduledNotifications
import expo.modules.notifications.service.NotificationsService.Companion.removeAllScheduledNotifications
import expo.modules.notifications.service.NotificationsService.Companion.removeScheduledNotification
import expo.modules.notifications.service.NotificationsService.Companion.schedule
open class NotificationScheduler : Module() {
protected open val schedulingContext: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
private val handler = Handler(Looper.getMainLooper())
protected fun createResultReceiver(body: ResultReceiverBody) =
createDefaultResultReceiver(handler, body)
override fun definition() = ModuleDefinition {
Name("ExpoNotificationScheduler")
AsyncFunction("getAllScheduledNotificationsAsync") { promise: Promise ->
getAllScheduledNotifications(
schedulingContext,
createResultReceiver { resultCode: Int, resultData: Bundle? ->
if (resultCode == NotificationsService.SUCCESS_CODE) {
val requests = resultData?.getParcelableArrayList<NotificationRequest>(NotificationsService.NOTIFICATION_REQUESTS_KEY)
if (requests == null) {
promise.reject("ERR_NOTIFICATIONS_FAILED_TO_FETCH", "Failed to fetch scheduled notifications.", null)
} else {
promise.resolve(serializeScheduledNotificationRequests(requests))
}
} else {
val e = resultData?.getSerializable(NotificationsService.EXCEPTION_KEY) as Exception
promise.reject("ERR_NOTIFICATIONS_FAILED_TO_FETCH", "Failed to fetch scheduled notifications.", e)
}
}
)
}
AsyncFunction("scheduleNotificationAsync") { identifier: String, notificationContentMap: ReadableArguments, triggerParams: ReadableArguments?, promise: Promise ->
try {
val content = ArgumentsNotificationContentBuilder(schedulingContext).setPayload(notificationContentMap).build()
val request = createNotificationRequest(
identifier,
content,
triggerFromParams(triggerParams)
)
schedule(
schedulingContext,
request,
createResultReceiver { resultCode: Int, resultData: Bundle? ->
if (resultCode == NotificationsService.SUCCESS_CODE) {
promise.resolve(identifier)
} else {
val e = resultData?.getSerializable(NotificationsService.EXCEPTION_KEY) as? Exception
promise.reject("ERR_NOTIFICATIONS_FAILED_TO_SCHEDULE", "Failed to schedule the notification. ${e?.message}", e)
}
}
)
} catch (e: InvalidArgumentException) {
promise.reject("ERR_NOTIFICATIONS_FAILED_TO_SCHEDULE", "Failed to schedule the notification. ${e.message}", e)
} catch (e: NullPointerException) {
promise.reject("ERR_NOTIFICATIONS_FAILED_TO_SCHEDULE", "Failed to schedule the notification. Encountered unexpected null value. ${e.message}", e)
}
}
AsyncFunction("cancelScheduledNotificationAsync", this@NotificationScheduler::cancelScheduledNotificationAsync)
AsyncFunction("cancelAllScheduledNotificationsAsync", this@NotificationScheduler::cancelAllScheduledNotificationsAsync)
AsyncFunction("getNextTriggerDateAsync") { triggerParams: ReadableArguments?, promise: Promise ->
try {
val trigger = triggerFromParams(triggerParams)
if (trigger is SchedulableNotificationTrigger) {
val nextTriggerDate = trigger.nextTriggerDate()
if (nextTriggerDate == null) {
promise.resolve(null)
} else {
promise.resolve(nextTriggerDate.time.toDouble())
}
} else {
val triggerDescription = if (trigger == null) "null" else trigger.javaClass.name
val message = String.format("It is not possible to get next trigger date for triggers other than calendar-based. Provided trigger resulted in %s trigger.", triggerDescription)
promise.reject("ERR_NOTIFICATIONS_INVALID_CALENDAR_TRIGGER", message, null)
}
} catch (e: InvalidArgumentException) {
promise.reject("ERR_NOTIFICATIONS_FAILED_TO_GET_NEXT_TRIGGER_DATE", "Failed to get next trigger date for the trigger. ${e.message}", e)
} catch (e: NullPointerException) {
promise.reject("ERR_NOTIFICATIONS_FAILED_TO_GET_NEXT_TRIGGER_DATE", "Failed to get next trigger date for the trigger. Encountered unexpected null value. ${e.message}", e)
}
}
}
open fun cancelScheduledNotificationAsync(identifier: String, promise: Promise) {
removeScheduledNotification(
schedulingContext,
identifier,
createResultReceiver { resultCode: Int, resultData: Bundle? ->
if (resultCode == NotificationsService.SUCCESS_CODE) {
promise.resolve(null)
} else {
val e = resultData?.getSerializable(NotificationsService.EXCEPTION_KEY) as? Exception
promise.reject("ERR_NOTIFICATIONS_FAILED_TO_CANCEL", "Failed to cancel notification.", e)
}
}
)
}
open fun cancelAllScheduledNotificationsAsync(promise: Promise) {
removeAllScheduledNotifications(
schedulingContext,
createResultReceiver { resultCode: Int, resultData: Bundle? ->
if (resultCode == NotificationsService.SUCCESS_CODE) {
promise.resolve(null)
} else {
val e = resultData?.getSerializable(NotificationsService.EXCEPTION_KEY) as? Exception
promise.reject("ERR_NOTIFICATIONS_FAILED_TO_CANCEL", "Failed to cancel all notifications.", e)
}
}
)
}
@Throws(InvalidArgumentException::class)
protected fun triggerFromParams(params: ReadableArguments?): NotificationTrigger? {
if (params == null) {
return null
}
val channelId = params.getString("channelId", null)
return when (val type = params.getString("type")) {
"timeInterval" -> {
val seconds = params["seconds"] as? Number
?: throw InvalidArgumentException("Invalid value provided as interval of trigger.")
TimeIntervalTrigger(seconds.toLong(), params.getBoolean("repeats"), channelId)
}
"date" -> {
val timestamp = params["timestamp"] as? Number
?: throw InvalidArgumentException("Invalid value provided as date of trigger.")
DateTrigger(timestamp.toLong(), channelId)
}
"daily" -> {
val hour = params["hour"] as? Number
val minute = params["minute"] as? Number
if (hour == null || minute == null) {
throw InvalidArgumentException("Invalid value(s) provided for daily trigger.")
}
DailyTrigger(
hour.toInt(),
minute.toInt(),
channelId
)
}
"weekly" -> {
val weekday = params["weekday"] as? Number
val hour = params["hour"] as? Number
val minute = params["minute"] as? Number
if (weekday == null || hour == null || minute == null) {
throw InvalidArgumentException("Invalid value(s) provided for weekly trigger.")
}
WeeklyTrigger(
weekday.toInt(),
hour.toInt(),
minute.toInt(),
channelId
)
}
"yearly" -> {
val day = params["day"] as? Number
val month = params["month"] as? Number
val hour = params["hour"] as? Number
val minute = params["minute"] as? Number
if (day == null || month == null || hour == null || minute == null) {
throw InvalidArgumentException("Invalid value(s) provided for yearly trigger.")
}
YearlyTrigger(
day.toInt(),
month.toInt(),
hour.toInt(),
minute.toInt(),
channelId
)
}
"channel" -> ChannelAwareTrigger(channelId)
else -> throw InvalidArgumentException("Trigger of type: $type is not supported on Android.")
}
}
protected open fun createNotificationRequest(
identifier: String,
content: NotificationContent,
notificationTrigger: NotificationTrigger?
): NotificationRequest {
return NotificationRequest(identifier, content, notificationTrigger)
}
protected open fun serializeScheduledNotificationRequests(requests: Collection<NotificationRequest>): List<Bundle> {
return requests.map(NotificationSerializer::toBundle)
}
}

View File

@@ -0,0 +1,49 @@
package expo.modules.notifications.notifications.triggers;
import android.os.Parcel;
import java.io.Serializable;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.interfaces.NotificationTrigger;
public class ChannelAwareTrigger implements NotificationTrigger, Serializable {
@Nullable
private String mChannelId;
public ChannelAwareTrigger(@Nullable String channelId) {
mChannelId = channelId;
}
public ChannelAwareTrigger(Parcel in) {
mChannelId = in.readString();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel parcel, int i) {
parcel.writeString(mChannelId);
}
@Nullable
@Override
public String getNotificationChannel() {
return mChannelId;
}
public static final Creator<ChannelAwareTrigger> CREATOR = new Creator<ChannelAwareTrigger>() {
@Override
public ChannelAwareTrigger createFromParcel(Parcel in) {
return new ChannelAwareTrigger(in);
}
@Override
public ChannelAwareTrigger[] newArray(int size) {
return new ChannelAwareTrigger[size];
}
};
}

View File

@@ -0,0 +1,76 @@
package expo.modules.notifications.notifications.triggers;
import android.os.Parcel;
import java.util.Calendar;
import java.util.Date;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.interfaces.SchedulableNotificationTrigger;
/**
* A schedulable trigger representing a notification to be scheduled once per day.
*/
public class DailyTrigger extends ChannelAwareTrigger implements SchedulableNotificationTrigger {
private int mHour;
private int mMinute;
public DailyTrigger(int hour, int minute, @Nullable String channelId) {
super(channelId);
mHour = hour;
mMinute = minute;
}
private DailyTrigger(Parcel in) {
super(in);
mHour = in.readInt();
mMinute = in.readInt();
}
public int getHour() {
return mHour;
}
public int getMinute() {
return mMinute;
}
@Nullable
@Override
public Date nextTriggerDate() {
Calendar nextTriggerDate = Calendar.getInstance();
nextTriggerDate.set(Calendar.HOUR_OF_DAY, mHour);
nextTriggerDate.set(Calendar.MINUTE, mMinute);
nextTriggerDate.set(Calendar.SECOND, 0);
nextTriggerDate.set(Calendar.MILLISECOND, 0);
Calendar rightNow = Calendar.getInstance();
if (nextTriggerDate.before(rightNow)) {
nextTriggerDate.add(Calendar.DATE, 1);
}
return nextTriggerDate.getTime();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeInt(mHour);
dest.writeInt(mMinute);
}
public static final Creator<DailyTrigger> CREATOR = new Creator<DailyTrigger>() {
@Override
public DailyTrigger createFromParcel(Parcel in) {
return new DailyTrigger(in);
}
@Override
public DailyTrigger[] newArray(int size) {
return new DailyTrigger[size];
}
};
}

View File

@@ -0,0 +1,64 @@
package expo.modules.notifications.notifications.triggers;
import android.os.Parcel;
import java.util.Date;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.interfaces.SchedulableNotificationTrigger;
/**
* A schedulable trigger representing notification to be scheduled only once at a given moment of time.
*/
public class DateTrigger extends ChannelAwareTrigger implements SchedulableNotificationTrigger {
private Date mTriggerDate;
public DateTrigger(long timestamp, @Nullable String channelId) {
super(channelId);
mTriggerDate = new Date(timestamp);
}
private DateTrigger(Parcel in) {
super(in);
mTriggerDate = new Date(in.readLong());
}
public Date getTriggerDate() {
return mTriggerDate;
}
@Nullable
@Override
public Date nextTriggerDate() {
Date now = new Date();
if (mTriggerDate.before(now)) {
return null;
}
return mTriggerDate;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeLong(mTriggerDate.getTime());
}
public static final Creator<DateTrigger> CREATOR = new Creator<DateTrigger>() {
@Override
public DateTrigger createFromParcel(Parcel in) {
return new DateTrigger(in);
}
@Override
public DateTrigger[] newArray(int size) {
return new DateTrigger[size];
}
};
}

View File

@@ -0,0 +1,87 @@
package expo.modules.notifications.notifications.triggers;
import android.os.Parcel;
import java.util.Date;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.interfaces.SchedulableNotificationTrigger;
/**
* A schedulable trigger representing notification to be scheduled after X milliseconds,
* optionally repeating.
* <p>
* <i>Note: The implementation ensures that the trigger times do not drift away too much from the
* * initial time, so eg. a trigger started at 11111000 time repeated every 1000 ms should always
* * trigger around …000 timestamp.</i>
*/
public class TimeIntervalTrigger extends ChannelAwareTrigger implements SchedulableNotificationTrigger {
private Date mTriggerDate;
private long mTimeInterval;
private boolean mRepeats;
public TimeIntervalTrigger(long timeInterval, boolean repeats, @Nullable String channelId) {
super(channelId);
mTimeInterval = timeInterval;
mTriggerDate = new Date(new Date().getTime() + mTimeInterval * 1000);
mRepeats = repeats;
}
private TimeIntervalTrigger(Parcel in) {
super(in);
mTriggerDate = new Date(in.readLong());
mTimeInterval = in.readLong();
mRepeats = in.readByte() == 1;
}
public boolean isRepeating() {
return mRepeats;
}
public long getTimeInterval() {
return mTimeInterval;
}
@Nullable
@Override
public Date nextTriggerDate() {
Date now = new Date();
if (mRepeats) {
while (mTriggerDate.before(now)) {
mTriggerDate.setTime(mTriggerDate.getTime() + mTimeInterval * 1000);
}
}
if (mTriggerDate.before(now)) {
return null;
}
return mTriggerDate;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeLong(mTriggerDate.getTime());
dest.writeLong(mTimeInterval);
dest.writeByte((byte) (mRepeats ? 1 : 0));
}
public static final Creator<TimeIntervalTrigger> CREATOR = new Creator<TimeIntervalTrigger>() {
@Override
public TimeIntervalTrigger createFromParcel(Parcel in) {
return new TimeIntervalTrigger(in);
}
@Override
public TimeIntervalTrigger[] newArray(int size) {
return new TimeIntervalTrigger[size];
}
};
}

View File

@@ -0,0 +1,85 @@
package expo.modules.notifications.notifications.triggers;
import android.os.Parcel;
import java.util.Calendar;
import java.util.Date;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.interfaces.SchedulableNotificationTrigger;
/**
* A schedulable trigger representing a notification to be scheduled once per week.
*/
public class WeeklyTrigger extends ChannelAwareTrigger implements SchedulableNotificationTrigger {
private int mWeekday;
private int mHour;
private int mMinute;
public WeeklyTrigger(int weekday, int hour, int minute, @Nullable String channelId) {
super(channelId);
mWeekday = weekday;
mHour = hour;
mMinute = minute;
}
private WeeklyTrigger(Parcel in) {
super(in);
mWeekday = in.readInt();
mHour = in.readInt();
mMinute = in.readInt();
}
public int getWeekday() {
return mWeekday;
}
public int getHour() {
return mHour;
}
public int getMinute() {
return mMinute;
}
@Nullable
@Override
public Date nextTriggerDate() {
Calendar nextTriggerDate = Calendar.getInstance();
nextTriggerDate.set(Calendar.DAY_OF_WEEK, mWeekday);
nextTriggerDate.set(Calendar.HOUR_OF_DAY, mHour);
nextTriggerDate.set(Calendar.MINUTE, mMinute);
nextTriggerDate.set(Calendar.SECOND, 0);
nextTriggerDate.set(Calendar.MILLISECOND, 0);
Calendar rightNow = Calendar.getInstance();
if (nextTriggerDate.before(rightNow)) {
nextTriggerDate.add(Calendar.DAY_OF_WEEK_IN_MONTH, 1);
}
return nextTriggerDate.getTime();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeInt(mWeekday);
dest.writeInt(mHour);
dest.writeInt(mMinute);
}
public static final Creator<WeeklyTrigger> CREATOR = new Creator<WeeklyTrigger>() {
@Override
public WeeklyTrigger createFromParcel(Parcel in) {
return new WeeklyTrigger(in);
}
@Override
public WeeklyTrigger[] newArray(int size) {
return new WeeklyTrigger[size];
}
};
}

View File

@@ -0,0 +1,94 @@
package expo.modules.notifications.notifications.triggers;
import android.os.Parcel;
import java.util.Calendar;
import java.util.Date;
import androidx.annotation.Nullable;
import expo.modules.notifications.notifications.interfaces.SchedulableNotificationTrigger;
/**
* A schedulable trigger representing a notification to be scheduled once per year.
*/
public class YearlyTrigger extends ChannelAwareTrigger implements SchedulableNotificationTrigger {
private int mDay;
private int mMonth;
private int mHour;
private int mMinute;
public YearlyTrigger(int day, int month, int hour, int minute, @Nullable String channelId) {
super(channelId);
mDay = day;
mMonth = month;
mHour = hour;
mMinute = minute;
}
private YearlyTrigger(Parcel in) {
super(in);
mDay = in.readInt();
mMonth = in.readInt();
mHour = in.readInt();
mMinute = in.readInt();
}
public int getDay() {
return mDay;
}
public int getMonth() {
return mMonth;
}
public int getHour() {
return mHour;
}
public int getMinute() {
return mMinute;
}
@Nullable
@Override
public Date nextTriggerDate() {
Calendar nextTriggerDate = Calendar.getInstance();
nextTriggerDate.set(Calendar.DATE, mDay);
nextTriggerDate.set(Calendar.MONTH, mMonth);
nextTriggerDate.set(Calendar.HOUR_OF_DAY, mHour);
nextTriggerDate.set(Calendar.MINUTE, mMinute);
nextTriggerDate.set(Calendar.SECOND, 0);
nextTriggerDate.set(Calendar.MILLISECOND, 0);
Calendar rightNow = Calendar.getInstance();
if (nextTriggerDate.before(rightNow)) {
nextTriggerDate.add(Calendar.YEAR, 1);
}
return nextTriggerDate.getTime();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeInt(mDay);
dest.writeInt(mMonth);
dest.writeInt(mHour);
dest.writeInt(mMinute);
}
public static final Creator<YearlyTrigger> CREATOR = new Creator<YearlyTrigger>() {
@Override
public YearlyTrigger createFromParcel(Parcel in) {
return new YearlyTrigger(in);
}
@Override
public YearlyTrigger[] newArray(int size) {
return new YearlyTrigger[size];
}
};
}

View File

@@ -0,0 +1,124 @@
package expo.modules.notifications.permissions
import android.Manifest
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationManagerCompat
import androidx.core.os.bundleOf
import expo.modules.core.arguments.ReadableArguments
import expo.modules.interfaces.permissions.Permissions
import expo.modules.interfaces.permissions.PermissionsResponse
import expo.modules.interfaces.permissions.PermissionsStatus
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.notifications.ModuleNotFoundException
private const val ANDROID_RESPONSE_KEY = "android"
private const val IMPORTANCE_KEY = "importance"
private const val INTERRUPTION_FILTER_KEY = "interruptionFilter"
private val PERMISSIONS: Array<String> = arrayOf(Manifest.permission.POST_NOTIFICATIONS)
class NotificationPermissionsModule : Module() {
private val permissions: Permissions
get() = appContext.permissions ?: throw ModuleNotFoundException(Permissions::class)
private val context: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
override fun definition() = ModuleDefinition {
Name("ExpoNotificationPermissionsModule")
AsyncFunction("getPermissionsAsync") { promise: Promise ->
if (context.applicationContext.applicationInfo.targetSdkVersion >= 33 && Build.VERSION.SDK_INT >= 33) {
getPermissionsWithPromiseImplApi33(promise)
} else {
getPermissionsWithPromiseImplClassic(promise)
}
}
AsyncFunction("requestPermissionsAsync") { _: ReadableArguments?, promise: Promise ->
if (context.applicationContext.applicationInfo.targetSdkVersion >= 33 && Build.VERSION.SDK_INT >= 33) {
requestPermissionsWithPromiseImplApi33(promise)
} else {
getPermissionsWithPromiseImplClassic(promise)
}
}
}
@RequiresApi(33)
private fun getPermissionsWithPromiseImplApi33(promise: Promise) {
permissions.getPermissions(
{ permissionsMap: Map<String, PermissionsResponse> ->
val managerCompat = NotificationManagerCompat.from(context)
val areEnabled = managerCompat.areNotificationsEnabled()
val platformBundle = bundleOf(
IMPORTANCE_KEY to managerCompat.importance
).apply {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
if (notificationManager != null) {
putInt(INTERRUPTION_FILTER_KEY, notificationManager.currentInterruptionFilter)
}
}
val areAllGranted = permissionsMap.all { (_, response) -> response.status == PermissionsStatus.GRANTED }
val areAllDenied = permissionsMap.all { (_, response) -> response.status == PermissionsStatus.DENIED }
val canAskAgain = permissionsMap.all { (_, response) -> response.canAskAgain }
val status = when {
areAllDenied -> PermissionsStatus.DENIED.status
!areEnabled -> PermissionsStatus.DENIED.status
areAllGranted -> PermissionsStatus.GRANTED.status
else -> PermissionsStatus.UNDETERMINED.status
}
promise.resolve(
bundleOf(
PermissionsResponse.EXPIRES_KEY to PermissionsResponse.PERMISSION_EXPIRES_NEVER,
PermissionsResponse.STATUS_KEY to status,
PermissionsResponse.CAN_ASK_AGAIN_KEY to canAskAgain,
PermissionsResponse.GRANTED_KEY to areAllGranted,
ANDROID_RESPONSE_KEY to platformBundle
)
)
},
*PERMISSIONS
)
}
private fun getPermissionsWithPromiseImplClassic(promise: Promise) {
val managerCompat = NotificationManagerCompat.from(context)
val areEnabled = managerCompat.areNotificationsEnabled()
val status = if (areEnabled) PermissionsStatus.GRANTED else PermissionsStatus.DENIED
val platformBundle = bundleOf(
IMPORTANCE_KEY to managerCompat.importance
).apply {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
if (notificationManager != null) {
putInt(INTERRUPTION_FILTER_KEY, notificationManager.currentInterruptionFilter)
}
}
promise.resolve(
bundleOf(
PermissionsResponse.EXPIRES_KEY to PermissionsResponse.PERMISSION_EXPIRES_NEVER,
PermissionsResponse.STATUS_KEY to status.status,
PermissionsResponse.CAN_ASK_AGAIN_KEY to areEnabled,
PermissionsResponse.GRANTED_KEY to (status == PermissionsStatus.GRANTED),
ANDROID_RESPONSE_KEY to platformBundle
)
)
}
@RequiresApi(33)
private fun requestPermissionsWithPromiseImplApi33(promise: Promise) {
permissions.askForPermissions(
{
getPermissionsWithPromiseImplApi33(promise)
},
*PERMISSIONS
)
}
}

View File

@@ -0,0 +1,134 @@
package expo.modules.notifications.serverregistration;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.UUID;
/**
* An installation ID provider - it solves two purposes:
* - in installations that have a legacy UUID persisted
* in shared-across-expo-modules SharedPreferences or
* shared-across-expo-modules non-backed-up file,
* migrates the UUID from there to its own non-backed-up file,
* - provides/creates a UUID unique per an installation.
* <p>
* Similar class exists in expoview and expo-constants.
*/
public class InstallationId {
private static final String TAG = InstallationId.class.getSimpleName();
// Legacy storage
public static final String LEGACY_PREFERENCES_FILE_NAME = "host.exp.exponent.SharedPreferences";
public static final String LEGACY_PREFERENCES_UUID_KEY = "uuid";
public static final String LEGACY_UUID_FILE_NAME = "expo_installation_uuid.txt";
// Primary storage
public static final String UUID_FILE_NAME = "expo_notifications_installation_uuid.txt";
private String mUuid;
private Context mContext;
private SharedPreferences mLegacySharedPreferences;
public InstallationId(Context context) {
mContext = context;
mLegacySharedPreferences = context.getSharedPreferences(LEGACY_PREFERENCES_FILE_NAME, Context.MODE_PRIVATE);
}
public String getUUID() {
// If it has already been cached, return the value.
if (mUuid != null) {
return mUuid;
}
// 1. Read from primary storage
// If there already is a value it must have been migrated previously.
mUuid = readUUIDFromFile(new File(mContext.getNoBackupFilesDir(), UUID_FILE_NAME));
if (mUuid != null) {
return mUuid;
}
// 2. Read from legacy shared preferences
// If there is a value we should use it - it's been used by previous versions
// of expo-notifications, so in order not to rotate the ID we should migrate it
// to new storage.
mUuid = mLegacySharedPreferences.getString(LEGACY_PREFERENCES_UUID_KEY, null);
if (mUuid != null) {
try {
saveUUID(mUuid);
// We only remove the value from old storage once it's set and saved in the new storage.
mLegacySharedPreferences.edit().remove(LEGACY_PREFERENCES_UUID_KEY).apply();
} catch (IOException e) {
Log.e(TAG, "Error while migrating UUID from legacy storage. " + e);
}
return mUuid;
}
// 3. Migrate from legacy file
// If there is a value and we've made it up to here it means
// expo-notifications hasn't used *its own* ID in the past -
// - it used expo-constants' ID. Since it's now deprecated,
// let's copy the value to our own storage.
mUuid = readUUIDFromFile(new File(mContext.getNoBackupFilesDir(), LEGACY_UUID_FILE_NAME));
if (mUuid != null) {
try {
saveUUID(mUuid);
} catch (IOException e) {
Log.e(TAG, "Error while migrating UUID from legacy storage. " + e);
}
return mUuid;
}
//noinspection ConstantConditions
return mUuid;
}
public String getOrCreateUUID() {
String uuid = getUUID();
if (uuid != null) {
return uuid;
}
// We persist the new UUID in "session storage"
// so that if writing to persistent storage
// fails subsequent calls to get(orCreate)UUID
// return the same value.
mUuid = UUID.randomUUID().toString();
try {
saveUUID(mUuid);
} catch (IOException e) {
Log.e(TAG, "Error while writing new UUID. " + e);
}
return mUuid;
}
protected String readUUIDFromFile(File file) {
try (FileReader fileReader = new FileReader(file);
BufferedReader bufferedReader = new BufferedReader(fileReader)) {
String line = bufferedReader.readLine();
// If line is not a UUID, it throws an IllegalArgumentException
return UUID.fromString(line).toString();
} catch (IOException | IllegalArgumentException e) {
return null;
}
}
protected void saveUUID(String uuid) throws IOException {
try (FileWriter writer = new FileWriter(getNonBackedUpUuidFile())) {
writer.write(uuid);
}
}
protected File getNonBackedUpUuidFile() {
return new File(mContext.getNoBackupFilesDir(), UUID_FILE_NAME);
}
}

View File

@@ -0,0 +1,26 @@
package expo.modules.notifications.serverregistration
import android.content.Context
import java.io.File
open class RegistrationInfo(private val context: Context) {
companion object {
const val REGISTRATION_INFO_FILE_NAME = "expo_notifications_registration_info.txt"
}
protected val nonBackedUpRegistrationInfoFile: File
get() = File(context.noBackupFilesDir, REGISTRATION_INFO_FILE_NAME)
fun get(): String? = if (nonBackedUpRegistrationInfoFile.exists()) {
nonBackedUpRegistrationInfoFile.readText()
} else {
null
}
fun set(registrationInfo: String?) {
nonBackedUpRegistrationInfoFile.delete()
registrationInfo?.let {
nonBackedUpRegistrationInfoFile.writeText(it)
}
}
}

View File

@@ -0,0 +1,32 @@
package expo.modules.notifications.serverregistration
import android.content.Context
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
open class ServerRegistrationModule : Module() {
val context: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
protected val installationId by lazy { InstallationId(context) }
private val mRegistrationInfo by lazy { RegistrationInfo(context) }
override fun definition() = ModuleDefinition {
Name("NotificationsServerRegistrationModule")
AsyncFunction<String>("getInstallationIdAsync", this@ServerRegistrationModule::getInstallationId)
AsyncFunction<String?>("getRegistrationInfoAsync") {
mRegistrationInfo.get()
}
AsyncFunction("setRegistrationInfoAsync") { registrationInfo: String? ->
mRegistrationInfo.set(registrationInfo)
}
}
open fun getInstallationId(): String {
return installationId.orCreateUUID
}
}

View File

@@ -0,0 +1,15 @@
package expo.modules.notifications.service
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import expo.modules.notifications.service.interfaces.FirebaseMessagingDelegate
open class ExpoFirebaseMessagingService : FirebaseMessagingService() {
protected open val firebaseMessagingDelegate: FirebaseMessagingDelegate by lazy {
expo.modules.notifications.service.delegates.FirebaseMessagingDelegate(this)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) = firebaseMessagingDelegate.onMessageReceived(remoteMessage)
override fun onNewToken(token: String) = firebaseMessagingDelegate.onNewToken(token)
override fun onDeletedMessages() = firebaseMessagingDelegate.onDeletedMessages()
}

View File

@@ -0,0 +1,33 @@
package expo.modules.notifications.service
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import expo.modules.notifications.BuildConfig
import expo.modules.notifications.service.delegates.ExpoHandlingDelegate
/**
* An internal Activity that passes given Intent extras from
* [NotificationsService.createNotificationResponseIntent]
* and send broadcasts to [NotificationsService].
*/
class NotificationForwarderActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val broadcastIntent =
NotificationsService.createNotificationResponseBroadcastIntent(applicationContext, intent.extras)
val notificationResponse = NotificationsService.getNotificationResponseFromBroadcastIntent(broadcastIntent)
ExpoHandlingDelegate.openAppToForeground(this, notificationResponse)
sendBroadcast(broadcastIntent)
finish()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// This Activity is expected to launch with new task, supposedly
// there's no way for `onNewIntent` to be called.
if (BuildConfig.DEBUG) {
throw AssertionError()
}
}
}

View File

@@ -0,0 +1,789 @@
package expo.modules.notifications.service
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.*
import android.util.Log
import androidx.core.app.RemoteInput
import expo.modules.core.interfaces.DoNotStrip
import expo.modules.notifications.BuildConfig
import expo.modules.notifications.notifications.model.*
import expo.modules.notifications.service.delegates.ExpoCategoriesDelegate
import expo.modules.notifications.service.delegates.ExpoHandlingDelegate
import expo.modules.notifications.service.delegates.ExpoPresentationDelegate
import expo.modules.notifications.service.delegates.ExpoSchedulingDelegate
import expo.modules.notifications.service.interfaces.CategoriesDelegate
import expo.modules.notifications.service.interfaces.HandlingDelegate
import expo.modules.notifications.service.interfaces.PresentationDelegate
import expo.modules.notifications.service.interfaces.SchedulingDelegate
import kotlin.concurrent.thread
/**
* Subclass of FirebaseMessagingService, central dispatcher for all the notifications-related actions.
*/
open class NotificationsService : BroadcastReceiver() {
companion object {
const val NOTIFICATION_EVENT_ACTION = "expo.modules.notifications.NOTIFICATION_EVENT"
val SETUP_ACTIONS = listOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_REBOOT,
Intent.ACTION_MY_PACKAGE_REPLACED,
"android.intent.action.QUICKBOOT_POWERON",
"com.htc.intent.action.QUICKBOOT_POWERON"
)
const val USER_TEXT_RESPONSE_KEY = "userTextResponse"
// Event types
private const val GET_ALL_DISPLAYED_TYPE = "getAllDisplayed"
private const val PRESENT_TYPE = "present"
private const val DISMISS_SELECTED_TYPE = "dismissSelected"
private const val DISMISS_ALL_TYPE = "dismissAll"
private const val RECEIVE_TYPE = "receive"
private const val RECEIVE_RESPONSE_TYPE = "receiveResponse"
private const val DROPPED_TYPE = "dropped"
private const val GET_CATEGORIES_TYPE = "getCategories"
private const val SET_CATEGORY_TYPE = "setCategory"
private const val DELETE_CATEGORY_TYPE = "deleteCategory"
private const val SCHEDULE_TYPE = "schedule"
private const val TRIGGER_TYPE = "trigger"
private const val GET_ALL_SCHEDULED_TYPE = "getAllScheduled"
private const val GET_SCHEDULED_TYPE = "getScheduled"
private const val REMOVE_SELECTED_TYPE = "removeSelected"
private const val REMOVE_ALL_TYPE = "removeAll"
// Messages parts
const val SUCCESS_CODE = 0
const val ERROR_CODE = 1
const val EVENT_TYPE_KEY = "type"
const val EXCEPTION_KEY = "exception"
const val RECEIVER_KEY = "receiver"
// Specific messages parts
const val NOTIFICATION_KEY = "notification"
const val NOTIFICATION_RESPONSE_KEY = "notificationResponse"
const val TEXT_INPUT_NOTIFICATION_RESPONSE_KEY = "textInputNotificationResponse"
const val SUCCEEDED_KEY = "succeeded"
const val IDENTIFIERS_KEY = "identifiers"
const val IDENTIFIER_KEY = "identifier"
const val NOTIFICATION_BEHAVIOR_KEY = "notificationBehavior"
const val NOTIFICATIONS_KEY = "notifications"
const val NOTIFICATION_CATEGORY_KEY = "notificationCategory"
const val NOTIFICATION_CATEGORIES_KEY = "notificationCategories"
const val NOTIFICATION_REQUEST_KEY = "notificationRequest"
const val NOTIFICATION_REQUESTS_KEY = "notificationRequests"
const val NOTIFICATION_ACTION_KEY = "notificationAction"
/**
* A helper function for dispatching a "fetch all displayed notifications" command to the service.
*
* @param context Context where to start the service.
* @param receiver A receiver to which send the notifications
*/
fun getAllPresented(context: Context, receiver: ResultReceiver? = null) {
doWork(
context,
Intent(NOTIFICATION_EVENT_ACTION, getUriBuilder().build()).apply {
putExtra(EVENT_TYPE_KEY, GET_ALL_DISPLAYED_TYPE)
putExtra(RECEIVER_KEY, receiver)
}
)
}
/**
* A helper function for dispatching a "present notification" command to the service.
*
* @param context Context where to start the service.
* @param notification Notification to present
* @param behavior Allowed notification behavior
* @param receiver A receiver to which send the result of presenting the notification
*/
fun present(context: Context, notification: Notification, behavior: NotificationBehavior? = null, receiver: ResultReceiver? = null) {
val data = getUriBuilderForIdentifier(notification.notificationRequest.identifier).appendPath("present").build()
doWork(
context,
Intent(NOTIFICATION_EVENT_ACTION, data).apply {
putExtra(EVENT_TYPE_KEY, PRESENT_TYPE)
putExtra(NOTIFICATION_KEY, notification)
putExtra(NOTIFICATION_BEHAVIOR_KEY, behavior)
putExtra(RECEIVER_KEY, receiver)
}
)
}
/**
* A helper function for dispatching a "notification received" command to the service.
*
* @param context Context where to start the service.
* @param notification Notification received
* @param receiver Result receiver
*/
fun receive(context: Context, notification: Notification, receiver: ResultReceiver? = null) {
val data = getUriBuilderForIdentifier(notification.notificationRequest.identifier).appendPath("receive").build()
doWork(
context,
Intent(NOTIFICATION_EVENT_ACTION, data).apply {
putExtra(EVENT_TYPE_KEY, RECEIVE_TYPE)
putExtra(NOTIFICATION_KEY, notification)
putExtra(RECEIVER_KEY, receiver)
}
)
}
/**
* A helper function for dispatching a "dismiss notification" command to the service.
*
* @param context Context where to start the service.
* @param identifier Notification identifier
* @param receiver A receiver to which send the result of the action
*/
fun dismiss(context: Context, identifiers: Array<String>, receiver: ResultReceiver? = null) {
val data = getUriBuilder().appendPath("dismiss").build()
doWork(
context,
Intent(NOTIFICATION_EVENT_ACTION, data).apply {
putExtra(EVENT_TYPE_KEY, DISMISS_SELECTED_TYPE)
putExtra(IDENTIFIERS_KEY, identifiers)
putExtra(RECEIVER_KEY, receiver)
}
)
}
/**
* A helper function for dispatching a "dismiss notification" command to the service.
*
* @param context Context where to start the service.
* @param receiver A receiver to which send the result of the action
*/
fun dismissAll(context: Context, receiver: ResultReceiver? = null) {
val data = getUriBuilder().appendPath("dismiss").build()
doWork(
context,
Intent(NOTIFICATION_EVENT_ACTION, data).apply {
putExtra(EVENT_TYPE_KEY, DISMISS_ALL_TYPE)
putExtra(RECEIVER_KEY, receiver)
}
)
}
/**
* A helper function for dispatching a "notifications dropped" command to the service.
*
* @param context Context where to start the service.
*/
fun handleDropped(context: Context) {
doWork(
context,
Intent(NOTIFICATION_EVENT_ACTION).apply {
putExtra(EVENT_TYPE_KEY, DROPPED_TYPE)
}
)
}
/**
* A helper function for dispatching a "get notification categories" command to the service.
*
* @param context Context where to start the service.
*/
fun getCategories(context: Context, receiver: ResultReceiver? = null) {
doWork(
context,
Intent(
NOTIFICATION_EVENT_ACTION,
getUriBuilder()
.appendPath("categories")
.build()
).apply {
putExtra(EVENT_TYPE_KEY, GET_CATEGORIES_TYPE)
putExtra(RECEIVER_KEY, receiver)
}
)
}
/**
* A helper function for dispatching a "set notification category" command to the service.
*
* @param context Context where to start the service.
* @param category Notification category to be set
*/
fun setCategory(context: Context, category: NotificationCategory, receiver: ResultReceiver? = null) {
doWork(
context,
Intent(
NOTIFICATION_EVENT_ACTION,
getUriBuilder()
.appendPath("categories")
.appendPath(category.identifier)
.build()
).apply {
putExtra(EVENT_TYPE_KEY, SET_CATEGORY_TYPE)
putExtra(NOTIFICATION_CATEGORY_KEY, category as Parcelable)
putExtra(RECEIVER_KEY, receiver)
}
)
}
/**
* A helper function for dispatching a "delete notification category" command to the service.
*
* @param context Context where to start the service.
* @param identifier Category Identifier
*/
fun deleteCategory(context: Context, identifier: String, receiver: ResultReceiver? = null) {
doWork(
context,
Intent(
NOTIFICATION_EVENT_ACTION,
getUriBuilder()
.appendPath("categories")
.appendPath(identifier)
.build()
).apply {
putExtra(EVENT_TYPE_KEY, DELETE_CATEGORY_TYPE)
putExtra(IDENTIFIER_KEY, identifier)
putExtra(RECEIVER_KEY, receiver)
}
)
}
/**
* Fetches all scheduled notifications asynchronously.
*
* @param context Context this is being called from
* @param resultReceiver Receiver to be called with the results
*/
fun getAllScheduledNotifications(context: Context, resultReceiver: ResultReceiver? = null) {
doWork(
context,
Intent(NOTIFICATION_EVENT_ACTION).also { intent ->
intent.putExtra(EVENT_TYPE_KEY, GET_ALL_SCHEDULED_TYPE)
intent.putExtra(RECEIVER_KEY, resultReceiver)
}
)
}
/**
* Fetches scheduled notification asynchronously. Used in Expo Go's ScopedNotificationScheduler.kt
*
* @param context Context this is being called from
* @param identifier Identifier of the notification to be fetched
* @param resultReceiver Receiver to be called with the results
*/
@DoNotStrip
fun getScheduledNotification(context: Context, identifier: String, resultReceiver: ResultReceiver? = null) {
doWork(
context,
Intent(
NOTIFICATION_EVENT_ACTION,
getUriBuilder()
.appendPath("scheduled")
.appendPath(identifier)
.build()
).also { intent ->
intent.putExtra(EVENT_TYPE_KEY, GET_SCHEDULED_TYPE)
intent.putExtra(IDENTIFIER_KEY, identifier)
intent.putExtra(RECEIVER_KEY, resultReceiver)
}
)
}
/**
* Schedule notification asynchronously.
*
* @param context Context this is being called from
* @param notificationRequest Notification request to schedule
* @param resultReceiver Receiver to be called with the result
*/
fun schedule(context: Context, notificationRequest: NotificationRequest, resultReceiver: ResultReceiver? = null) {
doWork(
context,
Intent(
NOTIFICATION_EVENT_ACTION,
getUriBuilder()
.appendPath("scheduled")
.appendPath(notificationRequest.identifier)
.build()
).apply {
putExtra(EVENT_TYPE_KEY, SCHEDULE_TYPE)
putExtra(NOTIFICATION_REQUEST_KEY, notificationRequest as Parcelable)
putExtra(RECEIVER_KEY, resultReceiver)
}
)
}
/**
* Cancel selected scheduled notification and remove it from the storage asynchronously.
*
* @param context Context this is being called from
* @param identifier Identifier of the notification to be removed
* @param resultReceiver Receiver to be called with the result
*/
fun removeScheduledNotification(context: Context, identifier: String, resultReceiver: ResultReceiver? = null) =
removeScheduledNotifications(context, listOf(identifier), resultReceiver)
/**
* Cancel selected scheduled notifications and remove them from the storage asynchronously.
*
* @param context Context this is being called from
* @param identifiers Identifiers of selected notifications to be removed
* @param resultReceiver Receiver to be called with the result
*/
fun removeScheduledNotifications(context: Context, identifiers: Collection<String>, resultReceiver: ResultReceiver? = null) {
doWork(
context,
Intent(
NOTIFICATION_EVENT_ACTION,
getUriBuilder()
.appendPath("scheduled")
.build()
).apply {
putExtra(EVENT_TYPE_KEY, REMOVE_SELECTED_TYPE)
putExtra(IDENTIFIERS_KEY, identifiers.toTypedArray())
putExtra(RECEIVER_KEY, resultReceiver)
}
)
}
/**
* Cancel all scheduled notifications and remove them from the storage asynchronously.
*
* @param context Context this is being called from
* @param resultReceiver Receiver to be called with the result
*/
fun removeAllScheduledNotifications(context: Context, resultReceiver: ResultReceiver? = null) {
doWork(
context,
Intent(NOTIFICATION_EVENT_ACTION).apply {
putExtra(EVENT_TYPE_KEY, REMOVE_ALL_TYPE)
putExtra(RECEIVER_KEY, resultReceiver)
}
)
}
/**
* Sends the intent to the best service to handle the {@link #NOTIFICATION_EVENT_ACTION} intent
* or handles the intent immediately if the service is already up.
*
* @param context Context where to start the service
* @param intent Intent to dispatch
*/
fun doWork(context: Context, intent: Intent) {
findDesignatedBroadcastReceiver(context, intent)?.let {
intent.component = ComponentName(it.packageName, it.name)
context.sendBroadcast(intent)
return
}
Log.e("expo-notifications", "No service capable of handling notifications found (intent = ${intent.action}). Ensure that you have configured your AndroidManifest.xml properly.")
}
protected fun getUriBuilder(): Uri.Builder {
return Uri.parse("expo-notifications://notifications/").buildUpon()
}
protected fun getUriBuilderForIdentifier(identifier: String): Uri.Builder {
return getUriBuilder().appendPath(identifier)
}
fun findDesignatedBroadcastReceiver(context: Context, intent: Intent): ActivityInfo? {
val searchIntent = Intent(intent.action).setPackage(context.packageName)
return context.packageManager.queryBroadcastReceivers(searchIntent, 0).firstOrNull()?.activityInfo
}
/**
* Creates and returns a pending intent that will trigger [NotificationsService],
* which hands off the work to this class. The intent triggers notification of the given identifier.
*
* @param context Context this is being called from
* @param identifier Notification identifier
* @return [PendingIntent] triggering [NotificationsService], triggering notification of given ID.
*/
fun createNotificationTrigger(context: Context, identifier: String): PendingIntent {
val intent = Intent(
NOTIFICATION_EVENT_ACTION,
getUriBuilder()
.appendPath("scheduled")
.appendPath(identifier)
.appendPath("trigger")
.build()
).also { intent ->
findDesignatedBroadcastReceiver(context, intent)?.let {
intent.component = ComponentName(it.packageName, it.name)
}
intent.putExtra(EVENT_TYPE_KEY, TRIGGER_TYPE)
intent.putExtra(IDENTIFIER_KEY, identifier)
}
// We're defaulting to the behaviour prior API 31 (mutable) even though Android recommends immutability
val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
return PendingIntent.getBroadcast(
context,
intent.component?.className?.hashCode() ?: NotificationsService::class.java.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag
)
}
/**
* Creates and returns a pending intent that will trigger [NotificationsService]'s "response received"
* event.
*
* @param context Context this is being called from
* @param notification Notification being responded to
* @param action Notification action being undertaken
* @return [PendingIntent] triggering [NotificationsService], triggering "response received" event
*/
fun createNotificationResponseIntent(context: Context, notification: Notification, action: NotificationAction): PendingIntent {
val intent = Intent(
NOTIFICATION_EVENT_ACTION,
getUriBuilder()
.appendPath(notification.notificationRequest.identifier)
.appendPath("actions")
.appendPath(action.identifier)
.build()
).also { intent ->
findDesignatedBroadcastReceiver(context, intent)?.let {
intent.component = ComponentName(it.packageName, it.name)
}
intent.putExtra(EVENT_TYPE_KEY, RECEIVE_RESPONSE_TYPE)
intent.putExtra(NOTIFICATION_KEY, notification)
intent.putExtra(NOTIFICATION_ACTION_KEY, action as Parcelable)
}
// Starting from Android 12,
// [notification trampolines](https://developer.android.com/about/versions/12/behavior-changes-12#identify-notification-trampolines)
// are not allowed. If the notification wants to open foreground app,
// we should use the dedicated Activity pendingIntent.
if (action.opensAppToForeground() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val notificationResponse = getNotificationResponseFromBroadcastIntent(intent)
return ExpoHandlingDelegate.createPendingIntentForOpeningApp(context, intent, notificationResponse)
}
// We're defaulting to the behaviour prior API 31 (mutable) even though Android recommends immutability
val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
return PendingIntent.getBroadcast(
context,
intent.component?.className?.hashCode() ?: NotificationsService::class.java.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag
)
}
/**
* Recreate an Intent from [createNotificationResponseIntent] extras
* for [NotificationForwarderActivity] to send broadcasts
*/
fun createNotificationResponseBroadcastIntent(context: Context, extras: Bundle?): Intent {
val notification = extras?.getParcelable<Notification>(NOTIFICATION_KEY)
val action = extras?.getParcelable<NotificationAction>(NOTIFICATION_ACTION_KEY)
if (notification == null || action == null) {
throw IllegalArgumentException("notification and action should not be null")
}
val backgroundAction = NotificationAction(action.identifier, action.title, false)
val intent = Intent(
NOTIFICATION_EVENT_ACTION,
getUriBuilder()
.appendPath(notification.notificationRequest.identifier)
.appendPath("actions")
.appendPath(backgroundAction.identifier)
.build()
).also { intent ->
findDesignatedBroadcastReceiver(context, intent)?.let {
intent.component = ComponentName(it.packageName, it.name)
}
intent.putExtra(EVENT_TYPE_KEY, RECEIVE_RESPONSE_TYPE)
intent.putExtra(NOTIFICATION_KEY, notification)
intent.putExtra(NOTIFICATION_ACTION_KEY, backgroundAction as Parcelable)
}
return intent
}
fun getNotificationResponseFromBroadcastIntent(intent: Intent): NotificationResponse {
val notification = intent.getParcelableExtra<Notification>(NOTIFICATION_KEY) ?: throw IllegalArgumentException("$NOTIFICATION_KEY not found in the intent extras.")
val action = intent.getParcelableExtra<NotificationAction>(NOTIFICATION_ACTION_KEY) ?: throw IllegalArgumentException("$NOTIFICATION_ACTION_KEY not found in the intent extras.")
val response = if (action is TextInputNotificationAction) {
val userText = RemoteInput.getResultsFromIntent(intent)?.getString(USER_TEXT_RESPONSE_KEY) ?: ""
TextInputNotificationResponse(action, notification, userText)
} else {
NotificationResponse(action, notification)
}
return response
}
// this is used by Expo Go's Kernel.kt
@DoNotStrip
fun getNotificationResponseFromOpenIntent(intent: Intent): NotificationResponse? {
intent.getByteArrayExtra(NOTIFICATION_RESPONSE_KEY)?.let { return unmarshalObject(NotificationResponse.CREATOR, it) }
intent.getByteArrayExtra(TEXT_INPUT_NOTIFICATION_RESPONSE_KEY)?.let { return unmarshalObject(TextInputNotificationResponse.CREATOR, it) }
return null
}
// Class loader used in BaseBundle when unmarshalling notification extras
// cannot handle expo.modules.notifications.….NotificationResponse
// so we go around it by marshalling and unmarshalling the object ourselves.
fun setNotificationResponseToIntent(intent: Intent, notificationResponse: NotificationResponse) {
try {
val keyToPutResponseUnder = if (notificationResponse is TextInputNotificationResponse) {
TEXT_INPUT_NOTIFICATION_RESPONSE_KEY
} else {
NOTIFICATION_RESPONSE_KEY
}
intent.putExtra(keyToPutResponseUnder, marshalObject(notificationResponse))
} catch (e: Exception) {
// If we couldn't marshal the request, let's not fail the whole build process.
Log.e("expo-notifications", "Could not marshal notification response: ${notificationResponse.actionIdentifier}.")
e.printStackTrace()
}
}
/**
* Marshals [Parcelable] into to a byte array.
*
* @param notificationResponse Notification response to marshall
* @return Given request marshalled to a byte array or null if the process failed.
*/
private fun marshalObject(objectToMarshal: Parcelable): ByteArray? {
val parcel: Parcel = Parcel.obtain()
objectToMarshal.writeToParcel(parcel, 0)
val bytes: ByteArray = parcel.marshall()
parcel.recycle()
return bytes
}
/**
* UNmarshals [Parcelable] object from a byte array given a [Parcelable.Creator].
* @return Object instance or null if the process failed.
*/
private fun <T> unmarshalObject(creator: Parcelable.Creator<T>, byteArray: ByteArray?): T? {
byteArray?.let {
try {
val parcel = Parcel.obtain()
parcel.unmarshall(it, 0, it.size)
parcel.setDataPosition(0)
val unmarshaledObject = creator.createFromParcel(parcel)
parcel.recycle()
return unmarshaledObject
} catch (e: Exception) {
Log.e("expo-notifications", "Could not unmarshall NotificationResponse from Intent.extra.", e)
}
}
return null
}
}
protected open fun getPresentationDelegate(context: Context): PresentationDelegate =
ExpoPresentationDelegate(context)
protected open fun getHandlingDelegate(context: Context): HandlingDelegate =
ExpoHandlingDelegate(context)
protected open fun getCategoriesDelegate(context: Context): CategoriesDelegate =
ExpoCategoriesDelegate(context)
protected open fun getSchedulingDelegate(context: Context): SchedulingDelegate =
ExpoSchedulingDelegate(context)
override fun onReceive(context: Context, intent: Intent?) {
val pendingIntent = goAsync()
thread {
try {
handleIntent(context, intent)
} finally {
pendingIntent.finish()
}
}
}
open fun handleIntent(context: Context, intent: Intent?) {
if (intent != null && SETUP_ACTIONS.contains(intent.action)) {
onSetupScheduledNotifications(context, intent)
} else if (intent?.action === NOTIFICATION_EVENT_ACTION) {
val receiver: ResultReceiver? = intent.extras?.get(RECEIVER_KEY) as? ResultReceiver
try {
var resultData: Bundle? = null
when (val eventType = intent.getStringExtra(EVENT_TYPE_KEY)) {
GET_ALL_DISPLAYED_TYPE ->
resultData = onGetAllPresentedNotifications(context, intent)
RECEIVE_TYPE -> onReceiveNotification(context, intent)
RECEIVE_RESPONSE_TYPE -> onReceiveNotificationResponse(context, intent)
DROPPED_TYPE -> onNotificationsDropped(context, intent)
PRESENT_TYPE -> onPresentNotification(context, intent)
DISMISS_SELECTED_TYPE -> onDismissNotifications(context, intent)
DISMISS_ALL_TYPE -> onDismissAllNotifications(context, intent)
GET_CATEGORIES_TYPE ->
resultData = onGetCategories(context, intent)
SET_CATEGORY_TYPE ->
resultData = onSetCategory(context, intent)
DELETE_CATEGORY_TYPE ->
resultData = onDeleteCategory(context, intent)
GET_ALL_SCHEDULED_TYPE ->
resultData = onGetAllScheduledNotifications(context, intent)
GET_SCHEDULED_TYPE ->
resultData = onGetScheduledNotification(context, intent)
SCHEDULE_TYPE -> onScheduleNotification(context, intent)
REMOVE_SELECTED_TYPE -> onRemoveScheduledNotifications(context, intent)
REMOVE_ALL_TYPE -> onRemoveAllScheduledNotifications(context, intent)
TRIGGER_TYPE -> onNotificationTriggered(context, intent)
else -> throw IllegalArgumentException("Received event of unrecognized type: $eventType. Ignoring.")
}
// If we ended up here, the callbacks must have completed successfully
receiver?.send(SUCCESS_CODE, resultData)
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
// Log stack trace for debugging
Log.e("expo-notifications", "Action ${intent.action} failed: ${e.message}\n${e.stackTraceToString()}")
} else {
Log.e("expo-notifications", "Action ${intent.action} failed: ${e.message}")
}
e.printStackTrace()
receiver?.send(ERROR_CODE, Bundle().also { it.putSerializable(EXCEPTION_KEY, e) })
}
} else {
throw IllegalArgumentException("Received intent of unrecognized action: ${intent?.action}. Ignoring.")
}
}
//region Presenting notifications
open fun onPresentNotification(context: Context, intent: Intent) =
getPresentationDelegate(context).presentNotification(
intent.extras?.getParcelable(NOTIFICATION_KEY)!!,
intent.extras?.getParcelable(NOTIFICATION_BEHAVIOR_KEY)
)
open fun onGetAllPresentedNotifications(context: Context, intent: Intent) =
Bundle().also {
it.putParcelableArrayList(
NOTIFICATIONS_KEY,
ArrayList(
getPresentationDelegate(context).getAllPresentedNotifications()
)
)
}
open fun onDismissNotifications(context: Context, intent: Intent) =
getPresentationDelegate(context).dismissNotifications(
intent.extras?.getStringArray(IDENTIFIERS_KEY)!!.asList()
)
open fun onDismissAllNotifications(context: Context, intent: Intent) =
getPresentationDelegate(context).dismissAllNotifications()
//endregion
//region Handling notifications
open fun onReceiveNotification(context: Context, intent: Intent) =
getHandlingDelegate(context).handleNotification(
intent.getParcelableExtra(NOTIFICATION_KEY)!!
)
open fun onReceiveNotificationResponse(context: Context, intent: Intent) {
val response = getNotificationResponseFromBroadcastIntent(intent)
getHandlingDelegate(context).handleNotificationResponse(response)
}
open fun onNotificationsDropped(context: Context, intent: Intent) =
getHandlingDelegate(context).handleNotificationsDropped()
//endregion
//region Category handling
open fun onGetCategories(context: Context, intent: Intent) =
Bundle().also {
it.putParcelableArrayList(
NOTIFICATION_CATEGORIES_KEY,
ArrayList(
getCategoriesDelegate(context).getCategories()
)
)
}
open fun onSetCategory(context: Context, intent: Intent) =
Bundle().also {
it.putParcelable(
NOTIFICATION_CATEGORY_KEY,
getCategoriesDelegate(context).setCategory(
intent.getParcelableExtra(NOTIFICATION_CATEGORY_KEY)!!
)
)
}
open fun onDeleteCategory(context: Context, intent: Intent) =
Bundle().also {
it.putBoolean(
SUCCEEDED_KEY,
getCategoriesDelegate(context).deleteCategory(
intent.extras?.getString(IDENTIFIER_KEY)!!
)
)
}
//endregion
//region Scheduling notifications
open fun onGetAllScheduledNotifications(context: Context, intent: Intent) =
Bundle().also {
it.putParcelableArrayList(
NOTIFICATION_REQUESTS_KEY,
ArrayList(
getSchedulingDelegate(context).getAllScheduledNotifications()
)
)
}
open fun onGetScheduledNotification(context: Context, intent: Intent) =
Bundle().also {
it.putParcelable(
NOTIFICATION_REQUEST_KEY,
getSchedulingDelegate(context).getScheduledNotification(
intent.extras?.getString(IDENTIFIER_KEY)!!
)
)
}
open fun onScheduleNotification(context: Context, intent: Intent) =
getSchedulingDelegate(context).scheduleNotification(
intent.extras?.getParcelable(NOTIFICATION_REQUEST_KEY)!!
)
open fun onNotificationTriggered(context: Context, intent: Intent) =
getSchedulingDelegate(context).triggerNotification(
intent.extras?.getString(IDENTIFIER_KEY)!!
)
open fun onRemoveScheduledNotifications(context: Context, intent: Intent) =
getSchedulingDelegate(context).removeScheduledNotifications(
intent.extras?.getStringArray(IDENTIFIERS_KEY)!!.asList()
)
open fun onRemoveAllScheduledNotifications(context: Context, intent: Intent) =
getSchedulingDelegate(context).removeAllScheduledNotifications()
open fun onSetupScheduledNotifications(context: Context, intent: Intent) =
getSchedulingDelegate(context).setupScheduledNotifications()
//endregion
}

View File

@@ -0,0 +1,33 @@
package expo.modules.notifications.service.delegates
import android.util.Base64
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InvalidClassException
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.io.Serializable
@Throws(IOException::class)
fun Serializable.encodedInBase64(): String =
ByteArrayOutputStream().use { byteArrayOutputStream ->
ObjectOutputStream(byteArrayOutputStream).use { objectOutputStream ->
objectOutputStream.writeObject(this)
Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.NO_WRAP)
}
}
@Throws(IOException::class, ClassNotFoundException::class, InvalidClassException::class)
inline fun <reified T> String.asBase64EncodedObject(): T =
ByteArrayInputStream(
Base64.decode(this, Base64.NO_WRAP)
).use { byteArrayInputStream ->
ObjectInputStream(byteArrayInputStream).use { ois ->
val o = ois.readObject()
if (o is T) {
return o
}
throw InvalidClassException("Expected serialized object to be an instance of ${T::class.java}. Found: $o")
}
}

View File

@@ -0,0 +1,21 @@
package expo.modules.notifications.service.delegates
import android.content.Context
import expo.modules.notifications.notifications.model.NotificationCategory
import expo.modules.notifications.service.interfaces.CategoriesDelegate
class ExpoCategoriesDelegate(protected val context: Context) : CategoriesDelegate {
private val mStore = SharedPreferencesNotificationCategoriesStore(context)
override fun getCategories(): Collection<NotificationCategory> {
return mStore.allNotificationCategories
}
override fun setCategory(category: NotificationCategory): NotificationCategory? {
return mStore.saveNotificationCategory(category)
}
override fun deleteCategory(identifier: String): Boolean {
return mStore.removeNotificationCategory(identifier)
}
}

View File

@@ -0,0 +1,146 @@
package expo.modules.notifications.service.delegates
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import expo.modules.notifications.notifications.NotificationManager
import expo.modules.notifications.notifications.model.Notification
import expo.modules.notifications.notifications.model.NotificationResponse
import expo.modules.notifications.service.NotificationForwarderActivity
import expo.modules.notifications.service.NotificationsService
import expo.modules.notifications.service.interfaces.HandlingDelegate
import java.lang.ref.WeakReference
import java.util.*
class ExpoHandlingDelegate(protected val context: Context) : HandlingDelegate {
companion object {
const val OPEN_APP_INTENT_ACTION = "expo.modules.notifications.OPEN_APP_ACTION"
protected var sPendingNotificationResponses: MutableCollection<NotificationResponse> = ArrayList()
/**
* A weak map of listeners -> reference. Used to check quickly whether given listener
* is already registered and to iterate over when notifying of new token.
*/
protected var sListenersReferences = WeakHashMap<NotificationManager, WeakReference<NotificationManager>>()
/**
* Used only by [NotificationManager] instances. If you look for a place to register
* your listener, use [NotificationManager] singleton module.
*
* Purposefully the argument is expected to be a [NotificationManager] and just a listener.
*
* This class doesn't hold strong references to listeners, so you need to own your listeners.
*
* @param listener A listener instance to be informed of new push device tokens.
*/
fun addListener(listener: NotificationManager) {
if (sListenersReferences.containsKey(listener)) {
// Listener is already registered
return
}
sListenersReferences[listener] = WeakReference(listener)
if (!sPendingNotificationResponses.isEmpty()) {
val responseIterator = sPendingNotificationResponses.iterator()
while (responseIterator.hasNext()) {
listener.onNotificationResponseReceived(responseIterator.next())
responseIterator.remove()
}
}
}
/**
* Create a PendingIntent to open app in foreground.
* We actually start two Activities
* - the foreground main Activity
* - the background [NotificationForwarderActivity] Activity that send notification clicked events through broadcast
*/
fun createPendingIntentForOpeningApp(context: Context, broadcastIntent: Intent, notificationResponse: NotificationResponse): PendingIntent {
var intentFlags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// The intent may include `RemoteInput` from `TextInputNotificationAction`.
// For intent with RemoteInput, it should be mutable.
intentFlags = intentFlags or PendingIntent.FLAG_MUTABLE
}
val backgroundActivityIntent = Intent(context, NotificationForwarderActivity::class.java)
backgroundActivityIntent.data = broadcastIntent.data
backgroundActivityIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
backgroundActivityIntent.putExtras(broadcastIntent)
val requestCode = broadcastIntent.component?.className?.hashCode() ?: NotificationsService::class.java.hashCode()
return PendingIntent.getActivity(context, requestCode, backgroundActivityIntent, intentFlags)
}
fun openAppToForeground(context: Context, notificationResponse: NotificationResponse) {
(getNotificationActionLauncher(context) ?: getMainActivityLauncher(context))?.let { intent ->
NotificationsService.setNotificationResponseToIntent(intent, notificationResponse)
context.startActivity(intent)
return
}
Log.w("expo-notifications", "No launch intent found for application. Interacting with the notification won't open the app. The implementation uses `getLaunchIntentForPackage` to find appropriate activity.")
}
private fun getNotificationActionLauncher(context: Context): Intent? {
Intent(OPEN_APP_INTENT_ACTION).also { intent ->
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.setPackage(context.applicationContext.packageName)
context.packageManager.resolveActivity(intent, 0)?.let {
return intent
}
}
return null
}
private fun getMainActivityLauncher(context: Context) =
context.packageManager.getLaunchIntentForPackage(context.packageName)
}
fun isAppInForeground() = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
fun getListeners() = sListenersReferences.values.mapNotNull { it.get() }
override fun handleNotification(notification: Notification) {
if (isAppInForeground()) {
getListeners().forEach {
it.onNotificationReceived(notification)
}
} else if (notification.shouldPresent()) {
NotificationsService.present(context, notification)
}
}
/**
* If the app is backgrounded, a notification is only presented if
* the title and or text is present. If both are null or empty, this is a "data-only" or "silent"
* notification that should not be presented to the user.
*/
private fun Notification.shouldPresent(): Boolean {
return !(notificationRequest.content.title.isNullOrEmpty() && notificationRequest.content.text.isNullOrEmpty())
}
override fun handleNotificationResponse(notificationResponse: NotificationResponse) {
if (notificationResponse.action.opensAppToForeground()) {
openAppToForeground(context, notificationResponse)
}
if (getListeners().isEmpty()) {
sPendingNotificationResponses.add(notificationResponse)
} else {
getListeners().forEach {
it.onNotificationResponseReceived(notificationResponse)
}
}
}
override fun handleNotificationsDropped() {
getListeners().forEach {
it.onNotificationsDropped()
}
}
}

View File

@@ -0,0 +1,69 @@
package expo.modules.notifications.service.delegates;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import expo.modules.core.interfaces.ReactActivityLifecycleListener;
import expo.modules.notifications.notifications.NotificationManager;
import expo.modules.notifications.notifications.debug.DebugLogging;
public class ExpoNotificationLifecycleListener implements ReactActivityLifecycleListener {
private NotificationManager mNotificationManager;
public ExpoNotificationLifecycleListener(Context context, NotificationManager notificationManager) {
mNotificationManager = notificationManager;
}
/**
* This will be triggered if the app is not running,
* and is started from clicking on a notification.
* <p>
* Notification data will be in activity.intent.extras
*
* @param activity
* @param savedInstanceState
*/
@Override
public void onCreate(Activity activity, Bundle savedInstanceState) {
Intent intent = activity.getIntent();
if (intent != null) {
Bundle extras = intent.getExtras();
if (extras != null) {
if (extras.containsKey("notificationResponse")) {
Log.d("ReactNativeJS", "[native] ExpoNotificationLifecycleListener contains an unmarshaled notification response. Skipping.");
return;
}
DebugLogging.logBundle("ExpoNotificationLifeCycleListener.onCreate:", extras);
mNotificationManager.onNotificationResponseFromExtras(extras);
}
}
}
/**
* This will be triggered if the app is running and in the background,
* and the user clicks on a notification to open the app.
* <p>
* Notification data will be in intent.extras
*
* @param intent
* @return
*/
@Override
public boolean onNewIntent(Intent intent) {
Bundle extras = intent.getExtras();
if (extras != null) {
if (extras.containsKey("notificationResponse")) {
Log.d("ReactNativeJS", "[native] ExpoNotificationLifecycleListener contains an unmarshaled notification response. Skipping.");
intent.removeExtra("notificationResponse");
return ReactActivityLifecycleListener.super.onNewIntent(intent);
}
DebugLogging.logBundle("ExpoNotificationLifeCycleListener.onNewIntent:", extras);
mNotificationManager.onNotificationResponseFromExtras(extras);
}
return ReactActivityLifecycleListener.super.onNewIntent(intent);
}
}

View File

@@ -0,0 +1,213 @@
package expo.modules.notifications.service.delegates
import android.app.NotificationManager
import android.content.Context
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Parcel
import android.provider.Settings
import android.service.notification.StatusBarNotification
import android.util.Log
import android.util.Pair
import androidx.core.app.NotificationManagerCompat
import expo.modules.notifications.notifications.SoundResolver
import expo.modules.notifications.notifications.enums.NotificationPriority
import expo.modules.notifications.notifications.model.Notification
import expo.modules.notifications.notifications.model.NotificationBehavior
import expo.modules.notifications.notifications.model.NotificationContent
import expo.modules.notifications.notifications.model.NotificationRequest
import expo.modules.notifications.notifications.presentation.builders.CategoryAwareNotificationBuilder
import expo.modules.notifications.notifications.presentation.builders.ExpoNotificationBuilder
import expo.modules.notifications.service.interfaces.PresentationDelegate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject
import java.util.Date
open class ExpoPresentationDelegate(
protected val context: Context,
private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(context)
) : PresentationDelegate {
companion object {
protected const val ANDROID_NOTIFICATION_ID = 0
protected const val INTERNAL_IDENTIFIER_SCHEME = "expo-notifications"
protected const val INTERNAL_IDENTIFIER_AUTHORITY = "foreign_notifications"
protected const val INTERNAL_IDENTIFIER_TAG_KEY = "tag"
protected const val INTERNAL_IDENTIFIER_ID_KEY = "id"
/**
* Tries to parse given identifier as an internal foreign notification identifier
* created by us in [getInternalIdentifierKey].
*
* @param identifier String identifier of the notification
* @return Pair of (notification tag, notification id), if the identifier could be parsed. null otherwise.
*/
fun parseNotificationIdentifier(identifier: String): Pair<String?, Int>? {
try {
val parsedIdentifier = Uri.parse(identifier)
if (INTERNAL_IDENTIFIER_SCHEME == parsedIdentifier.scheme && INTERNAL_IDENTIFIER_AUTHORITY == parsedIdentifier.authority) {
val tag = parsedIdentifier.getQueryParameter(INTERNAL_IDENTIFIER_TAG_KEY)
val id = parsedIdentifier.getQueryParameter(INTERNAL_IDENTIFIER_ID_KEY)!!.toInt()
return Pair(tag, id)
}
} catch (e: NullPointerException) {
Log.e("expo-notifications", "Malformed foreign notification identifier: $identifier", e)
} catch (e: NumberFormatException) {
Log.e("expo-notifications", "Malformed foreign notification identifier: $identifier", e)
} catch (e: UnsupportedOperationException) {
Log.e("expo-notifications", "Malformed foreign notification identifier: $identifier", e)
}
return null
}
/**
* Creates an identifier for given [StatusBarNotification]. It's supposed to be parsable
* by [parseNotificationIdentifier].
*
* @param notification Notification to be identified
* @return String identifier
*/
protected fun getInternalIdentifierKey(notification: StatusBarNotification): String {
return with(Uri.parse("$INTERNAL_IDENTIFIER_SCHEME://$INTERNAL_IDENTIFIER_AUTHORITY").buildUpon()) {
notification.tag?.let {
this.appendQueryParameter(INTERNAL_IDENTIFIER_TAG_KEY, it)
}
this.appendQueryParameter(INTERNAL_IDENTIFIER_ID_KEY, notification.id.toString())
this.toString()
}
}
}
/**
* Callback called to present the system UI for a notification.
*
* If the notification behavior is set to not show any alert,
* we (may) play a sound, but then bail out early. You cannot
* set badge count without showing a notification.
*/
override fun presentNotification(notification: Notification, behavior: NotificationBehavior?) {
if (behavior?.shouldShowAlert() == false) {
if (behavior.shouldPlaySound()) {
val sound = getNotificationSoundUri(notification) ?: Settings.System.DEFAULT_NOTIFICATION_URI
RingtoneManager.getRingtone(
context,
sound
).play()
}
return
}
CoroutineScope(Dispatchers.IO).launch {
val androidNotification = CategoryAwareNotificationBuilder(context, SharedPreferencesNotificationCategoriesStore(context)).apply {
setNotification(notification)
setAllowedBehavior(behavior)
}.build()
NotificationManagerCompat.from(context).notify(
notification.notificationRequest.identifier,
getNotifyId(notification.notificationRequest),
androidNotification
)
}
}
private fun getNotificationSoundUri(notification: Notification): Uri? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notification.notificationRequest.trigger.notificationChannel?.let {
notificationManager.getNotificationChannel(it)?.sound
}
} else {
val name = notification.notificationRequest.content.soundName
SoundResolver(context).resolve(name)
}
}
protected open fun getNotifyId(request: NotificationRequest?): Int {
return ANDROID_NOTIFICATION_ID
}
/**
* Callback called to fetch a collection of currently displayed notifications.
*
* **Note:** This feature is only supported on Android 23+.
*
* @return A collection of currently displayed notifications.
*/
override fun getAllPresentedNotifications(): Collection<Notification> {
val notificationManager = (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
return notificationManager.activeNotifications.mapNotNull { getNotification(it) }
}
override fun dismissNotifications(identifiers: Collection<String>) {
identifiers.forEach { identifier ->
val foreignNotification = parseNotificationIdentifier(identifier)
if (foreignNotification != null) {
// Foreign notification identified by us
NotificationManagerCompat.from(context).cancel(foreignNotification.first, foreignNotification.second)
} else {
// If the notification exists, let's assume it's ours, we have no reason to believe otherwise
val existingNotification = this.getAllPresentedNotifications().find { it.notificationRequest.identifier == identifier }
NotificationManagerCompat.from(context).cancel(identifier, getNotifyId(existingNotification?.notificationRequest))
}
}
}
override fun dismissAllNotifications() = NotificationManagerCompat.from(context).cancelAll()
protected open fun getNotification(statusBarNotification: StatusBarNotification): Notification? {
val notification = statusBarNotification.notification
notification.extras.getByteArray(ExpoNotificationBuilder.Companion.EXTRAS_MARSHALLED_NOTIFICATION_REQUEST_KEY)?.let {
try {
with(Parcel.obtain()) {
this.unmarshall(it, 0, it.size)
this.setDataPosition(0)
val request: NotificationRequest = NotificationRequest.CREATOR.createFromParcel(this)
this.recycle()
val notificationDate = Date(statusBarNotification.postTime)
return Notification(request, notificationDate)
}
} catch (e: Exception) {
// Let's catch all the exceptions -- there's nothing we can do here
// and we'd rather return an array with a single, naively reconstructed notification
// than throw an exception and return none.
val message = "Could not have unmarshalled NotificationRequest from (${statusBarNotification.tag}, ${statusBarNotification.id})."
Log.e("expo-notifications", message)
}
}
// We weren't able to reconstruct the notification from our data, which means
// it's either not our notification or we couldn't have unmarshaled it from
// the byte array. Let's do what we can.
val content = NotificationContent.Builder()
.setTitle(notification.extras.getString(android.app.Notification.EXTRA_TITLE))
.setText(notification.extras.getString(android.app.Notification.EXTRA_TEXT))
.setSubtitle(notification.extras.getString(android.app.Notification.EXTRA_SUB_TEXT)) // using deprecated field
.setPriority(NotificationPriority.fromNativeValue(notification.priority)) // using deprecated field
.setVibrationPattern(notification.vibrate) // using deprecated field
.setSound(notification.sound)
.setAutoDismiss(notification.flags and android.app.Notification.FLAG_AUTO_CANCEL != 0)
.setSticky(notification.flags and android.app.Notification.FLAG_ONGOING_EVENT != 0)
.setBody(fromBundle(notification.extras))
.build()
val request = NotificationRequest(getInternalIdentifierKey(statusBarNotification), content, null)
return Notification(request, Date(statusBarNotification.postTime))
}
protected open fun fromBundle(bundle: Bundle): JSONObject {
return JSONObject().also { json ->
for (key in bundle.keySet()) {
try {
json.put(key, JSONObject.wrap(bundle[key]))
} catch (e: JSONException) {
// can't do anything about it apart from logging it
Log.d("expo-notifications", "Error encountered while serializing Android notification extras: " + key + " -> " + bundle[key], e)
}
}
}
}
}

View File

@@ -0,0 +1,117 @@
package expo.modules.notifications.service.delegates
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.core.app.AlarmManagerCompat
import expo.modules.notifications.notifications.interfaces.SchedulableNotificationTrigger
import expo.modules.notifications.notifications.model.Notification
import expo.modules.notifications.notifications.model.NotificationRequest
import expo.modules.notifications.service.NotificationsService
import expo.modules.notifications.service.interfaces.SchedulingDelegate
import java.io.IOException
import java.io.InvalidClassException
class ExpoSchedulingDelegate(protected val context: Context) : SchedulingDelegate {
protected val store = SharedPreferencesNotificationsStore(context)
protected val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
override fun setupScheduledNotifications() {
store.allNotificationRequests.forEach {
try {
scheduleNotification(it)
} catch (e: Exception) {
Log.w("expo-notifications", "Notification ${it.identifier} could not have been scheduled: ${e.message}")
e.printStackTrace()
}
}
}
override fun getAllScheduledNotifications(): Collection<NotificationRequest> =
store.allNotificationRequests
override fun getScheduledNotification(identifier: String): NotificationRequest? = try {
store.getNotificationRequest(identifier)
} catch (e: IOException) {
null
} catch (e: ClassNotFoundException) {
null
} catch (e: NullPointerException) {
null
}
override fun scheduleNotification(request: NotificationRequest) {
// If the trigger is empty, handle receive immediately and return.
if (request.trigger == null) {
NotificationsService.receive(context, Notification(request))
return
}
if (request.trigger !is SchedulableNotificationTrigger) {
throw IllegalArgumentException("Notification request \"${request.identifier}\" does not have a schedulable trigger (it's ${request.trigger}). Refusing to schedule.")
}
(request.trigger as SchedulableNotificationTrigger).nextTriggerDate().let { nextTriggerDate ->
if (nextTriggerDate == null) {
Log.d("expo-notifications", "Notification request \"${request.identifier}\" will not trigger in the future, removing.")
NotificationsService.removeScheduledNotification(context, request.identifier)
} else {
store.saveNotificationRequest(request)
setupAlarm(nextTriggerDate.time, NotificationsService.createNotificationTrigger(context, request.identifier))
}
}
}
override fun triggerNotification(identifier: String) {
try {
val notificationRequest: NotificationRequest = store.getNotificationRequest(identifier)!!
NotificationsService.receive(context, Notification(notificationRequest))
NotificationsService.schedule(context, notificationRequest)
} catch (e: ClassNotFoundException) {
Log.e("expo-notifications", "An exception occurred while triggering notification " + identifier + ", removing. " + e.message)
e.printStackTrace()
NotificationsService.removeScheduledNotification(context, identifier)
} catch (e: InvalidClassException) {
Log.e("expo-notifications", "An exception occurred while triggering notification " + identifier + ", removing. " + e.message)
e.printStackTrace()
NotificationsService.removeScheduledNotification(context, identifier)
} catch (e: NullPointerException) {
Log.e("expo-notifications", "An exception occurred while triggering notification " + identifier + ", removing. " + e.message)
e.printStackTrace()
NotificationsService.removeScheduledNotification(context, identifier)
}
}
override fun removeScheduledNotifications(identifiers: Collection<String>) {
identifiers.forEach {
alarmManager.cancel(NotificationsService.createNotificationTrigger(context, it))
store.removeNotificationRequest(it)
}
}
override fun removeAllScheduledNotifications() {
store.removeAllNotificationRequests().forEach {
alarmManager.cancel(NotificationsService.createNotificationTrigger(context, it))
}
}
private fun setupAlarm(triggerAtMillis: Long, operation: PendingIntent) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()) {
AlarmManagerCompat.setExactAndAllowWhileIdle(
alarmManager,
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
operation
)
} else {
AlarmManagerCompat.setAndAllowWhileIdle(
alarmManager,
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
operation
)
}
}
}

View File

@@ -0,0 +1,119 @@
package expo.modules.notifications.service.delegates
import android.content.Context
import com.google.firebase.messaging.RemoteMessage
import expo.modules.notifications.notifications.RemoteMessageSerializer
import expo.modules.notifications.notifications.background.BackgroundRemoteNotificationTaskConsumer
import expo.modules.notifications.notifications.debug.DebugLogging
import expo.modules.notifications.notifications.model.Notification
import expo.modules.notifications.notifications.model.NotificationRequest
import expo.modules.notifications.notifications.model.RemoteNotificationContent
import expo.modules.notifications.notifications.model.triggers.FirebaseNotificationTrigger
import expo.modules.notifications.service.NotificationsService
import expo.modules.notifications.service.interfaces.FirebaseMessagingDelegate
import expo.modules.notifications.tokens.interfaces.FirebaseTokenListener
import java.lang.ref.WeakReference
import java.util.*
open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate {
companion object {
// Unfortunately we cannot save state between instances of a service other way
// than by static properties. Fortunately, using weak references we can
// be somehow sure instances of PushTokenListeners won't be leaked by this component.
/**
* We store this value to be able to inform new listeners of last known token.
*/
protected var sLastToken: String? = null
/**
* A weak map of listeners -> reference. Used to check quickly whether given listener
* is already registered and to iterate over when notifying of new token.
*/
protected val sTokenListenersReferences = WeakHashMap<FirebaseTokenListener, WeakReference<FirebaseTokenListener?>?>()
/**
* Used only by [FirebaseTokenListener] instances. If you look for a place to register
* your listener, use [FirebaseTokenListener] singleton module.
*
* Purposefully the argument is expected to be a [FirebaseTokenListener] and just a listener.
*
* This class doesn't hold strong references to listeners, so you need to own your listeners.
*
* @param listener A listener instance to be informed of new push device tokens.
*/
@JvmStatic
fun addTokenListener(listener: FirebaseTokenListener) {
// Checks whether this listener has already been registered
if (!sTokenListenersReferences.containsKey(listener)) {
sTokenListenersReferences[listener] = WeakReference(listener)
// Since it's a new listener and we know of a last valid token, let's let them know.
if (sLastToken != null) {
listener.onNewToken(sLastToken)
}
}
}
/**
* A weak map of task consumers -> reference. Used to check quickly whether given task
* is already registered and to iterate over when notifying of new notification received
* while the app is not in the foreground.
*/
protected var sBackgroundTaskConsumerReferences = WeakHashMap<BackgroundRemoteNotificationTaskConsumer, WeakReference<BackgroundRemoteNotificationTaskConsumer>>()
/**
* Background tasks are registered in [BackgroundRemoteNotificationTaskConsumer] instances.
*
* @param taskConsumer A task instance to be executed when a notification is received while the * app is not in the foreground
*/
fun addBackgroundTaskConsumer(taskConsumer: BackgroundRemoteNotificationTaskConsumer) {
if (sBackgroundTaskConsumerReferences.containsKey(taskConsumer)) {
return
}
sBackgroundTaskConsumerReferences[taskConsumer] = WeakReference(taskConsumer)
}
}
/**
* Called on new token, dispatches it to [NotificationsService.sTokenListenersReferences].
*
* @param token New device push token.
*/
override fun onNewToken(token: String) {
for (listenerReference in sTokenListenersReferences.values) {
listenerReference?.get()?.onNewToken(token)
}
sLastToken = token
}
fun getBackgroundTasks() = sBackgroundTaskConsumerReferences.values.mapNotNull { it.get() }
override fun onMessageReceived(remoteMessage: RemoteMessage) {
DebugLogging.logRemoteMessage("FirebaseMessagingDelegate.onMessageReceived: message", remoteMessage)
val notification = createNotification(remoteMessage)
DebugLogging.logNotification("FirebaseMessagingDelegate.onMessageReceived: notification", notification)
NotificationsService.receive(context, notification)
getBackgroundTasks().forEach {
it.scheduleJob(RemoteMessageSerializer.toBundle(remoteMessage))
}
}
protected fun createNotification(remoteMessage: RemoteMessage): Notification {
val identifier = getNotificationIdentifier(remoteMessage)
val request = NotificationRequest(identifier, RemoteNotificationContent(remoteMessage), FirebaseNotificationTrigger(remoteMessage))
return Notification(request, Date(remoteMessage.sentTime))
}
/**
* To match iOS behavior, we want to assign the remote message's tag as the notification ID.
* If a notification comes in with the same tag as a notification that is already in the tray,
* the existing notification is replaced, but the ID can remain constant.
*/
protected fun getNotificationIdentifier(remoteMessage: RemoteMessage): String {
return remoteMessage.data["tag"] ?: remoteMessage.messageId ?: UUID.randomUUID().toString()
}
override fun onDeletedMessages() {
NotificationsService.handleDropped(context)
}
}

View File

@@ -0,0 +1,106 @@
package expo.modules.notifications.service.delegates
import android.content.Context
import android.content.SharedPreferences
import expo.modules.notifications.notifications.model.NotificationCategory
import java.io.IOException
/**
* A fairly straightforward [SharedPreferences] wrapper to be used by [NotificationSchedulingHelper].
* Saves and reads notification category information (identifiers, actions, and options) to and from persistent storage.
*
* A notification category with identifier = 123abc will be persisted under key:
* [SharedPreferencesNotificationCategoriesStore.NOTIFICATION_CATEGORY_KEY_PREFIX]123abc
*/
class SharedPreferencesNotificationCategoriesStore(context: Context) {
companion object {
private const val SHARED_PREFERENCES_NAME = "expo.modules.notifications.SharedPreferencesNotificationCategoriesStore"
private const val NOTIFICATION_CATEGORY_KEY_PREFIX = "notification_category-"
}
private val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)
/**
* Fetches notification category info for given identifier.
*
* @param identifier Identifier of the category.
* @return Category information: actions and options.
* @throws JSONException Thrown if notification category could not be interpreted as a JSON object.
* @throws IOException Thrown if there is an error when fetching the category from storage.
* @throws ClassNotFoundException Thrown if there is an error when interpreting the category fetched from storage.
*/
@Throws(IOException::class, ClassNotFoundException::class)
fun getNotificationCategory(identifier: String) =
sharedPreferences.getString(
preferencesNotificationCategoryKey(identifier),
null
)?.asBase64EncodedObject<NotificationCategory>()
/**
* Fetches all categories, ignoring invalid ones.
*
* Goes through all the [SharedPreferences] entries, interpreting only the ones conforming
* to the expected format.
*
* @return Map with identifiers as keys and category info as values
*/
val allNotificationCategories: Collection<NotificationCategory>
get() =
sharedPreferences
.all
.filter { it.key.startsWith(NOTIFICATION_CATEGORY_KEY_PREFIX) }
.mapNotNull { (_, value) ->
return@mapNotNull try {
(value as String?)?.asBase64EncodedObject<NotificationCategory>()
} catch (e: ClassNotFoundException) {
// do nothing
null
} catch (e: IOException) {
// do nothing
null
}
}
/**
* Saves given category in persistent storage.
*
* @param notificationCategory Notification category
* @throws IOException Thrown if there is an error while serializing the category
* @return The category that was just created, or null if it couldn't be created.
*/
@Throws(IOException::class)
fun saveNotificationCategory(notificationCategory: NotificationCategory) =
sharedPreferences
.edit()
.putString(
preferencesNotificationCategoryKey(notificationCategory.identifier),
notificationCategory.encodedInBase64()
)
.commit()
.let { if (it) notificationCategory else null }
/**
* Removes notification category for the given identifier.
*
* @param identifier Category identifier
* @return Return true if category was deleted, false if not.
*/
fun removeNotificationCategory(identifier: String): Boolean {
sharedPreferences.getString(
preferencesNotificationCategoryKey(identifier),
null
).let { if (it == null) return false }
return sharedPreferences
.edit()
.remove(preferencesNotificationCategoryKey(identifier))
.commit()
}
/**
* @param identifier Category identifier
* @return Key under which the notification category will be persisted in storage.
*/
private fun preferencesNotificationCategoryKey(identifier: String) =
NOTIFICATION_CATEGORY_KEY_PREFIX + identifier
}

View File

@@ -0,0 +1,119 @@
package expo.modules.notifications.service.delegates
import android.content.Context
import android.content.SharedPreferences
import expo.modules.notifications.notifications.model.NotificationRequest
import java.io.IOException
/**
* A fairly straightforward [SharedPreferences] wrapper to be used by [NotificationSchedulingHelper].
* Saves and reads notifications (identifiers, requests and triggers) to and from the persistent storage.
*
* A notification request of identifier = 123abc, it will be persisted under key:
* [SharedPreferencesNotificationsStore.NOTIFICATION_REQUEST_KEY_PREFIX]123abc
*/
class SharedPreferencesNotificationsStore(context: Context) {
companion object {
private const val SHARED_PREFERENCES_NAME = "expo.modules.notifications.SharedPreferencesNotificationsStore"
private const val NOTIFICATION_REQUEST_KEY_PREFIX = "notification_request-"
}
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)
/**
* Fetches scheduled notification info for given identifier.
*
* @param identifier Identifier of the notification.
* @return Notification information: request and trigger.
* @throws JSONException Thrown if notification request could not have been interpreted as a JSON object.
* @throws IOException Thrown if there is an error when fetching trigger from the storage.
* @throws ClassNotFoundException Thrown if there is an error when interpreting trigger fetched from the storage.
*/
@Throws(IOException::class, ClassNotFoundException::class)
fun getNotificationRequest(identifier: String) =
sharedPreferences.getString(
preferencesNotificationRequestKey(identifier),
null
)?.asBase64EncodedObject<NotificationRequest>()
/**
* Fetches all scheduled notifications, ignoring invalid ones.
*
* Goes through all the [SharedPreferences] entries, interpreting only the ones conforming
* to the expected format.
*
* @return Map with identifiers as keys and notification info as values
*/
val allNotificationRequests: Collection<NotificationRequest>
get() =
sharedPreferences
.all
.filter { it.key.startsWith(NOTIFICATION_REQUEST_KEY_PREFIX) }
.mapNotNull { (_, value) ->
return@mapNotNull try {
(value as String?)?.asBase64EncodedObject<NotificationRequest>()
} catch (e: ClassNotFoundException) {
// do nothing
null
} catch (e: IOException) {
// do nothing
null
}
}
/**
* Saves given notification in the persistent storage.
*
* @param notificationRequest Notification request
* @throws IOException Thrown if there is an error while serializing trigger
*/
@Throws(IOException::class)
fun saveNotificationRequest(notificationRequest: NotificationRequest) =
sharedPreferences.edit()
.putString(
preferencesNotificationRequestKey(notificationRequest.identifier),
notificationRequest.encodedInBase64()
)
.apply()
/**
* Removes notification info for given identifier.
*
* @param identifier Notification identifier
*/
fun removeNotificationRequest(identifier: String) =
removeNotificationRequest(sharedPreferences.edit(), identifier).apply()
/**
* Perform notification removal on provided [SharedPreferences.Editor] instance. Can be reused
* to batch deletion.
*
* @param editor Editor to apply changes onto
* @param identifier Notification identifier
* @return Returns a reference to the same Editor object, so you can
* chain put calls together.
*/
private fun removeNotificationRequest(editor: SharedPreferences.Editor, identifier: String) =
editor.remove(preferencesNotificationRequestKey(identifier))
/**
* Removes all notification infos, returning removed IDs.
*/
fun removeAllNotificationRequests(): Collection<String> =
with(sharedPreferences.edit()) {
allNotificationRequests.map {
removeNotificationRequest(this, it.identifier)
it.identifier
}.let {
this.apply()
it
}
}
/**
* @param identifier Notification identifier
* @return Key under which notification request will be persisted in the storage.
*/
private fun preferencesNotificationRequestKey(identifier: String) =
NOTIFICATION_REQUEST_KEY_PREFIX + identifier
}

View File

@@ -0,0 +1,14 @@
package expo.modules.notifications.service.interfaces
import expo.modules.notifications.notifications.model.NotificationCategory
import expo.modules.notifications.service.NotificationsService
/**
* A delegate to [NotificationsService] responsible for handling events
* related to [NotificationCategory]s.
*/
interface CategoriesDelegate {
fun getCategories(): Collection<NotificationCategory>
fun setCategory(category: NotificationCategory): NotificationCategory?
fun deleteCategory(identifier: String): Boolean
}

View File

@@ -0,0 +1,20 @@
package expo.modules.notifications.service.interfaces
import com.google.firebase.messaging.RemoteMessage
import expo.modules.notifications.service.NotificationsService
/**
* A delegate to [NotificationsService] responsible for handling Firebase events.
*/
interface FirebaseMessagingDelegate {
/**
* Called on new token, dispatches it to [NotificationsService.sTokenListenersReferences].
*
* @param token New device push token.
*/
fun onNewToken(token: String)
fun onMessageReceived(remoteMessage: RemoteMessage)
fun onDeletedMessages()
}

View File

@@ -0,0 +1,10 @@
package expo.modules.notifications.service.interfaces
import expo.modules.notifications.notifications.model.Notification
import expo.modules.notifications.notifications.model.NotificationResponse
interface HandlingDelegate {
fun handleNotification(notification: Notification)
fun handleNotificationResponse(notificationResponse: NotificationResponse)
fun handleNotificationsDropped()
}

View File

@@ -0,0 +1,11 @@
package expo.modules.notifications.service.interfaces
import expo.modules.notifications.notifications.model.Notification
import expo.modules.notifications.notifications.model.NotificationBehavior
interface PresentationDelegate {
fun presentNotification(notification: Notification, behavior: NotificationBehavior?)
fun getAllPresentedNotifications(): Collection<Notification>
fun dismissNotifications(identifiers: Collection<String>)
fun dismissAllNotifications()
}

View File

@@ -0,0 +1,13 @@
package expo.modules.notifications.service.interfaces
import expo.modules.notifications.notifications.model.NotificationRequest
interface SchedulingDelegate {
fun setupScheduledNotifications()
fun getAllScheduledNotifications(): Collection<NotificationRequest>
fun getScheduledNotification(identifier: String): NotificationRequest?
fun scheduleNotification(request: NotificationRequest)
fun triggerNotification(identifier: String)
fun removeScheduledNotifications(identifiers: Collection<String>)
fun removeAllScheduledNotifications()
}

View File

@@ -0,0 +1,88 @@
package expo.modules.notifications.tokens;
import expo.modules.core.interfaces.SingletonModule;
import java.lang.ref.WeakReference;
import java.util.WeakHashMap;
import expo.modules.notifications.service.NotificationsService;
import expo.modules.notifications.service.delegates.FirebaseMessagingDelegate;
import expo.modules.notifications.tokens.interfaces.FirebaseTokenListener;
import expo.modules.notifications.tokens.interfaces.PushTokenListener;
public class PushTokenManager implements SingletonModule, FirebaseTokenListener, expo.modules.notifications.tokens.interfaces.PushTokenManager {
private static final String SINGLETON_NAME = "PushTokenManager";
/**
* We store this value to be able to inform new listeners of last known token.
*/
private String mLastToken;
/**
* A weak map of listeners -> reference. Used to check quickly whether given listener
* is already registered and to iterate over on new token.
*/
private WeakHashMap<PushTokenListener, WeakReference<PushTokenListener>> mListenerReferenceMap;
public PushTokenManager() {
mListenerReferenceMap = new WeakHashMap<>();
// Registers this singleton instance in static FirebaseListenerService listeners collection.
// Since it doesn't hold strong reference to the object this should be safe.
FirebaseMessagingDelegate.addTokenListener(this);
}
@Override
public String getName() {
return SINGLETON_NAME;
}
/**
* Registers a {@link PushTokenListener} by adding a {@link WeakReference} to
* the {@link PushTokenManager#mListenerReferenceMap} map.
*
* @param listener Listener to be notified of new device push tokens.
*/
@Override
public void addListener(PushTokenListener listener) {
// Check if the listener is already registered
if (!mListenerReferenceMap.containsKey(listener)) {
WeakReference<PushTokenListener> listenerReference = new WeakReference<>(listener);
mListenerReferenceMap.put(listener, listenerReference);
// Since it's a new listener and we know of a last valid value, let's let them know.
if (mLastToken != null) {
listener.onNewToken(mLastToken);
}
}
}
/**
* Unregisters a {@link PushTokenListener} by removing the {@link WeakReference} to the listener
* from the {@link PushTokenManager#mListenerReferenceMap} map.
*
* @param listener Listener previously registered with {@link PushTokenManager#addListener(PushTokenListener)}.
*/
@Override
public void removeListener(PushTokenListener listener) {
mListenerReferenceMap.remove(listener);
}
/**
* Used by {@link NotificationsService} to notify of new tokens.
* Calls {@link PushTokenListener#onNewToken(String)} on all values
* of {@link PushTokenManager#mListenerReferenceMap}.
*
* @param token New device push token.
*/
@Override
public void onNewToken(String token) {
for (WeakReference<PushTokenListener> listenerReference : mListenerReferenceMap.values()) {
PushTokenListener listener = listenerReference.get();
if (listener != null) {
listener.onNewToken(token);
}
}
mLastToken = token;
}
}

View File

@@ -0,0 +1,91 @@
package expo.modules.notifications.tokens
import android.os.Bundle
import com.google.firebase.messaging.FirebaseMessaging
import expo.modules.core.interfaces.services.EventEmitter
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.notifications.ModuleNotFoundException
import expo.modules.notifications.tokens.interfaces.PushTokenListener
import expo.modules.notifications.tokens.interfaces.PushTokenManager
private const val NEW_TOKEN_EVENT_NAME = "onDevicePushToken"
private const val NEW_TOKEN_EVENT_TOKEN_KEY = "devicePushToken"
private const val REGISTRATION_FAIL_CODE = "E_REGISTRATION_FAILED"
private const val UNREGISTER_FOR_NOTIFICATIONS_FAIL_CODE = "E_UNREGISTER_FOR_NOTIFICATIONS_FAILED"
class PushTokenModule : Module(), PushTokenListener {
private val tokenManager: PushTokenManager? get() = appContext.legacyModuleRegistry
.getSingletonModule("PushTokenManager", PushTokenManager::class.java)
private var eventEmitter: EventEmitter? = null
override fun definition() = ModuleDefinition {
Name("ExpoPushTokenManager")
Events("onDevicePushToken")
OnCreate {
eventEmitter = appContext.legacyModule()
?: throw ModuleNotFoundException(EventEmitter::class)
// Register the module as a listener in PushTokenManager singleton module.
// Deregistration happens in onDestroy callback.
tokenManager?.addListener(this@PushTokenModule)
}
OnDestroy {
tokenManager?.removeListener(this@PushTokenModule)
}
/**
* Fetches Firebase push token and resolves the promise.
*
* @param promise Promise to be resolved with the token.
*/
AsyncFunction("getDevicePushTokenAsync") { promise: Promise ->
FirebaseMessaging.getInstance().token
.addOnCompleteListener { task ->
if (!task.isSuccessful) {
val exception = task.exception
promise.reject(REGISTRATION_FAIL_CODE, "Fetching the token failed: ${exception?.message ?: "unknown"}", exception)
return@addOnCompleteListener
}
val token = task.result
if (token == null) {
promise.reject(REGISTRATION_FAIL_CODE, "Fetching the token failed. Invalid token.", null)
return@addOnCompleteListener
}
promise.resolve(token)
onNewToken(token)
}
}
AsyncFunction("unregisterForNotificationsAsync") { promise: Promise ->
FirebaseMessaging.getInstance().deleteToken()
.addOnCompleteListener { task ->
if (!task.isSuccessful) {
val exception = task.exception
promise.reject(UNREGISTER_FOR_NOTIFICATIONS_FAIL_CODE, "Unregistering for notifications failed: ${exception?.message ?: "unknown"}", exception)
return@addOnCompleteListener
}
promise.resolve(null)
}
}
}
/**
* Callback called when [PushTokenManager] gets notified of a new token.
* Emits a [NEW_TOKEN_EVENT_NAME] event.
*
* @param token New push token.
*/
override fun onNewToken(token: String) {
eventEmitter?.let {
val eventBody = Bundle()
eventBody.putString(NEW_TOKEN_EVENT_TOKEN_KEY, token)
it.emit(NEW_TOKEN_EVENT_NAME, eventBody)
}
}
}

View File

@@ -0,0 +1,16 @@
package expo.modules.notifications.tokens.interfaces;
import expo.modules.notifications.service.NotificationsService;
/**
* Interface used to register in {@link NotificationsService}
* and be notified of new device push tokens.
*/
public interface FirebaseTokenListener {
/**
* Callback called when new push token is generated.
*
* @param token New push token
*/
void onNewToken(String token);
}

View File

@@ -0,0 +1,14 @@
package expo.modules.notifications.tokens.interfaces;
/**
* Interface used to register in {@link PushTokenManager}
* and be notified of new device push tokens.
*/
public interface PushTokenListener {
/**
* Callback called when new push token is generated.
*
* @param token New push token
*/
void onNewToken(String token);
}

View File

@@ -0,0 +1,22 @@
package expo.modules.notifications.tokens.interfaces;
/**
* Interface of a singleton module responsible
* for dispatching new push token information to listeners.
*/
public interface PushTokenManager {
/**
* Registers a {@link PushTokenListener}.
*
* @param listener Listener to be notified of new device push tokens.
*/
void addListener(PushTokenListener listener);
/**
* Unregisters a {@link PushTokenListener}.
*
* @param listener Listener previously registered
* with {@link PushTokenManager#addListener(PushTokenListener)}.
*/
void removeListener(PushTokenListener listener);
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="expo_notifications_fallback_channel_name">Miscellaneous</string>
</resources>