pythonnative 0.20.0__py3-none-any.whl → 0.22.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/__init__.py +14 -3
- pythonnative/animated.py +420 -135
- pythonnative/cli/pn.py +450 -956
- pythonnative/components.py +519 -235
- pythonnative/events.py +210 -0
- pythonnative/gestures.py +875 -0
- pythonnative/layout.py +463 -149
- pythonnative/mutations.py +130 -0
- pythonnative/native_views/__init__.py +161 -97
- pythonnative/native_views/android.py +1050 -1124
- pythonnative/native_views/base.py +108 -18
- pythonnative/native_views/desktop.py +460 -417
- pythonnative/native_views/ios.py +1918 -1916
- pythonnative/project/__init__.py +68 -0
- pythonnative/project/android.py +504 -0
- pythonnative/project/builder.py +555 -0
- pythonnative/project/config.py +642 -0
- pythonnative/project/doctor.py +233 -0
- pythonnative/project/icons.py +247 -0
- pythonnative/project/ios.py +344 -0
- pythonnative/project/permissions.py +343 -0
- pythonnative/project/runtime_assets.py +272 -0
- pythonnative/reconciler.py +540 -470
- pythonnative/screen.py +5 -2
- pythonnative/sdk/_components.py +2 -2
- pythonnative/templates/android_template/app/build.gradle +2 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
|
@@ -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``) via ``getPackageName()``, so the relocation needs no
|
|
11
|
+
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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
|
+
]
|