pythonnative 0.20.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.
@@ -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
+ ]