bmsdna-app-native 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ from .actions import NotificationActionReceived, create_notification_action_router
2
+ from .apns import (
3
+ MAX_INLINE_WIDGETS_BYTES,
4
+ PERMANENTLY_INVALID_TOKEN_REASONS,
5
+ InterruptionLevel,
6
+ build_alert_message,
7
+ build_approvals_update_message,
8
+ build_critical_alert_message,
9
+ build_widget_update_message,
10
+ live_activity_topic,
11
+ send_approvals_update,
12
+ send_critical_alert,
13
+ send_push,
14
+ send_silent_push,
15
+ send_widget_update,
16
+ )
17
+ from .approvals import (
18
+ Approval,
19
+ ApprovalDecisionReceived,
20
+ ApprovalDecisionResult,
21
+ ApprovalDetail,
22
+ ApprovalFeed,
23
+ create_approvals_router,
24
+ )
25
+ from .auth import insecure_no_auth
26
+ from .documents import (
27
+ DEFAULT_MAX_DOCUMENT_BYTES,
28
+ DocumentMetadata,
29
+ ReceivedDocument,
30
+ create_document_router,
31
+ )
32
+ from .entra import (
33
+ EntraKeysUnavailableError,
34
+ EntraTokenError,
35
+ EntraTokenVerifier,
36
+ EntraUser,
37
+ SessionCookie,
38
+ SignedSessionCookie,
39
+ bearer_token,
40
+ copy_session_cookie,
41
+ create_entra_auth_dependency,
42
+ )
43
+ from .live_activities import (
44
+ ATTRIBUTES_TYPE,
45
+ LiveActivityAttributes,
46
+ LiveActivityState,
47
+ LiveActivityTokenReceived,
48
+ build_live_activity_message,
49
+ build_live_activity_start_message,
50
+ create_live_activity_router,
51
+ send_live_activity_end,
52
+ send_live_activity_start,
53
+ send_live_activity_update,
54
+ )
55
+ from .models import DeviceRegistration, LocationUpdate
56
+ from .router import create_ingest_router
57
+ from .widgets import (
58
+ WIDGET_SCHEMA_VERSION,
59
+ ActionButton,
60
+ ActionCondition,
61
+ ActionData,
62
+ ActionField,
63
+ ActionOption,
64
+ ActionPage,
65
+ ActionWidget,
66
+ ChartData,
67
+ ChartPoint,
68
+ ChartSeries,
69
+ ChartWidget,
70
+ KpiData,
71
+ KpiWidget,
72
+ ListData,
73
+ ListItem,
74
+ ListWidget,
75
+ NewsData,
76
+ NewsItem,
77
+ NewsWidget,
78
+ ProgressData,
79
+ ProgressWidget,
80
+ StatItem,
81
+ StatsData,
82
+ StatsWidget,
83
+ TextData,
84
+ TextWidget,
85
+ Trend,
86
+ Widget,
87
+ WidgetActionReceived,
88
+ WidgetActionResult,
89
+ WidgetFeed,
90
+ WidgetOptionsRequest,
91
+ WidgetOptionsResult,
92
+ WidgetRequestContext,
93
+ create_widget_action_router,
94
+ create_widget_feed_router,
95
+ create_widget_options_router,
96
+ dump_widgets,
97
+ )
98
+
99
+ __all__ = [
100
+ "DEFAULT_MAX_DOCUMENT_BYTES",
101
+ "ATTRIBUTES_TYPE",
102
+ "MAX_INLINE_WIDGETS_BYTES",
103
+ "PERMANENTLY_INVALID_TOKEN_REASONS",
104
+ "WIDGET_SCHEMA_VERSION",
105
+ "ActionButton",
106
+ "ActionCondition",
107
+ "ActionData",
108
+ "ActionField",
109
+ "ActionOption",
110
+ "ActionPage",
111
+ "ActionWidget",
112
+ "Approval",
113
+ "ApprovalDecisionReceived",
114
+ "ApprovalDecisionResult",
115
+ "ApprovalDetail",
116
+ "ApprovalFeed",
117
+ "ChartData",
118
+ "ChartPoint",
119
+ "ChartSeries",
120
+ "ChartWidget",
121
+ "DeviceRegistration",
122
+ "DocumentMetadata",
123
+ "EntraKeysUnavailableError",
124
+ "EntraTokenError",
125
+ "EntraTokenVerifier",
126
+ "EntraUser",
127
+ "InterruptionLevel",
128
+ "KpiData",
129
+ "KpiWidget",
130
+ "ListData",
131
+ "ListItem",
132
+ "ListWidget",
133
+ "LiveActivityAttributes",
134
+ "LiveActivityState",
135
+ "LiveActivityTokenReceived",
136
+ "LocationUpdate",
137
+ "NewsData",
138
+ "NewsItem",
139
+ "NewsWidget",
140
+ "NotificationActionReceived",
141
+ "ProgressData",
142
+ "ProgressWidget",
143
+ "ReceivedDocument",
144
+ "SessionCookie",
145
+ "SignedSessionCookie",
146
+ "StatItem",
147
+ "StatsData",
148
+ "StatsWidget",
149
+ "TextData",
150
+ "TextWidget",
151
+ "Trend",
152
+ "Widget",
153
+ "WidgetActionReceived",
154
+ "WidgetActionResult",
155
+ "WidgetFeed",
156
+ "WidgetOptionsRequest",
157
+ "WidgetOptionsResult",
158
+ "WidgetRequestContext",
159
+ "build_alert_message",
160
+ "build_approvals_update_message",
161
+ "build_critical_alert_message",
162
+ "build_live_activity_message",
163
+ "build_live_activity_start_message",
164
+ "bearer_token",
165
+ "build_widget_update_message",
166
+ "copy_session_cookie",
167
+ "create_approvals_router",
168
+ "create_document_router",
169
+ "create_entra_auth_dependency",
170
+ "create_ingest_router",
171
+ "create_live_activity_router",
172
+ "create_notification_action_router",
173
+ "create_widget_action_router",
174
+ "create_widget_feed_router",
175
+ "create_widget_options_router",
176
+ "dump_widgets",
177
+ "insecure_no_auth",
178
+ "live_activity_topic",
179
+ "send_approvals_update",
180
+ "send_critical_alert",
181
+ "send_live_activity_end",
182
+ "send_live_activity_start",
183
+ "send_live_activity_update",
184
+ "send_push",
185
+ "send_silent_push",
186
+ "send_widget_update",
187
+ ]
@@ -0,0 +1,56 @@
1
+ """Router for interactive push actions (buttons in the notifications tab of
2
+ the iOS app, e.g. "Accept"/"Decline" — see
3
+ `send_push(data={"actions": [...]})` in `apns.py`).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Awaitable, Callable
9
+ from typing import Any
10
+
11
+ from fastapi import APIRouter, Depends, Request
12
+ from pydantic import BaseModel
13
+
14
+ from .widgets import WidgetRequestContext
15
+
16
+
17
+ class NotificationActionReceived(BaseModel):
18
+ device_id: str
19
+ # The action the user tapped — the `id` from the `"actions"` entry that
20
+ # `send_push(data={"actions": [...]})` sent along, e.g.
21
+ # "confirm"/"reject".
22
+ action: str
23
+ # The custom data from the original push (e.g. an order ID), so you know
24
+ # what the action refers to.
25
+ data: dict[str, Any] | None = None
26
+
27
+
28
+ NotificationActionCallback = Callable[
29
+ [NotificationActionReceived, WidgetRequestContext], Awaitable[None]
30
+ ]
31
+
32
+
33
+ def create_notification_action_router(
34
+ *,
35
+ on_action_received: NotificationActionCallback,
36
+ auth_dependency: Callable[..., Any],
37
+ ) -> APIRouter:
38
+ """Builds an APIRouter for `POST <prefix>/notification-actions`.
39
+
40
+ Same as for devices/locations/documents: no storage of its own, the
41
+ action goes unchanged to your callback with the request context — e.g. to
42
+ mark an order as accepted. Check that `context.user` may act on what the
43
+ action refers to: `data` comes from the device.
44
+ """
45
+ router = APIRouter()
46
+
47
+ @router.post("/notification-actions")
48
+ async def receive_action(
49
+ body: NotificationActionReceived, request: Request, user: Any = Depends(auth_dependency)
50
+ ) -> dict[str, bool]:
51
+ await on_action_received(
52
+ body, WidgetRequestContext(device_id=body.device_id, user=user, request=request)
53
+ )
54
+ return {"ok": True}
55
+
56
+ return router
@@ -0,0 +1,363 @@
1
+ """APNs delivery via aioapns (HTTP/2 + JWT auth key, async-native).
2
+
3
+ A provider is created lazily and reused for the lifetime of the process.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from collections.abc import Sequence
11
+ from typing import TYPE_CHECKING, Any, Literal
12
+
13
+ from aioapns import APNs, NotificationRequest
14
+ from aioapns.common import PushType
15
+
16
+ if TYPE_CHECKING:
17
+ from .widgets import Widget
18
+
19
+ _client: APNs | None = None
20
+
21
+ #: Error descriptions (`aioapns`' `NotificationResult.description`, Apple's
22
+ #: `reason` field) that make a device token *permanently* invalid — unlike
23
+ #: transient errors (e.g. rate limiting), a retry is pointless here.
24
+ #: `"BadDeviceToken"` usually means a wrong format or a sandbox/production
25
+ #: mix-up, `"Unregistered"` practically always means the app was
26
+ #: uninstalled. Check `detail in PERMANENTLY_INVALID_TOKEN_REASONS` after a
27
+ #: failed `send_push`/`send_silent_push` and delete the device (or at least
28
+ #: its `push_token`) from your own storage — otherwise your device list keeps
29
+ #: growing without bound with dead entries that will never be delivered
30
+ #: successfully again.
31
+ PERMANENTLY_INVALID_TOKEN_REASONS = frozenset({"BadDeviceToken", "Unregistered"})
32
+
33
+
34
+ def _load_key() -> str:
35
+ """Contents of the APNs auth key (.p8) from `APNS_KEY`.
36
+
37
+ The PEM text is stored directly in the variable (in production via a
38
+ Key Vault reference in the App Service settings). Line breaks may be
39
+ written as `\\n`, because many environments don't allow multi-line
40
+ values.
41
+ """
42
+ key = os.environ.get("APNS_KEY", "").replace("\\n", "\n").strip()
43
+ if not key:
44
+ raise RuntimeError("APNs is not configured. APNS_KEY is missing (see README).")
45
+ if not key.startswith("-----BEGIN PRIVATE KEY-----"):
46
+ raise RuntimeError(
47
+ "APNS_KEY is not PEM text (expected: -----BEGIN PRIVATE KEY-----, "
48
+ "i.e. the contents of the .p8 file, not its path)."
49
+ )
50
+ return key
51
+
52
+
53
+ def _get_client() -> APNs:
54
+ global _client
55
+ if _client is not None:
56
+ return _client
57
+
58
+ key_id = os.environ.get("APNS_KEY_ID")
59
+ team_id = os.environ.get("APNS_TEAM_ID")
60
+ bundle_id = os.environ.get("APNS_BUNDLE_ID")
61
+
62
+ if not all([key_id, team_id, bundle_id]):
63
+ raise RuntimeError(
64
+ "APNs is not configured. APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID "
65
+ "and APNS_BUNDLE_ID must be set as environment variables "
66
+ "(see README)."
67
+ )
68
+
69
+ _client = APNs(
70
+ key=_load_key(),
71
+ key_id=key_id,
72
+ team_id=team_id,
73
+ topic=bundle_id,
74
+ use_sandbox=os.environ.get("APNS_USE_SANDBOX", "true").lower() == "true",
75
+ )
76
+ return _client
77
+
78
+
79
+ def live_activity_topic() -> str:
80
+ """APNs topic for Live Activity pushes: bundle ID plus
81
+ `.push-type.liveactivity` (see `live_activities.py`)."""
82
+ bundle_id = os.environ.get("APNS_BUNDLE_ID")
83
+ if not bundle_id:
84
+ raise RuntimeError("APNS_BUNDLE_ID is not set (see README).")
85
+ return f"{bundle_id}.push-type.liveactivity"
86
+
87
+
88
+ #: How strongly a push interrupts the user (Apple's `interruption-level`):
89
+ #:
90
+ #: - `"passive"`: silent, only lands in Notification Center.
91
+ #: - `"active"`: normal (default).
92
+ #: - `"time-sensitive"`: also breaks through a Focus mode or a scheduled
93
+ #: summary. For urgent things that can't wait.
94
+ #: - `"critical"`: critical alert, rings even with the mute switch on and
95
+ #: "Do Not Disturb". Only for real emergencies (evacuation, safety),
96
+ #: preferably via `send_critical_alert`. Only takes effect once Apple has
97
+ #: granted the app the entitlement; until then the push arrives as a
98
+ #: normal one.
99
+ InterruptionLevel = Literal["passive", "active", "time-sensitive", "critical"]
100
+
101
+
102
+ def build_alert_message(
103
+ *,
104
+ title: str,
105
+ body: str,
106
+ data: dict[str, Any] | None = None,
107
+ interruption_level: InterruptionLevel | None = None,
108
+ critical_volume: float = 1.0,
109
+ ) -> dict[str, Any]:
110
+ """Pure payload construction, extracted separately from `send_push` so
111
+ it can be tested without APNs credentials."""
112
+ aps: dict[str, Any] = {"alert": {"title": title, "body": body}, "sound": "default"}
113
+ if interruption_level is not None:
114
+ aps["interruption-level"] = interruption_level
115
+ if interruption_level == "critical":
116
+ # Critical sound: rings even with the mute switch on, at this
117
+ # volume (0.0 to 1.0).
118
+ aps["sound"] = {
119
+ "critical": 1,
120
+ "name": "default",
121
+ "volume": min(max(critical_volume, 0.0), 1.0),
122
+ }
123
+
124
+ message: dict[str, Any] = {"aps": aps}
125
+ if data:
126
+ message.update(data)
127
+ return message
128
+
129
+
130
+ async def send_push(
131
+ *,
132
+ device_token: str,
133
+ title: str,
134
+ body: str,
135
+ data: dict[str, Any] | None = None,
136
+ interruption_level: InterruptionLevel | None = None,
137
+ ) -> tuple[bool, str | None]:
138
+ """The actual "one-call" push: call it directly from your own code as
139
+ soon as you have an APNs device token (from your own storage — see
140
+ `create_ingest_router`).
141
+
142
+ Returns (successful, error description) instead of raising — if you call
143
+ this for several devices in a loop, a single failure shouldn't stop the
144
+ others.
145
+
146
+ For a deep link when the notification is tapped: pass
147
+ `data={"path": "/orders/123"}` — the iOS app reads `path` from the
148
+ notification user info and navigates there in the WebView (relative to
149
+ `webAppURL`).
150
+
151
+ For confirm/reject buttons that the user can tap in the notifications
152
+ tab of the iOS app (without the app having to know about them
153
+ beforehand): pass
154
+ `data={"actions": [{"id": "confirm", "title": "Confirm"},
155
+ {"id": "reject", "title": "Decline"}]}`.
156
+ When the user taps one, its `id` arrives as `action` in
157
+ `create_notification_action_router()`, together with the remaining
158
+ `data` fields of this push (e.g. an order ID). There are **no** buttons
159
+ directly on the system banner/Lock Screen — for that, iOS would require
160
+ the app to know and register this category beforehand, which would need
161
+ an App Store release for every new action.
162
+
163
+ For urgent things that should also break through a Focus mode:
164
+ `interruption_level="time-sensitive"` (see `InterruptionLevel`). For
165
+ real emergencies there is `send_critical_alert`.
166
+
167
+ On `sent=False`: check whether `detail in PERMANENTLY_INVALID_TOKEN_REASONS`
168
+ — if so, the token is permanently dead (usually an uninstall) and should
169
+ be removed from your storage instead of being retried next time.
170
+ """
171
+ client = _get_client()
172
+ message = build_alert_message(
173
+ title=title, body=body, data=data, interruption_level=interruption_level
174
+ )
175
+
176
+ request = NotificationRequest(device_token=device_token, message=message)
177
+ response = await client.send_notification(request)
178
+
179
+ return response.is_successful, getattr(response, "description", None)
180
+
181
+
182
+ async def send_silent_push(
183
+ *, device_token: str, data: dict[str, Any] | None = None
184
+ ) -> tuple[bool, str | None]:
185
+ """Silent push (`content-available`, no banner/sound) — briefly wakes the
186
+ app in the background, e.g. to reload the WebView before the user opens
187
+ the app.
188
+
189
+ Two things throttle this automatically, so you don't accidentally drain
190
+ the battery:
191
+ - **iOS itself** only delivers silent pushes "best effort" (no real-time
192
+ guarantee, the system may delay/drop delivery, depending among other
193
+ things on usage patterns).
194
+ - The **iOS app additionally throttles on the client side**
195
+ (default 15 minutes) — if
196
+ several silent pushes arrive in quicker succession, the
197
+ surplus ones are ignored instead of reloading the WebView every time.
198
+
199
+ If your backend itself sends very frequently (e.g. on every record
200
+ update), you should still throttle on the server side yourself (e.g. "at
201
+ most every X minutes per device") — the toolkit deliberately keeps no
202
+ state for this, that would be your own storage.
203
+
204
+ On `sent=False`: see `PERMANENTLY_INVALID_TOKEN_REASONS` — for a
205
+ permanently invalid reason, a retry is pointless.
206
+ """
207
+ client = _get_client()
208
+
209
+ message: dict[str, Any] = {"aps": {"content-available": 1}}
210
+ if data:
211
+ message.update(data)
212
+
213
+ request = NotificationRequest(
214
+ device_token=device_token,
215
+ message=message,
216
+ push_type=PushType.BACKGROUND,
217
+ priority=5,
218
+ )
219
+ response = await client.send_notification(request)
220
+
221
+ return response.is_successful, getattr(response, "description", None)
222
+
223
+
224
+ #: Upper limit for widgets sent directly in the push. APNs allows 4 KB per
225
+ #: payload, the rest is headroom for `aps` and `webapp_id`. Larger updates
226
+ #: are automatically sent by `send_widget_update` as a plain
227
+ #: `widgets_refresh`, and the app then fetches the feed itself.
228
+ MAX_INLINE_WIDGETS_BYTES = 3500
229
+
230
+
231
+ def build_widget_update_message(
232
+ *, webapp_id: str, widgets: Sequence[Widget] | None = None
233
+ ) -> dict[str, Any]:
234
+ """Pure payload construction for `send_widget_update`, testable without
235
+ APNs credentials."""
236
+ from .widgets import dump_widgets
237
+
238
+ message: dict[str, Any] = {"aps": {"content-available": 1}, "webapp_id": webapp_id}
239
+ if widgets is not None:
240
+ inline = {**message, "widgets": dump_widgets(widgets)}
241
+ size = len(json.dumps(inline, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
242
+ if size <= MAX_INLINE_WIDGETS_BYTES:
243
+ return inline
244
+ message["widgets_refresh"] = True
245
+ return message
246
+
247
+
248
+ async def send_widget_update(
249
+ *, device_token: str, webapp_id: str, widgets: Sequence[Widget] | None = None
250
+ ) -> tuple[bool, str | None]:
251
+ """Updates your web app's dashboard widgets on a device via silent push,
252
+ without a banner and without your web app having to be open.
253
+
254
+ - **With `widgets`**: The widgets are sent directly in the push and
255
+ replace ALL widgets of your web app on the device. This is the same as
256
+ your feed, so always send the complete list, not just the changed
257
+ widget. If the list doesn't fit into a push
258
+ (`MAX_INLINE_WIDGETS_BYTES`), only a feed fetch is triggered
259
+ automatically.
260
+ - **Without `widgets`**: The app reloads your feed
261
+ (`create_widget_feed_router`) immediately. This requires the feed path
262
+ to be configured in the app (`widgetFeedPath`).
263
+
264
+ `webapp_id` is the `id` of your web app in the app. The app only
265
+ accepts widgets from web apps that have `.dashboardWidgets`
266
+ enabled.
267
+
268
+ iOS only delivers silent pushes "best effort". For guaranteed up-to-date
269
+ data the feed remains the source; the app fetches it anyway every time
270
+ it returns to the foreground. Return value and error handling as for
271
+ `send_silent_push`.
272
+ """
273
+ message = build_widget_update_message(webapp_id=webapp_id, widgets=widgets)
274
+ data = {key: value for key, value in message.items() if key != "aps"}
275
+ return await send_silent_push(device_token=device_token, data=data)
276
+
277
+
278
+ def build_critical_alert_message(
279
+ *,
280
+ webapp_id: str,
281
+ title: str,
282
+ body: str,
283
+ acknowledge_label: str | None = "Got it",
284
+ volume: float = 1.0,
285
+ data: dict[str, Any] | None = None,
286
+ ) -> dict[str, Any]:
287
+ """Pure payload construction for `send_critical_alert`, testable without
288
+ APNs credentials."""
289
+ extra: dict[str, Any] = {**(data or {}), "webapp_id": webapp_id}
290
+ if acknowledge_label:
291
+ extra["ack_label"] = acknowledge_label
292
+ return build_alert_message(
293
+ title=title,
294
+ body=body,
295
+ data=extra,
296
+ interruption_level="critical",
297
+ critical_volume=volume,
298
+ )
299
+
300
+
301
+ async def send_critical_alert(
302
+ *,
303
+ device_token: str,
304
+ webapp_id: str,
305
+ title: str,
306
+ body: str,
307
+ acknowledge_label: str | None = "Got it",
308
+ volume: float = 1.0,
309
+ data: dict[str, Any] | None = None,
310
+ ) -> tuple[bool, str | None]:
311
+ """Critical alert for real emergencies (evacuation, severe weather,
312
+ security incident): rings even with the mute switch on and "Do Not
313
+ Disturb", and additionally appears full-screen in the app.
314
+
315
+ With `acknowledge_label` (e.g. "I am safe") the app shows a confirm
316
+ button. When the user taps it, this arrives as
317
+ `action="acknowledge"` in `create_notification_action_router()`,
318
+ together with your `data` fields (e.g. pass an `alert_id`). This way
319
+ you see who has seen the alert. `None` hides the button.
320
+
321
+ The app side needs Apple's entitlement for critical alerts (see
322
+ README). Until then the push arrives as a normal notification; the
323
+ acknowledgement in the app still works. Return value and error
324
+ handling as for `send_push`.
325
+ """
326
+ client = _get_client()
327
+ message = build_critical_alert_message(
328
+ webapp_id=webapp_id,
329
+ title=title,
330
+ body=body,
331
+ acknowledge_label=acknowledge_label,
332
+ volume=volume,
333
+ data=data,
334
+ )
335
+ request = NotificationRequest(device_token=device_token, message=message)
336
+ response = await client.send_notification(request)
337
+ return response.is_successful, getattr(response, "description", None)
338
+
339
+
340
+ def build_approvals_update_message(*, webapp_id: str) -> dict[str, Any]:
341
+ """Pure payload construction for `send_approvals_update`, testable
342
+ without APNs credentials."""
343
+ return {
344
+ "aps": {"content-available": 1},
345
+ "webapp_id": webapp_id,
346
+ "approvals_refresh": True,
347
+ }
348
+
349
+
350
+ async def send_approvals_update(*, device_token: str, webapp_id: str) -> tuple[bool, str | None]:
351
+ """Makes the app reload your web app's pending approvals immediately
352
+ (`create_approvals_router`), via silent push without a banner. E.g. when
353
+ a new request comes in or someone else has handled one.
354
+
355
+ For a visible notification ("New vacation request from Anna") you
356
+ additionally send a normal `send_push`. iOS only delivers silent pushes
357
+ "best effort"; the app reloads the feed anyway every time it returns to
358
+ the foreground. Return value and error handling as for
359
+ `send_silent_push`.
360
+ """
361
+ message = build_approvals_update_message(webapp_id=webapp_id)
362
+ data = {key: value for key, value in message.items() if key != "aps"}
363
+ return await send_silent_push(device_token=device_token, data=data)