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.
Files changed (225) hide show
  1. kivyforge/__init__.py +9 -0
  2. kivyforge/artifacts/__init__.py +41 -0
  3. kivyforge/artifacts/cache.py +81 -0
  4. kivyforge/artifacts/collect.py +354 -0
  5. kivyforge/artifacts/download.py +125 -0
  6. kivyforge/artifacts/frameworks.py +240 -0
  7. kivyforge/artifacts/native_stage_util.py +346 -0
  8. kivyforge/artifacts/runtime.py +65 -0
  9. kivyforge/artifacts/verify.py +45 -0
  10. kivyforge/artifacts/wheels.py +89 -0
  11. kivyforge/bundle/__init__.py +1 -0
  12. kivyforge/bundle/pycompile.py +161 -0
  13. kivyforge/cli/__init__.py +52 -0
  14. kivyforge/cli/_common.py +62 -0
  15. kivyforge/cli/_platform.py +84 -0
  16. kivyforge/cli/build.py +111 -0
  17. kivyforge/cli/clean.py +135 -0
  18. kivyforge/cli/doctor.py +52 -0
  19. kivyforge/cli/init.py +612 -0
  20. kivyforge/cli/init_writer.py +716 -0
  21. kivyforge/cli/lock.py +261 -0
  22. kivyforge/cli/open_cmd.py +15 -0
  23. kivyforge/cli/package.py +134 -0
  24. kivyforge/cli/run.py +162 -0
  25. kivyforge/cli/status.py +20 -0
  26. kivyforge/cli/upgrade.py +314 -0
  27. kivyforge/config/__init__.py +46 -0
  28. kivyforge/config/errors.py +43 -0
  29. kivyforge/config/icons.py +60 -0
  30. kivyforge/config/loader.py +3079 -0
  31. kivyforge/config/model.py +817 -0
  32. kivyforge/doctor/__init__.py +19 -0
  33. kivyforge/doctor/checks_common.py +95 -0
  34. kivyforge/doctor/probe.py +357 -0
  35. kivyforge/doctor/result.py +41 -0
  36. kivyforge/lock/__init__.py +42 -0
  37. kivyforge/lock/_pip_shim.py +63 -0
  38. kivyforge/lock/find_links.py +182 -0
  39. kivyforge/lock/model.py +69 -0
  40. kivyforge/lock/pep751.py +111 -0
  41. kivyforge/lock/reader.py +30 -0
  42. kivyforge/lock/resolver.py +124 -0
  43. kivyforge/lock/wheelruntime/__init__.py +70 -0
  44. kivyforge/lock/wheelruntime/builder.py +280 -0
  45. kivyforge/lock/wheelruntime/model.py +94 -0
  46. kivyforge/lock/wheelruntime/native_binaries.py +127 -0
  47. kivyforge/lock/wheelruntime/pbs_github.py +133 -0
  48. kivyforge/lock/wheelruntime/profile.py +104 -0
  49. kivyforge/lock/wheelruntime/resolver.py +248 -0
  50. kivyforge/lock/wheelruntime/runtime.py +161 -0
  51. kivyforge/lock/wheelruntime/serialize.py +245 -0
  52. kivyforge/platforms/__init__.py +114 -0
  53. kivyforge/platforms/android/__init__.py +140 -0
  54. kivyforge/platforms/android/adb.py +215 -0
  55. kivyforge/platforms/android/bootstrap/__init__.py +7 -0
  56. kivyforge/platforms/android/bootstrap/contract.py +166 -0
  57. kivyforge/platforms/android/bootstrap/render.py +244 -0
  58. kivyforge/platforms/android/bootstrap/templates/androidtest/KivyforgeContractTest.java +76 -0
  59. kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivy_bootstrap.py +50 -0
  60. kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivyforge_bootstrap.py +54 -0
  61. kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivyforge_selftest.py +97 -0
  62. kivyforge/platforms/android/bootstrap/templates/cpp/CMakeLists.txt +20 -0
  63. kivyforge/platforms/android/bootstrap/templates/cpp/main.c +150 -0
  64. kivyforge/platforms/android/bootstrap/templates/java/org/jnius/NativeInvocationHandler.java +48 -0
  65. kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonActivity.java +75 -0
  66. kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonBundle.java +132 -0
  67. kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonService.java +189 -0
  68. kivyforge/platforms/android/bootstrap/templates/java/org/renpy/android/Hardware.java +276 -0
  69. kivyforge/platforms/android/bootstrap/templates/sdl2/LICENSE-SDL.txt +18 -0
  70. kivyforge/platforms/android/bootstrap/templates/sdl2/SDL_REVISION.txt +3 -0
  71. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDevice.java +22 -0
  72. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceBLESteamController.java +650 -0
  73. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceManager.java +698 -0
  74. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceUSB.java +309 -0
  75. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDL.java +90 -0
  76. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLActivity.java +2120 -0
  77. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLAudioManager.java +514 -0
  78. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLControllerManager.java +856 -0
  79. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLSurface.java +405 -0
  80. kivyforge/platforms/android/bootstrap/templates/sdl3/LICENSE-SDL.txt +18 -0
  81. kivyforge/platforms/android/bootstrap/templates/sdl3/SDL_REVISION.txt +3 -0
  82. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDevice.java +21 -0
  83. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceBLESteamController.java +829 -0
  84. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceManager.java +698 -0
  85. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceUSB.java +354 -0
  86. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDL.java +90 -0
  87. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLActivity.java +2240 -0
  88. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLAudioManager.java +126 -0
  89. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLControllerManager.java +1010 -0
  90. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLDummyEdit.java +66 -0
  91. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLInputConnection.java +136 -0
  92. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLSensorManager.java +73 -0
  93. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLSurface.java +464 -0
  94. kivyforge/platforms/android/cli.py +1023 -0
  95. kivyforge/platforms/android/doctor.py +899 -0
  96. kivyforge/platforms/android/elf.py +148 -0
  97. kivyforge/platforms/android/generate/__init__.py +1 -0
  98. kivyforge/platforms/android/generate/manifest.py +261 -0
  99. kivyforge/platforms/android/generate/project.py +462 -0
  100. kivyforge/platforms/android/generate/services.py +187 -0
  101. kivyforge/platforms/android/gradle_wrapper/NOTICE +3 -0
  102. kivyforge/platforms/android/gradle_wrapper/SHA256SUMS +1 -0
  103. kivyforge/platforms/android/gradle_wrapper/gradle-wrapper.jar +0 -0
  104. kivyforge/platforms/android/gradle_wrapper/gradlew +164 -0
  105. kivyforge/platforms/android/gradle_wrapper/gradlew.bat +90 -0
  106. kivyforge/platforms/android/gradlew.py +60 -0
  107. kivyforge/platforms/android/icons.py +237 -0
  108. kivyforge/platforms/android/lock/__init__.py +1 -0
  109. kivyforge/platforms/android/lock/archives.py +109 -0
  110. kivyforge/platforms/android/lock/builder.py +387 -0
  111. kivyforge/platforms/android/lock/markers.py +71 -0
  112. kivyforge/platforms/android/lock/maven.py +252 -0
  113. kivyforge/platforms/android/lock/model.py +113 -0
  114. kivyforge/platforms/android/lock/python_meta.py +199 -0
  115. kivyforge/platforms/android/lock/reader.py +206 -0
  116. kivyforge/platforms/android/lock/resolver.py +276 -0
  117. kivyforge/platforms/android/lock/writer.py +121 -0
  118. kivyforge/platforms/android/policy.py +214 -0
  119. kivyforge/platforms/android/signing.py +145 -0
  120. kivyforge/platforms/android/smoke.py +43 -0
  121. kivyforge/platforms/android/splash.py +293 -0
  122. kivyforge/platforms/android/stage/__init__.py +1 -0
  123. kivyforge/platforms/android/stage/bundle.py +295 -0
  124. kivyforge/platforms/android/stage/jnilibs.py +186 -0
  125. kivyforge/platforms/android/stage/runtime.py +61 -0
  126. kivyforge/platforms/android/stage/wheels.py +106 -0
  127. kivyforge/platforms/android/toolchain.py +29 -0
  128. kivyforge/platforms/base.py +157 -0
  129. kivyforge/platforms/ios/__init__.py +129 -0
  130. kivyforge/platforms/ios/assets.py +193 -0
  131. kivyforge/platforms/ios/buildsettings.py +128 -0
  132. kivyforge/platforms/ios/cli.py +543 -0
  133. kivyforge/platforms/ios/doctor.py +410 -0
  134. kivyforge/platforms/ios/entitlements.py +34 -0
  135. kivyforge/platforms/ios/generator.py +318 -0
  136. kivyforge/platforms/ios/lock/__init__.py +71 -0
  137. kivyforge/platforms/ios/lock/builder.py +393 -0
  138. kivyforge/platforms/ios/lock/markers.py +69 -0
  139. kivyforge/platforms/ios/lock/model.py +95 -0
  140. kivyforge/platforms/ios/lock/python_meta.py +187 -0
  141. kivyforge/platforms/ios/lock/reader.py +191 -0
  142. kivyforge/platforms/ios/lock/resolver.py +304 -0
  143. kivyforge/platforms/ios/lock/spm.py +217 -0
  144. kivyforge/platforms/ios/lock/writer.py +114 -0
  145. kivyforge/platforms/ios/lock/xcframework.py +188 -0
  146. kivyforge/platforms/ios/materialize.py +59 -0
  147. kivyforge/platforms/ios/plist.py +91 -0
  148. kivyforge/platforms/ios/privacy.py +39 -0
  149. kivyforge/platforms/ios/skeleton.py +165 -0
  150. kivyforge/platforms/ios/sources.py +63 -0
  151. kivyforge/platforms/ios/staging.py +225 -0
  152. kivyforge/platforms/ios/swift_packages.py +324 -0
  153. kivyforge/platforms/ios/templates/kivyforge_bootstrap.h +34 -0
  154. kivyforge/platforms/ios/templates/kivyforge_bootstrap.m +146 -0
  155. kivyforge/platforms/ios/templates/main.m +15 -0
  156. kivyforge/platforms/ios/templates/main_config.h.in +12 -0
  157. kivyforge/platforms/ios/xcframeworks.py +194 -0
  158. kivyforge/platforms/ios/xcode/__init__.py +76 -0
  159. kivyforge/platforms/ios/xcode/commands.py +296 -0
  160. kivyforge/platforms/ios/xcode/runner.py +316 -0
  161. kivyforge/platforms/linux/__init__.py +120 -0
  162. kivyforge/platforms/linux/appimage.py +169 -0
  163. kivyforge/platforms/linux/bundle.py +235 -0
  164. kivyforge/platforms/linux/cli.py +232 -0
  165. kivyforge/platforms/linux/desktop.py +71 -0
  166. kivyforge/platforms/linux/doctor.py +438 -0
  167. kivyforge/platforms/linux/elftools.py +95 -0
  168. kivyforge/platforms/linux/icons.py +129 -0
  169. kivyforge/platforms/linux/launcher.py +107 -0
  170. kivyforge/platforms/linux/lock/__init__.py +126 -0
  171. kivyforge/platforms/linux/lock/profile.py +254 -0
  172. kivyforge/platforms/linux/lock/runtime.py +75 -0
  173. kivyforge/platforms/linux/native_stage.py +47 -0
  174. kivyforge/platforms/linux/runtime_stage.py +107 -0
  175. kivyforge/platforms/linux/wheels_stage.py +103 -0
  176. kivyforge/platforms/macos/__init__.py +121 -0
  177. kivyforge/platforms/macos/bundle.py +255 -0
  178. kivyforge/platforms/macos/cli.py +269 -0
  179. kivyforge/platforms/macos/doctor.py +480 -0
  180. kivyforge/platforms/macos/icns.py +75 -0
  181. kivyforge/platforms/macos/launcher.py +118 -0
  182. kivyforge/platforms/macos/lock/__init__.py +112 -0
  183. kivyforge/platforms/macos/lock/profile.py +119 -0
  184. kivyforge/platforms/macos/lock/runtime.py +58 -0
  185. kivyforge/platforms/macos/machotools.py +138 -0
  186. kivyforge/platforms/macos/native_stage.py +47 -0
  187. kivyforge/platforms/macos/notarize.py +143 -0
  188. kivyforge/platforms/macos/plist.py +37 -0
  189. kivyforge/platforms/macos/runtime_stage.py +110 -0
  190. kivyforge/platforms/macos/signing.py +78 -0
  191. kivyforge/platforms/macos/wheels_stage.py +108 -0
  192. kivyforge/platforms/windows/__init__.py +133 -0
  193. kivyforge/platforms/windows/assets.py +76 -0
  194. kivyforge/platforms/windows/bundle.py +298 -0
  195. kivyforge/platforms/windows/cli.py +314 -0
  196. kivyforge/platforms/windows/doctor.py +563 -0
  197. kivyforge/platforms/windows/fsswap.py +87 -0
  198. kivyforge/platforms/windows/icons.py +64 -0
  199. kivyforge/platforms/windows/launcher/__init__.py +193 -0
  200. kivyforge/platforms/windows/launcher/build_launcher.py +507 -0
  201. kivyforge/platforms/windows/launcher/launcher.c +379 -0
  202. kivyforge/platforms/windows/lock/__init__.py +112 -0
  203. kivyforge/platforms/windows/lock/profile.py +101 -0
  204. kivyforge/platforms/windows/lock/runtime.py +64 -0
  205. kivyforge/platforms/windows/naming.py +52 -0
  206. kivyforge/platforms/windows/native_stage.py +66 -0
  207. kivyforge/platforms/windows/petools.py +98 -0
  208. kivyforge/platforms/windows/rcedit.py +116 -0
  209. kivyforge/platforms/windows/runtime_stage.py +221 -0
  210. kivyforge/platforms/windows/signing.py +168 -0
  211. kivyforge/platforms/windows/vendor/LICENSE.rcedit +29 -0
  212. kivyforge/platforms/windows/vendor/NOTICE +17 -0
  213. kivyforge/platforms/windows/vendor/SHA256SUMS +4 -0
  214. kivyforge/platforms/windows/vendor/TOOLSET.txt +4 -0
  215. kivyforge/platforms/windows/vendor/__init__.py +12 -0
  216. kivyforge/platforms/windows/vendor/fetch_rcedit.py +97 -0
  217. kivyforge/platforms/windows/vendor/launcher-amd64.exe +0 -0
  218. kivyforge/platforms/windows/vendor/rcedit-x64.exe +0 -0
  219. kivyforge/platforms/windows/wheels_stage.py +139 -0
  220. kivyforge-3.0.0.dev0.dist-info/METADATA +609 -0
  221. kivyforge-3.0.0.dev0.dist-info/RECORD +225 -0
  222. kivyforge-3.0.0.dev0.dist-info/WHEEL +5 -0
  223. kivyforge-3.0.0.dev0.dist-info/entry_points.txt +3 -0
  224. kivyforge-3.0.0.dev0.dist-info/licenses/LICENSE +21 -0
  225. 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