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.
- Py2APK/__init__.py +0 -0
- Py2APK/classfile.py +191 -0
- Py2APK/cli.py +159 -0
- Py2APK/cli_build.py +273 -0
- Py2APK/cli_compile.py +93 -0
- Py2APK/cli_fetch_jars.py +158 -0
- Py2APK/cli_fetch_platform.py +115 -0
- Py2APK/cli_init.py +137 -0
- Py2APK/cli_setup.py +208 -0
- Py2APK/compiler_j.py +895 -0
- Py2APK/downloader.py +95 -0
- Py2APK/jar_index.py +361 -0
- Py2APK/maven_resolve.py +316 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/add2/_template.keep +1 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/blank/_template.keep +1 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/chart/deps.txt +2 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/chart/src/__PACKAGE__/MainActivity.py +33 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/chart/target/AndroidManifest.xml +25 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/chart/target/apktool.yml +20 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/chart/target/res/values/strings.xml +4 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/src/com/example/helloworld/DialogClickListener.py +18 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/src/com/example/helloworld/GreetingTextWatcher.py +37 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/src/com/example/helloworld/MainActivity.py +54 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/target/AndroidManifest.xml +25 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/target/apktool.yml +20 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/helloworld/target/res/values/strings.xml +4 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/deps.txt +2 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/src/__PACKAGE__/MainActivity.py +73 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/src/__PACKAGE__/RefreshClickListener.py +18 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/src/__PACKAGE__/WeatherCallback.py +27 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/src/__PACKAGE__/WeatherLocationListener.py +18 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/target/AndroidManifest.xml +28 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/target/apktool.yml +20 -0
- py2apk_compiler-0.1.0.data/data/share/py2apk/templates/weather/target/res/values/strings.xml +4 -0
- py2apk_compiler-0.1.0.dist-info/METADATA +144 -0
- py2apk_compiler-0.1.0.dist-info/RECORD +39 -0
- py2apk_compiler-0.1.0.dist-info/WHEEL +5 -0
- py2apk_compiler-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|