Skip to content

Commit 33ea295

Browse files
feat(host-api): scheduled push notifications (RFC 0019) (#170)
Co-authored-by: Sergey Zhuravlev <zhuravlev1337@gmail.com>
1 parent 855868e commit 33ea295

13 files changed

Lines changed: 269 additions & 72 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,5 @@ vitest.config.*.timestamp*
2929
packages/*/dist
3030

3131
storybook-static
32+
33+
docs/superpowers/

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
- **host-papp:** `UserSession` gains a `createTransaction(payload)` method. The Host can now delegate product-account transaction signing to the paired Polkadot Mobile app via the new `CreateTransactionRequest` / `CreateTransactionResponse` SSO message pair (legacy-account signing stays Host-local).
77
- **product-sdk:** new top-level `accounts` singleton (`createAccountsProvider()` with the default sandbox transport) for products that don't need a custom transport.
88
- **product-sdk:** export `ProductAccountId` and `LegacyAccount` types.
9+
- **host-api / product-sdk:** products can now schedule a push notification for a future time, not just send one right away. Pass `scheduledAt` (a UTC timestamp in milliseconds) when calling `notificationManager.push(...)`, and the host will deliver it at that moment. Leave it out to deliver immediately as before.
10+
- **host-api / product-sdk:** `push(...)` now returns an id you can hold onto, and the new `notificationManager.cancel(id)` lets a product cancel a notification it scheduled earlier — handy for "remind me in an hour" style flows where the user changes their mind.
11+
- **host-api / product-sdk:** if the host can't accept any more scheduled notifications, the product now gets a clear `ScheduleLimitReached` error instead of a generic failure, so it can tell the user what happened.
912

1013
### ⚠️ Breaking Changes
1114

@@ -18,6 +21,7 @@
1821
- **product-sdk:** new runtime dependency `@polkadot-api/substrate-bindings@^0.20.2` (used by `getProductAccountSigner` to decode metadata locally and pick `txExtVersion`).
1922

2023
> No compatibility shim. `host_create_transaction` had no production consumers and `host_create_transaction_with_legacy_account` is only reachable via `product-sdk`, which is bumped in lockstep.
24+
- **host-api:** the push-notification format changed to support scheduling and cancellation. Hosts and products must upgrade together — older clients won't be able to send notifications to a newer host (or vice versa).
2125

2226
## 0.7.8 (2026-05-08)
2327

__tests__/hostApi/notification.spec.ts

Lines changed: 103 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GenericError, createHostApi, createTransport, enumValue } from '@novasamatech/host-api';
1+
import { GenericError, PushNotificationError, createHostApi, createTransport, enumValue } from '@novasamatech/host-api';
22
import type { ContainerHandlerOf } from '@novasamatech/host-container';
33
import { createContainer } from '@novasamatech/host-container';
44

@@ -16,23 +16,23 @@ function setup() {
1616
}
1717

1818
describe('Host API: PushNotification', () => {
19-
it('should deliver a notification and gate on Notifications device permission', async () => {
19+
it('should deliver an immediate notification and return a NotificationId', async () => {
2020
const { container, hostApi } = setup();
21-
const payload = { text: 'Hello, world!', deeplink: 'https://example.com/deep' };
21+
const payload = { text: 'Hello, world!', deeplink: 'https://example.com/deep', scheduledAt: undefined };
2222

2323
const devicePermissionHandler = vi.fn<ContainerHandlerOf<typeof container.handleDevicePermission>>(
2424
(_params, { ok }) => ok(true),
2525
);
2626
container.handleDevicePermission(devicePermissionHandler);
27-
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(undefined));
27+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(42));
2828
container.handlePushNotification(handler);
2929

3030
const result = await hostApi.pushNotification(enumValue('v1', payload));
3131

3232
result.match(
3333
ok => {
3434
expect(ok.tag).toBe('v1');
35-
expect(ok.value).toBeUndefined();
35+
expect(ok.value).toBe(42);
3636
},
3737
() => {
3838
throw new Error('Expected success');
@@ -47,10 +47,10 @@ describe('Host API: PushNotification', () => {
4747

4848
it('should deliver a notification without a deeplink', async () => {
4949
const { container, hostApi } = setup();
50-
const payload = { text: 'Notification body', deeplink: undefined };
50+
const payload = { text: 'Notification body', deeplink: undefined, scheduledAt: undefined };
5151

5252
container.handleDevicePermission((_, { ok }) => ok(true));
53-
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(undefined));
53+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(1));
5454
container.handlePushNotification(handler);
5555

5656
const result = await hostApi.pushNotification(enumValue('v1', payload));
@@ -59,15 +59,27 @@ describe('Host API: PushNotification', () => {
5959
expect(handler).toBeCalledWith(payload, { ok: expect.any(Function), err: expect.any(Function) });
6060
});
6161

62-
it('should propagate handler errors and still gate on Notifications permission', async () => {
62+
it('should deliver a scheduled notification carrying scheduledAt as a u64', async () => {
6363
const { container, hostApi } = setup();
64-
const payload = { text: 'will fail', deeplink: undefined };
65-
const error = new GenericError({ reason: 'Delivery failed' });
64+
const scheduledAt = BigInt(Date.UTC(2027, 0, 1));
65+
const payload = { text: 'reminder', deeplink: undefined, scheduledAt };
6666

67-
const devicePermissionHandler = vi.fn<ContainerHandlerOf<typeof container.handleDevicePermission>>(
68-
(_params, { ok }) => ok(true),
69-
);
70-
container.handleDevicePermission(devicePermissionHandler);
67+
container.handleDevicePermission((_, { ok }) => ok(true));
68+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(7));
69+
container.handlePushNotification(handler);
70+
71+
const result = await hostApi.pushNotification(enumValue('v1', payload));
72+
73+
expect(result.isOk()).toBe(true);
74+
expect(handler).toBeCalledWith(payload, { ok: expect.any(Function), err: expect.any(Function) });
75+
});
76+
77+
it('should propagate ScheduleLimitReached', async () => {
78+
const { container, hostApi } = setup();
79+
const payload = { text: 'full queue', deeplink: undefined, scheduledAt: BigInt(2_000_000_000_000) };
80+
const error = new PushNotificationError.ScheduleLimitReached();
81+
82+
container.handleDevicePermission((_, { ok }) => ok(true));
7183
container.handlePushNotification((_, { err }) => err(error));
7284

7385
const result = await hostApi.pushNotification(enumValue('v1', payload));
@@ -81,18 +93,35 @@ describe('Host API: PushNotification', () => {
8193
expect(failure.value).toEqual(error);
8294
},
8395
);
96+
});
8497

85-
expect(devicePermissionHandler).toHaveBeenCalledOnce();
86-
const [receivedPermissionParams] = devicePermissionHandler.mock.calls[0]!;
87-
expect(receivedPermissionParams).toBe('Notifications');
98+
it('should propagate Unknown { reason } round-trip', async () => {
99+
const { container, hostApi } = setup();
100+
const payload = { text: 'boom', deeplink: undefined, scheduledAt: undefined };
101+
const error = new PushNotificationError.Unknown({ reason: 'OS rejected' });
102+
103+
container.handleDevicePermission((_, { ok }) => ok(true));
104+
container.handlePushNotification((_, { err }) => err(error));
105+
106+
const result = await hostApi.pushNotification(enumValue('v1', payload));
107+
108+
result.match(
109+
() => {
110+
throw new Error('Expected failure');
111+
},
112+
failure => {
113+
expect(failure.tag).toBe('v1');
114+
expect(failure.value).toEqual(error);
115+
},
116+
);
88117
});
89118

90119
it('should reject and skip the handler when Notifications permission is denied', async () => {
91120
const { container, hostApi } = setup();
92-
const payload = { text: 'blocked', deeplink: undefined };
121+
const payload = { text: 'blocked', deeplink: undefined, scheduledAt: undefined };
93122

94123
container.handleDevicePermission((_, { ok }) => ok(false));
95-
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(undefined));
124+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(1));
96125
container.handlePushNotification(handler);
97126

98127
const result = await hostApi.pushNotification(enumValue('v1', payload));
@@ -104,18 +133,18 @@ describe('Host API: PushNotification', () => {
104133
},
105134
failure => {
106135
expect(failure.tag).toBe('v1');
107-
expect(failure.value).toBeInstanceOf(GenericError);
136+
expect(failure.value).toBeInstanceOf(PushNotificationError);
108137
},
109138
);
110139
expect(handler).not.toHaveBeenCalled();
111140
});
112141

113142
it('should reject and skip the handler when the device permission handler errors', async () => {
114143
const { container, hostApi } = setup();
115-
const payload = { text: 'blocked', deeplink: undefined };
144+
const payload = { text: 'blocked', deeplink: undefined, scheduledAt: undefined };
116145

117146
container.handleDevicePermission((_, { err }) => err(new GenericError({ reason: 'permission lookup failed' })));
118-
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(undefined));
147+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotification>>((_, { ok }) => ok(1));
119148
container.handlePushNotification(handler);
120149

121150
const result = await hostApi.pushNotification(enumValue('v1', payload));
@@ -124,3 +153,55 @@ describe('Host API: PushNotification', () => {
124153
expect(handler).not.toHaveBeenCalled();
125154
});
126155
});
156+
157+
describe('Host API: PushNotificationCancel', () => {
158+
it('should cancel a pending notification by id', async () => {
159+
const { container, hostApi } = setup();
160+
161+
container.handleDevicePermission((_, { ok }) => ok(true));
162+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationCancel>>((_, { ok }) =>
163+
ok(undefined),
164+
);
165+
container.handlePushNotificationCancel(handler);
166+
167+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 42));
168+
169+
expect(result.isOk()).toBe(true);
170+
expect(handler).toBeCalledWith(42, { ok: expect.any(Function), err: expect.any(Function) });
171+
});
172+
173+
it('should propagate GenericError when the host returns one', async () => {
174+
const { container, hostApi } = setup();
175+
const error = new GenericError({ reason: 'cancel failed' });
176+
177+
container.handleDevicePermission((_, { ok }) => ok(true));
178+
container.handlePushNotificationCancel((_, { err }) => err(error));
179+
180+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 1));
181+
182+
result.match(
183+
() => {
184+
throw new Error('Expected failure');
185+
},
186+
failure => {
187+
expect(failure.tag).toBe('v1');
188+
expect(failure.value).toEqual(error);
189+
},
190+
);
191+
});
192+
193+
it('should reject cancel when Notifications permission is denied', async () => {
194+
const { container, hostApi } = setup();
195+
196+
container.handleDevicePermission((_, { ok }) => ok(false));
197+
const handler = vi.fn<ContainerHandlerOf<typeof container.handlePushNotificationCancel>>((_, { ok }) =>
198+
ok(undefined),
199+
);
200+
container.handlePushNotificationCancel(handler);
201+
202+
const result = await hostApi.pushNotificationCancel(enumValue('v1', 5));
203+
204+
expect(result.isErr()).toBe(true);
205+
expect(handler).not.toHaveBeenCalled();
206+
});
207+
});

docs/design/host-api-protocol.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ created: 2026-03-13
99

1010
## Changelog
1111

12+
### v0.8 - 2026-05-14
13+
14+
- **Breaking change.** Extended `host_push_notification`: request gains `scheduled_at: Option<u64>` (Unix ms UTC) for deferred delivery; response changed from `Result<(), GenericErr>` to `Result<NotificationId, PushNotificationError>` (RFC-0019). The prior wire format is replaced rather than versioned alongside.
15+
- Added `host_push_notification_cancel(NotificationId) -> Result<(), GenericErr>` for retracting a pending scheduled notification.
16+
- Both methods are gated by `DevicePermission::Notifications`. No new permission variant.
17+
- `NotificationId` is opaque per-product; cancellation is idempotent. Persistence, OS-scheduler integration, and the platform-wide queue cap are host-application concerns, not SDK concerns.
18+
1219
### v0.7 - 2026-04-13
1320

1421
- Renamed all `*_with_non_product_account` methods to `*_with_legacy_account`; renamed `host_get_non_product_accounts` to `host_get_legacy_accounts`; updated glossary term "Non-product account (NPA)" to "Legacy account".
@@ -87,7 +94,11 @@ fn host_feature_supported(
8794
) -> Result<bool, GenericErr>;
8895

8996
fn host_push_notification(
90-
text: str
97+
notification: PushNotification
98+
) -> Result<NotificationId, PushNotificationError>;
99+
100+
fn host_push_notification_cancel(
101+
identifier: NotificationId
91102
) -> Result<(), GenericErr>;
92103

93104
fn host_navigate_to(
@@ -482,6 +493,41 @@ fn host_derive_entropy(
482493
) -> Result<Entropy, DeriveEntropyErr>;
483494
```
484495

496+
#### Push Notifications
497+
498+
Products can request the host to display a notification, either immediately or scheduled for a future wall-clock instant. Both methods are gated by `DevicePermission::Notifications`. See [RFC-0019](https://github.com/paritytech/truapi/blob/main/docs/rfcs/0019-scheduled-notifications.md) for the full motivation and host-side behavioural requirements (persistence, OS-scheduler integration, platform-wide queue cap).
499+
500+
```rust
501+
type NotificationId = u32;
502+
503+
struct PushNotification {
504+
// Notification body text.
505+
text: String,
506+
// Optional URL to open when the user taps the notification.
507+
deeplink: Option<String>,
508+
// Optional Unix timestamp in milliseconds (UTC) at which the notification should fire.
509+
// `None` fires immediately, preserving prior behaviour. Past timestamps fire immediately.
510+
scheduled_at: Option<u64>,
511+
}
512+
513+
enum PushNotificationError {
514+
// The host has reached its platform-wide cap on pending scheduled notifications.
515+
// Only returned for scheduled (non-immediate) calls.
516+
ScheduleLimitReached,
517+
Unknown(GenericErr)
518+
}
519+
520+
fn host_push_notification(
521+
notification: PushNotification
522+
) -> Result<NotificationId, PushNotificationError>;
523+
524+
fn host_push_notification_cancel(
525+
identifier: NotificationId
526+
) -> Result<(), GenericErr>;
527+
```
528+
529+
`NotificationId` is opaque and unique per product; the host MUST NOT leak ids across products. An id is returned for every call — immediate or scheduled — for shape uniformity. Cancellation is idempotent: `host_push_notification_cancel` MUST return `Ok(())` regardless of whether the id refers to a pending, already-fired, never-issued, or other-product's notification. This is a breaking change relative to the pre-v0.8 wire format; products and hosts must upgrade together.
530+
485531
#### Device permissions request
486532

487533
Products can request additional device permissions. This check is layered on top of platform permissions (web, iOS, Android) and adds a product-level security gate.

0 commit comments

Comments
 (0)