pythonnative 0.24.0__py3-none-any.whl → 0.26.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.
Files changed (52) hide show
  1. pythonnative/__init__.py +16 -4
  2. pythonnative/animated.py +2 -2
  3. pythonnative/cli/pn.py +172 -12
  4. pythonnative/components.py +59 -0
  5. pythonnative/diagnostics.py +27 -0
  6. pythonnative/hooks.py +364 -81
  7. pythonnative/native_modules/app_state.py +2 -1
  8. pythonnative/native_modules/battery.py +3 -2
  9. pythonnative/native_modules/biometrics.py +2 -1
  10. pythonnative/native_modules/camera.py +16 -7
  11. pythonnative/native_modules/clipboard.py +3 -2
  12. pythonnative/native_modules/file_system.py +3 -2
  13. pythonnative/native_modules/haptics.py +7 -6
  14. pythonnative/native_modules/linking.py +62 -6
  15. pythonnative/native_modules/location.py +11 -8
  16. pythonnative/native_modules/net_info.py +2 -1
  17. pythonnative/native_modules/notifications.py +132 -9
  18. pythonnative/native_modules/permissions.py +27 -3
  19. pythonnative/native_modules/share.py +2 -1
  20. pythonnative/native_views/android.py +149 -124
  21. pythonnative/native_views/desktop.py +50 -49
  22. pythonnative/native_views/ios.py +240 -161
  23. pythonnative/project/android.py +15 -3
  24. pythonnative/project/builder.py +172 -70
  25. pythonnative/project/config.py +28 -6
  26. pythonnative/project/devices.py +291 -0
  27. pythonnative/project/doctor.py +5 -4
  28. pythonnative/project/ios.py +51 -77
  29. pythonnative/project/permissions.py +19 -1
  30. pythonnative/project/runtime_assets.py +78 -166
  31. pythonnative/reconciler.py +518 -37
  32. pythonnative/runtime.py +407 -215
  33. pythonnative/screen.py +53 -7
  34. pythonnative/storage.py +2 -2
  35. pythonnative/suspense.py +417 -0
  36. pythonnative/templates/android_template/app/build.gradle +5 -0
  37. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +1 -0
  38. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +88 -12
  39. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
  40. pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
  41. pythonnative/templates/ios_template/ios_template/Info.plist +0 -2
  42. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
  43. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
  44. pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
  45. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
  46. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/METADATA +2 -1
  47. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/RECORD +51 -48
  48. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/WHEEL +1 -1
  49. pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
  50. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/entry_points.txt +0 -0
  51. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/licenses/LICENSE +0 -0
  52. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/top_level.txt +0 -0
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Callable, Dict, List
12
12
 
13
+ from .. import diagnostics
13
14
  from ..utils import IS_ANDROID, IS_IOS
14
15
 
15
16
  BatteryState = str # "unknown" | "unplugged" | "charging" | "full"
@@ -62,7 +63,7 @@ def dispatch_battery(level: float, state: BatteryState) -> None:
62
63
  try:
63
64
  listener(dict(payload))
64
65
  except Exception:
65
- pass
66
+ diagnostics.swallowed("battery.dispatch_battery")
66
67
 
67
68
 
68
69
  # ======================================================================
@@ -77,7 +78,7 @@ def _ios_device() -> object:
77
78
  try:
78
79
  device.setBatteryMonitoringEnabled_(True)
79
80
  except Exception:
80
- pass
81
+ diagnostics.swallowed("battery._ios_device")
81
82
  return device
82
83
 
83
84
 
@@ -23,6 +23,7 @@ from __future__ import annotations
23
23
  import asyncio
24
24
  from typing import Any, Callable, Dict
25
25
 
26
+ from .. import diagnostics
26
27
  from ..runtime import resolve_future
27
28
  from ..utils import IS_ANDROID, IS_IOS
28
29
 
@@ -93,7 +94,7 @@ def _ios_authenticate(reason: str, on_done: Callable[[bool], None]) -> None:
93
94
  try:
94
95
  on_done(bool(success))
95
96
  except Exception:
96
- pass
97
+ diagnostics.swallowed("biometrics._ios_authenticate._reply")
97
98
 
98
99
  context.evaluatePolicy_localizedReason_reply_(1, reason, Block(_reply, None, bool, ObjCClass("NSError")))
99
100
  except Exception:
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
  import asyncio
26
26
  from typing import Any, Callable, Dict, Optional
27
27
 
28
+ from .. import diagnostics
28
29
  from ..runtime import resolve_future
29
30
  from ..utils import IS_ANDROID, IS_IOS
30
31
 
@@ -109,7 +110,9 @@ def _ios_launch_picker(on_result: Callable[[Optional[str]], None], source: str)
109
110
  try:
110
111
  picker.dismissViewControllerAnimated_completion_(True, None)
111
112
  except Exception:
112
- pass
113
+ diagnostics.swallowed(
114
+ "camera._ios_launch_picker._PNImagePickerDelegate.imagePickerController_didFinishPickingMediaWithInfo_"
115
+ )
113
116
  cb = self._callback
114
117
  self._callback = None
115
118
  _pending_delegates.pop(id(self), None)
@@ -117,14 +120,18 @@ def _ios_launch_picker(on_result: Callable[[Optional[str]], None], source: str)
117
120
  try:
118
121
  cb(path)
119
122
  except Exception:
120
- pass
123
+ diagnostics.swallowed(
124
+ "camera._ios_launch_picker._PNImagePickerDelegate.imagePickerController_didFinishPickingMediaWithInfo_"
125
+ )
121
126
 
122
127
  @objc_method
123
128
  def imagePickerControllerDidCancel_(self, picker: Any) -> None:
124
129
  try:
125
130
  picker.dismissViewControllerAnimated_completion_(True, None)
126
131
  except Exception:
127
- pass
132
+ diagnostics.swallowed(
133
+ "camera._ios_launch_picker._PNImagePickerDelegate.imagePickerControllerDidCancel_"
134
+ )
128
135
  cb = self._callback
129
136
  self._callback = None
130
137
  _pending_delegates.pop(id(self), None)
@@ -132,7 +139,9 @@ def _ios_launch_picker(on_result: Callable[[Optional[str]], None], source: str)
132
139
  try:
133
140
  cb(None)
134
141
  except Exception:
135
- pass
142
+ diagnostics.swallowed(
143
+ "camera._ios_launch_picker._PNImagePickerDelegate.imagePickerControllerDidCancel_"
144
+ )
136
145
 
137
146
  delegate = _PNImagePickerDelegate.new()
138
147
  delegate._callback = on_result
@@ -170,7 +179,7 @@ def _ios_extract_path(info: Any) -> Optional[str]:
170
179
  try:
171
180
  return str(url.path)
172
181
  except Exception:
173
- pass
182
+ diagnostics.swallowed("camera._ios_extract_path")
174
183
  image = info.objectForKey_("UIImagePickerControllerOriginalImage")
175
184
  if image is not None:
176
185
  return _ios_write_image_to_tmp(image)
@@ -293,13 +302,13 @@ def deliver_android_activity_result(request_code: int, result_code: int, data: A
293
302
  if thumb is not None:
294
303
  path = _android_write_bitmap_to_cache(thumb)
295
304
  except Exception:
296
- pass
305
+ diagnostics.swallowed("camera.deliver_android_activity_result")
297
306
  except Exception:
298
307
  path = None
299
308
  try:
300
309
  cb(path)
301
310
  except Exception:
302
- pass
311
+ diagnostics.swallowed("camera.deliver_android_activity_result")
303
312
  return True
304
313
 
305
314
 
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  from typing import Optional
24
24
 
25
+ from .. import diagnostics
25
26
  from ..utils import IS_ANDROID, IS_IOS
26
27
 
27
28
  # Desktop fallback buffer so the API is usable off-device.
@@ -79,7 +80,7 @@ def _ios_set(text: str) -> None:
79
80
  try:
80
81
  pb.string = text
81
82
  except Exception:
82
- pass
83
+ diagnostics.swallowed("clipboard._ios_set")
83
84
 
84
85
 
85
86
  def _ios_get() -> str:
@@ -122,7 +123,7 @@ def _android_set(text: str) -> None:
122
123
  clip = ClipData.newPlainText("pythonnative", text)
123
124
  manager.setPrimaryClip(clip)
124
125
  except Exception:
125
- pass
126
+ diagnostics.swallowed("clipboard._android_set")
126
127
 
127
128
 
128
129
  def _android_get() -> str:
@@ -17,6 +17,7 @@ Example:
17
17
  import os
18
18
  from typing import Any, Optional
19
19
 
20
+ from .. import diagnostics
20
21
  from ..utils import IS_ANDROID
21
22
 
22
23
 
@@ -49,7 +50,7 @@ class FileSystem:
49
50
 
50
51
  return str(get_android_context().getFilesDir().getAbsolutePath())
51
52
  except Exception:
52
- pass
53
+ diagnostics.swallowed("file_system.FileSystem.app_dir")
53
54
  else:
54
55
  try:
55
56
  from rubicon.objc import ObjCClass
@@ -61,7 +62,7 @@ class FileSystem:
61
62
  if docs and docs.count > 0:
62
63
  return str(docs.objectAtIndex_(0).path)
63
64
  except Exception:
64
- pass
65
+ diagnostics.swallowed("file_system.FileSystem.app_dir")
65
66
  return os.path.join(os.path.expanduser("~"), ".pythonnative_data")
66
67
 
67
68
  @staticmethod
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
 
20
20
  from typing import Any, Optional
21
21
 
22
+ from .. import diagnostics
22
23
  from ..utils import IS_ANDROID, IS_IOS
23
24
 
24
25
  ImpactStyle = str # "light" | "medium" | "heavy" | "soft" | "rigid"
@@ -86,7 +87,7 @@ class Vibration:
86
87
  try:
87
88
  vibrator.cancel()
88
89
  except Exception:
89
- pass
90
+ diagnostics.swallowed("haptics.Vibration.cancel")
90
91
 
91
92
 
92
93
  # ======================================================================
@@ -102,7 +103,7 @@ def _ios_impact(style: str) -> None:
102
103
  generator.prepare()
103
104
  generator.impactOccurred()
104
105
  except Exception:
105
- pass
106
+ diagnostics.swallowed("haptics._ios_impact")
106
107
 
107
108
 
108
109
  def _ios_notification(type_: str) -> None:
@@ -113,7 +114,7 @@ def _ios_notification(type_: str) -> None:
113
114
  generator.prepare()
114
115
  generator.notificationOccurred_(_IOS_NOTIFICATION_TYPE.get(type_, 0))
115
116
  except Exception:
116
- pass
117
+ diagnostics.swallowed("haptics._ios_notification")
117
118
 
118
119
 
119
120
  def _ios_selection() -> None:
@@ -124,7 +125,7 @@ def _ios_selection() -> None:
124
125
  generator.prepare()
125
126
  generator.selectionChanged()
126
127
  except Exception:
127
- pass
128
+ diagnostics.swallowed("haptics._ios_selection")
128
129
 
129
130
 
130
131
  def _ios_legacy_vibrate() -> None:
@@ -134,7 +135,7 @@ def _ios_legacy_vibrate() -> None:
134
135
  audio = CDLL(util.find_library("AudioToolbox"))
135
136
  audio.AudioServicesPlaySystemSound(4095) # kSystemSoundID_Vibrate
136
137
  except Exception:
137
- pass
138
+ diagnostics.swallowed("haptics._ios_legacy_vibrate")
138
139
 
139
140
 
140
141
  # ======================================================================
@@ -177,4 +178,4 @@ def _android_buzz(duration_ms: int) -> None:
177
178
  else:
178
179
  vibrator.vibrate(int(duration_ms))
179
180
  except Exception:
180
- pass
181
+ diagnostics.swallowed("haptics._android_buzz")
@@ -4,27 +4,38 @@
4
4
  ``Intent(ACTION_VIEW)`` so a Python app can hand a URL (``https:``,
5
5
  ``mailto:``, ``tel:``, a custom scheme, …) to the OS.
6
6
 
7
- All methods are synchronous and return a ``bool`` describing whether
8
- the platform accepted the request. On desktop they return ``False``.
7
+ Outbound methods are synchronous and return a ``bool`` describing
8
+ whether the platform accepted the request. On desktop they return
9
+ ``False``.
10
+
11
+ Inbound deep links flow the other way: declare your schemes in
12
+ ``pythonnative.toml`` (``app.url_schemes``) and the native host calls
13
+ [`dispatch_url`][pythonnative.native_modules.linking.dispatch_url] for
14
+ every URL that opens the app. The URL that cold-started the app is kept
15
+ and returned by ``get_initial_url``; later URLs reach subscribers added
16
+ with ``add_listener``.
9
17
 
10
18
  Example:
11
19
  ```python
12
20
  import pythonnative as pn
13
21
 
14
- if pn.Linking.can_open_url("tel:+15551234567"):
15
- pn.Linking.open_url("tel:+15551234567")
22
+ if url := pn.Linking.get_initial_url():
23
+ navigate_to(url)
24
+
25
+ unsubscribe = pn.Linking.add_listener(navigate_to)
16
26
  ```
17
27
  """
18
28
 
19
29
  from __future__ import annotations
20
30
 
21
- from typing import Any, Optional
31
+ from typing import Any, Callable, List, Optional
22
32
 
23
33
  from ..utils import IS_ANDROID, IS_IOS
24
34
 
25
35
  # Populated by the native host when the app is launched from a deep
26
- # link; ``get_initial_url`` returns it once.
36
+ # link; ``get_initial_url`` returns it.
27
37
  _initial_url: Optional[str] = None
38
+ _url_listeners: List[Callable[[str], None]] = []
28
39
 
29
40
 
30
41
  class Linking:
@@ -62,6 +73,28 @@ class Linking:
62
73
  """Return the URL that launched the app, if any."""
63
74
  return _initial_url
64
75
 
76
+ @staticmethod
77
+ def add_listener(callback: Callable[[str], None]) -> Callable[[], None]:
78
+ """Subscribe to deep links that arrive while the app is running.
79
+
80
+ Args:
81
+ callback: Called with the full URL string for every inbound
82
+ deep link (including the initial one, which is
83
+ dispatched right after startup).
84
+
85
+ Returns:
86
+ A zero-arg function that unsubscribes when called.
87
+ """
88
+ _url_listeners.append(callback)
89
+
90
+ def _unsubscribe() -> None:
91
+ try:
92
+ _url_listeners.remove(callback)
93
+ except ValueError:
94
+ pass
95
+
96
+ return _unsubscribe
97
+
65
98
 
66
99
  def set_initial_url(url: Optional[str]) -> None:
67
100
  """Record the launch URL (called by the native host on cold start)."""
@@ -69,6 +102,29 @@ def set_initial_url(url: Optional[str]) -> None:
69
102
  _initial_url = url
70
103
 
71
104
 
105
+ def dispatch_url(url: str) -> None:
106
+ """Deliver an inbound deep link from the native host.
107
+
108
+ The first URL ever dispatched is also recorded as the initial URL
109
+ (a cold start from a deep link reaches Python only after the
110
+ interpreter boots, so the host can't call ``set_initial_url``
111
+ earlier than this).
112
+
113
+ Args:
114
+ url: The full URL string that opened the app.
115
+ """
116
+ global _initial_url
117
+ if _initial_url is None:
118
+ _initial_url = url
119
+ for listener in list(_url_listeners):
120
+ try:
121
+ listener(url)
122
+ except Exception as exc:
123
+ from .. import diagnostics
124
+
125
+ diagnostics.warn(f"Linking listener raised while handling {url!r}: {exc!r}")
126
+
127
+
72
128
  # ======================================================================
73
129
  # iOS
74
130
  # ======================================================================
@@ -27,6 +27,7 @@ from __future__ import annotations
27
27
  import asyncio
28
28
  from typing import Any, Callable, Dict, Optional, Tuple
29
29
 
30
+ from .. import diagnostics
30
31
  from ..runtime import resolve_future
31
32
  from ..utils import IS_ANDROID, IS_IOS
32
33
 
@@ -94,7 +95,7 @@ def _ios_get(on_result: Callable[[Optional[Coords]], None]) -> None:
94
95
  if self._manager is not None:
95
96
  self._manager.stopUpdatingLocation()
96
97
  except Exception:
97
- pass
98
+ diagnostics.swallowed("location._ios_get._PNLocationDelegate._finish")
98
99
  cb = self._callback
99
100
  self._callback = None
100
101
  _pending_delegates.pop(id(self), None)
@@ -102,7 +103,7 @@ def _ios_get(on_result: Callable[[Optional[Coords]], None]) -> None:
102
103
  try:
103
104
  cb(coords)
104
105
  except Exception:
105
- pass
106
+ diagnostics.swallowed("location._ios_get._PNLocationDelegate._finish")
106
107
 
107
108
  @objc_method
108
109
  def locationManager_didUpdateLocations_(self, manager: Any, locations: Any) -> None:
@@ -131,7 +132,9 @@ def _ios_get(on_result: Callable[[Optional[Coords]], None]) -> None:
131
132
  try:
132
133
  manager.startUpdatingLocation()
133
134
  except Exception:
134
- pass
135
+ diagnostics.swallowed(
136
+ "location._ios_get._PNLocationDelegate.locationManagerDidChangeAuthorization_"
137
+ )
135
138
 
136
139
  delegate = _PNLocationDelegate.new()
137
140
  delegate._callback = on_result
@@ -143,11 +146,11 @@ def _ios_get(on_result: Callable[[Optional[Coords]], None]) -> None:
143
146
  try:
144
147
  manager.requestWhenInUseAuthorization()
145
148
  except Exception:
146
- pass
149
+ diagnostics.swallowed("location._ios_get")
147
150
  try:
148
151
  manager.startUpdatingLocation()
149
152
  except Exception:
150
- pass
153
+ diagnostics.swallowed("location._ios_get")
151
154
  except Exception:
152
155
  on_result(None)
153
156
 
@@ -176,7 +179,7 @@ def _android_get(on_result: Callable[[Optional[Coords]], None]) -> None:
176
179
  on_result((float(loc.getLatitude()), float(loc.getLongitude())))
177
180
  return
178
181
  except Exception:
179
- pass
182
+ diagnostics.swallowed("location._android_get")
180
183
 
181
184
  LocationListener = jclass("android.location.LocationListener")
182
185
  delivered = [False]
@@ -188,12 +191,12 @@ def _android_get(on_result: Callable[[Optional[Coords]], None]) -> None:
188
191
  try:
189
192
  lm.removeUpdates(listener)
190
193
  except Exception:
191
- pass
194
+ diagnostics.swallowed("location._android_get._finish")
192
195
  _pending_delegates.pop(id(listener), None)
193
196
  try:
194
197
  on_result(coords)
195
198
  except Exception:
196
- pass
199
+ diagnostics.swallowed("location._android_get._finish")
197
200
 
198
201
  class _PNLocationListener(dynamic_proxy(LocationListener)): # type: ignore[misc]
199
202
  def onLocationChanged(self, loc: Any) -> None:
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
 
20
20
  from typing import Callable, Dict, List
21
21
 
22
+ from .. import diagnostics
22
23
  from ..hooks import use_effect, use_state
23
24
  from ..utils import IS_ANDROID, IS_IOS
24
25
 
@@ -67,7 +68,7 @@ def dispatch_net_info(state: NetInfoState) -> None:
67
68
  try:
68
69
  listener(dict(state))
69
70
  except Exception:
70
- pass
71
+ diagnostics.swallowed("net_info.dispatch_net_info")
71
72
 
72
73
 
73
74
  def use_net_info() -> NetInfoState:
@@ -1,4 +1,4 @@
1
- """Cross-platform local notifications.
1
+ """Cross-platform local notifications and remote push registration.
2
2
 
3
3
  Provides coroutines for requesting permission and scheduling /
4
4
  cancelling local push notifications. Uses Android's
@@ -9,6 +9,14 @@ scheduling. On Android 13+ the runtime permission should be requested
9
9
  through standard Android APIs (the manifest declaration is otherwise
10
10
  sufficient).
11
11
 
12
+ For remote (server-sent) pushes, enable the ``remote_notifications``
13
+ capability in ``pythonnative.toml`` and call
14
+ ``Notifications.get_device_token()`` to register with APNs and receive
15
+ the device token your server needs. Android remote push requires
16
+ Firebase Cloud Messaging, which needs a per-app ``google-services.json``
17
+ and is not wired up by the built-in module; ``get_device_token`` returns
18
+ ``None`` there.
19
+
12
20
  Example:
13
21
  ```python
14
22
  import pythonnative as pn
@@ -28,10 +36,16 @@ Example:
28
36
  from __future__ import annotations
29
37
 
30
38
  import asyncio
31
- from typing import Any
39
+ from typing import Any, Dict, List, Optional
40
+
41
+ from .. import diagnostics
42
+ from ..runtime import reject_future, resolve_future
43
+ from ..utils import IS_ANDROID, IS_IOS
32
44
 
33
- from ..runtime import resolve_future
34
- from ..utils import IS_ANDROID
45
+ # Delayed Android deliveries in flight, keyed by identifier, so
46
+ # ``cancel`` can abort a notification that hasn't posted yet. (iOS
47
+ # delivers delays natively through UNTimeIntervalNotificationTrigger.)
48
+ _android_delayed: Dict[str, "asyncio.Task[Any]"] = {}
35
49
 
36
50
 
37
51
  class Notifications:
@@ -82,7 +96,13 @@ class Notifications:
82
96
  """
83
97
  del options
84
98
  if IS_ANDROID:
85
- return await asyncio.to_thread(_android_schedule, title, body, delay_seconds, identifier)
99
+ if delay_seconds > 0:
100
+ _cancel_android_delayed(identifier)
101
+ _android_delayed[identifier] = asyncio.ensure_future(
102
+ _android_schedule_later(title, body, delay_seconds, identifier)
103
+ )
104
+ return True
105
+ return await asyncio.to_thread(_android_schedule, title, body, identifier)
86
106
  return await asyncio.to_thread(_ios_schedule, title, body, delay_seconds, identifier)
87
107
 
88
108
  @staticmethod
@@ -94,18 +114,61 @@ class Notifications:
94
114
  [`schedule`][pythonnative.native_modules.notifications.Notifications.schedule].
95
115
  """
96
116
  if IS_ANDROID:
117
+ _cancel_android_delayed(identifier)
97
118
  await asyncio.to_thread(_android_cancel, identifier)
98
119
  return
99
120
  await asyncio.to_thread(_ios_cancel, identifier)
100
121
 
122
+ @staticmethod
123
+ async def get_device_token() -> Optional[str]:
124
+ """Register for remote notifications and return the device token.
125
+
126
+ On iOS this calls ``registerForRemoteNotifications`` and waits
127
+ for the APNs callback; the token is a lowercase hex string your
128
+ server passes to APNs. Requires the ``remote_notifications``
129
+ capability (which adds the ``aps-environment`` entitlement) and
130
+ a real device (the simulator has no APNs connection).
131
+
132
+ Returns:
133
+ The APNs token, or ``None`` on platforms without built-in
134
+ remote push support (Android and desktop).
135
+
136
+ Raises:
137
+ RuntimeError: If APNs registration fails, with the native
138
+ error description.
139
+ """
140
+ if not IS_IOS:
141
+ return None
142
+ return await _ios_get_device_token()
143
+
101
144
 
102
145
  # ======================================================================
103
146
  # Android implementation
104
147
  # ======================================================================
105
148
 
106
149
 
107
- def _android_schedule(title: str, body: str, delay_seconds: float, identifier: str) -> bool:
108
- del delay_seconds # Android schedule is fire-and-forget for now.
150
+ def _cancel_android_delayed(identifier: str) -> None:
151
+ pending = _android_delayed.pop(identifier, None)
152
+ if pending is not None and not pending.done():
153
+ pending.cancel()
154
+
155
+
156
+ async def _android_schedule_later(title: str, body: str, delay_seconds: float, identifier: str) -> None:
157
+ """Post an Android notification after ``delay_seconds`` on the framework loop.
158
+
159
+ The delay lives in the app process (matching the semantics of
160
+ ``UNTimeIntervalNotificationTrigger`` closely enough for in-app
161
+ reminders); notifications whose delay outlives the process need
162
+ ``AlarmManager``, which is out of scope for the built-in module.
163
+ """
164
+ try:
165
+ await asyncio.sleep(delay_seconds)
166
+ await asyncio.to_thread(_android_schedule, title, body, identifier)
167
+ finally:
168
+ _android_delayed.pop(identifier, None)
169
+
170
+
171
+ def _android_schedule(title: str, body: str, identifier: str) -> bool:
109
172
  try:
110
173
  from java import jclass
111
174
 
@@ -139,7 +202,7 @@ def _android_cancel(identifier: str) -> None:
139
202
  nm = ctx.getSystemService(jclass("android.content.Context").NOTIFICATION_SERVICE)
140
203
  nm.cancel(abs(hash(identifier)) % (2**31))
141
204
  except Exception:
142
- pass
205
+ diagnostics.swallowed("notifications._android_cancel")
143
206
 
144
207
 
145
208
  # ======================================================================
@@ -203,4 +266,64 @@ def _ios_cancel(identifier: str) -> None:
203
266
  arr = NSArray.arrayWithObject_(identifier)
204
267
  center.removePendingNotificationRequestsWithIdentifiers_(arr)
205
268
  except Exception:
206
- pass
269
+ diagnostics.swallowed("notifications._ios_cancel")
270
+
271
+
272
+ # ----------------------------------------------------------------------
273
+ # Remote notifications (APNs)
274
+ # ----------------------------------------------------------------------
275
+ #
276
+ # The registration result arrives through UIApplicationDelegate
277
+ # callbacks in the native template, which forward here via
278
+ # dispatch_device_token / dispatch_device_token_error. The token is
279
+ # cached so later get_device_token() calls resolve immediately.
280
+
281
+ _device_token: Optional[str] = None
282
+ _device_token_error: Optional[str] = None
283
+ _token_waiters: List["asyncio.Future[str]"] = []
284
+
285
+
286
+ async def _ios_get_device_token() -> str:
287
+ if _device_token is not None:
288
+ return _device_token
289
+ if _device_token_error is not None:
290
+ raise RuntimeError(f"APNs registration failed: {_device_token_error}")
291
+
292
+ loop = asyncio.get_running_loop()
293
+ future: asyncio.Future[str] = loop.create_future()
294
+ _token_waiters.append(future)
295
+
296
+ def _register() -> None:
297
+ from rubicon.objc import ObjCClass
298
+
299
+ ObjCClass("UIApplication").sharedApplication.registerForRemoteNotifications()
300
+
301
+ try:
302
+ # registerForRemoteNotifications must run on the main thread.
303
+ from ..runtime import call_on_main_thread
304
+
305
+ call_on_main_thread(_register)
306
+ except Exception as exc:
307
+ _token_waiters.remove(future)
308
+ raise RuntimeError(f"Could not start APNs registration: {exc}") from exc
309
+ return await future
310
+
311
+
312
+ def dispatch_device_token(token: str) -> None:
313
+ """Deliver the APNs device token from the native host."""
314
+ global _device_token, _device_token_error
315
+ _device_token = token
316
+ _device_token_error = None
317
+ waiters, _token_waiters[:] = list(_token_waiters), []
318
+ for future in waiters:
319
+ resolve_future(future, token)
320
+
321
+
322
+ def dispatch_device_token_error(message: str) -> None:
323
+ """Deliver an APNs registration failure from the native host."""
324
+ global _device_token_error
325
+ _device_token_error = message
326
+ waiters, _token_waiters[:] = list(_token_waiters), []
327
+ error = RuntimeError(f"APNs registration failed: {message}")
328
+ for future in waiters:
329
+ reject_future(future, error)
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
  import asyncio
22
22
  from typing import Any, Callable, Dict
23
23
 
24
+ from .. import diagnostics
24
25
  from ..runtime import resolve_future
25
26
  from ..utils import IS_ANDROID, IS_IOS
26
27
 
@@ -141,7 +142,7 @@ def deliver_android_permission_result(request_code: int, permissions: Any, grant
141
142
  try:
142
143
  cb(status)
143
144
  except Exception:
144
- pass
145
+ diagnostics.swallowed("permissions.deliver_android_permission_result")
145
146
  return True
146
147
 
147
148
 
@@ -193,8 +194,31 @@ def _ios_request(permission: str, on_done: Callable[[PermissionStatus], None]) -
193
194
 
194
195
  AVCaptureDevice.requestAccessForMediaType_completionHandler_(media, Block(_granted, None, bool))
195
196
  return
197
+
198
+ if permission == "photos":
199
+ PHPhotoLibrary = ObjCClass("PHPhotoLibrary")
200
+
201
+ def _photos_done(status: int) -> None:
202
+ # 0 notDetermined, 1 restricted, 2 denied, 3 authorized,
203
+ # 4 limited (counts as granted).
204
+ on_done({0: UNDETERMINED, 1: BLOCKED, 2: DENIED, 3: GRANTED, 4: GRANTED}.get(int(status), UNDETERMINED))
205
+
206
+ PHPhotoLibrary.requestAuthorization_(Block(_photos_done, None, int))
207
+ return
208
+
209
+ if permission == "notifications":
210
+ center = ObjCClass("UNUserNotificationCenter").currentNotificationCenter()
211
+
212
+ def _notif_done(granted: bool, _error: Any) -> None:
213
+ on_done(GRANTED if granted else DENIED)
214
+
215
+ # 0x07 = badge | sound | alert.
216
+ center.requestAuthorizationWithOptions_completionHandler_(0x07, Block(_notif_done, None, bool, object))
217
+ return
196
218
  except Exception:
197
- pass
219
+ diagnostics.swallowed("permissions._ios_request")
198
220
  # Fall back to reporting the current status for permissions whose
199
- # request flow isn't wired up natively.
221
+ # request flow isn't wired up natively (location needs a
222
+ # CLLocationManager delegate; use the Location module's own
223
+ # request path for that).
200
224
  on_done(_ios_check(permission))
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
  import asyncio
23
23
  from typing import Any, Callable, Dict, Optional
24
24
 
25
+ from .. import diagnostics
25
26
  from ..runtime import resolve_future
26
27
  from ..utils import IS_ANDROID, IS_IOS
27
28
 
@@ -102,7 +103,7 @@ def _ios_share(
102
103
  try:
103
104
  on_done(bool(completed))
104
105
  except Exception:
105
- pass
106
+ diagnostics.swallowed("share._ios_share._completion")
106
107
 
107
108
  controller.setCompletionWithItemsHandler_(
108
109
  Block(_completion, None, ObjCClass("NSString"), Block, ObjCClass("NSArray"), ObjCClass("NSError"))