@@ -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+
183233inline 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+
341498inline 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