diff --git a/ci/apple/harness/Info.plist b/ci/apple/harness/Info.plist
new file mode 100644
index 0000000..162e95a
--- /dev/null
+++ b/ci/apple/harness/Info.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ CFBundleIdentifier
+ rs.pushups.ci.harness
+ PushupsNotificationFiltering
+
+
+
diff --git a/ci/apple/harness/main.swift b/ci/apple/harness/main.swift
index 4758e24..d2b8119 100644
--- a/ci/apple/harness/main.swift
+++ b/ci/apple/harness/main.swift
@@ -104,6 +104,22 @@ for (kind, marker) in [("error", "failed"), ("panic", "panicked"), ("icon", "fai
expect(fallback.snapshot.content?.userInfo["original"] as? String == "ciphertext", "\(kind) preserves sender payload")
}
+// The harness embeds an Info.plist declaring `PushupsNotificationFiltering`, which the default initializer reads.
+let suppressed = receive(service, ["kind": "no-alert", "original": "ciphertext"])
+wait(suppressed)
+if let content = suppressed.snapshot.content {
+ expect(content.title.isEmpty && content.subtitle.isEmpty && content.body.isEmpty, "declared no-alert clears every alert text")
+ expect(content.sound == nil && content.attachments.isEmpty, "declared no-alert has no sound or attachments")
+ expect(content.userInfo.isEmpty, "declared no-alert completes with fresh content, without the original payload or a marker")
+}
+
+let undeclaredService = PushupsNotificationService(filteringDeclared: false)
+let undeclared = receive(undeclaredService, ["kind": "no-alert", "original": "ciphertext"])
+wait(undeclared)
+expect(outcome(undeclared) == "suppression-undeclared", "undeclared no-alert outcome")
+expect(undeclared.snapshot.content?.title == "Safe title" && undeclared.snapshot.content?.body == "Safe body", "undeclared no-alert keeps the safe alert")
+expect(undeclared.snapshot.content?.userInfo["original"] as? String == "ciphertext", "undeclared no-alert keeps the sender payload")
+
let expired = receive(service, ["sleep": 300])
service.serviceExtensionTimeWillExpire()
service.serviceExtensionTimeWillExpire()
diff --git a/ci/apple/probe/src/lib.rs b/ci/apple/probe/src/lib.rs
index a697da7..9d6a763 100644
--- a/ci/apple/probe/src/lib.rs
+++ b/ci/apple/probe/src/lib.rs
@@ -1,17 +1,20 @@
//! The consumer transform linked into the native completion harness and iOS extension.
-use pushups::{Error, Message, Notification, RenderingContext};
+use pushups::{Error, Message, Notification, Rendering, RenderingContext};
#[pushups::notification_service]
-fn render(_context: RenderingContext, message: Message) -> Result {
+fn render(_context: RenderingContext, message: Message) -> Result {
let payload: serde_json::Value = serde_json::from_slice(&message.payload)
.map_err(|error| Error::Platform(error.to_string()))?;
match payload["kind"].as_str().unwrap_or_default() {
"error" => Err(Error::Platform("an error".to_owned())),
"panic" => panic!("a panic"),
- "icon" => Ok(Notification::new("Icon title").icon("https://example.com/icon.png")),
- "clear" => Ok(Notification::new("").tag("").data("").navigate("")),
- "minimal" => Ok(Notification::new("Minimal")),
+ "no-alert" => Ok(Rendering::NoAlert),
+ "icon" => Ok(Notification::new("Icon title")
+ .icon("https://example.com/icon.png")
+ .into()),
+ "clear" => Ok(Notification::new("").tag("").data("").navigate("").into()),
+ "minimal" => Ok(Notification::new("Minimal").into()),
_ => {
if let Some(sleep) = payload["sleep"].as_u64() {
std::thread::sleep(std::time::Duration::from_millis(sleep));
@@ -20,7 +23,8 @@ fn render(_context: RenderingContext, message: Message) -> Result&2
+ exit 1
+ fi
+}
+check_row undeclared unentitled 0
+check_row declared entitled 0
+check_row declared unentitled 1
+check_row undeclared entitled 1
diff --git a/docs/mobile-notifications.md b/docs/mobile-notifications.md
index ab544e8..a9e727b 100644
--- a/docs/mobile-notifications.md
+++ b/docs/mobile-notifications.md
@@ -7,38 +7,46 @@ An iOS notification service extension runs in its own process before an eligible
The extension's Rust crate declares `crate-type = ["staticlib"]` and one callback, shown here for fixed-text rendering.
```rust
-use pushups::{Error, Message, Notification, RenderingContext};
+use pushups::{Error, Message, Notification, Rendering, RenderingContext};
#[pushups::notification_service]
-fn render(context: RenderingContext, message: Message) -> Result {
+fn render(context: RenderingContext, message: Message) -> Result {
if context.is_cancelled() {
return Err(Error::Platform("notification rendering expired".into()));
}
let data = String::from_utf8(message.payload)
.map_err(|error| Error::Platform(error.to_string()))?;
- Ok(Notification::new("New activity").data(data))
+ Ok(Notification::new("New activity").data(data).into())
}
```
Application-produced and encrypted rendering supply application-owned interpretation or decryption in that callback. `pushups` supplies no encryption, key storage, payload schema, network client or async runtime. The callback receives owned bytes containing the complete original APNs user-info dictionary as `JSON`, and `Message.started_app` is `false` because the extension does not launch the application.
-The callback runs on an extension-owned worker. `RenderingContext` is `Clone + Send + Sync` and contains an owned request identifier, a monotonic deadline and shared completion state, with `request_id()`, `deadline()`, `is_cancelled()` and `outcome()` accessors. Consumer async work must run on its own executor and obey the deadline and cancellation signal, without retaining native references.
+The callback runs on an extension-owned worker. `RenderingContext` is `Clone + Send + Sync` and contains an owned request identifier, a monotonic deadline and shared completion state, with `request_id()`, `deadline()`, `is_cancelled()`, `suppression()` and `outcome()` accessors. Consumer async work must run on its own executor and obey the deadline and cancellation signal, without retaining native references.
Completion selects one terminal outcome exactly once. The bound is 25 seconds from receipt or the earlier platform expiration callback. Cancellation is cooperative, and work that returns after completion cannot change the notification.
| Outcome | Presented content |
|---|---|
| `Rendered` | Returned title and body |
+| `SuppressionRequested` | Empty content, which hides the alert only under Apple's approved entitlement |
+| `SuppressionUndeclared` | Original safe alert |
| `Failed` | Original safe alert |
| `Panicked` | Original safe alert after an unwinding panic |
| `Expired` | Original safe alert |
| `WorkerUnavailable` | Original safe alert |
| `Superseded` | Original safe alert for the displaced request |
-The final outcome is stored in the `pushups.rendering-outcome` user-info string. Abort-mode panics and process termination cannot run Rust cleanup, and iOS retains its original-alert fallback. The original alert must therefore remain safe to show without decryption.
+The final outcome is stored in the `pushups.rendering-outcome` user-info string, except for `SuppressionRequested`, whose empty content carries none and which `RenderingContext::outcome()` reports. Abort-mode panics and process termination cannot run Rust cleanup, and iOS retains its original-alert fallback. The original alert must therefore remain safe to show without decryption.
A successful `Notification` replaces title and body, with an absent body clearing the body. `tag` maps to the native thread identifier, and `data` and `navigate` occupy reserved user-info keys while unrelated user info, attachments, badge, sound and category remain intact. Icon URLs are unsupported on mobile and select failure rather than fetching media.
+### Suppressing an alert
+
+`Rendering::NoAlert` asks for no alert, and an error or an empty title never means that. The extension's `Info.plist` declares the policy with `PushupsNotificationFiltering` set to `true`, and `RenderingContext::suppression()` reports `Declared` or `Undeclared`. Without the declaration, `NoAlert` settles `SuppressionUndeclared` on the original alert. With it, `NoAlert` completes with an empty `UNNotificationContent`.
+
+iOS cannot read an extension's signed entitlements at runtime, so `Declared` is the app's own assertion and `SuppressionRequested` only says the empty content went to the system. The alert disappears only when Apple has granted [`com.apple.developer.usernotifications.filtering`](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.usernotifications.filtering), the extension is signed with it, and the sender uses `apns-push-type: alert`. Before a release, `ios/check-filtering.sh ` on macOS fails when the declaration and the signed entitlement disagree. Seeing the alert disappear on a device remains the release owner's check.
+
### Bundle and signing
Compile the shipped `ios/PushupsNotificationService.swift` into the extension and link the consumer's Rust static library. The Swift adapter calls the Rust receive and expiration symbols generated by `#[pushups::notification_service]`. Its module-qualified `PushupsNotificationService` class is the extension's principal class.
@@ -67,7 +75,7 @@ The `Context` passed to `#[pushups::background_handler]` borrows the native call
## Acceptance boundary
-`ci/apple/run.sh` exercises native transformation and completion on macOS, then links and verifies Rust entry symbols in ad-hoc-signed iOS device and simulator extension bundles. The harness does not execute an iOS extension in the simulator or establish locked-iPhone alert delivery, background scheduling, real keychain accessibility or visible locked-device rendering, which require device acceptance.
+`ci/apple/run.sh` exercises native transformation and completion on macOS, including the empty content of a declared `NoAlert` and the safe alert of an undeclared one, then links and verifies Rust entry symbols in ad-hoc-signed iOS device and simulator extension bundles and runs `ios/check-filtering.sh` against them signed with and without the entitlement and the declaration. The harness does not execute an iOS extension in the simulator or establish Apple's filtering approval, locked-iPhone alert delivery, background scheduling, real keychain accessibility, visible locked-device rendering or a suppressed alert on a device, which require device acceptance.
`ci/android/rendering.sh` exercises headless presentation, refusal cases, tagged replacement, launcher tap delivery and the context `with_jni` lends on a booted Android device or emulator.
diff --git a/ios/PushupsNotificationService.swift b/ios/PushupsNotificationService.swift
index cadb373..1f20cda 100644
--- a/ios/PushupsNotificationService.swift
+++ b/ios/PushupsNotificationService.swift
@@ -32,6 +32,11 @@ private final class PushupsCompletion {
data: String?,
navigate: String?
) {
+ // Apple's filtering contract is a fresh content with no alert fields, so no marker rides on it.
+ if outcome == 6 {
+ handler(UNNotificationContent())
+ return
+ }
if outcome == 0, let title {
content.title = title
content.body = body ?? ""
@@ -85,7 +90,7 @@ private func pushupsFinish(
}
@_silgen_name("__pushups_notification_service_create")
-private func pushupsCreate() -> UnsafeMutableRawPointer
+private func pushupsCreate(_ filteringDeclared: Bool) -> UnsafeMutableRawPointer
@_silgen_name("__pushups_notification_service_receive")
private func pushupsReceive(
@@ -105,7 +110,17 @@ private func pushupsDeallocated(_ service: UnsafeMutableRawPointer)
/// Transforms an alert through the Rust `notification_service` callback.
public final class PushupsNotificationService: UNNotificationServiceExtension {
- private let service = pushupsCreate()
+ private let service: UnsafeMutableRawPointer
+
+ /// Reads the extension's `PushupsNotificationFiltering` declaration from its `Info.plist`.
+ public override convenience init() {
+ self.init(filteringDeclared: Bundle.main.object(forInfoDictionaryKey: "PushupsNotificationFiltering") as? Bool ?? false)
+ }
+
+ init(filteringDeclared: Bool) {
+ service = pushupsCreate(filteringDeclared)
+ super.init()
+ }
public override func didReceive(
_ request: UNNotificationRequest,
diff --git a/ios/check-filtering.sh b/ios/check-filtering.sh
new file mode 100755
index 0000000..56461a3
--- /dev/null
+++ b/ios/check-filtering.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Compares a signed notification service extension's `PushupsNotificationFiltering` declaration with its signed filtering entitlement.
+#
+# Usage: ios/check-filtering.sh
+set -euo pipefail
+
+appex=${1:?usage: check-filtering.sh }
+codesign --verify --strict "$appex"
+
+entitlements=$(mktemp)
+trap 'rm -f "$entitlements"' EXIT
+codesign -d --entitlements - --xml "$appex" > "$entitlements" 2>/dev/null
+
+declared=$(/usr/libexec/PlistBuddy -c 'Print :PushupsNotificationFiltering' "$appex/Info.plist" 2>/dev/null) || declared=false
+entitled=$(/usr/libexec/PlistBuddy -c 'Print :com.apple.developer.usernotifications.filtering' "$entitlements" 2>/dev/null) || entitled=false
+
+case "$declared/$entitled" in
+ false/false)
+ echo "check-filtering: no declaration and no entitlement, so Rendering::NoAlert keeps the original alert"
+ ;;
+ true/true)
+ echo "check-filtering: declared and entitled. Apple's approval of the entitlement, apns-push-type alert and suppression on a device still need checking"
+ ;;
+ true/false)
+ echo "check-filtering: PushupsNotificationFiltering is declared but the signature lacks com.apple.developer.usernotifications.filtering, so iOS would show alerts the app asks to suppress" >&2
+ exit 1
+ ;;
+ false/true)
+ echo "check-filtering: the signature carries com.apple.developer.usernotifications.filtering but PushupsNotificationFiltering is not declared, so Rendering::NoAlert keeps the original alert" >&2
+ exit 1
+ ;;
+ *)
+ echo "check-filtering: unexpected values, declaration '$declared' and entitlement '$entitled'" >&2
+ exit 2
+ ;;
+esac
diff --git a/pushups-macros/src/lib.rs b/pushups-macros/src/lib.rs
index 2e8eaff..acc9acf 100644
--- a/pushups-macros/src/lib.rs
+++ b/pushups-macros/src/lib.rs
@@ -183,7 +183,7 @@ pub fn background_handler(args: TokenStream, item: TokenStream) -> TokenStream {
.into()
}
-/// Exports a synchronous `fn(RenderingContext, Message) -> Result` for the [iOS service extension](https://github.com/LucaCappelletti94/pushups/blob/main/docs/mobile-notifications.md).
+/// Exports a synchronous `fn(RenderingContext, Message) -> Result` for the [iOS service extension](https://github.com/LucaCappelletti94/pushups/blob/main/docs/mobile-notifications.md).
#[proc_macro_attribute]
pub fn notification_service(args: TokenStream, item: TokenStream) -> TokenStream {
let item: TokenStream2 = item.into();
@@ -208,12 +208,14 @@ pub fn notification_service(args: TokenStream, item: TokenStream) -> TokenStream
const _: fn(
#pushups::RenderingContext,
#pushups::Message,
- ) -> core::result::Result<#pushups::Notification, #pushups::Error> = #name;
+ ) -> core::result::Result<#pushups::Rendering, #pushups::Error> = #name;
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[unsafe(no_mangle)]
- pub extern "C" fn __pushups_notification_service_create() -> *mut ::core::ffi::c_void {
- #pushups::__private::ios::create()
+ pub extern "C" fn __pushups_notification_service_create(
+ filtering_declared: bool,
+ ) -> *mut ::core::ffi::c_void {
+ #pushups::__private::ios::create(filtering_declared)
}
/// # Safety
@@ -275,7 +277,7 @@ pub fn notification_service(args: TokenStream, item: TokenStream) -> TokenStream
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[used]
static __PUSHUPS_NOTIFICATION_SERVICE_KEPT: (
- extern "C" fn() -> *mut ::core::ffi::c_void,
+ extern "C" fn(bool) -> *mut ::core::ffi::c_void,
unsafe extern "C" fn(
*const ::core::ffi::c_void,
*const u8,
diff --git a/pushups-macros/tests/notification_service.rs b/pushups-macros/tests/notification_service.rs
index 1b8b479..ae6e325 100644
--- a/pushups-macros/tests/notification_service.rs
+++ b/pushups-macros/tests/notification_service.rs
@@ -30,8 +30,8 @@ fn notification_service_expands_under_a_renamed_dependency() {
consumer.path().join("src/lib.rs"),
"#[notifications::notification_service]\n\
fn on_render(\n _context: notifications::RenderingContext,\n\
- message: notifications::Message,\n) -> Result {\n\
- Ok(notifications::Notification::new(\"Rendered\").body(message.payload.len().to_string()))\n}\n",
+ message: notifications::Message,\n) -> Result {\n\
+ Ok(notifications::Notification::new(\"Rendered\").body(message.payload.len().to_string()).into())\n}\n",
)
.expect("the consumer's source");
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
diff --git a/src/apple/service.rs b/src/apple/service.rs
index 0bd46d8..603bc9a 100644
--- a/src/apple/service.rs
+++ b/src/apple/service.rs
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
use crate::__private::ios::Finish;
use crate::rendering::{CompletionCallback, RenderingOutcome, RenderingService};
-use crate::{Error, Message, Notification, RenderingContext};
+use crate::{Error, Message, Rendering, RenderingContext, Suppression};
/// How long a transform may run before the alert is left as sent, inside Apple's 30 seconds.
const RENDERING_BOUND: Duration = Duration::from_secs(25);
@@ -15,13 +15,18 @@ const RENDERING_BOUND: Duration = Duration::from_secs(25);
const NANOSECONDS_PER_SECOND: u64 = 1_000_000_000;
/// Creates the rendering service one native service instance owns, the handle `receive`,
-/// `expired` and `deallocated` take.
+/// `expired` and `deallocated` take, under the policy the extension's `Info.plist` declares.
///
/// # Safety
///
/// The handle is released exactly once, by `deallocated`.
-pub(crate) fn create() -> *mut c_void {
- Box::into_raw(Box::new(RenderingService::new())).cast()
+pub(crate) fn create(filtering_declared: bool) -> *mut c_void {
+ let suppression = if filtering_declared {
+ Suppression::Declared
+ } else {
+ Suppression::Undeclared
+ };
+ Box::into_raw(Box::new(RenderingService::new(suppression))).cast()
}
/// Starts the request for the alert the adapter received, superseding a pending one, and
@@ -37,7 +42,7 @@ pub(crate) unsafe fn receive(
budget_nanos: u64,
finish: Finish,
token: *mut c_void,
- handler: fn(RenderingContext, Message) -> Result,
+ handler: fn(RenderingContext, Message) -> Result,
) {
// The deadline anchors before the payload copy, so the copy counts against the budget.
let deadline = Instant::now()
@@ -145,8 +150,9 @@ fn field_bytes(value: Option<&str>) -> (*const u8, usize) {
}
}
-/// The code the settled outcome takes on the wire. `Rendered` applies the fields, and the
-/// other codes settle the original alert, which the marker distinguishes.
+/// The code the settled outcome takes on the wire. `Rendered` applies the fields,
+/// `SuppressionRequested` completes with empty content, and the other codes settle the
+/// original alert, which the marker distinguishes.
#[must_use]
fn outcome_code(outcome: RenderingOutcome) -> i32 {
match outcome {
@@ -156,5 +162,7 @@ fn outcome_code(outcome: RenderingOutcome) -> i32 {
RenderingOutcome::Expired => 3,
RenderingOutcome::WorkerUnavailable => 4,
RenderingOutcome::Superseded => 5,
+ RenderingOutcome::SuppressionRequested => 6,
+ RenderingOutcome::SuppressionUndeclared => 7,
}
}
diff --git a/src/lib.rs b/src/lib.rs
index 02762f8..a563aef 100755
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -122,7 +122,7 @@ pub use event::{Event, Message};
pub use notification::Notification;
pub use permission::Permission;
pub use pushups_macros::{background_handler, firebase_config, notification_service};
-pub use rendering::{RenderingContext, RenderingOutcome};
+pub use rendering::{Rendering, RenderingContext, RenderingOutcome, Suppression};
pub use token::{Token, WebPushKeys, WebPushSubscription};
pub use worker_file::write_service_worker;
diff --git a/src/private.rs b/src/private.rs
index 5ffe072..1cbbed1 100644
--- a/src/private.rs
+++ b/src/private.rs
@@ -57,7 +57,7 @@ pub mod apple {
pub mod ios {
use core::ffi::c_void;
- use crate::{Error, Message, Notification, RenderingContext};
+ use crate::{Error, Message, Rendering, RenderingContext};
/// The adapter's completion thunk. The machine settles the request exactly once, with
/// the settled outcome and, for a rendered alert, the transform's fields.
@@ -82,10 +82,10 @@ pub mod ios {
navigate_len: usize,
);
- /// Allocates the service handle released by `deallocated`.
+ /// Allocates the service handle released by `deallocated`, under the filtering policy the extension's `Info.plist` declares.
#[must_use]
- pub fn create() -> *mut c_void {
- crate::apple::service::create()
+ pub fn create(filtering_declared: bool) -> *mut c_void {
+ crate::apple::service::create(filtering_declared)
}
/// Starts the rendering request for the alert the adapter received, superseding a pending
@@ -101,7 +101,7 @@ pub mod ios {
budget_nanos: u64,
finish: Finish,
token: *mut c_void,
- handler: fn(RenderingContext, Message) -> Result,
+ handler: fn(RenderingContext, Message) -> Result,
) {
// SAFETY: the caller passed a live handle, a live thunk and a payload of
// `user_info_len` bytes.
diff --git a/src/rendering.rs b/src/rendering.rs
index 1522e2e..a81b4f0 100644
--- a/src/rendering.rs
+++ b/src/rendering.rs
@@ -10,14 +10,43 @@ use std::time::Instant;
use parking_lot::Mutex;
+use crate::Notification;
#[cfg(any(target_os = "ios", target_os = "macos", test))]
-use crate::{Error, Message, Notification};
+use crate::{Error, Message};
+
+/// What a [`notification_service`](crate::notification_service) callback asks the system to present.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Rendering {
+ /// Replace the original alert's title and body with this notification's.
+ Show(Notification),
+ /// Present no alert, honoured only under a declared [`Suppression`] policy.
+ NoAlert,
+}
+
+impl From for Rendering {
+ fn from(notification: Notification) -> Self {
+ Self::Show(notification)
+ }
+}
+
+/// Whether the notification service extension declares Apple's filtering policy in its `Info.plist`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Suppression {
+ /// No declaration, so [`Rendering::NoAlert`] keeps the original alert.
+ Undeclared,
+ /// `PushupsNotificationFiltering` is true, an assertion iOS cannot check at runtime, so [`Rendering::NoAlert`] completes with empty content.
+ Declared,
+}
/// What the system shows for the alert after the transform settles.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderingOutcome {
/// The transform's content replaced the original alert.
Rendered,
+ /// The transform asked for no alert under a declared policy, and empty content went to the system.
+ SuppressionRequested,
+ /// The transform asked for no alert without a declared policy, and the original alert stands.
+ SuppressionUndeclared,
/// The transform returned an error, or content the platform cannot show, such as an icon URL.
Failed,
/// The transform unwound.
@@ -36,6 +65,8 @@ impl RenderingOutcome {
pub(crate) const fn marker(self) -> &'static str {
match self {
Self::Rendered => "rendered",
+ Self::SuppressionRequested => "suppression-requested",
+ Self::SuppressionUndeclared => "suppression-undeclared",
Self::Failed => "failed",
Self::Panicked => "panicked",
Self::Expired => "expired",
@@ -70,6 +101,12 @@ impl RenderingContext {
self.state.cancelled.load(Ordering::Acquire) || Instant::now() >= self.state.deadline
}
+ /// Whether the extension declares the filtering policy that lets [`Rendering::NoAlert`] suppress the alert.
+ #[must_use]
+ pub fn suppression(&self) -> Suppression {
+ self.state.suppression
+ }
+
/// The settled outcome, or `None` while the transform runs.
#[must_use]
pub fn outcome(&self) -> Option {
@@ -113,6 +150,7 @@ pub(crate) fn mapped_fields(notification: Notification) -> Option>>,
+ suppression: Suppression,
}
/// The process-wide generation of rendering requests, keeping request identities fresh
@@ -122,11 +160,12 @@ static NEXT_GENERATION: AtomicU64 = AtomicU64::new(0);
#[cfg(any(target_os = "ios", target_os = "macos", test))]
impl RenderingService {
- /// The service with no request yet.
+ /// The service with no request yet, under the extension's declared policy.
#[must_use]
- pub(crate) const fn new() -> Self {
+ pub(crate) const fn new(suppression: Suppression) -> Self {
Self {
current: Mutex::new(None),
+ suppression,
}
}
@@ -136,13 +175,14 @@ impl RenderingService {
&self,
deadline: Instant,
callback: CompletionCallback,
- handler: fn(RenderingContext, Message) -> Result,
+ handler: fn(RenderingContext, Message) -> Result,
user_info: Vec,
) -> RenderingContext {
let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
let state = Arc::new(RequestState {
id: format!("pushups-render-{generation}"),
deadline,
+ suppression: self.suppression,
cancelled: AtomicBool::new(false),
completion: Mutex::new(Completion {
outcome: None,
@@ -187,9 +227,17 @@ impl RenderingService {
(RenderingOutcome::Expired, None)
} else {
match result {
- Ok(Ok(notification)) => match mapped_fields(notification) {
- Some(fields) => (RenderingOutcome::Rendered, Some(fields)),
- None => (RenderingOutcome::Failed, None),
+ Ok(Ok(Rendering::Show(notification))) => {
+ match mapped_fields(notification) {
+ Some(fields) => (RenderingOutcome::Rendered, Some(fields)),
+ None => (RenderingOutcome::Failed, None),
+ }
+ }
+ Ok(Ok(Rendering::NoAlert)) => match worker_state.suppression {
+ Suppression::Declared => (RenderingOutcome::SuppressionRequested, None),
+ Suppression::Undeclared => {
+ (RenderingOutcome::SuppressionUndeclared, None)
+ }
},
Ok(Err(_)) => (RenderingOutcome::Failed, None),
Err(_) => (RenderingOutcome::Panicked, None),
@@ -227,6 +275,7 @@ impl RenderingService {
pub(crate) struct RequestState {
id: String,
deadline: Instant,
+ suppression: Suppression,
cancelled: AtomicBool,
completion: Mutex,
}
@@ -328,7 +377,7 @@ mod tests {
/// The consumer transform every scenario drives. A payload is a kind, optionally followed
/// by `:sleep-`, which the transform sleeps before answering.
- fn test_handler(_context: RenderingContext, message: Message) -> Result {
+ fn test_handler(_context: RenderingContext, message: Message) -> Result {
let Message {
payload,
started_app,
@@ -352,18 +401,20 @@ mod tests {
"panic" => panic!("the test transform unwinds"),
"icon" => Ok(Notification::new("Icon")
.body("Icon body")
- .icon("https://example.com/icon.png")),
+ .icon("https://example.com/icon.png")
+ .into()),
+ "no-alert" => Ok(Rendering::NoAlert),
"record" => {
MESSAGES.lock().push((payload, started_app));
- Ok(rendered())
+ Ok(rendered().into())
}
- _ => Ok(rendered()),
+ _ => Ok(rendered().into()),
}
}
#[test]
fn the_context_keeps_its_identity_and_deadline_after_completion() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let context = service.begin(
deadline_in(5_000),
@@ -383,7 +434,7 @@ mod tests {
#[test]
fn a_transformed_alert_completes_rendered_with_its_mapped_fields() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let context = service.begin(
deadline_in(5_000),
@@ -407,7 +458,7 @@ mod tests {
#[test]
fn a_returned_error_completes_failed_on_the_fallback() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let context = service.begin(
deadline_in(5_000),
@@ -427,7 +478,7 @@ mod tests {
#[test]
fn an_unwinding_transform_completes_panicked() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let context = service.begin(
deadline_in(5_000),
@@ -447,7 +498,7 @@ mod tests {
#[test]
fn an_icon_url_completes_failed_on_the_fallback() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let context = service.begin(
deadline_in(5_000),
@@ -467,7 +518,7 @@ mod tests {
#[test]
fn the_payload_is_the_original_user_info_and_the_app_did_not_start() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let payload = b"record:the-original-user-info".to_vec();
let context = service.begin(
@@ -491,7 +542,7 @@ mod tests {
#[test]
fn a_worker_returning_after_the_deadline_completes_expired_once() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
service.begin(
deadline_in(60),
@@ -513,7 +564,7 @@ mod tests {
#[test]
fn an_already_spent_budget_completes_expired_without_running_the_transform() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let context = service.begin(
Instant::now(),
@@ -543,7 +594,7 @@ mod tests {
#[test]
fn an_error_returning_after_the_deadline_completes_expired() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
service.begin(
deadline_in(60),
@@ -559,7 +610,7 @@ mod tests {
#[test]
fn the_deadline_cancels_the_context_before_the_timer_delivers() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let context = service.begin(
deadline_in(80),
@@ -576,7 +627,7 @@ mod tests {
#[test]
fn a_newer_alert_supersedes_the_pending_one() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let first = Arc::new(Mutex::new(Recorded::default()));
let second = Arc::new(Mutex::new(Recorded::default()));
let context_first = service.begin(
@@ -608,7 +659,7 @@ mod tests {
#[test]
fn a_supersession_after_the_deadline_completes_expired() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let first = Arc::new(Mutex::new(Recorded::default()));
let second = Arc::new(Mutex::new(Recorded::default()));
let context_first = service.begin(
@@ -636,7 +687,7 @@ mod tests {
#[test]
fn a_stale_timer_cannot_complete_the_newer_request() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let first = Arc::new(Mutex::new(Recorded::default()));
let second = Arc::new(Mutex::new(Recorded::default()));
let context_first = service.begin(
@@ -672,7 +723,7 @@ mod tests {
#[test]
fn the_platform_expiration_completes_expired_and_drops_the_late_worker() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let context = service.begin(
deadline_in(5_000),
@@ -692,7 +743,7 @@ mod tests {
#[test]
fn a_settled_request_ignores_later_expirations_and_deallocations() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let context = service.begin(
deadline_in(5_000),
@@ -712,7 +763,7 @@ mod tests {
#[test]
fn the_deallocation_supersedes_the_pending_request() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let recorded = Arc::new(Mutex::new(Recorded::default()));
let context = service.begin(
deadline_in(5_000),
@@ -730,7 +781,7 @@ mod tests {
#[test]
fn reused_payloads_keep_fresh_request_ids() {
- let service = RenderingService::new();
+ let service = RenderingService::new(Suppression::Undeclared);
let payload = b"render".to_vec();
let first = service.begin(
deadline_in(5_000),
@@ -757,7 +808,7 @@ mod tests {
#[test]
fn the_completion_runs_outside_the_lock_and_reenters_the_service() {
- let service = Arc::new(RenderingService::new());
+ let service = Arc::new(RenderingService::new(Suppression::Undeclared));
let inner = Arc::clone(&service);
let inner_recorded = Arc::new(Mutex::new(Recorded::default()));
let inner_callback = callback(&inner_recorded);
@@ -809,6 +860,7 @@ mod tests {
let state = Arc::new(RequestState {
id: "late-request".to_owned(),
deadline: Instant::now(),
+ suppression: Suppression::Undeclared,
cancelled: AtomicBool::new(false),
completion: Mutex::new(Completion {
outcome: None,
@@ -831,4 +883,90 @@ mod tests {
assert_send_sync::();
};
}
+
+ #[test]
+ fn no_alert_under_a_declared_policy_completes_suppression_requested() {
+ let service = RenderingService::new(Suppression::Declared);
+ let recorded = Arc::new(Mutex::new(Recorded::default()));
+ let context = service.begin(
+ deadline_in(5_000),
+ callback(&recorded),
+ test_handler,
+ b"no-alert".to_vec(),
+ );
+ assert_eq!(context.suppression(), Suppression::Declared);
+ wait_for(|| recorded.lock().count == 1, Duration::from_secs(5));
+ let recorded = recorded.lock();
+ assert_eq!(
+ recorded.outcome,
+ Some(RenderingOutcome::SuppressionRequested)
+ );
+ assert!(recorded.fields.is_none());
+ }
+
+ #[test]
+ fn no_alert_without_a_declared_policy_keeps_the_original_alert() {
+ let service = RenderingService::new(Suppression::Undeclared);
+ let recorded = Arc::new(Mutex::new(Recorded::default()));
+ let context = service.begin(
+ deadline_in(5_000),
+ callback(&recorded),
+ test_handler,
+ b"no-alert".to_vec(),
+ );
+ assert_eq!(context.suppression(), Suppression::Undeclared);
+ wait_for(|| recorded.lock().count == 1, Duration::from_secs(5));
+ let recorded = recorded.lock();
+ assert_eq!(
+ recorded.outcome,
+ Some(RenderingOutcome::SuppressionUndeclared)
+ );
+ assert!(recorded.fields.is_none());
+ }
+
+ #[test]
+ fn a_no_alert_after_the_deadline_completes_expired_once() {
+ let service = RenderingService::new(Suppression::Declared);
+ let recorded = Arc::new(Mutex::new(Recorded::default()));
+ let context = service.begin(
+ deadline_in(50),
+ callback(&recorded),
+ test_handler,
+ b"no-alert:sleep-200".to_vec(),
+ );
+ wait_for(
+ || context.outcome() == Some(RenderingOutcome::Expired),
+ Duration::from_secs(5),
+ );
+ std::thread::sleep(Duration::from_millis(300));
+ let recorded = recorded.lock();
+ assert_eq!(recorded.count, 1);
+ assert_eq!(recorded.outcome, Some(RenderingOutcome::Expired));
+ }
+
+ #[test]
+ fn a_superseded_no_alert_cannot_settle_after_its_replacement() {
+ let service = RenderingService::new(Suppression::Declared);
+ let first = Arc::new(Mutex::new(Recorded::default()));
+ let second = Arc::new(Mutex::new(Recorded::default()));
+ let context_first = service.begin(
+ deadline_in(5_000),
+ callback(&first),
+ test_handler,
+ b"no-alert:sleep-200".to_vec(),
+ );
+ let context_second = service.begin(
+ deadline_in(5_000),
+ callback(&second),
+ test_handler,
+ b"render".to_vec(),
+ );
+ wait_for(
+ || context_second.outcome() == Some(RenderingOutcome::Rendered),
+ Duration::from_secs(5),
+ );
+ std::thread::sleep(Duration::from_millis(300));
+ assert_eq!(context_first.outcome(), Some(RenderingOutcome::Superseded));
+ assert_eq!(first.lock().count, 1);
+ }
}