py2apk-compiler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. Py2APK/__init__.py +0 -0
  2. Py2APK/classfile.py +191 -0
  3. Py2APK/cli.py +159 -0
  4. Py2APK/cli_build.py +273 -0
  5. Py2APK/cli_compile.py +93 -0
  6. Py2APK/cli_fetch_jars.py +158 -0
  7. Py2APK/cli_fetch_platform.py +115 -0
  8. Py2APK/cli_init.py +137 -0
  9. Py2APK/cli_setup.py +208 -0
  10. Py2APK/compiler_j.py +895 -0
  11. Py2APK/downloader.py +95 -0
  12. Py2APK/jar_index.py +361 -0
  13. Py2APK/maven_resolve.py +316 -0
  14. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/add2/_template.keep +1 -0
  15. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/blank/_template.keep +1 -0
  16. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/chart/deps.txt +2 -0
  17. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/chart/src/__PACKAGE__/MainActivity.py +33 -0
  18. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/chart/target/AndroidManifest.xml +25 -0
  19. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/chart/target/apktool.yml +20 -0
  20. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/chart/target/res/values/strings.xml +4 -0
  21. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/src/com/example/helloworld/DialogClickListener.py +18 -0
  22. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/src/com/example/helloworld/GreetingTextWatcher.py +37 -0
  23. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/src/com/example/helloworld/MainActivity.py +54 -0
  24. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/target/AndroidManifest.xml +25 -0
  25. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/target/apktool.yml +20 -0
  26. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/target/res/values/strings.xml +4 -0
  27. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/deps.txt +2 -0
  28. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/src/__PACKAGE__/MainActivity.py +73 -0
  29. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/src/__PACKAGE__/RefreshClickListener.py +18 -0
  30. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/src/__PACKAGE__/WeatherCallback.py +27 -0
  31. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/src/__PACKAGE__/WeatherLocationListener.py +18 -0
  32. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/target/AndroidManifest.xml +28 -0
  33. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/target/apktool.yml +20 -0
  34. py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/target/res/values/strings.xml +4 -0
  35. py2apk_compiler-0.1.0.dist-info/METADATA +144 -0
  36. py2apk_compiler-0.1.0.dist-info/RECORD +39 -0
  37. py2apk_compiler-0.1.0.dist-info/WHEEL +5 -0
  38. py2apk_compiler-0.1.0.dist-info/entry_points.txt +2 -0
  39. py2apk_compiler-0.1.0.dist-info/top_level.txt +1 -0
Py2APK/__init__.py ADDED
File without changes
Py2APK/classfile.py ADDED
@@ -0,0 +1,191 @@
1
+ """Parse JVM class files into the members the Jasmin compiler needs.
2
+
3
+ The reader uses only ``struct``. It walks the constant pool and member tables
4
+ and skips attributes by length, so method bodies are never decoded.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+ from dataclasses import dataclass
11
+
12
+
13
+ ACC_PUBLIC = 0x0001
14
+ ACC_PRIVATE = 0x0002
15
+ ACC_PROTECTED = 0x0004
16
+ ACC_STATIC = 0x0008
17
+ ACC_INTERFACE = 0x0200
18
+ ACC_SYNTHETIC = 0x1000
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ClassMember:
23
+ owner: str
24
+ name: str
25
+ descriptor: str
26
+ is_static: bool
27
+ opcode: str = ""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ParsedClass:
32
+ internal: str
33
+ super_internal: str | None
34
+ is_interface: bool
35
+ interfaces: tuple[str, ...]
36
+ methods: tuple[ClassMember, ...]
37
+ fields: tuple[ClassMember, ...]
38
+
39
+
40
+ class ClassFileError(ValueError):
41
+ """Raised when a class file is not readable."""
42
+
43
+
44
+ class _Reader:
45
+ def __init__(self, data: bytes) -> None:
46
+ self.data = data
47
+ self.offset = 0
48
+
49
+ def u1(self) -> int:
50
+ value = self.data[self.offset]
51
+ self.offset += 1
52
+ return value
53
+
54
+ def u2(self) -> int:
55
+ value = struct.unpack_from(">H", self.data, self.offset)[0]
56
+ self.offset += 2
57
+ return value
58
+
59
+ def u4(self) -> int:
60
+ value = struct.unpack_from(">I", self.data, self.offset)[0]
61
+ self.offset += 4
62
+ return value
63
+
64
+ def skip(self, count: int) -> None:
65
+ self.offset += count
66
+
67
+ def bytes(self, count: int) -> bytes:
68
+ chunk = self.data[self.offset : self.offset + count]
69
+ self.offset += count
70
+ return chunk
71
+
72
+
73
+ def parameter_descriptors(params: str) -> tuple[str, ...]:
74
+ index = 0
75
+ found: list[str] = []
76
+ while index < len(params):
77
+ start = index
78
+ index = _skip_field(params, index)
79
+ found.append(params[start:index])
80
+ return tuple(found)
81
+
82
+
83
+ def split_method_descriptor(descriptor: str) -> tuple[str, str]:
84
+ """Return the concatenated parameter descriptors and the result descriptor."""
85
+ if not descriptor.startswith("(") or ")" not in descriptor:
86
+ raise ClassFileError(f"bad method descriptor: {descriptor}")
87
+ index = 1
88
+ while descriptor[index] != ")":
89
+ index = _skip_field(descriptor, index)
90
+ return descriptor[1:index], descriptor[index + 1 :]
91
+
92
+
93
+ def parse_class(data: bytes) -> ParsedClass:
94
+ reader = _Reader(data)
95
+ if len(data) < 8 or reader.u4() != 0xCAFEBABE:
96
+ raise ClassFileError("class file does not start with 0xCAFEBABE")
97
+ _ = reader.u2()
98
+ _ = reader.u2()
99
+ pool = _constant_pool(reader)
100
+ access = reader.u2()
101
+ internal = _class_name(pool, reader.u2())
102
+ super_index = reader.u2()
103
+ super_internal = _class_name(pool, super_index) if super_index else None
104
+ interfaces = tuple(_class_name(pool, reader.u2()) for _ in range(reader.u2()))
105
+ is_interface = bool(access & ACC_INTERFACE)
106
+ fields = tuple(_members(reader, pool, internal, is_interface, methods=False))
107
+ methods = tuple(_members(reader, pool, internal, is_interface, methods=True))
108
+ return ParsedClass(internal, super_internal, is_interface, interfaces, methods, fields)
109
+
110
+
111
+ def _constant_pool(reader: _Reader) -> list[object]:
112
+ count = reader.u2()
113
+ pool: list[object] = [None] * count
114
+ index = 1
115
+ while index < count:
116
+ tag = reader.u1()
117
+ if tag == 1:
118
+ pool[index] = reader.bytes(reader.u2()).decode("utf-8", "surrogateescape")
119
+ elif tag in {7, 8, 16, 19, 20}:
120
+ pool[index] = ("class", reader.u2()) if tag == 7 else reader.u2()
121
+ elif tag in {3, 4, 9, 10, 11, 12, 17, 18}:
122
+ reader.skip(4)
123
+ elif tag in {5, 6}:
124
+ reader.skip(8)
125
+ index += 1
126
+ elif tag == 15:
127
+ reader.skip(3)
128
+ else:
129
+ raise ClassFileError(f"unknown constant-pool tag {tag}")
130
+ index += 1
131
+ return pool
132
+
133
+
134
+ def _class_name(pool: list[object], index: int) -> str:
135
+ entry = pool[index]
136
+ if not isinstance(entry, tuple) or entry[0] != "class":
137
+ raise ClassFileError(f"constant pool {index} is not a class")
138
+ name = pool[entry[1]]
139
+ if not isinstance(name, str):
140
+ raise ClassFileError(f"class name {entry[1]} is not Utf8")
141
+ return name
142
+
143
+
144
+ def _utf8(pool: list[object], index: int) -> str:
145
+ value = pool[index]
146
+ if not isinstance(value, str):
147
+ raise ClassFileError(f"constant pool {index} is not Utf8")
148
+ return value
149
+
150
+
151
+ def _members(
152
+ reader: _Reader,
153
+ pool: list[object],
154
+ owner: str,
155
+ is_interface: bool,
156
+ *,
157
+ methods: bool,
158
+ ) -> list[ClassMember]:
159
+ found: list[ClassMember] = []
160
+ for _ in range(reader.u2()):
161
+ access = reader.u2()
162
+ name = _utf8(pool, reader.u2())
163
+ descriptor = _utf8(pool, reader.u2())
164
+ for _attribute in range(reader.u2()):
165
+ _ = reader.u2()
166
+ reader.skip(reader.u4())
167
+ visible = bool(access & (ACC_PUBLIC | ACC_PROTECTED))
168
+ if not visible or access & ACC_SYNTHETIC or access & ACC_PRIVATE:
169
+ continue
170
+ is_static = bool(access & ACC_STATIC)
171
+ opcode = ""
172
+ if methods:
173
+ if is_static:
174
+ opcode = "invokestatic"
175
+ elif name == "<init>":
176
+ opcode = "invokespecial"
177
+ elif is_interface:
178
+ opcode = "invokeinterface"
179
+ else:
180
+ opcode = "invokevirtual"
181
+ found.append(ClassMember(owner, name, descriptor, is_static, opcode))
182
+ return found
183
+
184
+
185
+ def _skip_field(descriptor: str, index: int) -> int:
186
+ while descriptor[index] == "[":
187
+ index += 1
188
+ if descriptor[index] == "L":
189
+ end = descriptor.index(";", index)
190
+ return end + 1
191
+ return index + 1
Py2APK/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ """Command-line interface for Py2APK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ from Py2APK.cli_build import build_project
11
+ from Py2APK.cli_compile import compile_project
12
+ from Py2APK.cli_fetch_jars import fetch_jars
13
+ from Py2APK.cli_fetch_platform import main as fetch_platform_main
14
+ from Py2APK.cli_setup import download_jars
15
+ from Py2APK.cli_init import init_project
16
+
17
+
18
+ def _setup(args: argparse.Namespace) -> int:
19
+ destination_arg = cast(str | None, args.destination)
20
+ destination = None if destination_arg is None else Path(destination_arg)
21
+ return download_jars(destination)
22
+
23
+
24
+ def _init(args: argparse.Namespace) -> int:
25
+ icon = None if args.icon is None else str(args.icon)
26
+ return init_project(
27
+ template_name=str(args.template),
28
+ icon=icon,
29
+ package=str(args.package),
30
+ name=str(args.name),
31
+ destination=Path(str(args.directory)),
32
+ )
33
+
34
+
35
+ def _fetch_jars(args: argparse.Namespace) -> int:
36
+ repositories = list(args.repositories or [])
37
+ dependency_file = None if args.dependencies is None else Path(args.dependencies)
38
+ return fetch_jars(Path(args.directory).expanduser(), dependency_file, repositories)
39
+
40
+
41
+ def _fetch_platform(args: argparse.Namespace) -> int:
42
+ return fetch_platform_main(Path(args.directory).expanduser())
43
+
44
+
45
+ def _compile(_args: argparse.Namespace) -> int:
46
+ return compile_project()
47
+
48
+
49
+ def _build(args: argparse.Namespace) -> int:
50
+ return build_project(Path(args.directory).expanduser())
51
+
52
+
53
+ def create_parser() -> argparse.ArgumentParser:
54
+ parser = argparse.ArgumentParser(
55
+ prog="py2apk",
56
+ description="Build Android APKs from Python source trees.",
57
+ )
58
+ subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
59
+
60
+ setup_parser = subparsers.add_parser(
61
+ "setup",
62
+ help="set up the required toolchain JAR files",
63
+ )
64
+ _ = setup_parser.add_argument(
65
+ "-d",
66
+ "--dst",
67
+ dest="destination",
68
+ help="destination jars directory (default: detected package assets)",
69
+ )
70
+ setup_parser.set_defaults(handler=_setup)
71
+
72
+ init_parser = subparsers.add_parser(
73
+ "init",
74
+ help="initialize a project from a template",
75
+ )
76
+ init_parser.add_argument(
77
+ "--template",
78
+ default="blank",
79
+ help="project template (default: blank)",
80
+ )
81
+ init_parser.add_argument("--icon", help="path to the app icon")
82
+ init_parser.add_argument(
83
+ "--pkg",
84
+ dest="package",
85
+ required=True,
86
+ help="Android package name",
87
+ )
88
+ init_parser.add_argument("--name", required=True, help="display name of the app")
89
+ init_parser.add_argument("directory", help="directory to create")
90
+ init_parser.set_defaults(handler=_init)
91
+
92
+ compile_parser = subparsers.add_parser(
93
+ "compile",
94
+ help="find Python sources that need Jasmin output",
95
+ )
96
+ compile_parser.set_defaults(handler=_compile)
97
+
98
+ build_parser = subparsers.add_parser(
99
+ "build",
100
+ help="build a project into an APK",
101
+ )
102
+ build_parser.add_argument(
103
+ "directory",
104
+ nargs="?",
105
+ default=".",
106
+ help="project directory (default: current directory)",
107
+ )
108
+ build_parser.set_defaults(handler=_build)
109
+
110
+ fetch_jars_parser = subparsers.add_parser(
111
+ "fetch_jars",
112
+ help="fetch dependency jars and the Android platform jar",
113
+ )
114
+ fetch_jars_parser.add_argument(
115
+ "-r",
116
+ dest="dependencies",
117
+ help="dependency file (default: deps.txt)",
118
+ )
119
+ fetch_jars_parser.add_argument(
120
+ "-R",
121
+ dest="repositories",
122
+ action="append",
123
+ help="extra Maven repository URL, tried before Central (repeatable)",
124
+ )
125
+ fetch_jars_parser.add_argument(
126
+ "directory",
127
+ nargs="?",
128
+ default=".",
129
+ help="project directory (default: current directory)",
130
+ )
131
+ fetch_jars_parser.set_defaults(handler=_fetch_jars)
132
+
133
+ fetch_platform_parser = subparsers.add_parser(
134
+ "fetch_platform",
135
+ help="fetch the Android platform stub jar for targetSdkVersion",
136
+ )
137
+ fetch_platform_parser.add_argument(
138
+ "directory",
139
+ nargs="?",
140
+ default=".",
141
+ help="project directory (default: current directory)",
142
+ )
143
+ fetch_platform_parser.set_defaults(handler=_fetch_platform)
144
+
145
+ return parser
146
+
147
+
148
+ def main(argv: Sequence[str] | None = None) -> int:
149
+ parser = create_parser()
150
+ args = parser.parse_args(argv)
151
+ handler = getattr(args, "handler", None)
152
+ if handler is None:
153
+ parser.print_help()
154
+ return 0
155
+ return handler(args)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ raise SystemExit(main())
Py2APK/cli_build.py ADDED
@@ -0,0 +1,273 @@
1
+ # pyright: reportMissingModuleSource=false
2
+
3
+ """Build an initialized Py2APK project into a signed APK."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import cast
15
+
16
+ import yaml
17
+
18
+ from Py2APK.cli_compile import compile_project
19
+ from Py2APK.cli_fetch_jars import fetch_jars
20
+ from Py2APK.cli_setup import KEY_ALIAS, asset_directories, ensure_keystore
21
+ from Py2APK.jar_index import dex_jars, library_jars
22
+
23
+
24
+ ROOT_BUILD_FILES = ("AndroidManifest.xml", "apktool.yml")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class _Toolchain:
29
+ apktool: Path
30
+ jasmin: Path
31
+ r8: Path
32
+ signer: Path
33
+
34
+
35
+ def _jar_directories(project_root: Path) -> list[Path]:
36
+ directories: list[Path] = []
37
+ configured = os.environ.get("PY2APK_JARS")
38
+ if configured:
39
+ directories.append(Path(configured).expanduser())
40
+ directories.append(project_root / "jars")
41
+ directories.extend(assets / "jars" for assets in asset_directories())
42
+
43
+ unique: list[Path] = []
44
+ for directory in directories:
45
+ if directory not in unique:
46
+ unique.append(directory)
47
+ return unique
48
+
49
+
50
+ def _toolchain(project_root: Path) -> tuple[_Toolchain | None, Path]:
51
+ directories = _jar_directories(project_root)
52
+ fallback = next((path for path in directories if path.is_dir()), directories[0])
53
+ for directory in directories:
54
+ if not directory.is_dir():
55
+ continue
56
+ found = _find_toolchain(directory)
57
+ if found is not None:
58
+ return found, directory
59
+ return None, fallback
60
+
61
+
62
+ def _single_jar(directory: Path, pattern: str) -> Path | None:
63
+ matches = sorted(directory.glob(pattern))
64
+ if len(matches) == 1:
65
+ return matches[0]
66
+ return None
67
+
68
+
69
+ def _find_toolchain(jars_dir: Path) -> _Toolchain | None:
70
+ if not jars_dir.is_dir():
71
+ return None
72
+ apktool = _single_jar(jars_dir, "apktool_*.jar")
73
+ jasmin = _single_jar(jars_dir, "jasmin-*-jar-with-dependencies.jar")
74
+ r8 = _single_jar(jars_dir, "r8-*.jar")
75
+ signer = _single_jar(jars_dir, "uber-apk-signer-*.jar")
76
+ if apktool and jasmin and r8 and signer:
77
+ return _Toolchain(apktool.resolve(), jasmin.resolve(), r8.resolve(), signer.resolve())
78
+ return None
79
+
80
+
81
+ def _sync_build_inputs(project_root: Path) -> None:
82
+ target_root = project_root / "target"
83
+ for filename in ROOT_BUILD_FILES:
84
+ source = project_root / filename
85
+ target = target_root / filename
86
+ if source.is_file() and (
87
+ not target.is_file() or source.stat().st_mtime_ns > target.stat().st_mtime_ns
88
+ ):
89
+ target.parent.mkdir(parents=True, exist_ok=True)
90
+ _ = shutil.copy2(source, target)
91
+
92
+
93
+ def _apk_output(target_root: Path) -> Path:
94
+ config_path = target_root / "apktool.yml"
95
+ try:
96
+ config = cast(object, yaml.safe_load(config_path.read_text()))
97
+ except (OSError, yaml.YAMLError) as error:
98
+ raise ValueError(f"could not read {config_path}: {error}") from error
99
+
100
+ if not isinstance(config, dict):
101
+ raise ValueError(f"invalid apktool configuration: {config_path}")
102
+ values = cast(dict[str, object], config)
103
+ filename = values.get("apkFileName")
104
+ if not isinstance(filename, str) or not filename:
105
+ raise ValueError(f"apkFileName is missing from {config_path}")
106
+ return target_root / "dist" / filename
107
+
108
+
109
+ def _run_command(
110
+ command: Sequence[str],
111
+ project_root: Path,
112
+ visible: Sequence[str] | None = None,
113
+ ) -> int:
114
+ shown = command if visible is None else visible
115
+ print(f"+ {' '.join(shown)}")
116
+ try:
117
+ completed = subprocess.run(command, cwd=project_root, check=False)
118
+ except OSError as error:
119
+ print(f"error: could not run {command[0]!r}: {error}", file=sys.stderr)
120
+ return 1
121
+ if completed.returncode != 0:
122
+ message = (
123
+ f"error: command failed with exit code {completed.returncode}: "
124
+ f"{' '.join(command)}"
125
+ )
126
+ print(message, file=sys.stderr)
127
+ return completed.returncode
128
+
129
+
130
+ def build_project(project_root: Path | None = None) -> int:
131
+ root = (Path.cwd() if project_root is None else project_root).resolve()
132
+
133
+ if not (root / "jars.db").is_file() and fetch_jars(root) != 0:
134
+ return 1
135
+
136
+ if compile_project(root) != 0:
137
+ return 1
138
+
139
+ _sync_build_inputs(root)
140
+ target_root = root / "target"
141
+ try:
142
+ apk = _apk_output(target_root)
143
+ except ValueError as error:
144
+ print(f"error: {error}", file=sys.stderr)
145
+ return 1
146
+
147
+ toolchain, jars_dir = _toolchain(root)
148
+ if toolchain is None:
149
+ print(f"error: required JARs are missing from {jars_dir}", file=sys.stderr)
150
+ print(f"run: py2apk setup -d {jars_dir}", file=sys.stderr)
151
+ return 1
152
+
153
+ jasmin_files = sorted((target_root / "j").rglob("*.j"))
154
+ if not jasmin_files:
155
+ print("error: compile produced no Jasmin files", file=sys.stderr)
156
+ return 1
157
+
158
+ classes_root = target_root / "classes"
159
+ dex_root = target_root / "dex"
160
+ shutil.rmtree(classes_root, ignore_errors=True)
161
+ shutil.rmtree(dex_root, ignore_errors=True)
162
+ classes_root.mkdir(parents=True)
163
+ dex_root.mkdir(parents=True)
164
+
165
+ jasmin_command = [
166
+ "java",
167
+ "-jar",
168
+ str(toolchain.jasmin),
169
+ "-d",
170
+ str(classes_root),
171
+ *(str(path) for path in jasmin_files),
172
+ ]
173
+ if _run_command(jasmin_command, root) != 0:
174
+ return 1
175
+
176
+ class_files = sorted(classes_root.rglob("*.class"))
177
+ if not class_files:
178
+ print("error: Jasmin produced no class files", file=sys.stderr)
179
+ return 1
180
+
181
+ d8_command = [
182
+ "java",
183
+ "-cp",
184
+ str(toolchain.r8),
185
+ "com.android.tools.r8.D8",
186
+ "--output",
187
+ str(dex_root),
188
+ ]
189
+ for library in library_jars(root / "jars.db"):
190
+ d8_command.extend(("--lib", str(library)))
191
+ d8_command.extend(str(path) for path in class_files)
192
+ d8_command.extend(str(path) for path in dex_jars(root / "jars.db"))
193
+ if _run_command(d8_command, root) != 0:
194
+ return 1
195
+
196
+ dex_file = dex_root / "classes.dex"
197
+ if not dex_file.is_file():
198
+ print("error: D8 produced no classes.dex", file=sys.stderr)
199
+ return 1
200
+ _ = shutil.copy2(dex_file, target_root / "classes.dex")
201
+
202
+ apktool_command = [
203
+ "java",
204
+ "-jar",
205
+ str(toolchain.apktool),
206
+ "b",
207
+ str(target_root),
208
+ ]
209
+ if _run_command(apktool_command, root) != 0:
210
+ return 1
211
+
212
+ if not apk.is_file():
213
+ print(f"error: apktool produced no APK at {apk}", file=sys.stderr)
214
+ return 1
215
+ try:
216
+ signed = _sign_apk(toolchain, apk, root)
217
+ except (OSError, RuntimeError) as error:
218
+ print(f"error: {error}", file=sys.stderr)
219
+ return 1
220
+ if signed is None:
221
+ return 1
222
+ print(f"built: {apk.relative_to(root)}")
223
+ print(f"signed: {signed.relative_to(root)}")
224
+ return 0
225
+
226
+
227
+ def _sign_apk(toolchain: _Toolchain, apk: Path, project_root: Path) -> Path | None:
228
+ """Sign ``apk`` with the setup keystore and return ``name.signed.apk``."""
229
+ keystore = ensure_keystore()
230
+ password = keystore.with_name("default.keystore.pass").read_text(encoding="utf-8").strip()
231
+ if not password:
232
+ raise RuntimeError(f"keystore password file is empty: {keystore.with_name('default.keystore.pass')}")
233
+ out_dir = apk.parent.parent / "sign"
234
+ shutil.rmtree(out_dir, ignore_errors=True)
235
+ out_dir.mkdir(parents=True)
236
+ command = [
237
+ "java",
238
+ "-jar",
239
+ str(toolchain.signer),
240
+ "-a",
241
+ str(apk),
242
+ "--ks",
243
+ str(keystore),
244
+ "--ksAlias",
245
+ KEY_ALIAS,
246
+ "--ksPass",
247
+ password,
248
+ "--ksKeyPass",
249
+ password,
250
+ "--out",
251
+ str(out_dir),
252
+ "--allowResign",
253
+ ]
254
+ visible: list[str] = []
255
+ skip_next = False
256
+ for part in command:
257
+ if skip_next:
258
+ visible.append("***")
259
+ skip_next = False
260
+ continue
261
+ visible.append(part)
262
+ if part in ("--ksPass", "--ksKeyPass"):
263
+ skip_next = True
264
+ if _run_command(command, project_root, visible) != 0:
265
+ return None
266
+ produced = sorted(out_dir.glob("*.apk"))
267
+ if len(produced) != 1:
268
+ print(f"error: uber-apk-signer produced no single APK in {out_dir}", file=sys.stderr)
269
+ return None
270
+ signed = apk.with_name(f"{apk.stem}.signed.apk")
271
+ signed.unlink(missing_ok=True)
272
+ _ = shutil.move(produced[0], signed)
273
+ return signed