From e59ba9ceea78a26ec60e521825f228baa9d74577 Mon Sep 17 00:00:00 2001 From: Dan Imhoff Date: Wed, 13 Jan 2021 11:30:31 -0800 Subject: [PATCH] feat: Local Notifications plugin (#94) Co-authored-by: Joseph Pender --- README.md | 1 + lerna.json | 1 + local-notifications/.eslintignore | 2 + local-notifications/.gitignore | 61 ++ local-notifications/.prettierignore | 2 + .../CapacitorLocalNotifications.podspec | 17 + local-notifications/LICENSE | 23 + local-notifications/README.md | 512 ++++++++++ local-notifications/android/.gitignore | 1 + local-notifications/android/build.gradle | 58 ++ local-notifications/android/gradle.properties | 24 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 58910 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + local-notifications/android/gradlew | 185 ++++ local-notifications/android/gradlew.bat | 104 ++ .../android/proguard-rules.pro | 21 + local-notifications/android/settings.gradle | 2 + .../android/ExampleInstrumentedTest.java | 26 + .../android/src/main/AndroidManifest.xml | 20 + .../plugins/localnotifications/DateMatch.java | 213 ++++ .../localnotifications/LocalNotification.java | 383 ++++++++ .../LocalNotificationAttachment.java | 70 ++ .../LocalNotificationManager.java | 427 ++++++++ .../LocalNotificationRestoreReceiver.java | 58 ++ .../LocalNotificationSchedule.java | 159 +++ .../LocalNotificationsPlugin.java | 116 +++ .../NotificationAction.java | 82 ++ .../NotificationChannelManager.java | 132 +++ .../NotificationDismissReceiver.java | 26 + .../NotificationStorage.java | 146 +++ .../TimedNotificationPublisher.java | 51 + .../com/getcapacitor/ExampleUnitTest.java | 18 + .../ios/Plugin.xcodeproj/project.pbxproj | 571 +++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Plugin.xcscheme | 77 ++ .../xcschemes/PluginTests.xcscheme | 68 ++ .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + local-notifications/ios/Plugin/Info.plist | 24 + .../Plugin/LocalNotificationsHandler.swift | 87 ++ .../ios/Plugin/LocalNotificationsPlugin.h | 10 + .../ios/Plugin/LocalNotificationsPlugin.m | 16 + .../ios/Plugin/LocalNotificationsPlugin.swift | 552 +++++++++++ .../ios/PluginTests/Info.plist | 22 + .../LocalNotificationsPluginTests.swift | 14 + local-notifications/ios/Podfile | 16 + local-notifications/package.json | 81 ++ local-notifications/rollup.config.js | 14 + local-notifications/src/definitions.ts | 909 ++++++++++++++++++ local-notifications/src/index.ts | 13 + local-notifications/src/web.ts | 136 +++ local-notifications/tsconfig.json | 19 + 53 files changed, 5608 insertions(+) create mode 100644 local-notifications/.eslintignore create mode 100644 local-notifications/.gitignore create mode 100644 local-notifications/.prettierignore create mode 100644 local-notifications/CapacitorLocalNotifications.podspec create mode 100644 local-notifications/LICENSE create mode 100644 local-notifications/README.md create mode 100644 local-notifications/android/.gitignore create mode 100644 local-notifications/android/build.gradle create mode 100644 local-notifications/android/gradle.properties create mode 100644 local-notifications/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 local-notifications/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 local-notifications/android/gradlew create mode 100644 local-notifications/android/gradlew.bat create mode 100644 local-notifications/android/proguard-rules.pro create mode 100644 local-notifications/android/settings.gradle create mode 100644 local-notifications/android/src/androidTest/java/com/getcapacitor/android/ExampleInstrumentedTest.java create mode 100644 local-notifications/android/src/main/AndroidManifest.xml create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/DateMatch.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotification.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationAttachment.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationManager.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationRestoreReceiver.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationSchedule.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationsPlugin.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationAction.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationChannelManager.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationDismissReceiver.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationStorage.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimedNotificationPublisher.java create mode 100644 local-notifications/android/src/test/java/com/getcapacitor/ExampleUnitTest.java create mode 100644 local-notifications/ios/Plugin.xcodeproj/project.pbxproj create mode 100644 local-notifications/ios/Plugin.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 local-notifications/ios/Plugin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 local-notifications/ios/Plugin.xcodeproj/xcshareddata/xcschemes/Plugin.xcscheme create mode 100644 local-notifications/ios/Plugin.xcodeproj/xcshareddata/xcschemes/PluginTests.xcscheme create mode 100644 local-notifications/ios/Plugin.xcworkspace/contents.xcworkspacedata create mode 100644 local-notifications/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 local-notifications/ios/Plugin/Info.plist create mode 100644 local-notifications/ios/Plugin/LocalNotificationsHandler.swift create mode 100644 local-notifications/ios/Plugin/LocalNotificationsPlugin.h create mode 100644 local-notifications/ios/Plugin/LocalNotificationsPlugin.m create mode 100644 local-notifications/ios/Plugin/LocalNotificationsPlugin.swift create mode 100644 local-notifications/ios/PluginTests/Info.plist create mode 100644 local-notifications/ios/PluginTests/LocalNotificationsPluginTests.swift create mode 100644 local-notifications/ios/Podfile create mode 100644 local-notifications/package.json create mode 100644 local-notifications/rollup.config.js create mode 100644 local-notifications/src/definitions.ts create mode 100644 local-notifications/src/index.ts create mode 100644 local-notifications/src/web.ts create mode 100644 local-notifications/tsconfig.json diff --git a/README.md b/README.md index e2853ce18..66c7937d9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ This repository contains the official Capacitor plugins maintained by the Capaci | [`@capacitor/geolocation`](https://capacitorjs.com/docs/v3/apis/geolocation) | [`./geolocation`](./geolocation) | [![npm badge](https://img.shields.io/npm/v/@capacitor/geolocation?style=flat-square)](https://www.npmjs.com/package/@capacitor/geolocation) | [`@capacitor/haptics`](https://capacitorjs.com/docs/v3/apis/haptics) | [`./haptics`](./haptics) | [![npm badge](https://img.shields.io/npm/v/@capacitor/haptics?style=flat-square)](https://www.npmjs.com/package/@capacitor/haptics) | [`@capacitor/keyboard`](https://capacitorjs.com/docs/v3/apis/keyboard) | [`./keyboard`](./keyboard) | [![npm badge](https://img.shields.io/npm/v/@capacitor/keyboard?style=flat-square)](https://www.npmjs.com/package/@capacitor/keyboard) +| [`@capacitor/local-notifications`](https://capacitorjs.com/docs/v3/apis/local-notifications) | [`./local-notifications`](./local-notifications) | [![npm badge](https://img.shields.io/npm/v/@capacitor/local-notifications?style=flat-square)](https://www.npmjs.com/package/@capacitor/local-notifications) | [`@capacitor/motion`](https://capacitorjs.com/docs/v3/apis/motion) | [`./motion`](./motion) | [![npm badge](https://img.shields.io/npm/v/@capacitor/motion?style=flat-square)](https://www.npmjs.com/package/@capacitor/motion) | [`@capacitor/network`](https://capacitorjs.com/docs/v3/apis/network) | [`./network`](./network) | [![npm badge](https://img.shields.io/npm/v/@capacitor/network?style=flat-square)](https://www.npmjs.com/package/@capacitor/network) | [`@capacitor/push-notifications`](https://capacitorjs.com/docs/v3/apis/push-notifications) | [`./push-notifications`](./push-notifications) | [![npm badge](https://img.shields.io/npm/v/@capacitor/push-notifications?style=flat-square)](https://www.npmjs.com/package/@capacitor/push-notifications) diff --git a/lerna.json b/lerna.json index 69a201197..82213c082 100644 --- a/lerna.json +++ b/lerna.json @@ -12,6 +12,7 @@ "geolocation", "haptics", "keyboard", + "local-notifications", "motion", "network", "push-notifications", diff --git a/local-notifications/.eslintignore b/local-notifications/.eslintignore new file mode 100644 index 000000000..9d0b71a3c --- /dev/null +++ b/local-notifications/.eslintignore @@ -0,0 +1,2 @@ +build +dist diff --git a/local-notifications/.gitignore b/local-notifications/.gitignore new file mode 100644 index 000000000..70ccbf713 --- /dev/null +++ b/local-notifications/.gitignore @@ -0,0 +1,61 @@ +# node files +dist +node_modules + +# iOS files +Pods +Podfile.lock +Build +xcuserdata + +# macOS files +.DS_Store + + + +# Based on Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.ap_ + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin +gen +out + +# Gradle files +.gradle +build + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation + +# Android Studio captures folder +captures + +# IntelliJ +*.iml +.idea + +# Keystore files +# Uncomment the following line if you do not want to check your keystore files in. +#*.jks + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild diff --git a/local-notifications/.prettierignore b/local-notifications/.prettierignore new file mode 100644 index 000000000..9d0b71a3c --- /dev/null +++ b/local-notifications/.prettierignore @@ -0,0 +1,2 @@ +build +dist diff --git a/local-notifications/CapacitorLocalNotifications.podspec b/local-notifications/CapacitorLocalNotifications.podspec new file mode 100644 index 000000000..5f674e205 --- /dev/null +++ b/local-notifications/CapacitorLocalNotifications.podspec @@ -0,0 +1,17 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'CapacitorLocalNotifications' + s.version = package['version'] + s.summary = package['description'] + s.license = package['license'] + s.homepage = package['repository']['url'] + s.author = package['author'] + s.source = { :git => package['repository']['url'], :tag => s.version.to_s } + s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}' + s.ios.deployment_target = '12.0' + s.dependency 'Capacitor' + s.swift_version = '5.1' +end diff --git a/local-notifications/LICENSE b/local-notifications/LICENSE new file mode 100644 index 000000000..6652495cb --- /dev/null +++ b/local-notifications/LICENSE @@ -0,0 +1,23 @@ +Copyright 2020-present Ionic +https://ionic.io + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/local-notifications/README.md b/local-notifications/README.md new file mode 100644 index 000000000..db8149992 --- /dev/null +++ b/local-notifications/README.md @@ -0,0 +1,512 @@ +# @capacitor/local-notifications + +The Local Notifications API provides a way to schedule device notifications locally (i.e. without a server sending push notifications). + +## Install + +```bash +npm install @capacitor/local-notifications +npx cap sync +``` + +## API + + + +* [`schedule(...)`](#schedule) +* [`getPending()`](#getpending) +* [`registerActionTypes(...)`](#registeractiontypes) +* [`cancel(...)`](#cancel) +* [`areEnabled()`](#areenabled) +* [`createChannel(...)`](#createchannel) +* [`deleteChannel(...)`](#deletechannel) +* [`listChannels()`](#listchannels) +* [`checkPermissions()`](#checkpermissions) +* [`requestPermissions()`](#requestpermissions) +* [`addListener('received', ...)`](#addlistenerreceived-) +* [`addListener('actionPerformed', ...)`](#addlisteneractionperformed-) +* [`removeAllListeners()`](#removealllisteners) +* [Interfaces](#interfaces) +* [Type Aliases](#type-aliases) + + + + + + +### schedule(...) + +```typescript +schedule(options: ScheduleOptions) => Promise +``` + +Schedule one or more local notifications. + +| Param | Type | +| ------------- | ----------------------------------------------------------- | +| **`options`** | ScheduleOptions | + +**Returns:** Promise<ScheduleResult> + +**Since:** 1.0.0 + +-------------------- + + +### getPending() + +```typescript +getPending() => Promise +``` + +Get a list of pending notifications. + +**Returns:** Promise<PendingResult> + +**Since:** 1.0.0 + +-------------------- + + +### registerActionTypes(...) + +```typescript +registerActionTypes(options: RegisterActionTypesOptions) => Promise +``` + +Register actions to take when notifications are displayed. + +Only available for iOS and Android. + +| Param | Type | +| ------------- | --------------------------------------------------------------------------------- | +| **`options`** | RegisterActionTypesOptions | + +**Since:** 1.0.0 + +-------------------- + + +### cancel(...) + +```typescript +cancel(options: CancelOptions) => Promise +``` + +Cancel pending notifications. + +| Param | Type | +| ------------- | ------------------------------------------------------- | +| **`options`** | CancelOptions | + +**Since:** 1.0.0 + +-------------------- + + +### areEnabled() + +```typescript +areEnabled() => Promise +``` + +Check if notifications are enabled or not. + +**Returns:** Promise<EnabledResult> + +**Since:** 1.0.0 + +-------------------- + + +### createChannel(...) + +```typescript +createChannel(channel: NotificationChannel) => Promise +``` + +Create a notification channel. + +Only available for Android. + +| Param | Type | +| ------------- | ------------------------------------------- | +| **`channel`** | Channel | + +**Since:** 1.0.0 + +-------------------- + + +### deleteChannel(...) + +```typescript +deleteChannel(channel: NotificationChannel) => Promise +``` + +Delete a notification channel. + +Only available for Android. + +| Param | Type | +| ------------- | ------------------------------------------- | +| **`channel`** | Channel | + +**Since:** 1.0.0 + +-------------------- + + +### listChannels() + +```typescript +listChannels() => Promise +``` + +Get a list of notification channels. + +Only available for Android. + +**Returns:** Promise<ListChannelsResult> + +**Since:** 1.0.0 + +-------------------- + + +### checkPermissions() + +```typescript +checkPermissions() => Promise +``` + +Check permission to display local notifications. + +**Returns:** Promise<PermissionStatus> + +**Since:** 1.0.0 + +-------------------- + + +### requestPermissions() + +```typescript +requestPermissions() => Promise +``` + +Request permission to display local notifications. + +**Returns:** Promise<PermissionStatus> + +**Since:** 1.0.0 + +-------------------- + + +### addListener('received', ...) + +```typescript +addListener(eventName: 'received', listenerFunc: (notification: LocalNotificationSchema) => void) => PluginListenerHandle +``` + +Listen for when notifications are displayed. + +| Param | Type | +| ------------------ | ------------------------------------------------------------------------------------------------------ | +| **`eventName`** | 'received' | +| **`listenerFunc`** | (notification: LocalNotificationSchema) => void | + +**Returns:** PluginListenerHandle + +**Since:** 1.0.0 + +-------------------- + + +### addListener('actionPerformed', ...) + +```typescript +addListener(eventName: 'actionPerformed', listenerFunc: (notificationAction: ActionPerformed) => void) => PluginListenerHandle +``` + +Listen for when an action is performed on a notification. + +| Param | Type | +| ------------------ | -------------------------------------------------------------------------------------------- | +| **`eventName`** | 'actionPerformed' | +| **`listenerFunc`** | (notificationAction: ActionPerformed) => void | + +**Returns:** PluginListenerHandle + +**Since:** 1.0.0 + +-------------------- + + +### removeAllListeners() + +```typescript +removeAllListeners() => void +``` + +Remove all listeners for this plugin. + +**Since:** 1.0.0 + +-------------------- + + +### Interfaces + + +#### ScheduleResult + +| Prop | Type | Description | Since | +| ------------------- | ------------------------------------------ | ------------------------------------ | ----- | +| **`notifications`** | LocalNotificationDescriptor[] | The list of scheduled notifications. | 1.0.0 | + + +#### LocalNotificationDescriptor + +The object that describes a local notification. + +| Prop | Type | Description | Since | +| -------- | ------------------- | ---------------------------- | ----- | +| **`id`** | string | The notification identifier. | 1.0.0 | + + +#### ScheduleOptions + +| Prop | Type | Description | Since | +| ------------------- | -------------------------------------- | -------------------------------------- | ----- | +| **`notifications`** | LocalNotificationSchema[] | The list of notifications to schedule. | 1.0.0 | + + +#### LocalNotificationSchema + +| Prop | Type | Description | Since | +| ---------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`title`** | string | The title of the notification. | 1.0.0 | +| **`body`** | string | The body of the notification, shown below the title. | 1.0.0 | +| **`id`** | number | The notification identifier. | 1.0.0 | +| **`schedule`** | Schedule | Schedule this notification for a later time. | 1.0.0 | +| **`sound`** | string | Name of the audio file to play when this notification is displayed. Include the file extension with the filename. On iOS, the file should be in the app bundle. On Android, the file should be in res/raw folder. Recommended format is `.wav` because is supported by both iOS and Android. Only available for iOS and Android 26+. | 1.0.0 | +| **`smallIcon`** | string | Set a custom status bar icon. If set, this overrides the `smallIcon` option from Capacitor configuration. Icons should be placed in your app's `res/drawable` folder. The value for this option should be the drawable resource ID, which is the filename without an extension. Only available for Android. | 1.0.0 | +| **`iconColor`** | string | Set the color of the notification icon. Only available for Android. | 1.0.0 | +| **`attachments`** | Attachment[] | Set attachments for this notification. | 1.0.0 | +| **`actionTypeId`** | string | Associate an action type with this notification. | 1.0.0 | +| **`extra`** | any | Set extra data to store within this notification. | 1.0.0 | +| **`threadIdentifier`** | string | Used to group multiple notifications. Sets `threadIdentifier` on the [`UNMutableNotificationContent`](https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent). Only available for iOS. | 1.0.0 | +| **`summaryArgument`** | string | The string this notification adds to the category's summary format string. Sets `summaryArgument` on the [`UNMutableNotificationContent`](https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent). Only available for iOS 12+. | 1.0.0 | +| **`group`** | string | Used to group multiple notifications. Calls `setGroup()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android. | 1.0.0 | +| **`groupSummary`** | boolean | If true, this notification becomes the summary for a group of notifications. Calls `setGroupSummary()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android when using `group`. | 1.0.0 | +| **`channelId`** | string | Specifies the channel the notification should be delivered on. If channel with the given name does not exist then the notification will not fire. If not provided, it will use the default channel. Calls `setChannelId()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android 26+. | 1.0.0 | +| **`ongoing`** | boolean | If true, the notification can't be swiped away. Calls `setOngoing()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android. | 1.0.0 | +| **`autoCancel`** | boolean | If true, the notification is canceled when the user clicks on it. Calls `setAutoCancel()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android. | 1.0.0 | + + +#### Schedule + +Represents a schedule for a notification. + +Use either `at`, `on`, or `every` to schedule notifications. + +| Prop | Type | Description | Since | +| ------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`at`** | Date | Schedule a notification at a specific date and time. | 1.0.0 | +| **`repeats`** | boolean | Repeat delivery of this notification at the date and time specified by `at`. Only available for iOS and Android. | 1.0.0 | +| **`on`** | { year?: number; month?: number; day?: number; hour?: number; minute?: number; } | Schedule a notification on particular interval(s). This is similar to scheduling [cron](https://en.wikipedia.org/wiki/Cron) jobs. Only available for iOS and Android. | 1.0.0 | +| **`every`** | 'year' \| 'month' \| 'two-weeks' \| 'week' \| 'day' \| 'hour' \| 'minute' \| 'second' | Schedule a notification on a particular interval. | 1.0.0 | +| **`count`** | number | Limit the number times a notification is delivered by the interval specified by `every`. | 1.0.0 | + + +#### Date + +Enables basic storage and retrieval of dates and times. + +| Method | Signature | Description | +| ---------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| **toString** | () => string | Returns a string representation of a date. The format of the string depends on the locale. | +| **toDateString** | () => string | Returns a date as a string value. | +| **toTimeString** | () => string | Returns a time as a string value. | +| **toLocaleString** | () => string | Returns a value as a string value appropriate to the host environment's current locale. | +| **toLocaleDateString** | () => string | Returns a date as a string value appropriate to the host environment's current locale. | +| **toLocaleTimeString** | () => string | Returns a time as a string value appropriate to the host environment's current locale. | +| **valueOf** | () => number | Returns the stored time value in milliseconds since midnight, January 1, 1970 UTC. | +| **getTime** | () => number | Gets the time value in milliseconds. | +| **getFullYear** | () => number | Gets the year, using local time. | +| **getUTCFullYear** | () => number | Gets the year using Universal Coordinated Time (UTC). | +| **getMonth** | () => number | Gets the month, using local time. | +| **getUTCMonth** | () => number | Gets the month of a Date object using Universal Coordinated Time (UTC). | +| **getDate** | () => number | Gets the day-of-the-month, using local time. | +| **getUTCDate** | () => number | Gets the day-of-the-month, using Universal Coordinated Time (UTC). | +| **getDay** | () => number | Gets the day of the week, using local time. | +| **getUTCDay** | () => number | Gets the day of the week using Universal Coordinated Time (UTC). | +| **getHours** | () => number | Gets the hours in a date, using local time. | +| **getUTCHours** | () => number | Gets the hours value in a Date object using Universal Coordinated Time (UTC). | +| **getMinutes** | () => number | Gets the minutes of a Date object, using local time. | +| **getUTCMinutes** | () => number | Gets the minutes of a Date object using Universal Coordinated Time (UTC). | +| **getSeconds** | () => number | Gets the seconds of a Date object, using local time. | +| **getUTCSeconds** | () => number | Gets the seconds of a Date object using Universal Coordinated Time (UTC). | +| **getMilliseconds** | () => number | Gets the milliseconds of a Date, using local time. | +| **getUTCMilliseconds** | () => number | Gets the milliseconds of a Date object using Universal Coordinated Time (UTC). | +| **getTimezoneOffset** | () => number | Gets the difference in minutes between the time on the local computer and Universal Coordinated Time (UTC). | +| **setTime** | (time: number) => number | Sets the date and time value in the Date object. | +| **setMilliseconds** | (ms: number) => number | Sets the milliseconds value in the Date object using local time. | +| **setUTCMilliseconds** | (ms: number) => number | Sets the milliseconds value in the Date object using Universal Coordinated Time (UTC). | +| **setSeconds** | (sec: number, ms?: number \| undefined) => number | Sets the seconds value in the Date object using local time. | +| **setUTCSeconds** | (sec: number, ms?: number \| undefined) => number | Sets the seconds value in the Date object using Universal Coordinated Time (UTC). | +| **setMinutes** | (min: number, sec?: number \| undefined, ms?: number \| undefined) => number | Sets the minutes value in the Date object using local time. | +| **setUTCMinutes** | (min: number, sec?: number \| undefined, ms?: number \| undefined) => number | Sets the minutes value in the Date object using Universal Coordinated Time (UTC). | +| **setHours** | (hours: number, min?: number \| undefined, sec?: number \| undefined, ms?: number \| undefined) => number | Sets the hour value in the Date object using local time. | +| **setUTCHours** | (hours: number, min?: number \| undefined, sec?: number \| undefined, ms?: number \| undefined) => number | Sets the hours value in the Date object using Universal Coordinated Time (UTC). | +| **setDate** | (date: number) => number | Sets the numeric day-of-the-month value of the Date object using local time. | +| **setUTCDate** | (date: number) => number | Sets the numeric day of the month in the Date object using Universal Coordinated Time (UTC). | +| **setMonth** | (month: number, date?: number \| undefined) => number | Sets the month value in the Date object using local time. | +| **setUTCMonth** | (month: number, date?: number \| undefined) => number | Sets the month value in the Date object using Universal Coordinated Time (UTC). | +| **setFullYear** | (year: number, month?: number \| undefined, date?: number \| undefined) => number | Sets the year of the Date object using local time. | +| **setUTCFullYear** | (year: number, month?: number \| undefined, date?: number \| undefined) => number | Sets the year value in the Date object using Universal Coordinated Time (UTC). | +| **toUTCString** | () => string | Returns a date converted to a string using Universal Coordinated Time (UTC). | +| **toISOString** | () => string | Returns a date as a string value in ISO format. | +| **toJSON** | (key?: any) => string | Used by the JSON.stringify method to enable the transformation of an object's data for JavaScript Object Notation (JSON) serialization. | + + +#### Attachment + +Represents a notification attachment. + +| Prop | Type | Description | Since | +| ------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`id`** | string | The attachment identifier. | 1.0.0 | +| **`url`** | string | The URL to the attachment. Use the `res` scheme to reference web assets, e.g. `res:///assets/img/icon.png`. Also accepts `file` URLs. | 1.0.0 | +| **`options`** | AttachmentOptions | Attachment options. | 1.0.0 | + + +#### AttachmentOptions + +| Prop | Type | Description | Since | +| ---------------------------------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`iosUNNotificationAttachmentOptionsTypeHintKey`** | string | Sets the `UNNotificationAttachmentOptionsTypeHintKey` key in the hashable options of [`UNNotificationAttachment`](https://developer.apple.com/documentation/usernotifications/unnotificationattachment). Only available for iOS. | 1.0.0 | +| **`iosUNNotificationAttachmentOptionsThumbnailHiddenKey`** | string | Sets the `UNNotificationAttachmentOptionsThumbnailHiddenKey` key in the hashable options of [`UNNotificationAttachment`](https://developer.apple.com/documentation/usernotifications/unnotificationattachment). Only available for iOS. | 1.0.0 | +| **`iosUNNotificationAttachmentOptionsThumbnailClippingRectKey`** | string | Sets the `UNNotificationAttachmentOptionsThumbnailClippingRectKey` key in the hashable options of [`UNNotificationAttachment`](https://developer.apple.com/documentation/usernotifications/unnotificationattachment). Only available for iOS. | 1.0.0 | +| **`iosUNNotificationAttachmentOptionsThumbnailTimeKey`** | string | Sets the `UNNotificationAttachmentOptionsThumbnailTimeKey` key in the hashable options of [`UNNotificationAttachment`](https://developer.apple.com/documentation/usernotifications/unnotificationattachment). Only available for iOS. | 1.0.0 | + + +#### PendingResult + +| Prop | Type | Description | Since | +| ------------------- | ------------------------------------------ | ---------------------------------- | ----- | +| **`notifications`** | LocalNotificationDescriptor[] | The list of pending notifications. | 1.0.0 | + + +#### RegisterActionTypesOptions + +| Prop | Type | Description | Since | +| ----------- | ------------------------- | ------------------------------------- | ----- | +| **`types`** | ActionType[] | The list of action types to register. | 1.0.0 | + + +#### ActionType + +A collection of actions. + +| Prop | Type | Description | Since | +| -------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`id`** | string | The ID of the action type. Referenced in notifications by the `actionTypeId` key. | 1.0.0 | +| **`actions`** | Action[] | The list of actions associated with this action type. | 1.0.0 | +| **`iosHiddenPreviewsBodyPlaceholder`** | string | Sets `hiddenPreviewsBodyPlaceholder` of the [`UNNotificationCategory`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory). Only available for iOS. | 1.0.0 | +| **`iosCustomDismissAction`** | boolean | Sets `customDismissAction` in the options of the [`UNNotificationCategory`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory). Only available for iOS. | 1.0.0 | +| **`iosAllowInCarPlay`** | boolean | Sets `allowInCarPlay` in the options of the [`UNNotificationCategory`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory). Only available for iOS. | 1.0.0 | +| **`iosHiddenPreviewsShowTitle`** | boolean | Sets `hiddenPreviewsShowTitle` in the options of the [`UNNotificationCategory`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory). Only available for iOS. | 1.0.0 | +| **`iosHiddenPreviewsShowSubtitle`** | boolean | Sets `hiddenPreviewsShowSubtitle` in the options of the [`UNNotificationCategory`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory). Only available for iOS. | 1.0.0 | + + +#### Action + +An action that can be taken when a notification is displayed. + +| Prop | Type | Description | Since | +| ---------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`id`** | string | The action identifier. Referenced in the `'actionPerformed'` event as `actionId`. | 1.0.0 | +| **`title`** | string | The title text to display for this action. | 1.0.0 | +| **`requiresAuthentication`** | boolean | Sets `authenticationRequired` in the options of the [`UNNotificationAction`](https://developer.apple.com/documentation/usernotifications/unnotificationaction). Only available for iOS. | 1.0.0 | +| **`foreground`** | boolean | Sets `foreground` in the options of the [`UNNotificationAction`](https://developer.apple.com/documentation/usernotifications/unnotificationaction). Only available for iOS. | 1.0.0 | +| **`destructive`** | boolean | Sets `destructive` in the options of the [`UNNotificationAction`](https://developer.apple.com/documentation/usernotifications/unnotificationaction). Only available for iOS. | 1.0.0 | +| **`input`** | boolean | Use a `UNTextInputNotificationAction` instead of a `UNNotificationAction`. Only available for iOS. | 1.0.0 | +| **`inputButtonTitle`** | string | Sets `textInputButtonTitle` on the [`UNTextInputNotificationAction`](https://developer.apple.com/documentation/usernotifications/untextinputnotificationaction). Only available for iOS when `input` is `true`. | 1.0.0 | +| **`inputPlaceholder`** | string | Sets `textInputPlaceholder` on the [`UNTextInputNotificationAction`](https://developer.apple.com/documentation/usernotifications/untextinputnotificationaction). Only available for iOS when `input` is `true`. | 1.0.0 | + + +#### CancelOptions + +| Prop | Type | Description | Since | +| ------------------- | ------------------------------------------ | ------------------------------------ | ----- | +| **`notifications`** | LocalNotificationDescriptor[] | The list of notifications to cancel. | 1.0.0 | + + +#### EnabledResult + +| Prop | Type | Description | Since | +| ----------- | -------------------- | ---------------------------------------------------------- | ----- | +| **`value`** | boolean | Whether or not the device has local notifications enabled. | 1.0.0 | + + +#### Channel + +| Prop | Type | Description | Since | +| ----------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`id`** | string | The channel identifier. | 1.0.0 | +| **`name`** | string | The human-friendly name of this channel (presented to the user). | 1.0.0 | +| **`description`** | string | The description of this channel (presented to the user). | 1.0.0 | +| **`sound`** | string | The sound that should be played for notifications posted to this channel. Notification channels with an importance of at least `3` should have a sound. The file name of a sound file should be specified relative to the android app `res/raw` directory. | 1.0.0 | +| **`importance`** | 1 \| 2 \| 5 \| 4 \| 3 | The level of interruption for notifications posted to this channel. | 1.0.0 | +| **`visibility`** | 0 \| 1 \| -1 | The visibility of notifications posted to this channel. This setting is for whether notifications posted to this channel appear on the lockscreen or not, and if so, whether they appear in a redacted form. | 1.0.0 | +| **`lights`** | boolean | Whether notifications posted to this channel should display notification lights, on devices that support it. | 1.0.0 | +| **`lightColor`** | string | The light color for notifications posted to this channel. Only supported if lights are enabled on this channel and the device supports it. Supported color formats are `#RRGGBB` and `#RRGGBBAA`. | 1.0.0 | +| **`vibration`** | boolean | Whether notifications posted to this channel should vibrate. | 1.0.0 | + + +#### ListChannelsResult + +| Prop | Type | Description | Since | +| -------------- | ---------------------- | ---------------------------------- | ----- | +| **`channels`** | Channel[] | The list of notification channels. | 1.0.0 | + + +#### PermissionStatus + +| Prop | Type | Description | Since | +| ------------- | ----------------------------------------------------------- | --------------------------------------------- | ----- | +| **`display`** | PermissionState | Permission state of displaying notifications. | 1.0.0 | + + +#### PluginListenerHandle + +| Prop | Type | +| ------------ | -------------------------- | +| **`remove`** | () => void | + + +#### ActionPerformed + +| Prop | Type | Description | Since | +| ------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----- | +| **`actionId`** | string | The identifier of the performed action. | 1.0.0 | +| **`inputValue`** | string | The value entered by the user on the notification. Only available on iOS for notifications with `input` set to `true`. | 1.0.0 | +| **`notification`** | LocalNotificationSchema | The original notification schema. | 1.0.0 | + + +### Type Aliases + + +#### NotificationChannel + +Channel + + +#### PermissionState + +'prompt' | 'prompt-with-rationale' | 'granted' | 'denied' + + diff --git a/local-notifications/android/.gitignore b/local-notifications/android/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/local-notifications/android/.gitignore @@ -0,0 +1 @@ +/build diff --git a/local-notifications/android/build.gradle b/local-notifications/android/build.gradle new file mode 100644 index 000000000..be79102f6 --- /dev/null +++ b/local-notifications/android/build.gradle @@ -0,0 +1,58 @@ +ext { + junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.12' + androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.1.0' + androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.1' + androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.2.0' +} + +buildscript { + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:4.1.1' + } +} + +apply plugin: 'com.android.library' + +android { + compileSdkVersion project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 29 + defaultConfig { + minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 21 + targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 29 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + lintOptions { + abortOnError false + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +repositories { + google() + jcenter() + mavenCentral() +} + + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':capacitor-android') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" +} diff --git a/local-notifications/android/gradle.properties b/local-notifications/android/gradle.properties new file mode 100644 index 000000000..0566c221d --- /dev/null +++ b/local-notifications/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true diff --git a/local-notifications/android/gradle/wrapper/gradle-wrapper.jar b/local-notifications/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..62d4c053550b91381bbd28b1afc82d634bf73a8a GIT binary patch literal 58910 zcma&ObC74zk}X`WF59+k+qTVL*+!RbS9RI8Z5v&-ZFK4Nn|tqzcjwK__x+Iv5xL`> zj94dg?X`0sMHx^qXds{;KY)OMg#H>35XgTVfq6#vc9ww|9) z@UMfwUqk)B9p!}NrNqTlRO#i!ALOPcWo78-=iy}NsAr~T8T0X0%G{DhX~u-yEwc29WQ4D zuv2j{a&j?qB4wgCu`zOXj!~YpTNFg)TWoV>DhYlR^Gp^rkOEluvxkGLB?!{fD!T@( z%3cy>OkhbIKz*R%uoKqrg1%A?)uTZD&~ssOCUBlvZhx7XHQ4b7@`&sPdT475?*zWy z>xq*iK=5G&N6!HiZaD{NSNhWL;+>Quw_#ZqZbyglna!Fqn3N!$L`=;TFPrhodD-Q` z1l*=DP2gKJP@)cwI@-M}?M$$$%u~=vkeC%>cwR$~?y6cXx-M{=wdT4|3X(@)a|KkZ z`w$6CNS@5gWS7s7P86L<=vg$Mxv$?)vMj3`o*7W4U~*Nden}wz=y+QtuMmZ{(Ir1D zGp)ZsNiy{mS}Au5;(fYf93rs^xvi(H;|H8ECYdC`CiC&G`zw?@)#DjMc7j~daL_A$ z7e3nF2$TKlTi=mOftyFBt8*Xju-OY@2k@f3YBM)-v8+5_o}M?7pxlNn)C0Mcd@87?+AA4{Ti2ptnYYKGp`^FhcJLlT%RwP4k$ad!ho}-^vW;s{6hnjD0*c39k zrm@PkI8_p}mnT&5I@=O1^m?g}PN^8O8rB`;t`6H+?Su0IR?;8txBqwK1Au8O3BZAX zNdJB{bpQWR@J|e=Z>XSXV1DB{uhr3pGf_tb)(cAkp)fS7*Qv))&Vkbb+cvG!j}ukd zxt*C8&RN}5ck{jkw0=Q7ldUp0FQ&Pb_$M7a@^nf`8F%$ftu^jEz36d#^M8Ia{VaTy z5(h$I)*l3i!VpPMW+XGgzL~fcN?{~1QWu9!Gu0jOWWE zNW%&&by0DbXL&^)r-A*7R@;T$P}@3eOj#gqJ!uvTqBL5bupU91UK#d|IdxBUZAeh1 z>rAI#*Y4jv>uhOh7`S@mnsl0g@1C;k$Z%!d*n8#_$)l}-1&z2kr@M+xWoKR z!KySy-7h&Bf}02%JeXmQGjO3ntu={K$jy$rFwfSV8!zqAL_*&e2|CJ06`4&0+ceI026REfNT>JzAdwmIlKLEr2? zaZ#d*XFUN*gpzOxq)cysr&#6zNdDDPH% zd8_>3B}uA7;bP4fKVdd~Og@}dW#74ceETOE- zlZgQqQfEc?-5ly(Z5`L_CCM!&Uxk5#wgo=OLs-kFHFG*cTZ)$VE?c_gQUW&*!2@W2 z7Lq&_Kf88OCo?BHCtwe*&fu&8PQ(R5&lnYo8%+U73U)Ec2&|A)Y~m7(^bh299REPe zn#gyaJ4%o4>diN3z%P5&_aFUmlKytY$t21WGwx;3?UC}vlxi-vdEQgsKQ;=#sJ#ll zZeytjOad$kyON4XxC}frS|Ybh`Yq!<(IrlOXP3*q86ImyV*mJyBn$m~?#xp;EplcM z+6sez%+K}Xj3$YN6{}VL;BZ7Fi|iJj-ywlR+AP8lq~mnt5p_%VmN{Sq$L^z!otu_u znVCl@FgcVXo510e@5(wnko%Pv+^r^)GRh;>#Z(|#cLnu_Y$#_xG&nvuT+~gzJsoSi zBvX`|IS~xaold!`P!h(v|=>!5gk)Q+!0R1Ge7!WpRP{*Ajz$oGG$_?Ajvz6F0X?809o`L8prsJ*+LjlGfSziO;+ zv>fyRBVx#oC0jGK8$%$>Z;0+dfn8x;kHFQ?Rpi7(Rc{Uq{63Kgs{IwLV>pDK7yX-2 zls;?`h!I9YQVVbAj7Ok1%Y+F?CJa-Jl>1x#UVL(lpzBBH4(6v0^4 z3Tf`INjml5`F_kZc5M#^J|f%7Hgxg3#o}Zwx%4l9yYG!WaYUA>+dqpRE3nw#YXIX%= ziH3iYO~jr0nP5xp*VIa#-aa;H&%>{mfAPPlh5Fc!N7^{!z$;p-p38aW{gGx z)dFS62;V;%%fKp&i@+5x=Cn7Q>H`NofJGXmNeh{sOL+Nk>bQJJBw3K*H_$}%*xJM=Kh;s#$@RBR z|75|g85da@#qT=pD777m$wI!Q8SC4Yw3(PVU53bzzGq$IdGQoFb-c_(iA_~qD|eAy z@J+2!tc{|!8fF;%6rY9`Q!Kr>MFwEH%TY0y>Q(D}xGVJM{J{aGN0drG&|1xO!Ttdw z-1^gQ&y~KS5SeslMmoA$Wv$ly={f}f9<{Gm!8ycp*D9m*5Ef{ymIq!MU01*)#J1_! zM_i4{LYButqlQ>Q#o{~W!E_#(S=hR}kIrea_67Z5{W>8PD>g$f;dTvlD=X@T$8D0;BWkle@{VTd&D5^)U>(>g(jFt4lRV6A2(Te->ooI{nk-bZ(gwgh zaH4GT^wXPBq^Gcu%xW#S#p_&x)pNla5%S5;*OG_T^PhIIw1gXP&u5c;{^S(AC*+$> z)GuVq(FT@zq9;i{*9lEsNJZ)??BbSc5vF+Kdh-kL@`(`l5tB4P!9Okin2!-T?}(w% zEpbEU67|lU#@>DppToestmu8Ce=gz=e#V+o)v)#e=N`{$MI5P0O)_fHt1@aIC_QCv=FO`Qf=Ga%^_NhqGI)xtN*^1n{ z&vgl|TrKZ3Vam@wE0p{c3xCCAl+RqFEse@r*a<3}wmJl-hoJoN<|O2zcvMRl<#BtZ z#}-bPCv&OTw`GMp&n4tutf|er`@#d~7X+);##YFSJ)BitGALu}-N*DJdCzs(cQ?I- z6u(WAKH^NUCcOtpt5QTsQRJ$}jN28ZsYx+4CrJUQ%egH zo#tMoywhR*oeIkS%}%WUAIbM`D)R6Ya&@sZvvUEM7`fR0Ga03*=qaEGq4G7-+30Ck zRkje{6A{`ebq?2BTFFYnMM$xcQbz0nEGe!s%}O)m={`075R0N9KTZ>vbv2^eml>@}722%!r#6Wto}?vNst? zs`IasBtcROZG9+%rYaZe^=5y3chDzBf>;|5sP0!sP(t^= z^~go8msT@|rp8LJ8km?4l?Hb%o10h7(ixqV65~5Y>n_zG3AMqM3UxUNj6K-FUgMT7 z*Dy2Y8Ws+%`Z*~m9P zCWQ8L^kA2$rf-S@qHow$J86t)hoU#XZ2YK~9GXVR|*`f6`0&8j|ss_Ai-x=_;Df^*&=bW$1nc{Gplm zF}VF`w)`5A;W@KM`@<9Bw_7~?_@b{Z`n_A6c1AG#h#>Z$K>gX6reEZ*bZRjCup|0# zQ{XAb`n^}2cIwLTN%5Ix`PB*H^(|5S{j?BwItu+MS`1)VW=TnUtt6{3J!WR`4b`LW z?AD#ZmoyYpL=903q3LSM=&5eNP^dwTDRD~iP=}FXgZ@2WqfdyPYl$9do?wX{RU*$S zgQ{OqXK-Yuf4+}x6P#A*la&^G2c2TC;aNNZEYuB(f25|5eYi|rd$;i0qk7^3Ri8of ziP~PVT_|4$n!~F-B1_Et<0OJZ*e+MN;5FFH`iec(lHR+O%O%_RQhvbk-NBQ+$)w{D+dlA0jxI;z|P zEKW`!X)${xzi}Ww5G&@g0akBb_F`ziv$u^hs0W&FXuz=Ap>SUMw9=M?X$`lgPRq11 zqq+n44qL;pgGO+*DEc+Euv*j(#%;>p)yqdl`dT+Og zZH?FXXt`<0XL2@PWYp|7DWzFqxLK)yDXae&3P*#+f+E{I&h=$UPj;ey9b`H?qe*Oj zV|-qgI~v%&oh7rzICXfZmg$8$B|zkjliQ=e4jFgYCLR%yi!9gc7>N z&5G#KG&Hr+UEfB;M(M>$Eh}P$)<_IqC_WKOhO4(cY@Gn4XF(#aENkp&D{sMQgrhDT zXClOHrr9|POHqlmm+*L6CK=OENXbZ+kb}t>oRHE2xVW<;VKR@ykYq04LM9L-b;eo& zl!QQo!Sw{_$-qosixZJWhciN>Gbe8|vEVV2l)`#5vKyrXc6E`zmH(76nGRdL)pqLb@j<&&b!qJRLf>d`rdz}^ZSm7E;+XUJ ziy;xY&>LM?MA^v0Fu8{7hvh_ynOls6CI;kQkS2g^OZr70A}PU;i^~b_hUYN1*j-DD zn$lHQG9(lh&sDii)ip*{;Sb_-Anluh`=l~qhqbI+;=ZzpFrRp&T+UICO!OoqX@Xr_ z32iJ`xSpx=lDDB_IG}k+GTYG@K8{rhTS)aoN8D~Xfe?ul&;jv^E;w$nhu-ICs&Q)% zZ=~kPNZP0-A$pB8)!`TEqE`tY3Mx^`%O`?EDiWsZpoP`e-iQ#E>fIyUx8XN0L z@S-NQwc;0HjSZKWDL}Au_Zkbh!juuB&mGL0=nO5)tUd_4scpPy&O7SNS^aRxUy0^< zX}j*jPrLP4Pa0|PL+nrbd4G;YCxCK-=G7TG?dby~``AIHwxqFu^OJhyIUJkO0O<>_ zcpvg5Fk$Wpj}YE3;GxRK67P_Z@1V#+pu>pRj0!mFf(m_WR3w3*oQy$s39~U7Cb}p(N&8SEwt+)@%o-kW9Ck=^?tvC2$b9% ze9(Jn+H`;uAJE|;$Flha?!*lJ0@lKfZM>B|c)3lIAHb;5OEOT(2453m!LgH2AX=jK zQ93An1-#l@I@mwB#pLc;M7=u6V5IgLl>E%gvE|}Hvd4-bE1>gs(P^C}gTv*&t>W#+ zASLRX$y^DD3Jrht zwyt`yuA1j(TcP*0p*Xkv>gh+YTLrcN_HuaRMso~0AJg`^nL#52dGBzY+_7i)Ud#X) zVwg;6$WV20U2uyKt8<)jN#^1>PLg`I`@Mmut*Zy!c!zshSA!e^tWVoKJD%jN&ml#{ z@}B$j=U5J_#rc%T7(DGKF+WwIblEZ;Vq;CsG~OKxhWYGJx#g7fxb-_ya*D0=_Ys#f zhXktl=Vnw#Z_neW>Xe#EXT(4sT^3p6srKby4Ma5LLfh6XrHGFGgM;5Z}jv-T!f~=jT&n>Rk z4U0RT-#2fsYCQhwtW&wNp6T(im4dq>363H^ivz#>Sj;TEKY<)dOQU=g=XsLZhnR>e zd}@p1B;hMsL~QH2Wq>9Zb; zK`0`09fzuYg9MLJe~cdMS6oxoAD{kW3sFAqDxvFM#{GpP^NU@9$d5;w^WgLYknCTN z0)N425mjsJTI@#2kG-kB!({*+S(WZ-{SckG5^OiyP%(6DpRsx60$H8M$V65a_>oME z^T~>oG7r!ew>Y)&^MOBrgc-3PezgTZ2xIhXv%ExMFgSf5dQbD=Kj*!J4k^Xx!Z>AW ziZfvqJvtm|EXYsD%A|;>m1Md}j5f2>kt*gngL=enh<>#5iud0dS1P%u2o+>VQ{U%(nQ_WTySY(s#~~> zrTsvp{lTSup_7*Xq@qgjY@1#bisPCRMMHnOL48qi*jQ0xg~TSW%KMG9zN1(tjXix()2$N}}K$AJ@GUth+AyIhH6Aeh7qDgt#t*`iF5#A&g4+ zWr0$h9Zx6&Uo2!Ztcok($F>4NA<`dS&Js%L+67FT@WmI)z#fF~S75TUut%V($oUHw z$IJsL0X$KfGPZYjB9jaj-LaoDD$OMY4QxuQ&vOGo?-*9@O!Nj>QBSA6n$Lx|^ zky)4+sy{#6)FRqRt6nM9j2Lzba!U;aL%ZcG&ki1=3gFx6(&A3J-oo|S2_`*w9zT)W z4MBOVCp}?4nY)1))SOX#6Zu0fQQ7V{RJq{H)S#;sElY)S)lXTVyUXTepu4N)n85Xo zIpWPT&rgnw$D2Fsut#Xf-hO&6uA0n~a;a3!=_!Tq^TdGE&<*c?1b|PovU}3tfiIUu z){4W|@PY}zJOXkGviCw^x27%K_Fm9GuKVpd{P2>NJlnk^I|h2XW0IO~LTMj>2<;S* zZh2uRNSdJM$U$@=`zz}%;ucRx{aKVxxF7?0hdKh6&GxO6f`l2kFncS3xu0Ly{ew0& zeEP*#lk-8-B$LD(5yj>YFJ{yf5zb41PlW7S{D9zC4Aa4nVdkDNH{UsFJp)q-`9OYt zbOKkigbmm5hF?tttn;S4g^142AF^`kiLUC?e7=*JH%Qe>uW=dB24NQa`;lm5yL>Dyh@HbHy-f%6Vz^ zh&MgwYsh(z#_fhhqY$3*f>Ha}*^cU-r4uTHaT?)~LUj5``FcS46oyoI5F3ZRizVD% zPFY(_S&5GN8$Nl2=+YO6j4d|M6O7CmUyS&}m4LSn6}J`$M0ZzT&Ome)ZbJDFvM&}A zZdhDn(*viM-JHf84$!I(8eakl#zRjJH4qfw8=60 z11Ely^FyXjVvtv48-Fae7p=adlt9_F^j5#ZDf7)n!#j?{W?@j$Pi=k`>Ii>XxrJ?$ z^bhh|X6qC8d{NS4rX5P!%jXy=>(P+r9?W(2)|(=a^s^l~x*^$Enw$~u%WRuRHHFan{X|S;FD(Mr z@r@h^@Bs#C3G;~IJMrERd+D!o?HmFX&#i|~q(7QR3f8QDip?ms6|GV_$86aDb|5pc?_-jo6vmWqYi{P#?{m_AesA4xX zi&ki&lh0yvf*Yw~@jt|r-=zpj!bw<6zI3Aa^Wq{|*WEC}I=O!Re!l~&8|Vu<$yZ1p zs-SlwJD8K!$(WWyhZ+sOqa8cciwvyh%zd`r$u;;fsHn!hub0VU)bUv^QH?x30#;tH zTc_VbZj|prj7)d%ORU;Vs{#ERb>K8>GOLSImnF7JhR|g$7FQTU{(a7RHQ*ii-{U3X z^7+vM0R$8b3k1aSU&kxvVPfOz3~)0O2iTYinV9_5{pF18j4b{o`=@AZIOAwwedB2@ ztXI1F04mg{<>a-gdFoRjq$6#FaevDn$^06L)k%wYq03&ysdXE+LL1#w$rRS1Y;BoS zH1x}{ms>LHWmdtP(ydD!aRdAa(d@csEo z0EF9L>%tppp`CZ2)jVb8AuoYyu;d^wfje6^n6`A?6$&%$p>HcE_De-Zh)%3o5)LDa zskQ}%o7?bg$xUj|n8gN9YB)z!N&-K&!_hVQ?#SFj+MpQA4@4oq!UQ$Vm3B`W_Pq3J z=ngFP4h_y=`Iar<`EESF9){%YZVyJqLPGq07TP7&fSDmnYs2NZQKiR%>){imTBJth zPHr@p>8b+N@~%43rSeNuOz;rgEm?14hNtI|KC6Xz1d?|2J`QS#`OW7gTF_;TPPxu@ z)9J9>3Lx*bc>Ielg|F3cou$O0+<b34_*ZJhpS&$8DP>s%47a)4ZLw`|>s=P_J4u z?I_%AvR_z8of@UYWJV?~c4Yb|A!9n!LEUE6{sn@9+D=0w_-`szJ_T++x3MN$v-)0d zy`?1QG}C^KiNlnJBRZBLr4G~15V3$QqC%1G5b#CEB0VTr#z?Ug%Jyv@a`QqAYUV~^ zw)d|%0g&kl{j#FMdf$cn(~L@8s~6eQ)6{`ik(RI(o9s0g30Li{4YoxcVoYd+LpeLz zai?~r)UcbYr@lv*Z>E%BsvTNd`Sc?}*}>mzJ|cr0Y(6rA7H_6&t>F{{mJ^xovc2a@ zFGGDUcGgI-z6H#o@Gj29C=Uy{wv zQHY2`HZu8+sBQK*_~I-_>fOTKEAQ8_Q~YE$c?cSCxI;vs-JGO`RS464Ft06rpjn+a zqRS0Y3oN(9HCP@{J4mOWqIyD8PirA!pgU^Ne{LHBG;S*bZpx3|JyQDGO&(;Im8!ed zNdpE&?3U?E@O~>`@B;oY>#?gXEDl3pE@J30R1;?QNNxZ?YePc)3=NS>!STCrXu*lM z69WkLB_RBwb1^-zEm*tkcHz3H;?v z;q+x0Jg$|?5;e1-kbJnuT+^$bWnYc~1qnyVTKh*cvM+8yJT-HBs1X@cD;L$su65;i z2c1MxyL~NuZ9+)hF=^-#;dS#lFy^Idcb>AEDXu1!G4Kd8YPy~0lZz$2gbv?su}Zn} zGtIbeYz3X8OA9{sT(aleold_?UEV{hWRl(@)NH6GFH@$<8hUt=dNte%e#Jc>7u9xi zuqv!CRE@!fmZZ}3&@$D>p0z=*dfQ_=IE4bG0hLmT@OP>x$e`qaqf_=#baJ8XPtOpWi%$ep1Y)o2(sR=v)M zt(z*pGS$Z#j_xq_lnCr+x9fwiT?h{NEn#iK(o)G&Xw-#DK?=Ms6T;%&EE${Gq_%99 z6(;P~jPKq9llc+cmI(MKQ6*7PcL)BmoI}MYFO)b3-{j>9FhNdXLR<^mnMP`I7z0v` zj3wxcXAqi4Z0kpeSf>?V_+D}NULgU$DBvZ^=0G8Bypd7P2>;u`yW9`%4~&tzNJpgp zqB+iLIM~IkB;ts!)exn643mAJ8-WlgFE%Rpq!UMYtB?$5QAMm)%PT0$$2{>Yu7&U@ zh}gD^Qdgu){y3ANdB5{75P;lRxSJPSpQPMJOiwmpMdT|?=q;&$aTt|dl~kvS z+*i;6cEQJ1V`R4Fd>-Uzsc=DPQ7A7#VPCIf!R!KK%LM&G%MoZ0{-8&99H!|UW$Ejv zhDLX3ESS6CgWTm#1ZeS2HJb`=UM^gsQ84dQpX(ESWSkjn>O zVxg%`@mh(X9&&wN$lDIc*@>rf?C0AD_mge3f2KkT6kGySOhXqZjtA?5z`vKl_{(5g z&%Y~9p?_DL{+q@siT~*3Q*$nWXQfNN;%s_eHP_A;O`N`SaoB z6xYR;z_;HQ2xAa9xKgx~2f2xEKiEDpGPH1d@||v#f#_Ty6_gY>^oZ#xac?pc-F`@ z*}8sPV@xiz?efDMcmmezYVw~qw=vT;G1xh+xRVBkmN66!u(mRG3G6P#v|;w@anEh7 zCf94arw%YB*=&3=RTqX?z4mID$W*^+&d6qI*LA-yGme;F9+wTsNXNaX~zl2+qIK&D-aeN4lr0+yP;W>|Dh?ms_ogT{DT+ ztXFy*R7j4IX;w@@R9Oct5k2M%&j=c_rWvoul+` z<18FH5D@i$P38W9VU2(EnEvlJ(SHCqTNBa)brkIjGP|jCnK&Qi%97tikU}Y#3L?s! z2ujL%YiHO-#!|g5066V01hgT#>fzls7P>+%D~ogOT&!Whb4iF=CnCto82Yb#b`YoVsj zS2q^W0Rj!RrM@=_GuPQy5*_X@Zmu`TKSbqEOP@;Ga&Rrr>#H@L41@ZX)LAkbo{G8+ z;!5EH6vv-ip0`tLB)xUuOX(*YEDSWf?PIxXe`+_B8=KH#HFCfthu}QJylPMTNmoV; zC63g%?57(&osaH^sxCyI-+gwVB|Xs2TOf=mgUAq?V~N_5!4A=b{AXbDae+yABuuu3B_XSa4~c z1s-OW>!cIkjwJf4ZhvT|*IKaRTU)WAK=G|H#B5#NB9<{*kt?7`+G*-^<)7$Iup@Um z7u*ABkG3F*Foj)W9-I&@BrN8(#$7Hdi`BU#SR1Uz4rh&=Ey!b76Qo?RqBJ!U+rh(1 znw@xw5$)4D8OWtB_^pJO*d~2Mb-f~>I!U#*=Eh*xa6$LX?4Evp4%;ENQR!mF4`f7F zpG!NX=qnCwE8@NAbQV`*?!v0;NJ(| zBip8}VgFVsXFqslXUV>_Z>1gmD(7p#=WACXaB|Y`=Kxa=p@_ALsL&yAJ`*QW^`2@% zW7~Yp(Q@ihmkf{vMF?kqkY%SwG^t&CtfRWZ{syK@W$#DzegcQ1>~r7foTw3^V1)f2Tq_5f$igmfch;8 zT-<)?RKcCdQh6x^mMEOS;4IpQ@F2q-4IC4%*dU@jfHR4UdG>Usw4;7ESpORL|2^#jd+@zxz{(|RV*1WKrw-)ln*8LnxVkKDfGDHA%7`HaiuvhMu%*mY9*Ya{Ti#{DW?i0 zXXsp+Bb(_~wv(3t70QU3a$*<$1&zm1t++x#wDLCRI4K)kU?Vm9n2c0m@TyUV&&l9%}fulj!Z9)&@yIcQ3gX}l0b1LbIh4S z5C*IDrYxR%qm4LVzSk{0;*npO_SocYWbkAjA6(^IAwUnoAzw_Uo}xYFo?Y<-4Zqec z&k7HtVlFGyt_pA&kX%P8PaRD8y!Wsnv}NMLNLy-CHZf(ObmzV|t-iC#@Z9*d-zUsx zxcYWw{H)nYXVdnJu5o-U+fn~W z-$h1ax>h{NlWLA7;;6TcQHA>UJB$KNk74T1xNWh9)kwK~wX0m|Jo_Z;g;>^E4-k4R zRj#pQb-Hg&dAh}*=2;JY*aiNZzT=IU&v|lQY%Q|=^V5pvTR7^t9+@+ST&sr!J1Y9a z514dYZn5rg6@4Cy6P`-?!3Y& z?B*5zw!mTiD2)>f@3XYrW^9V-@%YFkE_;PCyCJ7*?_3cR%tHng9%ZpIU}LJM=a+0s z(SDDLvcVa~b9O!cVL8)Q{d^R^(bbG=Ia$)dVN_tGMee3PMssZ7Z;c^Vg_1CjZYTnq z)wnF8?=-MmqVOMX!iE?YDvHCN?%TQtKJMFHp$~kX4}jZ;EDqP$?jqJZjoa2PM@$uZ zF4}iab1b5ep)L;jdegC3{K4VnCH#OV;pRcSa(&Nm50ze-yZ8*cGv;@+N+A?ncc^2z9~|(xFhwOHmPW@ zR5&)E^YKQj@`g=;zJ_+CLamsPuvppUr$G1#9urUj+p-mPW_QSSHkPMS!52t>Hqy|g z_@Yu3z%|wE=uYq8G>4`Q!4zivS}+}{m5Zjr7kMRGn_p&hNf|pc&f9iQ`^%78rl#~8 z;os@rpMA{ZioY~(Rm!Wf#Wx##A0PthOI341QiJ=G*#}pDAkDm+{0kz&*NB?rC0-)glB{0_Tq*^o zVS1>3REsv*Qb;qg!G^9;VoK)P*?f<*H&4Su1=}bP^Y<2PwFpoqw#up4IgX3L z`w~8jsFCI3k~Y9g(Y9Km`y$0FS5vHb)kb)Jb6q-9MbO{Hbb zxg?IWQ1ZIGgE}wKm{axO6CCh~4DyoFU+i1xn#oyfe+<{>=^B5tm!!*1M?AW8c=6g+%2Ft97_Hq&ZmOGvqGQ!Bn<_Vw`0DRuDoB6q8ME<;oL4kocr8E$NGoLI zXWmI7Af-DR|KJw!vKp2SI4W*x%A%5BgDu%8%Iato+pWo5`vH@!XqC!yK}KLzvfS(q z{!y(S-PKbk!qHsgVyxKsQWk_8HUSSmslUA9nWOjkKn0%cwn%yxnkfxn?Y2rysXKS=t-TeI%DN$sQ{lcD!(s>(4y#CSxZ4R} zFDI^HPC_l?uh_)-^ppeYRkPTPu~V^0Mt}#jrTL1Q(M;qVt4zb(L|J~sxx7Lva9`mh zz!#A9tA*6?q)xThc7(gB2Ryam$YG4qlh00c}r&$y6u zIN#Qxn{7RKJ+_r|1G1KEv!&uKfXpOVZ8tK{M775ws%nDyoZ?bi3NufNbZs)zqXiqc zqOsK@^OnlFMAT&mO3`@3nZP$3lLF;ds|;Z{W(Q-STa2>;)tjhR17OD|G>Q#zJHb*> zMO<{WIgB%_4MG0SQi2;%f0J8l_FH)Lfaa>*GLobD#AeMttYh4Yfg22@q4|Itq};NB z8;o*+@APqy@fPgrc&PTbGEwdEK=(x5K!If@R$NiO^7{#j9{~w=RBG)ZkbOw@$7Nhl zyp{*&QoVBd5lo{iwl2gfyip@}IirZK;ia(&ozNl!-EEYc=QpYH_= zJkv7gA{!n4up6$CrzDJIBAdC7D5D<_VLH*;OYN>_Dx3AT`K4Wyx8Tm{I+xplKP6k7 z2sb!i7)~%R#J0$|hK?~=u~rnH7HCUpsQJujDDE*GD`qrWWog+C+E~GGy|Hp_t4--} zrxtrgnPh}r=9o}P6jpAQuDN}I*GI`8&%Lp-C0IOJt#op)}XSr!ova@w{jG2V=?GXl3zEJJFXg)U3N>BQP z*Lb@%Mx|Tu;|u>$-K(q^-HG!EQ3o93%w(A7@ngGU)HRWoO&&^}U$5x+T&#zri>6ct zXOB#EF-;z3j311K`jrYyv6pOPF=*`SOz!ack=DuEi({UnAkL5H)@R?YbRKAeP|06U z?-Ns0ZxD0h9D8)P66Sq$w-yF+1hEVTaul%&=kKDrQtF<$RnQPZ)ezm1`aHIjAY=!S z`%vboP`?7mItgEo4w50C*}Ycqp9_3ZEr^F1;cEhkb`BNhbc6PvnXu@wi=AoezF4~K zkxx%ps<8zb=wJ+9I8o#do)&{(=yAlNdduaDn!=xGSiuo~fLw~Edw$6;l-qaq#Z7?# zGrdU(Cf-V@$x>O%yRc6!C1Vf`b19ly;=mEu8u9|zitcG^O`lbNh}k=$%a)UHhDwTEKis2yc4rBGR>l*(B$AC7ung&ssaZGkY-h(fpwcPyJSx*9EIJMRKbMP9}$nVrh6$g-Q^5Cw)BeWqb-qi#37ZXKL!GR;ql)~ z@PP*-oP?T|ThqlGKR84zi^CN z4TZ1A)7vL>ivoL2EU_~xl-P{p+sE}9CRwGJDKy{>0KP+gj`H9C+4fUMPnIB1_D`A- z$1`G}g0lQmqMN{Y&8R*$xYUB*V}dQPxGVZQ+rH!DVohIoTbh%#z#Tru%Px@C<=|og zGDDwGq7yz`%^?r~6t&>x*^We^tZ4!E4dhwsht#Pb1kCY{q#Kv;z%Dp#Dq;$vH$-(9 z8S5tutZ}&JM2Iw&Y-7KY4h5BBvS=Ove0#+H2qPdR)WyI zYcj)vB=MA{7T|3Ij_PN@FM@w(C9ANBq&|NoW30ccr~i#)EcH)T^3St~rJ0HKKd4wr z@_+132;Bj+>UC@h)Ap*8B4r5A1lZ!Dh%H7&&hBnlFj@eayk=VD*i5AQc z$uN8YG#PL;cuQa)Hyt-}R?&NAE1QT>svJDKt*)AQOZAJ@ zyxJoBebiobHeFlcLwu_iI&NEZuipnOR;Tn;PbT1Mt-#5v5b*8ULo7m)L-eti=UcGf zRZXidmxeFgY!y80-*PH-*=(-W+fK%KyUKpg$X@tuv``tXj^*4qq@UkW$ZrAo%+hay zU@a?z&2_@y)o@D!_g>NVxFBO!EyB&6Z!nd4=KyDP^hl!*(k{dEF6@NkXztO7gIh zQ&PC+p-8WBv;N(rpfKdF^@Z~|E6pa)M1NBUrCZvLRW$%N%xIbv^uv?=C!=dDVq3%* zgvbEBnG*JB*@vXx8>)7XL*!{1Jh=#2UrByF7U?Rj_}VYw88BwqefT_cCTv8aTrRVjnn z1HNCF=44?*&gs2`vCGJVHX@kO z240eo#z+FhI0=yy6NHQwZs}a+J~4U-6X`@ zZ7j+tb##m`x%J66$a9qXDHG&^kp|GkFFMmjD(Y-k_ClY~N$H|n@NkSDz=gg?*2ga5 z)+f)MEY>2Lp15;~o`t`qj;S>BaE;%dv@Ux11yq}I(k|o&`5UZFUHn}1kE^gIK@qV& z!S2IhyU;->VfA4Qb}m7YnkIa9%z{l~iPWo2YPk-`hy2-Eg=6E$21plQA5W2qMZDFU z-a-@Dndf%#on6chT`dOKnU9}BJo|kJwgGC<^nfo34zOKH96LbWY7@Wc%EoFF=}`VU zksP@wd%@W;-p!e^&-)N7#oR331Q)@9cx=mOoU?_Kih2!Le*8fhsZ8Qvo6t2vt+UOZ zw|mCB*t2%z21YqL>whu!j?s~}-L`OS+jdg1(XnmYw$rg~r(?5Y+qTg`$F}q3J?GtL z@BN&8#`u2RqkdG4yGGTus@7U_%{6C{XAhFE!2SelH?KtMtX@B1GBhEIDL-Bj#~{4! zd}p7!#XE9Lt;sy@p5#Wj*jf8zGv6tTotCR2X$EVOOup;GnRPRVU5A6N@Lh8?eA7k? zn~hz&gY;B0ybSpF?qwQ|sv_yO=8}zeg2$0n3A8KpE@q26)?707pPw?H76lCpjp=5r z6jjp|auXJDnW}uLb6d7rsxekbET9(=zdTqC8(F5@NNqII2+~yB;X5iJNQSiv`#ozm zf&p!;>8xAlwoxUC3DQ#!31ylK%VrcwS<$WeCY4V63V!|221oj+5#r}fGFQ}|uwC0) zNl8(CF}PD`&Sj+p{d!B&&JtC+VuH z#>US`)YQrhb6lIAYb08H22y(?)&L8MIQsA{26X`R5Km{YU)s!x(&gIsjDvq63@X`{ z=7{SiH*_ZsPME#t2m|bS76Uz*z{cpp1m|s}HIX}Ntx#v7Eo!1%G9__4dGSGl`p+xi zZ!VK#Qe;Re=9bqXuW+0DSP{uZ5-QXrNn-7qW19K0qU}OhVru7}3vqsG?#D67 zb}crN;QwsH*vymw(maZr_o|w&@sQki(X+D)gc5Bt&@iXisFG;eH@5d43~Wxq|HO(@ zV-rip4n#PEkHCWCa5d?@cQp^B;I-PzOfag|t-cuvTapQ@MWLmh*41NH`<+A+JGyKX zyYL6Ba7qqa5j@3lOk~`OMO7f0!@FaOeZxkbG@vXP(t3#U*fq8=GAPqUAS>vW2uxMk{a(<0=IxB;# zMW;M+owrHaZBp`3{e@7gJCHP!I(EeyGFF;pdFPdeP+KphrulPSVidmg#!@W`GpD&d z9p6R`dpjaR2E1Eg)Ws{BVCBU9-aCgN57N~uLvQZH`@T+2eOBD%73rr&sV~m#2~IZx zY_8f8O;XLu2~E3JDXnGhFvsyb^>*!D>5EtlKPe%kOLv6*@=Jpci`8h0z?+fbBUg_7 zu6DjqO=$SjAv{|Om5)nz41ZkS4E_|fk%NDY509VV5yNeo%O|sb>7C#wj8mL9cEOFh z>nDz%?vb!h*!0dHdnxDA>97~EoT~!N40>+)G2CeYdOvJr5^VnkGz)et&T9hrD(VAgCAJjQ7V$O?csICB*HFd^k@$M5*v$PZJD-OVL?Ze(U=XGqZPVG8JQ z<~ukO%&%nNXYaaRibq#B1KfW4+XMliC*Tng2G(T1VvP;2K~;b$EAqthc${gjn_P!b zs62UT(->A>!ot}cJXMZHuy)^qfqW~xO-In2);e>Ta{LD6VG2u&UT&a@>r-;4<)cJ9 zjpQThb4^CY)Ev0KR7TBuT#-v}W?Xzj{c7$S5_zJA57Qf=$4^npEjl9clH0=jWO8sX z3Fuu0@S!WY>0XX7arjH`?)I<%2|8HfL!~#c+&!ZVmhbh`wbzy0Ux|Jpy9A{_7GGB0 zadZ48dW0oUwUAHl%|E-Q{gA{z6TXsvU#Hj09<7i)d}wa+Iya)S$CVwG{4LqtB>w%S zKZx(QbV7J9pYt`W4+0~f{hoo5ZG<0O&&5L57oF%hc0xGJ@Zrg_D&lNO=-I^0y#3mxCSZFxN2-tN_mU@7<@PnWG?L5OSqkm8TR!`| zRcTeWH~0z1JY^%!N<(TtxSP5^G9*Vw1wub`tC-F`=U)&sJVfvmh#Pi`*44kSdG};1 zJbHOmy4Ot|%_?@$N?RA9fF?|CywR8Sf(SCN_luM8>(u0NSEbKUy7C(Sk&OuWffj)f za`+mo+kM_8OLuCUiA*CNE|?jra$M=$F3t+h-)?pXz&r^F!ck;r##`)i)t?AWq-9A9 zSY{m~TC1w>HdEaiR*%j)L);H{IULw)uxDO>#+WcBUe^HU)~L|9#0D<*Ld459xTyew zbh5vCg$a>`RCVk)#~ByCv@Ce!nm<#EW|9j><#jQ8JfTmK#~jJ&o0Fs9jz0Ux{svdM4__<1 zrb>H(qBO;v(pXPf5_?XDq!*3KW^4>(XTo=6O2MJdM^N4IIcYn1sZZpnmMAEdt}4SU zPO54j2d|(xJtQ9EX-YrlXU1}6*h{zjn`in-N!Ls}IJsG@X&lfycsoCemt_Ym(PXhv zc*QTnkNIV=Ia%tg%pwJtT^+`v8ng>;2~ps~wdqZSNI7+}-3r+#r6p`8*G;~bVFzg= z!S3&y)#iNSUF6z;%o)%h!ORhE?CUs%g(k2a-d576uOP2@QwG-6LT*G!I$JQLpd`cz z-2=Brr_+z96a0*aIhY2%0(Sz=|D`_v_7h%Yqbw2)8@1DwH4s*A82krEk{ zoa`LbCdS)R?egRWNeHV8KJG0Ypy!#}kslun?67}^+J&02!D??lN~t@;h?GS8#WX`)6yC**~5YNhN_Hj}YG<%2ao^bpD8RpgV|V|GQwlL27B zEuah|)%m1s8C6>FLY0DFe9Ob66fo&b8%iUN=y_Qj;t3WGlNqP9^d#75ftCPA*R4E8 z)SWKBKkEzTr4JqRMEs`)0;x8C35yRAV++n(Cm5++?WB@ya=l8pFL`N0ag`lWhrYo3 zJJ$< zQ*_YAqIGR*;`VzAEx1Pd4b3_oWtdcs7LU2#1#Ls>Ynvd8k^M{Ef?8`RxA3!Th-?ui{_WJvhzY4FiPxA?E4+NFmaC-Uh*a zeLKkkECqy>Qx&1xxEhh8SzMML=8VP}?b*sgT9ypBLF)Zh#w&JzP>ymrM?nnvt!@$2 zh>N$Q>mbPAC2kNd&ab;FkBJ}39s*TYY0=@e?N7GX>wqaM>P=Y12lciUmve_jMF0lY zBfI3U2{33vWo(DiSOc}!5##TDr|dgX1Uojq9!vW3$m#zM_83EGsP6&O`@v-PDdO3P z>#!BEbqpOXd5s?QNnN!p+92SHy{sdpePXHL{d@c6UilT<#~I!tH$S(~o}c#(j<2%! zQvm}MvAj-95Ekx3D4+|e%!?lO(F+DFw9bxb-}rsWQl)b44###eUg4N?N-P(sFH2hF z`{zu?LmAxn2=2wCE8?;%ZDi#Y;Fzp+RnY8fWlzVz_*PDO6?Je&aEmuS>=uCXgdP6r zoc_JB^TA~rU5*geh{G*gl%_HnISMS~^@{@KVC;(aL^ZA-De+1zwUSXgT>OY)W?d6~ z72znET0m`53q%AVUcGraYxIcAB?OZA8AT!uK8jU+=t;WneL~|IeQ>$*dWa#x%rB(+ z5?xEkZ&b{HsZ4Ju9TQ|)c_SIp`7r2qMJgaglfSBHhl)QO1aNtkGr0LUn{@mvAt=}nd7#>7ru}&I)FNsa*x?Oe3-4G`HcaR zJ}c%iKlwh`x)yX1vBB;-Nr=7>$~(u=AuPX2#&Eh~IeFw%afU+U)td0KC!pHd zyn+X$L|(H3uNit-bpn7%G%{&LsAaEfEsD?yM<;U2}WtD4KuVKuX=ec9X zIe*ibp1?$gPL7<0uj*vmj2lWKe`U(f9E{KVbr&q*RsO;O>K{i-7W)8KG5~~uS++56 zm@XGrX@x+lGEjDQJp~XCkEyJG5Y57omJhGN{^2z5lj-()PVR&wWnDk2M?n_TYR(gM zw4kQ|+i}3z6YZq8gVUN}KiYre^sL{ynS}o{z$s&I z{(rWaLXxcQ=MB(Cz7W$??Tn*$1y(7XX)tv;I-{7F$fPB%6YC7>-Dk#=Y8o1=&|>t5 zV_VVts>Eb@)&4%m}!K*WfLoLl|3FW)V~E1Z!yu`Sn+bAP5sRDyu7NEbLt?khAyz-ZyL-}MYb&nQ zU16f@q7E1rh!)d%f^tTHE3cVoa%Xs%rKFc|temN1sa)aSlT*)*4k?Z>b3NP(IRXfq zlB^#G6BDA1%t9^Nw1BD>lBV(0XW5c?l%vyB3)q*;Z5V~SU;HkN;1kA3Nx!$!9wti= zB8>n`gt;VlBt%5xmDxjfl0>`K$fTU-C6_Z;!A_liu0@Os5reMLNk;jrlVF^FbLETI zW+Z_5m|ozNBn7AaQ<&7zk}(jmEdCsPgmo%^GXo>YYt82n&7I-uQ%A;k{nS~VYGDTn zlr3}HbWQG6xu8+bFu^9%%^PYCbkLf=*J|hr>Sw+#l(Y#ZGKDufa#f-f0k-{-XOb4i zwVG1Oa0L2+&(u$S7TvedS<1m45*>a~5tuOZ;3x%!f``{=2QQlJk|b4>NpD4&L+xI+ z+}S(m3}|8|Vv(KYAGyZK5x*sgwOOJklN0jsq|BomM>OuRDVFf_?cMq%B*iQ*&|vS9 zVH7Kh)SjrCBv+FYAE=$0V&NIW=xP>d-s7@wM*sdfjVx6-Y@=~>rz%2L*rKp|*WXIz z*vR^4tV&7MQpS9%{9b*>E9d_ls|toL7J|;srnW{l-}1gP_Qr-bBHt=}PL@WlE|&KH zCUmDLZb%J$ZzNii-5VeygOM?K8e$EcK=z-hIk63o4y63^_*RdaitO^THC{boKstphXZ2Z+&3ToeLQUG(0Frs?b zCxB+65h7R$+LsbmL51Kc)pz_`YpGEzFEclzb=?FJ=>rJwgcp0QH-UuKRS1*yCHsO) z-8t?Zw|6t($Eh&4K+u$I7HqVJBOOFCRcmMMH};RX_b?;rnk`rz@vxT_&|6V@q0~Uk z9ax|!pA@Lwn8h7syrEtDluZ6G!;@=GL> zse#PRQrdDs=qa_v@{Wv(3YjYD0|qocDC;-F~&{oaTP?@pi$n z1L6SlmFU2~%)M^$@C(^cD!y)-2SeHo3t?u3JiN7UBa7E2 z;<+_A$V084@>&u)*C<4h7jw9joHuSpVsy8GZVT;(>lZ(RAr!;)bwM~o__Gm~exd`K zKEgh2)w?ReH&syI`~;Uo4`x4$&X+dYKI{e`dS~bQuS|p zA`P_{QLV3r$*~lb=9vR^H0AxK9_+dmHX}Y} zIV*#65%jRWem5Z($ji{!6ug$En4O*=^CiG=K zp4S?+xE|6!cn$A%XutqNEgUqYY3fw&N(Z6=@W6*bxdp~i_yz5VcgSj=lf-6X1Nz75 z^DabwZ4*70$$8NsEy@U^W67tcy7^lNbu;|kOLcJ40A%J#pZe0d#n zC{)}+p+?8*ftUlxJE*!%$`h~|KZSaCb=jpK3byAcuHk7wk@?YxkT1!|r({P*KY^`u z!hw#`5$JJZGt@nkBK_nwWA31_Q9UGvv9r-{NU<&7HHMQsq=sn@O?e~fwl20tnSBG* zO%4?Ew6`aX=I5lqmy&OkmtU}bH-+zvJ_CFy z_nw#!8Rap5Wcex#5}Ldtqhr_Z$}@jPuYljTosS1+WG+TxZ>dGeT)?ZP3#3>sf#KOG z0)s%{cEHBkS)019}-1A2kd*it>y65-C zh7J9zogM74?PU)0c0YavY7g~%j%yiWEGDb+;Ew5g5Gq@MpVFFBNOpu0x)>Yn>G6uo zKE%z1EhkG_N5$a8f6SRm(25iH#FMeaJ1^TBcBy<04ID47(1(D)q}g=_6#^V@yI?Y&@HUf z`;ojGDdsvRCoTmasXndENqfWkOw=#cV-9*QClpI03)FWcx(m5(P1DW+2-{Hr-`5M{v##Zu-i-9Cvt;V|n)1pR^y ztp3IXzHjYWqabuPqnCY9^^;adc!a%Z35VN~TzwAxq{NU&Kp35m?fw_^D{wzB}4FVXX5Zk@#={6jRh%wx|!eu@Xp;%x+{2;}!&J4X*_SvtkqE#KDIPPn@ z5BE$3uRlb>N<2A$g_cuRQM1T#5ra9u2x9pQuqF1l2#N{Q!jVJ<>HlLeVW|fN|#vqSnRr<0 zTVs=)7d`=EsJXkZLJgv~9JB&ay16xDG6v(J2eZy;U%a@EbAB-=C?PpA9@}?_Yfb&) zBpsih5m1U9Px<+2$TBJ@7s9HW>W){i&XKLZ_{1Wzh-o!l5_S+f$j^RNYo85}uVhN# zq}_mN-d=n{>fZD2Lx$Twd2)}X2ceasu91}n&BS+4U9=Y{aZCgV5# z?z_Hq-knIbgIpnkGzJz-NW*=p?3l(}y3(aPCW=A({g9CpjJfYuZ%#Tz81Y)al?!S~ z9AS5#&nzm*NF?2tCR#|D-EjBWifFR=da6hW^PHTl&km-WI9*F4o>5J{LBSieVk`KO z2(^9R(zC$@g|i3}`mK-qFZ33PD34jd_qOAFj29687wCUy>;(Hwo%Me&c=~)V$ua)V zsaM(aThQ3{TiM~;gTckp)LFvN?%TlO-;$y+YX4i`SU0hbm<})t0zZ!t1=wY&j#N>q zONEHIB^RW6D5N*cq6^+?T}$3m|L{Fe+L!rxJ=KRjlJS~|z-&CC{#CU8`}2|lo~)<| zk?Wi1;Cr;`?02-C_3^gD{|Ryhw!8i?yx5i0v5?p)9wZxSkwn z3C;pz25KR&7{|rc4H)V~y8%+6lX&KN&=^$Wqu+}}n{Y~K4XpI-#O?L=(2qncYNePX zTsB6_3`7q&e0K67=Kg7G=j#?r!j0S^w7;0?CJbB3_C4_8X*Q%F1%cmB{g%XE&|IA7 z(#?AeG{l)s_orNJp!$Q~qGrj*YnuKlV`nVdg4vkTNS~w$4d^Oc3(dxi(W5jq0e>x} z(GN1?u2%Sy;GA|B%Sk)ukr#v*UJU%(BE9X54!&KL9A^&rR%v zIdYt0&D59ggM}CKWyxGS@ z>T#})2Bk8sZMGJYFJtc>D#k0+Rrrs)2DG;(u(DB_v-sVg=GFMlSCx<&RL;BH}d6AG3VqP!JpC0Gv6f8d|+7YRC@g|=N=C2 zo>^0CE0*RW?W))S(N)}NKA)aSwsR{1*rs$(cZIs?nF9)G*bSr%%SZo^YQ|TSz={jX z4Z+(~v_>RH0(|IZ-_D_h@~p_i%k^XEi+CJVC~B zsPir zA0Jm2yIdo4`&I`hd%$Bv=Rq#-#bh{Mxb_{PN%trcf(#J3S1UKDfC1QjH2E;>wUf5= ze8tY9QSYx0J;$JUR-0ar6fuiQTCQP#P|WEq;Ez|*@d?JHu-(?*tTpGHC+=Q%H>&I> z*jC7%nJIy+HeoURWN%3X47UUusY2h7nckRxh8-)J61Zvn@j-uPA@99|y48pO)0XcW zX^d&kW^p7xsvdX?2QZ8cEUbMZ7`&n{%Bo*xgFr4&fd#tHOEboQos~xm8q&W;fqrj} z%KYnnE%R`=`+?lu-O+J9r@+$%YnqYq!SVs>xp;%Q8p^$wA~oynhnvIFp^)Z2CvcyC zIN-_3EUHW}1^VQ0;Oj>q?mkPx$Wj-i7QoXgQ!HyRh6Gj8p~gH22k&nmEqUR^)9qni{%uNeV{&0-H60C zibHZtbV=8=aX!xFvkO}T@lJ_4&ki$d+0ns3FXb+iP-VAVN`B7f-hO)jyh#4#_$XG%Txk6M<+q6D~ zi*UcgRBOoP$7P6RmaPZ2%MG}CMfs=>*~(b97V4+2qdwvwA@>U3QQAA$hiN9zi%Mq{ z*#fH57zUmi)GEefh7@`Uy7?@@=BL7cXbd{O9)*lJh*v!@ z-6}p9u0AreiGauxn7JBEa-2w&d=!*TLJ49`U@D7%2ppIh)ynMaAE2Q4dl@47cNu{9 z&3vT#pG$#%hrXzXsj=&Ss*0;W`Jo^mcy4*L8b^sSi;H{*`zW9xX2HAtQ*sO|x$c6UbRA(7*9=;D~(%wfo(Z6#s$S zuFk`dr%DfVX5KC|Af8@AIr8@OAVj=6iX!~8D_P>p7>s!Hj+X0_t}Y*T4L5V->A@Zx zcm1wN;TNq=h`5W&>z5cNA99U1lY6+!!u$ib|41VMcJk8`+kP{PEOUvc@2@fW(bh5pp6>C3T55@XlpsAd#vn~__3H;Dz2w=t9v&{v*)1m4)vX;4 zX4YAjM66?Z7kD@XX{e`f1t_ZvYyi*puSNhVPq%jeyBteaOHo7vOr8!qqp7wV;)%jtD5>}-a?xavZ;i|2P3~7c)vP2O#Fb`Y&Kce zQNr7%fr4#S)OOV-1piOf7NgQvR{lcvZ*SNbLMq(olrdDC6su;ubp5un!&oT=jVTC3uTw7|r;@&y*s)a<{J zkzG(PApmMCpMmuh6GkM_`AsBE@t~)EDcq1AJ~N@7bqyW_i!mtHGnVgBA`Dxi^P93i z5R;}AQ60wy=Q2GUnSwz+W6C^}qn`S-lY7=J(3#BlOK%pCl=|RVWhC|IDj1E#+|M{TV0vE;vMZLy7KpD1$Yk zi0!9%qy8>CyrcRK`juQ)I};r)5|_<<9x)32b3DT1M`>v^ld!yabX6@ihf`3ZVTgME zfy(l-ocFuZ(L&OM4=1N#Mrrm_<>1DZpoWTO70U8+x4r3BpqH6z@(4~sqv!A9_L}@7 z7o~;|?~s-b?ud&Wx6==9{4uTcS|0-p@dKi0y#tPm2`A!^o3fZ8Uidxq|uz2vxf;wr zM^%#9)h^R&T;}cxVI(XX7kKPEVb);AQO?cFT-ub=%lZPwxefymBk+!H!W(o(>I{jW z$h;xuNUr#^0ivvSB-YEbUqe$GLSGrU$B3q28&oA55l)ChKOrwiTyI~e*uN;^V@g-Dm4d|MK!ol8hoaSB%iOQ#i_@`EYK_9ZEjFZ8Ho7P^er z^2U6ZNQ{*hcEm?R-lK)pD_r(e=Jfe?5VkJ$2~Oq^7YjE^5(6a6Il--j@6dBHx2Ulq z!%hz{d-S~i9Eo~WvQYDt7O7*G9CP#nrKE#DtIEbe_uxptcCSmYZMqT2F}7Kw0AWWC zPjwo0IYZ6klc(h9uL|NY$;{SGm4R8Bt^^q{e#foMxfCSY^-c&IVPl|A_ru!ebwR#7 z3<4+nZL(mEsU}O9e`^XB4^*m)73hd04HH%6ok^!;4|JAENnEr~%s6W~8KWD)3MD*+ zRc46yo<}8|!|yW-+KulE86aB_T4pDgL$XyiRW(OOcnP4|2;v!m2fB7Hw-IkY#wYfF zP4w;k-RInWr4fbz=X$J;z2E8pvAuy9kLJUSl8_USi;rW`kZGF?*Ur%%(t$^{Rg!=v zg;h3@!Q$eTa7S0#APEDHLvK%RCn^o0u!xC1Y0Jg!Baht*a4mmKHy~88md{YmN#x) zBOAp_i-z2h#V~*oO-9k(BizR^l#Vm%uSa^~3337d;f=AhVp?heJ)nlZGm`}D(U^2w z#vC}o1g1h?RAV^90N|Jd@M00PoNUPyA?@HeX0P7`TKSA=*4s@R;Ulo4Ih{W^CD{c8 ze(ipN{CAXP(KHJ7UvpOc@9SUAS^wKo3h-}BDZu}-qjdNlVtp^Z{|CxKOEo?tB}-4; zEXyDzGbXttJ3V$lLo-D?HYwZm7vvwdRo}P#KVF>F|M&eJ44n*ZO~0)#0e0Vy&j00I z{%IrnUvKp70P?>~J^$^0Wo%>le>re2ZSvRfes@dC-*e=DD1-j%<$^~4^4>Id5w^Fr z{RWL>EbUCcyC%1980kOYqZAcgdz5cS8c^7%vvrc@CSPIx;X=RuodO2dxk17|am?HJ@d~Mp_l8H?T;5l0&WGFoTKM{eP!L-a0O8?w zgBPhY78tqf^+xv4#OK2I#0L-cSbEUWH2z+sDur85*!hjEhFfD!i0Eyr-RRLFEm5(n z-RV6Zf_qMxN5S6#8fr9vDL01PxzHr7wgOn%0Htmvk9*gP^Um=n^+7GLs#GmU&a#U^4jr)BkIubQO7oUG!4CneO2Ixa`e~+Jp9m{l6apL8SOqA^ zvrfEUPwnHQ8;yBt!&(hAwASmL?Axitiqvx%KZRRP?tj2521wyxN3ZD9buj4e;2y6U zw=TKh$4%tt(eh|y#*{flUJ5t4VyP*@3af`hyY^YU3LCE3Z|22iRK7M7E;1SZVHbXF zKVw!L?2bS|kl7rN4(*4h2qxyLjWG0vR@`M~QFPsf^KParmCX;Gh4OX6Uy9#4e_%oK zv1DRnfvd$pu(kUoV(MmAc09ckDiuqS$a%!AQ1Z>@DM#}-yAP$l`oV`BDYpkqpk(I|+qk!yoo$TwWr6dRzLy(c zi+qbVlYGz0XUq@;Fm3r~_p%by)S&SVWS+wS0rC9bk^3K^_@6N5|2rtF)wI>WJ=;Fz zn8$h<|Dr%kN|nciMwJAv;_%3XG9sDnO@i&pKVNEfziH_gxKy{l zo`2m4rnUT(qenuq9B0<#Iy(RPxP8R)=5~9wBku=%&EBoZ82x1GlV<>R=hIqf0PK!V zw?{z9e^B`bGyg2nH!^x}06oE%J_JLk)^QyHLipoCs2MWIqc>vaxsJj(=gg1ZSa=u{ zt}od#V;e7sA4S(V9^<^TZ#InyVBFT(V#$fvI7Q+pgsr_2X`N~8)IOZtX}e(Bn(;eF zsNj#qOF_bHl$nw5!ULY{lNx@93Fj}%R@lewUuJ*X*1$K`DNAFpE z7_lPE+!}uZ6c?+6NY1!QREg#iFy=Z!OEW}CXBd~wW|r_9%zkUPR0A3m+@Nk%4p>)F zXVut7$aOZ6`w}%+WV$te6-IX7g2yms@aLygaTlIv3=Jl#Nr}nN zp|vH-3L03#%-1-!mY`1z?+K1E>8K09G~JcxfS)%DZbteGQnQhaCGE2Y<{ut#(k-DL zh&5PLpi9x3$HM82dS!M?(Z zEsqW?dx-K_GMQu5K54pYJD=5+Rn&@bGjB?3$xgYl-|`FElp}?zP&RAd<522c$Rv6} zcM%rYClU%JB#GuS>FNb{P2q*oHy}UcQ-pZ2UlT~zXt5*k-ZalE(`p7<`0n7i(r2k{ zb84&^LA7+aW1Gx5!wK!xTbw0slM?6-i32CaOcLC2B>ZRI16d{&-$QBEu1fKF0dVU>GTP05x2>Tmdy`75Qx! z^IG;HB9V1-D5&&)zjJ&~G}VU1-x7EUlT3QgNT<&eIDUPYey$M|RD6%mVkoDe|;2`8Z+_{0&scCq>Mh3hj|E*|W3;y@{$qhu77D)QJ` znD9C1AHCKSAHQqdWBiP`-cAjq7`V%~JFES1=i-s5h6xVT<50kiAH_dn0KQB4t*=ua zz}F@mcKjhB;^7ka@WbSJFZRPeYI&JFkpJ-!B z!ju#!6IzJ;D@$Qhvz9IGY5!%TD&(db3<*sCpZ?U#1^9RWQ zs*O-)j!E85SMKtoZzE^8{w%E0R0b2lwwSJ%@E}Lou)iLmPQyO=eirG8h#o&E4~eew z;h><=|4m0$`ANTOixHQOGpksXlF0yy17E&JksB4_(vKR5s$Ve+i;gco2}^RRJI+~R zWJ82WGigLIUwP!uSELh3AAs9HmY-kz=_EL-w|9}noKE#(a;QBpEx9 z4BT-zY=6dJT>72Hkz=9J1E=}*MC;zzzUWb@x(Ho8cU_aRZ?fxse5_Ru2YOvcr?kg&pt@v;{ai7G--k$LQtoYj+Wjk+nnZty;XzANsrhoH#7=xVqfPIW(p zX5{YF+5=k4_LBnhLUZxX*O?29olfPS?u*ybhM_y z*XHUqM6OLB#lyTB`v<BZ&YRs$N)S@5Kn_b3;gjz6>fh@^j%y2-ya({>Hd@kv{CZZ2e)tva7gxLLp z`HoGW);eRtov~Ro5tetU2y72~ zQh>D`@dt@s^csdfN-*U&o*)i3c4oBufCa0e|BwT2y%Y~=U7A^ny}tx zHwA>Wm|!SCko~UN?hporyQHRUWl3djIc722EKbTIXQ6>>iC!x+cq^sUxVSj~u)dsY zW8QgfZlE*2Os%=K;_vy3wx{0u!2%A)qEG-$R^`($%AOfnA^LpkB_}Dd7AymC)zSQr z>C&N8V57)aeX8ap!|7vWaK6=-3~ko9meugAlBKYGOjc#36+KJwQKRNa_`W@7;a>ot zdRiJkz?+QgC$b}-Owzuaw3zBVLEugOp6UeMHAKo2$m4w zpw?i%Lft^UtuLI}wd4(-9Z^*lVoa}11~+0|Hs6zAgJ01`dEA&^>Ai=mr0nC%eBd_B zzgv2G_~1c1wr*q@QqVW*Wi1zn=}KCtSwLjwT>ndXE_Xa22HHL_xCDhkM( zhbw+j4uZM|r&3h=Z#YrxGo}GX`)AZyv@7#7+nd-D?BZV>thtc|3jt30j$9{aIw9)v zDY)*fsSLPQTNa&>UL^RWH(vpNXT7HBv@9=*=(Q?3#H*crA2>KYx7Ab?-(HU~a275)MBp~`P)hhzSsbj|d`aBe(L*(;zif{iFJu**ZR zkL-tPyh!#*r-JVQJq>5b0?cCy!uSKef+R=$s3iA7*k*_l&*e!$F zYwGI;=S^0)b`mP8&Ry@{R(dPfykD&?H)na^ihVS7KXkxb36TbGm%X1!QSmbV9^#>A z-%X>wljnTMU0#d;tpw?O1W@{X-k*>aOImeG z#N^x?ehaaQd}ReQykp>i;92q@%$a!y1PNyPYDIvMm& zyYVwn;+0({W@3h(r&i#FuCDE)AC(y&Vu>4?1@j0|CWnhHUx4|zL7cdaA32RSk?wl% zMK^n42@i5AU>f70(huWfOwaucbaToxj%+)7hnG^CjH|O`A}+GHZyQ-X57(WuiyRXV zPf>0N3GJ<2Myg!sE4XJY?Z7@K3ZgHy8f7CS5ton0Eq)Cp`iLROAglnsiEXpnI+S8; zZn>g2VqLxi^p8#F#Laf3<00AcT}Qh&kQnd^28u!9l1m^`lfh9+5$VNv=?(~Gl2wAl zx(w$Z2!_oESg_3Kk0hUsBJ<;OTPyL(?z6xj6LG5|Ic4II*P+_=ac7KRJZ`(k2R$L# zv|oWM@116K7r3^EL*j2ktjEEOY9c!IhnyqD&oy7+645^+@z5Y|;0+dyR2X6^%7GD* zXrbPqTO}O={ z4cGaI#DdpP;5u?lcNb($V`l>H7k7otl_jQFu1hh>=(?CTPN#IPO%O_rlVX}_Nq;L< z@YNiY>-W~&E@=EC5%o_z<^3YEw)i_c|NXxHF{=7U7Ev&C`c^0Z4-LGKXu*Hkk&Av= zG&RAv{cR7o4${k~f{F~J48Ks&o(D@j-PQ2`LL@I~b=ifx3q!p6`d>~Y!<-^mMk3)e zhi1;(YLU5KH}zzZNhl^`0HT(r`5FfmDEzxa zk&J7WQ|!v~TyDWdXQ)!AN_Y%xM*!jv^`s)A`|F%;eGg27KYsrCE2H}7*r)zvum6B{ z$k5Har9pv!dcG%f|3hE(#hFH+12RZPycVi?2y`-9I7JHryMn3 z9Y8?==_(vOAJ7PnT<0&85`_jMD0#ipta~Q3M!q5H1D@Nj-YXI$W%OQplM(GWZ5Lpq z-He6ul|3<;ZQsqs!{Y7x`FV@pOQc4|N;)qgtRe(Uf?|YqZv^$k8On7DJ5>f2%M=TV zw~x}9o=mh$JVF{v4H5Su1pq66+mhTG6?F>Do}x{V(TgFwuLfvNP^ijkrp5#s4UT!~ zEU7pr8aA)2z1zb|X9IpmJykQcqI#(rS|A4&=TtWu@g^;JCN`2kL}%+K!KlgC z>P)v+uCeI{1KZpewf>C=?N7%1e10Y3pQCZST1GT5fVyB1`q)JqCLXM zSN0qlreH1=%Zg-5`(dlfSHI&2?^SQdbEE&W4#%Eve2-EnX>NfboD<2l((>>34lE%) zS6PWibEvuBG7)KQo_`?KHSPk+2P;`}#xEs}0!;yPaTrR#j(2H|#-CbVnTt_?9aG`o z(4IPU*n>`cw2V~HM#O`Z^bv|cK|K};buJ|#{reT8R)f+P2<3$0YGh!lqx3&a_wi2Q zN^U|U$w4NP!Z>5|O)>$GjS5wqL3T8jTn%Vfg3_KnyUM{M`?bm)9oqZP&1w1)o=@+(5eUF@=P~ zk2B5AKxQ96n-6lyjh&xD!gHCzD$}OOdKQQk7LXS-fk2uy#h{ktqDo{o&>O!6%B|)` zg?|JgcH{P*5SoE3(}QyGc=@hqlB5w;bnmF#pL4iH`TSuft$dE5j^qP2S)?)@pjRQZ zBfo6g>c!|bN-Y|(Wah2o61Vd|OtXS?1`Fu&mFZ^yzUd4lgu7V|MRdGj3e#V`=mnk- zZ@LHn?@dDi=I^}R?}mZwduik!hC%=Hcl56u{Wrk1|1SxlgnzG&e7Vzh*wNM(6Y!~m z`cm8Ygc1$@z9u9=m5vs1(XXvH;q16fxyX4&e5dP-{!Kd555FD6G^sOXHyaCLka|8j zKKW^E>}>URx736WWNf?U6Dbd37Va3wQkiE;5F!quSnVKnmaIRl)b5rM_ICu4txs+w zj}nsd0I_VG^<%DMR8Zf}vh}kk;heOQTbl ziEoE;9@FBIfR7OO9y4Pwyz02OeA$n)mESpj zdd=xPwA`nO06uGGsXr4n>Cjot7m^~2X~V4yH&- zv2llS{|und45}Pm1-_W@)a-`vFBpD~>eVP(-rVHIIA|HD@%7>k8JPI-O*<7X{L*Ik zh^K`aEN!BteiRaY82FVo6<^8_22=aDIa8P&2A3V<(BQ;;x8Zs-1WuLRWjQvKv1rd2 zt%+fZ!L|ISVKT?$3iCK#7whp|1ivz1rV*R>yc5dS3kIKy_0`)n*%bfNyw%e7Uo}Mnnf>QwDgeH$X5eg_)!pI4EJjh6?kkG2oc6Af0py z(txE}$ukD|Zn=c+R`Oq;m~CSY{ebu9?!is}01sOK_mB?{lSY33E=!KkKtMeI*FO2b z%95awv9;Z|UDp3xm+aP*5I!R-_M2;GxeCRx3ATS0iF<_Do2Mi)Hk2 zjBF35VB>(oamIYjunu?g0O-?LuOvtfs5F(iiIicbu$HMPPF%F>pE@hIRjzT)>aa=m zwe;H9&+2|S!m74!E3xfO{l3E_ab`Q^tZ4yH9=~o2DUEtEMDqG=&D*8!>?2uao%w`&)THr z^>=L3HJquY>6)>dW4pCWbzrIB+>rdr{s}}cL_?#!sOPztRwPm1B=!jP7lQG|Iy6rP zVqZDNA;xaUx&xUt?Ox|;`9?oz`C0#}mc<1Urs#vTW4wd{1_r`eX=BeSV z_9WV*9mz>PH6b^z{VYQJ1nSTSqOFHE9u>cY)m`Q>=w1NzUShxcHsAxasnF2BG;NQ; zqL1tjLjImz_`q=|bAOr_i5_NEijqYZ^;d5y3ZFj6kCYakJh**N_wbfH;ICXq?-p#r z{{ljNDPSytOaG#7=yPmA&5gyYI%^7pLnMOw-RK}#*dk=@usL;|4US?{@K%7esmc&n z5$D*+l&C9)Bo@$d;Nwipd!68&+NnOj^<~vRcKLX>e03E|;to;$ndgR;9~&S-ly5gf z{rzj+j-g$;O|u?;wwxrEpD=8iFzUHQfl{B>bLHqH(9P zI59SS2PEBE;{zJUlcmf(T4DrcO?XRWR}?fekN<($1&AJTRDyW+D*2(Gyi?Qx-i}gy z&BpIO!NeVdLReO!YgdUfnT}7?5Z#~t5rMWqG+$N2n%5o#Np6ccNly}#IZQsW4?|NV zR9hrcyP(l#A+U4XcQvT;4{#i)dU>HK>aS!k1<3s2LyAhm2(!Nu%vRC9T`_yn9D+r} z1i&U~IcQ?4xhZYyH6WL-f%}qIhZkc&}n2N0PM| z6|XA9d-y;!`D{p;xu*gv7a|zaZ*MiQ)}zPzW4GB0mr)}N-DmB&hl1&x`2@sxN572_ zS)RdJyR%<7kW0v3Q_|57JKy&9tUdbqz}|hwn84}U*0r^jt6Ssrp+#1y=JBcZ+F`f(N?O0XL1OFGN`1-r?S<#t4*C9|y~e)!UYZ zRQ3M8m%~M)VriIvn~XzoP;5qeu(ZI>Y#r zAd)J)G9)*BeE%gmm&M@Olg3DI_zokjh9NvdGbT z+u4(Y&uC6tBBefIg~e=J#8i1Zxr>RT)#rGaB2C71usdsT=}mm`<#WY^6V{L*J6v&l z1^Tkr6-+^PA)yC;s1O^3Q!)Reb=fxs)P~I*?i&j{Vbb(Juc?La;cA5(H7#FKIj0Or zgV0BO{DUs`I9HgQ{-!g@5P^Vr|C4}~w6b=#`Zx0XcVSd?(04HUHwK(gJNafgQNB9Z zCi3TgNXAeJ+x|X|b@27$RxuYYuNSUBqo#uyiH6H(b~K*#!@g__4i%HP5wb<+Q7GSb zTZjJw96htUaGZ89$K_iBo4xEOJ#DT#KRu9ozu!GH0cqR>hP$nk=KXM%Y!(%vWQ#}s zy=O#BZ>xjUejMH^F39Bf0}>D}yiAh^toa-ts#gt6Mk9h1D<9_mGMBhLT0Ce2O3d_U znaTkBaxd-8XgwSp5)x-pqX5=+{cSuk6kyl@k|5DQ!5zLUVV%1X9vjY0gerbuG6nwZu5KDMdq(&UMLZ zy?jW#F6joUtVyz`Y?-#Yc0=i*htOFwQ3`hk$8oq35D}0m$FAOp#UFTV3|U3F>@N?d zeXLZCZjRC($%?dz(41e~)CN10qjh^1CdAcY(<=GMGk@`b1ptA&L*{L@_M{%Vd5b*x#b1(qh=7((<_l%ZUaHtmgq} zjchBdiis{Afxf@3CjPR09E*2#X(`W#-n`~6PcbaL_(^3tfDLk?Nb6CkW9v!v#&pWJ3iV-9hz zngp#Q`w`r~2wt&cQ9#S7z0CA^>Mzm7fpt72g<0y-KT{G~l-@L#edmjZQ}7{*$mLgSdJfS$Ge{hrD=mr;GD)uYq8}xS zT>(w_;}894Kb}(P5~FOpFIEjadhmxD(PsZbKwa-qxVa7Oc7~ebPKMeN(pCRzq8s@l z`|l^*X1eK1+Spz--WkSW_nK`Cs@JmkY4+p=U91nJoy{tSH;TzuIyS)Q_(S@;Iakua zpuDo5W54Mo;jY@Ly1dY)j|+M%$FJ0`C=FW#%UvOd&?p}0QqL20Xt!#pr8ujy6CA-2 zFz6Ex5H1i)c9&HUNwG{8K%FRK7HL$RJwvGakleLLo}tsb>t_nBCIuABNo$G--_j!gV&t8L^4N6wC|aLC)l&w04CD6Vc#h^(YH@Zs4nwUGkhc_-yt{dK zMZ<%$swLmUl8`E~RLihGt@J5v;r;vT&*Q!Cx zZ55-zpb;W7_Q{tf$mQvF61(K>kwTq0x{#Din||)B{+6O#ArLi)kiHWVC4`fOT&B(h zw&YV`J1|^FLx~9Q%r-SFhYl4PywI7sF2Q$>4o50~dfp5nn}XHv-_DM?RGs#+4gM;% znU>k=81G~f6u%^Z{bcX&sUv*h|L+|mNq=W43y@{~C zpL-TW3hYPs0^*OqS#KQwA^CGG_A-6#`_{1LBCD&*3nY0UHWJj1D|VP%oQlFxLllaA zVI@2^)HZ%E*=RbQcFOKIP7?+|_xVK+2oG(t_EGl2y;Ovox zZb^qVpe!4^reKvpIBFzx;Ji=PmrV>uu-Hb>`s?k?YZQ?>av45>i(w0V!|n?AP|v5H zm`e&Tgli#lqGEt?=(?~fy<(%#nDU`O@}Vjib6^rfE2xn;qgU6{u36j_+Km%v*2RLnGpsvS+THbZ>p(B zgb{QvqE?~50pkLP^0(`~K& zjT=2Pt2nSnwmnDFi2>;*C|OM1dY|CAZ5R|%SAuU|5KkjRM!LW_)LC*A zf{f>XaD+;rl6Y>Umr>M8y>lF+=nSxZX_-Z7lkTXyuZ(O6?UHw^q; z&$Zsm4U~}KLWz8>_{p*WQ!OgxT1JC&B&>|+LE3Z2mFNTUho<0u?@r^d=2 z-av!n8r#5M|F%l;=D=S1mGLjgFsiYAOODAR}#e^a8 zfVt$k=_o}kt3PTz?EpLkt54dY}kyd$rU zVqc9SN>0c z753j-gdN~UiW*FUDMOpYEkVzP)}{Ds*3_)ZBi)4v26MQr140|QRqhFoP=a|;C{#KS zD^9b-9HM11W+cb1Y)HAuk<^GUUo(ut!5kILBzAe)Vaxwu4Up!7Ql*#DDu z>EB84&xSrh>0jT!*X81jJQq$CRHqNj29!V3FN9DCx)~bvZbLwSlo3l^zPb1sqBnp) zfZpo|amY^H*I==3#8D%x3>zh#_SBf?r2QrD(Y@El!wa;Ja6G9Y1947P*DC|{9~nO& z*vDnnU!8(cV%HevsraF%Y%2{Z>CL0?64eu9r^t#WjW4~3uw8d}WHzsV%oq-T)Y z0-c!FWX5j1{1##?{aTeCW2b$PEnwe;t`VPCm@sQ`+$$L2=3kBR%2XU1{_|__XJ$xt zibjY2QlDVs)RgHH*kl&+jn*JqquF)k_Ypibo00lcc<2RYqsi-G%}k0r(N97H7JEn7@E3ZTH0JK>d8)E~A-D z!B&z9zJw0Bi^fgQZI%LirYaBKnWBXgc`An*qvO^*$xymqKOp(+3}IsnVhu?YnN7qz zNJxDN-JWd7-vIiv2M9ih>x3gNVY%DzzY~dCnA}76IRl!`VM=6=TYQ=o&uuE8kHqZT zoUNod0v+s9D)7aLJ|hVqL0li1hg)%&MAciI(4YJ=%D4H$fGQ&Lu-?@>>@pEgC;ERrL= zI^cS&3q8fvEGTJZgZwL5j&jp%j9U^Of6pR{wA^u=tVt#yCQepXNIbynGnuWbsC_EE zRyMFq{5DK692-*kyGy~An>AdVR9u___fzmmJ4;^s0yAGgO^h{YFmqJ%ZJ_^0BgCET zE6(B*SzeZ4pAxear^B-YW<%BK->X&Cr`g9_;qH~pCle# zdY|UB5cS<}DFRMO;&czbmV(?vzikf)Ks`d$LL801@HTP5@r><}$xp}+Ip`u_AZ~!K zT}{+R9Wkj}DtC=4QIqJok5(~0Ll&_6PPVQ`hZ+2iX1H{YjI8axG_Bw#QJy`6T>1Nn z%u^l`>XJ{^vX`L0 z1%w-ie!dE|!SP<>#c%ma9)8K4gm=!inHn2U+GR+~ zqZVoa!#aS0SP(|**WfQSe?cA=1|Jwk`UDsny%_y{@AV??N>xWekf>_IZLUEK3{Ksi zWWW$if&Go~@Oz)`#=6t_bNtD$d9FMBN#&97+XKa+K2C@I9xWgTE{?Xnhc9_KKPcujj@NprM@e|KtV_SR+ zSpeJ!1FGJ=Te6={;;+;a46-*DW*FjTnBfeuzI_=I1yk8M(}IwEIGWV0Y~wia;}^dg z{BK#G7^J`SE10z4(_Me=kF&4ld*}wpNs91%2Ute>Om`byv9qgK4VfwPj$`axsiZ)wxS4k4KTLb-d~!7I@^Jq`>?TrixHk|9 zqCX7@sWcVfNP8N;(T>>PJgsklQ#GF>F;fz_Rogh3r!dy*0qMr#>hvSua;$d z3TCZ4tlkyWPTD<=5&*bUck~J;oaIzSQ0E03_2x{?weax^jL3o`ZP#uvK{Z5^%H4b6 z%Kbp6K?>{;8>BnQy64Jy$~DN?l(ufkcs6TpaO&i~dC>0fvi-I^7YT#h?m;TVG|nba%CKRG%}3P*wejg) zI(ow&(5X3HR_xk{jrnkA-hbwxEQh|$CET9Qv6UpM+-bY?E!XVorBvHoU59;q<9$hK z%w5K-SK zWT#1OX__$ceoq0cRt>9|)v}$7{PlfwN}%Wh3rwSl;%JD|k~@IBMd5}JD#TOvp=S57 zae=J#0%+oH`-Av}a(Jqhd4h5~eG5ASOD)DfuqujI6p!;xF_GFcc;hZ9k^a7c%%h(J zhY;n&SyJWxju<+r`;pmAAWJmHDs{)V-x7(0-;E?I9FWK@Z6G+?7Py8uLc2~Fh1^0K zzC*V#P88(6U$XBjLmnahi2C!a+|4a)5Ho5>owQw$jaBm<)H2fR=-B*AI8G@@P-8I8 zHios92Q6Nk-n0;;c|WV$Q);Hu4;+y%C@3alP`cJ2{z~*m-@de%OKVgiWp;4Q)qf9n zJ!vmx(C=_>{+??w{U^Bh|LFJ<6t}Er<-Tu{C{dv8eb(kVQ4!fOuopTo!^x1OrG}0D zR{A#SrmN`=7T29bzQ}bwX8OUufW9d9T4>WY2n15=k3_rfGOp6sK0oj7(0xGaEe+-C zVuWa;hS*MB{^$=0`bWF(h|{}?53{5Wf!1M%YxVw}io4u-G2AYN|FdmhI13HvnoK zNS2fStm=?8ZpKt}v1@Dmz0FD(9pu}N@aDG3BY8y`O*xFsSz9f+Y({hFx;P_h>ER_& z`~{z?_vCNS>agYZI?ry*V96_uh;|EFc0*-x*`$f4A$*==p`TUVG;YDO+I4{gJGrj^ zn?ud(B4BlQr;NN?vaz_7{&(D9mfd z8esj=a4tR-ybJjCMtqV8>zn`r{0g$hwoWRUI3}X5=dofN){;vNoftEwX>2t@nUJro z#%7rpie2eH1sRa9i6TbBA4hLE8SBK@blOs=ouBvk{zFCYn4xY;v3QSM%y6?_+FGDn z4A;m)W?JL!gw^*tRx$gqmBXk&VU=Nh$gYp+Swu!h!+e(26(6*3Q!(!MsrMiLri`S= zKItik^R9g!0q7y$lh+L4zBc-?Fsm8`CX1+f>4GK7^X2#*H|oK}reQnT{Mm|0ar<+S zRc_dM%M?a3bC2ILD`|;6vKA`a3*N~(cjw~Xy`zhuY2s{(7KLB{S>QtR3NBQ3>vd+= z#}Q)AJr7Y_-eV(sMN#x!uGX08oE*g=grB*|bBs}%^3!RVA4f%m3=1f0K=T^}iI&2K zuM2GG5_%+#v-&V>?x4W9wQ|jE2Q7Be8mOyJtZrqn#gXy-1fF1P$C8+We&B*-pi#q5 zETp%H6g+%#sH+L4=ww?-h;MRCd2J9zwQUe4gHAbCbH08gDJY;F6F)HtWCRW1fLR;)ysGZanlz*a+|V&@(ipWdB!tz=m_0 z6F}`d$r%33bw?G*azn*}Z;UMr{z4d9j~s`0*foZkUPwpJsGgoR0aF>&@DC;$A&(av z?b|oo;`_jd>_5nye`DVOcMLr-*Nw&nA z82E8Dw^$Lpso)gEMh?N|Uc^X*NIhg=U%enuzZOGi-xcZRUZmkmq~(cP{S|*+A6P;Q zprIkJkIl51@ng)8cR6QSXJtoa$AzT@*(zN3M+6`BTO~ZMo0`9$s;pg0HE3C;&;D@q zd^0zcpT+jC%&=cYJF+j&uzX87d(gP9&kB9|-zN=69ymQS9_K@h3ph&wD5_!4q@qI@ zBMbd`2JJ2%yNX?`3(u&+nUUJLZ=|{t7^Rpw#v-pqD2_3}UEz!QazhRty%|Q~WCo7$ z+sIugHA%Lmm{lBP#bnu_>G}Ja<*6YOvSC;89z67M%iG0dagOt1HDpDn$<&H0DWxMU zxOYaaks6%R@{`l~zlZ*~2}n53mn2|O&gE+j*^ypbrtBv{xd~G(NF?Z%F3>S6+qcry z?ZdF9R*a;3lqX_!rI(Cov8ER_mOqSn6g&ZU(I|DHo7Jj`GJ}mF;T(vax`2+B8)H_D zD0I;%I?*oGD616DsC#j0x*p+ZpBfd=9gR|TvB)832CRhsW_7g&WI@zp@r7dhg}{+4f=(cO2s+)jg0x(*6|^+6W_=YIfSH0lTcK* z%)LyaOL6em@*-_u)}Swe8rU)~#zT-vNiW(D*~?Zp3NWl1y#fo!3sK-5Ek6F$F5l3| zrFFD~WHz1}WHmzzZ!n&O8rTgfytJG*7iE~0`0;HGXgWTgx@2fD`oodipOM*MOWN-} zJY-^>VMEi8v23ZlOn0NXp{7!QV3F1FY_URZjRKMcY(2PV_ms}EIC^x z=EYB5UUQ{@R~$2Mwiw$_JAcF+szKB*n(`MYpDCl>~ss54uDQ%Xf-8|dgO zY)B_qju=IaShS|XsQo=nSYxV$_vQR@hd~;qW)TEfU|BA0&-JSwO}-a*T;^}l;MgLM zz}CjPlJX|W2vCzm3oHw3vqsRc3RY=2()}iw_k2#eKf&VEP7TQ;(DDzEAUgj!z_h2Br;Z3u=K~LqM6YOrlh)v9`!n|6M-s z?XvA~y<5?WJ{+yM~uPh7uVM&g-(;IC3>uA}ud?B3F zelSyc)Nx>(?F=H88O&_70%{ATsLVTAp88F-`+|egQ7C4rpIgOf;1tU1au+D3 zlz?k$jJtTOrl&B2%}D}8d=+$NINOZjY$lb{O<;oT<zXoAp01KYG$Y4*=)!&4g|FL(!54OhR-?)DXC&VS5E|1HGk8LY;)FRJqnz zb_rV2F7=BGwHgDK&4J3{%&IK~rQx<&Kea|qEre;%A~5YD6x`mo>mdR)l?Nd%T2(5U z_ciT02-zt_*C|vn?BYDuqSFrk3R(4B0M@CRFmG{5sovIq4%8AhjXA5UwRGo)MxZlI zI%vz`v8B+#ff*XtGnciczFG}l(I}{YuCco#2E6|+5WJ|>BSDfz0oT+F z%QI^ixD|^(AN`MS6J$ zXlKNTFhb>KDkJp*4*LaZ2WWA5YR~{`={F^hwXGG*rJYQA7kx|nwnC58!eogSIvy{F zm1C#9@$LhK^Tl>&iM0wsnbG7Y^MnQ=q))MgApj4)DQt!Q5S`h+5a%c7M!m%)?+h65 z0NHDiEM^`W+M4)=q^#sk(g!GTpB}edwIe>FJQ+jAbCo#b zXmtd3raGJNH8vnqMtjem<_)9`gU_-RF&ZK!aIenv7B2Y0rZhon=2yh&VsHzM|`y|0x$Zez$bUg5Nqj?@~^ zPN43MB}q0kF&^=#3C;2T*bDBTyO(+#nZnULkVy0JcGJ36or7yl1wt7HI_>V7>mdud zv2II9P61FyEXZuF$=69dn%Z6F;SOwyGL4D5mKfW)q4l$8yUhv7|>>h_-4T*_CwAyu7;DW}_H zo>N_7Gm6eed=UaiEp_7aZko@CC61@(E1be&5I9TUq%AOJW>s^9w%pR5g2{7HW9qyF zh+ZvX;5}PN0!B4q2FUy+C#w5J?0Tkd&S#~94(AP4%fRb^742pgH7Tb1))siXWXHUT z1Wn5CG&!mGtr#jq6(P#!ck@K+FNprcWP?^wA2>mHA03W?kj>5b|P0ErXS) zg2qDTjQ|grCgYhrH-RapWCvMq5vCaF?{R%*mu}1)UDll~6;}3Q*^QOfj!dlt02lSzK z?+P)02Rrq``NbU3j&s*;<%i4Y>y9NK&=&KsYwvEmf5jwTG6?+Pu1q9M8lLlx)uZZ7 zizhr~e0ktGs-=$li-2jz^_48-jk**y&5u0`B2gc#i$T1~t+AS*kEfR*b{^Ec>2-F~ zKYRl&uQ5yO@EtAZX8ZSqx;8+AKf+CqhlUSpp*VfyBMv+%wxN5GukZEi^_to%MFRc0 zdXqJ*jk?#uYT6EJe446@(f6G4vhnxQP|pGeJ?-#|Ksq?g*ky=}x+Qnx+!<>Y(XStN zQIND`{KU}&l)E*ntI^}kJ=ly8DML{!(58Xk4_bzIc@v~e;>wKl_`7G%pGz~4KH*CTp;_|52)d!+ximd$|8v@zzEq%j68QXkgf$7eM~xdM5q5i z{?qFx_W|eq@L03bWJfjy^z@()-iCjzjREuf zb_a(yTz)ZKWCF%Lp>^2-%Q?*t{06}x#DLN3cO=i>h6#-a`z;<5rBGGM6GA(WqvRcX%Pn?Uvs1#e|ePSNJEC%+X(YI$x)`s$%>O#%}D9dgqWfq4yfVz^%FglokdFR}uJQhx|}_w`9Ulx38Ha>ZslKs58c-@IFI&f;?xM zbK>rKNfPFsf>%+k6%(A6=7Aac^_qrOCNqb3ZVJ;8pt!?1DR*ynJb#@II9h?)xB)A~ zm9Kk)Hy}!Z+W}i6ZJDy+?yY_=#kWrzgV)2eZAx_E=}Nh7*#<&mQz`Umfe$+l^P(xd zN}PA2qII4}ddCU+PN+yxkH%y!Qe(;iH3W%bwM3NKbU_saBo<8x9fGNtTAc_SizU=o zC3n2;c%LoU^j90Sz>B_p--Fzqv7x7*?|~-x{haH8RP)p|^u$}S9pD-}5;88pu0J~9 zj}EC`Q^Fw}`^pvAs4qOIuxKvGN@DUdRQ8p-RXh=3S#<`3{+Qv6&nEm)uV|kRVnu6f zco{(rJaWw(T0PWim?kkj9pJ)ZsUk9)dSNLDHf`y&@wbd;_ita>6RXFJ+8XC*-wsiN z(HR|9IF283fn=DI#3Ze&#y3yS5;!yoIBAH(v}3p5_Zr+F99*%+)cp!Sy8e+lG?dOc zuEz<;3X9Z5kkpL_ZYQa`sioR_@_cG z8tT~GOSTWnO~#?$u)AcaBSaV7P~RT?Nn8(OSL1RmzPWRWQ$K2`6*)+&7^zZBeWzud z*xb3|Fc~|R9eH+lQ#4wF#c;)Gka6lL(63C;>(bZob!i8F-3EhYU3|6-JBC0*5`y0| zBs!Frs=s!Sy0qmQNgIH|F`6(SrD1js2prni_QbG9Sv@^Pu2szR9NZl8GU89gWWvVg z2^-b*t+F{Nt>v?js7hnlC`tRU(an0qQG7;h6T~ z-`vf#R-AE$pzk`M{gCaia}F`->O2)60AuGFAJg> z*O2IZqTx=AzDvC49?A92>bQLdb&32_4>0Bgp0ESXXnd4B)!$t$g{*FG%HYdt3b3a^J9#so%BJMyr2 z{y?rzW!>lr097b9(75#&4&@lkB1vT*w&0E>!dS+a|ZOu6t^zro2tiP)bhcNNxn zbJs3_Fz+?t;4bkd8GfDI7ccJ5zU`Bs~ zN~bci`c`a%DoCMel<-KUCBdZRmew`MbZEPYE|R#|*hhvhyhOL#9Yt7$g_)!X?fK^F z8UDz)(zpsvriJ5aro5>qy`Fnz%;IR$@Kg3Z3EE!fv9CAdrAym6QU82=_$_N5*({_1 z7!-=zy(R{xg9S519S6W{HpJZ8Is|kQ!0?`!vxDggmslD59)>iQ15f z7J8NqdR`9f8H|~iFGNsPV!N)(CC9JRmzL9S}7U-K@`X893f3f<8|8Ls!^eA^#(O6nA+ByFIXcz_WLbfeG|nHJ5_sJJ^gNJ%SI9#XEfNRbzV+!RkI zXS$MOVYb2!0vU}Gt7oUy*|WpF^*orBot~b2J@^be?Gq;U%#am8`PmH-UCFZ&uTJlnetYij0z{K1mmivk$bdPbLodu;-R@@#gAV!=d%(caz$E?r zURX0pqAn7UuF6dULnoF1dZ$WM)tHAM{eZK6DbU1J`V5Dw<;xk}Nl`h+nfMO_Rdv z3SyOMzAbYaD;mkxA7_I_DOs#Bk;e5D%gsS3q)hlmi1w{FsjKNJE22`AjmNiAPRnIc zcIkN25;rOn3FipAFd(PnlK9{03w6Q<(68#1Jw`{axEGQE{Ac>^U$h);h2ADICmaNxrfpb`Jdr*)Y1SicpYKCFv$3vf~;5aW>n^7QGa63MJ z;B1+Z>WQ615R2D8JmmT`T{QcgZ+Kz1hTu{9FOL}Q8+iFx-Vyi}ZVVcGjTe>QfA`7W zFoS__+;E_rQIQxd(Bq4$egKeKsk#-9=&A!)(|hBvydsr5ts0Zjp*%*C0lM2sIOx1s zg$xz?Fh?x!P^!vWa|}^+SY8oZHub7f;E!S&Q;F?dZmvBxuFEISC}$^B_x*N-xRRJh zn4W*ThEWaPD*$KBr8_?}XRhHY7h^U1aN6>m=n~?YJQd8+!Uyq_3^)~4>XjelM&!c9 zCo|0KsGq7!KsZ~9@%G?i>LaU7#uSTMpypocm*oqJHR|wOgVWc7_8PVuuw>x{kEG4T z$p^DV`}jUK39zqFc(d5;N+M!Zd3zhZN&?Ww(<@AV-&f!v$uV>%z+dg9((35o@4rqLvTC-se@hkn^6k7+xHiK-vTRvM8{bCejbU;1@U=*r}GTI?Oc$!b6NRcj83-zF; z=TB#ESDB`F`jf4)z=OS76Se}tQDDHh{VKJk#Ad6FDB_=afpK#pyRkGrk~OuzmQG)} z*$t!nZu$KN&B;|O-aD=H<|n6aGGJZ=K9QFLG0y=Jye_ElJFNZJT;fU8P8CZcLBERjioAOC0Vz_pIXIc};)8HjfPwNy zE!g|lkRv3qpmU?shz(BBt5%TbpJC3HzP9!t7k*Fh48!-HlJ4TTgdCr3rCU!iF}kgu z4Qs;K@XOY~4f~N}Jl8V_mGbwzvNLbl&0e9UG4W;kvjTK|5`-Ld+eQ6YRF`N0ct%u% z^3J_{7r#_W1zm|>IPN!yWCRrN)N!7v`~ptNkIXKipQ6ogFvcnI5ugxdoa{d;uD67g zgo^}QuZRkB540Vc!@c80(wFG=$ct}oHq(#W0+-XX(;Rrt`x=<45X}ficNtI2(&}=~ zb(!}tNz?s`wm{gK?2tdf+OEF;tzx<(3fMd7_tM@Ghs$Z(Os-H(kYq#qB|J-aC9Ku?fsWwJhB36c)A zu|a7ZF?V8X7l2g5~xqZf>2=6Dsi5lfo zKIRL&@MLJyaBE)V_9=pJYu%U2wxR*-(0MI5_|yqP`?h@cks(5LR@XUKLMI_xuVtiu zRvpDS8MyUMRFM6`P+Sjc!A_e^H38Qu7b{b7QZ>NHyA6k-YYygQuW&C_OGO(7V7?}r)zedSVpBI zuk29Z4GW3C0GpfozbZQya454sjt@ndQmsp=DA&@sWw&xmOlDk1JIcMNp~-ES$&A~k zG#W(6hBj?!Fu8Q4WYexoSBa8_5=v20xnx6H?e;$t)5|f&{7=vOye^&3_c-Ug?|a@e z=X`&qT_5B7N9vZoPBhXOTEDV;4&x2Je4}T(UB~O-$D#CjX77$R?RZ*`ed~$G;$4YS z4n*|Pop(!NN79Hk2}U#cfEEwdxM)xQm}$~rV03xc=#U@@Y*}qEmot5KvDb=8{!E-n zl4p?}&g2h^sUGyTcGh=0aQzQb*k;K;dvbeZUgmwEv>%#(EPtj=gHKdi|E8@w+|>KC zxEU>b>P+9Xf}pEyQK(}#QrBG4Jaf!iE!qpMbTu>gb!gtdq<`@xO+roQl+S_7)!G(% zdy)$iGmJ1cwP?F=IyyV1-$|kf|EKM3B@I&lZ%NI@VV;*mQdLWjc#t|Vbk_Q~>&O03 zIcSr$(qLAINj7a z;!||v&1D5SX#X@5jNd}jUsi-CH_Scjyht&}q2p*CJCC-`&NyXf)vD5{e!HO629D-O z%bZelTcq=DoRX>zeWCa^RmR3*{x9;3lZ75M#S)!W0bRIFH#P6b%{|HRSZ5!!I#s)W z_|XXZQ<0_`>b^^0Z>LU64Yg1w)8}#M^9se(OZ9~baZ7fsKFc;EtnB>kesci#>=icG zuHdjax2^=!_(9?0l7;G7^-}9>Y#M zm;9*GT~dBuYWdk49%mZM0=H#FY1)}7NE5DE_vsqrA0`?0R0q535qHjWXcl|gz9Fq$ zMKxgL;68l!gm3y0durIr3LHv~y*ABm` zYhQG0UW#hg@*A{&G!;$FS43}rIF$e6yRdGJWVR<}uuJ_5_8qa3xaHH^!VzUteVp;> z<0`M>3tnY$ZFb$(`0sg93TwGyP;`9UYUWxO&CvAnSzei&ap))NcW;R`tA=y^?mBmG+M*&bqW5kL$V(O;(p)aEk`^ci?2Jwxu>0sy>a7+Wa9t z5#I2o;+gr^9^&km^z7>xJWbN&Ft>Vna34E zI@BBzwX)R}K3SL?)enrDJ45QLt;-7CFJk{`cF3L4Z^CtG_r5)0)HV>BOYPIUh#D%| zYQAu31f{bm-D*`_k7DTTr?Nkw_gY%J1cb2&TdtibY?V=|SSIOlA;|5C!2@?YQ z-$?G0jj^mG|MP>DmbF7}T~C$H6=CpZ~hd zZ1C|xV@=h#^~`3LSCnmI(vZ|5r3>eq5*UB)dhdy``*gKY3Eg%jSK8I-`G+OWWlD)T zt$wSQ=||lSkiKy}YF-k}@W9EiS?)z`hK{R!dd-$BCJvBtAN-yXn3njU$MisEtp!?Q z%Vk-*(wy9dd15(-WFw_&^tT;;IpF?ox1`Qq3-0zVTk+$W_?q}GfAQlPcrB^?&tWSI z2BB!K=sH7FUYmXa_dcV^Z3>5z8}~W{S!$jVR_3hu_|wl2|gmRH8ftn^z@fW75*;-`;wU+fY+BR_yx6BZnE5_Hna({jrPiubRp$jZ=T=t$hx&NeCV1!vuCcl4PJ0p0Fjp>6K} zHkoD1gQk=P2hYcT%)cJ2Q5WuA|5_x+dX0%hnozfTF>$#Wz~X!MY>){H4#fB#7^ID* z1*o2Hzp}?WVs&gbS?Uq(CT0sP+F)u9{xfgg6o_{8J#m;|NeJqDHhb(Q8%z8aM_qeM zn83>d`uDd47WIuKp78JBYo2SYupGcNXIzeou^eMY`@%Bv8elZ>q~3uq#~IX)g%g;h zoUXymEd>|kVsMkyb&1l~lrE-`w(0PObapYa35DJ4Y03Jv_!DKp}0HTbOgZRM=;PSsuAJJJ1 zItc+tu9;ANG;qHaCI|T85!euhFK~VK^G2LZV1+cbzS?>ar@>emg;JTI5VAn1g5U~| zU=p&k0OlSzc$U=s#9_uL3&n|6A1X$XvrE9vFV@`A4G#!D1QcFCeE`F2N(deJx>)*A z$XIW0P~-NbAd=5i6`s<~(vAQX9t$dbVqc5|E|CHRtb$1(l&KSNh_t2#k_l95KnP86 z)ns_DGspv-M0z0#h2a+*oH|{5~j{ zXGD=}cLrBSESQ0u$XmQlFfWMCAWaS;wKK%#aSSYK=qljBiY(s zT$v;We24&$w=avIILsMt0%1fDyah|AlLNg#WL$Lu)tf}YfqO%+pH~QC*bZO4aM*i9 zrPFf|5!hv@XY8CzaFh*Dy9vH|2fKKr(@x}`L#9^*vOae|lk`adG#oZZAyk|TOV8`9L zc-sQu%y1MQes&J?)a1}Zc*>-P!6j-T#75V$lLC!TuMB(!G-+D2;XptUxymSPFI-K&0x}B1?h$ z3-9**-9!);fwyiWB5gS$i;P~c=^}5-6G@{4TWDBRDc6(M|%qa-mS`z`u9kWo{Xl_uc;hXOkRd literal 0 HcmV?d00001 diff --git a/local-notifications/android/gradle/wrapper/gradle-wrapper.properties b/local-notifications/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..186b71557 --- /dev/null +++ b/local-notifications/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/local-notifications/android/gradlew b/local-notifications/android/gradlew new file mode 100755 index 000000000..fbd7c5158 --- /dev/null +++ b/local-notifications/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/local-notifications/android/gradlew.bat b/local-notifications/android/gradlew.bat new file mode 100644 index 000000000..5093609d5 --- /dev/null +++ b/local-notifications/android/gradlew.bat @@ -0,0 +1,104 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/local-notifications/android/proguard-rules.pro b/local-notifications/android/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/local-notifications/android/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/local-notifications/android/settings.gradle b/local-notifications/android/settings.gradle new file mode 100644 index 000000000..1e5b8431f --- /dev/null +++ b/local-notifications/android/settings.gradle @@ -0,0 +1,2 @@ +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') \ No newline at end of file diff --git a/local-notifications/android/src/androidTest/java/com/getcapacitor/android/ExampleInstrumentedTest.java b/local-notifications/android/src/androidTest/java/com/getcapacitor/android/ExampleInstrumentedTest.java new file mode 100644 index 000000000..58020e16c --- /dev/null +++ b/local-notifications/android/src/androidTest/java/com/getcapacitor/android/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.android; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.android", appContext.getPackageName()); + } +} diff --git a/local-notifications/android/src/main/AndroidManifest.xml b/local-notifications/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ff9a54bee --- /dev/null +++ b/local-notifications/android/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/DateMatch.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/DateMatch.java new file mode 100644 index 000000000..f1a131724 --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/DateMatch.java @@ -0,0 +1,213 @@ +package com.capacitorjs.plugins.localnotifications; + +import java.util.Calendar; +import java.util.Date; + +/** + * Class that holds logic for on triggers + * (Specific time) + */ +public class DateMatch { + + private static final String separator = " "; + + private Integer year; + private Integer month; + private Integer day; + private Integer hour; + private Integer minute; + + // Unit used to save the last used unit for a trigger. + // One of the Calendar constants values + private Integer unit = -1; + + public DateMatch() {} + + public Integer getYear() { + return year; + } + + public void setYear(Integer year) { + this.year = year; + } + + public Integer getMonth() { + return month; + } + + public void setMonth(Integer month) { + this.month = month; + } + + public Integer getDay() { + return day; + } + + public void setDay(Integer day) { + this.day = day; + } + + public Integer getHour() { + return hour; + } + + public void setHour(Integer hour) { + this.hour = hour; + } + + public Integer getMinute() { + return minute; + } + + public void setMinute(Integer minute) { + this.minute = minute; + } + + /** + * Gets a calendar instance pointing to the specified date. + * + * @param date The date to point. + */ + private Calendar buildCalendar(Date date) { + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + cal.set(Calendar.MILLISECOND, 0); + cal.set(Calendar.SECOND, 0); + return cal; + } + + /** + * Calculates next trigger date for + * + * @param date base date used to calculate trigger + * @return next trigger timestamp + */ + public long nextTrigger(Date date) { + Calendar current = buildCalendar(date); + Calendar next = buildNextTriggerTime(date); + return postponeTriggerIfNeeded(current, next); + } + + /** + * Postpone trigger if first schedule matches the past + */ + private long postponeTriggerIfNeeded(Calendar current, Calendar next) { + if (next.getTimeInMillis() <= current.getTimeInMillis() && unit != -1) { + Integer incrementUnit = -1; + if (unit == Calendar.YEAR || unit == Calendar.MONTH) { + incrementUnit = Calendar.YEAR; + } else if (unit == Calendar.DAY_OF_MONTH) { + incrementUnit = Calendar.MONTH; + } else if (unit == Calendar.HOUR_OF_DAY) { + incrementUnit = Calendar.DAY_OF_MONTH; + } else if (unit == Calendar.MINUTE) { + incrementUnit = Calendar.HOUR_OF_DAY; + } + + if (incrementUnit != -1) { + next.set(incrementUnit, next.get(incrementUnit) + 1); + } + } + return next.getTimeInMillis(); + } + + private Calendar buildNextTriggerTime(Date date) { + Calendar next = buildCalendar(date); + if (year != null) { + next.set(Calendar.YEAR, year); + if (unit == -1) unit = Calendar.YEAR; + } + if (month != null) { + next.set(Calendar.MONTH, month); + if (unit == -1) unit = Calendar.MONTH; + } + if (day != null) { + next.set(Calendar.DAY_OF_MONTH, day); + if (unit == -1) unit = Calendar.DAY_OF_MONTH; + } + if (hour != null) { + next.set(Calendar.HOUR_OF_DAY, hour); + if (unit == -1) unit = Calendar.HOUR_OF_DAY; + } + if (minute != null) { + next.set(Calendar.MINUTE, minute); + if (unit == -1) unit = Calendar.MINUTE; + } + return next; + } + + @Override + public String toString() { + return "DateMatch{" + "year=" + year + ", month=" + month + ", day=" + day + ", hour=" + hour + ", minute=" + minute + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DateMatch dateMatch = (DateMatch) o; + + if (year != null ? !year.equals(dateMatch.year) : dateMatch.year != null) return false; + if (month != null ? !month.equals(dateMatch.month) : dateMatch.month != null) return false; + if (day != null ? !day.equals(dateMatch.day) : dateMatch.day != null) return false; + if (hour != null ? !hour.equals(dateMatch.hour) : dateMatch.hour != null) return false; + return minute != null ? minute.equals(dateMatch.minute) : dateMatch.minute == null; + } + + @Override + public int hashCode() { + int result = year != null ? year.hashCode() : 0; + result = 31 * result + (month != null ? month.hashCode() : 0); + result = 31 * result + (day != null ? day.hashCode() : 0); + result = 31 * result + (hour != null ? hour.hashCode() : 0); + result = 31 * result + (minute != null ? minute.hashCode() : 0); + return result; + } + + /** + * Transform DateMatch object to CronString + * + * @return + */ + public String toMatchString() { + String matchString = year + separator + month + separator + day + separator + hour + separator + minute + separator + unit; + return matchString.replace("null", "*"); + } + + /** + * Create DateMatch object from stored string + * + * @param matchString + * @return + */ + public static DateMatch fromMatchString(String matchString) { + DateMatch date = new DateMatch(); + String[] split = matchString.split(separator); + if (split != null && split.length == 6) { + date.setYear(getValueFromCronElement(split[0])); + date.setMonth(getValueFromCronElement(split[1])); + date.setDay(getValueFromCronElement(split[2])); + date.setHour(getValueFromCronElement(split[3])); + date.setMinute(getValueFromCronElement(split[4])); + date.setUnit(getValueFromCronElement(split[5])); + } + return date; + } + + public static Integer getValueFromCronElement(String token) { + try { + return Integer.parseInt(token); + } catch (NumberFormatException e) { + return null; + } + } + + public Integer getUnit() { + return unit; + } + + public void setUnit(Integer unit) { + this.unit = unit; + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotification.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotification.java new file mode 100644 index 000000000..5582392d7 --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotification.java @@ -0,0 +1,383 @@ +package com.capacitorjs.plugins.localnotifications; + +import android.content.ContentResolver; +import android.content.Context; +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Logger; +import com.getcapacitor.PluginCall; +import com.getcapacitor.plugin.util.AssetUtil; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.List; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Local notification object mapped from json plugin + */ +public class LocalNotification { + + private String title; + private String body; + private Integer id; + private String sound; + private String smallIcon; + private String iconColor; + private String actionTypeId; + private String group; + private boolean groupSummary; + private boolean ongoing; + private boolean autoCancel; + private JSObject extra; + private List attachments; + private LocalNotificationSchedule schedule; + private String channelId; + + private String source; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public LocalNotificationSchedule getSchedule() { + return schedule; + } + + public void setSchedule(LocalNotificationSchedule schedule) { + this.schedule = schedule; + } + + public String getSound(Context context, int defaultSound) { + String soundPath = null; + int resId = AssetUtil.RESOURCE_ID_ZERO_VALUE; + String name = AssetUtil.getResourceBaseName(sound); + if (name != null) { + resId = AssetUtil.getResourceID(context, name, "raw"); + } + if (resId == AssetUtil.RESOURCE_ID_ZERO_VALUE) { + resId = defaultSound; + } + if (resId != AssetUtil.RESOURCE_ID_ZERO_VALUE) { + soundPath = ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.getPackageName() + "/" + resId; + } + return soundPath; + } + + public void setSound(String sound) { + this.sound = sound; + } + + public void setSmallIcon(String smallIcon) { + this.smallIcon = AssetUtil.getResourceBaseName(smallIcon); + } + + public String getIconColor(String globalColor) { + // use the one defined local before trying for a globally defined color + if (iconColor != null) { + return iconColor; + } + + return globalColor; + } + + public void setIconColor(String iconColor) { + this.iconColor = iconColor; + } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } + + public String getActionTypeId() { + return actionTypeId; + } + + public void setActionTypeId(String actionTypeId) { + this.actionTypeId = actionTypeId; + } + + public String getGroup() { + return group; + } + + public void setGroup(String group) { + this.group = group; + } + + public JSObject getExtra() { + return extra; + } + + public void setExtra(JSObject extra) { + this.extra = extra; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public boolean isGroupSummary() { + return groupSummary; + } + + public void setGroupSummary(boolean groupSummary) { + this.groupSummary = groupSummary; + } + + public boolean isOngoing() { + return ongoing; + } + + public void setOngoing(boolean ongoing) { + this.ongoing = ongoing; + } + + public boolean isAutoCancel() { + return autoCancel; + } + + public void setAutoCancel(boolean autoCancel) { + this.autoCancel = autoCancel; + } + + public String getChannelId() { + return channelId; + } + + public void setChannelId(String channelId) { + this.channelId = channelId; + } + + /** + * Build list of the notifications from remote plugin call + */ + public static List buildNotificationList(PluginCall call) { + JSArray notificationArray = call.getArray("notifications"); + if (notificationArray == null) { + call.reject("Must provide notifications array as notifications option"); + return null; + } + List resultLocalNotifications = new ArrayList<>(notificationArray.length()); + List notificationsJson; + try { + notificationsJson = notificationArray.toList(); + } catch (JSONException e) { + call.reject("Provided notification format is invalid"); + return null; + } + + for (JSONObject jsonNotification : notificationsJson) { + JSObject notification = null; + try { + notification = JSObject.fromJSONObject(jsonNotification); + } catch (JSONException e) { + call.reject("Invalid JSON object sent to NotificationPlugin", e); + return null; + } + + try { + LocalNotification activeLocalNotification = buildNotificationFromJSObject(notification); + resultLocalNotifications.add(activeLocalNotification); + } catch (ParseException e) { + call.reject("Invalid date format sent to Notification plugin", e); + return null; + } + } + return resultLocalNotifications; + } + + public static LocalNotification buildNotificationFromJSObject(JSObject jsonObject) throws ParseException { + LocalNotification localNotification = new LocalNotification(); + localNotification.setSource(jsonObject.toString()); + localNotification.setId(jsonObject.getInteger("id")); + localNotification.setBody(jsonObject.getString("body")); + localNotification.setActionTypeId(jsonObject.getString("actionTypeId")); + localNotification.setGroup(jsonObject.getString("group")); + localNotification.setSound(jsonObject.getString("sound")); + localNotification.setTitle(jsonObject.getString("title")); + localNotification.setSmallIcon(jsonObject.getString("smallIcon")); + localNotification.setIconColor(jsonObject.getString("iconColor")); + localNotification.setAttachments(LocalNotificationAttachment.getAttachments(jsonObject)); + localNotification.setGroupSummary(jsonObject.getBoolean("groupSummary", false)); + localNotification.setChannelId(jsonObject.getString("channelId")); + localNotification.setSchedule(new LocalNotificationSchedule(jsonObject)); + localNotification.setExtra(jsonObject.getJSObject("extra")); + localNotification.setOngoing(jsonObject.getBoolean("ongoing", false)); + localNotification.setAutoCancel(jsonObject.getBoolean("autoCancel", true)); + + return localNotification; + } + + public static List getLocalNotificationPendingList(PluginCall call) { + List notifications = null; + try { + notifications = call.getArray("notifications").toList(); + } catch (JSONException e) {} + if (notifications == null || notifications.size() == 0) { + call.reject("Must provide notifications array as notifications option"); + return null; + } + List notificationsList = new ArrayList<>(notifications.size()); + for (JSONObject notificationToCancel : notifications) { + try { + notificationsList.add(notificationToCancel.getInt("id")); + } catch (JSONException e) {} + } + return notificationsList; + } + + public static JSObject buildLocalNotificationPendingList(List ids) { + JSObject result = new JSObject(); + JSArray jsArray = new JSArray(); + for (String id : ids) { + JSObject notification = new JSObject(); + notification.put("id", id); + jsArray.put(notification); + } + result.put("notifications", jsArray); + return result; + } + + public int getSmallIcon(Context context, int defaultIcon) { + int resId = AssetUtil.RESOURCE_ID_ZERO_VALUE; + + if (smallIcon != null) { + resId = AssetUtil.getResourceID(context, smallIcon, "drawable"); + } + + if (resId == AssetUtil.RESOURCE_ID_ZERO_VALUE) { + resId = defaultIcon; + } + + return resId; + } + + public boolean isScheduled() { + return ( + this.schedule != null && (this.schedule.getOn() != null || this.schedule.getAt() != null || this.schedule.getEvery() != null) + ); + } + + @Override + public String toString() { + return ( + "LocalNotification{" + + "title='" + + title + + '\'' + + ", body='" + + body + + '\'' + + ", id=" + + id + + ", sound='" + + sound + + '\'' + + ", smallIcon='" + + smallIcon + + '\'' + + ", iconColor='" + + iconColor + + '\'' + + ", actionTypeId='" + + actionTypeId + + '\'' + + ", group='" + + group + + '\'' + + ", extra=" + + extra + + ", attachments=" + + attachments + + ", schedule=" + + schedule + + ", groupSummary=" + + groupSummary + + ", ongoing=" + + ongoing + + ", autoCancel=" + + autoCancel + + '}' + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + LocalNotification that = (LocalNotification) o; + + if (title != null ? !title.equals(that.title) : that.title != null) return false; + if (body != null ? !body.equals(that.body) : that.body != null) return false; + if (id != null ? !id.equals(that.id) : that.id != null) return false; + if (sound != null ? !sound.equals(that.sound) : that.sound != null) return false; + if (smallIcon != null ? !smallIcon.equals(that.smallIcon) : that.smallIcon != null) return false; + if (iconColor != null ? !iconColor.equals(that.iconColor) : that.iconColor != null) return false; + if (actionTypeId != null ? !actionTypeId.equals(that.actionTypeId) : that.actionTypeId != null) return false; + if (group != null ? !group.equals(that.group) : that.group != null) return false; + if (extra != null ? !extra.equals(that.extra) : that.extra != null) return false; + if (attachments != null ? !attachments.equals(that.attachments) : that.attachments != null) return false; + if (groupSummary != that.groupSummary) return false; + if (ongoing != that.ongoing) return false; + if (autoCancel != that.autoCancel) return false; + return schedule != null ? schedule.equals(that.schedule) : that.schedule == null; + } + + @Override + public int hashCode() { + int result = title != null ? title.hashCode() : 0; + result = 31 * result + (body != null ? body.hashCode() : 0); + result = 31 * result + (id != null ? id.hashCode() : 0); + result = 31 * result + (sound != null ? sound.hashCode() : 0); + result = 31 * result + (smallIcon != null ? smallIcon.hashCode() : 0); + result = 31 * result + (iconColor != null ? iconColor.hashCode() : 0); + result = 31 * result + (actionTypeId != null ? actionTypeId.hashCode() : 0); + result = 31 * result + (group != null ? group.hashCode() : 0); + result = 31 * result + Boolean.hashCode(groupSummary); + result = 31 * result + Boolean.hashCode(ongoing); + result = 31 * result + Boolean.hashCode(autoCancel); + result = 31 * result + (extra != null ? extra.hashCode() : 0); + result = 31 * result + (attachments != null ? attachments.hashCode() : 0); + result = 31 * result + (schedule != null ? schedule.hashCode() : 0); + return result; + } + + public void setExtraFromString(String extraFromString) { + try { + JSONObject jsonObject = new JSONObject(extraFromString); + this.extra = JSObject.fromJSONObject(jsonObject); + } catch (JSONException e) { + Logger.error(Logger.tags("LN"), "Cannot rebuild extra data", e); + } + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationAttachment.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationAttachment.java new file mode 100644 index 000000000..8b4ead25c --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationAttachment.java @@ -0,0 +1,70 @@ +package com.capacitorjs.plugins.localnotifications; + +import com.getcapacitor.JSObject; +import java.util.ArrayList; +import java.util.List; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class LocalNotificationAttachment { + + private String id; + private String url; + private JSONObject options; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public JSONObject getOptions() { + return options; + } + + public void setOptions(JSONObject options) { + this.options = options; + } + + public static List getAttachments(JSObject notification) { + List attachmentsList = new ArrayList<>(); + JSONArray attachments = null; + try { + attachments = notification.getJSONArray("attachments"); + } catch (Exception e) {} + if (attachments != null) { + for (int i = 0; i < attachments.length(); i++) { + LocalNotificationAttachment newAttachment = new LocalNotificationAttachment(); + JSONObject jsonObject = null; + try { + jsonObject = attachments.getJSONObject(i); + } catch (JSONException e) {} + if (jsonObject != null) { + JSObject jsObject = null; + try { + jsObject = JSObject.fromJSONObject(jsonObject); + } catch (JSONException e) {} + newAttachment.setId(jsObject.getString("id")); + newAttachment.setUrl(jsObject.getString("url")); + try { + newAttachment.setOptions(jsObject.getJSONObject("options")); + } catch (JSONException e) {} + attachmentsList.add(newAttachment); + } + } + } + + return attachmentsList; + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationManager.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationManager.java new file mode 100644 index 000000000..29e52adc3 --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationManager.java @@ -0,0 +1,427 @@ +package com.capacitorjs.plugins.localnotifications; + +import android.app.Activity; +import android.app.AlarmManager; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.PendingIntent; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.media.AudioAttributes; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.app.RemoteInput; +import com.getcapacitor.CapConfig; +import com.getcapacitor.JSObject; +import com.getcapacitor.Logger; +import com.getcapacitor.PluginCall; +import com.getcapacitor.android.R; +import com.getcapacitor.plugin.util.AssetUtil; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Contains implementations for all notification actions + */ +public class LocalNotificationManager { + + private static final String CONFIG_KEY_PREFIX = "plugins.LocalNotifications."; + private static int defaultSoundID = AssetUtil.RESOURCE_ID_ZERO_VALUE; + private static int defaultSmallIconID = AssetUtil.RESOURCE_ID_ZERO_VALUE; + // Action constants + public static final String NOTIFICATION_INTENT_KEY = "LocalNotificationId"; + public static final String NOTIFICATION_OBJ_INTENT_KEY = "LocalNotficationObject"; + public static final String ACTION_INTENT_KEY = "LocalNotificationUserAction"; + public static final String NOTIFICATION_IS_REMOVABLE_KEY = "LocalNotificationRepeating"; + public static final String REMOTE_INPUT_KEY = "LocalNotificationRemoteInput"; + + public static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "default"; + private static final String DEFAULT_PRESS_ACTION = "tap"; + + private Context context; + private Activity activity; + private NotificationStorage storage; + private CapConfig config; + + public LocalNotificationManager(NotificationStorage notificationStorage, Activity activity, Context context, CapConfig config) { + storage = notificationStorage; + this.activity = activity; + this.context = context; + this.config = config; + } + + /** + * Method extecuted when notification is launched by user from the notification bar. + */ + public JSObject handleNotificationActionPerformed(Intent data, NotificationStorage notificationStorage) { + Logger.debug(Logger.tags("LN"), "LocalNotification received: " + data.getDataString()); + int notificationId = data.getIntExtra(LocalNotificationManager.NOTIFICATION_INTENT_KEY, Integer.MIN_VALUE); + if (notificationId == Integer.MIN_VALUE) { + Logger.debug(Logger.tags("LN"), "Activity started without notification attached"); + return null; + } + boolean isRemovable = data.getBooleanExtra(LocalNotificationManager.NOTIFICATION_IS_REMOVABLE_KEY, true); + if (isRemovable) { + notificationStorage.deleteNotification(Integer.toString(notificationId)); + } + JSObject dataJson = new JSObject(); + + Bundle results = RemoteInput.getResultsFromIntent(data); + if (results != null) { + CharSequence input = results.getCharSequence(LocalNotificationManager.REMOTE_INPUT_KEY); + dataJson.put("inputValue", input.toString()); + } + String menuAction = data.getStringExtra(LocalNotificationManager.ACTION_INTENT_KEY); + + dismissVisibleNotification(notificationId); + + dataJson.put("actionId", menuAction); + JSONObject request = null; + try { + String notificationJsonString = data.getStringExtra(LocalNotificationManager.NOTIFICATION_OBJ_INTENT_KEY); + if (notificationJsonString != null) { + request = new JSObject(notificationJsonString); + } + } catch (JSONException e) {} + dataJson.put("notification", request); + return dataJson; + } + + /** + * Create notification channel + */ + public void createNotificationChannel() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = "Default"; + String description = "Default"; + int importance = android.app.NotificationManager.IMPORTANCE_DEFAULT; + NotificationChannel channel = new NotificationChannel(DEFAULT_NOTIFICATION_CHANNEL_ID, name, importance); + channel.setDescription(description); + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ALARM) + .build(); + Uri soundUri = this.getDefaultSoundUrl(context); + if (soundUri != null) { + channel.setSound(soundUri, audioAttributes); + } + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + android.app.NotificationManager notificationManager = context.getSystemService(android.app.NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + @Nullable + public JSONArray schedule(PluginCall call, List localNotifications) { + JSONArray ids = new JSONArray(); + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + + boolean notificationsEnabled = notificationManager.areNotificationsEnabled(); + if (!notificationsEnabled) { + if (call != null) { + call.reject("Notifications not enabled on this device"); + } + return null; + } + for (LocalNotification localNotification : localNotifications) { + Integer id = localNotification.getId(); + if (localNotification.getId() == null) { + if (call != null) { + call.reject("LocalNotification missing identifier"); + } + return null; + } + dismissVisibleNotification(id); + cancelTimerForNotification(id); + buildNotification(notificationManager, localNotification, call); + ids.put(id); + } + return ids; + } + + // TODO Progressbar support + // TODO System categories (DO_NOT_DISTURB etc.) + // TODO control visibility by flag Notification.VISIBILITY_PRIVATE + // TODO Group notifications (setGroup, setGroupSummary, setNumber) + // TODO use NotificationCompat.MessagingStyle for latest API + // TODO expandable notification NotificationCompat.MessagingStyle + // TODO media style notification support NotificationCompat.MediaStyle + // TODO custom small/large icons + private void buildNotification(NotificationManagerCompat notificationManager, LocalNotification localNotification, PluginCall call) { + String channelId = DEFAULT_NOTIFICATION_CHANNEL_ID; + if (localNotification.getChannelId() != null) { + channelId = localNotification.getChannelId(); + } + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(this.context, channelId) + .setContentTitle(localNotification.getTitle()) + .setContentText(localNotification.getBody()) + .setAutoCancel(localNotification.isAutoCancel()) + .setOngoing(localNotification.isOngoing()) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setGroupSummary(localNotification.isGroupSummary()); + + // support multiline text + mBuilder.setStyle(new NotificationCompat.BigTextStyle().bigText(localNotification.getBody())); + + String sound = localNotification.getSound(context, getDefaultSound(context)); + if (sound != null) { + Uri soundUri = Uri.parse(sound); + // Grant permission to use sound + context.grantUriPermission("com.android.systemui", soundUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + mBuilder.setSound(soundUri); + mBuilder.setDefaults(Notification.DEFAULT_VIBRATE | Notification.DEFAULT_LIGHTS); + } else { + mBuilder.setDefaults(Notification.DEFAULT_ALL); + } + + String group = localNotification.getGroup(); + if (group != null) { + mBuilder.setGroup(group); + } + + // make sure scheduled time is shown instead of display time + if (localNotification.isScheduled() && localNotification.getSchedule().getAt() != null) { + mBuilder.setWhen(localNotification.getSchedule().getAt().getTime()).setShowWhen(true); + } + + mBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE); + mBuilder.setOnlyAlertOnce(true); + + mBuilder.setSmallIcon(localNotification.getSmallIcon(context, getDefaultSmallIcon(context))); + + String iconColor = localNotification.getIconColor(config.getString(CONFIG_KEY_PREFIX + "iconColor")); + if (iconColor != null) { + try { + mBuilder.setColor(Color.parseColor(iconColor)); + } catch (IllegalArgumentException ex) { + if (call != null) { + call.reject("Invalid color provided. Must be a hex string (ex: #ff0000"); + } + return; + } + } + + createActionIntents(localNotification, mBuilder); + // notificationId is a unique int for each localNotification that you must define + Notification buildNotification = mBuilder.build(); + if (localNotification.isScheduled()) { + triggerScheduledNotification(buildNotification, localNotification); + } else { + notificationManager.notify(localNotification.getId(), buildNotification); + } + } + + // Create intents for open/dissmis actions + private void createActionIntents(LocalNotification localNotification, NotificationCompat.Builder mBuilder) { + // Open intent + Intent intent = buildIntent(localNotification, DEFAULT_PRESS_ACTION); + + PendingIntent pendingIntent = PendingIntent.getActivity( + context, + localNotification.getId(), + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ); + mBuilder.setContentIntent(pendingIntent); + + // Build action types + String actionTypeId = localNotification.getActionTypeId(); + if (actionTypeId != null) { + NotificationAction[] actionGroup = storage.getActionGroup(actionTypeId); + for (NotificationAction notificationAction : actionGroup) { + // TODO Add custom icons to actions + Intent actionIntent = buildIntent(localNotification, notificationAction.getId()); + PendingIntent actionPendingIntent = PendingIntent.getActivity( + context, + localNotification.getId() + notificationAction.getId().hashCode(), + actionIntent, + PendingIntent.FLAG_CANCEL_CURRENT + ); + NotificationCompat.Action.Builder actionBuilder = new NotificationCompat.Action.Builder( + R.drawable.ic_transparent, + notificationAction.getTitle(), + actionPendingIntent + ); + if (notificationAction.isInput()) { + RemoteInput remoteInput = new RemoteInput.Builder(REMOTE_INPUT_KEY).setLabel(notificationAction.getTitle()).build(); + actionBuilder.addRemoteInput(remoteInput); + } + mBuilder.addAction(actionBuilder.build()); + } + } + + // Dismiss intent + Intent dissmissIntent = new Intent(context, NotificationDismissReceiver.class); + dissmissIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + dissmissIntent.putExtra(NOTIFICATION_INTENT_KEY, localNotification.getId()); + dissmissIntent.putExtra(ACTION_INTENT_KEY, "dismiss"); + LocalNotificationSchedule schedule = localNotification.getSchedule(); + dissmissIntent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable()); + PendingIntent deleteIntent = PendingIntent.getBroadcast(context, localNotification.getId(), dissmissIntent, 0); + mBuilder.setDeleteIntent(deleteIntent); + } + + @NonNull + private Intent buildIntent(LocalNotification localNotification, String action) { + Intent intent; + if (activity != null) { + intent = new Intent(context, activity.getClass()); + } else { + String packageName = context.getPackageName(); + intent = context.getPackageManager().getLaunchIntentForPackage(packageName); + } + intent.setAction(Intent.ACTION_MAIN); + intent.addCategory(Intent.CATEGORY_LAUNCHER); + intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtra(NOTIFICATION_INTENT_KEY, localNotification.getId()); + intent.putExtra(ACTION_INTENT_KEY, action); + intent.putExtra(NOTIFICATION_OBJ_INTENT_KEY, localNotification.getSource()); + LocalNotificationSchedule schedule = localNotification.getSchedule(); + intent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable()); + return intent; + } + + /** + * Build a notification trigger, such as triggering each N seconds, or + * on a certain date "shape" (such as every first of the month) + */ + // TODO support different AlarmManager.RTC modes depending on priority + private void triggerScheduledNotification(Notification notification, LocalNotification request) { + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + LocalNotificationSchedule schedule = request.getSchedule(); + Intent notificationIntent = new Intent(context, TimedNotificationPublisher.class); + notificationIntent.putExtra(NOTIFICATION_INTENT_KEY, request.getId()); + notificationIntent.putExtra(TimedNotificationPublisher.NOTIFICATION_KEY, notification); + PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, + request.getId(), + notificationIntent, + PendingIntent.FLAG_CANCEL_CURRENT + ); + + // Schedule at specific time (with repeating support) + Date at = schedule.getAt(); + if (at != null) { + if (at.getTime() < new Date().getTime()) { + Logger.error(Logger.tags("LN"), "Scheduled time must be *after* current time", null); + return; + } + if (schedule.isRepeating()) { + long interval = at.getTime() - new Date().getTime(); + alarmManager.setRepeating(AlarmManager.RTC, at.getTime(), interval, pendingIntent); + } else { + alarmManager.setExact(AlarmManager.RTC, at.getTime(), pendingIntent); + } + return; + } + + // Schedule at specific intervals + String every = schedule.getEvery(); + if (every != null) { + Long everyInterval = schedule.getEveryInterval(); + if (everyInterval != null) { + long startTime = new Date().getTime() + everyInterval; + alarmManager.setRepeating(AlarmManager.RTC, startTime, everyInterval, pendingIntent); + } + return; + } + + // Cron like scheduler + DateMatch on = schedule.getOn(); + if (on != null) { + long trigger = on.nextTrigger(new Date()); + notificationIntent.putExtra(TimedNotificationPublisher.CRON_KEY, on.toMatchString()); + pendingIntent = PendingIntent.getBroadcast(context, request.getId(), notificationIntent, PendingIntent.FLAG_CANCEL_CURRENT); + alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"); + Logger.debug(Logger.tags("LN"), "notification " + request.getId() + " will next fire at " + sdf.format(new Date(trigger))); + } + } + + public void cancel(PluginCall call) { + List notificationsToCancel = LocalNotification.getLocalNotificationPendingList(call); + if (notificationsToCancel != null) { + for (Integer id : notificationsToCancel) { + dismissVisibleNotification(id); + cancelTimerForNotification(id); + storage.deleteNotification(Integer.toString(id)); + } + } + call.resolve(); + } + + private void cancelTimerForNotification(Integer notificationId) { + Intent intent = new Intent(context, TimedNotificationPublisher.class); + PendingIntent pi = PendingIntent.getBroadcast(context, notificationId, intent, 0); + if (pi != null) { + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + alarmManager.cancel(pi); + } + } + + private void dismissVisibleNotification(int notificationId) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this.context); + notificationManager.cancel(notificationId); + } + + public boolean areNotificationsEnabled() { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + return notificationManager.areNotificationsEnabled(); + } + + public Uri getDefaultSoundUrl(Context context) { + int soundId = this.getDefaultSound(context); + if (soundId != AssetUtil.RESOURCE_ID_ZERO_VALUE) { + return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.getPackageName() + "/" + soundId); + } + return null; + } + + private int getDefaultSound(Context context) { + if (defaultSoundID != AssetUtil.RESOURCE_ID_ZERO_VALUE) return defaultSoundID; + + int resId = AssetUtil.RESOURCE_ID_ZERO_VALUE; + String soundConfigResourceName = config.getString(CONFIG_KEY_PREFIX + "sound"); + soundConfigResourceName = AssetUtil.getResourceBaseName(soundConfigResourceName); + + if (soundConfigResourceName != null) { + resId = AssetUtil.getResourceID(context, soundConfigResourceName, "raw"); + } + + defaultSoundID = resId; + return resId; + } + + private int getDefaultSmallIcon(Context context) { + if (defaultSmallIconID != AssetUtil.RESOURCE_ID_ZERO_VALUE) return defaultSmallIconID; + + int resId = AssetUtil.RESOURCE_ID_ZERO_VALUE; + String smallIconConfigResourceName = config.getString(CONFIG_KEY_PREFIX + "smallIcon"); + smallIconConfigResourceName = AssetUtil.getResourceBaseName(smallIconConfigResourceName); + + if (smallIconConfigResourceName != null) { + resId = AssetUtil.getResourceID(context, smallIconConfigResourceName, "drawable"); + } + + if (resId == AssetUtil.RESOURCE_ID_ZERO_VALUE) { + resId = android.R.drawable.ic_dialog_info; + } + + defaultSmallIconID = resId; + return resId; + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationRestoreReceiver.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationRestoreReceiver.java new file mode 100644 index 000000000..e365942bc --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationRestoreReceiver.java @@ -0,0 +1,58 @@ +package com.capacitorjs.plugins.localnotifications; + +import static android.os.Build.VERSION.SDK_INT; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.UserManager; +import com.getcapacitor.CapConfig; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +public class LocalNotificationRestoreReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (SDK_INT >= 24) { + UserManager um = context.getSystemService(UserManager.class); + if (um == null || !um.isUserUnlocked()) return; + } + + NotificationStorage storage = new NotificationStorage(context); + List ids = storage.getSavedNotificationIds(); + + ArrayList notifications = new ArrayList<>(ids.size()); + ArrayList updatedNotifications = new ArrayList<>(); + for (String id : ids) { + LocalNotification notification = storage.getSavedNotification(id); + if (notification == null) { + continue; + } + + LocalNotificationSchedule schedule = notification.getSchedule(); + if (schedule != null) { + Date at = schedule.getAt(); + if (at != null && at.before(new Date())) { + // modify the scheduled date in order to show notifications that would have been delivered while device was off. + long newDateTime = new Date().getTime() + 15 * 1000; + schedule.setAt(new Date(newDateTime)); + notification.setSchedule(schedule); + updatedNotifications.add(notification); + } + } + + notifications.add(notification); + } + + if (updatedNotifications.size() > 0) { + storage.appendNotifications(updatedNotifications); + } + + CapConfig config = CapConfig.loadDefault(context); + LocalNotificationManager localNotificationManager = new LocalNotificationManager(storage, null, context, config); + + localNotificationManager.schedule(null, notifications); + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationSchedule.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationSchedule.java new file mode 100644 index 000000000..0843db7cd --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationSchedule.java @@ -0,0 +1,159 @@ +package com.capacitorjs.plugins.localnotifications; + +import android.text.format.DateUtils; +import com.getcapacitor.JSObject; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +public class LocalNotificationSchedule { + + public static String JS_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; + + private Date at; + private Boolean repeats; + private String every; + private Integer count; + + private DateMatch on; + + public LocalNotificationSchedule(JSObject jsonNotification) throws ParseException { + JSObject schedule = jsonNotification.getJSObject("schedule"); + if (schedule != null) { + // Every specific unit of time (always constant) + buildEveryElement(schedule); + // Count of units of time from every to repeat on + buildCountElement(schedule); + // At specific moment of time (with repeating option) + buildAtElement(schedule); + // Build on - recurring times. For e.g. every 1st day of the month at 8:30. + buildOnElement(schedule); + } + } + + public LocalNotificationSchedule() {} + + private void buildEveryElement(JSObject schedule) { + // 'year'|'month'|'two-weeks'|'week'|'day'|'hour'|'minute'|'second'; + this.every = schedule.getString("every"); + } + + private void buildCountElement(JSObject schedule) { + this.count = schedule.getInteger("count", 1); + } + + private void buildAtElement(JSObject schedule) throws ParseException { + this.repeats = schedule.getBool("repeats"); + String dateString = schedule.getString("at"); + if (dateString != null) { + SimpleDateFormat sdf = new SimpleDateFormat(JS_DATE_FORMAT); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + this.at = sdf.parse(dateString); + } + } + + private void buildOnElement(JSObject schedule) { + JSObject onJson = schedule.getJSObject("on"); + if (onJson != null) { + this.on = new DateMatch(); + on.setYear(onJson.getInteger("year")); + on.setMonth(onJson.getInteger("month")); + on.setDay(onJson.getInteger("day")); + on.setHour(onJson.getInteger("hour")); + on.setMinute(onJson.getInteger("minute")); + } + } + + public DateMatch getOn() { + return on; + } + + public void setOn(DateMatch on) { + this.on = on; + } + + public Date getAt() { + return at; + } + + public void setAt(Date at) { + this.at = at; + } + + public Boolean getRepeats() { + return repeats; + } + + public void setRepeats(Boolean repeats) { + this.repeats = repeats; + } + + public String getEvery() { + return every; + } + + public void setEvery(String every) { + this.every = every; + } + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public boolean isRepeating() { + return Boolean.TRUE.equals(this.repeats); + } + + public boolean isRemovable() { + if (every == null && on == null) { + if (at != null) { + return !isRepeating(); + } else { + return true; + } + } + return false; + } + + /** + * Get constant long value representing specific interval of time (weeks, days etc.) + */ + public Long getEveryInterval() { + switch (every) { + case "year": + return count * DateUtils.YEAR_IN_MILLIS; + case "month": + // This case is just approximation as months have different number of days + return count * 30 * DateUtils.DAY_IN_MILLIS; + case "two-weeks": + return count * 2 * DateUtils.WEEK_IN_MILLIS; + case "week": + return count * DateUtils.WEEK_IN_MILLIS; + case "day": + return count * DateUtils.DAY_IN_MILLIS; + case "hour": + return count * DateUtils.HOUR_IN_MILLIS; + case "minute": + return count * DateUtils.MINUTE_IN_MILLIS; + case "second": + return count * DateUtils.SECOND_IN_MILLIS; + default: + return null; + } + } + + /** + * Get next trigger time based on calendar and current time + * + * @param currentTime - current time that will be used to calculate next trigger + * @return millisecond trigger + */ + public Long getNextOnSchedule(Date currentTime) { + return this.on.nextTrigger(currentTime); + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationsPlugin.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationsPlugin.java new file mode 100644 index 000000000..2983c99bc --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationsPlugin.java @@ -0,0 +1,116 @@ +package com.capacitorjs.plugins.localnotifications; + +import android.content.Intent; +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; +import java.util.List; +import java.util.Map; +import org.json.JSONArray; + +@CapacitorPlugin(name = "LocalNotifications", permissions = @Permission(strings = {}, alias = "display")) +public class LocalNotificationsPlugin extends Plugin { + + private LocalNotificationManager manager; + private NotificationStorage notificationStorage; + private NotificationChannelManager notificationChannelManager; + + @Override + public void load() { + super.load(); + notificationStorage = new NotificationStorage(getContext()); + manager = new LocalNotificationManager(notificationStorage, getActivity(), getContext(), this.bridge.getConfig()); + manager.createNotificationChannel(); + notificationChannelManager = new NotificationChannelManager(getActivity()); + } + + @Override + protected void handleOnNewIntent(Intent data) { + super.handleOnNewIntent(data); + if (!Intent.ACTION_MAIN.equals(data.getAction())) { + return; + } + JSObject dataJson = manager.handleNotificationActionPerformed(data, notificationStorage); + if (dataJson != null) { + notifyListeners("actionPerformed", dataJson, true); + } + } + + @Override + protected void handleOnActivityResult(PluginCall savedCall, int requestCode, int resultCode, Intent data) { + super.handleOnActivityResult(savedCall, requestCode, resultCode, data); + this.handleOnNewIntent(data); + } + + /** + * Schedule a notification call from JavaScript + * Creates local notification in system. + */ + @PluginMethod + public void schedule(PluginCall call) { + List localNotifications = LocalNotification.buildNotificationList(call); + if (localNotifications == null) { + return; + } + JSONArray ids = manager.schedule(call, localNotifications); + if (ids != null) { + notificationStorage.appendNotifications(localNotifications); + JSObject result = new JSObject(); + JSArray jsArray = new JSArray(); + for (int i = 0; i < ids.length(); i++) { + try { + JSObject notification = new JSObject().put("id", ids.getString(i)); + jsArray.put(notification); + } catch (Exception ex) {} + } + result.put("notifications", jsArray); + call.resolve(result); + } + } + + @PluginMethod + public void cancel(PluginCall call) { + manager.cancel(call); + } + + @PluginMethod + public void getPending(PluginCall call) { + List ids = notificationStorage.getSavedNotificationIds(); + JSObject result = LocalNotification.buildLocalNotificationPendingList(ids); + call.resolve(result); + } + + @PluginMethod + public void registerActionTypes(PluginCall call) { + JSArray types = call.getArray("types"); + Map typesArray = NotificationAction.buildTypes(types); + notificationStorage.writeActionGroup(typesArray); + call.resolve(); + } + + @PluginMethod + public void areEnabled(PluginCall call) { + JSObject data = new JSObject(); + data.put("value", manager.areNotificationsEnabled()); + call.resolve(data); + } + + @PluginMethod + public void createChannel(PluginCall call) { + notificationChannelManager.createChannel(call); + } + + @PluginMethod + public void deleteChannel(PluginCall call) { + notificationChannelManager.deleteChannel(call); + } + + @PluginMethod + public void listChannels(PluginCall call) { + notificationChannelManager.listChannels(call); + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationAction.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationAction.java new file mode 100644 index 000000000..d4bc88fa5 --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationAction.java @@ -0,0 +1,82 @@ +package com.capacitorjs.plugins.localnotifications; + +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Logger; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Action types that will be registered for the notifications + */ +public class NotificationAction { + + private String id; + private String title; + private Boolean input; + + public NotificationAction() {} + + public NotificationAction(String id, String title, Boolean input) { + this.id = id; + this.title = title; + this.input = input; + } + + public static Map buildTypes(JSArray types) { + Map actionTypeMap = new HashMap<>(); + try { + List objects = types.toList(); + for (JSONObject obj : objects) { + JSObject jsObject = JSObject.fromJSONObject(obj); + String actionGroupId = jsObject.getString("id"); + if (actionGroupId == null) { + return null; + } + JSONArray actions = jsObject.getJSONArray("actions"); + if (actions != null) { + NotificationAction[] typesArray = new NotificationAction[actions.length()]; + for (int i = 0; i < typesArray.length; i++) { + NotificationAction notificationAction = new NotificationAction(); + JSObject action = JSObject.fromJSONObject(actions.getJSONObject(i)); + notificationAction.setId(action.getString("id")); + notificationAction.setTitle(action.getString("title")); + notificationAction.setInput(action.getBool("input")); + typesArray[i] = notificationAction; + } + actionTypeMap.put(actionGroupId, typesArray); + } + } + } catch (Exception e) { + Logger.error(Logger.tags("LN"), "Error when building action types", e); + } + return actionTypeMap; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public boolean isInput() { + return Boolean.TRUE.equals(input); + } + + public void setInput(Boolean input) { + this.input = input; + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationChannelManager.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationChannelManager.java new file mode 100644 index 000000000..bf969684e --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationChannelManager.java @@ -0,0 +1,132 @@ +package com.capacitorjs.plugins.localnotifications; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Color; +import android.media.AudioAttributes; +import android.net.Uri; +import androidx.core.app.NotificationCompat; +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Logger; +import com.getcapacitor.PluginCall; +import java.util.List; + +public class NotificationChannelManager { + + private Context context; + private NotificationManager notificationManager; + + public NotificationChannelManager(Context context) { + this.context = context; + this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + } + + public NotificationChannelManager(Context context, NotificationManager manager) { + this.context = context; + this.notificationManager = manager; + } + + private static String CHANNEL_ID = "id"; + private static String CHANNEL_NAME = "name"; + private static String CHANNEL_DESCRIPTION = "description"; + private static String CHANNEL_IMPORTANCE = "importance"; + private static String CHANNEL_VISIBILITY = "visibility"; + private static String CHANNEL_SOUND = "sound"; + private static String CHANNEL_VIBRATE = "vibration"; + private static String CHANNEL_USE_LIGHTS = "lights"; + private static String CHANNEL_LIGHT_COLOR = "lightColor"; + + public void createChannel(PluginCall call) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + JSObject channel = new JSObject(); + channel.put(CHANNEL_ID, call.getString(CHANNEL_ID)); + channel.put(CHANNEL_NAME, call.getString(CHANNEL_NAME)); + channel.put(CHANNEL_DESCRIPTION, call.getString(CHANNEL_DESCRIPTION, "")); + channel.put(CHANNEL_VISIBILITY, call.getInt(CHANNEL_VISIBILITY, NotificationCompat.VISIBILITY_PUBLIC)); + channel.put(CHANNEL_IMPORTANCE, call.getInt(CHANNEL_IMPORTANCE)); + channel.put(CHANNEL_SOUND, call.getString(CHANNEL_SOUND, null)); + channel.put(CHANNEL_VIBRATE, call.getBoolean(CHANNEL_VIBRATE, false)); + channel.put(CHANNEL_USE_LIGHTS, call.getBoolean(CHANNEL_USE_LIGHTS, false)); + channel.put(CHANNEL_LIGHT_COLOR, call.getString(CHANNEL_LIGHT_COLOR, null)); + createChannel(channel); + call.resolve(); + } else { + call.unavailable(); + } + } + + public void createChannel(JSObject channel) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + NotificationChannel notificationChannel = new NotificationChannel( + channel.getString(CHANNEL_ID), + channel.getString(CHANNEL_NAME), + channel.getInteger(CHANNEL_IMPORTANCE) + ); + notificationChannel.setDescription(channel.getString(CHANNEL_DESCRIPTION)); + notificationChannel.setLockscreenVisibility(channel.getInteger(CHANNEL_VISIBILITY)); + notificationChannel.enableVibration(channel.getBool(CHANNEL_VIBRATE)); + notificationChannel.enableLights(channel.getBool(CHANNEL_USE_LIGHTS)); + String lightColor = channel.getString(CHANNEL_LIGHT_COLOR); + if (lightColor != null) { + try { + notificationChannel.setLightColor(Color.parseColor(lightColor)); + } catch (IllegalArgumentException ex) { + Logger.error(Logger.tags("NotificationChannel"), "Invalid color provided for light color.", null); + } + } + String sound = channel.getString(CHANNEL_SOUND, null); + if (sound != null && !sound.isEmpty()) { + if (sound.contains(".")) { + sound = sound.substring(0, sound.lastIndexOf('.')); + } + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build(); + Uri soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.getPackageName() + "/raw/" + sound); + notificationChannel.setSound(soundUri, audioAttributes); + } + notificationManager.createNotificationChannel(notificationChannel); + } + } + + public void deleteChannel(PluginCall call) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + String channelId = call.getString("id"); + notificationManager.deleteNotificationChannel(channelId); + call.resolve(); + } else { + call.unavailable(); + } + } + + public void listChannels(PluginCall call) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + List notificationChannels = notificationManager.getNotificationChannels(); + JSArray channels = new JSArray(); + for (NotificationChannel notificationChannel : notificationChannels) { + JSObject channel = new JSObject(); + channel.put(CHANNEL_ID, notificationChannel.getId()); + channel.put(CHANNEL_NAME, notificationChannel.getName()); + channel.put(CHANNEL_DESCRIPTION, notificationChannel.getDescription()); + channel.put(CHANNEL_IMPORTANCE, notificationChannel.getImportance()); + channel.put(CHANNEL_VISIBILITY, notificationChannel.getLockscreenVisibility()); + channel.put(CHANNEL_SOUND, notificationChannel.getSound()); + channel.put(CHANNEL_VIBRATE, notificationChannel.shouldVibrate()); + channel.put(CHANNEL_USE_LIGHTS, notificationChannel.shouldShowLights()); + channel.put(CHANNEL_LIGHT_COLOR, String.format("#%06X", (0xFFFFFF & notificationChannel.getLightColor()))); + Logger.debug(Logger.tags("NotificationChannel"), "visibility " + notificationChannel.getLockscreenVisibility()); + Logger.debug(Logger.tags("NotificationChannel"), "importance " + notificationChannel.getImportance()); + channels.put(channel); + } + JSObject result = new JSObject(); + result.put("channels", channels); + call.resolve(result); + } else { + call.unavailable(); + } + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationDismissReceiver.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationDismissReceiver.java new file mode 100644 index 000000000..c29cbb19a --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationDismissReceiver.java @@ -0,0 +1,26 @@ +package com.capacitorjs.plugins.localnotifications; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import com.getcapacitor.Logger; + +/** + * Receiver called when notification is dismissed by user + */ +public class NotificationDismissReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + int intExtra = intent.getIntExtra(LocalNotificationManager.NOTIFICATION_INTENT_KEY, Integer.MIN_VALUE); + if (intExtra == Integer.MIN_VALUE) { + Logger.error(Logger.tags("LN"), "Invalid notification dismiss operation", null); + return; + } + boolean isRemovable = intent.getBooleanExtra(LocalNotificationManager.NOTIFICATION_IS_REMOVABLE_KEY, true); + if (isRemovable) { + NotificationStorage notificationStorage = new NotificationStorage(context); + notificationStorage.deleteNotification(Integer.toString(intExtra)); + } + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationStorage.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationStorage.java new file mode 100644 index 000000000..3399d6563 --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/NotificationStorage.java @@ -0,0 +1,146 @@ +package com.capacitorjs.plugins.localnotifications; + +import android.content.Context; +import android.content.SharedPreferences; +import com.getcapacitor.JSObject; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Class used to abstract storage for notification data + */ +public class NotificationStorage { + + // Key for private preferences + private static final String NOTIFICATION_STORE_ID = "NOTIFICATION_STORE"; + + // Key used to save action types + private static final String ACTION_TYPES_ID = "ACTION_TYPE_STORE"; + + private static final String ID_KEY = "notificationIds"; + + private Context context; + + public NotificationStorage(Context context) { + this.context = context; + } + + /** + * Persist the id of currently scheduled notification + */ + public void appendNotifications(List localNotifications) { + SharedPreferences storage = getStorage(NOTIFICATION_STORE_ID); + SharedPreferences.Editor editor = storage.edit(); + for (LocalNotification request : localNotifications) { + String key = request.getId().toString(); + editor.putString(key, request.getSource()); + } + editor.apply(); + } + + public List getSavedNotificationIds() { + SharedPreferences storage = getStorage(NOTIFICATION_STORE_ID); + Map all = storage.getAll(); + if (all != null) { + return new ArrayList<>(all.keySet()); + } + return new ArrayList<>(); + } + + public JSObject getSavedNotificationAsJSObject(String key) { + SharedPreferences storage = getStorage(NOTIFICATION_STORE_ID); + String notificationString = storage.getString(key, null); + + if (notificationString == null) { + return null; + } + + JSObject jsNotification; + try { + jsNotification = new JSObject(notificationString); + } catch (JSONException ex) { + return null; + } + + return jsNotification; + } + + public LocalNotification getSavedNotification(String key) { + JSObject jsNotification = getSavedNotificationAsJSObject(key); + if (jsNotification == null) { + return null; + } + + LocalNotification notification; + try { + notification = LocalNotification.buildNotificationFromJSObject(jsNotification); + } catch (ParseException ex) { + return null; + } + + return notification; + } + + /** + * Remove the stored notifications + */ + public void deleteNotification(String id) { + SharedPreferences.Editor editor = getStorage(NOTIFICATION_STORE_ID).edit(); + editor.remove(id); + editor.apply(); + } + + /** + * Shared private preferences for the application. + */ + private SharedPreferences getStorage(String key) { + return context.getSharedPreferences(key, Context.MODE_PRIVATE); + } + + /** + * Writes new action types (actions that being displayed in notification) to storage. + * Write will override previous data. + * + * @param typesMap - map with groupId and actionArray assigned to group + */ + public void writeActionGroup(Map typesMap) { + Set typesIds = typesMap.keySet(); + for (String id : typesIds) { + SharedPreferences.Editor editor = getStorage(ACTION_TYPES_ID + id).edit(); + editor.clear(); + NotificationAction[] notificationActions = typesMap.get(id); + editor.putInt("count", notificationActions.length); + for (int i = 0; i < notificationActions.length; i++) { + editor.putString("id" + i, notificationActions[i].getId()); + editor.putString("title" + i, notificationActions[i].getTitle()); + editor.putBoolean("input" + i, notificationActions[i].isInput()); + } + editor.apply(); + } + } + + /** + * Retrieve array of notification actions per ActionTypeId + * + * @param forId - id of the group + */ + public NotificationAction[] getActionGroup(String forId) { + SharedPreferences storage = getStorage(ACTION_TYPES_ID + forId); + int count = storage.getInt("count", 0); + NotificationAction[] actions = new NotificationAction[count]; + for (int i = 0; i < count; i++) { + String id = storage.getString("id" + i, ""); + String title = storage.getString("title" + i, ""); + Boolean input = storage.getBoolean("input" + i, false); + actions[i] = new NotificationAction(id, title, input); + } + return actions; + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimedNotificationPublisher.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimedNotificationPublisher.java new file mode 100644 index 000000000..a0b983748 --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimedNotificationPublisher.java @@ -0,0 +1,51 @@ +package com.capacitorjs.plugins.localnotifications; + +import android.app.AlarmManager; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import com.getcapacitor.Logger; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * Class used to create notification from timer event + * Note: Class is being registered in Android manifest as broadcast receiver + */ +public class TimedNotificationPublisher extends BroadcastReceiver { + + public static String NOTIFICATION_KEY = "NotificationPublisher.notification"; + public static String CRON_KEY = "NotificationPublisher.cron"; + + /** + * Restore and present notification + */ + @Override + public void onReceive(Context context, Intent intent) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + Notification notification = intent.getParcelableExtra(NOTIFICATION_KEY); + int id = intent.getIntExtra(LocalNotificationManager.NOTIFICATION_INTENT_KEY, Integer.MIN_VALUE); + if (id == Integer.MIN_VALUE) { + Logger.error(Logger.tags("LN"), "No valid id supplied", null); + } + notificationManager.notify(id, notification); + rescheduleNotificationIfNeeded(context, intent, id); + } + + private void rescheduleNotificationIfNeeded(Context context, Intent intent, int id) { + String dateString = intent.getStringExtra(CRON_KEY); + if (dateString != null) { + DateMatch date = DateMatch.fromMatchString(dateString); + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + long trigger = date.nextTrigger(new Date()); + Intent clone = (Intent) intent.clone(); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, id, clone, PendingIntent.FLAG_CANCEL_CURRENT); + alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"); + Logger.debug(Logger.tags("LN"), "notification " + id + " will next fire at " + sdf.format(new Date(trigger))); + } + } +} diff --git a/local-notifications/android/src/test/java/com/getcapacitor/ExampleUnitTest.java b/local-notifications/android/src/test/java/com/getcapacitor/ExampleUnitTest.java new file mode 100644 index 000000000..a0fed0cfb --- /dev/null +++ b/local-notifications/android/src/test/java/com/getcapacitor/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/local-notifications/ios/Plugin.xcodeproj/project.pbxproj b/local-notifications/ios/Plugin.xcodeproj/project.pbxproj new file mode 100644 index 000000000..027ff9b47 --- /dev/null +++ b/local-notifications/ios/Plugin.xcodeproj/project.pbxproj @@ -0,0 +1,571 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 48; + objects = { + +/* Begin PBXBuildFile section */ + 03FC29A292ACC40490383A1F /* Pods_Plugin.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B2A61DA5A1F2DD4F959604D /* Pods_Plugin.framework */; }; + 20C0B05DCFC8E3958A738AF2 /* Pods_PluginTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F6753A823D3815DB436415E3 /* Pods_PluginTests.framework */; }; + 37F524A5255A0D730085E3FD /* LocalNotificationsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F524A4255A0D730085E3FD /* LocalNotificationsHandler.swift */; }; + 50ADFF92201F53D600D50D53 /* Plugin.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50ADFF88201F53D600D50D53 /* Plugin.framework */; }; + 50ADFF97201F53D600D50D53 /* LocalNotificationsPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50ADFF96201F53D600D50D53 /* LocalNotificationsPluginTests.swift */; }; + 50ADFF99201F53D600D50D53 /* LocalNotificationsPlugin.h in Headers */ = {isa = PBXBuildFile; fileRef = 50ADFF8B201F53D600D50D53 /* LocalNotificationsPlugin.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 50ADFFA42020D75100D50D53 /* Capacitor.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50ADFFA52020D75100D50D53 /* Capacitor.framework */; }; + 50ADFFA82020EE4F00D50D53 /* LocalNotificationsPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = 50ADFFA72020EE4F00D50D53 /* LocalNotificationsPlugin.m */; }; + 50E1A94820377CB70090CE1A /* LocalNotificationsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E1A94720377CB70090CE1A /* LocalNotificationsPlugin.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 50ADFF93201F53D600D50D53 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50ADFF7F201F53D600D50D53 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 50ADFF87201F53D600D50D53; + remoteInfo = Plugin; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 37F524A4255A0D730085E3FD /* LocalNotificationsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNotificationsHandler.swift; sourceTree = ""; }; + 3B2A61DA5A1F2DD4F959604D /* Pods_Plugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Plugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 50ADFF88201F53D600D50D53 /* Plugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Plugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 50ADFF8B201F53D600D50D53 /* LocalNotificationsPlugin.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LocalNotificationsPlugin.h; sourceTree = ""; }; + 50ADFF8C201F53D600D50D53 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50ADFF91201F53D600D50D53 /* PluginTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PluginTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 50ADFF96201F53D600D50D53 /* LocalNotificationsPluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNotificationsPluginTests.swift; sourceTree = ""; }; + 50ADFF98201F53D600D50D53 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50ADFFA52020D75100D50D53 /* Capacitor.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Capacitor.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 50ADFFA72020EE4F00D50D53 /* LocalNotificationsPlugin.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LocalNotificationsPlugin.m; sourceTree = ""; }; + 50E1A94720377CB70090CE1A /* LocalNotificationsPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalNotificationsPlugin.swift; sourceTree = ""; }; + 5E23F77F099397094342571A /* Pods-Plugin.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Plugin.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Plugin/Pods-Plugin.debug.xcconfig"; sourceTree = ""; }; + 91781294A431A2A7CC6EB714 /* Pods-Plugin.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Plugin.release.xcconfig"; path = "Pods/Target Support Files/Pods-Plugin/Pods-Plugin.release.xcconfig"; sourceTree = ""; }; + 96ED1B6440D6672E406C8D19 /* Pods-PluginTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PluginTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-PluginTests/Pods-PluginTests.debug.xcconfig"; sourceTree = ""; }; + F65BB2953ECE002E1EF3E424 /* Pods-PluginTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PluginTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-PluginTests/Pods-PluginTests.release.xcconfig"; sourceTree = ""; }; + F6753A823D3815DB436415E3 /* Pods_PluginTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PluginTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 50ADFF84201F53D600D50D53 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50ADFFA42020D75100D50D53 /* Capacitor.framework in Frameworks */, + 03FC29A292ACC40490383A1F /* Pods_Plugin.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 50ADFF8E201F53D600D50D53 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50ADFF92201F53D600D50D53 /* Plugin.framework in Frameworks */, + 20C0B05DCFC8E3958A738AF2 /* Pods_PluginTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 50ADFF7E201F53D600D50D53 = { + isa = PBXGroup; + children = ( + 50ADFF8A201F53D600D50D53 /* Plugin */, + 50ADFF95201F53D600D50D53 /* PluginTests */, + 50ADFF89201F53D600D50D53 /* Products */, + 8C8E7744173064A9F6D438E3 /* Pods */, + A797B9EFA3DCEFEA1FBB66A9 /* Frameworks */, + ); + sourceTree = ""; + }; + 50ADFF89201F53D600D50D53 /* Products */ = { + isa = PBXGroup; + children = ( + 50ADFF88201F53D600D50D53 /* Plugin.framework */, + 50ADFF91201F53D600D50D53 /* PluginTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 50ADFF8A201F53D600D50D53 /* Plugin */ = { + isa = PBXGroup; + children = ( + 50E1A94720377CB70090CE1A /* LocalNotificationsPlugin.swift */, + 50ADFF8B201F53D600D50D53 /* LocalNotificationsPlugin.h */, + 50ADFFA72020EE4F00D50D53 /* LocalNotificationsPlugin.m */, + 50ADFF8C201F53D600D50D53 /* Info.plist */, + 37F524A4255A0D730085E3FD /* LocalNotificationsHandler.swift */, + ); + path = Plugin; + sourceTree = ""; + }; + 50ADFF95201F53D600D50D53 /* PluginTests */ = { + isa = PBXGroup; + children = ( + 50ADFF96201F53D600D50D53 /* LocalNotificationsPluginTests.swift */, + 50ADFF98201F53D600D50D53 /* Info.plist */, + ); + path = PluginTests; + sourceTree = ""; + }; + 8C8E7744173064A9F6D438E3 /* Pods */ = { + isa = PBXGroup; + children = ( + 5E23F77F099397094342571A /* Pods-Plugin.debug.xcconfig */, + 91781294A431A2A7CC6EB714 /* Pods-Plugin.release.xcconfig */, + 96ED1B6440D6672E406C8D19 /* Pods-PluginTests.debug.xcconfig */, + F65BB2953ECE002E1EF3E424 /* Pods-PluginTests.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + A797B9EFA3DCEFEA1FBB66A9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 50ADFFA52020D75100D50D53 /* Capacitor.framework */, + 3B2A61DA5A1F2DD4F959604D /* Pods_Plugin.framework */, + F6753A823D3815DB436415E3 /* Pods_PluginTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 50ADFF85201F53D600D50D53 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 50ADFF99201F53D600D50D53 /* LocalNotificationsPlugin.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 50ADFF87201F53D600D50D53 /* Plugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = 50ADFF9C201F53D600D50D53 /* Build configuration list for PBXNativeTarget "Plugin" */; + buildPhases = ( + AB5B3E54B4E897F32C2279DA /* [CP] Check Pods Manifest.lock */, + 50ADFF83201F53D600D50D53 /* Sources */, + 50ADFF84201F53D600D50D53 /* Frameworks */, + 50ADFF85201F53D600D50D53 /* Headers */, + 50ADFF86201F53D600D50D53 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Plugin; + productName = Plugin; + productReference = 50ADFF88201F53D600D50D53 /* Plugin.framework */; + productType = "com.apple.product-type.framework"; + }; + 50ADFF90201F53D600D50D53 /* PluginTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 50ADFF9F201F53D600D50D53 /* Build configuration list for PBXNativeTarget "PluginTests" */; + buildPhases = ( + 0596884F929ED6F1DE134961 /* [CP] Check Pods Manifest.lock */, + 50ADFF8D201F53D600D50D53 /* Sources */, + 50ADFF8E201F53D600D50D53 /* Frameworks */, + 50ADFF8F201F53D600D50D53 /* Resources */, + 8E97F58B69A94C6503FC9C85 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 50ADFF94201F53D600D50D53 /* PBXTargetDependency */, + ); + name = PluginTests; + productName = PluginTests; + productReference = 50ADFF91201F53D600D50D53 /* PluginTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 50ADFF7F201F53D600D50D53 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1230; + ORGANIZATIONNAME = "Max Lynch"; + TargetAttributes = { + 50ADFF87201F53D600D50D53 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + 50ADFF90201F53D600D50D53 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 50ADFF82201F53D600D50D53 /* Build configuration list for PBXProject "Plugin" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 50ADFF7E201F53D600D50D53; + productRefGroup = 50ADFF89201F53D600D50D53 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 50ADFF87201F53D600D50D53 /* Plugin */, + 50ADFF90201F53D600D50D53 /* PluginTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 50ADFF86201F53D600D50D53 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 50ADFF8F201F53D600D50D53 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0596884F929ED6F1DE134961 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-PluginTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8E97F58B69A94C6503FC9C85 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-PluginTests/Pods-PluginTests-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/Capacitor/Capacitor.framework", + "${BUILT_PRODUCTS_DIR}/CapacitorCordova/Cordova.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Capacitor.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Cordova.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-PluginTests/Pods-PluginTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + AB5B3E54B4E897F32C2279DA /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Plugin-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 50ADFF83201F53D600D50D53 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 37F524A5255A0D730085E3FD /* LocalNotificationsHandler.swift in Sources */, + 50E1A94820377CB70090CE1A /* LocalNotificationsPlugin.swift in Sources */, + 50ADFFA82020EE4F00D50D53 /* LocalNotificationsPlugin.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 50ADFF8D201F53D600D50D53 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 50ADFF97201F53D600D50D53 /* LocalNotificationsPluginTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 50ADFF94201F53D600D50D53 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 50ADFF87201F53D600D50D53 /* Plugin */; + targetProxy = 50ADFF93201F53D600D50D53 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 50ADFF9A201F53D600D50D53 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + FRAMEWORK_SEARCH_PATHS = ( + "\"${BUILT_PRODUCTS_DIR}/Capacitor\"", + "\"${BUILT_PRODUCTS_DIR}/CapacitorCordova\"", + ); + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 50ADFF9B201F53D600D50D53 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + FRAMEWORK_SEARCH_PATHS = ( + "\"${BUILT_PRODUCTS_DIR}/Capacitor\"", + "\"${BUILT_PRODUCTS_DIR}/CapacitorCordova\"", + ); + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 50ADFF9D201F53D600D50D53 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5E23F77F099397094342571A /* Pods-Plugin.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = Plugin/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks $(FRAMEWORK_SEARCH_PATHS)\n$(FRAMEWORK_SEARCH_PATHS)\n$(FRAMEWORK_SEARCH_PATHS)"; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.getcapacitor.Plugin; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 50ADFF9E201F53D600D50D53 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 91781294A431A2A7CC6EB714 /* Pods-Plugin.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = Plugin/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks $(FRAMEWORK_SEARCH_PATHS)"; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.getcapacitor.Plugin; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 50ADFFA0201F53D600D50D53 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 96ED1B6440D6672E406C8D19 /* Pods-PluginTests.debug.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = PluginTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.getcapacitor.PluginTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 50ADFFA1201F53D600D50D53 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F65BB2953ECE002E1EF3E424 /* Pods-PluginTests.release.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = PluginTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.getcapacitor.PluginTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 50ADFF82201F53D600D50D53 /* Build configuration list for PBXProject "Plugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50ADFF9A201F53D600D50D53 /* Debug */, + 50ADFF9B201F53D600D50D53 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 50ADFF9C201F53D600D50D53 /* Build configuration list for PBXNativeTarget "Plugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50ADFF9D201F53D600D50D53 /* Debug */, + 50ADFF9E201F53D600D50D53 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 50ADFF9F201F53D600D50D53 /* Build configuration list for PBXNativeTarget "PluginTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50ADFFA0201F53D600D50D53 /* Debug */, + 50ADFFA1201F53D600D50D53 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 50ADFF7F201F53D600D50D53 /* Project object */; +} diff --git a/local-notifications/ios/Plugin.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/local-notifications/ios/Plugin.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/local-notifications/ios/Plugin.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/local-notifications/ios/Plugin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/local-notifications/ios/Plugin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/local-notifications/ios/Plugin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/local-notifications/ios/Plugin.xcodeproj/xcshareddata/xcschemes/Plugin.xcscheme b/local-notifications/ios/Plugin.xcodeproj/xcshareddata/xcschemes/Plugin.xcscheme new file mode 100644 index 000000000..901886c9b --- /dev/null +++ b/local-notifications/ios/Plugin.xcodeproj/xcshareddata/xcschemes/Plugin.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/local-notifications/ios/Plugin.xcodeproj/xcshareddata/xcschemes/PluginTests.xcscheme b/local-notifications/ios/Plugin.xcodeproj/xcshareddata/xcschemes/PluginTests.xcscheme new file mode 100644 index 000000000..fca4e2b2c --- /dev/null +++ b/local-notifications/ios/Plugin.xcodeproj/xcshareddata/xcschemes/PluginTests.xcscheme @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/local-notifications/ios/Plugin.xcworkspace/contents.xcworkspacedata b/local-notifications/ios/Plugin.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..afad624ec --- /dev/null +++ b/local-notifications/ios/Plugin.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/local-notifications/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/local-notifications/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/local-notifications/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/local-notifications/ios/Plugin/Info.plist b/local-notifications/ios/Plugin/Info.plist new file mode 100644 index 000000000..1007fd9dd --- /dev/null +++ b/local-notifications/ios/Plugin/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/local-notifications/ios/Plugin/LocalNotificationsHandler.swift b/local-notifications/ios/Plugin/LocalNotificationsHandler.swift new file mode 100644 index 000000000..427a5d5bf --- /dev/null +++ b/local-notifications/ios/Plugin/LocalNotificationsHandler.swift @@ -0,0 +1,87 @@ +import Capacitor +import UserNotifications + +public class LocalNotificationsHandler: NSObject, NotificationHandlerProtocol { + + public weak var plugin: CAPPlugin? + + // Local list of notification id -> JSObject for storing options + // between notification requets + var notificationRequestLookup = [String: JSObject]() + + public func requestPermissions(with completion: ((Bool, Error?) -> Void)? = nil) { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.badge, .alert, .sound]) { (granted, error) in + completion?(granted, error) + } + } + + public func checkPermissions(with completion: ((UNAuthorizationStatus) -> Void)? = nil) { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + completion?(settings.authorizationStatus) + } + } + + public func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions { + let notificationData = makeNotificationRequestJSObject(notification.request) + + self.plugin?.notifyListeners("received", data: notificationData) + + if let options = notificationRequestLookup[notification.request.identifier] { + let silent = options["silent"] as? Bool ?? false + if silent { + return UNNotificationPresentationOptions.init(rawValue: 0) + } + } + + return [ + .badge, + .sound, + .alert + ] + } + + public func didReceive(response: UNNotificationResponse) { + var data = JSObject() + + // Get the info for the original notification request + let originalNotificationRequest = response.notification.request + + let actionId = response.actionIdentifier + + // We turn the two default actions (open/dismiss) into generic strings + if actionId == UNNotificationDefaultActionIdentifier { + data["actionId"] = "tap" + } else if actionId == UNNotificationDismissActionIdentifier { + data["actionId"] = "dismiss" + } else { + data["actionId"] = actionId + } + + // If the type of action was for an input type, get the value + if let inputType = response as? UNTextInputNotificationResponse { + data["inputValue"] = inputType.userText + } + + data["notification"] = makeNotificationRequestJSObject(originalNotificationRequest) + + self.plugin?.notifyListeners("actionPerformed", data: data, retainUntilConsumed: true) + } + + /** + * Turn a UNNotificationRequest into a JSObject to return back to the client. + */ + func makeNotificationRequestJSObject(_ request: UNNotificationRequest) -> JSObject { + let notificationRequest = notificationRequestLookup[request.identifier] ?? [:] + return [ + "id": request.identifier, + "title": request.content.title, + "sound": notificationRequest["sound"] ?? "", + "body": request.content.body, + "extra": request.content.userInfo as? JSObject ?? [:], + "actionTypeId": request.content.categoryIdentifier, + "attachments": notificationRequest["attachments"] ?? [] + ] + } +} diff --git a/local-notifications/ios/Plugin/LocalNotificationsPlugin.h b/local-notifications/ios/Plugin/LocalNotificationsPlugin.h new file mode 100644 index 000000000..f2bd9e0bb --- /dev/null +++ b/local-notifications/ios/Plugin/LocalNotificationsPlugin.h @@ -0,0 +1,10 @@ +#import + +//! Project version number for Plugin. +FOUNDATION_EXPORT double PluginVersionNumber; + +//! Project version string for Plugin. +FOUNDATION_EXPORT const unsigned char PluginVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + diff --git a/local-notifications/ios/Plugin/LocalNotificationsPlugin.m b/local-notifications/ios/Plugin/LocalNotificationsPlugin.m new file mode 100644 index 000000000..21150d9b6 --- /dev/null +++ b/local-notifications/ios/Plugin/LocalNotificationsPlugin.m @@ -0,0 +1,16 @@ +#import +#import + +CAP_PLUGIN(LocalNotificationsPlugin, "LocalNotifications", + CAP_PLUGIN_METHOD(schedule, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(requestPermissions, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(checkPermissions, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(cancel, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(getPending, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(registerActionTypes, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(areEnabled, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(createChannel, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(deleteChannel, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(listChannels, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(removeAllListeners, CAPPluginReturnNone); +) diff --git a/local-notifications/ios/Plugin/LocalNotificationsPlugin.swift b/local-notifications/ios/Plugin/LocalNotificationsPlugin.swift new file mode 100644 index 000000000..588ae3e14 --- /dev/null +++ b/local-notifications/ios/Plugin/LocalNotificationsPlugin.swift @@ -0,0 +1,552 @@ +import Foundation +import Capacitor +import UserNotifications + +enum LocalNotificationError: LocalizedError { + case contentNoId + case contentNoTitle + case contentNoBody + case triggerConstructionFailed + case triggerRepeatIntervalTooShort + case attachmentNoId + case attachmentNoUrl + case attachmentFileNotFound(path: String) + case attachmentUnableToCreate(String) + + var errorDescription: String? { + switch self { + case .attachmentFileNotFound(path: let path): + return "Unable to find file \(path) for attachment" + default: + return "" + } + } +} + +@objc(LocalNotificationsPlugin) +public class LocalNotificationsPlugin: CAPPlugin { + private let notificationDelegationHandler = LocalNotificationsHandler() + + override public func load() { + self.bridge?.notificationRouter.localNotificationHandler = self.notificationDelegationHandler + self.notificationDelegationHandler.plugin = self + } + + /** + * Schedule a notification. + */ + @objc func schedule(_ call: CAPPluginCall) { + guard let notifications = call.getArray("notifications", JSObject.self) else { + call.reject("Must provide notifications array as notifications option") + return + } + var ids = [String]() + + for notification in notifications { + guard let identifier = notification["id"] as? Int else { + call.reject("Notification missing identifier") + return + } + + // let extra = notification["options"] as? JSObject ?? [:] + + var content: UNNotificationContent + do { + content = try makeNotificationContent(notification) + } catch { + CAPLog.print(error.localizedDescription) + call.reject("Unable to make notification", nil, error) + return + } + + var trigger: UNNotificationTrigger? + + do { + if let schedule = notification["schedule"] as? JSObject { + try trigger = handleScheduledNotification(call, schedule) + } + } catch { + call.reject("Unable to create notification, trigger failed", nil, error) + return + } + + // Schedule the request. + let request = UNNotificationRequest(identifier: "\(identifier)", content: content, trigger: trigger) + + self.notificationDelegationHandler.notificationRequestLookup[request.identifier] = notification + + let center = UNUserNotificationCenter.current() + center.add(request) { (error: Error?) in + if let theError = error { + CAPLog.print(theError.localizedDescription) + call.reject(theError.localizedDescription) + } + } + + ids.append(request.identifier) + } + + let ret = ids.map({ (id) -> JSObject in + return [ + "id": id + ] + }) + call.resolve([ + "notifications": ret + ]) + } + + /** + * Request notification permission + */ + @objc override public func requestPermissions(_ call: CAPPluginCall) { + self.notificationDelegationHandler.requestPermissions { granted, error in + guard error == nil else { + call.reject(error!.localizedDescription) + return + } + call.resolve(["display": granted ? "granted" : "denied"]) + } + } + + @objc override public func checkPermissions(_ call: CAPPluginCall) { + self.notificationDelegationHandler.checkPermissions { status in + let permission: String + + switch status { + case .authorized, .ephemeral, .provisional: + permission = "granted" + case .denied: + permission = "denied" + case .notDetermined: + permission = "prompt" + @unknown default: + permission = "prompt" + } + + call.resolve(["display": permission]) + } + } + + /** + * Cancel notifications by id + */ + @objc func cancel(_ call: CAPPluginCall) { + guard let notifications = call.getArray("notifications", JSObject.self), notifications.count > 0 else { + call.reject("Must supply notifications to cancel") + return + } + + let ids = notifications.map { $0["id"] as? String ?? "" } + + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) + call.resolve() + } + + /** + * Get all pending notifications. + */ + @objc func getPending(_ call: CAPPluginCall) { + UNUserNotificationCenter.current().getPendingNotificationRequests(completionHandler: { (notifications) in + CAPLog.print("num of pending notifications \(notifications.count)") + CAPLog.print(notifications) + + let ret = notifications.compactMap({ [weak self] (notification) -> JSObject? in + return self?.makePendingNotificationRequestJSObject(notification) + }) + call.resolve([ + "notifications": ret + ]) + }) + } + + /** + * Register allowed action types that a notification may present. + */ + @objc func registerActionTypes(_ call: CAPPluginCall) { + guard let types = call.getArray("types", JSObject.self) else { + return + } + + makeActionTypes(types) + + call.resolve() + } + + /** + * Check if Local Notifications are authorized and enabled + */ + @objc func areEnabled(_ call: CAPPluginCall) { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { (settings) in + let authorized = settings.authorizationStatus == UNAuthorizationStatus.authorized + let enabled = settings.notificationCenterSetting == UNNotificationSetting.enabled + call.resolve([ + "value": enabled && authorized + ]) + } + } + + /** + * Build the content for a notification. + */ + func makeNotificationContent(_ notification: JSObject) throws -> UNNotificationContent { + guard let title = notification["title"] as? String else { + throw LocalNotificationError.contentNoTitle + } + guard let body = notification["body"] as? String else { + throw LocalNotificationError.contentNoBody + } + + let extra = notification["extra"] as? JSObject ?? [:] + let content = UNMutableNotificationContent() + content.title = NSString.localizedUserNotificationString(forKey: title, arguments: nil) + content.body = NSString.localizedUserNotificationString(forKey: body, + arguments: nil) + + content.userInfo = extra + if let actionTypeId = notification["actionTypeId"] as? String { + content.categoryIdentifier = actionTypeId + } + + if let threadIdentifier = notification["threadIdentifier"] as? String { + content.threadIdentifier = threadIdentifier + } + + if #available(iOS 12, *), let summaryArgument = notification["summaryArgument"] as? String { + content.summaryArgument = summaryArgument + } + + if let sound = notification["sound"] as? String { + content.sound = UNNotificationSound(named: UNNotificationSoundName(sound)) + } + + if let attachments = notification["attachments"] as? [JSObject] { + content.attachments = try makeAttachments(attachments) + } + + return content + } + + /** + * Build a notification trigger, such as triggering each N seconds, or + * on a certain date "shape" (such as every first of the month) + */ + func handleScheduledNotification(_ call: CAPPluginCall, _ schedule: JSObject) throws -> UNNotificationTrigger? { + var at: Date? + if let scheduleDate = schedule["at"] as? NSDate { + at = scheduleDate as Date + } + let every = schedule["every"] as? String + let count = schedule["count"] as? Int ?? 1 + let on = schedule["on"] as? JSObject + let repeats = schedule["repeats"] as? Bool ?? false + + // If there's a specific date for this notificiation + if let at = at { + let dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: at) + + if dateInfo.date! < Date() { + call.reject("Scheduled time must be *after* current time") + return nil + } + + let dateInterval = DateInterval(start: Date(), end: dateInfo.date!) + + // Notifications that repeat have to be at least a minute between each other + if repeats && dateInterval.duration < 60 { + throw LocalNotificationError.triggerRepeatIntervalTooShort + } + + return UNTimeIntervalNotificationTrigger(timeInterval: dateInterval.duration, repeats: repeats) + } + + // If this notification should repeat every count of day/month/week/etc. or on a certain + // matching set of date components + if let on = on { + let dateComponents = getDateComponents(on) + return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + } + + if let every = every { + if let repeatDateInterval = getRepeatDateInterval(every, count) { + return UNTimeIntervalNotificationTrigger(timeInterval: repeatDateInterval.duration, repeats: true) + } + } + + return nil + } + + /** + * Given our schedule format, return a DateComponents object + * that only contains the components passed in. + */ + func getDateComponents(_ at: JSObject) -> DateComponents { + // var dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: Date()) + // dateInfo.calendar = Calendar.current + var dateInfo = DateComponents() + + if let year = at["year"] as? Int { + dateInfo.year = year + } + if let month = at["month"] as? Int { + dateInfo.month = month + } + if let day = at["day"] as? Int { + dateInfo.day = day + } + if let hour = at["hour"] as? Int { + dateInfo.hour = hour + } + if let minute = at["minute"] as? Int { + dateInfo.minute = minute + } + if let second = at["second"] as? Int { + dateInfo.second = second + } + return dateInfo + } + + /** + * Compute the difference between the string representation of a date + * interval and today. For example, if every is "month", then we + * return the interval between today and a month from today. + */ + func getRepeatDateInterval(_ every: String, _ count: Int) -> DateInterval? { + let cal = Calendar.current + let now = Date() + switch every { + case "year": + let newDate = cal.date(byAdding: .year, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "month": + let newDate = cal.date(byAdding: .month, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "two-weeks": + let newDate = cal.date(byAdding: .weekOfYear, value: 2 * count, to: now)! + return DateInterval(start: now, end: newDate) + case "week": + let newDate = cal.date(byAdding: .weekOfYear, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "day": + let newDate = cal.date(byAdding: .day, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "hour": + let newDate = cal.date(byAdding: .hour, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "minute": + let newDate = cal.date(byAdding: .minute, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "second": + let newDate = cal.date(byAdding: .second, value: count, to: now)! + return DateInterval(start: now, end: newDate) + default: + return nil + } + } + + /** + * Make required UNNotificationCategory entries for action types + */ + func makeActionTypes(_ actionTypes: [JSObject]) { + var createdCategories = [UNNotificationCategory]() + + let generalCategory = UNNotificationCategory(identifier: "GENERAL", + actions: [], + intentIdentifiers: [], + options: .customDismissAction) + + createdCategories.append(generalCategory) + for type in actionTypes { + guard let id = type["id"] as? String else { + CAPLog.print("⚡️ ", self.pluginId, "-", "Action type must have an id field") + continue + } + let hiddenBodyPlaceholder = type["iosHiddenPreviewsBodyPlaceholder"] as? String ?? "" + let actions = type["actions"] as? [JSObject] ?? [] + + let newActions = makeActions(actions) + + // Create the custom actions for the TIMER_EXPIRED category. + var newCategory: UNNotificationCategory? + + newCategory = UNNotificationCategory(identifier: id, + actions: newActions, + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: hiddenBodyPlaceholder, + options: makeCategoryOptions(type)) + + createdCategories.append(newCategory!) + } + + let center = UNUserNotificationCenter.current() + center.setNotificationCategories(Set(createdCategories)) + } + + /** + * Build the required UNNotificationAction objects for each action type registered. + */ + func makeActions(_ actions: [JSObject]) -> [UNNotificationAction] { + var createdActions = [UNNotificationAction]() + + for action in actions { + guard let id = action["id"] as? String else { + CAPLog.print("⚡️ ", self.pluginId, "-", "Action must have an id field") + continue + } + let title = action["title"] as? String ?? "" + let input = action["input"] as? Bool ?? false + + var newAction: UNNotificationAction + if input { + let inputButtonTitle = action["inputButtonTitle"] as? String + let inputPlaceholder = action["inputPlaceholder"] as? String ?? "" + + if inputButtonTitle != nil { + newAction = UNTextInputNotificationAction(identifier: id, + title: title, + options: makeActionOptions(action), + textInputButtonTitle: inputButtonTitle!, + textInputPlaceholder: inputPlaceholder) + } else { + newAction = UNTextInputNotificationAction(identifier: id, title: title, options: makeActionOptions(action)) + } + } else { + // Create the custom actions for the TIMER_EXPIRED category. + newAction = UNNotificationAction(identifier: id, + title: title, + options: makeActionOptions(action)) + } + createdActions.append(newAction) + } + + return createdActions + } + + /** + * Make options for UNNotificationActions + */ + func makeActionOptions(_ action: JSObject) -> UNNotificationActionOptions { + let foreground = action["foreground"] as? Bool ?? false + let destructive = action["destructive"] as? Bool ?? false + let requiresAuthentication = action["requiresAuthentication"] as? Bool ?? false + + if foreground { + return .foreground + } + if destructive { + return .destructive + } + if requiresAuthentication { + return .authenticationRequired + } + return UNNotificationActionOptions(rawValue: 0) + } + + /** + * Make options for UNNotificationCategoryActions + */ + func makeCategoryOptions(_ type: JSObject) -> UNNotificationCategoryOptions { + let customDismiss = type["iosCustomDismissAction"] as? Bool ?? false + let carPlay = type["iosAllowInCarPlay"] as? Bool ?? false + let hiddenPreviewsShowTitle = type["iosHiddenPreviewsShowTitle"] as? Bool ?? false + let hiddenPreviewsShowSubtitle = type["iosHiddenPreviewsShowSubtitle"] as? Bool ?? false + + if customDismiss { + return .customDismissAction + } + if carPlay { + return .allowInCarPlay + } + + if hiddenPreviewsShowTitle { + return .hiddenPreviewsShowTitle + } + if hiddenPreviewsShowSubtitle { + return .hiddenPreviewsShowSubtitle + } + + return UNNotificationCategoryOptions(rawValue: 0) + } + + /** + * Build the UNNotificationAttachment object for each attachment supplied. + */ + func makeAttachments(_ attachments: [JSObject]) throws -> [UNNotificationAttachment] { + var createdAttachments = [UNNotificationAttachment]() + + for attachment in attachments { + guard let id = attachment["id"] as? String else { + throw LocalNotificationError.attachmentNoId + } + guard let url = attachment["url"] as? String else { + throw LocalNotificationError.attachmentNoUrl + } + guard let urlObject = makeAttachmentUrl(url) else { + throw LocalNotificationError.attachmentFileNotFound(path: url) + } + + let options = attachment["options"] as? JSObject ?? [:] + + do { + let newAttachment = try UNNotificationAttachment(identifier: id, url: urlObject, options: makeAttachmentOptions(options)) + createdAttachments.append(newAttachment) + } catch { + throw LocalNotificationError.attachmentUnableToCreate(error.localizedDescription) + } + } + + return createdAttachments + } + + /** + * Get the internal URL for the attachment URL + */ + func makeAttachmentUrl(_ path: String) -> URL? { + guard let webURL = URL(string: path) else { + return nil + } + + return bridge?.localURL(fromWebURL: webURL) + } + + /** + * Build the options for the attachment, if any. (For example: the clipping rectangle to use + * for image attachments) + */ + func makeAttachmentOptions(_ options: JSObject) -> JSObject { + var opts: JSObject = [:] + + if let iosUNNotificationAttachmentOptionsTypeHintKey = options["iosUNNotificationAttachmentOptionsTypeHintKey"] as? String { + opts[UNNotificationAttachmentOptionsTypeHintKey] = iosUNNotificationAttachmentOptionsTypeHintKey + } + if let iosUNNotificationAttachmentOptionsThumbnailHiddenKey = options["iosUNNotificationAttachmentOptionsThumbnailHiddenKey"] as? String { + opts[UNNotificationAttachmentOptionsThumbnailHiddenKey] = iosUNNotificationAttachmentOptionsThumbnailHiddenKey + } + if let iosUNNotificationAttachmentOptionsThumbnailClippingRectKey = options["iosUNNotificationAttachmentOptionsThumbnailClippingRectKey"] as? String { + opts[UNNotificationAttachmentOptionsThumbnailClippingRectKey] = iosUNNotificationAttachmentOptionsThumbnailClippingRectKey + } + if let iosUNNotificationAttachmentOptionsThumbnailTimeKey = options["iosUNNotificationAttachmentOptionsThumbnailTimeKey"] as? String { + opts[UNNotificationAttachmentOptionsThumbnailTimeKey] = iosUNNotificationAttachmentOptionsThumbnailTimeKey + } + return opts + } + + func makePendingNotificationRequestJSObject(_ request: UNNotificationRequest) -> JSObject { + return [ + "id": request.identifier + ] + } + + @objc func createChannel(_ call: CAPPluginCall) { + call.unimplemented() + } + + @objc func deleteChannel(_ call: CAPPluginCall) { + call.unimplemented() + } + + @objc func listChannels(_ call: CAPPluginCall) { + call.unimplemented() + } +} diff --git a/local-notifications/ios/PluginTests/Info.plist b/local-notifications/ios/PluginTests/Info.plist new file mode 100644 index 000000000..6c40a6cd0 --- /dev/null +++ b/local-notifications/ios/PluginTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/local-notifications/ios/PluginTests/LocalNotificationsPluginTests.swift b/local-notifications/ios/PluginTests/LocalNotificationsPluginTests.swift new file mode 100644 index 000000000..8eec98b1a --- /dev/null +++ b/local-notifications/ios/PluginTests/LocalNotificationsPluginTests.swift @@ -0,0 +1,14 @@ +import XCTest +@testable import Plugin + +class LocalNotificationsTests: XCTestCase { + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } +} diff --git a/local-notifications/ios/Podfile b/local-notifications/ios/Podfile new file mode 100644 index 000000000..54a00c161 --- /dev/null +++ b/local-notifications/ios/Podfile @@ -0,0 +1,16 @@ +platform :ios, '12.0' + +def capacitor_pods + # Comment the next line if you're not using Swift and don't want to use dynamic frameworks + use_frameworks! + pod 'Capacitor', :path => '../node_modules/@capacitor/ios' + pod 'CapacitorCordova', :path => '../node_modules/@capacitor/ios' +end + +target 'Plugin' do + capacitor_pods +end + +target 'PluginTests' do + capacitor_pods +end diff --git a/local-notifications/package.json b/local-notifications/package.json new file mode 100644 index 000000000..67281aa03 --- /dev/null +++ b/local-notifications/package.json @@ -0,0 +1,81 @@ +{ + "name": "@capacitor/local-notifications", + "version": "0.0.1", + "description": "The Local Notifications API provides a way to schedule device notifications locally (i.e. without a server sending push notifications).", + "main": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "unpkg": "dist/plugin.js", + "files": [ + "android/src/main/", + "android/build.gradle", + "dist/", + "ios/Plugin/", + "CapacitorLocalNotifications.podspec" + ], + "author": "Ionic ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ionic-team/capacitor-plugins.git" + }, + "bugs": { + "url": "https://github.com/ionic-team/capacitor-plugins/issues" + }, + "keywords": [ + "capacitor", + "plugin", + "native" + ], + "scripts": { + "verify": "npm run verify:ios && npm run verify:android && npm run verify:web", + "verify:ios": "cd ios && pod install && xcodebuild -workspace Plugin.xcworkspace -scheme Plugin && cd ..", + "verify:android": "cd android && ./gradlew clean build test && cd ..", + "verify:web": "npm run build", + "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint", + "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- autocorrect --format", + "eslint": "eslint . --ext ts", + "prettier": "prettier \"**/*.{css,html,ts,js,java}\"", + "swiftlint": "node-swiftlint", + "docgen": "docgen --api LocalNotificationsPlugin --output-readme README.md --output-json dist/docs.json", + "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.js", + "clean": "rimraf ./dist", + "watch": "tsc --watch", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "@capacitor/android": "^3.0.0-alpha.12", + "@capacitor/cli": "^3.0.0-alpha.13", + "@capacitor/core": "^3.0.0-alpha.12", + "@capacitor/docgen": "0.0.14", + "@capacitor/ios": "^3.0.0-alpha.13", + "@ionic/eslint-config": "^0.3.0", + "@ionic/prettier-config": "~1.0.1", + "@ionic/swiftlint-config": "^1.1.2", + "eslint": "^7.11.0", + "prettier": "~2.2.0", + "prettier-plugin-java": "~1.0.0", + "rimraf": "^3.0.0", + "rollup": "^2.29.0", + "swiftlint": "^1.0.1", + "typescript": "~4.0.3" + }, + "peerDependencies": { + "@capacitor/core": "^3.0.0-alpha.12" + }, + "prettier": "@ionic/prettier-config", + "swiftlint": "@ionic/swiftlint-config", + "eslintConfig": { + "extends": "@ionic/eslint-config/recommended" + }, + "capacitor": { + "ios": { + "src": "ios" + }, + "android": { + "src": "android" + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/local-notifications/rollup.config.js b/local-notifications/rollup.config.js new file mode 100644 index 000000000..f262b11ca --- /dev/null +++ b/local-notifications/rollup.config.js @@ -0,0 +1,14 @@ +export default { + input: 'dist/esm/index.js', + output: { + file: 'dist/plugin.js', + format: 'iife', + name: 'capacitorLocalNotifications', + globals: { + '@capacitor/core': 'capacitorExports', + }, + sourcemap: true, + inlineDynamicImports: true, + }, + external: ['@capacitor/core'], +}; diff --git a/local-notifications/src/definitions.ts b/local-notifications/src/definitions.ts new file mode 100644 index 000000000..17e8984f0 --- /dev/null +++ b/local-notifications/src/definitions.ts @@ -0,0 +1,909 @@ +/// + +import type { PermissionState, PluginListenerHandle } from '@capacitor/core'; + +declare module '@capacitor/cli' { + export interface PluginsConfig { + LocalNotifications?: { + /** + * Set the default status bar icon for notifications. + * + * Icons should be placed in your app's `res/drawable` folder. The value for + * this option should be the drawable resource ID, which is the filename + * without an extension. + * + * Only available for Android. + * + * @since 1.0.0 + */ + smallIcon?: string; + + /** + * Set the default color of status bar icons for notifications. + * + * Only available for Android. + * + * @since 1.0.0 + */ + iconColor?: string; + + /** + * Set the default notification sound for notifications. + * + * On Android 26+ it sets the default channel sound and can't be + * changed unless the app is uninstalled. + * + * Only available for Android. + * + * @since 1.0.0 + */ + sound?: string; + }; + } +} + +export interface LocalNotificationsPlugin { + /** + * Schedule one or more local notifications. + * + * @since 1.0.0 + */ + schedule(options: ScheduleOptions): Promise; + + /** + * Get a list of pending notifications. + * + * @since 1.0.0 + */ + getPending(): Promise; + + /** + * Register actions to take when notifications are displayed. + * + * Only available for iOS and Android. + * + * @since 1.0.0 + */ + registerActionTypes(options: RegisterActionTypesOptions): Promise; + + /** + * Cancel pending notifications. + * + * @since 1.0.0 + */ + cancel(options: CancelOptions): Promise; + + /** + * Check if notifications are enabled or not. + * + * @deprecated Use `checkPermissions()` to check if the user has allowed + * notifications to be displayed. + * @since 1.0.0 + */ + areEnabled(): Promise; + + /** + * Create a notification channel. + * + * Only available for Android. + * + * @since 1.0.0 + */ + createChannel(channel: NotificationChannel): Promise; + + /** + * Delete a notification channel. + * + * Only available for Android. + * + * @since 1.0.0 + */ + deleteChannel(channel: NotificationChannel): Promise; + + /** + * Get a list of notification channels. + * + * Only available for Android. + * + * @since 1.0.0 + */ + listChannels(): Promise; + + /** + * Check permission to display local notifications. + * + * @since 1.0.0 + */ + checkPermissions(): Promise; + + /** + * Request permission to display local notifications. + * + * @since 1.0.0 + */ + requestPermissions(): Promise; + + /** + * Listen for when notifications are displayed. + * + * @since 1.0.0 + */ + addListener( + eventName: 'received', + listenerFunc: (notification: LocalNotificationSchema) => void, + ): PluginListenerHandle; + + /** + * Listen for when an action is performed on a notification. + * + * @since 1.0.0 + */ + addListener( + eventName: 'actionPerformed', + listenerFunc: (notificationAction: ActionPerformed) => void, + ): PluginListenerHandle; + + /** + * Remove all listeners for this plugin. + * + * @since 1.0.0 + */ + removeAllListeners(): void; +} + +/** + * The object that describes a local notification. + * + * @since 1.0.0 + */ +export interface LocalNotificationDescriptor { + /** + * The notification identifier. + * + * @since 1.0.0 + */ + id: string; +} + +export interface ScheduleOptions { + /** + * The list of notifications to schedule. + * + * @since 1.0.0 + */ + notifications: LocalNotificationSchema[]; +} + +export interface ScheduleResult { + /** + * The list of scheduled notifications. + * + * @since 1.0.0 + */ + notifications: LocalNotificationDescriptor[]; +} + +export interface PendingResult { + /** + * The list of pending notifications. + * + * @since 1.0.0 + */ + notifications: LocalNotificationDescriptor[]; +} + +export interface RegisterActionTypesOptions { + /** + * The list of action types to register. + * + * @since 1.0.0 + */ + types: ActionType[]; +} + +export interface CancelOptions { + /** + * The list of notifications to cancel. + * + * @since 1.0.0 + */ + notifications: LocalNotificationDescriptor[]; +} + +/** + * A collection of actions. + * + * @since 1.0.0 + */ +export interface ActionType { + /** + * The ID of the action type. + * + * Referenced in notifications by the `actionTypeId` key. + * + * @since 1.0.0 + */ + id: string; + + /** + * The list of actions associated with this action type. + * + * @since 1.0.0 + */ + actions?: Action[]; + + /** + * Sets `hiddenPreviewsBodyPlaceholder` of the + * [`UNNotificationCategory`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + iosHiddenPreviewsBodyPlaceholder?: string; + + /** + * Sets `customDismissAction` in the options of the + * [`UNNotificationCategory`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + iosCustomDismissAction?: boolean; + + /** + * Sets `allowInCarPlay` in the options of the + * [`UNNotificationCategory`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + iosAllowInCarPlay?: boolean; + + /** + * Sets `hiddenPreviewsShowTitle` in the options of the + * [`UNNotificationCategory`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + iosHiddenPreviewsShowTitle?: boolean; + + /** + * Sets `hiddenPreviewsShowSubtitle` in the options of the + * [`UNNotificationCategory`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + iosHiddenPreviewsShowSubtitle?: boolean; +} + +/** + * An action that can be taken when a notification is displayed. + * + * @since 1.0.0 + */ +export interface Action { + /** + * The action identifier. + * + * Referenced in the `'actionPerformed'` event as `actionId`. + * + * @since 1.0.0 + */ + id: string; + + /** + * The title text to display for this action. + * + * @since 1.0.0 + */ + title: string; + + /** + * Sets `authenticationRequired` in the options of the + * [`UNNotificationAction`](https://developer.apple.com/documentation/usernotifications/unnotificationaction). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + requiresAuthentication?: boolean; + + /** + * Sets `foreground` in the options of the + * [`UNNotificationAction`](https://developer.apple.com/documentation/usernotifications/unnotificationaction). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + foreground?: boolean; + + /** + * Sets `destructive` in the options of the + * [`UNNotificationAction`](https://developer.apple.com/documentation/usernotifications/unnotificationaction). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + destructive?: boolean; + + /** + * Use a `UNTextInputNotificationAction` instead of a `UNNotificationAction`. + * + * Only available for iOS. + * + * @since 1.0.0 + */ + input?: boolean; + + /** + * Sets `textInputButtonTitle` on the + * [`UNTextInputNotificationAction`](https://developer.apple.com/documentation/usernotifications/untextinputnotificationaction). + * + * Only available for iOS when `input` is `true`. + * + * @since 1.0.0 + */ + inputButtonTitle?: string; + + /** + * Sets `textInputPlaceholder` on the + * [`UNTextInputNotificationAction`](https://developer.apple.com/documentation/usernotifications/untextinputnotificationaction). + * + * Only available for iOS when `input` is `true`. + * + * @since 1.0.0 + */ + inputPlaceholder?: string; +} + +/** + * Represents a notification attachment. + * + * @since 1.0.0 + */ +export interface Attachment { + /** + * The attachment identifier. + * + * @since 1.0.0 + */ + id: string; + + /** + * The URL to the attachment. + * + * Use the `res` scheme to reference web assets, e.g. + * `res:///assets/img/icon.png`. Also accepts `file` URLs. + * + * @since 1.0.0 + */ + url: string; + + /** + * Attachment options. + * + * @since 1.0.0 + */ + options?: AttachmentOptions; +} + +export interface AttachmentOptions { + /** + * Sets the `UNNotificationAttachmentOptionsTypeHintKey` key in the hashable + * options of + * [`UNNotificationAttachment`](https://developer.apple.com/documentation/usernotifications/unnotificationattachment). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + iosUNNotificationAttachmentOptionsTypeHintKey?: string; + + /** + * Sets the `UNNotificationAttachmentOptionsThumbnailHiddenKey` key in the + * hashable options of + * [`UNNotificationAttachment`](https://developer.apple.com/documentation/usernotifications/unnotificationattachment). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + iosUNNotificationAttachmentOptionsThumbnailHiddenKey?: string; + + /** + * Sets the `UNNotificationAttachmentOptionsThumbnailClippingRectKey` key in + * the hashable options of + * [`UNNotificationAttachment`](https://developer.apple.com/documentation/usernotifications/unnotificationattachment). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + iosUNNotificationAttachmentOptionsThumbnailClippingRectKey?: string; + + /** + * Sets the `UNNotificationAttachmentOptionsThumbnailTimeKey` key in the + * hashable options of + * [`UNNotificationAttachment`](https://developer.apple.com/documentation/usernotifications/unnotificationattachment). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + iosUNNotificationAttachmentOptionsThumbnailTimeKey?: string; +} + +export interface LocalNotificationSchema { + /** + * The title of the notification. + * + * @since 1.0.0 + */ + title: string; + + /** + * The body of the notification, shown below the title. + * + * @since 1.0.0 + */ + body: string; + + /** + * The notification identifier. + * + * @since 1.0.0 + */ + id: number; + + /** + * Schedule this notification for a later time. + * + * @since 1.0.0 + */ + schedule?: Schedule; + + /** + * Name of the audio file to play when this notification is displayed. + * + * Include the file extension with the filename. + * + * On iOS, the file should be in the app bundle. + * On Android, the file should be in res/raw folder. + * + * Recommended format is `.wav` because is supported by both iOS and Android. + * + * Only available for iOS and Android 26+. + * + * @since 1.0.0 + */ + sound?: string; + + /** + * Set a custom status bar icon. + * + * If set, this overrides the `smallIcon` option from Capacitor + * configuration. + * + * Icons should be placed in your app's `res/drawable` folder. The value for + * this option should be the drawable resource ID, which is the filename + * without an extension. + * + * Only available for Android. + * + * @since 1.0.0 + */ + smallIcon?: string; + + /** + * Set the color of the notification icon. + * + * Only available for Android. + * + * @since 1.0.0 + */ + iconColor?: string; + + /** + * Set attachments for this notification. + * + * @since 1.0.0 + */ + attachments?: Attachment[]; + + /** + * Associate an action type with this notification. + * + * @since 1.0.0 + */ + actionTypeId?: string; + + /** + * Set extra data to store within this notification. + * + * @since 1.0.0 + */ + extra?: any; + + /** + * Used to group multiple notifications. + * + * Sets `threadIdentifier` on the + * [`UNMutableNotificationContent`](https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent). + * + * Only available for iOS. + * + * @since 1.0.0 + */ + threadIdentifier?: string; + + /** + * The string this notification adds to the category's summary format string. + * + * Sets `summaryArgument` on the + * [`UNMutableNotificationContent`](https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent). + * + * Only available for iOS 12+. + * + * @since 1.0.0 + */ + summaryArgument?: string; + + /** + * Used to group multiple notifications. + * + * Calls `setGroup()` on + * [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) + * with the provided value. + * + * Only available for Android. + * + * @since 1.0.0 + */ + group?: string; + + /** + * If true, this notification becomes the summary for a group of + * notifications. + * + * Calls `setGroupSummary()` on + * [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) + * with the provided value. + * + * Only available for Android when using `group`. + * + * @since 1.0.0 + */ + groupSummary?: boolean; + + /** + * Specifies the channel the notification should be delivered on. + * + * If channel with the given name does not exist then the notification will + * not fire. If not provided, it will use the default channel. + * + * Calls `setChannelId()` on + * [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) + * with the provided value. + * + * Only available for Android 26+. + * + * @since 1.0.0 + */ + channelId?: string; + + /** + * If true, the notification can't be swiped away. + * + * Calls `setOngoing()` on + * [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) + * with the provided value. + * + * Only available for Android. + * + * @since 1.0.0 + */ + ongoing?: boolean; + + /** + * If true, the notification is canceled when the user clicks on it. + * + * Calls `setAutoCancel()` on + * [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) + * with the provided value. + * + * Only available for Android. + * + * @since 1.0.0 + */ + autoCancel?: boolean; +} + +/** + * Represents a schedule for a notification. + * + * Use either `at`, `on`, or `every` to schedule notifications. + * + * @since 1.0.0 + */ +export interface Schedule { + /** + * Schedule a notification at a specific date and time. + * + * @since 1.0.0 + */ + at?: Date; + + /** + * Repeat delivery of this notification at the date and time specified by + * `at`. + * + * Only available for iOS and Android. + * + * @since 1.0.0 + */ + repeats?: boolean; + + /** + * Schedule a notification on particular interval(s). + * + * This is similar to scheduling [cron](https://en.wikipedia.org/wiki/Cron) + * jobs. + * + * Only available for iOS and Android. + * + * @since 1.0.0 + */ + on?: { + year?: number; + month?: number; + day?: number; + hour?: number; + minute?: number; + }; + + /** + * Schedule a notification on a particular interval. + * + * @since 1.0.0 + */ + every?: + | 'year' + | 'month' + | 'two-weeks' + | 'week' + | 'day' + | 'hour' + | 'minute' + | 'second'; + + /** + * Limit the number times a notification is delivered by the interval + * specified by `every`. + * + * @since 1.0.0 + */ + count?: number; +} + +export interface ListChannelsResult { + /** + * The list of notification channels. + * + * @since 1.0.0 + */ + channels: Channel[]; +} + +export interface PermissionStatus { + /** + * Permission state of displaying notifications. + * + * @since 1.0.0 + */ + display: PermissionState; +} + +export interface ActionPerformed { + /** + * The identifier of the performed action. + * + * @since 1.0.0 + */ + actionId: string; + + /** + * The value entered by the user on the notification. + * + * Only available on iOS for notifications with `input` set to `true`. + * + * @since 1.0.0 + */ + inputValue?: string; + + /** + * The original notification schema. + * + * @since 1.0.0 + */ + notification: LocalNotificationSchema; +} + +/** + * @deprecated + */ +export interface EnabledResult { + /** + * Whether or not the device has local notifications enabled. + * + * @since 1.0.0 + */ + value: boolean; +} + +export interface Channel { + /** + * The channel identifier. + * + * @since 1.0.0 + */ + id: string; + + /** + * The human-friendly name of this channel (presented to the user). + * + * @since 1.0.0 + */ + name: string; + + /** + * The description of this channel (presented to the user). + * + * @since 1.0.0 + */ + description?: string; + + /** + * The sound that should be played for notifications posted to this channel. + * + * Notification channels with an importance of at least `3` should have a + * sound. + * + * The file name of a sound file should be specified relative to the android + * app `res/raw` directory. + * + * @since 1.0.0 + * @example "jingle.wav" + */ + sound?: string; + + /** + * The level of interruption for notifications posted to this channel. + * + * @since 1.0.0 + */ + importance: 1 | 2 | 3 | 4 | 5; + + /** + * The visibility of notifications posted to this channel. + * + * This setting is for whether notifications posted to this channel appear on + * the lockscreen or not, and if so, whether they appear in a redacted form. + * + * @since 1.0.0 + */ + visibility?: -1 | 0 | 1; + + /** + * Whether notifications posted to this channel should display notification + * lights, on devices that support it. + * + * @since 1.0.0 + */ + lights?: boolean; + + /** + * The light color for notifications posted to this channel. + * + * Only supported if lights are enabled on this channel and the device + * supports it. + * + * Supported color formats are `#RRGGBB` and `#RRGGBBAA`. + * + * @since 1.0.0 + */ + lightColor?: string; + + /** + * Whether notifications posted to this channel should vibrate. + * + * @since 1.0.0 + */ + vibration?: boolean; +} + +/** + * @deprecated Use 'Channel`. + * @since 1.0.0 + */ +export type NotificationChannel = Channel; + +/** + * @deprecated Use `LocalNotificationDescriptor`. + * @since 1.0.0 + */ +export type LocalNotificationRequest = LocalNotificationDescriptor; + +/** + * @deprecated Use `ScheduleResult`. + * @since 1.0.0 + */ +export type LocalNotificationScheduleResult = ScheduleResult; + +/** + * @deprecated Use `PendingResult`. + * @since 1.0.0 + */ +export type LocalNotificationPendingList = PendingResult; + +/** + * @deprecated Use `ActionType`. + * @since 1.0.0 + */ +export type LocalNotificationActionType = ActionType; + +/** + * @deprecated Use `Action`. + * @since 1.0.0 + */ +export type LocalNotificationAction = Action; + +/** + * @deprecated Use `EnabledResult`. + * @since 1.0.0 + */ +export type LocalNotificationEnabledResult = EnabledResult; + +/** + * @deprecated Use `ListChannelsResult`. + * @since 1.0.0 + */ +export type NotificationChannelList = ListChannelsResult; + +/** + * @deprecated Use `Attachment`. + * @since 1.0.0 + */ +export type LocalNotificationAttachment = Attachment; + +/** + * @deprecated Use `AttachmentOptions`. + * @since 1.0.0 + */ +export type LocalNotificationAttachmentOptions = AttachmentOptions; + +/** + * @deprecated Use `LocalNotificationSchema`. + * @since 1.0.0 + */ +export type LocalNotification = LocalNotificationSchema; + +/** + * @deprecated Use `Schedule`. + * @since 1.0.0 + */ +export type LocalNotificationSchedule = Schedule; + +/** + * @deprecated Use `ActionPerformed`. + * @since 1.0.0 + */ +export type LocalNotificationActionPerformed = ActionPerformed; diff --git a/local-notifications/src/index.ts b/local-notifications/src/index.ts new file mode 100644 index 000000000..14423d4ca --- /dev/null +++ b/local-notifications/src/index.ts @@ -0,0 +1,13 @@ +import { registerPlugin } from '@capacitor/core'; + +import type { LocalNotificationsPlugin } from './definitions'; + +const LocalNotifications = registerPlugin( + 'LocalNotifications', + { + web: () => import('./web').then(m => new m.LocalNotificationsWeb()), + }, +); + +export * from './definitions'; +export { LocalNotifications }; diff --git a/local-notifications/src/web.ts b/local-notifications/src/web.ts new file mode 100644 index 000000000..0faebafca --- /dev/null +++ b/local-notifications/src/web.ts @@ -0,0 +1,136 @@ +import type { PermissionState } from '@capacitor/core'; +import { WebPlugin } from '@capacitor/core'; + +import type { + EnabledResult, + ListChannelsResult, + LocalNotificationSchema, + LocalNotificationsPlugin, + PermissionStatus, + ScheduleOptions, + ScheduleResult, +} from './definitions'; + +export class LocalNotificationsWeb + extends WebPlugin + implements LocalNotificationsPlugin { + protected pending: LocalNotificationSchema[] = []; + + async createChannel(): Promise { + throw this.unimplemented('Not implemented on web.'); + } + + async deleteChannel(): Promise { + throw this.unimplemented('Not implemented on web.'); + } + + async listChannels(): Promise { + throw this.unimplemented('Not implemented on web.'); + } + + async schedule(options: ScheduleOptions): Promise { + for (const notification of options.notifications) { + this.sendNotification(notification); + } + + return { + notifications: options.notifications.map(notification => ({ + id: notification.id.toString(), + })), + }; + } + + async getPending(): Promise { + return { + notifications: this.pending.map(notification => ({ + id: notification.id.toString(), + })), + }; + } + + async registerActionTypes(): Promise { + throw this.unimplemented('Not implemented on web.'); + } + + async cancel(pending: ScheduleResult): Promise { + this.pending = this.pending.filter( + notification => + !pending.notifications.find(n => n.id === notification.id.toString()), + ); + } + + async areEnabled(): Promise { + const { display } = await this.checkPermissions(); + + return { + value: display === 'granted', + }; + } + + async requestPermissions(): Promise { + const display = this.transformNotificationPermission( + await Notification.requestPermission(), + ); + + return { display }; + } + + async checkPermissions(): Promise { + const display = this.transformNotificationPermission( + Notification.permission, + ); + + return { display }; + } + + protected transformNotificationPermission( + permission: NotificationPermission, + ): PermissionState { + switch (permission) { + case 'granted': + return 'granted'; + case 'denied': + return 'denied'; + default: + return 'prompt'; + } + } + + protected sendPending(): void { + const toRemove: LocalNotificationSchema[] = []; + const now = new Date().getTime(); + + for (const notification of this.pending) { + if ( + notification.schedule?.at && + notification.schedule.at.getTime() <= now + ) { + this.buildNotification(notification); + toRemove.push(notification); + } + } + + this.pending = this.pending.filter( + notification => !toRemove.find(n => n === notification), + ); + } + + protected sendNotification(notification: LocalNotificationSchema): void { + if (notification.schedule?.at) { + const diff = notification.schedule.at.getTime() - new Date().getTime(); + + this.pending.push(notification); + setTimeout(() => { + this.sendPending(); + }, diff); + } + } + + protected buildNotification( + notification: LocalNotificationSchema, + ): Notification { + return new Notification(notification.title, { + body: notification.body, + }); + } +} diff --git a/local-notifications/tsconfig.json b/local-notifications/tsconfig.json new file mode 100644 index 000000000..3bb999d96 --- /dev/null +++ b/local-notifications/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "declaration": true, + "esModuleInterop": true, + "lib": ["dom"], + "module": "esnext", + "moduleResolution": "node", + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "dist/esm", + "pretty": true, + "sourceMap": true, + "strict": true, + "target": "es2017" + }, + "files": ["src/index.ts"] +}