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.
- pythonnative/__init__.py +16 -4
- pythonnative/animated.py +2 -2
- pythonnative/cli/pn.py +172 -12
- pythonnative/components.py +59 -0
- pythonnative/diagnostics.py +27 -0
- pythonnative/hooks.py +364 -81
- pythonnative/native_modules/app_state.py +2 -1
- pythonnative/native_modules/battery.py +3 -2
- pythonnative/native_modules/biometrics.py +2 -1
- pythonnative/native_modules/camera.py +16 -7
- pythonnative/native_modules/clipboard.py +3 -2
- pythonnative/native_modules/file_system.py +3 -2
- pythonnative/native_modules/haptics.py +7 -6
- pythonnative/native_modules/linking.py +62 -6
- pythonnative/native_modules/location.py +11 -8
- pythonnative/native_modules/net_info.py +2 -1
- pythonnative/native_modules/notifications.py +132 -9
- pythonnative/native_modules/permissions.py +27 -3
- pythonnative/native_modules/share.py +2 -1
- pythonnative/native_views/android.py +149 -124
- pythonnative/native_views/desktop.py +50 -49
- pythonnative/native_views/ios.py +240 -161
- pythonnative/project/android.py +15 -3
- pythonnative/project/builder.py +172 -70
- pythonnative/project/config.py +28 -6
- pythonnative/project/devices.py +291 -0
- pythonnative/project/doctor.py +5 -4
- pythonnative/project/ios.py +51 -77
- pythonnative/project/permissions.py +19 -1
- pythonnative/project/runtime_assets.py +78 -166
- pythonnative/reconciler.py +518 -37
- pythonnative/runtime.py +407 -215
- pythonnative/screen.py +53 -7
- pythonnative/storage.py +2 -2
- pythonnative/suspense.py +417 -0
- pythonnative/templates/android_template/app/build.gradle +5 -0
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +1 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +88 -12
- pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
- pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
- pythonnative/templates/ios_template/ios_template/Info.plist +0 -2
- pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
- pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
- pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
- pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/METADATA +2 -1
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/RECORD +51 -48
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/WHEEL +1 -1
- pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
the platform accepted the request. On desktop they return
|
|
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.
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
+
diagnostics.swallowed("location._ios_get")
|
|
147
150
|
try:
|
|
148
151
|
manager.startUpdatingLocation()
|
|
149
152
|
except Exception:
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"))
|