pythonnative 0.24.0__py3-none-any.whl → 0.26.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. pythonnative/__init__.py +16 -4
  2. pythonnative/animated.py +2 -2
  3. pythonnative/cli/pn.py +172 -12
  4. pythonnative/components.py +59 -0
  5. pythonnative/diagnostics.py +27 -0
  6. pythonnative/hooks.py +364 -81
  7. pythonnative/native_modules/app_state.py +2 -1
  8. pythonnative/native_modules/battery.py +3 -2
  9. pythonnative/native_modules/biometrics.py +2 -1
  10. pythonnative/native_modules/camera.py +16 -7
  11. pythonnative/native_modules/clipboard.py +3 -2
  12. pythonnative/native_modules/file_system.py +3 -2
  13. pythonnative/native_modules/haptics.py +7 -6
  14. pythonnative/native_modules/linking.py +62 -6
  15. pythonnative/native_modules/location.py +11 -8
  16. pythonnative/native_modules/net_info.py +2 -1
  17. pythonnative/native_modules/notifications.py +132 -9
  18. pythonnative/native_modules/permissions.py +27 -3
  19. pythonnative/native_modules/share.py +2 -1
  20. pythonnative/native_views/android.py +149 -124
  21. pythonnative/native_views/desktop.py +50 -49
  22. pythonnative/native_views/ios.py +240 -161
  23. pythonnative/project/android.py +15 -3
  24. pythonnative/project/builder.py +172 -70
  25. pythonnative/project/config.py +28 -6
  26. pythonnative/project/devices.py +291 -0
  27. pythonnative/project/doctor.py +5 -4
  28. pythonnative/project/ios.py +51 -77
  29. pythonnative/project/permissions.py +19 -1
  30. pythonnative/project/runtime_assets.py +78 -166
  31. pythonnative/reconciler.py +518 -37
  32. pythonnative/runtime.py +407 -215
  33. pythonnative/screen.py +53 -7
  34. pythonnative/storage.py +2 -2
  35. pythonnative/suspense.py +417 -0
  36. pythonnative/templates/android_template/app/build.gradle +5 -0
  37. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +1 -0
  38. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +88 -12
  39. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
  40. pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
  41. pythonnative/templates/ios_template/ios_template/Info.plist +0 -2
  42. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
  43. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
  44. pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
  45. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
  46. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/METADATA +2 -1
  47. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/RECORD +51 -48
  48. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/WHEEL +1 -1
  49. pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
  50. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/entry_points.txt +0 -0
  51. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/licenses/LICENSE +0 -0
  52. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/top_level.txt +0 -0
pythonnative/screen.py CHANGED
@@ -1587,6 +1587,19 @@ else:
1587
1587
  return True
1588
1588
  _log_pn("_request_render: native iOS scheduler unavailable; render remains queued")
1589
1589
  return True
1590
+ # Defer via the main dispatch queue rather than an NSTimer.
1591
+ # GCD main-queue blocks are serviced in the common run-loop
1592
+ # modes, so they still run while UIKit is in tracking mode
1593
+ # (scrolls, synthesized test touches). A default-mode NSTimer
1594
+ # is starved for the whole gesture, which delayed renders
1595
+ # long enough on slow CI simulators for Maestro asserts to
1596
+ # time out, and let stale flushes land mid-touch.
1597
+ from .runtime import _ensure_libdispatch_loaded, _ios_dispatch_async
1598
+
1599
+ if _ensure_libdispatch_loaded():
1600
+ _ios_dispatch_async(_flush_ios_scheduled_renders)
1601
+ _log_pn("_request_render: deferred iOS render to next main-queue turn")
1602
+ return True
1590
1603
  try:
1591
1604
  NSTimer = ObjCClass("NSTimer")
1592
1605
  target = _ensure_ios_render_scheduler_target()
@@ -1719,22 +1732,55 @@ else:
1719
1732
  raise RuntimeError(
1720
1733
  "No UINavigationController available; ensure template embeds root in navigation controller"
1721
1734
  )
1722
- nav.pushViewController_animated_(next_vc, True)
1735
+ self._run_nav_op(lambda n: n.pushViewController_animated_(next_vc, True))
1723
1736
 
1724
1737
  def _pop(self) -> None:
1725
- nav = getattr(self.native_instance, "navigationController", None)
1726
- if nav is not None:
1727
- nav.popViewControllerAnimated_(True)
1738
+ self._run_nav_op(lambda n: n.popViewControllerAnimated_(True))
1728
1739
 
1729
1740
  def _reset_to_root(self) -> None:
1730
1741
  """Pop everything above the root view-controller."""
1731
- nav = getattr(self.native_instance, "navigationController", None)
1732
- if nav is not None:
1742
+
1743
+ def op(n: Any) -> None:
1733
1744
  try:
1734
- nav.popToRootViewControllerAnimated_(True)
1745
+ n.popToRootViewControllerAnimated_(True)
1735
1746
  except Exception:
1736
1747
  pass
1737
1748
 
1749
+ self._run_nav_op(op)
1750
+
1751
+ def _run_nav_op(self, op: Any) -> None:
1752
+ """Run ``op(nav)`` unless a stack transition is mid-flight.
1753
+
1754
+ UIKit does not queue navigation calls: a
1755
+ ``pushViewController`` / ``popViewController`` issued
1756
+ while the previous transition is still animating is
1757
+ silently ignored or, worse, misapplies and pops past
1758
+ the intended screen. A fast tap right after a push
1759
+ (easy for a user, common for a UI test on a slow
1760
+ machine) lands exactly in that window. Dropping the
1761
+ operation keeps the stack consistent: the tap simply
1762
+ does nothing, and a retry lands on a settled stack.
1763
+ (Deferring the operation instead was tried and is
1764
+ worse: the user retries, then the deferred operation
1765
+ also fires, popping one screen too many.)
1766
+ """
1767
+ nav = getattr(self.native_instance, "navigationController", None)
1768
+ if nav is None:
1769
+ return
1770
+ try:
1771
+ # rubicon resolves ``transitionCoordinator`` as a bound
1772
+ # method rather than a property on some classes; call
1773
+ # it to get the actual (possibly nil -> None) value.
1774
+ coord = nav.transitionCoordinator
1775
+ if callable(coord):
1776
+ coord = coord()
1777
+ if coord is not None:
1778
+ _log_pn("_run_nav_op: transition in flight; dropping nav op")
1779
+ return
1780
+ except Exception:
1781
+ pass
1782
+ op(nav)
1783
+
1738
1784
  def _set_screen_options(self, options: Dict[str, Any]) -> None:
1739
1785
  """Bind screen options (e.g. title) to the native nav bar.
1740
1786
 
pythonnative/storage.py CHANGED
@@ -379,7 +379,7 @@ def use_persisted_state(
379
379
  )
380
380
  ```
381
381
  """
382
- from .hooks import use_async_effect, use_callback, use_ref, use_state
382
+ from .hooks import use_callback, use_effect, use_ref, use_state
383
383
  from .runtime import run_async
384
384
 
385
385
  state, set_state = use_state(initial)
@@ -391,7 +391,7 @@ def use_persisted_state(
391
391
  set_state(stored)
392
392
  loaded.current = True
393
393
 
394
- use_async_effect(_load, [key])
394
+ use_effect(_load, [key])
395
395
 
396
396
  def setter(value_or_updater: Any) -> None:
397
397
  def _reducer(current: Any) -> Any:
@@ -0,0 +1,417 @@
1
+ """Suspense primitives: suspension signals, resources, and lazy loading.
2
+
3
+ This module implements the machinery behind PythonNative's async
4
+ rendering model:
5
+
6
+ - ``async def`` components: a component body may be a coroutine. The
7
+ reconciler drives it synchronously as far as possible (awaits on
8
+ already-resolved futures complete inline); if it blocks on pending
9
+ work, the render **suspends** and the nearest
10
+ [`Suspense`][pythonnative.Suspense] boundary shows its fallback
11
+ until the coroutine finishes.
12
+ - [`use_resource`][pythonnative.use_resource]: a hook that starts an
13
+ async fetch and caches it across renders. Reading an unresolved
14
+ [`Resource`][pythonnative.Resource] suspends the render; awaiting it
15
+ inside an ``async def`` component does the same thing without the
16
+ boundary having to re-run anything by hand.
17
+ - [`lazy`][pythonnative.lazy]: code-splitting for components. The
18
+ loader runs once, and renders suspend until it resolves.
19
+
20
+ The core building block is [`CoroDriver`][pythonnative.suspense.CoroDriver],
21
+ a miniature task that steps a coroutine **synchronously** until it
22
+ either finishes or blocks on a pending :class:`asyncio.Future`. This is
23
+ what lets an ``async def`` component whose awaits are all cached
24
+ (resolved resources, completed tasks) render in a single synchronous
25
+ pass with zero event-loop round trips, while genuinely pending awaits
26
+ suspend cleanly.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import contextvars
33
+ import inspect
34
+ from typing import Any, Callable, Generic, List, Optional, Tuple, TypeVar
35
+
36
+ T = TypeVar("T")
37
+
38
+ _UNSET = object()
39
+
40
+
41
+ class Suspend(BaseException):
42
+ """Signal that a render is blocked on pending async work.
43
+
44
+ Raised while a component renders, either by
45
+ [`Resource.read`][pythonnative.suspense.Resource.read] or by an
46
+ ``async def`` component body blocking on a pending await. The
47
+ reconciler catches it: the nearest
48
+ [`Suspense`][pythonnative.Suspense] boundary shows its fallback
49
+ (initial mounts), or the component keeps its previous content and
50
+ re-renders when the work finishes (updates).
51
+
52
+ Derives from :class:`BaseException` so
53
+ [`ErrorBoundary`][pythonnative.ErrorBoundary] (which catches
54
+ :class:`Exception`) never mistakes a suspension for a crash.
55
+
56
+ Attributes:
57
+ waitable: The pending work; exposes ``done()`` and
58
+ ``add_done_callback(cb)``.
59
+ hook_state: The suspended component's hook state, carried so
60
+ a Suspense boundary can preserve it across retries (its
61
+ cached resources survive, so the retry doesn't refetch).
62
+ key: ``(component identity, element key)`` used to re-match
63
+ ``hook_state`` on retry.
64
+ label: Component name for diagnostics.
65
+ """
66
+
67
+ def __init__(self, waitable: Any, hook_state: Any = None, label: str = "") -> None:
68
+ super().__init__(label or "render suspended")
69
+ self.waitable = waitable
70
+ self.hook_state = hook_state
71
+ self.key: Optional[Tuple[int, Any]] = None
72
+ self.label = label
73
+
74
+
75
+ class CoroDriver:
76
+ """Drive a coroutine synchronously until it blocks on pending work.
77
+
78
+ A miniature :class:`asyncio.Task`: it steps the coroutine with
79
+ ``send()``, resolving awaits on already-done futures inline. When
80
+ the coroutine blocks on a *pending* future, the driver parks and
81
+ resumes from that future's done callback (on the framework loop's
82
+ thread). Each step runs inside the :mod:`contextvars` context
83
+ captured at creation, so hooks called after an ``await`` still see
84
+ the owning component's hook state.
85
+
86
+ Attributes:
87
+ done: Whether the coroutine finished (returned, raised, or was
88
+ cancelled).
89
+ """
90
+
91
+ __slots__ = (
92
+ "_coro",
93
+ "_context",
94
+ "done",
95
+ "_cancelled",
96
+ "_result",
97
+ "_error",
98
+ "_callbacks",
99
+ "_waiting_on",
100
+ "_stepping",
101
+ )
102
+
103
+ def __init__(self, coro: Any, context: Optional[contextvars.Context] = None) -> None:
104
+ self._coro = coro
105
+ self._context = context if context is not None else contextvars.copy_context()
106
+ self.done = False
107
+ self._cancelled = False
108
+ self._result: Any = _UNSET
109
+ self._error: Optional[BaseException] = None
110
+ self._callbacks: List[Callable[["CoroDriver"], None]] = []
111
+ self._waiting_on: Optional[Any] = None
112
+ self._stepping = False
113
+
114
+ # -- inspection ------------------------------------------------------
115
+
116
+ def cancelled(self) -> bool:
117
+ """Whether the driver was cancelled before finishing normally."""
118
+ return self._cancelled
119
+
120
+ def result(self) -> Any:
121
+ """Return the coroutine's return value (raises if not done or failed)."""
122
+ if not self.done:
123
+ raise RuntimeError("CoroDriver is not done")
124
+ if self._error is not None:
125
+ raise self._error
126
+ return self._result
127
+
128
+ def exception(self) -> Optional[BaseException]:
129
+ """Return the coroutine's exception, or ``None``."""
130
+ if not self.done:
131
+ raise RuntimeError("CoroDriver is not done")
132
+ return self._error
133
+
134
+ def add_done_callback(self, callback: Callable[["CoroDriver"], None]) -> None:
135
+ """Invoke ``callback(self)`` when the coroutine finishes.
136
+
137
+ Fires immediately when already done. Callbacks run on whatever
138
+ thread finished the coroutine (the framework loop's thread for
139
+ suspended coroutines).
140
+ """
141
+ if self.done:
142
+ callback(self)
143
+ return
144
+ self._callbacks.append(callback)
145
+
146
+ # -- driving ---------------------------------------------------------
147
+
148
+ def start(self) -> None:
149
+ """Run the coroutine as far as it can go without blocking."""
150
+ self._step(("send", None))
151
+
152
+ def cancel(self) -> bool:
153
+ """Throw :class:`asyncio.CancelledError` into the coroutine.
154
+
155
+ Returns:
156
+ ``False`` when the coroutine had already finished,
157
+ ``True`` otherwise.
158
+ """
159
+ if self.done:
160
+ return False
161
+ waiting = self._waiting_on
162
+ self._waiting_on = None
163
+ if waiting is not None:
164
+ try:
165
+ waiting.remove_done_callback(self._resume)
166
+ except Exception:
167
+ pass
168
+ self._cancelled = True
169
+ self._step(("throw", asyncio.CancelledError()))
170
+ return True
171
+
172
+ def _resume(self, _fut: Any) -> None:
173
+ self._waiting_on = None
174
+ self._step(("send", None))
175
+
176
+ def _step(self, action: Tuple[str, Any]) -> None:
177
+ if self.done or self._stepping:
178
+ return
179
+ self._stepping = True
180
+ # When a step runs outside a loop iteration (a synchronous
181
+ # render), mark the framework loop as the running loop for the
182
+ # duration, exactly as asyncio.Task steps do. Without this,
183
+ # anything the coroutine calls that uses
184
+ # ``asyncio.get_running_loop()`` internally (``asyncio.sleep``,
185
+ # ``asyncio.create_task``, ...) raises "no running event loop".
186
+ from . import runtime
187
+
188
+ events = asyncio.events
189
+ installed_loop = None
190
+ if events._get_running_loop() is None:
191
+ installed_loop = runtime.get_loop()
192
+ events._set_running_loop(installed_loop)
193
+ try:
194
+ self._context.run(self._advance, action)
195
+ finally:
196
+ if installed_loop is not None:
197
+ events._set_running_loop(None)
198
+ self._stepping = False
199
+
200
+ def _advance(self, action: Tuple[str, Any]) -> None:
201
+ kind, value = action
202
+ while True:
203
+ try:
204
+ if kind == "throw":
205
+ yielded = self._coro.throw(value)
206
+ else:
207
+ yielded = self._coro.send(value)
208
+ except StopIteration as stop:
209
+ self._finish(stop.value, None)
210
+ return
211
+ except asyncio.CancelledError as exc:
212
+ self._cancelled = True
213
+ self._finish(None, exc)
214
+ return
215
+ except BaseException as exc:
216
+ self._finish(None, exc)
217
+ return
218
+
219
+ kind, value = "send", None
220
+ if yielded is None:
221
+ # ``await asyncio.sleep(0)`` and bare yields: resume on
222
+ # the next loop tick to preserve cooperative yielding.
223
+ from . import runtime
224
+
225
+ runtime.get_loop().call_soon(self._resume, None)
226
+ return
227
+ if isinstance(yielded, asyncio.Future):
228
+ # Mirror asyncio.Task's handshake with Future.__await__.
229
+ try:
230
+ yielded._asyncio_future_blocking = False
231
+ except AttributeError:
232
+ pass
233
+ if yielded.done():
234
+ continue
235
+ self._waiting_on = yielded
236
+ yielded.add_done_callback(self._resume)
237
+ return
238
+ self._finish(
239
+ None,
240
+ RuntimeError(
241
+ f"async component awaited an unsupported object: {yielded!r}. "
242
+ "Await asyncio futures, tasks, or PythonNative awaitables."
243
+ ),
244
+ )
245
+ return
246
+
247
+ def _finish(self, result: Any, error: Optional[BaseException]) -> None:
248
+ self.done = True
249
+ self._result = result
250
+ self._error = error
251
+ self._waiting_on = None
252
+ callbacks = self._callbacks
253
+ self._callbacks = []
254
+ for callback in callbacks:
255
+ try:
256
+ callback(self)
257
+ except Exception as exc:
258
+ print(f"[pn.suspense] done callback raised: {exc!r}")
259
+
260
+
261
+ class Resource(Generic[T]):
262
+ """A cached async value with Suspense integration.
263
+
264
+ Returned by [`use_resource`][pythonnative.use_resource]. A resource
265
+ starts fetching as soon as the hook runs and remembers its result
266
+ across renders (until its dependencies change), so re-renders never
267
+ refetch.
268
+
269
+ Two ways to consume it:
270
+
271
+ - ``resource.read()`` in a regular component: returns the value
272
+ when ready, re-raises the fetcher's error if it failed, and
273
+ **suspends the render** while pending.
274
+ - ``await resource`` in an ``async def`` component: same
275
+ semantics, expressed as a plain await.
276
+ """
277
+
278
+ __slots__ = ("_driver",)
279
+
280
+ def __init__(self, driver: CoroDriver) -> None:
281
+ self._driver = driver
282
+
283
+ @property
284
+ def ready(self) -> bool:
285
+ """Whether the fetch has finished (successfully or not)."""
286
+ return self._driver.done
287
+
288
+ def read(self) -> T:
289
+ """Return the fetched value, or suspend the render while pending.
290
+
291
+ Raises:
292
+ Suspend: While the fetch is still in flight (caught by the
293
+ reconciler, never by user code).
294
+ BaseException: Whatever the fetcher raised, re-raised so an
295
+ enclosing [`ErrorBoundary`][pythonnative.ErrorBoundary]
296
+ can catch it.
297
+ """
298
+ driver = self._driver
299
+ if driver.done:
300
+ return driver.result()
301
+ raise Suspend(driver, label="Resource.read")
302
+
303
+ def __await__(self) -> Any:
304
+ driver = self._driver
305
+ if driver.done:
306
+
307
+ def _done() -> Any:
308
+ return driver.result()
309
+ yield # pragma: no cover - marks this as a generator
310
+
311
+ return _done()
312
+ from . import runtime
313
+
314
+ future = runtime.create_future()
315
+
316
+ def _transfer(d: CoroDriver) -> None:
317
+ error = d.exception() if not d.cancelled() else asyncio.CancelledError()
318
+ if error is not None:
319
+ runtime.reject_future(future, error)
320
+ else:
321
+ runtime.resolve_future(future, d.result())
322
+
323
+ driver.add_done_callback(_transfer)
324
+ return future.__await__()
325
+
326
+ def cancel(self) -> None:
327
+ """Cancel the in-flight fetch (no-op when already done)."""
328
+ self._driver.cancel()
329
+
330
+
331
+ def start_resource(fetcher: Callable[[], Any]) -> Resource[Any]:
332
+ """Start ``fetcher`` immediately and wrap it in a [`Resource`][pythonnative.Resource].
333
+
334
+ The fetcher may be an ``async def`` (typical) or a plain function;
335
+ synchronous results resolve the resource immediately, so reading
336
+ it never suspends.
337
+
338
+ This is the non-hook constructor used for module-level resources
339
+ (preloading data before a screen mounts) and by
340
+ [`lazy`][pythonnative.lazy]. Inside components, prefer
341
+ [`use_resource`][pythonnative.use_resource], which caches per
342
+ component instance and re-fetches when dependencies change.
343
+ """
344
+
345
+ async def _run() -> Any:
346
+ value = fetcher()
347
+ if inspect.isawaitable(value):
348
+ return await value
349
+ return value
350
+
351
+ driver = CoroDriver(_run())
352
+ driver.start()
353
+ return Resource(driver)
354
+
355
+
356
+ def lazy(loader: Callable[[], Any]) -> Callable[..., Any]:
357
+ """Define a component that loads its implementation on first render.
358
+
359
+ ``loader`` runs once, the first time the returned component
360
+ renders; until it resolves, renders suspend (so wrap usages in a
361
+ [`Suspense`][pythonnative.Suspense] boundary to show a loading
362
+ state). The loaded value must be a component (a ``@component``
363
+ function or any element factory).
364
+
365
+ Args:
366
+ loader: A zero-arg callable returning the component, or an
367
+ ``async def`` resolving to it. Synchronous loaders (a
368
+ deferred ``import``) resolve immediately and never
369
+ suspend.
370
+
371
+ Returns:
372
+ A component. Props (and children) pass through to the loaded
373
+ component unchanged.
374
+
375
+ Example:
376
+ ```python
377
+ import pythonnative as pn
378
+
379
+ Chart = pn.lazy(lambda: __import__("app.chart", fromlist=["Chart"]).Chart)
380
+
381
+ @pn.component
382
+ def Dashboard():
383
+ return pn.Suspense(
384
+ Chart(points=[1, 2, 3]),
385
+ fallback=pn.ActivityIndicator(),
386
+ )
387
+ ```
388
+ """
389
+ from .hooks import component
390
+
391
+ state: dict = {}
392
+
393
+ @component
394
+ def Lazy(**props: Any) -> Any:
395
+ resource = state.get("resource")
396
+ if resource is None:
397
+ resource = start_resource(loader)
398
+ state["resource"] = resource
399
+ loaded = resource.read()
400
+ children = props.pop("children", None)
401
+ if children:
402
+ return loaded(*children, **props)
403
+ return loaded(**props)
404
+
405
+ Lazy.__name__ = getattr(loader, "__name__", "Lazy")
406
+ if Lazy.__name__ == "<lambda>":
407
+ Lazy.__name__ = "Lazy"
408
+ return Lazy
409
+
410
+
411
+ __all__ = [
412
+ "CoroDriver",
413
+ "Resource",
414
+ "Suspend",
415
+ "lazy",
416
+ "start_resource",
417
+ ]
@@ -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
@@ -15,6 +15,7 @@
15
15
  <activity
16
16
  android:name=".MainActivity"
17
17
  android:exported="true"
18
+ android:launchMode="singleTask"
18
19
  android:windowSoftInputMode="adjustResize">
19
20
  <intent-filter>
20
21
  <action android:name="android.intent.action.MAIN" />
@@ -1,39 +1,115 @@
1
1
  package com.pythonnative.android_template
2
2
 
3
- import androidx.appcompat.app.AppCompatActivity
3
+ import android.content.Intent
4
+ import android.graphics.Color
5
+ import android.graphics.Typeface
4
6
  import android.os.Bundle
5
7
  import android.util.Log
8
+ import android.util.TypedValue
9
+ import android.widget.ScrollView
6
10
  import android.widget.TextView
11
+ import androidx.appcompat.app.AppCompatActivity
7
12
  import com.chaquo.python.Python
8
13
  import com.chaquo.python.android.AndroidPlatform
9
14
 
10
15
  class MainActivity : AppCompatActivity() {
11
16
  private val TAG = javaClass.simpleName
17
+ private var pythonReady = false
12
18
 
13
19
  override fun onCreate(savedInstanceState: Bundle?) {
14
20
  super.onCreate(savedInstanceState)
15
21
  Log.d(TAG, "onCreate() called")
16
22
 
17
- // Initialize Chaquopy
18
23
  if (!Python.isStarted()) {
19
24
  Python.start(AndroidPlatform(this))
20
25
  }
21
26
  try {
22
- // Set content view to the NavHost layout; the initial screen loads via nav_graph startDestination
27
+ // Set content view to the NavHost layout; the initial screen
28
+ // loads via nav_graph startDestination.
23
29
  setContentView(R.layout.activity_main)
24
- // Optionally, bootstrap Python so first fragment can create the initial screen onCreate
25
30
  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
31
+ if (BuildConfig.DEBUG) {
32
+ // Dev-only writable source overlay for hot reload.
33
+ py.getModule("pythonnative.hot_reload").callAttr(
34
+ "configure_dev_environment",
35
+ filesDir.absolutePath
36
+ )
37
+ }
38
+ // Import the entry module now so a broken app fails here,
39
+ // with a full traceback, instead of inside the first fragment.
31
40
  py.getModule("app.main")
41
+ pythonReady = true
32
42
  } catch (e: Exception) {
33
43
  Log.e("PythonNative", "Bootstrap failed", e)
34
- val tv = TextView(this)
35
- tv.text = "Hello from PythonNative (Android template)"
36
- setContentView(tv)
44
+ showBootstrapError(e)
45
+ return
46
+ }
47
+
48
+ // A cold start from a deep link carries the URL on the launch intent.
49
+ intent?.dataString?.let { dispatchUrl(it) }
50
+ }
51
+
52
+ override fun onNewIntent(intent: Intent) {
53
+ super.onNewIntent(intent)
54
+ setIntent(intent)
55
+ intent.dataString?.let { dispatchUrl(it) }
56
+ }
57
+
58
+ // MARK: - AppState forwarding
59
+
60
+ override fun onResume() {
61
+ super.onResume()
62
+ dispatchAppState("active")
63
+ }
64
+
65
+ override fun onPause() {
66
+ super.onPause()
67
+ dispatchAppState("inactive")
68
+ }
69
+
70
+ override fun onStop() {
71
+ super.onStop()
72
+ dispatchAppState("background")
73
+ }
74
+
75
+ private fun dispatchAppState(state: String) {
76
+ if (!pythonReady) return
77
+ try {
78
+ Python.getInstance()
79
+ .getModule("pythonnative.native_modules.app_state")
80
+ .callAttr("dispatch_app_state", state)
81
+ } catch (e: Exception) {
82
+ Log.e("PythonNative", "dispatch_app_state($state) failed", e)
37
83
  }
38
84
  }
85
+
86
+ // MARK: - Deep links
87
+
88
+ private fun dispatchUrl(url: String) {
89
+ if (!pythonReady) return
90
+ try {
91
+ Python.getInstance()
92
+ .getModule("pythonnative.native_modules.linking")
93
+ .callAttr("dispatch_url", url)
94
+ } catch (e: Exception) {
95
+ Log.e("PythonNative", "dispatch_url failed", e)
96
+ }
97
+ }
98
+
99
+ // MARK: - Bootstrap error UI
100
+
101
+ private fun showBootstrapError(error: Exception) {
102
+ val text = TextView(this)
103
+ text.text = "PythonNative could not start\n\n" + Log.getStackTraceString(error)
104
+ text.setTextColor(Color.WHITE)
105
+ text.setBackgroundColor(Color.rgb(191, 26, 26))
106
+ text.typeface = Typeface.MONOSPACE
107
+ text.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
108
+ val pad = (16 * resources.displayMetrics.density).toInt()
109
+ text.setPadding(pad, pad * 3, pad, pad * 2)
110
+ val scroll = ScrollView(this)
111
+ scroll.setBackgroundColor(Color.rgb(191, 26, 26))
112
+ scroll.addView(text)
113
+ setContentView(scroll)
114
+ }
39
115
  }