pythonnative 0.19.0__py3-none-any.whl → 0.21.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/hooks.py CHANGED
@@ -76,6 +76,8 @@ class HookState:
76
76
  "_trigger_render",
77
77
  "_pending_effects",
78
78
  "_dirty",
79
+ "_vnode",
80
+ "_reconciler",
79
81
  )
80
82
 
81
83
  def __init__(self) -> None:
@@ -95,6 +97,13 @@ class HookState:
95
97
  # knows that a memoized component still needs to re-render even
96
98
  # when its props didn't change.
97
99
  self._dirty: bool = False
100
+ # Back-references wired by the reconciler so a state setter can
101
+ # mark *its own* component subtree dirty for a local re-render
102
+ # (instead of forcing a whole-app re-render from the root). Both
103
+ # stay ``None`` until the component is mounted, and are cleared
104
+ # again when it unmounts.
105
+ self._vnode: Any = None
106
+ self._reconciler: Any = None
98
107
 
99
108
  def reset_index(self) -> None:
100
109
  """Reset every per-hook cursor to ``0``.
@@ -182,6 +191,25 @@ def _schedule_trigger(trigger: Callable[[], None]) -> None:
182
191
  trigger()
183
192
 
184
193
 
194
+ def _notify_state_changed(ctx: "HookState") -> None:
195
+ """Mark ``ctx``'s component dirty and schedule a render after a state change.
196
+
197
+ Enqueuing the owning ``VNode`` in the reconciler's dirty set is what
198
+ makes the subsequent render *local*: the screen host's trigger calls
199
+ ``flush_dirty``, which re-renders only the components marked here
200
+ rather than the whole app. The dirty mark is eager (so several
201
+ setters coalesce), while the render trigger respects
202
+ [`batch_updates`][pythonnative.batch_updates].
203
+ """
204
+ ctx._dirty = True
205
+ reconciler = ctx._reconciler
206
+ vnode = ctx._vnode
207
+ if reconciler is not None and vnode is not None:
208
+ reconciler.mark_dirty(vnode)
209
+ if ctx._trigger_render:
210
+ _schedule_trigger(ctx._trigger_render)
211
+
212
+
185
213
  @contextmanager
186
214
  def batch_updates() -> Generator[None, None, None]:
187
215
  """Coalesce multiple state updates into a single re-render.
@@ -272,9 +300,7 @@ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
272
300
  new_value = new_value(ctx.states[idx])
273
301
  if ctx.states[idx] is not new_value and ctx.states[idx] != new_value:
274
302
  ctx.states[idx] = new_value
275
- ctx._dirty = True
276
- if ctx._trigger_render:
277
- _schedule_trigger(ctx._trigger_render)
303
+ _notify_state_changed(ctx)
278
304
 
279
305
  return current, setter
280
306
 
@@ -339,9 +365,7 @@ def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple
339
365
  new_state = reducer(ctx.states[idx], action)
340
366
  if ctx.states[idx] is not new_state and ctx.states[idx] != new_state:
341
367
  ctx.states[idx] = new_state
342
- ctx._dirty = True
343
- if ctx._trigger_render:
344
- _schedule_trigger(ctx._trigger_render)
368
+ _notify_state_changed(ctx)
345
369
 
346
370
  return current, dispatch
347
371
 
@@ -48,6 +48,26 @@ def _ctx() -> Any:
48
48
  return get_android_context()
49
49
 
50
50
 
51
+ def _pn_runtime_class(class_name: str) -> Any:
52
+ """Resolve a PythonNative Android helper class for the running app.
53
+
54
+ The Android template's helper classes (e.g. ``PNVirtualListView``)
55
+ live in the app's own package, which the ``pn`` CLI relocates to the
56
+ configured ``application_id`` at build time. Deriving the package from
57
+ the runtime ``Context`` (rather than hardcoding the template package)
58
+ keeps these lookups correct for any app id.
59
+
60
+ Args:
61
+ class_name: The class name within the app package, e.g.
62
+ ``"PNVirtualListView"`` or ``"PNVirtualListView$Delegate"``.
63
+
64
+ Returns:
65
+ The resolved Java class.
66
+ """
67
+ package = _ctx().getPackageName()
68
+ return jclass(f"{package}.{class_name}")
69
+
70
+
51
71
  def _density() -> float:
52
72
  return float(_ctx().getResources().getDisplayMetrics().density)
53
73
 
@@ -1942,7 +1962,7 @@ def _java_id(jobj: Any) -> int:
1942
1962
 
1943
1963
 
1944
1964
  def _make_recyclerview_delegate(props: Dict[str, Any]) -> Any:
1945
- Delegate = jclass("com.pythonnative.android_template.PNVirtualListView$Delegate")
1965
+ Delegate = _pn_runtime_class("PNVirtualListView$Delegate")
1946
1966
 
1947
1967
  class _Delegate(dynamic_proxy(Delegate)):
1948
1968
  def __init__(self, initial: Dict[str, Any]) -> None:
@@ -2003,7 +2023,7 @@ class VirtualListHandler(AndroidViewHandler):
2003
2023
 
2004
2024
  def create(self, props: Dict[str, Any]) -> Any:
2005
2025
  try:
2006
- PNVirtualListView = jclass("com.pythonnative.android_template.PNVirtualListView")
2026
+ PNVirtualListView = _pn_runtime_class("PNVirtualListView")
2007
2027
  delegate = _make_recyclerview_delegate(props)
2008
2028
  rv = PNVirtualListView(_ctx(), delegate)
2009
2029
  if "background_color" in props and props["background_color"] is not None:
@@ -0,0 +1,68 @@
1
+ """App project model and build system for PythonNative.
2
+
3
+ This package turns a declarative ``pythonnative.toml`` into real,
4
+ branded, installable native apps. It is the engine behind the ``pn``
5
+ CLI's ``run``, ``build``, and ``doctor`` commands, split into focused,
6
+ independently-testable modules:
7
+
8
+ - [`config`][pythonnative.project.config]: parse/validate the TOML into a
9
+ typed [`AppConfig`][pythonnative.project.config.AppConfig].
10
+ - [`permissions`][pythonnative.project.permissions]: map declarative
11
+ capabilities to native permission artifacts.
12
+ - [`android`][pythonnative.project.android] /
13
+ [`ios`][pythonnative.project.ios]: configure the staged native
14
+ templates for a specific app (identity, permissions, branding).
15
+ - [`icons`][pythonnative.project.icons]: generate icons and splash
16
+ assets.
17
+ - [`runtime_assets`][pythonnative.project.runtime_assets]: acquire the
18
+ embedded iOS CPython runtime.
19
+ - [`builder`][pythonnative.project.builder]: orchestrate staging,
20
+ configuration, and the native toolchains.
21
+ - [`doctor`][pythonnative.project.doctor]: diagnose the local toolchain.
22
+
23
+ Most users interact with this package only through the ``pn`` CLI, but
24
+ the API is public and stable enough to script against.
25
+ """
26
+
27
+ from .builder import (
28
+ BuildArtifacts,
29
+ Builder,
30
+ BuildError,
31
+ CommandResult,
32
+ CommandRunner,
33
+ PreparedProject,
34
+ SubprocessRunner,
35
+ )
36
+ from .config import (
37
+ AndroidConfig,
38
+ AndroidSigning,
39
+ AppConfig,
40
+ ConfigError,
41
+ IOSConfig,
42
+ IOSSigning,
43
+ entrypoint_to_module,
44
+ render_default_toml,
45
+ )
46
+ from .permissions import CAPABILITIES, Capability, ResolvedPermissions, resolve_permissions
47
+
48
+ __all__ = [
49
+ "AppConfig",
50
+ "ConfigError",
51
+ "IOSConfig",
52
+ "IOSSigning",
53
+ "AndroidConfig",
54
+ "AndroidSigning",
55
+ "entrypoint_to_module",
56
+ "render_default_toml",
57
+ "Capability",
58
+ "CAPABILITIES",
59
+ "ResolvedPermissions",
60
+ "resolve_permissions",
61
+ "Builder",
62
+ "BuildError",
63
+ "BuildArtifacts",
64
+ "PreparedProject",
65
+ "CommandRunner",
66
+ "SubprocessRunner",
67
+ "CommandResult",
68
+ ]
@@ -0,0 +1,504 @@
1
+ """Config-driven Android project configurator.
2
+
3
+ Turns the bundled ``android_template`` into a concrete, buildable Gradle
4
+ project for a specific [`AppConfig`][pythonnative.project.config.AppConfig]:
5
+
6
+ - **Package relocation.** The template lives under
7
+ ``com.pythonnative.android_template``; this module rewrites and moves
8
+ it to the app's own ``application_id`` so each app ships a distinct
9
+ package. The PythonNative Android runtime resolves its helper classes
10
+ (``Navigator``, ``PNVirtualListView``) via ``getPackageName()``, so the
11
+ relocation needs no runtime configuration.
12
+ - **Identity & SDKs.** ``applicationId``, ``versionCode``/``versionName``,
13
+ ``minSdk``/``targetSdk``/``compileSdk``, ABI filters, and the embedded
14
+ CPython version are written into ``app/build.gradle``.
15
+ - **Permissions.** ``<uses-permission>`` entries (derived from
16
+ ``[permissions]``) and the launch orientation are written into
17
+ ``AndroidManifest.xml``.
18
+ - **Signing.** A release ``signingConfig`` is injected when a keystore is
19
+ configured (passwords are read from the environment at build time).
20
+ - **Branding.** Launcher icons and an Android 12+ splash screen are
21
+ generated from the configured assets.
22
+ - **Python sources.** The user's ``app/`` and (in a dev checkout) the
23
+ in-repo ``pythonnative`` package are staged into Chaquopy's source set,
24
+ and ``requirements.txt`` is generated from ``[requirements].packages``.
25
+
26
+ Everything here is plain file manipulation, which keeps it fully unit
27
+ testable without an Android toolchain.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import re
34
+ import shutil
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+ from typing import Callable, List, Optional
38
+
39
+ from . import icons
40
+ from .config import AppConfig
41
+
42
+ TEMPLATE_PACKAGE = "com.pythonnative.android_template"
43
+ """The fixed package the bundled Android template ships under."""
44
+
45
+ TEXT_SUFFIXES = {".kt", ".java", ".gradle", ".xml", ".pro", ".properties", ".cfg"}
46
+
47
+ _SOURCE_ROOTS = (
48
+ ("app", "src", "main", "java"),
49
+ ("app", "src", "test", "java"),
50
+ ("app", "src", "androidTest", "java"),
51
+ )
52
+
53
+ Logger = Callable[[str], None]
54
+
55
+
56
+ @dataclass
57
+ class AndroidLayout:
58
+ """Resolved paths within a configured Android project.
59
+
60
+ Attributes:
61
+ project_dir: The Gradle project root (``.../android_template``).
62
+ application_id: The final Android application id.
63
+ python_root: Chaquopy Python source root
64
+ (``app/src/main/python``).
65
+ """
66
+
67
+ project_dir: Path
68
+ application_id: str
69
+ python_root: Path
70
+
71
+
72
+ def configure(
73
+ project_dir: Path,
74
+ config: AppConfig,
75
+ *,
76
+ dev_lib_root: Optional[Path] = None,
77
+ log: Optional[Logger] = None,
78
+ ) -> AndroidLayout:
79
+ """Fully configure a staged Android template for ``config``.
80
+
81
+ Args:
82
+ project_dir: The staged ``android_template`` directory.
83
+ config: The validated app configuration.
84
+ dev_lib_root: Path to an in-repo ``pythonnative`` package to
85
+ bundle (dev checkout); ``None`` to rely on the PyPI install.
86
+ log: Optional progress logger.
87
+
88
+ Returns:
89
+ An [`AndroidLayout`][pythonnative.project.android.AndroidLayout]
90
+ describing the configured project.
91
+ """
92
+ emit: Logger = log or (lambda _message: None)
93
+ project_dir = Path(project_dir)
94
+
95
+ relocate_package(project_dir, TEMPLATE_PACKAGE, config.application_id)
96
+ configure_gradle(project_dir, config)
97
+ configure_settings_gradle(project_dir, config)
98
+ configure_strings(project_dir, config)
99
+ configure_manifest(project_dir, config)
100
+ write_requirements(project_dir, config)
101
+
102
+ _apply_branding(project_dir, config, emit)
103
+
104
+ python_root = stage_python_sources(project_dir, config, dev_lib_root=dev_lib_root)
105
+ emit(f"Configured Android project ({config.application_id}).")
106
+ return AndroidLayout(project_dir=project_dir, application_id=config.application_id, python_root=python_root)
107
+
108
+
109
+ # ======================================================================
110
+ # Package relocation
111
+ # ======================================================================
112
+
113
+
114
+ def relocate_package(project_dir: Path, old_package: str, new_package: str) -> None:
115
+ """Rewrite and move the template's Java/Kotlin package.
116
+
117
+ Replaces every occurrence of ``old_package`` with ``new_package`` in
118
+ all text sources, then moves the source directories from the old
119
+ package path to the new one. A no-op when the packages are equal.
120
+
121
+ Args:
122
+ project_dir: The staged Android project root.
123
+ old_package: The dotted package currently in the template.
124
+ new_package: The desired dotted package (the app's id).
125
+ """
126
+ if old_package == new_package:
127
+ return
128
+
129
+ _replace_in_tree(project_dir, old_package, new_package)
130
+
131
+ old_rel = old_package.replace(".", os.sep)
132
+ new_rel = new_package.replace(".", os.sep)
133
+ for parts in _SOURCE_ROOTS:
134
+ source_root = project_dir.joinpath(*parts)
135
+ old_dir = source_root / old_rel
136
+ if not old_dir.is_dir():
137
+ continue
138
+ new_dir = source_root / new_rel
139
+ new_dir.mkdir(parents=True, exist_ok=True)
140
+ for entry in old_dir.iterdir():
141
+ shutil.move(str(entry), str(new_dir / entry.name))
142
+ _prune_empty_dirs(source_root, old_rel)
143
+
144
+
145
+ def _replace_in_tree(root: Path, needle: str, replacement: str) -> None:
146
+ for path in root.rglob("*"):
147
+ if not path.is_file() or path.suffix not in TEXT_SUFFIXES:
148
+ continue
149
+ try:
150
+ text = path.read_text(encoding="utf-8")
151
+ except (UnicodeDecodeError, OSError):
152
+ continue
153
+ if needle in text:
154
+ path.write_text(text.replace(needle, replacement), encoding="utf-8")
155
+
156
+
157
+ def _prune_empty_dirs(source_root: Path, relative: str) -> None:
158
+ current = source_root / relative
159
+ while current != source_root and current.is_dir() and not any(current.iterdir()):
160
+ current.rmdir()
161
+ current = current.parent
162
+
163
+
164
+ # ======================================================================
165
+ # Gradle / manifest / resources
166
+ # ======================================================================
167
+
168
+
169
+ def configure_gradle(project_dir: Path, config: AppConfig) -> None:
170
+ """Write identity, SDK levels, ABIs, Python version, and signing.
171
+
172
+ Args:
173
+ project_dir: The staged Android project root.
174
+ config: The validated app configuration.
175
+ """
176
+ gradle_path = project_dir / "app" / "build.gradle"
177
+ content = gradle_path.read_text(encoding="utf-8")
178
+
179
+ content = re.sub(r"versionCode\s+\d+", f"versionCode {config.build}", content)
180
+ content = re.sub(r'versionName\s+"[^"]*"', f'versionName "{config.version}"', content)
181
+ content = re.sub(r"minSdk\s+\d+", f"minSdk {config.android.min_sdk}", content)
182
+ content = re.sub(r"targetSdk\s+\d+", f"targetSdk {config.android.target_sdk}", content)
183
+ content = re.sub(r"compileSdk\s+\d+", f"compileSdk {config.android.compile_sdk}", content)
184
+ content = re.sub(r'version\s+"3\.\d+"', f'version "{config.python_version}"', content)
185
+
186
+ abi_csv = ", ".join(f'"{abi}"' for abi in config.android.abi_filters)
187
+ content = re.sub(r"abiFilters[^\n]*", f"abiFilters {abi_csv}", content)
188
+
189
+ if config.android.signing.is_configured:
190
+ content = _inject_signing(content, config)
191
+
192
+ gradle_path.write_text(content, encoding="utf-8")
193
+
194
+
195
+ def _inject_signing(content: str, config: AppConfig) -> str:
196
+ signing = config.android.signing
197
+ assert signing.keystore is not None and signing.key_alias is not None
198
+ keystore_path = config.resolve_path(signing.keystore).as_posix()
199
+ block = (
200
+ " signingConfigs {\n"
201
+ " release {\n"
202
+ f" storeFile file('{keystore_path}')\n"
203
+ f' storePassword System.getenv("{signing.store_password_env}")\n'
204
+ f" keyAlias '{signing.key_alias}'\n"
205
+ f' keyPassword System.getenv("{signing.key_password_env}")\n'
206
+ " }\n"
207
+ " }\n"
208
+ )
209
+ if "signingConfigs {" not in content:
210
+ content = content.replace(" buildTypes {", block + " buildTypes {", 1)
211
+ if "signingConfig signingConfigs.release" not in content:
212
+ content = content.replace(
213
+ " release {\n minifyEnabled false",
214
+ " release {\n signingConfig signingConfigs.release\n minifyEnabled false",
215
+ 1,
216
+ )
217
+ return content
218
+
219
+
220
+ def configure_settings_gradle(project_dir: Path, config: AppConfig) -> None:
221
+ """Update ``rootProject.name`` to the configured project name.
222
+
223
+ Args:
224
+ project_dir: The staged Android project root.
225
+ config: The validated app configuration.
226
+ """
227
+ settings_path = project_dir / "settings.gradle"
228
+ if not settings_path.is_file():
229
+ return
230
+ content = settings_path.read_text(encoding="utf-8")
231
+ safe_name = re.sub(r"[^A-Za-z0-9_]", "_", config.name) or "app"
232
+ content = re.sub(r'rootProject\.name\s*=\s*"[^"]*"', f'rootProject.name = "{safe_name}"', content)
233
+ settings_path.write_text(content, encoding="utf-8")
234
+
235
+
236
+ def configure_strings(project_dir: Path, config: AppConfig) -> None:
237
+ """Set the ``app_name`` string resource to the display name.
238
+
239
+ Args:
240
+ project_dir: The staged Android project root.
241
+ config: The validated app configuration.
242
+ """
243
+ strings_path = project_dir / "app" / "src" / "main" / "res" / "values" / "strings.xml"
244
+ if not strings_path.is_file():
245
+ return
246
+ content = strings_path.read_text(encoding="utf-8")
247
+ escaped = _xml_escape(config.display_name)
248
+ content = re.sub(
249
+ r'(<string name="app_name">)(.*?)(</string>)',
250
+ rf"\g<1>{escaped}\g<3>",
251
+ content,
252
+ flags=re.DOTALL,
253
+ )
254
+ strings_path.write_text(content, encoding="utf-8")
255
+
256
+
257
+ def configure_manifest(project_dir: Path, config: AppConfig) -> None:
258
+ """Inject ``<uses-permission>`` entries and the launch orientation.
259
+
260
+ Args:
261
+ project_dir: The staged Android project root.
262
+ config: The validated app configuration.
263
+ """
264
+ manifest_path = project_dir / "app" / "src" / "main" / "AndroidManifest.xml"
265
+ content = manifest_path.read_text(encoding="utf-8")
266
+
267
+ permissions = config.resolved_permissions().android_permissions
268
+ if permissions:
269
+ lines = "".join(f' <uses-permission android:name="{name}" />\n' for name in permissions)
270
+ content = content.replace(" <application", f"{lines}\n <application", 1)
271
+
272
+ orientation_attr = _ANDROID_ORIENTATION.get(config.orientation)
273
+ if orientation_attr:
274
+ content = content.replace(
275
+ ' android:exported="true">',
276
+ f' android:exported="true"\n android:screenOrientation="{orientation_attr}">',
277
+ 1,
278
+ )
279
+
280
+ manifest_path.write_text(content, encoding="utf-8")
281
+
282
+
283
+ _ANDROID_ORIENTATION = {
284
+ "portrait": "portrait",
285
+ "landscape": "sensorLandscape",
286
+ }
287
+
288
+
289
+ def write_requirements(project_dir: Path, config: AppConfig) -> None:
290
+ """Generate ``app/requirements.txt`` from ``[requirements].packages``.
291
+
292
+ Chaquopy installs from this file (referenced by ``build.gradle``).
293
+
294
+ Args:
295
+ project_dir: The staged Android project root.
296
+ config: The validated app configuration.
297
+ """
298
+ requirements_path = project_dir / "app" / "requirements.txt"
299
+ body = "\n".join(config.requirements)
300
+ requirements_path.write_text(body + ("\n" if body else ""), encoding="utf-8")
301
+
302
+
303
+ # ======================================================================
304
+ # Branding (icons + splash)
305
+ # ======================================================================
306
+
307
+
308
+ def _apply_branding(project_dir: Path, config: AppConfig, emit: Logger) -> None:
309
+ res_dir = project_dir / "app" / "src" / "main" / "res"
310
+ icon_path = config.resolve_path(config.icon) if config.icon else None
311
+ splash_path = config.resolve_path(config.splash) if config.splash else None
312
+
313
+ if icon_path and icons.has_source(icon_path):
314
+ if icons.generate_android_icons(icon_path, res_dir):
315
+ emit("Generated Android launcher icons.")
316
+ else:
317
+ emit("Skipping Android icons: Pillow not installed (pip install 'pythonnative[build]').")
318
+
319
+ if splash_path and icons.has_source(splash_path):
320
+ if icons.pillow_available():
321
+ configure_splash(project_dir, config, splash_path)
322
+ emit("Configured Android splash screen.")
323
+ else:
324
+ emit("Skipping Android splash: Pillow not installed (pip install 'pythonnative[build]').")
325
+
326
+
327
+ def configure_splash(project_dir: Path, config: AppConfig, splash_path: Path) -> None:
328
+ """Wire up an Android 12+ splash screen from the splash asset.
329
+
330
+ Adds the ``androidx.core:core-splashscreen`` dependency, a
331
+ ``Theme.App.Starting`` splash theme (with the splash background color
332
+ and centered icon), installs the splash in ``MainActivity``, and
333
+ points the launcher activity at the splash theme.
334
+
335
+ Args:
336
+ project_dir: The staged Android project root.
337
+ config: The validated app configuration.
338
+ splash_path: Path to the source splash image.
339
+ """
340
+ res_dir = project_dir / "app" / "src" / "main" / "res"
341
+ background = icons.dominant_background_color(splash_path) or "#FFFFFF"
342
+
343
+ icons.generate_android_splash_icon(splash_path, res_dir / "drawable-xxxhdpi" / "pn_splash_icon.png")
344
+
345
+ _upsert_color(res_dir / "values" / "colors.xml", "pn_splash_background", background)
346
+
347
+ splash_style = (
348
+ ' <style name="Theme.App.Starting" parent="Theme.SplashScreen">\n'
349
+ ' <item name="windowSplashScreenBackground">@color/pn_splash_background</item>\n'
350
+ ' <item name="windowSplashScreenAnimatedIcon">@drawable/pn_splash_icon</item>\n'
351
+ ' <item name="postSplashScreenTheme">@style/Theme.Android_template</item>\n'
352
+ " </style>\n"
353
+ )
354
+ for themes in (res_dir / "values" / "themes.xml", res_dir / "values-night" / "themes.xml"):
355
+ _insert_style(themes, splash_style)
356
+
357
+ _add_gradle_dependency(
358
+ project_dir / "app" / "build.gradle",
359
+ "implementation 'androidx.core:core-splashscreen:1.0.1'",
360
+ )
361
+ _install_splash_in_activity(project_dir, config)
362
+
363
+ # Point the app theme at the splash theme; ``installSplashScreen()`` swaps
364
+ # to ``postSplashScreenTheme`` (the original theme) right after launch.
365
+ manifest_path = project_dir / "app" / "src" / "main" / "AndroidManifest.xml"
366
+ manifest = manifest_path.read_text(encoding="utf-8")
367
+ manifest = manifest.replace(
368
+ 'android:theme="@style/Theme.Android_template"',
369
+ 'android:theme="@style/Theme.App.Starting"',
370
+ 1,
371
+ )
372
+ manifest_path.write_text(manifest, encoding="utf-8")
373
+
374
+
375
+ def _install_splash_in_activity(project_dir: Path, config: AppConfig) -> None:
376
+ activity = project_dir / "app" / "src" / "main" / "java" / config.android_package_path / "MainActivity.kt"
377
+ if not activity.is_file():
378
+ return
379
+ content = activity.read_text(encoding="utf-8")
380
+ if "installSplashScreen" in content:
381
+ return
382
+ content = content.replace(
383
+ "import androidx.appcompat.app.AppCompatActivity",
384
+ "import androidx.appcompat.app.AppCompatActivity\n"
385
+ "import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen",
386
+ 1,
387
+ )
388
+ content = content.replace(
389
+ " super.onCreate(savedInstanceState)",
390
+ " installSplashScreen()\n super.onCreate(savedInstanceState)",
391
+ 1,
392
+ )
393
+ activity.write_text(content, encoding="utf-8")
394
+
395
+
396
+ # ======================================================================
397
+ # Python source staging
398
+ # ======================================================================
399
+
400
+
401
+ def stage_python_sources(
402
+ project_dir: Path,
403
+ config: AppConfig,
404
+ *,
405
+ dev_lib_root: Optional[Path] = None,
406
+ ) -> Path:
407
+ """Copy the user's ``app/`` and (optionally) the in-repo library.
408
+
409
+ Args:
410
+ project_dir: The staged Android project root.
411
+ config: The validated app configuration.
412
+ dev_lib_root: Path to an in-repo ``pythonnative`` package to
413
+ bundle, or ``None``.
414
+
415
+ Returns:
416
+ The Chaquopy Python source root (``app/src/main/python``).
417
+ """
418
+ python_root = project_dir / "app" / "src" / "main" / "python"
419
+ python_root.mkdir(parents=True, exist_ok=True)
420
+
421
+ app_src = config.project_root / "app"
422
+ if app_src.is_dir():
423
+ shutil.copytree(app_src, python_root / "app", dirs_exist_ok=True)
424
+
425
+ if dev_lib_root and dev_lib_root.is_dir():
426
+ shutil.copytree(
427
+ dev_lib_root,
428
+ python_root / "pythonnative",
429
+ dirs_exist_ok=True,
430
+ ignore=LIB_IGNORE,
431
+ )
432
+
433
+ return python_root
434
+
435
+
436
+ LIB_IGNORE = shutil.ignore_patterns("templates", "__pycache__", "*.pyc", "*.pyo")
437
+ """Ignore rules for bundling the ``pythonnative`` package (skips templates)."""
438
+
439
+
440
+ # ======================================================================
441
+ # Small XML/text helpers
442
+ # ======================================================================
443
+
444
+
445
+ def _xml_escape(value: str) -> str:
446
+ return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
447
+
448
+
449
+ def _upsert_color(colors_path: Path, name: str, value: str) -> None:
450
+ if not colors_path.is_file():
451
+ colors_path.parent.mkdir(parents=True, exist_ok=True)
452
+ colors_path.write_text(
453
+ '<?xml version="1.0" encoding="utf-8"?>\n<resources>\n</resources>\n',
454
+ encoding="utf-8",
455
+ )
456
+ content = colors_path.read_text(encoding="utf-8")
457
+ entry = f' <color name="{name}">{value}</color>\n'
458
+ if f'name="{name}"' in content:
459
+ content = re.sub(
460
+ rf'(<color name="{re.escape(name)}">)(.*?)(</color>)',
461
+ rf"\g<1>{value}\g<3>",
462
+ content,
463
+ )
464
+ else:
465
+ content = content.replace("</resources>", f"{entry}</resources>", 1)
466
+ colors_path.write_text(content, encoding="utf-8")
467
+
468
+
469
+ def _insert_style(themes_path: Path, style_block: str) -> None:
470
+ if not themes_path.is_file():
471
+ return
472
+ content = themes_path.read_text(encoding="utf-8")
473
+ if "Theme.App.Starting" in content:
474
+ return
475
+ content = content.replace("</resources>", f"{style_block}</resources>", 1)
476
+ themes_path.write_text(content, encoding="utf-8")
477
+
478
+
479
+ def _add_gradle_dependency(gradle_path: Path, dependency: str) -> None:
480
+ content = gradle_path.read_text(encoding="utf-8")
481
+ if dependency in content:
482
+ return
483
+ content = content.replace("dependencies {\n", f"dependencies {{\n {dependency}\n", 1)
484
+ gradle_path.write_text(content, encoding="utf-8")
485
+
486
+
487
+ def collect_logcat_filters() -> List[str]:
488
+ """Return the logcat tag filters used when streaming device logs.
489
+
490
+ Returns:
491
+ A list of ``tag:level`` filter specs ending with ``*:S`` to
492
+ silence everything else.
493
+ """
494
+ return [
495
+ "python.stdout:V",
496
+ "python.stderr:V",
497
+ "MainActivity:V",
498
+ "ScreenFragment:V",
499
+ "Navigator:V",
500
+ "PythonNative:V",
501
+ "AndroidRuntime:E",
502
+ "System.err:W",
503
+ "*:S",
504
+ ]