kivyforge 3.0.0.dev0__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.
- kivyforge/__init__.py +9 -0
- kivyforge/artifacts/__init__.py +41 -0
- kivyforge/artifacts/cache.py +81 -0
- kivyforge/artifacts/collect.py +354 -0
- kivyforge/artifacts/download.py +125 -0
- kivyforge/artifacts/frameworks.py +240 -0
- kivyforge/artifacts/native_stage_util.py +346 -0
- kivyforge/artifacts/runtime.py +65 -0
- kivyforge/artifacts/verify.py +45 -0
- kivyforge/artifacts/wheels.py +89 -0
- kivyforge/bundle/__init__.py +1 -0
- kivyforge/bundle/pycompile.py +161 -0
- kivyforge/cli/__init__.py +52 -0
- kivyforge/cli/_common.py +62 -0
- kivyforge/cli/_platform.py +84 -0
- kivyforge/cli/build.py +111 -0
- kivyforge/cli/clean.py +135 -0
- kivyforge/cli/doctor.py +52 -0
- kivyforge/cli/init.py +612 -0
- kivyforge/cli/init_writer.py +716 -0
- kivyforge/cli/lock.py +261 -0
- kivyforge/cli/open_cmd.py +15 -0
- kivyforge/cli/package.py +134 -0
- kivyforge/cli/run.py +162 -0
- kivyforge/cli/status.py +20 -0
- kivyforge/cli/upgrade.py +314 -0
- kivyforge/config/__init__.py +46 -0
- kivyforge/config/errors.py +43 -0
- kivyforge/config/icons.py +60 -0
- kivyforge/config/loader.py +3079 -0
- kivyforge/config/model.py +817 -0
- kivyforge/doctor/__init__.py +19 -0
- kivyforge/doctor/checks_common.py +95 -0
- kivyforge/doctor/probe.py +357 -0
- kivyforge/doctor/result.py +41 -0
- kivyforge/lock/__init__.py +42 -0
- kivyforge/lock/_pip_shim.py +63 -0
- kivyforge/lock/find_links.py +182 -0
- kivyforge/lock/model.py +69 -0
- kivyforge/lock/pep751.py +111 -0
- kivyforge/lock/reader.py +30 -0
- kivyforge/lock/resolver.py +124 -0
- kivyforge/lock/wheelruntime/__init__.py +70 -0
- kivyforge/lock/wheelruntime/builder.py +280 -0
- kivyforge/lock/wheelruntime/model.py +94 -0
- kivyforge/lock/wheelruntime/native_binaries.py +127 -0
- kivyforge/lock/wheelruntime/pbs_github.py +133 -0
- kivyforge/lock/wheelruntime/profile.py +104 -0
- kivyforge/lock/wheelruntime/resolver.py +248 -0
- kivyforge/lock/wheelruntime/runtime.py +161 -0
- kivyforge/lock/wheelruntime/serialize.py +245 -0
- kivyforge/platforms/__init__.py +114 -0
- kivyforge/platforms/android/__init__.py +140 -0
- kivyforge/platforms/android/adb.py +215 -0
- kivyforge/platforms/android/bootstrap/__init__.py +7 -0
- kivyforge/platforms/android/bootstrap/contract.py +166 -0
- kivyforge/platforms/android/bootstrap/render.py +244 -0
- kivyforge/platforms/android/bootstrap/templates/androidtest/KivyforgeContractTest.java +76 -0
- kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivy_bootstrap.py +50 -0
- kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivyforge_bootstrap.py +54 -0
- kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivyforge_selftest.py +97 -0
- kivyforge/platforms/android/bootstrap/templates/cpp/CMakeLists.txt +20 -0
- kivyforge/platforms/android/bootstrap/templates/cpp/main.c +150 -0
- kivyforge/platforms/android/bootstrap/templates/java/org/jnius/NativeInvocationHandler.java +48 -0
- kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonActivity.java +75 -0
- kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonBundle.java +132 -0
- kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonService.java +189 -0
- kivyforge/platforms/android/bootstrap/templates/java/org/renpy/android/Hardware.java +276 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/LICENSE-SDL.txt +18 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/SDL_REVISION.txt +3 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDevice.java +22 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceBLESteamController.java +650 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceManager.java +698 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceUSB.java +309 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDL.java +90 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLActivity.java +2120 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLAudioManager.java +514 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLControllerManager.java +856 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLSurface.java +405 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/LICENSE-SDL.txt +18 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/SDL_REVISION.txt +3 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDevice.java +21 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceBLESteamController.java +829 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceManager.java +698 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceUSB.java +354 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDL.java +90 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLActivity.java +2240 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLAudioManager.java +126 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLControllerManager.java +1010 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLDummyEdit.java +66 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLInputConnection.java +136 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLSensorManager.java +73 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLSurface.java +464 -0
- kivyforge/platforms/android/cli.py +1023 -0
- kivyforge/platforms/android/doctor.py +899 -0
- kivyforge/platforms/android/elf.py +148 -0
- kivyforge/platforms/android/generate/__init__.py +1 -0
- kivyforge/platforms/android/generate/manifest.py +261 -0
- kivyforge/platforms/android/generate/project.py +462 -0
- kivyforge/platforms/android/generate/services.py +187 -0
- kivyforge/platforms/android/gradle_wrapper/NOTICE +3 -0
- kivyforge/platforms/android/gradle_wrapper/SHA256SUMS +1 -0
- kivyforge/platforms/android/gradle_wrapper/gradle-wrapper.jar +0 -0
- kivyforge/platforms/android/gradle_wrapper/gradlew +164 -0
- kivyforge/platforms/android/gradle_wrapper/gradlew.bat +90 -0
- kivyforge/platforms/android/gradlew.py +60 -0
- kivyforge/platforms/android/icons.py +237 -0
- kivyforge/platforms/android/lock/__init__.py +1 -0
- kivyforge/platforms/android/lock/archives.py +109 -0
- kivyforge/platforms/android/lock/builder.py +387 -0
- kivyforge/platforms/android/lock/markers.py +71 -0
- kivyforge/platforms/android/lock/maven.py +252 -0
- kivyforge/platforms/android/lock/model.py +113 -0
- kivyforge/platforms/android/lock/python_meta.py +199 -0
- kivyforge/platforms/android/lock/reader.py +206 -0
- kivyforge/platforms/android/lock/resolver.py +276 -0
- kivyforge/platforms/android/lock/writer.py +121 -0
- kivyforge/platforms/android/policy.py +214 -0
- kivyforge/platforms/android/signing.py +145 -0
- kivyforge/platforms/android/smoke.py +43 -0
- kivyforge/platforms/android/splash.py +293 -0
- kivyforge/platforms/android/stage/__init__.py +1 -0
- kivyforge/platforms/android/stage/bundle.py +295 -0
- kivyforge/platforms/android/stage/jnilibs.py +186 -0
- kivyforge/platforms/android/stage/runtime.py +61 -0
- kivyforge/platforms/android/stage/wheels.py +106 -0
- kivyforge/platforms/android/toolchain.py +29 -0
- kivyforge/platforms/base.py +157 -0
- kivyforge/platforms/ios/__init__.py +129 -0
- kivyforge/platforms/ios/assets.py +193 -0
- kivyforge/platforms/ios/buildsettings.py +128 -0
- kivyforge/platforms/ios/cli.py +543 -0
- kivyforge/platforms/ios/doctor.py +410 -0
- kivyforge/platforms/ios/entitlements.py +34 -0
- kivyforge/platforms/ios/generator.py +318 -0
- kivyforge/platforms/ios/lock/__init__.py +71 -0
- kivyforge/platforms/ios/lock/builder.py +393 -0
- kivyforge/platforms/ios/lock/markers.py +69 -0
- kivyforge/platforms/ios/lock/model.py +95 -0
- kivyforge/platforms/ios/lock/python_meta.py +187 -0
- kivyforge/platforms/ios/lock/reader.py +191 -0
- kivyforge/platforms/ios/lock/resolver.py +304 -0
- kivyforge/platforms/ios/lock/spm.py +217 -0
- kivyforge/platforms/ios/lock/writer.py +114 -0
- kivyforge/platforms/ios/lock/xcframework.py +188 -0
- kivyforge/platforms/ios/materialize.py +59 -0
- kivyforge/platforms/ios/plist.py +91 -0
- kivyforge/platforms/ios/privacy.py +39 -0
- kivyforge/platforms/ios/skeleton.py +165 -0
- kivyforge/platforms/ios/sources.py +63 -0
- kivyforge/platforms/ios/staging.py +225 -0
- kivyforge/platforms/ios/swift_packages.py +324 -0
- kivyforge/platforms/ios/templates/kivyforge_bootstrap.h +34 -0
- kivyforge/platforms/ios/templates/kivyforge_bootstrap.m +146 -0
- kivyforge/platforms/ios/templates/main.m +15 -0
- kivyforge/platforms/ios/templates/main_config.h.in +12 -0
- kivyforge/platforms/ios/xcframeworks.py +194 -0
- kivyforge/platforms/ios/xcode/__init__.py +76 -0
- kivyforge/platforms/ios/xcode/commands.py +296 -0
- kivyforge/platforms/ios/xcode/runner.py +316 -0
- kivyforge/platforms/linux/__init__.py +120 -0
- kivyforge/platforms/linux/appimage.py +169 -0
- kivyforge/platforms/linux/bundle.py +235 -0
- kivyforge/platforms/linux/cli.py +232 -0
- kivyforge/platforms/linux/desktop.py +71 -0
- kivyforge/platforms/linux/doctor.py +438 -0
- kivyforge/platforms/linux/elftools.py +95 -0
- kivyforge/platforms/linux/icons.py +129 -0
- kivyforge/platforms/linux/launcher.py +107 -0
- kivyforge/platforms/linux/lock/__init__.py +126 -0
- kivyforge/platforms/linux/lock/profile.py +254 -0
- kivyforge/platforms/linux/lock/runtime.py +75 -0
- kivyforge/platforms/linux/native_stage.py +47 -0
- kivyforge/platforms/linux/runtime_stage.py +107 -0
- kivyforge/platforms/linux/wheels_stage.py +103 -0
- kivyforge/platforms/macos/__init__.py +121 -0
- kivyforge/platforms/macos/bundle.py +255 -0
- kivyforge/platforms/macos/cli.py +269 -0
- kivyforge/platforms/macos/doctor.py +480 -0
- kivyforge/platforms/macos/icns.py +75 -0
- kivyforge/platforms/macos/launcher.py +118 -0
- kivyforge/platforms/macos/lock/__init__.py +112 -0
- kivyforge/platforms/macos/lock/profile.py +119 -0
- kivyforge/platforms/macos/lock/runtime.py +58 -0
- kivyforge/platforms/macos/machotools.py +138 -0
- kivyforge/platforms/macos/native_stage.py +47 -0
- kivyforge/platforms/macos/notarize.py +143 -0
- kivyforge/platforms/macos/plist.py +37 -0
- kivyforge/platforms/macos/runtime_stage.py +110 -0
- kivyforge/platforms/macos/signing.py +78 -0
- kivyforge/platforms/macos/wheels_stage.py +108 -0
- kivyforge/platforms/windows/__init__.py +133 -0
- kivyforge/platforms/windows/assets.py +76 -0
- kivyforge/platforms/windows/bundle.py +298 -0
- kivyforge/platforms/windows/cli.py +314 -0
- kivyforge/platforms/windows/doctor.py +563 -0
- kivyforge/platforms/windows/fsswap.py +87 -0
- kivyforge/platforms/windows/icons.py +64 -0
- kivyforge/platforms/windows/launcher/__init__.py +193 -0
- kivyforge/platforms/windows/launcher/build_launcher.py +507 -0
- kivyforge/platforms/windows/launcher/launcher.c +379 -0
- kivyforge/platforms/windows/lock/__init__.py +112 -0
- kivyforge/platforms/windows/lock/profile.py +101 -0
- kivyforge/platforms/windows/lock/runtime.py +64 -0
- kivyforge/platforms/windows/naming.py +52 -0
- kivyforge/platforms/windows/native_stage.py +66 -0
- kivyforge/platforms/windows/petools.py +98 -0
- kivyforge/platforms/windows/rcedit.py +116 -0
- kivyforge/platforms/windows/runtime_stage.py +221 -0
- kivyforge/platforms/windows/signing.py +168 -0
- kivyforge/platforms/windows/vendor/LICENSE.rcedit +29 -0
- kivyforge/platforms/windows/vendor/NOTICE +17 -0
- kivyforge/platforms/windows/vendor/SHA256SUMS +4 -0
- kivyforge/platforms/windows/vendor/TOOLSET.txt +4 -0
- kivyforge/platforms/windows/vendor/__init__.py +12 -0
- kivyforge/platforms/windows/vendor/fetch_rcedit.py +97 -0
- kivyforge/platforms/windows/vendor/launcher-amd64.exe +0 -0
- kivyforge/platforms/windows/vendor/rcedit-x64.exe +0 -0
- kivyforge/platforms/windows/wheels_stage.py +139 -0
- kivyforge-3.0.0.dev0.dist-info/METADATA +609 -0
- kivyforge-3.0.0.dev0.dist-info/RECORD +225 -0
- kivyforge-3.0.0.dev0.dist-info/WHEEL +5 -0
- kivyforge-3.0.0.dev0.dist-info/entry_points.txt +3 -0
- kivyforge-3.0.0.dev0.dist-info/licenses/LICENSE +21 -0
- kivyforge-3.0.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
"""``kivyforge build`` — Android behavior (android/06 steps 1-8).
|
|
2
|
+
|
|
3
|
+
Steps 1-7 (verify, collect, stage, generate) always run; step 8 (Gradle
|
|
4
|
+
``assembleDebug``) only with ``--debug``. Signed releases are ``package``'s
|
|
5
|
+
job (Phase 6).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from kivyforge.artifacts.download import fetch_artifact
|
|
16
|
+
from kivyforge.config import ConfigError, load_config
|
|
17
|
+
from kivyforge.config.model import AndroidConfig, Config
|
|
18
|
+
from kivyforge.lock.reader import LockError, is_in_sync
|
|
19
|
+
|
|
20
|
+
from . import AndroidBuildError
|
|
21
|
+
from .bootstrap.contract import (
|
|
22
|
+
ContractError,
|
|
23
|
+
check_pyjnius_contract,
|
|
24
|
+
check_sdl_generation,
|
|
25
|
+
check_sdl_glue_contract,
|
|
26
|
+
sdl_library_name,
|
|
27
|
+
)
|
|
28
|
+
from .bootstrap.render import (
|
|
29
|
+
androidtest_files,
|
|
30
|
+
finder_source,
|
|
31
|
+
kivy_bootstrap_source,
|
|
32
|
+
python_stem,
|
|
33
|
+
render_bootstrap,
|
|
34
|
+
selftest_source,
|
|
35
|
+
)
|
|
36
|
+
from .generate.manifest import generate_manifest
|
|
37
|
+
from .generate.project import (
|
|
38
|
+
ABI_TO_TRIPLET,
|
|
39
|
+
ProjectGenError,
|
|
40
|
+
android_abi,
|
|
41
|
+
stage_gradle_wrapper,
|
|
42
|
+
write_app_build_gradle,
|
|
43
|
+
write_gradle_pins,
|
|
44
|
+
write_gradle_properties,
|
|
45
|
+
write_resources,
|
|
46
|
+
write_root_build_gradle,
|
|
47
|
+
write_settings_gradle,
|
|
48
|
+
)
|
|
49
|
+
from .generate.services import render_service_classes, render_service_contract_test
|
|
50
|
+
from .gradlew import GradleError, run_gradle
|
|
51
|
+
from .lock import reader as lock_reader
|
|
52
|
+
from .lock.model import AndroidLockfile
|
|
53
|
+
from .stage.bundle import BUNDLE_DIRNAME, BundleError, assemble_bundle
|
|
54
|
+
from .stage.jnilibs import (
|
|
55
|
+
JniLibsError,
|
|
56
|
+
JniLibsStager,
|
|
57
|
+
stage_runtime_libs,
|
|
58
|
+
stage_site_packages_extensions,
|
|
59
|
+
stage_wheel_libs_dir,
|
|
60
|
+
)
|
|
61
|
+
from .stage.runtime import RuntimeStageError, extract_runtime, stdlib_dir
|
|
62
|
+
from .stage.wheels import WheelStageError, install_wheels, select_wheel
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def project_dir_for(project_root: Path, config: Config) -> Path:
|
|
66
|
+
return project_root / f"{config.app_slug}-android"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _prune_stale(directory: Path, pattern: str, *, keep: set[str]) -> None:
|
|
70
|
+
"""Delete generated sources that this build no longer produces.
|
|
71
|
+
|
|
72
|
+
The project dir is incremental, so a class generated for a service that has
|
|
73
|
+
since been removed from pyproject.toml would keep being compiled into the
|
|
74
|
+
APK — and, for the service probe, would stop the test source compiling at
|
|
75
|
+
all once its target class is gone.
|
|
76
|
+
"""
|
|
77
|
+
if not directory.is_dir():
|
|
78
|
+
return
|
|
79
|
+
for path in directory.glob(pattern):
|
|
80
|
+
if path.name not in keep:
|
|
81
|
+
path.unlink()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _setting_applies(value: bool | str, *, release: bool) -> bool:
|
|
85
|
+
"""A ``build_settings`` tri-state resolved for the build being produced."""
|
|
86
|
+
if isinstance(value, bool):
|
|
87
|
+
return value
|
|
88
|
+
return release # ANDROID_RELEASE_ONLY
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _target_minor(python_version: str) -> tuple[int, int]:
|
|
92
|
+
parts = python_version.split(".")[:2]
|
|
93
|
+
return (int(parts[0].split("rc")[0]), int(parts[1].split("rc")[0]))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _byte_compile_interpreter(python_version: str) -> tuple[str, ...] | None:
|
|
97
|
+
"""Find an interpreter that can write ``.pyc`` for the target runtime.
|
|
98
|
+
|
|
99
|
+
A ``.pyc`` is keyed to one exact CPython magic number, and that number is
|
|
100
|
+
frozen at each ``3.x.0`` — so any ``3.x.z`` will do, but a different minor
|
|
101
|
+
version will not: its output is silently ignored when the source ships
|
|
102
|
+
alongside, and fails outright once the source is stripped.
|
|
103
|
+
|
|
104
|
+
kivyforge is installed under whatever Python the user has, which is usually
|
|
105
|
+
*not* the version being shipped to the device, so this looks for a matching
|
|
106
|
+
one rather than insisting on being run under it. Returns ``()`` for "this
|
|
107
|
+
interpreter", an argv prefix for another one, or ``None`` when there is no
|
|
108
|
+
match to be found.
|
|
109
|
+
"""
|
|
110
|
+
import os
|
|
111
|
+
import sys
|
|
112
|
+
|
|
113
|
+
target = _target_minor(python_version)
|
|
114
|
+
if sys.version_info[:2] == target:
|
|
115
|
+
return ()
|
|
116
|
+
tag = f"{target[0]}.{target[1]}"
|
|
117
|
+
candidates: list[tuple[str, ...]] = []
|
|
118
|
+
if os.name == "nt":
|
|
119
|
+
# The PEP 397 launcher is the reliable way to reach a specific version
|
|
120
|
+
# on Windows; versioned executables are usually not on PATH there.
|
|
121
|
+
candidates.append(("py", f"-{tag}"))
|
|
122
|
+
candidates += [(f"python{tag}",), (f"python{target[0]}",), ("python",)]
|
|
123
|
+
for candidate in candidates:
|
|
124
|
+
if _reports_version(candidate, tag):
|
|
125
|
+
return candidate
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _reports_version(argv: tuple[str, ...], tag: str) -> bool:
|
|
130
|
+
import subprocess
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
proc = subprocess.run(
|
|
134
|
+
[*argv, "-c", "import sys;print('%d.%d' % sys.version_info[:2])"],
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
timeout=30,
|
|
138
|
+
)
|
|
139
|
+
except (OSError, subprocess.SubprocessError):
|
|
140
|
+
return False
|
|
141
|
+
return proc.returncode == 0 and proc.stdout.strip() == tag
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _resolve_byte_compile(
|
|
145
|
+
android: AndroidConfig, *, python_version: str, debug: bool
|
|
146
|
+
) -> tuple[tuple[str, ...] | None, bool]:
|
|
147
|
+
"""Decide whether to byte-compile, and with which interpreter.
|
|
148
|
+
|
|
149
|
+
Returns ``(compiler_argv_or_None, strip_source)``; ``None`` means do not
|
|
150
|
+
byte-compile. ``byte_compile = true`` is read as "I insist", so a missing
|
|
151
|
+
interpreter is an error; the default ``"release"`` is read as "when it makes
|
|
152
|
+
sense", so it degrades to shipping source with a warning rather than
|
|
153
|
+
breaking a build the user never configured.
|
|
154
|
+
"""
|
|
155
|
+
settings = android.build_settings
|
|
156
|
+
if not _setting_applies(settings.byte_compile, release=not debug):
|
|
157
|
+
return None, False
|
|
158
|
+
compiler = _byte_compile_interpreter(python_version)
|
|
159
|
+
if compiler is None:
|
|
160
|
+
target = ".".join(str(p) for p in _target_minor(python_version))
|
|
161
|
+
message = (
|
|
162
|
+
f"this project ships CPython {target}, and no CPython {target} was "
|
|
163
|
+
"found to byte-compile with (a .pyc is only loadable by the exact "
|
|
164
|
+
"CPython that wrote it)"
|
|
165
|
+
)
|
|
166
|
+
if settings.byte_compile is True:
|
|
167
|
+
raise AndroidBuildError(
|
|
168
|
+
"[tool.kivy.android.build_settings].byte_compile = true but "
|
|
169
|
+
f"{message}.\n"
|
|
170
|
+
f" Install CPython {target} (kivyforge will find it), or set "
|
|
171
|
+
"byte_compile = false."
|
|
172
|
+
)
|
|
173
|
+
click.echo(f"[stage] not byte-compiling: {message}.")
|
|
174
|
+
return None, False
|
|
175
|
+
# Documented as "ignored when byte_compile is off": stripping sources with
|
|
176
|
+
# no .pyc beside them would ship a bundle that imports nothing.
|
|
177
|
+
strip = _setting_applies(settings.strip_source, release=not debug)
|
|
178
|
+
return compiler, strip
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def android_build(
|
|
182
|
+
project_root: Path,
|
|
183
|
+
*,
|
|
184
|
+
debug: bool = False,
|
|
185
|
+
fmt: str = "apk",
|
|
186
|
+
abi: str | None = None,
|
|
187
|
+
no_verify_lock: bool = False,
|
|
188
|
+
no_cache: bool = False,
|
|
189
|
+
signing_config_block: str = "",
|
|
190
|
+
release_signing_config: str | None = None,
|
|
191
|
+
test_build_type: str | None = None,
|
|
192
|
+
) -> Path:
|
|
193
|
+
"""Steps 1-7 (+8 with ``debug``); returns the generated project dir.
|
|
194
|
+
|
|
195
|
+
``debug`` also selects which side of the ``"release"`` build settings the
|
|
196
|
+
staged bundle gets: the payload is baked into ``assets/`` at generate time,
|
|
197
|
+
so the tri-state has to be resolved here rather than per Gradle build type.
|
|
198
|
+
Generating without ``--debug`` stages the release payload, which is what
|
|
199
|
+
``kivyforge package`` then assembles.
|
|
200
|
+
"""
|
|
201
|
+
config, lock = _load(project_root, no_verify_lock=no_verify_lock)
|
|
202
|
+
android = config.android_required
|
|
203
|
+
abis = _select_abis(android, abi)
|
|
204
|
+
|
|
205
|
+
dest = project_dir_for(project_root, config)
|
|
206
|
+
app_main = dest / "app" / "src" / "main"
|
|
207
|
+
|
|
208
|
+
# Step 7 pre-check (hard gate BEFORE any generation): the pyjnius/bootstrap
|
|
209
|
+
# invoke0 matched pair (android/05).
|
|
210
|
+
locked_pyjnius = _locked_version(lock, "pyjnius")
|
|
211
|
+
if locked_pyjnius is not None:
|
|
212
|
+
try:
|
|
213
|
+
check_pyjnius_contract(locked_pyjnius)
|
|
214
|
+
except ContractError as exc:
|
|
215
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
216
|
+
|
|
217
|
+
python_version = lock.python_android[0].version
|
|
218
|
+
stem = python_stem(python_version)
|
|
219
|
+
|
|
220
|
+
# --- Step 2: collect + extract the runtime per targeted ABI ---
|
|
221
|
+
runtime_root = dest / "python-runtime"
|
|
222
|
+
prefixes: dict[str, Path] = {}
|
|
223
|
+
for runtime in lock.python_android:
|
|
224
|
+
if runtime.abi not in abis:
|
|
225
|
+
continue
|
|
226
|
+
click.echo(f"[collect] python.org runtime {runtime.version} ({runtime.abi})")
|
|
227
|
+
tarball = fetch_artifact(
|
|
228
|
+
name=f"python-android-{runtime.abi}",
|
|
229
|
+
sha256=runtime.sha256,
|
|
230
|
+
filename=(runtime.url or runtime.path or "").rsplit("/", 1)[-1],
|
|
231
|
+
url=runtime.url,
|
|
232
|
+
path=runtime.path,
|
|
233
|
+
project_root=project_root,
|
|
234
|
+
no_cache=no_cache,
|
|
235
|
+
)
|
|
236
|
+
triplet = ABI_TO_TRIPLET[runtime.abi]
|
|
237
|
+
prefixes[runtime.abi] = _stage(
|
|
238
|
+
lambda: extract_runtime(Path(tarball), runtime_root / triplet),
|
|
239
|
+
RuntimeStageError,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# --- Step 3: collect .aar/.jar into app/libs ---
|
|
243
|
+
libs_dir = dest / "app" / "libs"
|
|
244
|
+
staged_libs: list[str] = []
|
|
245
|
+
for lib in lock.android_libs:
|
|
246
|
+
click.echo(f"[collect] {lib.kind} {lib.name} {lib.version}")
|
|
247
|
+
archive = fetch_artifact(
|
|
248
|
+
name=lib.name,
|
|
249
|
+
sha256=lib.sha256,
|
|
250
|
+
filename=(lib.url or lib.path or "").rsplit("/", 1)[-1],
|
|
251
|
+
url=lib.url,
|
|
252
|
+
path=lib.path,
|
|
253
|
+
project_root=project_root,
|
|
254
|
+
no_cache=no_cache,
|
|
255
|
+
)
|
|
256
|
+
libs_dir.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
target = libs_dir / Path(archive).name
|
|
258
|
+
shutil.copy2(archive, target)
|
|
259
|
+
staged_libs.append(target.name)
|
|
260
|
+
|
|
261
|
+
# --- Step 4: install wheels per ABI ---
|
|
262
|
+
site_packages: dict[str, Path] = {}
|
|
263
|
+
for abi_name in abis:
|
|
264
|
+
target = dest / "pip-deps" / abi_name
|
|
265
|
+
if target.exists():
|
|
266
|
+
shutil.rmtree(target)
|
|
267
|
+
# The artifact cache stores files as {sha256}-{name}; pip validates
|
|
268
|
+
# wheel FILENAMES, so each fetched wheel is staged under its true name.
|
|
269
|
+
wheel_stage = dest / "pip-deps" / f"_wheels-{abi_name}"
|
|
270
|
+
if wheel_stage.exists():
|
|
271
|
+
shutil.rmtree(wheel_stage)
|
|
272
|
+
wheel_stage.mkdir(parents=True)
|
|
273
|
+
files = []
|
|
274
|
+
for package in lock.packages:
|
|
275
|
+
wheel = select_wheel(package, abi=abi_name, min_sdk=android.min_sdk)
|
|
276
|
+
fetched = Path(
|
|
277
|
+
fetch_artifact(
|
|
278
|
+
name=package.name,
|
|
279
|
+
sha256=wheel.sha256,
|
|
280
|
+
filename=wheel.name,
|
|
281
|
+
url=wheel.url,
|
|
282
|
+
path=wheel.path,
|
|
283
|
+
project_root=project_root,
|
|
284
|
+
no_cache=no_cache,
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
staged = wheel_stage / wheel.name
|
|
288
|
+
if fetched.name != wheel.name:
|
|
289
|
+
shutil.copy2(fetched, staged)
|
|
290
|
+
else:
|
|
291
|
+
staged = fetched
|
|
292
|
+
files.append(staged)
|
|
293
|
+
click.echo(f"[stage] installing {len(files)} wheels for {abi_name}")
|
|
294
|
+
_stage(
|
|
295
|
+
lambda: install_wheels(files, target, python_version=python_version),
|
|
296
|
+
WheelStageError,
|
|
297
|
+
)
|
|
298
|
+
site_packages[abi_name] = target
|
|
299
|
+
|
|
300
|
+
# --- Step 5: jniLibs per ABI (runtime split + wheel exts + .libs) ---
|
|
301
|
+
ext_manifest_json = ""
|
|
302
|
+
for abi_name in abis:
|
|
303
|
+
jni = app_main / "jniLibs" / android_abi(abi_name)
|
|
304
|
+
if jni.exists():
|
|
305
|
+
shutil.rmtree(jni)
|
|
306
|
+
stager = JniLibsStager(dest=jni)
|
|
307
|
+
|
|
308
|
+
def _stage_all() -> None:
|
|
309
|
+
prefix = prefixes[abi_name]
|
|
310
|
+
stage_runtime_libs(stager, prefix / "lib", python_stem=stem)
|
|
311
|
+
sp = site_packages[abi_name]
|
|
312
|
+
for package in lock.packages:
|
|
313
|
+
wheel_name = package.name
|
|
314
|
+
libs = sp / ".libs"
|
|
315
|
+
if libs.is_dir():
|
|
316
|
+
stage_wheel_libs_dir(stager, libs, wheel_name=wheel_name)
|
|
317
|
+
break # one flat .libs per staging tree
|
|
318
|
+
stage_site_packages_extensions(stager, sp, wheel_name="site-packages")
|
|
319
|
+
|
|
320
|
+
_stage(_stage_all, JniLibsError)
|
|
321
|
+
manifest = stager.manifest()
|
|
322
|
+
import json as _json
|
|
323
|
+
|
|
324
|
+
ext_manifest_json = _json.dumps(manifest, indent=1, sort_keys=True) + "\n"
|
|
325
|
+
click.echo(
|
|
326
|
+
f"[stage] jniLibs/{android_abi(abi_name)}: "
|
|
327
|
+
f"{len(manifest)} extensions flattened"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# --- Step 6: the ABI-independent asset bundle ---
|
|
331
|
+
canonical = abis[0]
|
|
332
|
+
compiler, strip_source = _resolve_byte_compile(
|
|
333
|
+
android, python_version=python_version, debug=debug
|
|
334
|
+
)
|
|
335
|
+
if compiler is not None:
|
|
336
|
+
with_what = " ".join(compiler) if compiler else "this interpreter"
|
|
337
|
+
click.echo(
|
|
338
|
+
f"[stage] byte-compiling the Python payload with {with_what}"
|
|
339
|
+
+ (" (.pyc only)" if strip_source else "")
|
|
340
|
+
)
|
|
341
|
+
stamp = _stage(
|
|
342
|
+
lambda: assemble_bundle(
|
|
343
|
+
app_main / "assets" / BUNDLE_DIRNAME,
|
|
344
|
+
stdlib_src=stdlib_dir(prefixes[canonical], stem),
|
|
345
|
+
site_packages_by_abi={a: site_packages[a] for a in abis},
|
|
346
|
+
canonical_abi=canonical,
|
|
347
|
+
app_src=project_root / config.kivy.app_dir,
|
|
348
|
+
entry_point=config.kivy.entry_point,
|
|
349
|
+
service_entry_points={s.name: s.entry_point for s in android.services},
|
|
350
|
+
finder_source=finder_source(),
|
|
351
|
+
kivy_bootstrap_source=kivy_bootstrap_source(),
|
|
352
|
+
ext_manifest_json=ext_manifest_json,
|
|
353
|
+
selftest_source=selftest_source(),
|
|
354
|
+
byte_compile=compiler,
|
|
355
|
+
strip_source=strip_source,
|
|
356
|
+
),
|
|
357
|
+
BundleError,
|
|
358
|
+
)
|
|
359
|
+
click.echo(f"[stage] asset bundle assembled (stamp {stamp})")
|
|
360
|
+
|
|
361
|
+
# --- Step 7: (re)generate the Gradle project ---
|
|
362
|
+
sdl_activity_java = ""
|
|
363
|
+
for rendered in render_bootstrap(
|
|
364
|
+
sdl=android.kivy_generation,
|
|
365
|
+
python_version=python_version,
|
|
366
|
+
entry_point=config.kivy.entry_point,
|
|
367
|
+
):
|
|
368
|
+
out = app_main / rendered.relpath
|
|
369
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
370
|
+
out.write_text(rendered.content, encoding="utf-8", newline="\n")
|
|
371
|
+
if rendered.relpath.endswith("org/libsdl/app/SDLActivity.java"):
|
|
372
|
+
sdl_activity_java = rendered.content
|
|
373
|
+
|
|
374
|
+
# 7b. The classes the manifest's <service> entries name. Written after the
|
|
375
|
+
# bootstrap so they land next to the PythonService base they extend, and
|
|
376
|
+
# pruned so a service removed from pyproject.toml stops being compiled in.
|
|
377
|
+
service_classes = render_service_classes(android.services)
|
|
378
|
+
for rendered in service_classes:
|
|
379
|
+
out = app_main / rendered.relpath
|
|
380
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
out.write_text(rendered.content, encoding="utf-8", newline="\n")
|
|
382
|
+
_prune_stale(
|
|
383
|
+
app_main / "java" / "org" / "kivy" / "android",
|
|
384
|
+
"Service*.java",
|
|
385
|
+
keep={Path(r.relpath).name for r in service_classes},
|
|
386
|
+
)
|
|
387
|
+
if service_classes:
|
|
388
|
+
click.echo(f"[generate] {len(service_classes)} service class(es)")
|
|
389
|
+
|
|
390
|
+
# The SDL glue and the wheel's libSDL<N>.so are a matched pair: SDLActivity
|
|
391
|
+
# aborts onCreate silently when their versions disagree, and dies in its
|
|
392
|
+
# static initializer when the generations disagree — so catch both here
|
|
393
|
+
# rather than as an unexplained black screen on-device.
|
|
394
|
+
for abi_name in abis:
|
|
395
|
+
jni = app_main / "jniLibs" / android_abi(abi_name)
|
|
396
|
+
staged = [p.name for p in jni.glob("libSDL*.so")] if jni.is_dir() else []
|
|
397
|
+
try:
|
|
398
|
+
check_sdl_generation(generation=android.kivy_generation, staged=staged)
|
|
399
|
+
except ContractError as exc:
|
|
400
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
401
|
+
so = jni / sdl_library_name(android.kivy_generation)
|
|
402
|
+
if not sdl_activity_java or not so.is_file():
|
|
403
|
+
continue
|
|
404
|
+
try:
|
|
405
|
+
check_sdl_glue_contract(java_source=sdl_activity_java, so_path=so)
|
|
406
|
+
except ContractError as exc:
|
|
407
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
408
|
+
service_tests = render_service_contract_test(android.services)
|
|
409
|
+
for rendered in androidtest_files() + service_tests:
|
|
410
|
+
out = dest / "app" / "src" / rendered.relpath
|
|
411
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
412
|
+
out.write_text(rendered.content, encoding="utf-8", newline="\n")
|
|
413
|
+
if not service_tests:
|
|
414
|
+
# No service declared: the probe from a previous build would fail to
|
|
415
|
+
# compile now that its target class is gone.
|
|
416
|
+
_prune_stale(
|
|
417
|
+
dest
|
|
418
|
+
/ "app"
|
|
419
|
+
/ "src"
|
|
420
|
+
/ "androidTest"
|
|
421
|
+
/ "java"
|
|
422
|
+
/ "org"
|
|
423
|
+
/ "kivyforge"
|
|
424
|
+
/ "test",
|
|
425
|
+
"KivyforgeServiceContractTest.java",
|
|
426
|
+
keep=set(),
|
|
427
|
+
)
|
|
428
|
+
(app_main / "AndroidManifest.xml").write_text(
|
|
429
|
+
generate_manifest(android, orientation=tuple(config.kivy.orientation)),
|
|
430
|
+
encoding="utf-8",
|
|
431
|
+
newline="\n",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def _generate_project_files() -> None:
|
|
435
|
+
write_settings_gradle(dest, android)
|
|
436
|
+
write_root_build_gradle(dest, android)
|
|
437
|
+
write_gradle_properties(dest, android)
|
|
438
|
+
stage_gradle_wrapper(dest)
|
|
439
|
+
write_app_build_gradle(
|
|
440
|
+
dest,
|
|
441
|
+
config,
|
|
442
|
+
android,
|
|
443
|
+
python_version=python_version,
|
|
444
|
+
runtime_root=runtime_root,
|
|
445
|
+
staged_libs=staged_libs,
|
|
446
|
+
abis=abis,
|
|
447
|
+
signing_config_block=signing_config_block,
|
|
448
|
+
release_signing_config=release_signing_config,
|
|
449
|
+
test_build_type=test_build_type,
|
|
450
|
+
)
|
|
451
|
+
write_resources(dest, config, android, project_root=project_root)
|
|
452
|
+
|
|
453
|
+
_stage(_generate_project_files, ProjectGenError)
|
|
454
|
+
write_gradle_pins(dest, lock)
|
|
455
|
+
_write_local_properties(dest)
|
|
456
|
+
_copy_include_files(project_root, dest, android, lock, verify=not no_verify_lock)
|
|
457
|
+
click.echo(f"[generate] {dest.name}/ regenerated")
|
|
458
|
+
|
|
459
|
+
# --- Step 8: assembleDebug (only with --debug) ---
|
|
460
|
+
if debug:
|
|
461
|
+
task = "assembleDebug" if fmt == "apk" else "bundleDebug"
|
|
462
|
+
click.echo(f"[gradle] {task}")
|
|
463
|
+
try:
|
|
464
|
+
run_gradle(dest, [task])
|
|
465
|
+
except GradleError as exc:
|
|
466
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
467
|
+
out = _require_artifact(_debug_output(dest, fmt), task)
|
|
468
|
+
click.echo(f"Built {out}")
|
|
469
|
+
return dest
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _require_artifact(path: Path, task: str) -> Path:
|
|
473
|
+
"""Confirm Gradle actually produced the artifact we are about to announce."""
|
|
474
|
+
if not path.is_file():
|
|
475
|
+
raise AndroidBuildError(
|
|
476
|
+
f"Gradle reported success for {task} but no artifact is at {path}.\n"
|
|
477
|
+
" This usually means AGP's output layout moved under a new plugin "
|
|
478
|
+
"version; check app/build/outputs/ and file a kivyforge issue."
|
|
479
|
+
)
|
|
480
|
+
return path
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def android_package(
|
|
484
|
+
project_root: Path,
|
|
485
|
+
*,
|
|
486
|
+
fmt: str = "apk",
|
|
487
|
+
abi: str | None = None,
|
|
488
|
+
keystore: str | None = None,
|
|
489
|
+
key_alias: str | None = None,
|
|
490
|
+
no_verify_lock: bool = False,
|
|
491
|
+
no_cache: bool = False,
|
|
492
|
+
) -> Path:
|
|
493
|
+
"""Produce the signed release distributable (android/06 §package)."""
|
|
494
|
+
from .policy import ManifestPolicyError, enforce_release_manifest
|
|
495
|
+
from .signing import SigningError, resolve_signing, signing_config_gradle
|
|
496
|
+
|
|
497
|
+
config, _lock = _load(project_root, no_verify_lock=no_verify_lock)
|
|
498
|
+
android = config.android_required
|
|
499
|
+
|
|
500
|
+
# Signing pre-flight (fail fast, BEFORE staging/generation).
|
|
501
|
+
try:
|
|
502
|
+
signing = resolve_signing(
|
|
503
|
+
android.signing,
|
|
504
|
+
project_root=project_root,
|
|
505
|
+
keystore_override=keystore,
|
|
506
|
+
key_alias_override=key_alias,
|
|
507
|
+
)
|
|
508
|
+
except SigningError as exc:
|
|
509
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
510
|
+
|
|
511
|
+
# Stage + generate (steps 1-7), injecting the release signing config.
|
|
512
|
+
signing_block = signing_config_gradle(signing)
|
|
513
|
+
dest = android_build(
|
|
514
|
+
project_root,
|
|
515
|
+
debug=False,
|
|
516
|
+
abi=abi,
|
|
517
|
+
no_verify_lock=no_verify_lock,
|
|
518
|
+
no_cache=no_cache,
|
|
519
|
+
signing_config_block=signing_block,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# Manifest policy, pass 1: the GENERATED manifest, so an own-goal (placeholder
|
|
523
|
+
# applicationId, a passthrough that forces debuggable) costs no Gradle time.
|
|
524
|
+
manifest_xml = (dest / "app" / "src" / "main" / "AndroidManifest.xml").read_text(
|
|
525
|
+
encoding="utf-8"
|
|
526
|
+
)
|
|
527
|
+
try:
|
|
528
|
+
infos = enforce_release_manifest(
|
|
529
|
+
manifest_xml,
|
|
530
|
+
package=android.package,
|
|
531
|
+
allow_exported=android.manifest.allow_exported,
|
|
532
|
+
)
|
|
533
|
+
except ManifestPolicyError as exc:
|
|
534
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
535
|
+
for info in infos:
|
|
536
|
+
click.echo(f"[policy] INFO: {info.message}")
|
|
537
|
+
|
|
538
|
+
# Manifest policy, pass 2: Lint's curated subset plus the same checks over
|
|
539
|
+
# AGP's MERGED manifest — the one that is actually packaged, including what
|
|
540
|
+
# library (.aar/Maven) manifests contribute. Still before signing.
|
|
541
|
+
_enforce_merged_manifest(dest, android)
|
|
542
|
+
|
|
543
|
+
task = "assembleRelease" if fmt == "apk" else "bundleRelease"
|
|
544
|
+
click.echo(f"[gradle] {task}")
|
|
545
|
+
try:
|
|
546
|
+
run_gradle(dest, [task])
|
|
547
|
+
except GradleError as exc:
|
|
548
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
549
|
+
out = _require_artifact(_release_output(dest, fmt), task)
|
|
550
|
+
click.echo(f"Packaged {out}")
|
|
551
|
+
return out
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _enforce_merged_manifest(dest: Path, android: AndroidConfig) -> None:
|
|
555
|
+
"""Run lintRelease + the merged-manifest policy pass, before signing."""
|
|
556
|
+
from .generate.project import MERGED_MANIFEST_RELPATH, MERGED_MANIFEST_TASK
|
|
557
|
+
from .policy import LINT_CHECKS, ManifestPolicyError, enforce_release_manifest
|
|
558
|
+
|
|
559
|
+
click.echo(f"[gradle] lintRelease ({len(LINT_CHECKS)} curated checks)")
|
|
560
|
+
try:
|
|
561
|
+
run_gradle(dest, ["lintRelease", MERGED_MANIFEST_TASK])
|
|
562
|
+
except GradleError as exc:
|
|
563
|
+
raise AndroidBuildError(
|
|
564
|
+
f"{exc}\n"
|
|
565
|
+
" A lintRelease finding blocks the release (android/06 §package). "
|
|
566
|
+
"The curated subset is "
|
|
567
|
+
f"{', '.join(LINT_CHECKS)}; see the report under "
|
|
568
|
+
"app/build/reports/lint-results-release.html."
|
|
569
|
+
) from exc
|
|
570
|
+
|
|
571
|
+
merged = dest / MERGED_MANIFEST_RELPATH
|
|
572
|
+
if not merged.is_file():
|
|
573
|
+
raise AndroidBuildError(
|
|
574
|
+
f"{MERGED_MANIFEST_TASK} produced no manifest at {merged}; the "
|
|
575
|
+
"release policy cannot check what would actually be packaged."
|
|
576
|
+
)
|
|
577
|
+
click.echo("[policy] merged release manifest")
|
|
578
|
+
try:
|
|
579
|
+
infos = enforce_release_manifest(
|
|
580
|
+
merged.read_text(encoding="utf-8"),
|
|
581
|
+
package=android.package,
|
|
582
|
+
allow_exported=android.manifest.allow_exported,
|
|
583
|
+
)
|
|
584
|
+
except ManifestPolicyError as exc:
|
|
585
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
586
|
+
for info in infos:
|
|
587
|
+
click.echo(f"[policy] INFO (merged): {info.message}")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _release_output(dest: Path, fmt: str) -> Path:
|
|
591
|
+
outputs = dest / "app" / "build" / "outputs"
|
|
592
|
+
if fmt == "apk":
|
|
593
|
+
return outputs / "apk" / "release" / "app-release.apk"
|
|
594
|
+
return outputs / "bundle" / "release" / "app-release.aab"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def android_run(
|
|
598
|
+
project_root: Path,
|
|
599
|
+
*,
|
|
600
|
+
no_build: bool = False,
|
|
601
|
+
abi: str | None = None,
|
|
602
|
+
serial: str | None = None,
|
|
603
|
+
avd: str | None = None,
|
|
604
|
+
prefer_emulator: bool = False,
|
|
605
|
+
require_physical: bool = False,
|
|
606
|
+
wait_sec: int = 25,
|
|
607
|
+
) -> str:
|
|
608
|
+
"""Build (unless ``--no-build``), install, launch, echo + return app logcat."""
|
|
609
|
+
from . import adb as adb_mod
|
|
610
|
+
|
|
611
|
+
config, _lock = _load(project_root, no_verify_lock=False)
|
|
612
|
+
android = config.android_required
|
|
613
|
+
|
|
614
|
+
# The target comes first, even though the build is the long step: it decides
|
|
615
|
+
# which ABI the dev loop needs, and an emulator boots while Gradle works.
|
|
616
|
+
try:
|
|
617
|
+
device = adb_mod.resolve_device(
|
|
618
|
+
prefer_emulator=prefer_emulator,
|
|
619
|
+
require_physical=require_physical,
|
|
620
|
+
serial=serial,
|
|
621
|
+
avd=avd,
|
|
622
|
+
)
|
|
623
|
+
except adb_mod.AdbError as exc:
|
|
624
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
625
|
+
click.echo(f"[run] device {device}")
|
|
626
|
+
if not no_build:
|
|
627
|
+
android_build(
|
|
628
|
+
project_root,
|
|
629
|
+
debug=True,
|
|
630
|
+
fmt="apk",
|
|
631
|
+
abi=abi or _abi_for_device(device, android),
|
|
632
|
+
)
|
|
633
|
+
dest = project_dir_for(project_root, config)
|
|
634
|
+
apk = _debug_output(dest, "apk")
|
|
635
|
+
if not apk.is_file():
|
|
636
|
+
raise AndroidBuildError(f"no debug APK at {apk}; run without --no-build first.")
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
adb_mod.install_apk(device, apk)
|
|
640
|
+
adb_mod.logcat_clear(device)
|
|
641
|
+
adb_mod.launch(device, android.package, "org.kivy.android.PythonActivity")
|
|
642
|
+
click.echo(f"[run] launched {android.package}; capturing logcat...")
|
|
643
|
+
import time as _time
|
|
644
|
+
|
|
645
|
+
_time.sleep(wait_sec)
|
|
646
|
+
log = adb_mod.logcat_dump(device)
|
|
647
|
+
for line in log.splitlines():
|
|
648
|
+
if any(tag in line for tag in ("kivyforge", "python.std", "SDL")):
|
|
649
|
+
click.echo(line)
|
|
650
|
+
return log
|
|
651
|
+
except adb_mod.AdbError as exc:
|
|
652
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _abi_for_device(device: str, android: AndroidConfig) -> str:
|
|
656
|
+
"""The ABI to restrict a `run` build to, asked of the target itself.
|
|
657
|
+
|
|
658
|
+
android/06 §run documents `--abi`'s default as the target's architecture;
|
|
659
|
+
reading it off the device is the accurate form of that, and lets a locked-ABI
|
|
660
|
+
mismatch fail here with a name rather than later as
|
|
661
|
+
``INSTALL_FAILED_NO_MATCHING_ABIS``.
|
|
662
|
+
"""
|
|
663
|
+
from . import adb as adb_mod
|
|
664
|
+
|
|
665
|
+
abi = adb_mod.device_abi(device)
|
|
666
|
+
if abi is None:
|
|
667
|
+
abi = adb_mod.host_abi()
|
|
668
|
+
click.echo(
|
|
669
|
+
f"[run] {device} reports no ABI kivyforge builds for; assuming the "
|
|
670
|
+
f"host's {abi}."
|
|
671
|
+
)
|
|
672
|
+
if abi not in android.abis:
|
|
673
|
+
raise AndroidBuildError(
|
|
674
|
+
f"{device} needs the {abi} ABI, but this project locks "
|
|
675
|
+
f"{', '.join(android.abis)}.\n"
|
|
676
|
+
f" Add {abi!r} to [tool.kivy.android].abis and re-run "
|
|
677
|
+
"`kivyforge lock -p android`, or target a device/AVD matching a "
|
|
678
|
+
"locked ABI. Installing the wrong ABI fails as "
|
|
679
|
+
"INSTALL_FAILED_NO_MATCHING_ABIS."
|
|
680
|
+
)
|
|
681
|
+
click.echo(f"[run] building for {abi} (the target's ABI)")
|
|
682
|
+
return abi
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def android_smoke(
|
|
686
|
+
project_root: Path,
|
|
687
|
+
*,
|
|
688
|
+
release: bool = False,
|
|
689
|
+
abi: str | None = None,
|
|
690
|
+
serial: str | None = None,
|
|
691
|
+
avd: str | None = None,
|
|
692
|
+
prefer_emulator: bool = True,
|
|
693
|
+
require_physical: bool = False,
|
|
694
|
+
) -> None:
|
|
695
|
+
"""Generate the project for the target variant, ensure a device, run the test."""
|
|
696
|
+
from . import adb as adb_mod
|
|
697
|
+
from .smoke import SmokeError, run_smoke
|
|
698
|
+
|
|
699
|
+
config, _lock = _load(project_root, no_verify_lock=False)
|
|
700
|
+
# As in `android_run`: resolve the target first so the probe builds for the
|
|
701
|
+
# ABI it will actually be installed on.
|
|
702
|
+
try:
|
|
703
|
+
device = adb_mod.resolve_device(
|
|
704
|
+
prefer_emulator=prefer_emulator,
|
|
705
|
+
require_physical=require_physical,
|
|
706
|
+
serial=serial,
|
|
707
|
+
avd=avd,
|
|
708
|
+
)
|
|
709
|
+
except adb_mod.AdbError as exc:
|
|
710
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
711
|
+
click.echo(f"[smoke] device {device}; building the probe...")
|
|
712
|
+
abi = abi or _abi_for_device(device, config.android_required)
|
|
713
|
+
# The release probe exists to exercise byte-compilation, stripping and R8,
|
|
714
|
+
# so it must build the *release* variant — which needs both a signing config
|
|
715
|
+
# (AGP refuses to assemble an unsigned release, and the app and test APKs
|
|
716
|
+
# must share a key) and testBuildType, since AGP only generates
|
|
717
|
+
# connectedReleaseAndroidTest when the tests target that variant.
|
|
718
|
+
signing_block, signing_name = (
|
|
719
|
+
_release_smoke_signing(config.android_required, project_root)
|
|
720
|
+
if release
|
|
721
|
+
else ("", None)
|
|
722
|
+
)
|
|
723
|
+
android_build(
|
|
724
|
+
project_root,
|
|
725
|
+
debug=False,
|
|
726
|
+
abi=abi,
|
|
727
|
+
signing_config_block=signing_block,
|
|
728
|
+
release_signing_config=signing_name,
|
|
729
|
+
test_build_type="release" if release else None,
|
|
730
|
+
)
|
|
731
|
+
dest = project_dir_for(project_root, config)
|
|
732
|
+
|
|
733
|
+
click.echo(f"[smoke] running the contract test on {device}...")
|
|
734
|
+
try:
|
|
735
|
+
run_smoke(dest, release=release)
|
|
736
|
+
except (adb_mod.AdbError, SmokeError) as exc:
|
|
737
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
738
|
+
click.echo("Contract smoke test PASSED.")
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _release_smoke_signing(
|
|
742
|
+
android: AndroidConfig, project_root: Path
|
|
743
|
+
) -> tuple[str, str]:
|
|
744
|
+
"""The signing config the release smoke test builds against.
|
|
745
|
+
|
|
746
|
+
The project's real release identity when it is configured *and* usable — the
|
|
747
|
+
probe then exercises exactly what ships. Otherwise the debug keystore, which
|
|
748
|
+
Android Studio and the SDK create unattended: a smoke test is a test, and
|
|
749
|
+
requiring release secrets to run it would keep the gate out of reach of the
|
|
750
|
+
hosts that most need it (android/06 §--smoke, "test-instrumentation signing
|
|
751
|
+
config").
|
|
752
|
+
"""
|
|
753
|
+
from .signing import SigningError, resolve_signing, signing_config_gradle
|
|
754
|
+
|
|
755
|
+
if android.signing.configured:
|
|
756
|
+
try:
|
|
757
|
+
signing = resolve_signing(android.signing, project_root=project_root)
|
|
758
|
+
except SigningError as exc:
|
|
759
|
+
click.echo(
|
|
760
|
+
f"[smoke] release signing is configured but unusable ({exc}); "
|
|
761
|
+
"falling back to the debug keystore for the probe."
|
|
762
|
+
)
|
|
763
|
+
else:
|
|
764
|
+
return signing_config_gradle(signing), "release"
|
|
765
|
+
click.echo("[smoke] signing the release probe with the debug keystore.")
|
|
766
|
+
return "", "debug"
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _copy_include_files(
|
|
770
|
+
project_root: Path,
|
|
771
|
+
dest: Path,
|
|
772
|
+
android: AndroidConfig,
|
|
773
|
+
lock: AndroidLockfile,
|
|
774
|
+
*,
|
|
775
|
+
verify: bool = True,
|
|
776
|
+
) -> None:
|
|
777
|
+
"""Stage ``include_files`` into the generated project, checking the pins.
|
|
778
|
+
|
|
779
|
+
``kivyforge lock`` records a SHA-256 per staged file
|
|
780
|
+
(``[[tool.kivyforge.include_files]]``) precisely so that content drift is
|
|
781
|
+
detectable, and until now nothing read them back: an edited vendored asset
|
|
782
|
+
shipped silently, with a lock that claimed otherwise. Verification is
|
|
783
|
+
checked-in drift detection, not supply-chain integrity — these are the
|
|
784
|
+
user's own repo files — so the fix is always "re-run lock".
|
|
785
|
+
"""
|
|
786
|
+
generated = {
|
|
787
|
+
"app/src/main/AndroidManifest.xml",
|
|
788
|
+
"app/build.gradle",
|
|
789
|
+
"build.gradle",
|
|
790
|
+
"settings.gradle",
|
|
791
|
+
"gradle.properties",
|
|
792
|
+
}
|
|
793
|
+
pins = {(p.dest, p.source): p.sha256 for p in lock.include_files}
|
|
794
|
+
staged: set[tuple[str, str]] = set()
|
|
795
|
+
for entry in android.include_files:
|
|
796
|
+
target_dir = dest / entry.dest
|
|
797
|
+
for source in entry.sources:
|
|
798
|
+
src = project_root / source
|
|
799
|
+
targets = (
|
|
800
|
+
[
|
|
801
|
+
(
|
|
802
|
+
child,
|
|
803
|
+
target_dir / child.relative_to(src),
|
|
804
|
+
(Path(source) / child.relative_to(src)).as_posix(),
|
|
805
|
+
)
|
|
806
|
+
for child in sorted(src.rglob("*"))
|
|
807
|
+
if child.is_file()
|
|
808
|
+
]
|
|
809
|
+
if src.is_dir()
|
|
810
|
+
else [(src, target_dir / src.name, source)]
|
|
811
|
+
)
|
|
812
|
+
for file_src, file_dest, rel_source in targets:
|
|
813
|
+
rel = file_dest.relative_to(dest).as_posix()
|
|
814
|
+
if rel in generated:
|
|
815
|
+
raise AndroidBuildError(
|
|
816
|
+
f"include_files entry would overwrite the generated "
|
|
817
|
+
f"{rel}; use the manifest/gradle passthroughs instead "
|
|
818
|
+
f"(android/01 §include_files)."
|
|
819
|
+
)
|
|
820
|
+
if verify:
|
|
821
|
+
_verify_include_file(file_src, rel_source, entry.dest, pins)
|
|
822
|
+
staged.add((entry.dest, rel_source))
|
|
823
|
+
file_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
824
|
+
shutil.copy2(file_src, file_dest)
|
|
825
|
+
if verify:
|
|
826
|
+
# A pin with nothing behind it: a file the lock recorded has since been
|
|
827
|
+
# deleted from a directory source, which the per-file loop cannot see.
|
|
828
|
+
missing = sorted(pins.keys() - staged)
|
|
829
|
+
if missing:
|
|
830
|
+
names = ", ".join(source for _dest, source in missing)
|
|
831
|
+
raise AndroidBuildError(
|
|
832
|
+
f"include_files drift: the lock records {names}, which no longer "
|
|
833
|
+
"exists.\n"
|
|
834
|
+
" Re-run `kivyforge lock -p android` to re-record what the "
|
|
835
|
+
"project actually ships."
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _verify_include_file(
|
|
840
|
+
file_src: Path, rel_source: str, dest: str, pins: dict[tuple[str, str], str]
|
|
841
|
+
) -> None:
|
|
842
|
+
from kivyforge.artifacts.verify import sha256_file
|
|
843
|
+
|
|
844
|
+
recorded = pins.get((dest, rel_source))
|
|
845
|
+
if recorded is None:
|
|
846
|
+
raise AndroidBuildError(
|
|
847
|
+
f"include_files drift: {rel_source} would be staged into {dest} but "
|
|
848
|
+
"the lock records no hash for it.\n"
|
|
849
|
+
" Re-run `kivyforge lock -p android` to record it."
|
|
850
|
+
)
|
|
851
|
+
actual = sha256_file(file_src)
|
|
852
|
+
if actual != recorded:
|
|
853
|
+
raise AndroidBuildError(
|
|
854
|
+
f"include_files drift: {rel_source} has changed since the lock was "
|
|
855
|
+
"written.\n"
|
|
856
|
+
f" locked: {recorded}\n"
|
|
857
|
+
f" on disk: {actual}\n"
|
|
858
|
+
" Re-run `kivyforge lock -p android` to accept the new content."
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def android_status(project_root: Path) -> None:
|
|
863
|
+
"""Read-only project snapshot (android/06 §status)."""
|
|
864
|
+
from kivyforge.lock.model import canonical_name
|
|
865
|
+
from kivyforge.lock.reader import LockError, is_in_sync
|
|
866
|
+
|
|
867
|
+
config, _ = _load_config_only(project_root)
|
|
868
|
+
android = config.android_required
|
|
869
|
+
click.echo(f"App: {config.project.name} ({android.package})")
|
|
870
|
+
click.echo(f"Python: {android.python_required.version}")
|
|
871
|
+
|
|
872
|
+
lock_path = project_root / "pylock.android.toml"
|
|
873
|
+
kivy_ver = "?"
|
|
874
|
+
lock_state = "missing"
|
|
875
|
+
if lock_path.is_file():
|
|
876
|
+
try:
|
|
877
|
+
lock = lock_reader.load(lock_path)
|
|
878
|
+
kivy = next(
|
|
879
|
+
(p for p in lock.packages if canonical_name(p.name) == "kivy"), None
|
|
880
|
+
)
|
|
881
|
+
kivy_ver = kivy.version if kivy else "—"
|
|
882
|
+
lock_state = (
|
|
883
|
+
"in sync"
|
|
884
|
+
if is_in_sync(
|
|
885
|
+
lock, (project_root / "pyproject.toml").read_text("utf-8")
|
|
886
|
+
)
|
|
887
|
+
else "out of date"
|
|
888
|
+
)
|
|
889
|
+
except LockError:
|
|
890
|
+
lock_state = "unreadable"
|
|
891
|
+
click.echo(
|
|
892
|
+
f"Kivy/SDL: kivy {kivy_ver} (kivy_generation {android.kivy_generation})"
|
|
893
|
+
)
|
|
894
|
+
click.echo(f"ABIs: {', '.join(android.abis)}")
|
|
895
|
+
click.echo(f"Lock: {lock_state}")
|
|
896
|
+
|
|
897
|
+
dest = project_dir_for(project_root, config)
|
|
898
|
+
click.echo("Build:")
|
|
899
|
+
for label, path in (
|
|
900
|
+
("apk (debug)", _debug_output(dest, "apk")),
|
|
901
|
+
("apk (release)", _release_output(dest, "apk")),
|
|
902
|
+
("aab (release)", _release_output(dest, "aab")),
|
|
903
|
+
):
|
|
904
|
+
if path.is_file():
|
|
905
|
+
import datetime
|
|
906
|
+
|
|
907
|
+
mtime = datetime.datetime.fromtimestamp(path.stat().st_mtime)
|
|
908
|
+
click.echo(f" {label:<16} built {mtime:%Y-%m-%d %H:%M}")
|
|
909
|
+
else:
|
|
910
|
+
click.echo(f" {label:<16} not built")
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def android_open(project_root: Path) -> None:
|
|
914
|
+
"""Open the generated project in Android Studio (android/06 §open)."""
|
|
915
|
+
config, _ = _load_config_only(project_root)
|
|
916
|
+
dest = project_dir_for(project_root, config)
|
|
917
|
+
if not dest.is_dir():
|
|
918
|
+
raise AndroidBuildError(
|
|
919
|
+
f"{dest.name}/ does not exist yet; run `kivyforge build -p android` first."
|
|
920
|
+
)
|
|
921
|
+
studio = shutil.which("studio") or shutil.which("studio.sh")
|
|
922
|
+
if studio:
|
|
923
|
+
import subprocess
|
|
924
|
+
|
|
925
|
+
subprocess.Popen([studio, str(dest)])
|
|
926
|
+
click.echo(f"Opening {dest.name}/ in Android Studio...")
|
|
927
|
+
return
|
|
928
|
+
click.echo(
|
|
929
|
+
f"Android Studio launcher not found on PATH.\n"
|
|
930
|
+
f" Open this project manually: {dest}"
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _load_config_only(project_root: Path):
|
|
935
|
+
pyproject = project_root / "pyproject.toml"
|
|
936
|
+
try:
|
|
937
|
+
return (
|
|
938
|
+
load_config(pyproject, require_ios=False, require_android=True),
|
|
939
|
+
pyproject,
|
|
940
|
+
)
|
|
941
|
+
except ConfigError as exc:
|
|
942
|
+
raise AndroidBuildError(exc.format()) from exc
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _write_local_properties(dest: Path) -> None:
|
|
946
|
+
"""Point Gradle at the detected SDK when ANDROID_HOME is not exported.
|
|
947
|
+
|
|
948
|
+
``local.properties`` is machine-local by convention and lives inside the
|
|
949
|
+
regenerated (gitignored) project, so writing it here is safe and matches
|
|
950
|
+
what Android Studio itself does. The env var still wins when set.
|
|
951
|
+
"""
|
|
952
|
+
import os
|
|
953
|
+
|
|
954
|
+
if os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT"):
|
|
955
|
+
return
|
|
956
|
+
from .doctor import RealAndroidProbe
|
|
957
|
+
|
|
958
|
+
sdk = RealAndroidProbe().sdk_root()
|
|
959
|
+
if sdk is None:
|
|
960
|
+
return # gradle will surface the standard actionable error
|
|
961
|
+
escaped = str(sdk).replace("\\", "\\\\").replace(":", "\\:")
|
|
962
|
+
(dest / "local.properties").write_text(
|
|
963
|
+
f"sdk.dir={escaped}\n", encoding="utf-8", newline="\n"
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _debug_output(dest: Path, fmt: str) -> Path:
|
|
968
|
+
if fmt == "apk":
|
|
969
|
+
return dest / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
|
|
970
|
+
return dest / "app" / "build" / "outputs" / "bundle" / "debug" / "app-debug.aab"
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _select_abis(android: AndroidConfig, abi: str | None) -> tuple[str, ...]:
|
|
974
|
+
if abi is None:
|
|
975
|
+
return tuple(android.abis)
|
|
976
|
+
if abi not in android.abis:
|
|
977
|
+
raise AndroidBuildError(
|
|
978
|
+
f"--abi {abi!r} is not in [tool.kivy.android].abis "
|
|
979
|
+
f"({', '.join(android.abis)})."
|
|
980
|
+
)
|
|
981
|
+
return (abi,)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _locked_version(lock: AndroidLockfile, name: str) -> str | None:
|
|
985
|
+
from kivyforge.lock.model import canonical_name
|
|
986
|
+
|
|
987
|
+
for package in lock.packages:
|
|
988
|
+
if canonical_name(package.name) == name:
|
|
989
|
+
return package.version
|
|
990
|
+
return None
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _load(project_root: Path, *, no_verify_lock: bool):
|
|
994
|
+
pyproject = project_root / "pyproject.toml"
|
|
995
|
+
try:
|
|
996
|
+
config = load_config(pyproject, require_ios=False, require_android=True)
|
|
997
|
+
except ConfigError as exc:
|
|
998
|
+
raise AndroidBuildError(exc.format()) from exc
|
|
999
|
+
lock_path = project_root / "pylock.android.toml"
|
|
1000
|
+
if not lock_path.is_file():
|
|
1001
|
+
raise AndroidBuildError(
|
|
1002
|
+
"pylock.android.toml not found. Run `kivyforge lock -p android` first."
|
|
1003
|
+
)
|
|
1004
|
+
try:
|
|
1005
|
+
lock = lock_reader.load(lock_path)
|
|
1006
|
+
except LockError as exc:
|
|
1007
|
+
raise AndroidBuildError(str(exc)) from exc
|
|
1008
|
+
if not no_verify_lock and not is_in_sync(
|
|
1009
|
+
lock, pyproject.read_text(encoding="utf-8")
|
|
1010
|
+
):
|
|
1011
|
+
raise AndroidBuildError(
|
|
1012
|
+
"pyproject.toml has changed since pylock.android.toml was "
|
|
1013
|
+
"generated.\n Run: kivyforge lock -p android\n"
|
|
1014
|
+
" Or: kivyforge build --no-verify-lock (not recommended)"
|
|
1015
|
+
)
|
|
1016
|
+
return config, lock
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _stage(fn, error_type):
|
|
1020
|
+
try:
|
|
1021
|
+
return fn()
|
|
1022
|
+
except error_type as exc:
|
|
1023
|
+
raise AndroidBuildError(str(exc)) from exc
|