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
@@ -3,11 +3,16 @@
3
3
  iOS apps can't rely on a system Python, so PythonNative bundles a copy of
4
4
  CPython built for iOS by the excellent
5
5
  [Python-Apple-support](https://github.com/beeware/Python-Apple-support)
6
- project. This module downloads the pinned release asset, verifies it,
7
- extracts it once (cached under the build directory), and exposes the
8
- paths the [`ios`][pythonnative.project.ios] configurator needs:
9
- ``Python.xcframework``, the simulator ``Python.framework``, the standard
10
- library, and the simulator headers/static lib.
6
+ project. This module downloads the pinned release asset for the
7
+ project's ``app.python_version``, verifies its checksum, extracts it
8
+ once (cached under the build directory), and exposes the path to
9
+ ``Python.xcframework``.
10
+
11
+ The xcframework is linked and embedded by the bundled Xcode template at
12
+ build time; its ``build/utils.sh`` helper (shipped inside the framework
13
+ by BeeWare) installs the standard library and converts binary modules
14
+ into signed frameworks during the Xcode build. There is no post-build
15
+ copy step.
11
16
 
12
17
  Android doesn't need any of this: Chaquopy ships its own CPython via
13
18
  Gradle, so there's no Android equivalent here.
@@ -16,23 +21,34 @@ Gradle, so there's no Android equivalent here.
16
21
  from __future__ import annotations
17
22
 
18
23
  import hashlib
19
- import json
20
- import os
21
24
  import tarfile
22
25
  import urllib.request
23
26
  from dataclasses import dataclass
24
27
  from pathlib import Path
25
- from typing import Callable, List, Optional
26
-
27
- # Pinned, checksum-verified asset for the supported iOS Python version.
28
- _PINNED_ASSETS = {
28
+ from typing import Callable, Optional
29
+
30
+ # Pinned, checksum-verified Python-Apple-support assets. Every version in
31
+ # ``config.SUPPORTED_PYTHON_VERSIONS`` must have an entry here; iOS builds
32
+ # refuse to run against an unpinned, unverified runtime.
33
+ PINNED_ASSETS = {
34
+ "3.10": (
35
+ "3.10-b14",
36
+ "Python-3.10-iOS-support.b14.tar.gz",
37
+ "a6da479a67be74569813af77179dc8ac83a5e685324408110bfc04632166e404",
38
+ ),
29
39
  "3.11": (
30
- "Python-3.11-iOS-support.b7.tar.gz",
31
- "2b7d8589715b9890e8dd7e1bce91c210bb5287417e17b9af120fc577675ed28e",
40
+ "3.11-b9",
41
+ "Python-3.11-iOS-support.b9.tar.gz",
42
+ "56810335d2b73558f7a16b6b2f7ad855b88c3007b200cbaecae9d6a25a2d1ecc",
43
+ ),
44
+ "3.12": (
45
+ "3.12-b9",
46
+ "Python-3.12-iOS-support.b9.tar.gz",
47
+ "a3be9e278c742911db54dd3045bd7451928813508771c9acf14b4af75294edd2",
32
48
  ),
33
49
  }
34
50
 
35
- _RELEASES_API = "https://api.github.com/repos/beeware/Python-Apple-support/releases?per_page=100"
51
+ _DOWNLOAD_URL = "https://github.com/beeware/Python-Apple-support/releases/download/{tag}/{name}"
36
52
  _USER_AGENT = "pythonnative-cli"
37
53
 
38
54
  Logger = Callable[[str], None]
@@ -40,98 +56,20 @@ Logger = Callable[[str], None]
40
56
 
41
57
  @dataclass
42
58
  class IOSRuntime:
43
- """Resolved paths to an extracted iOS CPython support package.
59
+ """A resolved, extracted iOS CPython support package.
44
60
 
45
61
  Attributes:
46
62
  python_version: The CPython ``major.minor`` version.
47
- xcframework_dir: Path to ``Python.xcframework``.
48
- simulator_framework: Path to the simulator-slice
49
- ``Python.framework`` (embedded into the simulator ``.app``).
50
- stdlib_dir: Path to the simulator standard library directory.
51
- simulator_headers: Path to the simulator-slice ``Headers``
52
- directory (used when the project links the static lib).
53
- simulator_static_lib: Path to the simulator ``libPythonX.Y.a``.
54
- device_framework: Path to the device-slice ``Python.framework``
55
- (embedded when archiving for a real device).
56
- device_stdlib: Path to the device standard library directory.
63
+ xcframework_dir: Path to the extracted ``Python.xcframework``.
57
64
  """
58
65
 
59
66
  python_version: str
60
67
  xcframework_dir: Path
61
- simulator_framework: Optional[Path]
62
- stdlib_dir: Optional[Path]
63
- simulator_headers: Optional[Path]
64
- simulator_static_lib: Optional[Path]
65
- device_framework: Optional[Path] = None
66
- device_stdlib: Optional[Path] = None
67
-
68
- def framework_for(self, destination: str) -> Optional[Path]:
69
- """Return the ``Python.framework`` for a build destination.
70
-
71
- Args:
72
- destination: ``"simulator"`` or ``"device"``.
73
-
74
- Returns:
75
- The matching framework path, or ``None`` if unavailable.
76
- """
77
- return self.device_framework if destination == "device" else self.simulator_framework
78
-
79
- def stdlib_for(self, destination: str) -> Optional[Path]:
80
- """Return the standard library directory for a build destination.
81
-
82
- Args:
83
- destination: ``"simulator"`` or ``"device"``.
84
-
85
- Returns:
86
- The matching stdlib path, or ``None`` if unavailable.
87
- """
88
- return self.device_stdlib if destination == "device" else self.stdlib_dir
89
-
90
-
91
- def _github_json(url: str) -> object:
92
- headers = {"User-Agent": _USER_AGENT}
93
- token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
94
- if token:
95
- headers["Authorization"] = f"Bearer {token}"
96
- req = urllib.request.Request(url, headers=headers)
97
- with urllib.request.urlopen(req) as response:
98
- return json.loads(response.read().decode("utf-8"))
99
-
100
-
101
- def resolve_asset_url(python_version: str, preferred_name: Optional[str] = None) -> Optional[str]:
102
- """Resolve a download URL for a Python-Apple-support iOS release asset.
103
-
104
- Prefers an exact ``preferred_name`` match across all releases, then
105
- falls back to the newest asset whose name contains
106
- ``Python-<version>-iOS-support`` and ends in ``.tar.gz``.
107
-
108
- Args:
109
- python_version: CPython ``major.minor`` (e.g., ``"3.11"``).
110
- preferred_name: Exact asset filename to prefer.
111
-
112
- Returns:
113
- A ``browser_download_url``, or ``None`` if resolution fails.
114
- """
115
- try:
116
- releases = _github_json(_RELEASES_API)
117
- except Exception:
118
- return None
119
- if not isinstance(releases, list):
120
- return None
121
-
122
- if preferred_name:
123
- for release in releases:
124
- for asset in release.get("assets", []) or []:
125
- if asset.get("name") == preferred_name:
126
- return asset.get("browser_download_url")
127
68
 
128
- needle = f"Python-{python_version}-iOS-support"
129
- for release in releases:
130
- for asset in release.get("assets", []) or []:
131
- name = asset.get("name") or ""
132
- if needle in name and name.endswith(".tar.gz"):
133
- return asset.get("browser_download_url")
134
- return None
69
+ @property
70
+ def install_script(self) -> Path:
71
+ """Path to BeeWare's ``utils.sh`` build helper inside the framework."""
72
+ return self.xcframework_dir / "build" / "utils.sh"
135
73
 
136
74
 
137
75
  def _sha256(path: Path) -> str:
@@ -159,60 +97,31 @@ def _safe_extract(tar_path: Path, dest: Path) -> None:
159
97
  tar.extractall(dest)
160
98
 
161
99
 
162
- def _first_existing(candidates: List[Path]) -> Optional[Path]:
163
- for candidate in candidates:
164
- if candidate.exists():
165
- return candidate
166
- return None
167
-
168
-
169
100
  def _locate_runtime(extract_root: Path, python_version: str) -> IOSRuntime:
170
- xc_candidates = [
171
- extract_root / "Python.xcframework",
172
- extract_root / "support" / "Python.xcframework",
173
- ]
174
- xcframework = _first_existing(xc_candidates)
175
- if xcframework is None:
101
+ xcframework = extract_root / "Python.xcframework"
102
+ if not xcframework.is_dir():
176
103
  raise RuntimeError("Python.xcframework not found in extracted Python-Apple-support package.")
177
-
178
- sim_slice = xcframework / "ios-arm64_x86_64-simulator"
179
- simulator_framework = _first_existing([sim_slice / "Python.framework"])
180
- stdlib_dir = _first_existing([sim_slice / "lib" / f"python{python_version}"])
181
- simulator_headers = _first_existing([sim_slice / "Headers"])
182
- simulator_static_lib = _first_existing(
183
- [
184
- sim_slice / f"libPython{python_version}.a",
185
- sim_slice / "libpython.a",
186
- ]
187
- )
188
-
189
- device_slice = xcframework / "ios-arm64"
190
- device_framework = _first_existing([device_slice / "Python.framework"])
191
- device_stdlib = _first_existing([device_slice / "lib" / f"python{python_version}"])
192
-
193
- return IOSRuntime(
194
- python_version=python_version,
195
- xcframework_dir=xcframework,
196
- simulator_framework=simulator_framework,
197
- stdlib_dir=stdlib_dir,
198
- simulator_headers=simulator_headers,
199
- simulator_static_lib=simulator_static_lib,
200
- device_framework=device_framework,
201
- device_stdlib=device_stdlib,
202
- )
104
+ runtime = IOSRuntime(python_version=python_version, xcframework_dir=xcframework)
105
+ if not runtime.install_script.is_file():
106
+ raise RuntimeError(
107
+ "The extracted Python.xcframework is missing build/utils.sh; the support "
108
+ "package layout is older than PythonNative expects. Delete the "
109
+ "build/ios_runtime cache and re-run to fetch the pinned asset."
110
+ )
111
+ return runtime
203
112
 
204
113
 
205
114
  def prepare_ios_runtime(
206
115
  cache_dir: Path,
207
- python_version: str = "3.11",
116
+ python_version: str = "3.12",
208
117
  *,
209
118
  log: Optional[Logger] = None,
210
119
  ) -> IOSRuntime:
211
120
  """Download (if needed), verify, and extract the iOS CPython package.
212
121
 
213
122
  The download and extraction are cached under ``cache_dir`` so repeat
214
- builds are fast. For the pinned version the tarball checksum is
215
- verified; for other versions the checksum is skipped with a warning.
123
+ builds are fast. Only pinned, checksum-verified versions are
124
+ accepted; there is no unverified fallback.
216
125
 
217
126
  Args:
218
127
  cache_dir: Directory to store downloads and extractions in.
@@ -223,15 +132,20 @@ def prepare_ios_runtime(
223
132
  A resolved [`IOSRuntime`][pythonnative.project.runtime_assets.IOSRuntime].
224
133
 
225
134
  Raises:
226
- RuntimeError: If the asset URL can't be resolved, the checksum
227
- doesn't match, or the package layout is unexpected.
135
+ RuntimeError: If the version has no pinned asset, the download
136
+ fails, the checksum doesn't match, or the package layout is
137
+ unexpected.
228
138
  """
229
139
  emit: Logger = log or (lambda _message: None)
230
140
  cache_dir.mkdir(parents=True, exist_ok=True)
231
141
 
232
- pinned = _PINNED_ASSETS.get(python_version)
233
- preferred_name = pinned[0] if pinned else None
234
- expected_sha = pinned[1] if pinned else None
142
+ pinned = PINNED_ASSETS.get(python_version)
143
+ if pinned is None:
144
+ supported = ", ".join(sorted(PINNED_ASSETS))
145
+ raise RuntimeError(
146
+ f"No pinned iOS runtime for Python {python_version}. " f"Set app.python_version to one of: {supported}."
147
+ )
148
+ tag, asset_name, expected_sha = pinned
235
149
 
236
150
  extract_root = cache_dir / f"python-{python_version}"
237
151
  if extract_root.is_dir():
@@ -241,30 +155,28 @@ def prepare_ios_runtime(
241
155
  # Stale/partial extraction: re-extract below.
242
156
  pass
243
157
 
244
- url = resolve_asset_url(python_version, preferred_name=preferred_name)
245
- if not url:
246
- raise RuntimeError(
247
- f"Could not resolve a Python-Apple-support iOS asset for Python {python_version}. "
248
- "Check your network connection or set GITHUB_TOKEN to avoid rate limits."
249
- )
250
-
251
- tar_path = cache_dir / os.path.basename(url)
252
- if not tar_path.exists():
253
- emit(f"Downloading embedded Python runtime ({python_version} iOS): {os.path.basename(url)}")
158
+ url = _DOWNLOAD_URL.format(tag=tag, name=asset_name)
159
+ tar_path = cache_dir / asset_name
160
+ if not tar_path.exists() or _sha256(tar_path) != expected_sha:
161
+ emit(f"Downloading embedded Python runtime ({python_version} iOS): {asset_name}")
254
162
  req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
255
- with urllib.request.urlopen(req) as response, open(tar_path, "wb") as handle:
256
- handle.write(response.read())
257
-
258
- if expected_sha:
259
- actual = _sha256(tar_path)
260
- if actual != expected_sha:
163
+ try:
164
+ with urllib.request.urlopen(req) as response, open(tar_path, "wb") as handle:
165
+ handle.write(response.read())
166
+ except OSError as exc:
261
167
  tar_path.unlink(missing_ok=True)
262
168
  raise RuntimeError(
263
- f"Checksum mismatch for {tar_path.name}: expected {expected_sha}, got {actual}. "
264
- "The download may be corrupt; re-run to try again."
265
- )
266
- else:
267
- emit(f"Warning: no pinned checksum for Python {python_version}; skipping verification.")
169
+ f"Could not download the iOS Python runtime from {url}: {exc}. "
170
+ "Check your network connection and re-run."
171
+ ) from exc
172
+
173
+ actual = _sha256(tar_path)
174
+ if actual != expected_sha:
175
+ tar_path.unlink(missing_ok=True)
176
+ raise RuntimeError(
177
+ f"Checksum mismatch for {asset_name}: expected {expected_sha}, got {actual}. "
178
+ "The download may be corrupt; re-run to try again."
179
+ )
268
180
 
269
181
  emit("Extracting embedded Python runtime...")
270
182
  extract_root.mkdir(parents=True, exist_ok=True)
@@ -307,6 +307,9 @@ class Reconciler:
307
307
  # Transaction state for the in-flight pass.
308
308
  self._ops: List[Mutation] = []
309
309
  self._created: List[VNode] = []
310
+ # ``(tag, frame)`` pairs whose ``on_layout`` should fire after
311
+ # the layout pass commits (frames are only queued on change).
312
+ self._pending_layout_events: List[Tuple[int, Tuple[float, float, float, float]]] = []
310
313
  # Function-component VNodes whose own state changed since the
311
314
  # last flush, keyed by ``id`` to dedupe while keeping a strong
312
315
  # reference. Drained by
@@ -485,6 +488,7 @@ class Reconciler:
485
488
  if self._tree is not None:
486
489
  self._run_layout()
487
490
  self._flush_ops()
491
+ self._dispatch_layout_events()
488
492
 
489
493
  # ------------------------------------------------------------------
490
494
  # Back handlers (use_back_handler)
@@ -610,6 +614,7 @@ class Reconciler:
610
614
  self._fix_tree_links()
611
615
  self._run_layout()
612
616
  self._flush_ops()
617
+ self._dispatch_layout_events()
613
618
  self._flush_layout_effects()
614
619
  self._flush_ops()
615
620
  self._flush_passive_effects()
@@ -2216,6 +2221,8 @@ class Reconciler:
2216
2221
  ref._pn_frame = frame
2217
2222
  except Exception:
2218
2223
  pass
2224
+ if vnode.element.props and "on_layout" in vnode.element.props:
2225
+ self._pending_layout_events.append((vnode.tag, frame))
2219
2226
  child_offset_x = 0.0
2220
2227
  child_offset_y = 0.0
2221
2228
  else:
@@ -2225,6 +2232,27 @@ class Reconciler:
2225
2232
  for child in layout_node.children:
2226
2233
  self._collect_frames(child, child_offset_x, child_offset_y)
2227
2234
 
2235
+ def _dispatch_layout_events(self) -> None:
2236
+ """Fire queued ``on_layout`` callbacks after frames were applied.
2237
+
2238
+ Runs post-commit (alongside layout effects), so callbacks may
2239
+ set state; the resulting re-render is scheduled normally.
2240
+ """
2241
+ if not self._pending_layout_events:
2242
+ return
2243
+ from .events import dispatch_event
2244
+
2245
+ pending, self._pending_layout_events = self._pending_layout_events, []
2246
+ for tag, frame in pending:
2247
+ try:
2248
+ dispatch_event(
2249
+ tag,
2250
+ "on_layout",
2251
+ {"x": frame[0], "y": frame[1], "width": frame[2], "height": frame[3]},
2252
+ )
2253
+ except Exception as exc:
2254
+ diagnostics.report_error(exc, phase="event")
2255
+
2228
2256
  # ------------------------------------------------------------------
2229
2257
  # Hot-reload support
2230
2258
  # ------------------------------------------------------------------
pythonnative/style.py CHANGED
@@ -230,6 +230,16 @@ KeyboardType = Literal[
230
230
  ]
231
231
  AutoCapitalize = Literal["none", "sentences", "words", "characters"]
232
232
  ReturnKeyType = Literal["default", "done", "go", "next", "send", "search"]
233
+ PointerEvents = Literal["auto", "none", "box_none", "box_only"]
234
+ """Touch-handling mode for a view and its subtree:
235
+
236
+ - ``"auto"``: the view and its children receive touches (default).
237
+ - ``"none"``: neither the view nor its children receive touches;
238
+ touches pass through to whatever is underneath.
239
+ - ``"box_none"``: the view itself ignores touches but its children
240
+ still receive them (decorative overlays with interactive content).
241
+ - ``"box_only"``: the view receives touches but its children do not.
242
+ """
233
243
 
234
244
 
235
245
  # ======================================================================
@@ -331,6 +341,10 @@ class Style(TypedDict, total=False):
331
341
  # --- Visual: borders ---
332
342
  border_width: float
333
343
  border_radius: float
344
+ border_top_left_radius: float
345
+ border_top_right_radius: float
346
+ border_bottom_left_radius: float
347
+ border_bottom_right_radius: float
334
348
  border_top_width: float
335
349
  border_right_width: float
336
350
  border_bottom_width: float
@@ -361,6 +375,10 @@ class Style(TypedDict, total=False):
361
375
  opacity: float
362
376
  transform: TransformSpec
363
377
 
378
+ # --- Interaction & stacking ---
379
+ z_index: int
380
+ pointer_events: PointerEvents
381
+
364
382
 
365
383
  StyleProp = Union[Style, Dict[str, Any], List[Optional[Union[Style, Dict[str, Any]]]], None]
366
384
  """Public type for the ``style`` parameter on every component factory.
@@ -665,6 +683,7 @@ __all__ = [
665
683
  "JustifyContent",
666
684
  "KeyboardType",
667
685
  "Overflow",
686
+ "PointerEvents",
668
687
  "Position",
669
688
  "ReturnKeyType",
670
689
  "ScaleType",
@@ -27,6 +27,11 @@ android {
27
27
  }
28
28
  }
29
29
 
30
+ buildFeatures {
31
+ // MainActivity gates dev-only behavior (hot reload) on BuildConfig.DEBUG.
32
+ buildConfig true
33
+ }
34
+
30
35
  buildTypes {
31
36
  release {
32
37
  minifyEnabled false
@@ -51,6 +56,10 @@ dependencies {
51
56
  implementation 'androidx.recyclerview:recyclerview:1.3.2'
52
57
  // Pull-to-refresh (ScrollView's refresh_control prop)
53
58
  implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
59
+ // SecureStore (EncryptedSharedPreferences)
60
+ implementation 'androidx.security:security-crypto:1.1.0'
61
+ // Biometrics (BiometricPrompt)
62
+ implementation 'androidx.biometric:biometric:1.1.0'
54
63
  // AndroidX Navigation for Fragment-based navigation
55
64
  implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
56
65
  implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
@@ -2,6 +2,10 @@
2
2
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3
3
  xmlns:tools="http://schemas.android.com/tools">
4
4
 
5
+ <!-- Runtime-gated on Android 13+ (API 33); requested by the
6
+ pythonnative notifications module before posting. -->
7
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
8
+
5
9
  <application
6
10
  android:allowBackup="true"
7
11
  android:dataExtractionRules="@xml/data_extraction_rules"
@@ -15,6 +19,7 @@
15
19
  <activity
16
20
  android:name=".MainActivity"
17
21
  android:exported="true"
22
+ android:launchMode="singleTask"
18
23
  android:windowSoftInputMode="adjustResize">
19
24
  <intent-filter>
20
25
  <action android:name="android.intent.action.MAIN" />
@@ -1,39 +1,175 @@
1
1
  package com.pythonnative.android_template
2
2
 
3
- import androidx.appcompat.app.AppCompatActivity
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.graphics.Color
6
+ import android.graphics.Typeface
7
+ import android.net.ConnectivityManager
8
+ import android.net.Network
9
+ import android.net.NetworkCapabilities
4
10
  import android.os.Bundle
5
11
  import android.util.Log
12
+ import android.util.TypedValue
13
+ import android.widget.ScrollView
6
14
  import android.widget.TextView
15
+ import androidx.appcompat.app.AppCompatActivity
7
16
  import com.chaquo.python.Python
8
17
  import com.chaquo.python.android.AndroidPlatform
9
18
 
10
19
  class MainActivity : AppCompatActivity() {
11
20
  private val TAG = javaClass.simpleName
21
+ private var pythonReady = false
22
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
12
23
 
13
24
  override fun onCreate(savedInstanceState: Bundle?) {
14
25
  super.onCreate(savedInstanceState)
15
26
  Log.d(TAG, "onCreate() called")
16
27
 
17
- // Initialize Chaquopy
18
28
  if (!Python.isStarted()) {
19
29
  Python.start(AndroidPlatform(this))
20
30
  }
21
31
  try {
22
- // Set content view to the NavHost layout; the initial screen loads via nav_graph startDestination
32
+ // Set content view to the NavHost layout; the initial screen
33
+ // loads via nav_graph startDestination.
23
34
  setContentView(R.layout.activity_main)
24
- // Optionally, bootstrap Python so first fragment can create the initial screen onCreate
25
35
  val py = Python.getInstance()
26
- py.getModule("pythonnative.hot_reload").callAttr(
27
- "configure_dev_environment",
28
- filesDir.absolutePath
29
- )
30
- // Touch module to ensure bundled Python code is available; actual instantiation happens in ScreenFragment
36
+ if (BuildConfig.DEBUG) {
37
+ // Dev-only writable source overlay for hot reload.
38
+ py.getModule("pythonnative.hot_reload").callAttr(
39
+ "configure_dev_environment",
40
+ filesDir.absolutePath
41
+ )
42
+ }
43
+ // Import the entry module now so a broken app fails here,
44
+ // with a full traceback, instead of inside the first fragment.
31
45
  py.getModule("app.main")
46
+ pythonReady = true
32
47
  } catch (e: Exception) {
33
48
  Log.e("PythonNative", "Bootstrap failed", e)
34
- val tv = TextView(this)
35
- tv.text = "Hello from PythonNative (Android template)"
36
- setContentView(tv)
49
+ showBootstrapError(e)
50
+ return
51
+ }
52
+
53
+ // A cold start from a deep link carries the URL on the launch intent.
54
+ intent?.dataString?.let { dispatchUrl(it) }
55
+
56
+ registerNetworkCallback()
57
+ }
58
+
59
+ override fun onDestroy() {
60
+ super.onDestroy()
61
+ networkCallback?.let {
62
+ try {
63
+ (getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager)
64
+ .unregisterNetworkCallback(it)
65
+ } catch (e: Exception) {
66
+ Log.e("PythonNative", "unregisterNetworkCallback failed", e)
67
+ }
68
+ networkCallback = null
37
69
  }
38
70
  }
71
+
72
+ override fun onNewIntent(intent: Intent) {
73
+ super.onNewIntent(intent)
74
+ setIntent(intent)
75
+ intent.dataString?.let { dispatchUrl(it) }
76
+ }
77
+
78
+ // MARK: - AppState forwarding
79
+
80
+ override fun onResume() {
81
+ super.onResume()
82
+ dispatchAppState("active")
83
+ }
84
+
85
+ override fun onPause() {
86
+ super.onPause()
87
+ dispatchAppState("inactive")
88
+ }
89
+
90
+ override fun onStop() {
91
+ super.onStop()
92
+ dispatchAppState("background")
93
+ }
94
+
95
+ private fun dispatchAppState(state: String) {
96
+ if (!pythonReady) return
97
+ try {
98
+ Python.getInstance()
99
+ .getModule("pythonnative.native_modules.app_state")
100
+ .callAttr("dispatch_app_state", state)
101
+ } catch (e: Exception) {
102
+ Log.e("PythonNative", "dispatch_app_state($state) failed", e)
103
+ }
104
+ }
105
+
106
+ // MARK: - NetInfo forwarding
107
+
108
+ // Live connectivity updates. The callback must be a NetworkCallback
109
+ // subclass, which Chaquopy's dynamic_proxy can't create from Python
110
+ // (interfaces only), so the registration lives here and forwards
111
+ // into the pythonnative net_info module.
112
+ private fun registerNetworkCallback() {
113
+ if (!pythonReady || networkCallback != null) return
114
+ try {
115
+ val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
116
+ val callback = object : ConnectivityManager.NetworkCallback() {
117
+ override fun onAvailable(network: Network) = dispatchNetInfo()
118
+ override fun onLost(network: Network) = dispatchNetInfo()
119
+ override fun onCapabilitiesChanged(
120
+ network: Network,
121
+ capabilities: NetworkCapabilities
122
+ ) = dispatchNetInfo()
123
+ }
124
+ manager.registerDefaultNetworkCallback(callback)
125
+ networkCallback = callback
126
+ } catch (e: Exception) {
127
+ Log.e("PythonNative", "registerDefaultNetworkCallback failed", e)
128
+ }
129
+ }
130
+
131
+ private fun dispatchNetInfo() {
132
+ // NetworkCallback fires on a ConnectivityManager binder thread;
133
+ // hop to the main thread before crossing into Python.
134
+ runOnUiThread {
135
+ if (!pythonReady) return@runOnUiThread
136
+ try {
137
+ Python.getInstance()
138
+ .getModule("pythonnative.native_modules.net_info")
139
+ .callAttr("dispatch_android_change")
140
+ } catch (e: Exception) {
141
+ Log.e("PythonNative", "dispatch_android_change failed", e)
142
+ }
143
+ }
144
+ }
145
+
146
+ // MARK: - Deep links
147
+
148
+ private fun dispatchUrl(url: String) {
149
+ if (!pythonReady) return
150
+ try {
151
+ Python.getInstance()
152
+ .getModule("pythonnative.native_modules.linking")
153
+ .callAttr("dispatch_url", url)
154
+ } catch (e: Exception) {
155
+ Log.e("PythonNative", "dispatch_url failed", e)
156
+ }
157
+ }
158
+
159
+ // MARK: - Bootstrap error UI
160
+
161
+ private fun showBootstrapError(error: Exception) {
162
+ val text = TextView(this)
163
+ text.text = "PythonNative could not start\n\n" + Log.getStackTraceString(error)
164
+ text.setTextColor(Color.WHITE)
165
+ text.setBackgroundColor(Color.rgb(191, 26, 26))
166
+ text.typeface = Typeface.MONOSPACE
167
+ text.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
168
+ val pad = (16 * resources.displayMetrics.density).toInt()
169
+ text.setPadding(pad, pad * 3, pad, pad * 2)
170
+ val scroll = ScrollView(this)
171
+ scroll.setBackgroundColor(Color.rgb(191, 26, 26))
172
+ scroll.addView(text)
173
+ setContentView(scroll)
174
+ }
39
175
  }