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.
Files changed (50) hide show
  1. pythonnative/__init__.py +1 -1
  2. pythonnative/animated.py +697 -62
  3. pythonnative/cli/pn.py +172 -12
  4. pythonnative/components.py +61 -7
  5. pythonnative/diagnostics.py +27 -0
  6. pythonnative/gestures.py +446 -34
  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 +75 -5
  17. pythonnative/native_modules/notifications.py +134 -15
  18. pythonnative/native_modules/permissions.py +3 -2
  19. pythonnative/native_modules/share.py +2 -1
  20. pythonnative/native_views/android.py +461 -137
  21. pythonnative/native_views/desktop.py +816 -96
  22. pythonnative/native_views/ios.py +750 -193
  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 +28 -0
  32. pythonnative/style.py +19 -0
  33. pythonnative/templates/android_template/app/build.gradle +9 -0
  34. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +5 -0
  35. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +148 -12
  36. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNFrameLayout.kt +50 -0
  37. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
  38. pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
  39. pythonnative/templates/ios_template/ios_template/Info.plist +5 -2
  40. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
  41. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
  42. pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
  43. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
  44. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/METADATA +3 -3
  45. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/RECORD +49 -46
  46. pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
  47. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/WHEEL +0 -0
  48. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/entry_points.txt +0 -0
  49. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/licenses/LICENSE +0 -0
  50. {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
- pass
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
- 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:
@@ -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 as the native host forwards connectivity
7
- changes through
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
- from typing import Callable, Dict, List
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
- pass
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: