From 038f6d32fca97791cbf1fc6fb48a631d5515e574 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Sun, 30 Apr 2023 15:28:33 -0300 Subject: [PATCH] rust and TS apis --- Cargo.lock | 1 + plugins/notification/Cargo.toml | 1 + .../src/main/java/NotificationPlugin.kt | 29 +- plugins/notification/guest-js/index.ts | 278 ++++++++++- .../ios/Sources/NotificationCategory.swift | 10 +- .../ios/Sources/NotificationPlugin.swift | 29 +- plugins/notification/src/lib.rs | 191 +------- plugins/notification/src/mobile.rs | 110 +++++ plugins/notification/src/models.rs | 456 +++++++++++++++++- 9 files changed, 876 insertions(+), 229 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 707ab57f..4624b32c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4930,6 +4930,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_json", + "serde_repr", "tauri", "tauri-build", "thiserror", diff --git a/plugins/notification/Cargo.toml b/plugins/notification/Cargo.toml index 8db456c0..f136b2e5 100644 --- a/plugins/notification/Cargo.toml +++ b/plugins/notification/Cargo.toml @@ -19,6 +19,7 @@ thiserror.workspace = true rand = "0.8" time = { version = "0.3", features = ["serde", "parsing", "formatting"] } url = { version = "2", features = ["serde"] } +serde_repr = "0.1" [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] notify-rust = "4.5" diff --git a/plugins/notification/android/src/main/java/NotificationPlugin.kt b/plugins/notification/android/src/main/java/NotificationPlugin.kt index 1c7d22b4..e67cdefc 100644 --- a/plugins/notification/android/src/main/java/NotificationPlugin.kt +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -111,24 +111,31 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } @Command - fun cancel(invoke: Invoke) { + void cancel(invoke: Invoke) { + manager.cancel(invoke) + } + + @Command + fun removeActive(invoke: Invoke) { val notifications = invoke.getArray("notifications") if (notifications == null) { - manager.cancel(invoke) + notificationManager.cancelAll() + invoke.resolve() } else { try { for (o in notifications.toList()) { if (o is JSONObject) { val notification = JSObject.fromJSONObject((o)) - val tag = notification.getString("tag") - val id = notification.getInteger("id") - if (tag.isEmpty()) { - notificationManager.cancel(id!!) + val tag = notification.getString("tag", null) + val id = notification.getInteger("id", 0) + if (tag == null) { + notificationManager.cancel(id) } else { - notificationManager.cancel(tag, id!!) + notificationManager.cancel(tag, id) } } else { invoke.reject("Unexpected notification type") + return } } } catch (e: JSONException) { @@ -155,7 +162,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @SuppressLint("ObsoleteSdkInt") @Command - fun getDeliveredNotifications(invoke: Invoke) { + fun getActive(invoke: Invoke) { val notifications = JSArray() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val activeNotifications = notificationManager.activeNotifications @@ -186,12 +193,6 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { invoke.resolve(result) } - @Command - fun cancelAll(invoke: Invoke) { - notificationManager.cancelAll() - invoke.resolve() - } - @Command fun createChannel(invoke: Invoke) { channelManager.createChannel(invoke) diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts index 2cdc3c06..13128270 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -210,6 +210,81 @@ interface Attachment { url: string } +interface Action { + id: string + title: string + requiresAuthentication?: boolean + foreground?: boolean + destructive?: boolean + input?: boolean + inputButtonTitle?: string + inputPlaceholder?: string +} + +interface ActionType { + /** + * The identifier of this action type + */ + id: string + /** + * The list of associated actions + */ + actions: Action[] + hiddenPreviewsBodyPlaceholder?: string, + customDismissAction?: boolean, + allowInCarPlay?: boolean, + hiddenPreviewsShowTitle?: boolean, + hiddenPreviewsShowSubtitle?: boolean, +} + +interface PendingNotification { + id: number + title?: string + body?: string + schedule: Schedule +} + +interface ActiveNotification { + id: number + tag?: string + title?: string + body?: string + group?: string + groupSummary: boolean + data: Record + extra: Record + attachments: Attachment[] + actionTypeId?: string + schedule?: Schedule + sound?: string +} + +enum Importance { + None = 0, + Min, + Low, + Default, + High +} + +enum Visibility { + Secret = -1, + Private, + Public +} + +interface Channel { + id: string + name: string + description?: string + sound?: string + lights?: boolean + lightColor?: string + vibration?: boolean + importance?: Importance + visibility?: Visibility +} + /** Possible permission values. */ type Permission = 'granted' | 'denied' | 'default' @@ -278,6 +353,205 @@ function sendNotification(options: Options | string): void { } } -export type { Attachment, Options, Permission } +/** + * Register actions that are performed when the user clicks on the notification. + * + * @example + * ```typescript + * import { registerActionTypes } from '@tauri-apps/api/notification'; + * await registerActionTypes([{ + * id: 'tauri', + * actions: [{ + * id: 'my-action', + * title: 'Settings' + * }] + * }]) + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function registerActionTypes(types: ActionType[]): Promise { + return invoke('plugin:notification|register_action_types', { types }) +} + +/** + * Retrieves the list of pending notifications. + * + * @example + * ```typescript + * import { pending } from '@tauri-apps/api/notification'; + * const pendingNotifications = await pending(); + * ``` + * + * @returns A promise resolving to the list of pending notifications. + * + * @since 2.0.0 + */ +async function pending(): Promise { + return invoke('plugin:notification|get_pending') +} + +/** + * Cancels the pending notifications with the given list of identifiers. + * + * @example + * ```typescript + * import { cancel } from '@tauri-apps/api/notification'; + * await cancel([-34234, 23432, 4311]); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function cancel(notifications: number[]): Promise { + return invoke('plugin:notification|cancel', { notifications }) +} + +/** + * Cancels all pending notifications. + * + * @example + * ```typescript + * import { cancelAll } from '@tauri-apps/api/notification'; + * await cancelAll(); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function cancelAll(): Promise { + return invoke('plugin:notification|cancel') +} + +/** + * Retrieves the list of active notifications. + * + * @example + * ```typescript + * import { active } from '@tauri-apps/api/notification'; + * const activeNotifications = await active(); + * ``` + * + * @returns A promise resolving to the list of active notifications. + * + * @since 2.0.0 + */ +async function active(): Promise { + return invoke('plugin:notification|get_active') +} + +/** + * Removes the active notifications with the given list of identifiers. + * + * @example + * ```typescript + * import { cancel } from '@tauri-apps/api/notification'; + * await cancel([-34234, 23432, 4311]) + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeActive(notifications: number[]): Promise { + return invoke('plugin:notification|remove_active', { notifications }) +} + +/** + * Removes all active notifications. + * + * @example + * ```typescript + * import { removeAllActive } from '@tauri-apps/api/notification'; + * await removeAllActive() + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeAllActive(): Promise { + return invoke('plugin:notification|remove_active') +} + +/** + * Removes all active notifications. + * + * @example + * ```typescript + * import { createChannel, Importance, Visibility } from '@tauri-apps/api/notification'; + * await createChannel({ + * id: 'new-messages', + * name: 'New Messages', + * lights: true, + * vibration: true, + * importance: Importance.Default, + * visibility: Visibility.Private + * }); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function createChannel(channel: Channel): Promise { + return invoke('plugin:notification|create_channel', { ...channel }) +} + +/** + * Removes the channel with the given identifier. + * + * @example + * ```typescript + * import { removeChannel } from '@tauri-apps/api/notification'; + * await removeChannel(); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeChannel(id: string): Promise { + return invoke('plugin:notification|delete_channel', { id }) +} + +/** + * Retrieves the list of notification channels. + * + * @example + * ```typescript + * import { channels } from '@tauri-apps/api/notification'; + * const notificationChannels = await channels(); + * ``` + * + * @returns A promise resolving to the list of notification channels. + * + * @since 2.0.0 + */ +async function channels(): Promise { + return invoke('plugin:notification|getActive') +} + +export type { Attachment, Options, Permission, Action, ActionType, PendingNotification, ActiveNotification, Channel } -export { sendNotification, requestPermission, isPermissionGranted } +export { + Importance, + Visibility, + sendNotification, + requestPermission, + isPermissionGranted, + registerActionTypes, + pending, + cancel, + cancelAll, + active, + removeActive, + removeAllActive, + createChannel, + removeChannel, + channels +} diff --git a/plugins/notification/ios/Sources/NotificationCategory.swift b/plugins/notification/ios/Sources/NotificationCategory.swift index 32f23af3..d6d32edf 100644 --- a/plugins/notification/ios/Sources/NotificationCategory.swift +++ b/plugins/notification/ios/Sources/NotificationCategory.swift @@ -16,7 +16,7 @@ public func makeCategories(_ actionTypes: [JSObject]) { Logger.error("Action type must have an id field") continue } - let hiddenBodyPlaceholder = type["iosHiddenPreviewsBodyPlaceholder"] as? String ?? "" + let hiddenBodyPlaceholder = type["hiddenPreviewsBodyPlaceholder"] as? String ?? "" let actions = type["actions"] as? [JSObject] ?? [] let newActions = makeActions(actions) @@ -96,10 +96,10 @@ func makeActionOptions(_ action: JSObject) -> UNNotificationActionOptions { } 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 + let customDismiss = type["customDismissAction"] as? Bool ?? false + let carPlay = type["allowInCarPlay"] as? Bool ?? false + let hiddenPreviewsShowTitle = type["hiddenPreviewsShowTitle"] as? Bool ?? false + let hiddenPreviewsShowSubtitle = type["hiddenPreviewsShowSubtitle"] as? Bool ?? false if customDismiss { return .customDismissAction diff --git a/plugins/notification/ios/Sources/NotificationPlugin.swift b/plugins/notification/ios/Sources/NotificationPlugin.swift index 283d03ce..f08ffdc3 100644 --- a/plugins/notification/ios/Sources/NotificationPlugin.swift +++ b/plugins/notification/ios/Sources/NotificationPlugin.swift @@ -172,26 +172,21 @@ class NotificationPlugin: Plugin { invoke.resolve() } - @objc func removeDeliveredNotifications(_ invoke: Invoke) { - guard let notifications = invoke.getArray("notifications", JSObject.self) else { - invoke.reject("`notifications` input is required") - return + @objc func removeActive(_ invoke: Invoke) { + if let notifications = invoke.getArray("notifications", JSObject.self) { + let ids = notifications.map { "\($0["id"] ?? "")" } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) + invoke.resolve() + } else { + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + DispatchQueue.main.async(execute: { + UIApplication.shared.applicationIconBadgeNumber = 0 + }) + invoke.resolve() } - - let ids = notifications.map { "\($0["id"] ?? "")" } - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) - invoke.resolve() - } - - @objc func removeAllDeliveredNotifications(_ invoke: Invoke) { - UNUserNotificationCenter.current().removeAllDeliveredNotifications() - DispatchQueue.main.async(execute: { - UIApplication.shared.applicationIconBadgeNumber = 0 - }) - invoke.resolve() } - @objc func getDeliveredNotifications(_ invoke: Invoke) { + @objc func getActive(_ invoke: Invoke) { UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { (notifications) in let ret = notifications.map({ (notification) -> [String: Any] in diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs index 544c83fe..fab0020f 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -2,9 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::{collections::HashMap, fmt::Display}; - -use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; +use serde::Serialize; #[cfg(mobile)] use tauri::plugin::PluginHandle; #[cfg(desktop)] @@ -31,193 +29,6 @@ pub use error::{Error, Result}; use desktop::Notification; #[cfg(mobile)] use mobile::Notification; -use url::Url; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Attachment { - id: String, - url: Url, -} - -impl Attachment { - pub fn new(id: impl Into, url: Url) -> Self { - Self { id: id.into(), url } - } -} - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct ScheduleInterval { - pub year: Option, - pub month: Option, - pub day: Option, - pub weekday: Option, - pub hour: Option, - pub minute: Option, - pub second: Option, -} - -#[derive(Debug)] -pub enum ScheduleEvery { - Year, - Month, - TwoWeeks, - Week, - Day, - Hour, - Minute, - Second, -} - -impl Display for ScheduleEvery { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - Self::Year => "Year", - Self::Month => "Month", - Self::TwoWeeks => "TwoWeeks", - Self::Week => "Week", - Self::Day => "Day", - Self::Hour => "Hour", - Self::Minute => "Minute", - Self::Second => "Second", - } - ) - } -} - -impl Serialize for ScheduleEvery { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - serializer.serialize_str(self.to_string().as_ref()) - } -} - -impl<'de> Deserialize<'de> for ScheduleEvery { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - match s.to_lowercase().as_str() { - "year" => Ok(Self::Year), - "month" => Ok(Self::Month), - "twoweeks" => Ok(Self::TwoWeeks), - "week" => Ok(Self::Week), - "day" => Ok(Self::Day), - "hour" => Ok(Self::Hour), - "minute" => Ok(Self::Minute), - "second" => Ok(Self::Second), - _ => Err(DeError::custom(format!("unknown every kind '{s}'"))), - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "kind", content = "data")] -pub enum Schedule { - At { - #[serde( - serialize_with = "iso8601::serialize", - deserialize_with = "time::serde::iso8601::deserialize" - )] - date: time::OffsetDateTime, - #[serde(default)] - repeating: bool, - }, - Interval(ScheduleInterval), - Every { - interval: ScheduleEvery, - }, -} - -// custom ISO-8601 serialization that does not use 6 digits for years. -mod iso8601 { - use serde::{ser::Error as _, Serialize, Serializer}; - use time::{ - format_description::well_known::iso8601::{Config, EncodedConfig}, - format_description::well_known::Iso8601, - OffsetDateTime, - }; - - const SERDE_CONFIG: EncodedConfig = Config::DEFAULT.encode(); - - pub fn serialize( - datetime: &OffsetDateTime, - serializer: S, - ) -> Result { - datetime - .format(&Iso8601::) - .map_err(S::Error::custom)? - .serialize(serializer) - } -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct NotificationData { - /// Notification id. - #[serde(default = "default_id")] - id: i32, - channel_id: Option, - title: Option, - body: Option, - schedule: Option, - large_body: Option, - summary: Option, - action_type_id: Option, - group: Option, - #[serde(default)] - group_summary: bool, - sound: Option, - #[serde(default)] - inbox_lines: Vec, - icon: Option, - large_icon: Option, - icon_color: Option, - #[serde(default)] - attachments: Vec, - #[serde(default)] - extra: HashMap, - #[serde(default)] - ongoing: bool, - #[serde(default)] - auto_cancel: bool, -} - -fn default_id() -> i32 { - rand::random() -} - -impl Default for NotificationData { - fn default() -> Self { - Self { - id: default_id(), - channel_id: None, - title: None, - body: None, - schedule: None, - large_body: None, - summary: None, - action_type_id: None, - group: None, - group_summary: false, - sound: None, - inbox_lines: Vec::new(), - icon: None, - large_icon: None, - icon_color: None, - attachments: Vec::new(), - extra: Default::default(), - ongoing: false, - auto_cancel: false, - } - } -} /// The notification builder. #[derive(Debug)] diff --git a/plugins/notification/src/mobile.rs b/plugins/notification/src/mobile.rs index 287d8ceb..b6f82650 100644 --- a/plugins/notification/src/mobile.rs +++ b/plugins/notification/src/mobile.rs @@ -10,6 +10,8 @@ use tauri::{ use crate::models::*; +use std::collections::HashMap; + #[cfg(target_os = "android")] const PLUGIN_IDENTIFIER: &str = "app.tauri.notification"; @@ -58,6 +60,114 @@ impl Notification { .map(|r| r.permission_state) .map_err(Into::into) } + + pub fn register_action_types(&self, types: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("types", types); + self.0 + .run_mobile_plugin("registerActionTypes", args) + .map_err(Into::into) + } + + pub fn remove_active(&self, notifications: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert( + "notifications", + notifications + .into_iter() + .map(|id| { + let mut notification = HashMap::new(); + notification.insert("id", id); + notification + }) + .collect::>>(), + ); + self.0 + .run_mobile_plugin("removeActive", args) + .map_err(Into::into) + } + + pub fn active(&self) -> crate::Result> { + self.0 + .run_mobile_plugin::("getActive", ()) + .map(|r| r.notifications) + .map_err(Into::into) + } + + pub fn remove_all_active(&self) -> crate::Result<()> { + self.0 + .run_mobile_plugin("removeActive", ()) + .map_err(Into::into) + } + + pub fn pending(&self) -> crate::Result> { + self.0 + .run_mobile_plugin::("getPending", ()) + .map(|r| r.notifications) + .map_err(Into::into) + } + + /// Cancel pending notifications. + pub fn cancel(&self, notifications: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert( + "notifications", + notifications + .into_iter() + .map(|id| { + let mut notification = HashMap::new(); + notification.insert("id", id); + notification + }) + .collect::>>(), + ); + self.0.run_mobile_plugin("cancel", args).map_err(Into::into) + } + + /// Cancel all pending notifications. + pub fn cancel_all(&self) -> crate::Result<()> { + self.0.run_mobile_plugin("cancel", ()).map_err(Into::into) + } + + #[cfg(target_os = "android")] + pub fn create_channel(&self, channel: Channel) -> crate::Result<()> { + self.0 + .run_mobile_plugin("createChannel", channel) + .map_err(Into::into) + } + + #[cfg(target_os = "android")] + pub fn delete_channel(&self, id: impl Into) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("id", id.into()); + self.0 + .run_mobile_plugin("deleteChannel", args) + .map_err(Into::into) + } + + #[cfg(target_os = "android")] + pub fn list_channels(&self) -> crate::Result> { + self.0 + .run_mobile_plugin::("listChannels", ()) + .map(|r| r.channels) + .map_err(Into::into) + } +} + +#[cfg(target_os = "android")] +#[derive(Deserialize)] +struct ListChannelsResult { + channels: Vec, +} + +#[derive(Deserialize)] +struct PendingResponse { + notifications: Vec, +} + +#[derive(Deserialize)] +struct ActiveResponse { + notifications: Vec, } #[derive(Deserialize)] diff --git a/plugins/notification/src/models.rs b/plugins/notification/src/models.rs index 0ec2506f..bfcdb582 100644 --- a/plugins/notification/src/models.rs +++ b/plugins/notification/src/models.rs @@ -2,10 +2,198 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::fmt::Display; +use std::{collections::HashMap, fmt::Display}; use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; +use url::Url; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Attachment { + id: String, + url: Url, +} + +impl Attachment { + pub fn new(id: impl Into, url: Url) -> Self { + Self { id: id.into(), url } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduleInterval { + pub year: Option, + pub month: Option, + pub day: Option, + pub weekday: Option, + pub hour: Option, + pub minute: Option, + pub second: Option, +} + +#[derive(Debug)] +pub enum ScheduleEvery { + Year, + Month, + TwoWeeks, + Week, + Day, + Hour, + Minute, + Second, +} + +impl Display for ScheduleEvery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Year => "Year", + Self::Month => "Month", + Self::TwoWeeks => "TwoWeeks", + Self::Week => "Week", + Self::Day => "Day", + Self::Hour => "Hour", + Self::Minute => "Minute", + Self::Second => "Second", + } + ) + } +} + +impl Serialize for ScheduleEvery { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +impl<'de> Deserialize<'de> for ScheduleEvery { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "year" => Ok(Self::Year), + "month" => Ok(Self::Month), + "twoweeks" => Ok(Self::TwoWeeks), + "week" => Ok(Self::Week), + "day" => Ok(Self::Day), + "hour" => Ok(Self::Hour), + "minute" => Ok(Self::Minute), + "second" => Ok(Self::Second), + _ => Err(DeError::custom(format!("unknown every kind '{s}'"))), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data")] +pub enum Schedule { + At { + #[serde( + serialize_with = "iso8601::serialize", + deserialize_with = "time::serde::iso8601::deserialize" + )] + date: time::OffsetDateTime, + #[serde(default)] + repeating: bool, + }, + Interval(ScheduleInterval), + Every { + interval: ScheduleEvery, + }, +} + +// custom ISO-8601 serialization that does not use 6 digits for years. +mod iso8601 { + use serde::{ser::Error as _, Serialize, Serializer}; + use time::{ + format_description::well_known::iso8601::{Config, EncodedConfig}, + format_description::well_known::Iso8601, + OffsetDateTime, + }; + + const SERDE_CONFIG: EncodedConfig = Config::DEFAULT.encode(); + + pub fn serialize( + datetime: &OffsetDateTime, + serializer: S, + ) -> Result { + datetime + .format(&Iso8601::) + .map_err(S::Error::custom)? + .serialize(serializer) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationData { + #[serde(default = "default_id")] + pub(crate) id: i32, + pub(crate) channel_id: Option, + pub(crate) title: Option, + pub(crate) body: Option, + pub(crate) schedule: Option, + pub(crate) large_body: Option, + pub(crate) summary: Option, + pub(crate) action_type_id: Option, + pub(crate) group: Option, + #[serde(default)] + pub(crate) group_summary: bool, + pub(crate) sound: Option, + #[serde(default)] + pub(crate) inbox_lines: Vec, + pub(crate) icon: Option, + pub(crate) large_icon: Option, + pub(crate) icon_color: Option, + #[serde(default)] + pub(crate) attachments: Vec, + #[serde(default)] + pub(crate) extra: HashMap, + #[serde(default)] + pub(crate) ongoing: bool, + #[serde(default)] + pub(crate) auto_cancel: bool, +} + +fn default_id() -> i32 { + rand::random() +} + +impl Default for NotificationData { + fn default() -> Self { + Self { + id: default_id(), + channel_id: None, + title: None, + body: None, + schedule: None, + large_body: None, + summary: None, + action_type_id: None, + group: None, + group_summary: false, + sound: None, + inbox_lines: Vec::new(), + icon: None, + large_icon: None, + icon_color: None, + attachments: Vec::new(), + extra: Default::default(), + ongoing: false, + auto_cancel: false, + } + } +} + /// Permission state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionState { @@ -50,3 +238,269 @@ impl<'de> Deserialize<'de> for PermissionState { } } } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingNotification { + id: i32, + title: Option, + body: Option, + schedule: Schedule, +} + +impl PendingNotification { + pub fn id(&self) -> i32 { + self.id + } + + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + pub fn body(&self) -> Option<&str> { + self.body.as_deref() + } + + pub fn schedule(&self) -> &Schedule { + &self.schedule + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActiveNotification { + id: i32, + tag: Option, + title: Option, + body: Option, + group: Option, + #[serde(default)] + group_summary: bool, + #[serde(default)] + data: HashMap, + #[serde(default)] + extra: HashMap, + #[serde(default)] + attachments: Vec, + action_type_id: Option, + schedule: Option, + sound: Option, +} + +impl ActiveNotification { + pub fn id(&self) -> i32 { + self.id + } + + pub fn tag(&self) -> Option<&str> { + self.tag.as_deref() + } + + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + pub fn body(&self) -> Option<&str> { + self.body.as_deref() + } + + pub fn group(&self) -> Option<&str> { + self.group.as_deref() + } + + pub fn group_summary(&self) -> bool { + self.group_summary + } + + pub fn data(&self) -> &HashMap { + &self.data + } + + pub fn extra(&self) -> &HashMap { + &self.extra + } + + pub fn attachments(&self) -> &[Attachment] { + &self.attachments + } + + pub fn action_type_id(&self) -> Option<&str> { + self.action_type_id.as_deref() + } + + pub fn schedule(&self) -> Option<&Schedule> { + self.schedule.as_ref() + } + + pub fn sound(&self) -> Option<&str> { + self.sound.as_deref() + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionType { + id: String, + actions: Vec, + hidden_previews_body_placeholder: Option, + custom_dismiss_action: bool, + allow_in_car_play: bool, + hidden_previews_show_title: bool, + hidden_previews_show_subtitle: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Action { + id: String, + title: String, + requires_authentication: bool, + foreground: bool, + destructive: bool, + input: bool, + input_button_title: Option, + input_placeholder: Option, +} + +#[cfg(target_os = "android")] +pub use android::*; + +#[cfg(target_os = "android")] +mod android { + use serde::{Deserialize, Serialize}; + use serde_repr::{Deserialize_repr, Serialize_repr}; + + #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] + #[repr(u8)] + pub enum Importance { + None = 0, + Min = 1, + Low = 2, + Default = 3, + High = 4, + } + + impl Default for Importance { + fn default() -> Self { + Self::Default + } + } + + #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] + #[repr(i8)] + pub enum Visibility { + Secret = -1, + Private = 0, + Public = 1, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Channel { + id: String, + name: String, + description: Option, + sound: Option, + lights: bool, + light_color: Option, + vibration: bool, + importance: Importance, + visibility: Option, + } + + #[derive(Debug)] + pub struct ChannelBuilder(Channel); + + impl Channel { + pub fn builder(id: impl Into, name: impl Into) -> ChannelBuilder { + ChannelBuilder(Self { + id: id.into(), + name: name.into(), + description: None, + sound: None, + lights: false, + light_color: None, + vibration: false, + importance: Default::default(), + visibility: None, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + pub fn sound(&self) -> Option<&str> { + self.sound.as_deref() + } + + pub fn lights(&self) -> bool { + self.lights + } + + pub fn light_color(&self) -> Option<&str> { + self.light_color.as_deref() + } + + pub fn vibration(&self) -> bool { + self.vibration + } + + pub fn importance(&self) -> Importance { + self.importance + } + + pub fn visibility(&self) -> Option { + self.visibility + } + } + + impl ChannelBuilder { + pub fn description(mut self, description: impl Into) -> Self { + self.0.description.replace(description.into()); + self + } + + pub fn sound(mut self, sound: impl Into) -> Self { + self.0.sound.replace(sound.into()); + self + } + + pub fn lights(mut self, lights: bool) -> Self { + self.0.lights = lights; + self + } + + pub fn light_color(mut self, color: impl Into) -> Self { + self.0.light_color.replace(color.into()); + self + } + + pub fn vibration(mut self, vibration: bool) -> Self { + self.0.vibration = vibration; + self + } + + pub fn importance(mut self, importance: Importance) -> Self { + self.0.importance = importance; + self + } + + pub fn visibility(mut self, visibility: Visibility) -> Self { + self.0.visibility.replace(visibility); + self + } + + pub fn build(self) -> Channel { + self.0 + } + } +}