split-dex 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.
split_dex/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """split-dex: split the classes of an Android APK into grouped DEX files."""
2
+
3
+
4
+ class Error(Exception):
5
+ """Base error for split-dex."""
@@ -0,0 +1,135 @@
1
+ """Sort the classes of an APK into named sets, by patterns on their names.
2
+
3
+ Each set is grouped by its own strategy, and two sets never share a DEX file.
4
+
5
+ Patterns match binary class names, such as `com.myapp.Main` or `com.myapp.Outer$Inner`. In a
6
+ pattern, `*` matches any characters except a dot, and `**` matches any characters, dots
7
+ included. So `com.myapp.**` matches every class under `com.myapp`, `com.myapp.*` matches the
8
+ classes directly in `com.myapp`, and `**` matches every class.
9
+ """
10
+
11
+ import collections.abc
12
+ import dataclasses
13
+ import re
14
+
15
+ import split_dex
16
+ from split_dex import strategy
17
+
18
+ # How many unmatched classes an error message names.
19
+ _EXAMPLES = 3
20
+
21
+
22
+ class AssignmentError(split_dex.Error):
23
+ """Raised when the classes cannot be sorted into the sets."""
24
+
25
+
26
+ @dataclasses.dataclass(frozen=True)
27
+ class ClassSet:
28
+ """A named set of classes, and the strategy that groups them.
29
+
30
+ Attributes:
31
+ name: The set's name, written to sets.csv next to each of its DEX files.
32
+ strategy: How the classes of the set are grouped.
33
+ """
34
+
35
+ name: str
36
+ strategy: strategy.Strategy
37
+
38
+
39
+ @dataclasses.dataclass(frozen=True)
40
+ class Assignment:
41
+ """Sends the classes whose names match `pattern` to the set named `set_name`.
42
+
43
+ Attributes:
44
+ pattern: A pattern on binary class names, such as `com.myapp.**`.
45
+ set_name: The name of a set.
46
+ """
47
+
48
+ pattern: str
49
+ set_name: str
50
+
51
+
52
+ def binary_name(descriptor: str) -> str:
53
+ """The binary name of a class: `Lcom/a/B$C;` gives `com.a.B$C`.
54
+
55
+ Args:
56
+ descriptor: A class descriptor.
57
+
58
+ Returns:
59
+ The binary name.
60
+ """
61
+ return descriptor[1:-1].replace("/", ".")
62
+
63
+
64
+ def assign(
65
+ descriptors: collections.abc.Sequence[str],
66
+ sets: collections.abc.Sequence[ClassSet],
67
+ assignments: collections.abc.Sequence[Assignment],
68
+ ) -> dict[str, list[int]]:
69
+ """Sort classes into sets.
70
+
71
+ Each class goes to the set of the first assignment whose pattern matches its name. With no
72
+ assignments and a single set, every class goes to that set.
73
+
74
+ Args:
75
+ descriptors: The descriptor of every class, by class id.
76
+ sets: The sets.
77
+ assignments: The assignments, in the order they are tried.
78
+
79
+ Returns:
80
+ The ids of the classes of each set, in class id order, by set name. A set that no class
81
+ matches has an empty list.
82
+
83
+ Raises:
84
+ AssignmentError: If two sets have the same name, an assignment names an unknown set,
85
+ there are several sets but no assignments, or a class matches no pattern.
86
+ """
87
+ members: dict[str, list[int]] = {}
88
+ for class_set in sets:
89
+ if class_set.name in members:
90
+ raise AssignmentError(f"two sets are named {class_set.name!r}")
91
+ members[class_set.name] = []
92
+ if len(assignments) == 0:
93
+ if len(sets) != 1:
94
+ raise AssignmentError("with several sets, assignments must say which classes go where")
95
+ members[sets[0].name] = list(range(len(descriptors)))
96
+ return members
97
+ rules = [(_compile(rule.pattern), _members_of(members, rule.set_name)) for rule in assignments]
98
+ unmatched: list[str] = []
99
+ for class_id, descriptor in enumerate(descriptors):
100
+ name = binary_name(descriptor)
101
+ target = next((ids for pattern, ids in rules if pattern.fullmatch(name) is not None), None)
102
+ if target is None:
103
+ unmatched.append(name)
104
+ else:
105
+ target.append(class_id)
106
+ if len(unmatched) > 0:
107
+ raise AssignmentError(
108
+ f"{len(unmatched)} classes match no pattern, such as "
109
+ f"{', '.join(unmatched[:_EXAMPLES])}; add a last pattern such as '**' to catch them"
110
+ )
111
+ return members
112
+
113
+
114
+ def _members_of(members: dict[str, list[int]], set_name: str) -> list[int]:
115
+ ids = members.get(set_name)
116
+ if ids is None:
117
+ raise AssignmentError(f"no set is named {set_name!r}")
118
+ return ids
119
+
120
+
121
+ def _compile(pattern: str) -> re.Pattern[str]:
122
+ """The regular expression of a pattern: `**` is any text, `*` any text without a dot."""
123
+ parts: list[str] = []
124
+ index = 0
125
+ while index < len(pattern):
126
+ if pattern.startswith("**", index):
127
+ parts.append(".*")
128
+ index += 2
129
+ elif pattern[index] == "*":
130
+ parts.append("[^.]*")
131
+ index += 1
132
+ else:
133
+ parts.append(re.escape(pattern[index]))
134
+ index += 1
135
+ return re.compile("".join(parts))
split_dex/cli.py ADDED
@@ -0,0 +1,227 @@
1
+ """The split-dex command line."""
2
+
3
+ import argparse
4
+ import collections.abc
5
+ import importlib.metadata
6
+ import pathlib
7
+ import re
8
+ import sys
9
+ from typing import Any
10
+
11
+ import split_dex
12
+ from split_dex import class_sets
13
+ from split_dex import registry
14
+ from split_dex import splitter
15
+ from split_dex import strategy
16
+
17
+ _EPILOG = """\
18
+ sets:
19
+ Each class goes to the set of the first --assign pattern that matches its
20
+ name, and each set is split by its own strategy. Two sets never share a DEX
21
+ file. Without --set, every class is in one set, "all", split by
22
+ package-affinity. Patterns match class names such as com.myapp.Main: "*"
23
+ matches any characters except a dot, "**" any characters. "@FILE" reads
24
+ patterns from a file, one per line.
25
+
26
+ strategies:
27
+ STRATEGY is a built-in or plugin name (see --list-strategies), or your own
28
+ strategy class as MODULE:CLASS or FILE.py:CLASS. Its settings are the
29
+ keyword parameters of the class's constructor.
30
+
31
+ examples:
32
+ split-dex --apk app.apk --out out/
33
+ split-dex --apk app.apk --out out/ --set all=package-affinity,cap=500
34
+ split-dex --apk app.apk --out out/ \\
35
+ --set app=package-affinity,cap=500 --set libraries=single \\
36
+ --assign 'com.myapp.**=app' --assign '**=libraries'
37
+ split-dex --apk app.apk --out out/ --set all=./my_strategy.py:MyStrategy,k=8
38
+
39
+ output in DIR:
40
+ <k>.dex the grouped DEX files: 0.dex, 1.dex, ..., set by set
41
+ index.csv class,file: every class and the DEX file it is in
42
+ sets.csv file,set: the set of each DEX file
43
+ """
44
+
45
+ _BYTES_PER_MB = 1048576
46
+
47
+ # A set name is written to sets.csv, so it cannot hold a comma or a line break.
48
+ _SET_NAME = re.compile(r"[A-Za-z0-9_.-]+")
49
+
50
+
51
+ class _UsageError(split_dex.Error):
52
+ """Raised when an option's value is malformed."""
53
+
54
+
55
+ def main(argv: collections.abc.Sequence[str] | None = None) -> int:
56
+ """Run split-dex with command-line arguments.
57
+
58
+ Prints a summary of the split on stdout, and a warning on stderr for each block, such as a
59
+ package, that was too big for one DEX file.
60
+
61
+ Args:
62
+ argv: The arguments, without the program name. None reads them from `sys.argv`.
63
+
64
+ Returns:
65
+ The exit status: 0 when the split succeeds, 1 when it fails. Bad arguments exit with
66
+ status 2 instead, through argparse's SystemExit.
67
+ """
68
+ parser = _parser()
69
+ arguments = parser.parse_args(argv)
70
+ try:
71
+ options = _options(arguments)
72
+ except (_UsageError, strategy.StrategyError) as error:
73
+ parser.error(str(error))
74
+ try:
75
+ result = splitter.split(options)
76
+ except split_dex.Error as error:
77
+ print(f"split-dex: {error}", file=sys.stderr)
78
+ return 1
79
+ _print_summary(result)
80
+ return 0
81
+
82
+
83
+ class _ListStrategies(argparse.Action):
84
+ """Prints the built-in and plugin strategies and exits, like --help."""
85
+
86
+ def __call__(
87
+ self,
88
+ parser: argparse.ArgumentParser,
89
+ namespace: argparse.Namespace,
90
+ values: Any,
91
+ option_string: str | None = None,
92
+ ) -> None:
93
+ try:
94
+ lines = registry.describe()
95
+ except strategy.StrategyError as error:
96
+ parser.exit(1, f"split-dex: {error}\n")
97
+ print("\n".join(lines))
98
+ parser.exit()
99
+
100
+
101
+ def _parser() -> argparse.ArgumentParser:
102
+ version = importlib.metadata.version("split-dex")
103
+ parser = argparse.ArgumentParser(
104
+ prog="split-dex",
105
+ description=f"split-dex {version}: split an APK's classes into grouped DEX files.",
106
+ epilog=_EPILOG,
107
+ formatter_class=argparse.RawDescriptionHelpFormatter,
108
+ )
109
+ required = parser.add_argument_group("required arguments")
110
+ required.add_argument(
111
+ "--apk",
112
+ required=True,
113
+ type=_non_empty,
114
+ metavar="FILE",
115
+ help="input APK; all its classes*.dex entries are read",
116
+ )
117
+ required.add_argument(
118
+ "--out", required=True, type=_non_empty, metavar="DIR", help="output directory"
119
+ )
120
+ parser.add_argument(
121
+ "--set",
122
+ action="append",
123
+ default=[],
124
+ dest="sets",
125
+ metavar="NAME=STRATEGY[,KEY=VALUE...]",
126
+ help="a set of classes and the strategy that splits it, with the strategy's settings; "
127
+ "repeat for more sets (DEX files are written in this order)",
128
+ )
129
+ parser.add_argument(
130
+ "--assign",
131
+ action="append",
132
+ default=[],
133
+ dest="assignments",
134
+ metavar="PATTERN[,PATTERN...]=NAME",
135
+ help="send the classes whose names match a pattern to the set NAME; repeat, the first "
136
+ "match wins",
137
+ )
138
+ parser.add_argument(
139
+ "--list-strategies",
140
+ action=_ListStrategies,
141
+ nargs=0,
142
+ help="show the strategies and their settings, and exit",
143
+ )
144
+ return parser
145
+
146
+
147
+ def _non_empty(value: str) -> str:
148
+ if value == "":
149
+ raise argparse.ArgumentTypeError("must not be empty")
150
+ return value
151
+
152
+
153
+ def _options(arguments: argparse.Namespace) -> splitter.Options:
154
+ sets = [_parse_set(text) for text in arguments.sets]
155
+ if len(sets) == 0:
156
+ sets = list(splitter.default_sets())
157
+ names = [class_set.name for class_set in sets]
158
+ if len(set(names)) < len(names):
159
+ raise _UsageError("two --set options have the same name")
160
+ assignments = [rule for text in arguments.assignments for rule in _parse_assignment(text)]
161
+ unknown = sorted({rule.set_name for rule in assignments} - set(names))
162
+ if len(unknown) > 0:
163
+ raise _UsageError(f"--assign names a set that no --set declares: {', '.join(unknown)}")
164
+ if len(sets) > 1 and len(assignments) == 0:
165
+ raise _UsageError("with several --set options, --assign must say which classes go where")
166
+ return splitter.Options(
167
+ apk_path=pathlib.Path(arguments.apk),
168
+ out_dir=pathlib.Path(arguments.out),
169
+ sets=sets,
170
+ assignments=assignments,
171
+ )
172
+
173
+
174
+ def _parse_set(text: str) -> class_sets.ClassSet:
175
+ """A set from `NAME=STRATEGY[,KEY=VALUE...]`."""
176
+ name, separator, spec = text.partition("=")
177
+ if separator == "" or spec == "":
178
+ raise _UsageError(f"--set {text}: expected NAME=STRATEGY[,KEY=VALUE...]")
179
+ if _SET_NAME.fullmatch(name) is None:
180
+ raise _UsageError(f"--set {text}: a set name uses only letters, digits, '_', '.' and '-'")
181
+ strategy_name, *setting_texts = spec.split(",")
182
+ settings: dict[str, str] = {}
183
+ for setting in setting_texts:
184
+ key, separator, value = setting.partition("=")
185
+ if separator == "" or key == "":
186
+ raise _UsageError(f"--set {text}: expected KEY=VALUE, not {setting!r}")
187
+ settings[key] = value
188
+ return class_sets.ClassSet(name, registry.create(strategy_name, settings))
189
+
190
+
191
+ def _parse_assignment(text: str) -> list[class_sets.Assignment]:
192
+ """Assignments from `PATTERN[,PATTERN...]=NAME`, where `@FILE` reads patterns from a file."""
193
+ patterns_text, separator, set_name = text.rpartition("=")
194
+ if separator == "" or patterns_text == "" or set_name == "":
195
+ raise _UsageError(f"--assign {text}: expected PATTERN[,PATTERN...]=NAME")
196
+ patterns: list[str] = []
197
+ for pattern in patterns_text.split(","):
198
+ if pattern.startswith("@"):
199
+ patterns.extend(_read_patterns(pathlib.Path(pattern[1:])))
200
+ else:
201
+ patterns.append(pattern)
202
+ return [class_sets.Assignment(pattern, set_name) for pattern in patterns]
203
+
204
+
205
+ def _read_patterns(path: pathlib.Path) -> list[str]:
206
+ """The patterns of a file: one per line. Blank lines and lines starting with '#' are skipped."""
207
+ try:
208
+ lines = path.read_bytes().decode("utf-8").split("\n")
209
+ except (OSError, UnicodeDecodeError) as error:
210
+ raise _UsageError(f"cannot read patterns from {path}: {error}") from error
211
+ patterns = [line.strip() for line in lines]
212
+ return [pattern for pattern in patterns if pattern != "" and pattern.startswith("#") is False]
213
+
214
+
215
+ def _print_summary(result: splitter.Result) -> None:
216
+ print(f"total_classes={result.total_classes} duplicate_classes={result.duplicate_classes}")
217
+ print(
218
+ f"total_dex={result.dex_files} overflow_splits={result.overflow_splits} "
219
+ f"total_mb={result.total_bytes / _BYTES_PER_MB:.1f}"
220
+ )
221
+ for set_result in result.sets:
222
+ print(f"set={set_result.name} classes={set_result.classes} dex={set_result.dex_files}")
223
+ for block in result.split_blocks:
224
+ print(
225
+ f"split-dex: warning: {block} is too big for one DEX file and was split across files",
226
+ file=sys.stderr,
227
+ )
split_dex/partition.py ADDED
@@ -0,0 +1,229 @@
1
+ """Group classes by how they are linked, without splitting a Java package.
2
+
3
+ A class usually loads the classes it is linked to, so linked classes should land in the same DEX
4
+ file. A package is never split across two groups: when each DEX file is served by its own class
5
+ loader, two classes of one package in different loaders cannot use each other's package-private
6
+ members, and ART throws IllegalAccessError. Keeping each package whole keeps that access inside
7
+ one class loader.
8
+ """
9
+
10
+ import collections.abc
11
+ import dataclasses
12
+ import heapq
13
+
14
+
15
+ class AffinityGraph:
16
+ """An undirected, weighted graph of the links between classes.
17
+
18
+ Each class is a dense id in `range(size)`. The weight of the edge between two classes counts
19
+ the links between them. The graph is symmetric: an edge is stored on both of its ends.
20
+ """
21
+
22
+ def __init__(self, size: int) -> None:
23
+ """Create a graph of `size` classes with no edges.
24
+
25
+ Args:
26
+ size: The number of classes.
27
+ """
28
+ self._neighbours: list[dict[int, int]] = [{} for _ in range(size)]
29
+
30
+ @property
31
+ def size(self) -> int:
32
+ """The number of classes in the graph."""
33
+ return len(self._neighbours)
34
+
35
+ def neighbours(self, class_id: int) -> collections.abc.Mapping[int, int]:
36
+ """The classes linked to a class.
37
+
38
+ Args:
39
+ class_id: The class.
40
+
41
+ Returns:
42
+ A mapping from each linked class to the weight of its edge.
43
+ """
44
+ return self._neighbours[class_id]
45
+
46
+ def add_edge(self, first: int, second: int, weight: int = 1) -> None:
47
+ """Add `weight` to the edge between two classes, on both of its ends.
48
+
49
+ An edge from a class to itself is ignored.
50
+
51
+ Args:
52
+ first: One end of the edge.
53
+ second: The other end of the edge.
54
+ weight: How much to add to the weight of the edge.
55
+ """
56
+ if first == second:
57
+ return
58
+ self._neighbours[first][second] = self._neighbours[first].get(second, 0) + weight
59
+ self._neighbours[second][first] = self._neighbours[second].get(first, 0) + weight
60
+
61
+
62
+ def package_of(descriptor: str) -> str:
63
+ """The package of a class descriptor: the descriptor up to its last '/'.
64
+
65
+ For example `Lcom/a/B;` gives `Lcom/a`. A class in the unnamed package, such as `LFoo;`,
66
+ gives `L`.
67
+
68
+ Args:
69
+ descriptor: A class descriptor.
70
+
71
+ Returns:
72
+ The package, in descriptor form.
73
+ """
74
+ slash = descriptor.rfind("/")
75
+ if slash == -1:
76
+ return "L"
77
+ return descriptor[:slash]
78
+
79
+
80
+ def partition_by_package_affinity(
81
+ members: collections.abc.Sequence[int],
82
+ graph: AffinityGraph,
83
+ descriptors: collections.abc.Sequence[str],
84
+ cap: int,
85
+ ) -> list[list[int]]:
86
+ """Partition classes into groups of about `cap` classes, keeping each package whole.
87
+
88
+ The steps:
89
+
90
+ 1. Group the members by package. A package is the unit that moves; it is never split.
91
+ 2. Add up the edges between classes into edges between packages.
92
+ 3. Put the packages in order so that linked packages are next to each other. The order
93
+ starts from the package linked to the most other packages. Each next package is the one
94
+ with the heaviest edge to the packages already placed.
95
+ 4. Fill groups of about `cap` classes in that order, each package whole. A package of `cap`
96
+ classes or more ends the group it is in, so a package bigger than `cap` gets a group of
97
+ its own.
98
+
99
+ Packages with no links come last and are packed together, so no group is left with a single
100
+ stray class. The result is deterministic: ties are broken by package ids, which follow the
101
+ order in which `members` first shows each package, never by hash order.
102
+
103
+ Args:
104
+ members: The ids of the classes to partition, a subset of the classes of `graph`.
105
+ graph: The links between all classes. Only edges between two members are used.
106
+ descriptors: The descriptor of every class, indexed by class id.
107
+ cap: The target number of classes per group. A value below 1 counts as 1.
108
+
109
+ Returns:
110
+ The groups, each a list of class ids. No group is empty.
111
+ """
112
+ packages = _group_by_package(members, descriptors)
113
+ edges = _package_edges(members, graph, packages)
114
+ order = _order_packages(edges)
115
+ return fill_groups((packages.classes[package] for package in order), cap)
116
+
117
+
118
+ def fill_groups(
119
+ units: collections.abc.Iterable[collections.abc.Sequence[int]], cap: int
120
+ ) -> list[list[int]]:
121
+ """Fill groups of about `cap` classes with whole units, such as packages, in order.
122
+
123
+ A unit that would push the current group over `cap` starts a new group, and a group that
124
+ reaches `cap` is closed. So a unit of `cap` classes or more gets a group of its own.
125
+
126
+ Args:
127
+ units: The units, each a sequence of class ids, in the order they are filled in.
128
+ cap: The target number of classes per group. A value below 1 counts as 1.
129
+
130
+ Returns:
131
+ The groups, each a list of class ids. No group is empty.
132
+ """
133
+ cap = max(cap, 1)
134
+ groups: list[list[int]] = []
135
+ current: list[int] = []
136
+ for unit in units:
137
+ if len(current) > 0 and len(current) + len(unit) > cap:
138
+ groups.append(current)
139
+ current = []
140
+ current.extend(unit)
141
+ if len(current) >= cap:
142
+ groups.append(current)
143
+ current = []
144
+ if len(current) > 0:
145
+ groups.append(current)
146
+ return groups
147
+
148
+
149
+ @dataclasses.dataclass(frozen=True)
150
+ class _Packages:
151
+ """The packages of a set of classes. Package ids count from 0, in order of appearance.
152
+
153
+ Attributes:
154
+ classes: The classes of each package, indexed by package id.
155
+ package_of_class: The package id of each class.
156
+ """
157
+
158
+ classes: list[list[int]]
159
+ package_of_class: dict[int, int]
160
+
161
+
162
+ def _group_by_package(
163
+ members: collections.abc.Sequence[int], descriptors: collections.abc.Sequence[str]
164
+ ) -> _Packages:
165
+ package_ids: dict[str, int] = {}
166
+ classes: list[list[int]] = []
167
+ package_of_class: dict[int, int] = {}
168
+ for class_id in members:
169
+ package_id = package_ids.setdefault(package_of(descriptors[class_id]), len(classes))
170
+ if package_id == len(classes):
171
+ classes.append([])
172
+ classes[package_id].append(class_id)
173
+ package_of_class[class_id] = package_id
174
+ return _Packages(classes, package_of_class)
175
+
176
+
177
+ def _package_edges(
178
+ members: collections.abc.Sequence[int], graph: AffinityGraph, packages: _Packages
179
+ ) -> list[dict[int, int]]:
180
+ """Add up the edges between classes of two different packages into package edges."""
181
+ edges: list[dict[int, int]] = [{} for _ in packages.classes]
182
+ for class_id in members:
183
+ package = packages.package_of_class[class_id]
184
+ for neighbour, weight in graph.neighbours(class_id).items():
185
+ neighbour_package = packages.package_of_class.get(neighbour)
186
+ if neighbour_package is None or neighbour_package == package:
187
+ continue
188
+ edges[package][neighbour_package] = edges[package].get(neighbour_package, 0) + weight
189
+ return edges
190
+
191
+
192
+ def _order_packages(
193
+ edges: collections.abc.Sequence[collections.abc.Mapping[int, int]],
194
+ ) -> list[int]:
195
+ """Order the packages so that linked packages are next to each other.
196
+
197
+ Walks start from the packages with the most links (ties: the lower id first). Each walk is
198
+ best-first, like Prim's algorithm: it takes the package with the heaviest edge to the
199
+ packages already placed.
200
+ """
201
+ seeds = sorted(range(len(edges)), key=lambda package: (-len(edges[package]), package))
202
+ placed = [False] * len(edges)
203
+ order: list[int] = []
204
+ for seed in seeds:
205
+ if placed[seed] is False:
206
+ _walk_from(seed, edges, placed, order)
207
+ return order
208
+
209
+
210
+ def _walk_from(
211
+ seed: int,
212
+ edges: collections.abc.Sequence[collections.abc.Mapping[int, int]],
213
+ placed: list[bool],
214
+ order: list[int],
215
+ ) -> None:
216
+ """Place every package reachable from `seed`, heaviest edge first, and append it to `order`."""
217
+ # A max-heap of (edge weight, package id), kept as a min-heap of the negated values: the
218
+ # heaviest edge comes out first, and of equal weights the higher package id.
219
+ heap = [(0, -seed)]
220
+ while len(heap) > 0:
221
+ _, negated_package = heapq.heappop(heap)
222
+ package = -negated_package
223
+ if placed[package] is True:
224
+ continue
225
+ placed[package] = True
226
+ order.append(package)
227
+ for neighbour, weight in edges[package].items():
228
+ if placed[neighbour] is False:
229
+ heapq.heappush(heap, (-weight, -neighbour))
split_dex/py.typed ADDED
File without changes