Skip to content

Commit f419584

Browse files
committed
dist-apk: a project manifest template with six tokens, and java_sources as an array
options::manifest_template renders a project AndroidManifest.xml through six tokens substituted verbatim: {{application_id}}, {{label}}, {{activity}}, {{lib_name}}, {{min_sdk}}, {{target_sdk}}. Three are required, not merely substituted, because their value is also written to assets/mcpp-run.json, which adb-run reads to start the application: {{application_id}} and {{activity}} always, {{lib_name}} at level 0. A missing required token or an unknown {{...}} token is refused at plan time, naming it. The built-in default is 0.8.0's manifest_xml output expressed with these tokens, so level 0 with no template renders byte-identical to 0.8.0's. options::java_sources becomes std::vector<std::string>: one javac over every root's .java files and one d8 over the result. rerun_if_changed_glob is declared only for a root under mcpp::manifest_dir(); a root outside it (a dependency's own Java tree) is not walked by the glob fingerprint regardless, and its files are already inputs of the javac action while its version is already in the build's fingerprint. Design record: .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md, §3.
1 parent db16e27 commit f419584

1 file changed

Lines changed: 244 additions & 41 deletions

File tree

‎dist/apk.cppm‎

Lines changed: 244 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,46 @@ struct options {
109109
// `<application android:label>`. Empty means the package name.
110110
std::string label;
111111

112+
// A project file, package-root-relative, that replaces the built-in
113+
// manifest. Six tokens are substituted verbatim wherever they appear --
114+
// `{{application_id}}`, `{{label}}`, `{{activity}}`, `{{lib_name}}`,
115+
// `{{min_sdk}}`, `{{target_sdk}}` -- and everything else in the file is
116+
// the project's, verbatim: permissions, receivers, meta-data, an icon,
117+
// an activity-alias, `configChanges`. This member adds nothing to it
118+
// (design record `2026-09-13-four-upstream-asks-from-a-ui-framework.md`,
119+
// §3.2).
120+
//
121+
// THREE TOKENS ARE REQUIRED, NOT MERELY SUBSTITUTED, because their value
122+
// is also written to `assets/mcpp-run.json`, which `adb-run` reads to
123+
// start the application without `aapt2` on the machine that runs it: a
124+
// template missing `{{application_id}}` or `{{activity}}` is refused at
125+
// plan time, naming the token and that reader; `{{lib_name}}` joins them
126+
// at level 0 (`java_sources` empty), because the manifest's own
127+
// `<meta-data>` element is the only place the loaded library's name is
128+
// recorded. An unknown `{{...}}` token is refused too, naming it -- see
129+
// the render function below for why that check belongs to this member
130+
// and is not proposed for `dist-web`.
131+
//
132+
// Empty means the built-in default, which is `manifest_xml`'s own 0.8.0
133+
// output expressed with these tokens; level 0 with no template renders a
134+
// manifest byte-identical to 0.8.0's (`tests/apk-consumer`).
135+
std::string manifest_template;
136+
112137
// A `res/`-shaped directory `aapt2 compile --dir` compiles. Empty means
113138
// no resources at all -- a legal, common case for a NativeActivity
114139
// application that draws everything itself.
115140
std::string resources;
116141

117-
// LEVEL 1. A directory of `.java` sources this member compiles with
118-
// `javac` and dexes with `d8`. Empty (the default) is level 0: no Java,
119-
// `hasCode="false"`, `android.app.NativeActivity` as the manifest's
120-
// activity.
121-
std::string java_sources;
142+
// LEVEL 1. One or more directories of `.java` sources; one `javac` over
143+
// every root's files and one `d8` over the result (the member compiles
144+
// what it is given, and a second root is more of the same input, not a
145+
// second step). A project with a path-dependency framework that also
146+
// hosts Java lists that dependency's own directory alongside its own
147+
// rather than merging the two trees itself. Empty (the default) is
148+
// level 0: no Java, `hasCode="false"`, `android.app.NativeActivity` as
149+
// the manifest's activity. A single string is still accepted in a
150+
// `build.mcpp`: a one-element initialiser list is the same spelling.
151+
std::vector<std::string> java_sources;
122152

123153
// LEVEL 1, REQUIRED WHEN `java_sources` IS SET. The fully-qualified
124154
// activity class the manifest names as `<activity android:name>` and the
@@ -180,6 +210,26 @@ inline bool is_dir(const std::string& p) {
180210
return !p.empty() && fs::is_directory(p, ec);
181211
}
182212

213+
// Is `root` under the package root, `mcpp::manifest_dir()`? A project root is
214+
// declared with `rerun_if_changed_glob` below (a file appearing there re-runs
215+
// the build program); a dependency root is not -- its file set changes only
216+
// with the dependency's version, already in the build's fingerprint, and the
217+
// glob's own walk does not reach outside the package root regardless
218+
// (design record §3.3). Same shape as `mcpp.tools.island`'s overlap check:
219+
// `weakly_canonical` plus `lexically_relative`, never the iterator that
220+
// poisons an importer under GCC 16 / MSVC (`mcpp::plugins::names::
221+
// relative_to`'s own header) -- safe here because this member is its own
222+
// module and imports no sibling that would inherit the instantiation.
223+
inline bool root_in_project(const std::string& root) {
224+
std::error_code ec;
225+
const auto a = fs::weakly_canonical(root, ec);
226+
if (ec) return false;
227+
const auto b = fs::weakly_canonical(mcpp::manifest_dir(), ec);
228+
if (ec) return false;
229+
const auto rel = a.lexically_relative(b).generic_string();
230+
return !rel.empty() && !rel.starts_with("..");
231+
}
232+
183233
inline bool write_if_different(const fs::path& path, std::string_view bytes) {
184234
std::error_code ec;
185235
fs::create_directories(path.parent_path(), ec);
@@ -311,22 +361,81 @@ inline std::string api_level_from_platform_dir(const std::string& dir) {
311361
return digits;
312362
}
313363

314-
inline std::string manifest_xml(const std::string& app_id, const std::string& label,
315-
const std::string& min_sdk, const std::string& target_sdk,
316-
const std::string& target, bool has_code,
317-
const std::string& activity_name) {
318-
std::string a = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
364+
inline std::string replace_all_copy(std::string s, std::string_view from, std::string_view to) {
365+
if (from.empty()) return s;
366+
std::size_t pos = 0;
367+
while ((pos = s.find(from, pos)) != std::string::npos) {
368+
s.replace(pos, from.size(), to);
369+
pos += to.size();
370+
}
371+
return s;
372+
}
373+
374+
// The six tokens a manifest template may use.
375+
inline const std::vector<std::string>& manifest_tokens() {
376+
static const std::vector<std::string> v = {
377+
"application_id", "label", "activity", "lib_name", "min_sdk", "target_sdk"};
378+
return v;
379+
}
380+
381+
// Every `{{...}}` a template names, in first-appearance order, duplicates
382+
// dropped -- what both checks in `render_manifest` below read.
383+
inline std::vector<std::string> tokens_in(const std::string& text) {
384+
std::vector<std::string> out;
385+
std::size_t i = 0;
386+
while ((i = text.find("{{", i)) != std::string::npos) {
387+
const auto close = text.find("}}", i + 2);
388+
if (close == std::string::npos) break;
389+
std::string name = text.substr(i + 2, close - (i + 2));
390+
if (std::ranges::find(out, name) == out.end()) out.push_back(std::move(name));
391+
i = close + 2;
392+
}
393+
return out;
394+
}
395+
396+
// The tokens `assets/mcpp-run.json` is ALSO written from -- a template that
397+
// omits one silently ships a run sidecar the manifest disagrees with.
398+
// `application_id` and `activity` are required unconditionally; `lib_name`
399+
// joins them at level 0, where the manifest's own `<meta-data>` element is
400+
// the only place the loaded library's name is recorded.
401+
inline std::vector<std::string> required_manifest_tokens(bool has_code) {
402+
std::vector<std::string> v = {"application_id", "activity"};
403+
if (!has_code) v.push_back("lib_name");
404+
return v;
405+
}
406+
407+
// THE BUILT-IN DEFAULT, EXPRESSED WITH THE TOKENS. This is `manifest_xml`'s
408+
// 0.8.0 output verbatim, with every literal value it used to compute
409+
// replaced by the token that value now comes through -- so level 0 with no
410+
// project template renders byte-identical to what 0.8.0 wrote
411+
// (`tests/apk-consumer`). `{{activity}}` carries the level-0 constant
412+
// (`android.app.NativeActivity`) as well as a level-1 project's own class,
413+
// because the run sidecar needs the activity name at both levels and the
414+
// required-token check reads the TEMPLATE TEXT, not the level -- so the same
415+
// token has to appear on both of this function's two branches.
416+
// `{{lib_name}}`'s `<meta-data>` element exists only at level 0: it
417+
// announces which shared object `NativeActivity` should load, and a
418+
// Java-hosted activity finds its own native library another way.
419+
//
420+
// NO XML COMMENT MARKS THE THREE REQUIRED TOKENS IN THIS STRING, ON PURPOSE:
421+
// this exact text is compared byte-for-byte against 0.8.0's output, which
422+
// carried none, and a template with no author to read a comment gains
423+
// nothing from one. The design record's "mark the required tokens" is done
424+
// here instead, in the `REQUIRED` labels on the C++ lines that build them.
425+
inline std::string default_manifest_template(bool has_code) {
426+
std::string a =
427+
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
319428
"<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n"
320-
" package=\"" + app_id + "\">\n"
321-
" <uses-sdk android:minSdkVersion=\"" + min_sdk +
322-
"\" android:targetSdkVersion=\"" + target_sdk + "\"/>\n"
323-
" <application android:label=\"" + label + "\" android:hasCode=\"" +
324-
(has_code ? "true" : "false") + "\">\n"
325-
" <activity android:name=\"" + activity_name +
326-
"\" android:exported=\"true\">\n";
429+
" package=\"{{application_id}}\">\n" // REQUIRED
430+
" <uses-sdk android:minSdkVersion=\"{{min_sdk}}\" "
431+
"android:targetSdkVersion=\"{{target_sdk}}\"/>\n"
432+
" <application android:label=\"{{label}}\" android:hasCode=\"" +
433+
std::string(has_code ? "true" : "false") + "\">\n"
434+
" <activity android:name=\"{{activity}}\" " // REQUIRED
435+
"android:exported=\"true\">\n";
327436
if (!has_code) {
328437
a += " <meta-data android:name=\"android.app.lib_name\" "
329-
"android:value=\"" + target + "\"/>\n";
438+
"android:value=\"{{lib_name}}\"/>\n"; // REQUIRED at level 0
330439
}
331440
a += " <intent-filter>\n"
332441
" <action android:name=\"android.intent.action.MAIN\"/>\n"
@@ -338,6 +447,54 @@ inline std::string manifest_xml(const std::string& app_id, const std::string& la
338447
return a;
339448
}
340449

450+
// Checks a manifest template and substitutes it, or refuses (returning
451+
// `false` with `reason` set) naming exactly what is wrong.
452+
//
453+
// THIS CHECK IS `dist-apk`'S OWN, DELIBERATELY NOT `dist-web`'S. A manifest
454+
// has a closed, six-token vocabulary this member itself defines; a web page
455+
// template may legitimately carry `{{ }}` for a front-end framework (Vue,
456+
// Mustache, ...) this member never reads, so `dist-web` leaves an unknown
457+
// token literal for that project's own tooling to read. An unknown token
458+
// here would otherwise reach `aapt2` unsubstituted and fail there with a
459+
// worse message, and the refusal belongs where the name is known -- the same
460+
// rule read against two different facts, not an inconsistency between the
461+
// two members.
462+
inline bool render_manifest(const std::string& templateText, bool has_code,
463+
const std::string& appId, const std::string& label,
464+
const std::string& activityName, const std::string& libName,
465+
const std::string& minSdk, const std::string& targetSdk,
466+
std::string& out, std::string& reason) {
467+
for (auto const& tok : tokens_in(templateText)) {
468+
if (std::ranges::find(manifest_tokens(), tok) == manifest_tokens().end()) {
469+
std::cerr << "mcpp.dist.apk: the manifest template names an unknown "
470+
"token '{{" << tok << "}}' -- expected one of "
471+
"application_id, label, activity, lib_name, min_sdk, "
472+
"target_sdk\n";
473+
reason = "unknown manifest template token '" + tok + "'";
474+
return false;
475+
}
476+
}
477+
for (auto const& tok : required_manifest_tokens(has_code)) {
478+
if (templateText.find("{{" + tok + "}}") == std::string::npos) {
479+
std::cerr << "mcpp.dist.apk: the manifest template does not use "
480+
"'{{" << tok << "}}', and assets/mcpp-run.json -- "
481+
"which adb-run starts the application from -- is "
482+
"written from the same value: add {{" << tok
483+
<< "}} to the template.\n";
484+
reason = "manifest template missing required token '" + tok + "'";
485+
return false;
486+
}
487+
}
488+
out = templateText;
489+
out = replace_all_copy(std::move(out), "{{application_id}}", appId);
490+
out = replace_all_copy(std::move(out), "{{label}}", label);
491+
out = replace_all_copy(std::move(out), "{{activity}}", activityName);
492+
out = replace_all_copy(std::move(out), "{{lib_name}}", libName);
493+
out = replace_all_copy(std::move(out), "{{min_sdk}}", minSdk);
494+
out = replace_all_copy(std::move(out), "{{target_sdk}}", targetSdk);
495+
return true;
496+
}
497+
341498
inline std::string run_json(const std::string& app_id, const std::string& activity_name) {
342499
// Hand-built, not through a JSON library this collection does not
343500
// depend on: two string fields, neither of which this member lets
@@ -624,9 +781,36 @@ inline plan plan_for(options opt = {}) {
624781
const std::string label = label_for(opt);
625782
const std::string activityName = hasCode ? opt.activity : std::string("android.app.NativeActivity");
626783

784+
std::string manifestTemplateText;
785+
if (!opt.manifest_template.empty()) {
786+
const std::string tplPath =
787+
(fs::path(mcpp::manifest_dir()) / opt.manifest_template).string();
788+
if (!is_file(tplPath)) {
789+
std::cerr << std::format(
790+
"mcpp.dist.apk: the manifest template {} was not found", tplPath) << '\n';
791+
p.reason = "manifest template not found";
792+
return p;
793+
}
794+
// The template is declared so an edit to it reaches the graph -- the
795+
// one thing the ask's workaround (overwriting the manifest after
796+
// `plan_for()` returns) cannot do, because it depends on this
797+
// member's own internal path.
798+
mcpp::rerun_if_changed(tplPath.c_str());
799+
std::ifstream in(tplPath, std::ios::binary);
800+
manifestTemplateText.assign((std::istreambuf_iterator<char>(in)),
801+
std::istreambuf_iterator<char>());
802+
} else {
803+
manifestTemplateText = default_manifest_template(hasCode);
804+
}
805+
806+
std::string manifestBytes;
807+
if (!render_manifest(manifestTemplateText, hasCode, appId, label, activityName,
808+
target, minSdk, targetSdk, manifestBytes, p.reason)) {
809+
return p;
810+
}
811+
627812
const std::string manifestPath = (fs::path(opt.out_dir) / "dist-apk" / "AndroidManifest.xml").string();
628-
if (!write_if_different(manifestPath,
629-
manifest_xml(appId, label, minSdk, targetSdk, target, hasCode, activityName))) {
813+
if (!write_if_different(manifestPath, manifestBytes)) {
630814
std::cerr << std::format("mcpp.dist.apk: cannot write {}", manifestPath) << '\n';
631815
p.reason = "cannot write AndroidManifest.xml";
632816
return p;
@@ -806,29 +990,48 @@ inline plan plan_for(options opt = {}) {
806990

807991
std::vector<std::string> javaOutputs; // classes.dex, when level 1
808992
if (hasCode) {
809-
if (!is_dir(opt.java_sources)) {
810-
std::cerr << std::format(
811-
"mcpp.dist.apk: options::java_sources '{}' is not a "
812-
"directory", opt.java_sources) << '\n';
813-
p.reason = "java_sources directory not found";
814-
return p;
815-
}
993+
// ONE `javac` OVER EVERY ROOT'S `.java` FILES. The member compiles
994+
// what it is given (design record §3.3) and a second root is more of
995+
// the same input, not a second step -- `javaFiles` below is one flat
996+
// list across every root, and one `javac` invocation compiles all of
997+
// it into one `classesDir`, exactly as it did over one root before.
816998
std::vector<std::string> javaFiles;
817-
{ std::error_code ec;
818-
for (auto& e : fs::recursive_directory_iterator(opt.java_sources, ec)) {
819-
if (ec) break;
820-
if (e.is_regular_file(ec) && e.path().extension() == ".java")
821-
javaFiles.push_back(e.path().string());
822-
}
823-
}
824-
if (javaFiles.empty()) {
825-
std::cerr << std::format(
826-
"mcpp.dist.apk: options::java_sources '{}' carries no .java "
827-
"file", opt.java_sources) << '\n';
828-
p.reason = "no .java sources";
829-
return p;
999+
for (auto const& root : opt.java_sources) {
1000+
if (!is_dir(root)) {
1001+
std::cerr << std::format(
1002+
"mcpp.dist.apk: options::java_sources root '{}' is not a "
1003+
"directory", root) << '\n';
1004+
p.reason = "java_sources directory not found";
1005+
return p;
1006+
}
1007+
const std::size_t before = javaFiles.size();
1008+
{ std::error_code ec;
1009+
for (auto& e : fs::recursive_directory_iterator(root, ec)) {
1010+
if (ec) break;
1011+
if (e.is_regular_file(ec) && e.path().extension() == ".java")
1012+
javaFiles.push_back(e.path().string());
1013+
}
1014+
}
1015+
if (javaFiles.size() == before) {
1016+
std::cerr << std::format(
1017+
"mcpp.dist.apk: options::java_sources root '{}' carries "
1018+
"no .java file", root) << '\n';
1019+
p.reason = "no .java sources";
1020+
return p;
1021+
}
1022+
// THE RE-RUN QUESTION (design record §3.3). `glob_fingerprint`
1023+
// walks the PACKAGE ROOT and matches paths relative to it; a
1024+
// root outside that walk (a dependency's unpack directory)
1025+
// matches nothing, and the fingerprint would be the same as "no
1026+
// files" -- a criterion whose "no" reads as silence. So a
1027+
// project root (under `mcpp::manifest_dir()`) is declared with
1028+
// the glob, as today; a dependency root is not: its file set
1029+
// changes only with the dependency's version, already in the
1030+
// build's fingerprint, and each of its files is already an
1031+
// input of the `javac` action below.
1032+
if (root_in_project(root))
1033+
mcpp::rerun_if_changed_glob((root + "/**/*.java").c_str());
8301034
}
831-
mcpp::rerun_if_changed_glob((opt.java_sources + "/**/*.java").c_str());
8321035

8331036
const std::string classesDir = (outDir / "classes").string();
8341037
step javacStep;

0 commit comments

Comments
 (0)