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,214 @@
|
|
|
1
|
+
"""Release manifest-policy preflight (android/04 §release policy, android/06).
|
|
2
|
+
|
|
3
|
+
Raw XML + attribute passthrough make it possible to ship a footgun (an
|
|
4
|
+
accidentally-exported component, a debuggable/cleartext release, the
|
|
5
|
+
``org.example.*`` placeholder). ``kivyforge package`` lints the *generated*
|
|
6
|
+
manifest and **fails before signing** on a violation.
|
|
7
|
+
|
|
8
|
+
Scope note (android/06): a full release lint delegates the well-covered checks
|
|
9
|
+
to a curated Android ``lintRelease`` subset (see ``LINT_CHECKS``, emitted into
|
|
10
|
+
``app/build.gradle`` and run by ``kivyforge package``). This module hand-rolls
|
|
11
|
+
the checks Lint misses or that are kivyforge-specific.
|
|
12
|
+
|
|
13
|
+
It runs **twice**: first over the manifest kivyforge generated, to fail before
|
|
14
|
+
any Gradle work, and then over AGP's *merged* release manifest — the one that is
|
|
15
|
+
actually packaged, including whatever ``.aar``/Maven library manifests
|
|
16
|
+
contribute. Because a library's component cannot be edited out of pyproject.toml,
|
|
17
|
+
``[tool.kivy.android.manifest].allow_exported`` is the escape hatch for an
|
|
18
|
+
exported component a dependency insists on. Findings are (severity, message);
|
|
19
|
+
any FAIL aborts.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import xml.etree.ElementTree as ET
|
|
25
|
+
from collections.abc import Iterable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
|
29
|
+
_NS = f"{{{ANDROID_NS}}}"
|
|
30
|
+
|
|
31
|
+
# Exported components kivyforge itself ships in *every* generated project, not
|
|
32
|
+
# something a user's pyproject.toml controls, so they are baked into the
|
|
33
|
+
# allowlist rather than making every user rediscover and add them via
|
|
34
|
+
# allow_exported:
|
|
35
|
+
# - org.kivy.android.PythonActivity: the app's own launcher.
|
|
36
|
+
# - androidx.profileinstaller.ProfileInstallReceiver: pulled in transitively
|
|
37
|
+
# by the hardcoded `com.google.android.material` dependency (generate/
|
|
38
|
+
# project.py). It is exported by AndroidX's own design so ADB/Play/
|
|
39
|
+
# Macrobenchmark tooling can trigger baseline-profile installation, and is
|
|
40
|
+
# itself gated by the system-only android.permission.DUMP, so no
|
|
41
|
+
# third-party app can reach it.
|
|
42
|
+
BOOTSTRAP_EXPORTED = {
|
|
43
|
+
"org.kivy.android.PythonActivity",
|
|
44
|
+
"androidx.profileinstaller.ProfileInstallReceiver",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# The curated `lintRelease` subset the generated project runs as errors. These
|
|
48
|
+
# are the checks Lint already does better than a hand-rolled XML walk: exported
|
|
49
|
+
# components reachable without a permission, the backup posture, and cleartext
|
|
50
|
+
# HTTP (both the manifest flag and a network-security-config that re-enables it).
|
|
51
|
+
# Kept deliberately small: `checkOnly` means everything else stays advisory, so
|
|
52
|
+
# a Lint release with new checks can't spuriously block someone's release.
|
|
53
|
+
LINT_CHECKS = (
|
|
54
|
+
"AllowBackup",
|
|
55
|
+
"ExportedActivity",
|
|
56
|
+
"ExportedContentProvider",
|
|
57
|
+
"ExportedPreferenceActivity",
|
|
58
|
+
"ExportedReceiver",
|
|
59
|
+
"ExportedService",
|
|
60
|
+
"InsecureBaseConfiguration",
|
|
61
|
+
"IntentFilterExportedReceiver",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class PolicyFinding:
|
|
67
|
+
severity: str # "FAIL" | "INFO"
|
|
68
|
+
message: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ManifestPolicyError(Exception):
|
|
72
|
+
def __init__(self, fails: list[PolicyFinding]):
|
|
73
|
+
self.fails = fails
|
|
74
|
+
joined = "\n".join(f" • {f.message}" for f in fails)
|
|
75
|
+
super().__init__(
|
|
76
|
+
"release manifest policy check failed (blocked before signing):\n"
|
|
77
|
+
f"{joined}\n"
|
|
78
|
+
" Fix these through the manifest escape hatches "
|
|
79
|
+
"(passthrough / raw XML), never by editing the generated file "
|
|
80
|
+
"(android/04 §release policy)."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Dangerous runtime permissions worth an INFO reminder (need a runtime request).
|
|
85
|
+
_DANGEROUS = {
|
|
86
|
+
"android.permission.CAMERA",
|
|
87
|
+
"android.permission.RECORD_AUDIO",
|
|
88
|
+
"android.permission.ACCESS_FINE_LOCATION",
|
|
89
|
+
"android.permission.ACCESS_COARSE_LOCATION",
|
|
90
|
+
"android.permission.READ_CONTACTS",
|
|
91
|
+
"android.permission.READ_EXTERNAL_STORAGE",
|
|
92
|
+
"android.permission.WRITE_EXTERNAL_STORAGE",
|
|
93
|
+
"android.permission.READ_PHONE_STATE",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_release_manifest(
|
|
98
|
+
manifest_xml: str, *, package: str, allow_exported: Iterable[str] = ()
|
|
99
|
+
) -> list[PolicyFinding]:
|
|
100
|
+
"""Return all findings; the caller raises on any FAIL. INFO is advisory."""
|
|
101
|
+
findings: list[PolicyFinding] = []
|
|
102
|
+
allowed = BOOTSTRAP_EXPORTED | set(allow_exported)
|
|
103
|
+
root = ET.fromstring(manifest_xml)
|
|
104
|
+
app = root.find("application")
|
|
105
|
+
|
|
106
|
+
# 1. applicationId must not be the init placeholder.
|
|
107
|
+
if package.startswith("org.example."):
|
|
108
|
+
findings.append(
|
|
109
|
+
PolicyFinding(
|
|
110
|
+
"FAIL",
|
|
111
|
+
f"applicationId {package!r} is the init placeholder "
|
|
112
|
+
"(org.example.*); set a real package before release.",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# 2. debuggable must not be forced true.
|
|
117
|
+
if app is not None and app.get(f"{_NS}debuggable") == "true":
|
|
118
|
+
findings.append(
|
|
119
|
+
PolicyFinding(
|
|
120
|
+
"FAIL", "android:debuggable is forced true in a release build."
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# 3. Exactly one LAUNCHER activity, and it is the bootstrap's.
|
|
125
|
+
launchers = []
|
|
126
|
+
for activity in root.iter("activity"):
|
|
127
|
+
for category in activity.iter("category"):
|
|
128
|
+
if category.get(f"{_NS}name") == "android.intent.category.LAUNCHER":
|
|
129
|
+
launchers.append(activity.get(f"{_NS}name"))
|
|
130
|
+
if len(launchers) != 1:
|
|
131
|
+
findings.append(
|
|
132
|
+
PolicyFinding(
|
|
133
|
+
"FAIL",
|
|
134
|
+
f"expected exactly one LAUNCHER activity, found {len(launchers)}: "
|
|
135
|
+
f"{launchers}.",
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# 4. Every intent-filtered component sets exported explicitly, and only the
|
|
140
|
+
# bootstrap components are exported.
|
|
141
|
+
for kind in ("activity", "service", "receiver", "provider"):
|
|
142
|
+
for comp in root.iter(kind):
|
|
143
|
+
name = comp.get(f"{_NS}name")
|
|
144
|
+
has_filter = comp.find("intent-filter") is not None
|
|
145
|
+
exported = comp.get(f"{_NS}exported")
|
|
146
|
+
if has_filter and exported is None:
|
|
147
|
+
findings.append(
|
|
148
|
+
PolicyFinding(
|
|
149
|
+
"FAIL",
|
|
150
|
+
f"{kind} {name!r} has an intent-filter but no explicit "
|
|
151
|
+
"android:exported.",
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
if exported == "true" and name not in allowed:
|
|
155
|
+
findings.append(
|
|
156
|
+
PolicyFinding(
|
|
157
|
+
"FAIL",
|
|
158
|
+
f"{kind} {name!r} is exported; only "
|
|
159
|
+
f"{sorted(allowed)} may be exported. If a dependency "
|
|
160
|
+
"requires this component to be exported, add it to "
|
|
161
|
+
"[tool.kivy.android.manifest].allow_exported.",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# 5. No conflicting deep-link <data> (same scheme+host on two filters).
|
|
166
|
+
seen_data: set[tuple[str, str]] = set()
|
|
167
|
+
for data in root.iter("data"):
|
|
168
|
+
key = (data.get(f"{_NS}scheme", ""), data.get(f"{_NS}host", ""))
|
|
169
|
+
if key != ("", "") and key in seen_data:
|
|
170
|
+
findings.append(
|
|
171
|
+
PolicyFinding(
|
|
172
|
+
"FAIL",
|
|
173
|
+
f"conflicting deep-link <data> scheme/host {key} appears on "
|
|
174
|
+
"more than one intent-filter.",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
seen_data.add(key)
|
|
178
|
+
|
|
179
|
+
# 6. Cleartext posture (INFO if explicitly enabled).
|
|
180
|
+
if app is not None and app.get(f"{_NS}usesCleartextTraffic") == "true":
|
|
181
|
+
findings.append(
|
|
182
|
+
PolicyFinding(
|
|
183
|
+
"INFO",
|
|
184
|
+
"android:usesCleartextTraffic is true — the release permits "
|
|
185
|
+
"cleartext HTTP.",
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# 7. Dangerous runtime permissions (INFO reminder).
|
|
190
|
+
for perm in root.iter("uses-permission"):
|
|
191
|
+
name = perm.get(f"{_NS}name")
|
|
192
|
+
if name in _DANGEROUS:
|
|
193
|
+
findings.append(
|
|
194
|
+
PolicyFinding(
|
|
195
|
+
"INFO",
|
|
196
|
+
f"{name} is a dangerous runtime permission; it needs a "
|
|
197
|
+
"runtime request (not just the manifest entry).",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return findings
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def enforce_release_manifest(
|
|
205
|
+
manifest_xml: str, *, package: str, allow_exported: Iterable[str] = ()
|
|
206
|
+
) -> list[PolicyFinding]:
|
|
207
|
+
"""Raise ``ManifestPolicyError`` on any FAIL; return the INFO findings."""
|
|
208
|
+
findings = check_release_manifest(
|
|
209
|
+
manifest_xml, package=package, allow_exported=allow_exported
|
|
210
|
+
)
|
|
211
|
+
fails = [f for f in findings if f.severity == "FAIL"]
|
|
212
|
+
if fails:
|
|
213
|
+
raise ManifestPolicyError(fails)
|
|
214
|
+
return [f for f in findings if f.severity == "INFO"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Release signing preflight + Gradle signingConfig values (android/06, 07).
|
|
2
|
+
|
|
3
|
+
Android signing is **mandatory** and kivyforge owns it via Gradle's signing
|
|
4
|
+
config. The preflight resolves the effective keystore/alias (overlay or CLI
|
|
5
|
+
override), confirms the passwords are present in the configured env vars, and
|
|
6
|
+
verifies the alias exists — failing fast **before** Gradle, with the android/06
|
|
7
|
+
remediation text. Passwords are read by Gradle at build time from the env;
|
|
8
|
+
kivyforge never writes them to disk.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from kivyforge.config.model import AndroidSigningConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SigningError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ResolvedSigning:
|
|
28
|
+
"""The effective, verified signing identity for a release build."""
|
|
29
|
+
|
|
30
|
+
keystore: Path
|
|
31
|
+
key_alias: str
|
|
32
|
+
store_password_env: str
|
|
33
|
+
key_password_env: str
|
|
34
|
+
v1_signing: bool
|
|
35
|
+
v2_signing: bool
|
|
36
|
+
v3_signing: bool
|
|
37
|
+
v4_signing: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_signing(
|
|
41
|
+
config: AndroidSigningConfig,
|
|
42
|
+
*,
|
|
43
|
+
project_root: Path,
|
|
44
|
+
keystore_override: str | None = None,
|
|
45
|
+
key_alias_override: str | None = None,
|
|
46
|
+
) -> ResolvedSigning:
|
|
47
|
+
"""Resolve + verify the signing identity; raise ``SigningError`` if unusable."""
|
|
48
|
+
keystore = keystore_override or config.keystore
|
|
49
|
+
key_alias = key_alias_override or config.key_alias
|
|
50
|
+
if not keystore or not key_alias:
|
|
51
|
+
raise SigningError(
|
|
52
|
+
"code signing required to package a release, but no keystore is "
|
|
53
|
+
"resolved.\n"
|
|
54
|
+
" Set it one of these ways:\n"
|
|
55
|
+
" • [tool.kivy.android.signing] keystore/key_alias in "
|
|
56
|
+
"pyproject.toml, then re-lock\n"
|
|
57
|
+
" • kivyforge package --keystore release.keystore "
|
|
58
|
+
"--key-alias upload\n"
|
|
59
|
+
" And export the passwords:\n"
|
|
60
|
+
" • export KIVYFORGE_KEYSTORE_PASSWORD=... "
|
|
61
|
+
"(and KIVYFORGE_KEY_PASSWORD if different)"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
keystore_path = Path(keystore)
|
|
65
|
+
if not keystore_path.is_absolute():
|
|
66
|
+
keystore_path = (project_root / keystore_path).resolve()
|
|
67
|
+
if not keystore_path.is_file():
|
|
68
|
+
raise SigningError(
|
|
69
|
+
f"signing keystore not found: {keystore_path}\n"
|
|
70
|
+
" Provide a valid keystore path (repo-relative or absolute)."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
store_pw = os.environ.get(config.store_password_env)
|
|
74
|
+
if not store_pw:
|
|
75
|
+
raise SigningError(
|
|
76
|
+
f"the keystore password env var ${config.store_password_env} is not "
|
|
77
|
+
"set.\n export "
|
|
78
|
+
f"{config.store_password_env}=... before packaging a release."
|
|
79
|
+
)
|
|
80
|
+
# The key password is optional: if its env var is unset, Gradle's signing
|
|
81
|
+
# config falls back to the store password (keytool convention, android/01).
|
|
82
|
+
|
|
83
|
+
_verify_alias(keystore_path, key_alias, store_pw)
|
|
84
|
+
|
|
85
|
+
return ResolvedSigning(
|
|
86
|
+
keystore=keystore_path,
|
|
87
|
+
key_alias=key_alias,
|
|
88
|
+
store_password_env=config.store_password_env,
|
|
89
|
+
key_password_env=config.key_password_env,
|
|
90
|
+
v1_signing=config.v1_signing,
|
|
91
|
+
v2_signing=config.v2_signing,
|
|
92
|
+
v3_signing=config.v3_signing,
|
|
93
|
+
v4_signing=config.v4_signing,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _verify_alias(keystore: Path, alias: str, store_password: str) -> None:
|
|
98
|
+
keytool = shutil.which("keytool")
|
|
99
|
+
if keytool is None:
|
|
100
|
+
# Non-fatal: a JDK is a doctor prerequisite; Gradle will surface a
|
|
101
|
+
# clear error if the alias is truly wrong. Don't block on tool absence.
|
|
102
|
+
return
|
|
103
|
+
proc = subprocess.run(
|
|
104
|
+
[
|
|
105
|
+
keytool,
|
|
106
|
+
"-list",
|
|
107
|
+
"-keystore",
|
|
108
|
+
str(keystore),
|
|
109
|
+
"-alias",
|
|
110
|
+
alias,
|
|
111
|
+
"-storepass",
|
|
112
|
+
store_password,
|
|
113
|
+
],
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
)
|
|
117
|
+
if proc.returncode != 0:
|
|
118
|
+
raise SigningError(
|
|
119
|
+
f"key alias {alias!r} not found in {keystore.name} (or the keystore "
|
|
120
|
+
"password is wrong).\n"
|
|
121
|
+
f" keytool said:\n {(proc.stderr or proc.stdout).strip()}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def signing_config_gradle(signing: ResolvedSigning) -> str:
|
|
126
|
+
"""The ``signingConfigs { release { … } }`` block for app/build.gradle.
|
|
127
|
+
|
|
128
|
+
Reads passwords from the env at Gradle time via ``System.getenv`` — never
|
|
129
|
+
written to disk. The v1–v4 scheme toggles apply to ``.apk`` outputs.
|
|
130
|
+
"""
|
|
131
|
+
keystore = str(signing.keystore).replace("\\", "/")
|
|
132
|
+
return f"""\
|
|
133
|
+
signingConfigs {{
|
|
134
|
+
release {{
|
|
135
|
+
storeFile file('{keystore}')
|
|
136
|
+
storePassword System.getenv('{signing.store_password_env}')
|
|
137
|
+
keyAlias '{signing.key_alias}'
|
|
138
|
+
keyPassword System.getenv('{signing.key_password_env}') \
|
|
139
|
+
?: System.getenv('{signing.store_password_env}')
|
|
140
|
+
enableV1Signing {str(signing.v1_signing).lower()}
|
|
141
|
+
enableV2Signing {str(signing.v2_signing).lower()}
|
|
142
|
+
enableV3Signing {str(signing.v3_signing).lower()}
|
|
143
|
+
enableV4Signing {str(signing.v4_signing).lower()}
|
|
144
|
+
}}
|
|
145
|
+
}}"""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""``kivyforge run --smoke`` — the contract smoke test (android/06 §--smoke).
|
|
2
|
+
|
|
3
|
+
Drives the generated instrumented test via Gradle's own connected-test path
|
|
4
|
+
(``connectedDebugAndroidTest`` / ``connectedReleaseAndroidTest``), which
|
|
5
|
+
validates the load-bearing runtime mechanisms — the extension-module finder, the
|
|
6
|
+
pyjnius ``invoke0`` glue, and, for a project that declares services, that a
|
|
7
|
+
declared service really starts an interpreter in its own process — on a real
|
|
8
|
+
device/emulator with no app-specific test code. Non-zero exit on any failure; it
|
|
9
|
+
is the mechanism that promotes a compatibility-matrix row to Validated
|
|
10
|
+
(android/08).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .gradlew import GradleError, run_gradle
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SmokeError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_smoke(project_dir: Path, *, release: bool = False) -> None:
|
|
25
|
+
"""Run the generated contract test; raise ``SmokeError`` on failure.
|
|
26
|
+
|
|
27
|
+
A device/emulator must already be attached (Gradle's connected-test task
|
|
28
|
+
selects it). ``release`` targets the release variant so the probe also
|
|
29
|
+
exercises byte-compilation, stripping, and R8 under a test signing config.
|
|
30
|
+
"""
|
|
31
|
+
task = "connectedReleaseAndroidTest" if release else "connectedDebugAndroidTest"
|
|
32
|
+
try:
|
|
33
|
+
run_gradle(project_dir, [task])
|
|
34
|
+
except GradleError as exc:
|
|
35
|
+
raise SmokeError(
|
|
36
|
+
f"the contract smoke test failed ({task}).\n"
|
|
37
|
+
" This means a load-bearing runtime mechanism broke on-device — "
|
|
38
|
+
"the extension-module finder, the pyjnius invoke0 glue, or a "
|
|
39
|
+
"declared service's interpreter.\n"
|
|
40
|
+
f" Gradle output is above; the test report is under "
|
|
41
|
+
f"{project_dir / 'app' / 'build' / 'reports' / 'androidTests'}.\n"
|
|
42
|
+
f" {exc}"
|
|
43
|
+
) from exc
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Generate the splash-screen resources (android/01 §splash, android/04).
|
|
2
|
+
|
|
3
|
+
The splash model is the platform's: *a centered icon on a background*, driven
|
|
4
|
+
entirely by ``windowSplashScreen*`` theme attributes with no runtime dependency
|
|
5
|
+
and no code in the activity. Two layers of resources come out of one config:
|
|
6
|
+
|
|
7
|
+
- ``values-v31/`` carries the real attributes. On Android 12+ the OS draws the
|
|
8
|
+
system splash from them, so an ``AnimatedVectorDrawable`` source animates and
|
|
9
|
+
``animation_duration``/``branding`` mean what they say.
|
|
10
|
+
- The base theme's ``android:windowBackground`` gets a generated layer-list of
|
|
11
|
+
the same background + icon. That is what fills the gap below API 31 — where no
|
|
12
|
+
system splash exists at all — and, on every API level, what the window shows
|
|
13
|
+
between the splash handing off and Kivy drawing its first frame. Without it
|
|
14
|
+
that handoff cuts to a blank window, which for a cold start (unpack the bundle,
|
|
15
|
+
boot CPython) is seconds of apparent hang.
|
|
16
|
+
|
|
17
|
+
**Icon sizing** follows the platform spec: the icon drawable is 288dp with the
|
|
18
|
+
inner two thirds guaranteed visible, or 240dp/160dp when ``icon_background``
|
|
19
|
+
puts a colored disc behind it. A raster source is scaled into that inner box on
|
|
20
|
+
a transparent canvas, so a plain square PNG is not clipped by the circular mask
|
|
21
|
+
the system splash applies.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from kivyforge.config.model import AndroidConfig
|
|
30
|
+
|
|
31
|
+
# The dp buckets a generated raster drawable is emitted for.
|
|
32
|
+
DENSITIES = {"mdpi": 1, "hdpi": 1.5, "xhdpi": 2, "xxhdpi": 3, "xxxhdpi": 4}
|
|
33
|
+
|
|
34
|
+
# Platform spec: canvas dp -> guaranteed-visible inner dp.
|
|
35
|
+
ICON_CANVAS_DP = 288
|
|
36
|
+
ICON_INNER_DP = 192
|
|
37
|
+
# With a colored disc behind the icon the canvas shrinks.
|
|
38
|
+
ICON_CANVAS_WITH_BACKGROUND_DP = 240
|
|
39
|
+
ICON_INNER_WITH_BACKGROUND_DP = 160
|
|
40
|
+
# The platform's maximum branding-image box.
|
|
41
|
+
BRANDING_MAX_DP = (200, 80)
|
|
42
|
+
|
|
43
|
+
# windowSplashScreen* are API 31 attributes; AAPT resolves attribute names
|
|
44
|
+
# against the compile SDK regardless of which values-* folder they sit in.
|
|
45
|
+
SPLASH_MIN_COMPILE_SDK = 31
|
|
46
|
+
|
|
47
|
+
DEFAULT_BACKGROUND = "#ffffff"
|
|
48
|
+
|
|
49
|
+
ICON_DRAWABLE = "kf_splash_icon"
|
|
50
|
+
BRANDING_DRAWABLE = "kf_splash_branding"
|
|
51
|
+
WINDOW_DRAWABLE = "kf_splash"
|
|
52
|
+
BACKGROUND_COLOR = "kf_splash_background"
|
|
53
|
+
ICON_BACKGROUND_COLOR = "kf_splash_icon_background"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SplashError(Exception):
|
|
57
|
+
"""The configured splash resources could not be generated."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class SplashTheme:
|
|
62
|
+
"""What the generated app theme has to say about the splash.
|
|
63
|
+
|
|
64
|
+
Empty when no splash is configured, in which case the theme is emitted
|
|
65
|
+
exactly as it was before splash support existed.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
window_background: str | None = None
|
|
69
|
+
v31_items: tuple[tuple[str, str], ...] = ()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def configured(self) -> bool:
|
|
73
|
+
return self.window_background is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write_splash(res: Path, android: AndroidConfig, project_root: Path) -> SplashTheme:
|
|
77
|
+
"""Write the splash resources under *res*; return the theme contribution."""
|
|
78
|
+
splash = android.splash
|
|
79
|
+
if not splash.source:
|
|
80
|
+
return SplashTheme()
|
|
81
|
+
if android.compile_sdk < SPLASH_MIN_COMPILE_SDK:
|
|
82
|
+
raise SplashError(
|
|
83
|
+
"[tool.kivy.android.splash] needs compile_sdk >= "
|
|
84
|
+
f"{SPLASH_MIN_COMPILE_SDK} (windowSplashScreen* are API "
|
|
85
|
+
f"{SPLASH_MIN_COMPILE_SDK} attributes); this project compiles "
|
|
86
|
+
f"against android-{android.compile_sdk}.\n"
|
|
87
|
+
" Raise [tool.kivy.android].compile_sdk, or drop the "
|
|
88
|
+
"[tool.kivy.android.splash] table."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
background = splash.background or DEFAULT_BACKGROUND
|
|
92
|
+
icon_background = splash.icon_background
|
|
93
|
+
canvas_dp, inner_dp = (
|
|
94
|
+
(ICON_CANVAS_WITH_BACKGROUND_DP, ICON_INNER_WITH_BACKGROUND_DP)
|
|
95
|
+
if icon_background
|
|
96
|
+
else (ICON_CANVAS_DP, ICON_INNER_DP)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
_write_colors(res, background=background, icon_background=icon_background)
|
|
100
|
+
animated = _write_icon(
|
|
101
|
+
res,
|
|
102
|
+
project_root / splash.source,
|
|
103
|
+
canvas_dp=canvas_dp,
|
|
104
|
+
inner_dp=inner_dp,
|
|
105
|
+
)
|
|
106
|
+
if splash.branding:
|
|
107
|
+
_write_branding(res, project_root / splash.branding)
|
|
108
|
+
_write_window_drawable(res)
|
|
109
|
+
|
|
110
|
+
items = [
|
|
111
|
+
("android:windowSplashScreenBackground", f"@color/{BACKGROUND_COLOR}"),
|
|
112
|
+
("android:windowSplashScreenAnimatedIcon", f"@drawable/{ICON_DRAWABLE}"),
|
|
113
|
+
]
|
|
114
|
+
if icon_background:
|
|
115
|
+
items.append(
|
|
116
|
+
(
|
|
117
|
+
"android:windowSplashScreenIconBackgroundColor",
|
|
118
|
+
f"@color/{ICON_BACKGROUND_COLOR}",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
# Duration only drives an AnimatedVectorDrawable; emitting it for a static
|
|
122
|
+
# PNG would delay every launch for nothing.
|
|
123
|
+
if splash.animation_duration is not None and animated:
|
|
124
|
+
items.append(
|
|
125
|
+
(
|
|
126
|
+
"android:windowSplashScreenAnimationDuration",
|
|
127
|
+
str(splash.animation_duration),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
if splash.branding:
|
|
131
|
+
items.append(
|
|
132
|
+
(
|
|
133
|
+
"android:windowSplashScreenBrandingImage",
|
|
134
|
+
f"@drawable/{BRANDING_DRAWABLE}",
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return SplashTheme(
|
|
138
|
+
window_background=f"@drawable/{WINDOW_DRAWABLE}", v31_items=tuple(items)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def window_drawable_xml() -> str:
|
|
143
|
+
"""The ``windowBackground`` layer-list: background color + centered icon."""
|
|
144
|
+
return (
|
|
145
|
+
'<?xml version="1.0" encoding="utf-8"?>\n'
|
|
146
|
+
'<layer-list xmlns:android="http://schemas.android.com/apk/res/android">\n'
|
|
147
|
+
f' <item android:drawable="@color/{BACKGROUND_COLOR}" />\n'
|
|
148
|
+
' <item android:gravity="center">\n'
|
|
149
|
+
f' <bitmap android:src="@drawable/{ICON_DRAWABLE}" '
|
|
150
|
+
'android:gravity="center" />\n'
|
|
151
|
+
" </item>\n"
|
|
152
|
+
"</layer-list>\n"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def vector_window_drawable_xml() -> str:
|
|
157
|
+
"""The same layer-list for a vector icon (``<bitmap>`` needs a raster)."""
|
|
158
|
+
return (
|
|
159
|
+
'<?xml version="1.0" encoding="utf-8"?>\n'
|
|
160
|
+
'<layer-list xmlns:android="http://schemas.android.com/apk/res/android">\n'
|
|
161
|
+
f' <item android:drawable="@color/{BACKGROUND_COLOR}" />\n'
|
|
162
|
+
f' <item android:drawable="@drawable/{ICON_DRAWABLE}" '
|
|
163
|
+
'android:gravity="center" />\n'
|
|
164
|
+
"</layer-list>\n"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _write_window_drawable(res: Path) -> None:
|
|
169
|
+
drawable = res / "drawable"
|
|
170
|
+
drawable.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
vector = (drawable / f"{ICON_DRAWABLE}.xml").is_file()
|
|
172
|
+
xml = vector_window_drawable_xml() if vector else window_drawable_xml()
|
|
173
|
+
_write(drawable / f"{WINDOW_DRAWABLE}.xml", xml)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _write_colors(res: Path, *, background: str, icon_background: str | None) -> None:
|
|
177
|
+
lines = [f' <color name="{BACKGROUND_COLOR}">{background}</color>']
|
|
178
|
+
if icon_background:
|
|
179
|
+
lines.append(
|
|
180
|
+
f' <color name="{ICON_BACKGROUND_COLOR}">{icon_background}</color>'
|
|
181
|
+
)
|
|
182
|
+
values = res / "values"
|
|
183
|
+
values.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
_write(
|
|
185
|
+
values / "kf_splash.xml",
|
|
186
|
+
"<resources>\n" + "\n".join(lines) + "\n</resources>\n",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _write_icon(res: Path, source: Path, *, canvas_dp: int, inner_dp: int) -> bool:
|
|
191
|
+
"""Emit the splash icon; return True when it is an animatable vector."""
|
|
192
|
+
if not source.is_file():
|
|
193
|
+
raise SplashError(
|
|
194
|
+
f"[tool.kivy.android.splash].source not found: {source}\n"
|
|
195
|
+
" Expected a PNG, or an AnimatedVectorDrawable XML for an "
|
|
196
|
+
"animated splash."
|
|
197
|
+
)
|
|
198
|
+
if source.suffix.lower() == ".xml":
|
|
199
|
+
# A vector/AnimatedVectorDrawable is density-independent and the
|
|
200
|
+
# platform animates it as-is; passing it through Pillow would destroy
|
|
201
|
+
# exactly the property that makes it worth using.
|
|
202
|
+
drawable = res / "drawable"
|
|
203
|
+
drawable.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
_write(drawable / f"{ICON_DRAWABLE}.xml", _read_vector(source))
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
image = _open(source)
|
|
208
|
+
for bucket, scale in DENSITIES.items():
|
|
209
|
+
target = res / f"drawable-{bucket}"
|
|
210
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
canvas = round(canvas_dp * scale)
|
|
212
|
+
inner = round(inner_dp * scale)
|
|
213
|
+
_centered(image, canvas=canvas, inner=inner).save(
|
|
214
|
+
target / f"{ICON_DRAWABLE}.png", format="PNG"
|
|
215
|
+
)
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _write_branding(res: Path, source: Path) -> None:
|
|
220
|
+
if not source.is_file():
|
|
221
|
+
raise SplashError(f"[tool.kivy.android.splash].branding not found: {source}")
|
|
222
|
+
image = _open(source)
|
|
223
|
+
max_w, max_h = BRANDING_MAX_DP
|
|
224
|
+
for bucket, scale in DENSITIES.items():
|
|
225
|
+
target = res / f"drawable-{bucket}"
|
|
226
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
_contain(image, round(max_w * scale), round(max_h * scale)).save(
|
|
228
|
+
target / f"{BRANDING_DRAWABLE}.png", format="PNG"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _read_vector(source: Path) -> str:
|
|
233
|
+
try:
|
|
234
|
+
text = source.read_text(encoding="utf-8")
|
|
235
|
+
except OSError as exc:
|
|
236
|
+
raise SplashError(f"cannot read splash source {source}: {exc}") from exc
|
|
237
|
+
if "<animated-vector" not in text and "<vector" not in text:
|
|
238
|
+
raise SplashError(
|
|
239
|
+
f"[tool.kivy.android.splash].source {source.name} is XML but is "
|
|
240
|
+
"neither a <vector> nor an <animated-vector> drawable; AAPT would "
|
|
241
|
+
"reject it.\n"
|
|
242
|
+
" Use a PNG for a static splash, or an AnimatedVectorDrawable "
|
|
243
|
+
"for an animated one."
|
|
244
|
+
)
|
|
245
|
+
return text
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _centered(image, *, canvas: int, inner: int):
|
|
249
|
+
"""*image* scaled to fit *inner*, centered on a transparent *canvas* square."""
|
|
250
|
+
from PIL import Image
|
|
251
|
+
|
|
252
|
+
layer = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0))
|
|
253
|
+
fitted = _contain(image, inner, inner)
|
|
254
|
+
layer.paste(
|
|
255
|
+
fitted,
|
|
256
|
+
((canvas - fitted.width) // 2, (canvas - fitted.height) // 2),
|
|
257
|
+
)
|
|
258
|
+
return layer
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _contain(image, max_w: int, max_h: int):
|
|
262
|
+
"""*image* scaled to fit inside the box, preserving its aspect ratio."""
|
|
263
|
+
from PIL import Image
|
|
264
|
+
|
|
265
|
+
scale = min(max_w / image.width, max_h / image.height)
|
|
266
|
+
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
|
|
267
|
+
return image.resize(size, Image.LANCZOS)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _open(path: Path):
|
|
271
|
+
image_module = _pillow()
|
|
272
|
+
try:
|
|
273
|
+
return image_module.open(path).convert("RGBA")
|
|
274
|
+
except OSError as exc:
|
|
275
|
+
raise SplashError(f"cannot read splash image {path}: {exc}") from exc
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _write(path: Path, text: str) -> None:
|
|
279
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _pillow():
|
|
283
|
+
try:
|
|
284
|
+
from PIL import Image
|
|
285
|
+
except ModuleNotFoundError as exc:
|
|
286
|
+
raise SplashError(
|
|
287
|
+
"generating the splash resources needs Pillow, which is not "
|
|
288
|
+
"installed.\n"
|
|
289
|
+
" Install the Android extra: pip install 'kivyforge[android]'\n"
|
|
290
|
+
" (or use an AnimatedVectorDrawable XML source, which needs no "
|
|
291
|
+
"image processing, or drop [tool.kivy.android.splash])."
|
|
292
|
+
) from exc
|
|
293
|
+
return Image
|