pythonnative 0.25.0__py3-none-any.whl → 0.27.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 +1 -1
- pythonnative/animated.py +697 -62
- pythonnative/cli/pn.py +172 -12
- pythonnative/components.py +61 -7
- pythonnative/diagnostics.py +27 -0
- pythonnative/gestures.py +446 -34
- 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 +75 -5
- pythonnative/native_modules/notifications.py +134 -15
- pythonnative/native_modules/permissions.py +3 -2
- pythonnative/native_modules/share.py +2 -1
- pythonnative/native_views/android.py +461 -137
- pythonnative/native_views/desktop.py +816 -96
- pythonnative/native_views/ios.py +750 -193
- 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 +28 -0
- pythonnative/style.py +19 -0
- pythonnative/templates/android_template/app/build.gradle +9 -0
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +5 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +148 -12
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNFrameLayout.kt +50 -0
- 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 +5 -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.25.0.dist-info → pythonnative-0.27.0.dist-info}/METADATA +3 -3
- {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/RECORD +49 -46
- pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
- {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/top_level.txt +0 -0
|
@@ -25,6 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
|
|
26
26
|
from typing import Callable, List
|
|
27
27
|
|
|
28
|
+
from .. import diagnostics
|
|
28
29
|
from ..hooks import use_effect, use_state
|
|
29
30
|
|
|
30
31
|
AppStateStatus = str # "active" | "inactive" | "background"
|
|
@@ -74,7 +75,7 @@ def dispatch_app_state(state: AppStateStatus) -> None:
|
|
|
74
75
|
try:
|
|
75
76
|
listener(state)
|
|
76
77
|
except Exception:
|
|
77
|
-
|
|
78
|
+
diagnostics.swallowed("app_state.dispatch_app_state")
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def use_app_state() -> AppStateStatus:
|
|
@@ -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:
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
[`NetInfo`][pythonnative.NetInfo] reports whether the device is online
|
|
4
4
|
and over what kind of connection. ``fetch`` returns a snapshot dict;
|
|
5
5
|
``add_listener`` (and the [`use_net_info`][pythonnative.use_net_info]
|
|
6
|
-
hook) deliver live updates
|
|
7
|
-
|
|
6
|
+
hook) deliver live updates: while at least one listener is subscribed,
|
|
7
|
+
a lightweight background watcher polls the platform's connectivity
|
|
8
|
+
state and dispatches a fresh snapshot whenever it changes. Native
|
|
9
|
+
hosts may also push changes directly through
|
|
8
10
|
[`dispatch_net_info`][pythonnative.native_modules.net_info.dispatch_net_info].
|
|
9
11
|
|
|
10
12
|
A snapshot looks like::
|
|
@@ -17,8 +19,10 @@ A snapshot looks like::
|
|
|
17
19
|
|
|
18
20
|
from __future__ import annotations
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
import threading
|
|
23
|
+
from typing import Callable, Dict, List, Optional
|
|
21
24
|
|
|
25
|
+
from .. import diagnostics
|
|
22
26
|
from ..hooks import use_effect, use_state
|
|
23
27
|
from ..utils import IS_ANDROID, IS_IOS
|
|
24
28
|
|
|
@@ -31,6 +35,11 @@ _last_state: NetInfoState = {
|
|
|
31
35
|
"is_internet_reachable": True,
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
# Background watcher driving live updates while listeners exist.
|
|
39
|
+
_WATCH_INTERVAL_S = 2.0
|
|
40
|
+
_watcher_lock = threading.Lock()
|
|
41
|
+
_watcher_stop: Optional[threading.Event] = None
|
|
42
|
+
|
|
34
43
|
|
|
35
44
|
class NetInfo:
|
|
36
45
|
"""Network connectivity interface."""
|
|
@@ -47,18 +56,60 @@ class NetInfo:
|
|
|
47
56
|
|
|
48
57
|
@staticmethod
|
|
49
58
|
def add_listener(callback: Callable[[NetInfoState], None]) -> Callable[[], None]:
|
|
50
|
-
"""Subscribe to connectivity changes; returns an unsubscribe fn.
|
|
59
|
+
"""Subscribe to connectivity changes; returns an unsubscribe fn.
|
|
60
|
+
|
|
61
|
+
The first subscription starts a background watcher that polls
|
|
62
|
+
the platform connectivity state every couple of seconds and
|
|
63
|
+
dispatches a snapshot on change; the watcher stops when the
|
|
64
|
+
last listener unsubscribes.
|
|
65
|
+
"""
|
|
51
66
|
_listeners.append(callback)
|
|
67
|
+
_ensure_watcher()
|
|
52
68
|
|
|
53
69
|
def _unsubscribe() -> None:
|
|
54
70
|
try:
|
|
55
71
|
_listeners.remove(callback)
|
|
56
72
|
except ValueError:
|
|
57
73
|
pass
|
|
74
|
+
if not _listeners:
|
|
75
|
+
_stop_watcher()
|
|
58
76
|
|
|
59
77
|
return _unsubscribe
|
|
60
78
|
|
|
61
79
|
|
|
80
|
+
def _ensure_watcher() -> None:
|
|
81
|
+
"""Start the connectivity watcher if a platform backend exists."""
|
|
82
|
+
global _watcher_stop
|
|
83
|
+
if not (IS_ANDROID or IS_IOS):
|
|
84
|
+
return
|
|
85
|
+
with _watcher_lock:
|
|
86
|
+
if _watcher_stop is not None:
|
|
87
|
+
return
|
|
88
|
+
stop = threading.Event()
|
|
89
|
+
_watcher_stop = stop
|
|
90
|
+
|
|
91
|
+
def _watch() -> None:
|
|
92
|
+
previous = dict(_last_state)
|
|
93
|
+
while not stop.wait(_WATCH_INTERVAL_S):
|
|
94
|
+
try:
|
|
95
|
+
current = _android_state() if IS_ANDROID else _ios_state()
|
|
96
|
+
except Exception:
|
|
97
|
+
continue
|
|
98
|
+
if current != previous:
|
|
99
|
+
previous = dict(current)
|
|
100
|
+
dispatch_net_info(current)
|
|
101
|
+
|
|
102
|
+
threading.Thread(target=_watch, daemon=True, name="pn-netinfo").start()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stop_watcher() -> None:
|
|
106
|
+
global _watcher_stop
|
|
107
|
+
with _watcher_lock:
|
|
108
|
+
if _watcher_stop is not None:
|
|
109
|
+
_watcher_stop.set()
|
|
110
|
+
_watcher_stop = None
|
|
111
|
+
|
|
112
|
+
|
|
62
113
|
def dispatch_net_info(state: NetInfoState) -> None:
|
|
63
114
|
"""Push a new connectivity snapshot and notify listeners."""
|
|
64
115
|
global _last_state
|
|
@@ -67,7 +118,26 @@ def dispatch_net_info(state: NetInfoState) -> None:
|
|
|
67
118
|
try:
|
|
68
119
|
listener(dict(state))
|
|
69
120
|
except Exception:
|
|
70
|
-
|
|
121
|
+
diagnostics.swallowed("net_info.dispatch_net_info")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def dispatch_android_change() -> None:
|
|
125
|
+
"""Re-read Android connectivity and notify listeners on change.
|
|
126
|
+
|
|
127
|
+
Called by the template's ``MainActivity`` from the
|
|
128
|
+
``ConnectivityManager.NetworkCallback`` it registers via
|
|
129
|
+
``registerDefaultNetworkCallback`` whenever the default network
|
|
130
|
+
appears, drops, or changes capabilities. The callback lives in
|
|
131
|
+
Kotlin because Chaquopy's ``dynamic_proxy`` implements interfaces
|
|
132
|
+
only and ``NetworkCallback`` is a class. Re-reading the snapshot
|
|
133
|
+
here keeps the transport-type mapping in one place, and identical
|
|
134
|
+
consecutive snapshots are dropped so paired ``onAvailable`` /
|
|
135
|
+
``onCapabilitiesChanged`` callbacks don't double-notify.
|
|
136
|
+
"""
|
|
137
|
+
state = _android_state()
|
|
138
|
+
if state == _last_state:
|
|
139
|
+
return
|
|
140
|
+
dispatch_net_info(state)
|
|
71
141
|
|
|
72
142
|
|
|
73
143
|
def use_net_info() -> NetInfoState:
|