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,2 @@
// @generated by expo-module-scripts
module.exports = require('expo-module-scripts/eslintrc.base.js');

View File

@@ -0,0 +1,645 @@
# Changelog
## Unpublished
### 🛠 Breaking changes
### 🎉 New features
### 🐛 Bug fixes
### 💡 Others
## 0.28.19 — 2024-10-22
### 🐛 Bug fixes
- [android] fix: allow data message to control notification appearance ([#32162](https://github.com/expo/expo/pull/32162) by [@vonovak](https://github.com/vonovak))
## 0.28.18 — 2024-10-01
### 🎉 New features
- Add clearLastNotificationResponseAsync to API. ([#31607](https://github.com/expo/expo/pull/31607) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [android] fix notifications actions not being presented ([#31795](https://github.com/expo/expo/pull/31795) by [@vonovak](https://github.com/vonovak))
## 0.28.17 — 2024-09-17
### 🐛 Bug fixes
- [Android] image was missing on android when in foreground ([#31405](https://github.com/expo/expo/pull/31405) by [@vonovak](https://github.com/vonovak))
- [Android] fix local notifications with null trigger. ([#31157](https://github.com/expo/expo/pull/31157) by [@douglowder](https://github.com/douglowder))
- [Android] Take `channelId` into account when presenting notifications. ([#31201](https://github.com/expo/expo/pull/31201) by [@vonovak](https://github.com/vonovak))
## 0.28.16 — 2024-08-21
### 🐛 Bug fixes
- [Android] Fix content.data in scheduled notifications surfaced to JS. ([#31048](https://github.com/expo/expo/pull/31048) by [@douglowder](https://github.com/douglowder))
## 0.28.15 — 2024-08-05
### 🐛 Bug fixes
- [Android] Eliminate unsupported types when processing notification intents from onCreate/onNewIntent. ([#30750](https://github.com/expo/expo/pull/30750) by [@douglowder](https://github.com/douglowder))
## 0.28.14 — 2024-07-30
### 🐛 Bug fixes
- `useLastNotificationResponse` should have only one effect. ([#30653](https://github.com/expo/expo/pull/30653) by [@douglowder](https://github.com/douglowder))
## 0.28.13 — 2024-07-29
### 🐛 Bug fixes
- [Android] map Expo and Firebase notifications correctly. ([#30615](https://github.com/expo/expo/pull/30615) by [@douglowder](https://github.com/douglowder))
- [Android] Apply requested changes from #30615. ([#30658](https://github.com/expo/expo/pull/30615) by [@lukmccall](https://github.com/lukmccall))
## 0.28.12 — 2024-07-25
### 🐛 Bug fixes
- [Android] Android 11 crash when click foreground notifications. ([#30207](https://github.com/expo/expo/pull/30207) by [@GrinZero](https://github.com/GrinZero))
- Notification's textInput content would contain `placeholder` instead of the actual user input ([#27479](https://github.com/expo/expo/pull/27479) by [@Victor-FT](https://github.com/Victor-FT))
## 0.28.11 — 2024-07-22
### 🐛 Bug fixes
- [Android] Fix serialization of vibration pattern. ([#30495](https://github.com/expo/expo/pull/30495) by [@douglowder](https://github.com/douglowder))
## 0.28.10 — 2024-07-15
### 🐛 Bug fixes
- [Android] fix getLastNotificationResponseAsync. ([#30301](https://github.com/expo/expo/pull/30301) by [@douglowder](https://github.com/douglowder))
## 0.28.9 — 2024-06-12
_This version does not introduce any user-facing changes._
## 0.28.8 — 2024-06-10
### 🐛 Bug fixes
- [Android] Add default channel plugin prop, restore legacy icon and color. ([#29491](https://github.com/expo/expo/pull/29491) by [@douglowder](https://github.com/douglowder))
## 0.28.7 — 2024-06-05
### 🐛 Bug fixes
- Remove console.log line. ([#29443](https://github.com/expo/expo/pull/29443) by [@douglowder](https://github.com/douglowder))
## 0.28.6 — 2024-06-03
### 🐛 Bug fixes
- [Android] Remove unneeded logging. ([#29370](https://github.com/expo/expo/pull/29370) by [@douglowder](https://github.com/douglowder))
## 0.28.5 — 2024-05-31
### 🐛 Bug fixes
- [Android] Fix FCMv1 icons and NPE. ([#29204](https://github.com/expo/expo/pull/29204) by [@douglowder](https://github.com/douglowder))
## 0.28.4 — 2024-05-29
### 🐛 Bug fixes
- [Android] Correctly map response in useLastNotificationResponse hook. ([#28938](https://github.com/expo/expo/pull/28938) by [@douglowder](https://github.com/douglowder))
### 💡 Others
- [iOS] Add support for `interruptionLevel`. ([#28921](https://github.com/expo/expo/pull/28921) by [@lukmccall](https://github.com/lukmccall))
## 0.28.2 — 2024-05-15
### 🐛 Bug fixes
- [Android] fix response handling when app in background or not running. ([#28883](https://github.com/expo/expo/pull/28883) by [@douglowder](https://github.com/douglowder))
## 0.28.1 — 2024-04-23
_This version does not introduce any user-facing changes._
## 0.28.0 — 2024-04-18
### 🐛 Bug fixes
- [Android] Fix notifications events were using an incorrect event emitter. ([#28207](https://github.com/expo/expo/pull/28207) by [@lukmccall](https://github.com/lukmccall))
### 💡 Others
- [iOS] Add privacy manifest describing required reason API usage. ([#27770](https://github.com/expo/expo/pull/27770) by [@aleqsio](https://github.com/aleqsio))
- drop unused web `name` property. ([#27437](https://github.com/expo/expo/pull/27437) by [@EvanBacon](https://github.com/EvanBacon))
- Removed deprecated backward compatible Gradle settings. ([#28083](https://github.com/expo/expo/pull/28083) by [@kudo](https://github.com/kudo))
## 0.27.5 - 2024-01-25
_This version does not introduce any user-facing changes._
## 0.27.4 - 2024-01-20
### 🐛 Bug fixes
- Throw `UnavailabilityError` when trying to use `setNotificationCategoryAsync` on web. ([#26511](https://github.com/expo/expo/pull/26511) by [@marklawlor](https://github.com/marklawlor))
- Remove `.native` hardcoded platform imports ([#26511](https://github.com/expo/expo/pull/26511) by [@marklawlor](https://github.com/marklawlor))
- On `Android`, added events to module definition to clear warnings. ([#26654](https://github.com/expo/expo/pull/26654) by [@alanjhughes](https://github.com/alanjhughes))
## 0.27.3 - 2024-01-10
### 🐛 Bug fixes
- [Android] Fix `expo-notifications` requiring the `expo-task-manager` module to start. ([#26227](https://github.com/expo/expo/pull/26227) by [@behenate](https://github.com/behenate))
## 0.27.2 - 2023-12-19
_This version does not introduce any user-facing changes._
## 0.27.1 — 2023-12-13
_This version does not introduce any user-facing changes._
## 0.27.0 — 2023-12-12
### 🐛 Bug fixes
- On `Android`, make `tokenManager` nullable to prevent crash if we can't find it. ([#25860](https://github.com/expo/expo/pull/25860) by [@alanjhughes](https://github.com/alanjhughes))
## 0.26.0 — 2023-11-14
### 🛠 Breaking changes
- Bumped iOS deployment target to 13.4. ([#25063](https://github.com/expo/expo/pull/25063) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- On `Android` bump `compileSdkVersion` and `targetSdkVersion` to `34`. ([#24708](https://github.com/expo/expo/pull/24708) by [@alanjhughes](https://github.com/alanjhughes))
## 0.25.0 — 2023-10-17
### 🛠 Breaking changes
- Dropped support for Android SDK 21 and 22. ([#24201](https://github.com/expo/expo/pull/24201) by [@behenate](https://github.com/behenate))
### 🐛 Bug fixes
- Send background notifications through when the app is in the foreground ([#24684](https://github.com/expo/expo/pull/24684) by [@kadikraman](https://github.com/kadikraman))
### 💡 Others
- Migrated codebase to use Expo Modules API. ([#24499](https://github.com/expo/expo/pull/24499) by [@lukmccall](https://github.com/lukmccall))
## 0.24.2 — 2023-09-18
_This version does not introduce any user-facing changes._
## 0.24.1 — 2023-09-15
### 💡 Others
- Remove legacy expo package notifications module code. ([#24325](https://github.com/expo/expo/pull/24325) by [@wschurman](https://github.com/wschurman))
## 0.24.0 — 2023-09-15
_This version does not introduce any user-facing changes._
## 0.23.0 — 2023-09-04
### 🎉 New features
- Added support for React Native 0.73. ([#24018](https://github.com/expo/expo/pull/24018) by [@kudo](https://github.com/kudo))
### 🐛 Bug fixes
- Fix server rendering with Metro web. ([#24195](https://github.com/expo/expo/pull/24195) by [@EvanBacon](https://github.com/EvanBacon))
## 0.22.0 — 2023-08-02
_This version does not introduce any user-facing changes._
## 0.21.0 — 2023-07-28
### 💡 Others
- Fork `uuid@3.4.0` and move into `expo-modules-core`. Remove the original dependency. ([#23249](https://github.com/expo/expo/pull/23249) by [@alanhughes](https://github.com/alanjhughes))
## 0.20.1 — 2023-06-24
_This version does not introduce any user-facing changes._
## 0.20.0 — 2023-06-21
### 🐛 Bug fixes
- Fixed Android build warnings for Gradle version 8. ([#22537](https://github.com/expo/expo/pull/22537), [#22609](https://github.com/expo/expo/pull/22609) by [@kudo](https://github.com/kudo))
## 0.19.0 — 2023-05-08
### 🛠 Breaking changes
- Removed the deprecated `ExpoPushTokenOptions.experienceId` field. ([#22303](https://github.com/expo/expo/pull/22303) by [@gabrieldonadel](https://github.com/gabrieldonadel))
### 💡 Others
- Update fixtures. ([#21397](https://github.com/expo/expo/pull/21397) by [@EvanBacon](https://github.com/EvanBacon))
- Warn on use of Constants.manifest. ([#22247](https://github.com/expo/expo/pull/22247) by [@wschurman](https://github.com/wschurman))
## 0.18.1 — 2023-02-09
### 💡 Others
- Export `getExpoPushTokenAsync` parameter type. ([#21104](https://github.com/expo/expo/pull/21104) by [@Simek](https://github.com/Simek))
## 0.18.0 — 2023-02-03
### 💡 Others
- Update `getExpoPushTokenAsync` to make `projectId` required. ([#20833](https://github.com/expo/expo/pull/20833) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- On Android bump `compileSdkVersion` and `targetSdkVersion` to `33`. ([#20721](https://github.com/expo/expo/pull/20721) by [@lukmccall](https://github.com/lukmccall))
- Add JSDoc comments, perform type changes related to documentation autogeneration. ([#21002](https://github.com/expo/expo/pull/21002) by [@Simek](https://github.com/Simek))
## 0.17.0 — 2022-10-25
### 🛠 Breaking changes
- [plugin] Upgrade minimum runtime requirement to Node 14 (LTS). ([#18204](https://github.com/expo/expo/pull/18204) by [@EvanBacon](https://github.com/EvanBacon))
- Bumped iOS deployment target to 13.0 and deprecated support for iOS 12. ([#18873](https://github.com/expo/expo/pull/18873) by [@tsapeta](https://github.com/tsapeta))
### 🐛 Bug fixes
- Fixed build error for setting `compileSdkVersion` to 33. ([#19432](https://github.com/expo/expo/pull/19432) by [@kudo](https://github.com/kudo))
- Fixed the `POST_NOTIFICATIONS` runtime permission integration when `targerSdkVersion` is set to 33. ([#19672](https://github.com/expo/expo/pull/19672) by [@kudo](https://github.com/kudo), [@kudo](https://github.com/kudo))
- Fixed `projectId` variable not found reference error when using development builds. ([#20276](https://github.com/expo/expo/pull/20276) by [@amandeepmittal](https://github.com/amandeepmittal))
### 💡 Others
- [plugin] Migrate import from @expo/config-plugins to expo/config-plugins and @expo/config-types to expo/config. ([#18855](https://github.com/expo/expo/pull/18855) by [@brentvatne](https://github.com/brentvatne))
- Drop `@expo/config-plugins` dependency in favor of peer dependency on `expo`. ([#18595](https://github.com/expo/expo/pull/18595) by [@EvanBacon](https://github.com/EvanBacon))
## 0.16.1 — 2022-07-16
_This version does not introduce any user-facing changes._
## 0.16.0 — 2022-07-07
### 🛠 Breaking changes
- [android] Set the "notification number" (sometimes used to increment badge count on some launchers) from the notification payload `badge` field. ([#17171](https://github.com/expo/expo/pull/17171) by [@danstepanov](https://github.com/danstepanov))
### 🐛 Bug fixes
- Fixed Android 12+ runtime crash caused by `PendingIntent` misconfiguration. ([#17333](https://github.com/expo/expo/pull/17333) by [@kudo](https://github.com/kudo))
- Fix app not bringing to foreground when clicking notification on Android 12+. ([#17686](https://github.com/expo/expo/pull/17686) by [@kudo](https://github.com/kudo))
- Fixed Android data-only FCM notifications (i.e. notifications without a title and message) appearing in the notification drawer ([#17707](https://github.com/expo/expo/pull/17707) by [@sausti](https://github.com/sausti))
- Add support for unregistering from push notifications on Android and iOS ([#17812](https://github.com/expo/expo/pull/17812) by [@sausti](https://github.com/sausti))
- Fix another Android 12+ trampoline issue from push notifications. ([#17871](https://github.com/expo/expo/pull/17871) by [@kudo](https://github.com/kudo))
- Fixed `useLastNotificationResponse` returns latest received notification but not the clicked notification on Android. ([#17974](https://github.com/expo/expo/pull/17974) by [@kudo](https://github.com/kudo))
### ⚠️ Notices
- Fixed exception on Android 12+ devices for missing `SCHEDULE_EXACT_ALARM` permission. If `scheduleNotificationAsync` needs a precise timer, the `SCHEDULE_EXACT_ALARM` should be explicitly added to **AndroidManifest.xml**. ([#17334](https://github.com/expo/expo/pull/17334) by [@kudo](https://github.com/kudo))
## 0.15.1 — 2022-04-27
### 💡 Others
- Remove badge deadcode ([#17205](https://github.com/expo/expo/pull/17205) by [@wschurman](https://github.com/wschurman))
## 0.15.0 — 2022-04-18
### 🐛 Bug fixes
- Upgrade firebase messaging dependency to v21. This means `expo-notifications` no longer relies on `FirebaseInstanceId`. If you added `com.google.firebase:firebase-iid` to your `android/app/build.gradle` file for this library, it is no longer required and you can safely remove that dependency. ([#15010](https://github.com/expo/expo/pull/15010) by [@cruzach](https://github.com/cruzach))
### 💡 Others
- Updated `@expo/config-plugins` from `4.0.2` to `4.0.14` and `@expo/image-utils` from `^0.3.16` to `^0.3.18` ([#15621](https://github.com/expo/expo/pull/15621) by [@EvanBacon](https://github.com/EvanBacon))
### ⚠️ Notices
- On Android bump `compileSdkVersion` to `31`, `targetSdkVersion` to `31` and `Java` version to `11`. ([#16941](https://github.com/expo/expo/pull/16941) by [@bbarthec](https://github.com/bbarthec))
## 0.14.1 - 2022-02-01
### 🐛 Bug fixes
- Fix `Plugin with id 'maven' not found` build error from Android Gradle 7. ([#16080](https://github.com/expo/expo/pull/16080) by [@kudo](https://github.com/kudo))
## 0.14.0 — 2021-12-03
### 💡 Others
- Update `fs-extra` dependency. ([#15069](https://github.com/expo/expo/pull/15069) by [@Simek](https://github.com/Simek))
## 0.13.1 — 2021-10-01
_This version does not introduce any user-facing changes._
## 0.13.0 — 2021-09-28
### 🛠 Breaking changes
- Dropped support for iOS 11.0 ([#14383](https://github.com/expo/expo/pull/14383) by [@cruzach](https://github.com/cruzach))
### 🎉 New features
- Update JS code to read manifest2 when manifest is not available. ([#13602](https://github.com/expo/expo/pull/13602) by [@wschurman](https://github.com/wschurman))
- Add usePermissions hook from modules factory. ([#13863](https://github.com/expo/expo/pull/13863) by [@bycedric](https://github.com/bycedric))
### 🐛 Bug fixes
- Fixed Android notifications not respecting the `shouldPlaySound` property in `setNotificationHandler`. ([#13411](https://github.com/expo/expo/pull/13411) by [@cruzach](https://github.com/cruzach))
- Force device ID to lowercase before sending to Expo's servers. (Only applicable if you're using `ExpoPushToken`s). ([#13409](https://github.com/expo/expo/pull/13409) by [@cruzach](https://github.com/cruzach))
- Fixed plugin to not throw if the notification icon isn't set, and there's no notification icon present in the Android project. ([#13539](https://github.com/expo/expo/pull/13539) by [@cruzach](https://github.com/cruzach))
- Fix building errors from use_frameworks! in Podfile. ([#14523](https://github.com/expo/expo/pull/14523) by [@kudo](https://github.com/kudo))
### 💡 Others
- Updated `@expo/config-plugins`, `@expo/image-utils` ([#14443](https://github.com/expo/expo/pull/14443) by [@EvanBacon](https://github.com/EvanBacon))
## 0.12.0 — 2021-06-16
### 🎉 New features
- [plugin] Refactor imports ([#13029](https://github.com/expo/expo/pull/13029) by [@EvanBacon](https://github.com/EvanBacon))
- Add support for custom notification sounds when using EAS Build. ([#12782](https://github.com/expo/expo/pull/12782) by [@cruzach](https://github.com/cruzach))
- Added ability to respond to remote notifications received while the app is backgrounded. ([#13130](https://github.com/expo/expo/pull/13130) by [@cruzach](https://github.com/cruzach))
### 🐛 Bug fixes
- Enable kotlin in all modules. ([#12716](https://github.com/expo/expo/pull/12716) by [@wschurman](https://github.com/wschurman))
- Add new manifest2 field and make existing field optional. ([#12817](https://github.com/expo/expo/pull/12817) by [@wschurman](https://github.com/wschurman))
- Use originalFullName instead of currentFullName ([#12953](https://github.com/expo/expo/pull/12953)) by [@wschurman](https://github.com/wschurman))
### 💡 Others
- Migrated from `unimodules-permissions-interface` to `expo-modules-core`. ([#12961](https://github.com/expo/expo/pull/12961) by [@tsapeta](https://github.com/tsapeta))
- Refactored uuid imports to v7 style. ([#13037](https://github.com/expo/expo/pull/13037) by [@giautm](https://github.com/giautm))
## 0.11.5 — 2021-04-13
_This version does not introduce any user-facing changes._
## 0.11.4 — 2021-04-09
### 🎉 New features
- Add bare workflow support to `getExpoPushTokenAsync`. ([#12465](https://github.com/expo/expo/pull/12465) by [@EvanBacon](https://github.com/EvanBacon))
## 0.11.3 — 2021-03-31
_This version does not introduce any user-facing changes._
## 0.11.2 — 2021-03-30
### 🐛 Bug fixes
- Fixed an issue on Android where dismissing notifications by ID inside of Expo Go did nothing. ([#12306](https://github.com/expo/expo/pull/12306 by [@cruzach](https://github.com/cruzach))
## 0.11.1 — 2021-03-23
### 🎉 New features
- Expose `getLastNotificationResponseAsync` method (non-hook version of `useLastNotificationResponse`).
### 🐛 Bug fixes
- Prevent scoped category IDs from being returned from `setNotificationCategoryAsync`. ([#12212](https://github.com/expo/expo/pull/12212 by [@cruzach](https://github.com/cruzach))
## 0.11.0 — 2021-03-10
### 🎉 New features
- Allow for remote notifications to overwrite notifications already existing in the tray. ([#12050](https://github.com/expo/expo/pull/12050) and [#12055](https://github.com/expo/expo/pull/12055) by [@cruzach](https://github.com/cruzach))
- Notifications from different experiences in Expo Go can no longer overwrite each other. ([#12050](https://github.com/expo/expo/pull/12050) and [#12055](https://github.com/expo/expo/pull/12055) by [@cruzach](https://github.com/cruzach))
## 0.10.0 — 2021-03-03
### 🎉 New features
- Updated Android build configuration to target Android 11 (added support for Android SDK 30). ([#11647](https://github.com/expo/expo/pull/11647) by [@bbarthec](https://github.com/bbarthec))
- Added `YearlyTriggerInput` that allows scheduling a yearly recurring notification for a specific day of the year, hour and minute. It is supported on both iOS and Android. ([#11898](https://github.com/expo/expo/pull/11898) by [@raulmt](https://github.com/raulmt))
### 🐛 Bug fixes
- Notification categories will no longer be lost after ejecting to the bare workflow (if ejecting after SDK 41). ([#11651](https://github.com/expo/expo/pull/11651) by [@cruzach](https://github.com/cruzach))
- Notify all listeners of pending notification responses. ([#11536](https://github.com/expo/expo/pull/11536) by [@esamelson](https://github.com/esamelson))
## 0.9.0 — 2021-01-15
### ⚠️ Notices
- The package is now shipped with prebuilt binaries on iOS. You can read more about it on [expo.fyi/prebuilt-modules](https://expo.fyi/prebuilt-modules). ([#11224](https://github.com/expo/expo/pull/11224) by [@tsapeta](https://github.com/tsapeta))
### 🛠 Breaking changes
- Dropped support for iOS 10.0 ([#11344](https://github.com/expo/expo/pull/11344) by [@tsapeta](https://github.com/tsapeta))
- When migrating installation identifier (used internally to fetch Expo push token) `expo-notifications` will now remove existing `SharedPreferences` entry, if the migrated identifier comes from there. This may cause issues in bare workflow projects if `expo-constants` is installed in version lower than `10.0.0`. **Please upgrade `expo-constants` in your project to at least `10.0.0` when installing new versions of `expo-notifications`. If you do not upgrade `expo-constants`, its `.installationId` may change.** ([#11283](https://github.com/expo/expo/pull/11283) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- Created config plugin. ([#11633](https://github.com/expo/expo/pull/11633) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- Fixed a case where `requestPermissionsAsync` would ignore the provided `NotificationPermissionsRequest`. ([#11548](https://github.com/expo/expo/pull/11548) by [@cruzach](https://github.com/cruzach))
- Fixed case on Android where `getPermissionsAsync` would always return `canAskAgain: true`. ([#11551](https://github.com/expo/expo/pull/11551) by [@cruzach](https://github.com/cruzach))
- Fixed migration process to **not** use `expo-constants` installation ID if there is a notifications-specific identifier. ([#11287](https://github.com/expo/expo/pull/11287) by [@sjchmiela](https://github.com/sjchmiela))
- Native iOS notifications emitter module no longer registers for notification events as soon as module registry is ready which fixes initial notification response not being delivered to JS in standalone (Expo managed workflow) iOS apps. ([#11382](https://github.com/expo/expo/pull/11382) by [@sjchmiela](https://github.com/sjchmiela))
- Changed the visibility of Android's `InstallationId#getNonBackedUpUuidFile` method so it's easier to override by custom implementations. ([#11249](https://github.com/expo/expo/pull/11249) by [@sjchmiela](https://github.com/sjchmiela))
- Added extra check for marking pending notification responses as delivered which prevents legacy Expo notifications to consume notification responses when we don't want it to which should help fix initial notification response (causing the application to start) not being delivered (only in iOS standalone applications in Expo managed workflow). ([#11378](https://github.com/expo/expo/pull/11378) by [@sjchmiela](https://github.com/sjchmiela))
- Removed `fbjs` dependency ([#11396](https://github.com/expo/expo/pull/11396) by [@cruzach](https://github.com/cruzach))
## 0.8.2 — 2020-11-30
### 🐛 Bug fixes
- Added `assert` as a package dependency. ([#11171](https://github.com/expo/expo/pull/11171) by [@cruzach](https://github.com/cruzach))
## 0.8.1 — 2020-11-25
_This version does not introduce any user-facing changes._
## 0.8.0 — 2020-11-17
### 🛠 Breaking changes
- Changed the way `PermissionResponse.status` is calculated on iOS. Previously, it returns the numeric value of `UMPermissionStatus` which does not match the TypeScript enum declaration. ([#10513](https://github.com/expo/expo/pull/10513) by [@cHaLkdusT](https://github.com/cHaLkdusT))
- Changed the way `NotificationContent.data` is calculated on iOS. Previously it was the contents of remote notification payload with all entries from under `"body"` moved from under `"body"` to root level. Now it's the sole unchanged contents of `payload["body"]`. Other fields of the payload can now be accessed on iOS through `PushNotificationTrigger.payload` (similarly to how other fields of native remote message can be accessed on Android under `PushNotificationTrigger.remoteMessage`). ([#10453](https://github.com/expo/expo/pull/10453) by [@sjchmiela](https://github.com/sjchmiela))
- Changed class responsible for handling Firebase events from `FirebaseMessagingService` to `.service.NotificationsService` on Android. ([#10558](https://github.com/expo/expo/pull/10558) by [@sjchmiela](https://github.com/sjchmiela))
> Note that this change most probably will not affect you — it only affects projects that override `FirebaseMessagingService` to implement some custom handling logic.
- Changed how you can override ways in which a notification is reinterpreted from a [`StatusBarNotification`](https://developer.android.com/reference/android/service/notification/StatusBarNotification) and in which a [`Notification`](https://developer.android.com/reference/android/app/Notification.html?hl=en) is built from defining an `expo.modules.notifications#NotificationsScoper` meta-data value in `AndroidManifest.xml` to implementing a `BroadcastReceiver` subclassing `NotificationsService` delegating those responsibilities to your custom `PresentationDelegate` instance. ([#10558](https://github.com/expo/expo/pull/10558) by [@sjchmiela](https://github.com/sjchmiela))
> Note that this change most probably will not affect you — it only affects projects that override those methods to implement some custom handling logic.
- Removed `removeAllNotificationListeners` method. You can (and should) still remove listeners using `remove` method on `Subscription` objects returned by `addNotification…Listener`. ([#10883](https://github.com/expo/expo/pull/10883) by [@sjchmiela](https://github.com/sjchmiela))
- Fixed device identifier being used to fetch Expo push token being backed up on Android which resulted in multiple devices having the same `deviceId` (and eventually, Expo push token). ([#11005](https://github.com/expo/expo/pull/11005) by [@sjchmiela](https://github.com/sjchmiela))
- Fixed device identifier used when fetching Expo push token being different than `Constants.installationId` in managed workflow apps which resulted in different Expo push tokens returned for the same experience across old and new Expo API and the device push token not being automatically updated on Expo push servers which lead to Expo push tokens corresponding to outdated Firebase tokens. ([#11005](https://github.com/expo/expo/pull/11005) by [@sjchmiela](https://github.com/sjchmiela))
- Removed `removeAllPushTokenListeners` method. You can (and should) still remove listeners using `remove` method on `Subscription` objects returned by `addPushTokenListener`. ([#11106](https://github.com/expo/expo/pull/11106) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- Added `useLastNotificationResponse` React hook that always returns the notification response that has been emitted most recently. ([#10883](https://github.com/expo/expo/pull/10883) by [@sjchmiela](https://github.com/sjchmiela))
- Added `WeeklyTriggerInput` that allows scheduling a weekly recurring notification for a specific day of week, hour and minute. It is supported on both iOS and Android. ([#9973](https://github.com/expo/expo/pull/9973) by [@RikTheunis](https://github.com/riktheunis))
- Added `getNextTriggerDateAsync` method allowing you to verify manually when would the next trigger date for a particular notification trigger be. ([#10455](https://github.com/expo/expo/pull/10455) by [@sjchmiela](https://github.com/sjchmiela))
- Added support for restoring scheduled notifications alarms on Android after an app is updated. ([#10708](https://github.com/expo/expo/pull/10708) by [@sjchmiela](https://github.com/sjchmiela))
- Added support for auto server reregistration for Expo push tokens (keeping Expo push token always valid) and auto server registration customizations. ([#10908](https://github.com/expo/expo/pull/10908) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Fixed TypeScript definition: `setNotificationCategoryAsync` should expect `options.allowAnnouncement`, **not** `options.allowAnnouncment`. ([#11025](https://github.com/expo/expo/pull/11025) by [@cruzach](https://github.com/cruzach))
- Fixed issue where custom notification icon and color weren't being properly applied in Android managed workflow apps. ([#10828](https://github.com/expo/expo/pull/10828) by [@cruzach](https://github.com/cruzach))
- Fixed case where Android managed workflow apps could crash when receiving an interactive notification. ([#10608](https://github.com/expo/expo/pull/10608) by [@cruzach](https://github.com/cruzach))
- Fixed case where Android apps could crash if you set a new category with a text input action **without** providing any `options`. ([#10141](https://github.com/expo/expo/pull/10141) by [@cruzach](https://github.com/cruzach))
- Android apps no longer rely on the `submitButtonTitle` property as the action button title (they rely on `buttonTitle`, which matches iOS behavior). ([#10141](https://github.com/expo/expo/pull/10141) by [@cruzach](https://github.com/cruzach))
- Fixed `Notifications.requestPermissions()` returning `undetermined` instead of a known status in some browsers. ([#10296](https://github.com/expo/expo/pull/10296) by [@sjchmiela](https://github.com/sjchmiela))
- Fixed crashing when Proguard is enabled. ([#10421](https://github.com/expo/expo/pull/10421) by [@lukmccall](https://github.com/lukmccall))
- Fixed the application icon being always added as a notification icon. ([#10471](https://github.com/expo/expo/pull/10471) by [@lukmccall](https://github.com/lukmccall))
- Fixed faulty trigger detection mechanism which caused some triggers with `channelId` specified get recognized as triggers of other types. ([#10454](https://github.com/expo/expo/pull/10454) by [@sjchmiela](https://github.com/sjchmiela))
- Fixed fatal exception sometimes being thrown when notification was received or tapped on Android due to observer being cleared before it's added. ([#10640](https://github.com/expo/expo/pull/10640) by [@sjchmiela](https://github.com/sjchmiela))
- Removed the large icon from managed workflow. ([#10492](https://github.com/expo/expo/pull/10492) by [@lukmccall](https://github.com/lukmccall))
- Fixed crash happening due to non-existent `ExpoNotificationsService` being declared in `AndroidManifest.xml`. ([#10638](https://github.com/expo/expo/pull/10638) by [@sjchmiela](https://github.com/sjchmiela))
- Fixed notifications _not_ playing any sound when `shouldShowAlert: false` but `shouldPlaySound: true` in `setNotificationHandler`. ([#10699](https://github.com/expo/expo/pull/10699) by [@cruzach](https://github.com/cruzach))
- Add guard against badgin usage in SSR environments. ([#10741](https://github.com/expo/expo/pull/10741) by [@bycedric](https://github.com/bycedric))
- Moved notification events handling from main thread to a background thread which makes users' devices more responsive. ([#10762](https://github.com/expo/expo/pull/10762) by [@sjchmiela](https://github.com/sjchmiela))
- Fixed having to define `CATEGORY_DEFAULT` on an `Activity` that is expected to receive `expo.modules.notifications.OPEN_APP_ACTION` intent when handling notification response. ([#10755](https://github.com/expo/expo/pull/10755) by [@sjchmiela](https://github.com/sjchmiela))
- Fixed notifications not being returned at all from `getAllPresentedNotificationsAsync()` if the library fails to reconstruct notification request based on marshaled copy in notification data. From now on they'll be naively reconstructed from the Android notification. ([#10801](https://github.com/expo/expo/pull/10801) by [@sjchmiela](https://github.com/sjchmiela))
- May have helped fix an issue where "initial notification response" (the one that opened the app) was not being delivered to Android apps. ([#10773](https://github.com/expo/expo/pull/10773) by [@sjchmiela](https://github.com/sjchmiela))
## 0.7.1 — 2020-08-26
_This version does not introduce any user-facing changes._
## 0.7.0 — 2020-08-18
### 🎉 New features
- Added permissions support for web. ([#9576](https://github.com/expo/expo/pull/9576) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- Fix scheduled notifications not being displayed after five minutes of phone inactivity on Android. ([#9816](https://github.com/expo/expo/pull/9816) by [@sjchmiela](https://github.com/sjchmiela))
- Fixed case where iOS notification category would not be set on the very first call to `setNotificationCategoryAsync`. ([#9515](https://github.com/expo/expo/pull/9515) by [@cruzach](https://github.com/cruzach))
- Fixed notification response listener not triggering in the managed workflow on iOS when app was completely killed ([#9478](https://github.com/expo/expo/pull/9478) by [@cruzach](https://github.com/cruzach))
- Fixed notifications being displayed when `shouldShowAlert` was `false` on Android. ([#9563](https://github.com/expo/expo/pull/9563) by [@barthap](https://github.com/barthap))
- Fixed `Application Not Responding` occurring in the Google Play Console. ([#9792](https://github.com/expo/expo/pull/9792) by [@lukmccall](https://github.com/lukmccall))
## 0.6.0 — 2020-07-29
### 🎉 New features
- Added Notification categories functionality to allow for interactive push notifications on Android and iOS! ([#9015](https://github.com/expo/expo/pull/9015) by [@cruzach](https://github.com/cruzach))
- Added support for channels to local notifications. ([#9385](https://github.com/expo/expo/pull/9385) by [@lukmccall](https://github.com/lukmccall))
## 0.5.0 — 2020-07-27
### 🎉 New features
- Added support for custom large icon on the Android. ([#9116](https://github.com/expo/expo/pull/9116) by [@lukmccall](https://github.com/lukmccall))
- Added `sticky` property, which defines if notification can be dismissed by swipe. ([#9351](https://github.com/expo/expo/pull/9351) by [@barthap](https://github.com/barthap))
### 🐛 Bug fixes
- Fix notifications not being displayed after five minutes of phone inactivity on Android. ([#9287](https://github.com/expo/expo/pull/9287) by [@mczernek](https://github.com/mczernek))
- Include `content-type: application/json` when requesting an Expo push token ([#9332](https://github.com/expo/expo/pull/9332) by @ide)
- Export `NotificationPermissions.types` to make `Notifications.IosAuthorizationStatus` available. ([#8747](https://github.com/expo/expo/pull/8747) by [@brentvatne](https://github.com/brentvatne))
- Fixed remote notifications ignoring the `channelId` parameter. ([#9080](https://github.com/expo/expo/pull/9080) by [@lukmccall](https://github.com/lukmccall))
- Fixed malformed data object on iOS. ([#9164](https://github.com/expo/expo/pull/9164) by [@lukmccall](https://github.com/lukmccall))
## 0.4.0 — 2020-06-24
### 🎉 New features
- Added `IosAuthorizationStatus.EPHEMERAL`, an option that maps to `UNAuthorizationStatusEphemeral` for compatibility with iOS 14. ([#8938](https://github.com/expo/expo/pull/8938) by [@ide](https://github.com/ide))
### 🐛 Bug fixes
- Fixed total incompatibility with the web platform calling unsupported methods will now throw a readable `UnavailabilityError`. ([#8853](https://github.com/expo/expo/pull/8853) by [@sjchmiela](https://github.com/sjchmiela))
## 0.3.2 — 2020-06-10
### 🐛 Bug fixes
- Fixed compatibility with `expo-permissions` below `9.0.0` (the _duplicate symbols_ problem). ([#8753](https://github.com/expo/expo/pull/8753) by [@sjchmiela](https://github.com/sjchmiela))
## 0.3.1 — 2020-06-03
### 🎉 New features
- Added support for including foreign (non-`expo-notifications`-created) notifications in `getPresentedNotificationsAsync` on Android. ([#8614](https://github.com/expo/expo/pull/8614) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Fixed `getExpoPushTokenAsync` rejecting when `getDevicePushTokenAsync`'s `Promise` hasn't fulfilled yet (and vice versa). Probably also added support for calling these methods reliably with Fast Refresh enabled. ([#8608](https://github.com/expo/expo/pull/8608) by [@sjchmiela](https://github.com/sjchmiela))
## 0.3.0 — 2020-05-28
### 🎉 New features
- Added native permission requester that will let developers call `Permissions.getAsync(Permissions.NOTIFICATIONS)` (or `askAsync`) when this module is installed. ([#8486](https://github.com/expo/expo/pull/8486) by [@sjchmiela](https://github.com/sjchmiela))
> Note that the effect of this method is the same as if you called `Notifications.getPermissionsAsync()` (or `requestPermissionsAsync`) and then `Notifications.getDevicePushTokenAsync()`—it tries to both ask the user for user-facing notifications permissions and then tries to register the device for remote notifications. We are planning to deprecate the `.NOTIFICATIONS` permission soon.
## 0.2.0 — 2020-05-27
### 🛠 Breaking changes
- > Note that this may or may not be a breaking change for you — if you'd expect the notification to be automatically dismissed when tapped on this is a bug fix and a new feature (fixes inconsistency between platforms as on iOS this is the only supported behavior; adds the ability to customize the behavior on Android). If you'd expect the notification to only be dismissed at your will this is a breaking change and you'll need to add `autoDismiss: false` to your notification content inputs.
- Changed the default notification behavior on Android to be automatically dismissed when clicked. This is customizable with the `autoDismiss` parameter of `NotificationContentInput`. ([#8241](https://github.com/expo/expo/pull/8241) by [@thorbenprimke](https://github.com/thorbenprimke))
### 🎉 New features
- Added the ability to configure whether the notification should be automatically dismissed when tapped on or not (on Android) with the `autoDismiss` parameter of `NotificationContentInput`. ([#8241](https://github.com/expo/expo/pull/8241) by [@thorbenprimke](https://github.com/thorbenprimke))
- Added `DailyTriggerInput` that allows scheduling a daily recurring notification for a specific hour and minute. It is supported on both iOS and Android. ([#8199](https://github.com/expo/expo/pull/8199) by [@thorbenprimke](https://github.com/thorbenprimke))
### 🐛 Bug fixes
- Added a macro check for `UNLocationNotificationTrigger` to make this module compatible with Mac Catalyst ([#8171](https://github.com/expo/expo/pull/8171) by [@robertying](https://github.com/robertying))
- Fixed notification content text being truncated without the ability to expand the notification by adding [`BigTextStyle`](https://developer.android.com/reference/android/app/Notification.BigTextStyle) to all Android notifications, which allows them to be expanded and their content text fully viewed ([#8140](https://github.com/expo/expo/pull/8140) by [@thorbenprimke](https://github.com/thorbenprimke))
- Added a check for trigger input that throws an error if user misuses the `seconds` property ([#8261](https://github.com/expo/expo/pull/8261) by [@sjchmiela](https://github.com/sjchmiela))
## 0.1.7 - 2020-05-05
### 🐛 Bug fixes
- Fixed obsolete and invalid dependency on `>= @unimodules/core@5.1.1`, bringing backwards compatibility with older versions of `@unimodules/core` ([#8162](https://github.com/expo/expo/pull/8162) by [@sjchmiela](https://github.com/sjchmiela))
## 0.1.6 - 2020-05-05
### 🐛 Bug fixes
- Fixed crash when serializing a notification containing a `null` value ([#8153](https://github.com/expo/expo/pull/8153) by [@sjchmiela](https://github.com/sjchmiela))
- Fixed a typo in `AndroidImportance` enum (`DEEFAULT` is now deprecated in favor of `DEFAULT`) ([#8161](https://github.com/expo/expo/pull/8161) by [@trevorah](https://github.com/trevorah))
## 0.1.5 - 2020-05-05
### 🐛 Bug fixes
- Fixed the ability to override the `FirebaseListenerService` without having to add a custom priority. ([#8175](https://github.com/expo/expo/pull/8175) by [@lukmccall](https://github.com/lukmccall))
- Fixed `SoundResolver` causing crash if the `sound` property is not defined or doesn't contain a `.` ([#8150](https://github.com/expo/expo/pull/8150) by [@sjchmiela](https://github.com/sjchmiela))
## 0.1.4 - 2020-05-04
### 🎉 New features
- Added a native setting allowing you to use a custom notification icon for Android notifications ([#8035](https://github.com/expo/expo/pull/8035) by [@sjchmiela](https://github.com/sjchmiela))
- Added a native setting and a runtime option allowing you to use a custom notification color for Android notifications ([#8035](https://github.com/expo/expo/pull/8035) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Fixed initial notification not being emitted to `NotificationResponse` listener on iOS ([#7958](https://github.com/expo/expo/pull/7958) by [@sjchmiela](https://github.com/sjchmiela))
## 0.1.3 - 2020-04-30
### 🐛 Bug fixes
- Fixed custom notification sounds not being applied properly to notifications and channels ([#8036](https://github.com/expo/expo/pull/8036) by [@sjchmiela](https://github.com/sjchmiela))
- Fixed iOS rejecting the Promise to schedule a notification if `sound` is not empty or a boolean ([#8036](https://github.com/expo/expo/pull/8036) by [@sjchmiela](https://github.com/sjchmiela))
## 0.1.2 - 2020-04-21
### 🐛 Bug fixes
- Fixed interpretation of `Date` and `number` triggers when calling `scheduleNotificationAsync` on iOS ([#7942](https://github.com/expo/expo/pull/7942) by [@sjchmiela](https://github.com/sjchmiela))

File diff suppressed because it is too large Load Diff

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);
}

Some files were not shown because too many files have changed in this diff Show More