diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 2ddad09b17b..1b26a84c9cc 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -3190,6 +3190,99 @@ public boolean canDial() { /** * @inheritDoc */ + private static String cn1DistributionChannel; + private static boolean cn1DistributionChannelResolved; + /** Codename One channel id-value pair id in the APK Signing Block ('c','n','1','C'). */ + private static final int CN1_CHANNEL_PAIR_ID = 0x636E3143; + + /** + * The distribution channel (app store) stamped into this APK's Signing Block by + * the build server's channel packages, or null for a normal build. Read once and + * cached. Mirrors the daemon's {@code ApkChannelWriter}: locate the signing block + * before the central directory and return the Codename One channel pair's value. + */ + private String readDistributionChannel() { + if (cn1DistributionChannelResolved) { + return cn1DistributionChannel; + } + cn1DistributionChannelResolved = true; + try { + cn1DistributionChannel = cn1ReadChannelFromApk(getContext().getApplicationInfo().sourceDir); + } catch (Throwable t) { + cn1DistributionChannel = null; + } + return cn1DistributionChannel; + } + + private static String cn1ReadChannelFromApk(String path) throws java.io.IOException { + java.io.RandomAccessFile f = new java.io.RandomAccessFile(path, "r"); + try { + long len = f.length(); + long eocd = -1; + long maxBack = Math.min(len, 22 + 0xFFFF); + for (long i = len - 22; i >= len - maxBack && i >= 0; i--) { + if (cn1U32(f, i) == 0x06054b50L) { + eocd = i; + break; + } + } + if (eocd < 0) { + return null; + } + long cdOffset = cn1U32(f, eocd + 16); + if (cdOffset < 24 || cdOffset == 0xFFFFFFFFL) { + return null; + } + byte[] magic = "APK Sig Block 42".getBytes("US-ASCII"); + byte[] m = new byte[magic.length]; + f.seek(cdOffset - 16); + f.readFully(m); + for (int i = 0; i < magic.length; i++) { + if (m[i] != magic[i]) { + return null; + } + } + long sizeOfBlock = cn1U64(f, cdOffset - 24); + long blockStart = cdOffset - 8 - sizeOfBlock; + if (blockStart < 0) { + return null; + } + long p = blockStart + 8, to = cdOffset - 24; + while (p < to) { + long pairLen = cn1U64(f, p); + p += 8; + if (pairLen < 4 || p + pairLen > to + 8) { + break; + } + if ((int) cn1U32(f, p) == CN1_CHANNEL_PAIR_ID) { + byte[] v = new byte[(int) (pairLen - 4)]; + f.seek(p + 4); + f.readFully(v); + return new String(v, "UTF-8"); + } + p += pairLen; + } + return null; + } finally { + f.close(); + } + } + + private static long cn1U32(java.io.RandomAccessFile f, long at) throws java.io.IOException { + f.seek(at); + int b0 = f.read(), b1 = f.read(), b2 = f.read(), b3 = f.read(); + return (b0 & 0xFFL) | ((b1 & 0xFFL) << 8) | ((b2 & 0xFFL) << 16) | ((b3 & 0xFFL) << 24); + } + + private static long cn1U64(java.io.RandomAccessFile f, long at) throws java.io.IOException { + f.seek(at); + long v = 0; + for (int i = 0; i < 8; i++) { + v |= (f.read() & 0xFFL) << (8 * i); + } + return v; + } + public String getProperty(String key, String defaultValue) { if(key.equalsIgnoreCase("cn1_push_prefix")) { /*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get notifications")){ @@ -3204,6 +3297,13 @@ public String getProperty(String key, String defaultValue) { if ("OS".equals(key)) { return "Android"; } + if ("DistributionChannel".equalsIgnoreCase(key) || "cn1.channel".equalsIgnoreCase(key)) { + // The app store this build was distributed through, stamped into the APK + // Signing Block by the Codename One build server's channel packages + // (android.distributionChannels). Empty for a normal Google Play build. + String ch = readDistributionChannel(); + return ch != null ? ch : defaultValue; + } // It's possible that this is triggering a Google Play data collection verification error /*if ("androidId".equals(key)) { @@ -9618,11 +9718,11 @@ public boolean isGalleryTypeSupported(int type) { - public void openGallery(final ActionListener response, int type){ - if (!isGalleryTypeSupported(type)) { - throw new IllegalArgumentException("Gallery type "+type+" not supported on this platform."); - } - if (getActivity() == null) { + public void openGallery(final ActionListener response, int type){ + if (!isGalleryTypeSupported(type)) { + throw new IllegalArgumentException("Gallery type "+type+" not supported on this platform."); + } + if (getActivity() == null) { throw new RuntimeException("Cannot open galery in background mode"); } if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { @@ -9686,64 +9786,64 @@ public void openGallery(final ActionListener response, int type){ galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, Display.getInstance().getProperty("android.openGallery.accept", "*/*").split(",")); }else{ galleryIntent.setType("*/*"); - } - this.getActivity().startActivityForResult(galleryIntent, multi ? OPEN_GALLERY_MULTI: OPEN_GALLERY); - } - - @Override - public void openFileChooser(final ActionListener response, String accept) { - if (getActivity() == null) { - throw new RuntimeException("Cannot open file chooser in background mode"); - } - if(editInProgress()) { - stopEditing(true); - } - callback = new EventDispatcher(); - callback.addListener(response); - Intent pickerIntent = new Intent(); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - pickerIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); - } else { - pickerIntent.setAction(Intent.ACTION_GET_CONTENT); - } - pickerIntent.addCategory(Intent.CATEGORY_OPENABLE); - pickerIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - pickerIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - } - String[] mimeTypes = getFileChooserMimeTypes(accept); - pickerIntent.setType("*/*"); - if (mimeTypes.length > 0) { - pickerIntent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); - } - this.getActivity().startActivityForResult(pickerIntent, OPEN_GALLERY); - } - - private String[] getFileChooserMimeTypes(String accept) { - if (accept == null || accept.trim().length() == 0 || "*/*".equals(accept.trim())) { - return new String[0]; - } - ArrayList out = new ArrayList(); - String[] tokens = accept.split(","); - for (int iter = 0; iter < tokens.length; iter++) { - String token = tokens[iter].trim(); - if (token.length() == 0 || "*".equals(token)) { - continue; - } - if (token.indexOf('/') > 0) { - out.add(token); - } - } - if (out.isEmpty()) { - out.add("*/*"); - } - return out.toArray(new String[out.size()]); - } - - class NativeImage extends Image { - - public NativeImage(Bitmap nativeImage) { - super(nativeImage); + } + this.getActivity().startActivityForResult(galleryIntent, multi ? OPEN_GALLERY_MULTI: OPEN_GALLERY); + } + + @Override + public void openFileChooser(final ActionListener response, String accept) { + if (getActivity() == null) { + throw new RuntimeException("Cannot open file chooser in background mode"); + } + if(editInProgress()) { + stopEditing(true); + } + callback = new EventDispatcher(); + callback.addListener(response); + Intent pickerIntent = new Intent(); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + pickerIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); + } else { + pickerIntent.setAction(Intent.ACTION_GET_CONTENT); + } + pickerIntent.addCategory(Intent.CATEGORY_OPENABLE); + pickerIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + pickerIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + } + String[] mimeTypes = getFileChooserMimeTypes(accept); + pickerIntent.setType("*/*"); + if (mimeTypes.length > 0) { + pickerIntent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + } + this.getActivity().startActivityForResult(pickerIntent, OPEN_GALLERY); + } + + private String[] getFileChooserMimeTypes(String accept) { + if (accept == null || accept.trim().length() == 0 || "*/*".equals(accept.trim())) { + return new String[0]; + } + ArrayList out = new ArrayList(); + String[] tokens = accept.split(","); + for (int iter = 0; iter < tokens.length; iter++) { + String token = tokens[iter].trim(); + if (token.length() == 0 || "*".equals(token)) { + continue; + } + if (token.indexOf('/') > 0) { + out.add(token); + } + } + if (out.isEmpty()) { + out.add("*/*"); + } + return out.toArray(new String[out.size()]); + } + + class NativeImage extends Image { + + public NativeImage(Bitmap nativeImage) { + super(nativeImage); } } diff --git a/docs/developer-guide/App-Store-Submission.asciidoc b/docs/developer-guide/App-Store-Submission.asciidoc new file mode 100644 index 00000000000..99265dd13d3 --- /dev/null +++ b/docs/developer-guide/App-Store-Submission.asciidoc @@ -0,0 +1,284 @@ +[[app-store-submission]] +== App Store Submission and Deployment + +Once a build is signed you still have to get it in front of users: upload the +binary, fill in the release notes, set the store listing, and send it for review -- +on every store where your users are. Codename One automates that last mile as a +one-stop shop. From the build console you can submit a finished build to the +*App Store*, the *Mac App Store*, *Google Play*, and *Huawei AppGallery* with one +click, manage the store *listing itself* -- title, description, keywords, +what's-new, screenshots -- as code in your project, and produce per-store Android +packages so you can reach the large Android audience that isn't on Google Play. + +Reaching those audiences doesn't depend on, and can't be restricted by, any single +store. Google Play always receives the standard, unmodified Android App Bundle, so +your Play listing follows Google's rules exactly; the additional channels +(AppGallery and other Android markets) are separate artifacts delivered to +separate stores. + +This chapter covers the end-to-end flow, credentials, the metadata-as-code +workflow, and multi-store Android distribution. It builds on +<>: submission reuses the same App Store +Connect API key the certificate wizard configures. + +=== What it does, and what it doesn't + +Automated submission delivers the binary and applies your release metadata to an +*existing* app on the store. It doesn't create the app for you. + +IMPORTANT: You must create the app once, by hand, in +https://appstoreconnect.apple.com[App Store Connect] and/or the +https://play.google.com/console[Google Play Console] before you can submit to it. +Neither store's API can register a brand-new app, set up pricing, in-app +purchases, the privacy questionnaire, or the content-rating questionnaire -- those +remain one-time manual steps in the store consoles. After that, every release's +binary and listing can be shipped automatically. + +Three things are worth separating, because they happen at different times: + +* *The app record* -- the listing shell in the store. Created once, manually, and + exists before any build. +* *The binary* -- produced by your build, delivered when you *submit*. +* *The metadata* -- description, what's-new, screenshots. Managed as code and + applied when you submit. Metadata attaches to the app record and the new + version, not to the binary, so there's no chicken-and-egg problem: the app + already exists on the store by the time you build. + +=== Prerequisites + +==== Store credentials + +*Apple.* Submission uses the App Store Connect API key described under +<>. If you have already run +`mvn cn1:certificatewizard` and stored the key, you are ready -- the same key that +creates certificates and profiles also uploads builds and manages listings. + +*Google.* Create a Google Play service account with the *Android Publisher* role +in the Google Play Console (API access > Service accounts), download its JSON key, +and add it to the build console's submission credentials page. The console shows +which stores are connected. + +*Huawei.* In AppGallery Connect, create an API client under *Users and +permissions > API client* and note its *Client ID* and *Client Secret*, then add +them on the same submission credentials page. The app must already exist in +AppGallery Connect (submission binds to the app resolved from your package name). + +The Submissions page shows a connect card for each store you haven't configured +yet; fill one in and save to enable one-click submission to that store. + +.The submission credentials cards in the build console +image::img/submission-store-credentials.png[Build console Submissions page showing the App Store Connect and Huawei AppGallery credential cards,scaledwidth=90%] + +==== Beta vs production + +When you submit you choose a destination. *Beta* means TestFlight (Apple) or the +internal-testing track (Google). *Production* means App Store review (Apple) or the +production track (Google). + +=== Submitting a build from the console + +On any successful build the console shows a *Submit* action for each store the +build can go to: + +* *Submit to App Store* -- when the build produced an iOS `.ipa`. +* *Submit to Mac App Store* -- when the build produced a native/Catalyst `.pkg` + (see the *Working with macOS* chapter). An iOS build that also produced a + `.pkg` offers both. +* *Submit to Google Play* -- for an Android build. +* *Submit to AppGallery* -- for an Android build, to Huawei AppGallery. Production + sends the build for review; the beta destination uploads it and leaves it as a + draft for you to promote or open-test. + +The submit dialog lets you pick the destination (Beta or Production) and add +release notes for this submission, then delivers the binary. For an Apple +*production* submission the console then tracks the App Store review state +(processing -> in review -> approved/rejected) and can email you when the state +changes. Google submissions and TestFlight deliveries complete as soon as the +binary is delivered. + +TIP: Submitting to *Production* on Apple opens an App Store review submission +automatically. Submitting to *Beta* delivers to TestFlight without opening a +review. + +=== Metadata as code + +Release metadata -- the store listing text and screenshots -- can live in your +project as a folder of plain text and image files, versioned in git alongside your +code, and pushed to the stores with a single command. This makes your listing +reproducible and CI-drivable: generate release notes from your changelog, push, +and the next submission applies it to both stores. It's the alternative to typing +the same fields into two different web consoles by hand for every release. + +==== The metadata folder + +By default the goal reads a `cn1-metadata` folder next to your +`codenameone_settings.properties`. It's organized by store, then locale, with one +plain text file per listing field: + +[source] +---- +cn1-metadata/ + apple/ + primary_locale.txt (optional; defaults to the first locale) + primary_category.txt (optional; an App Store Connect category id) + secondary_category.txt (optional) + en-US/ + name.txt (app name, max 30 chars) + subtitle.txt (max 30 chars) + description.txt (max 4000 chars) + keywords.txt (comma separated, max 100 chars) + promotional_text.txt (max 170 chars) + whats_new.txt (release notes for this version) + marketing_url.txt + support_url.txt + privacy_url.txt + screenshots/ + APP_IPHONE_67/1.png 2.png ... + APP_IPAD_PRO_129/1.png ... + google/ + en-US/ + name.txt (listing title, max 30 chars) + subtitle.txt (short description, max 80 chars) + description.txt (full description, max 4000 chars) + whats_new.txt (release notes, max 500 chars) + support_url.txt + screenshots/ + phoneScreenshots/1.png 2.png ... + tenInchScreenshots/1.png ... +---- + +Every file is optional; a field you don't include keeps whatever value the store +already has. The same neutral file names map to each store's fields (for example +`subtitle.txt` becomes the App Store *subtitle* and the Google Play *short +description*). Fields a store doesn't have (Apple `keywords`/`promotional_text`) +are simply ignored for the other store. + +The `screenshots/` sub-folders are named with each store's own type +identifiers: App Store `screenshotDisplayType` values (for example +`APP_IPHONE_67`) for Apple, and listing `imageType` values (for example +`phoneScreenshots`) for Google. Images are uploaded in filename order. + +TIP: You don't have to create the folder by hand. Run `mvn cn1:metadata-init` to +scaffold `cn1-metadata` with an empty template of the field files, a screenshots +folder, and a `README`, ready to fill in: ++ +[source] +---- +mvn cn1:metadata-init # apple + google, en-US +mvn cn1:metadata-init -Dstore=apple -Dlocale=fr-FR +---- ++ +It only creates what's missing, so it's safe to re-run (for example to add a new +locale) -- it never overwrites files you've already filled in. + +==== Pushing the metadata + +Run the goal from your project; it authenticates with the same Codename One +account your builds use (run `mvn cn1:certificatewizard` once to sign in, or pass +`-Dtoken=`): + +[source] +---- +# push both stores from cn1-metadata/ +mvn cn1:metadata-push + +# or a single store +mvn cn1:metadata-push -Dstore=apple + +# override the folder or the package id if needed +mvn cn1:metadata-push -DmetadataDir=path/to/metadata -Dpackage=com.example.app +---- + +The package id defaults to `codename1.packageName` from +`codenameone_settings.properties`. The goal validates every field against the +store's limits before storing it, so an over-long name or a malformed URL fails +fast with a clear message. Wire it into CI to keep the stored listing in sync with +your repo. + +NOTE: `metadata-push` uploads the descriptor and screenshots to Codename One; it +does *not* submit. The stored metadata is applied to the store the next time you +*submit* a build for that package. + +==== When and how it's applied + +At submission time the stored descriptor is applied to the store automatically: + +* *Apple* -- applied to the *production* App Store version (App Store review), + while the version is still editable. TestFlight has no store listing, so beta + Apple submissions don't apply listing metadata. +* *Google* -- applied to the listing on the edit that publishes the binary, on any + track. + +Applying metadata is *best-effort*: the binary is delivered first, so a rejected +field (say a name that's too long, or a locale the app hasn't enabled) never blocks +the submission -- it's recorded as a warning you can see in the console, and the +rest of the listing still applies. You can always fix a field by hand in the store +console afterward. + +==== Screenshots aren't stored long-term + +Screenshots are a transient pass-through: `metadata-push` uploads them, the next +submission delivers them to the store, and they're then deleted from Codename +One's servers. Keep the canonical images in your repo (that's the point of +managing them as code) and re-push when they change. Text descriptors are retained +until you delete them, your account is deleted, or they go unused for about a year. + +Per-image limits: up to 10 MB, PNG or JPEG, and at most 10 screenshots per locale +and display type (the stores' own limit). + +=== Reaching Android users beyond Google Play + +A large share of Android users -- most of the market in China, and many devices +elsewhere -- can't install from Google Play. Reaching them means shipping to other +Android stores: Huawei AppGallery, and the many app markets run by device makers +and carriers (Xiaomi, OPPO, VIVO, Tencent MyApp, Baidu, 360 and others). Codename +One makes your app available on those stores without changing how you build or +locking you to any of them. + +Huawei AppGallery is a first-class submission target, exactly like the App Store or +Google Play (see <> above). For the +remaining Android markets, Codename One produces *distribution-channel packages*. + +==== Distribution-channel packages + +Most of these stores accept a plain, self-signed APK rather than an App Bundle, and +they attribute each download and its revenue to the store it came from. A +distribution-channel package is your signed release APK stamped with a store +identifier -- one APK per store, all built from the same release. + +You choose the stores; the build produces one package per store, each carrying its +channel id. Your app reads that id at runtime with +`Display.getInstance().getProperty("DistributionChannel", "")` -- an empty string +for a normal Google Play build -- so you can report the install source (to your own +analytics or the store's) *without adding any third-party channel SDK*. Codename One +writes the id and reads it back for you. + +TIP: Google Play is never a channel package. Play always receives the standard, +unmodified Android App Bundle, so nothing about this feature affects your Play +listing or its compliance -- the channel packages are separate APKs for separate +stores. + +Sensible defaults keep this simple: enable it and you get packages for the major +Android markets out of the box, and you can narrow or extend the channel list to +match the stores you actually publish to. Each package is signed with your app's +release key, so every store receives a signed, installable APK. + +=== Benefits over editing the stores by hand + +Managing submission and metadata through Codename One pays off most for teams, +frequent releases, many locales, or many apps: + +* *Automated release notes.* Generate what's-new from your changelog and ship it to + both stores on every release -- the one field that always changes. +* *One source, two stores.* A single descriptor fans out to App Store Connect and + Google Play, which have different fields and limits. +* *Reproducible and reviewable.* The listing lives in git, versioned and + code-reviewed like everything else. Console edits have no history. +* *Localization at scale.* Localized listings are just files, and can be generated + or translated in a pipeline. +* *Fewer console logins.* Team members who ship releases don't each need App Store + Connect or Play Console access; they ship through the build pipeline instead. + +For a single app with infrequent updates, editing the store console by hand is +fine -- the payoff scales with how often you release and how many listings you +maintain. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 97a49572788..a78d414ce04 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -155,6 +155,8 @@ include::Working-With-Javascript.asciidoc[] include::Working-with-Mac-OS-X.asciidoc[] +include::App-Store-Submission.asciidoc[] + include::Desktop-Integration.asciidoc[] include::Working-With-Windows.asciidoc[] diff --git a/docs/developer-guide/img/submission-store-credentials.png b/docs/developer-guide/img/submission-store-credentials.png new file mode 100644 index 00000000000..9c9609332fb Binary files /dev/null and b/docs/developer-guide/img/submission-store-credentials.png differ diff --git a/docs/website/content/pricing.md b/docs/website/content/pricing.md index fae0bd68c6c..4ac0aa739fc 100644 --- a/docs/website/content/pricing.md +++ b/docs/website/content/pricing.md @@ -40,6 +40,11 @@ For annual Pro/Enterprise subscriptions, SWIFT transfer and invoicing are availa ### Are there limits on number of apps? No. Pricing is per developer seat, not per app. +### Are there limits on submitting my app to the App Store or Google Play? +Manual submission is always unlimited and free. You can upload as many builds as you like to App Store Connect or the Google Play Console yourself, on any plan including Free — Codename One never sits between you and the stores. + +The limits shown above apply only to the optional *automated* submission feature: one-click submit from the build console and pushing your store listing as code (`cn1:metadata-push`). That's a convenience that delivers the binary and updates the listing for you; higher plans raise how many automated submissions and metadata updates you get per month, and add production review and zero-touch auto-submit. It never restricts your ability to publish manually. + ### Will my app still work if I cancel subscription? Yes. Built apps continue to work. diff --git a/docs/website/layouts/_default/pricing.html b/docs/website/layouts/_default/pricing.html index b6cdc61138e..78ff0e807b8 100644 --- a/docs/website/layouts/_default/pricing.html +++ b/docs/website/layouts/_default/pricing.html @@ -52,6 +52,9 @@

{{ .Title }}

  • Versioning (master + 2 weeks)
  • Push notifications (5K/month)
  • Commerce IAP validation up to $10K/mo
  • +
  • Automated submission: App Store, Google Play, AppGallery (5/mo, beta)
  • +
  • Multi-store Android distribution (reach beyond Google Play)
  • +
  • Store metadata & screenshots as code
  • {{ .Title }}
  • Email support
  • Concurrent builds
  • Commerce IAP validation up to $200K/mo
  • +
  • Production store submission & review tracking
  • +
  • 50 automated submissions/month
  • {{ .Title }}
  • Push notifications (10M/month)
  • Phone support (office hours)
  • Commerce IAP validation (unlimited)
  • +
  • Unlimited automated submissions
  • +
  • Zero-touch auto-submit on build
  • + * mvn cn1:metadata-init # apple + google, en-US + * mvn cn1:metadata-init -Dstore=apple -Dlocale=fr-FR + * + * + * Empty field files are ignored by {@code metadata-push} (an unset field leaves + * the store's value unchanged), so you can scaffold everything and fill in only + * the fields you want to manage. + */ +@Mojo(name = "metadata-init", requiresProject = true) +public class MetadataInitMojo extends AbstractCN1Mojo { + + @Parameter(property = "store", defaultValue = "both") + private String store; + + @Parameter(property = "locale", defaultValue = "en-US") + private String locale; + + @Parameter(property = "metadataDir") + private File metadataDir; + + private static final List APPLE_FIELDS = Arrays.asList( + "name.txt", "subtitle.txt", "description.txt", "keywords.txt", + "promotional_text.txt", "whats_new.txt", "marketing_url.txt", + "support_url.txt", "privacy_url.txt"); + private static final List GOOGLE_FIELDS = Arrays.asList( + "name.txt", "subtitle.txt", "description.txt", "whats_new.txt", "support_url.txt"); + + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + if (!isCN1ProjectDir()) { + getLog().warn("Not a Codename One project directory; skipping metadata-init."); + return; + } + File dir = metadataDir != null ? metadataDir : new File(getCN1ProjectDir(), "cn1-metadata"); + List stores = "both".equalsIgnoreCase(store) + ? Arrays.asList("apple", "google") : Arrays.asList(store.toLowerCase()); + try { + mkdirs(dir); + writeIfMissing(new File(dir, "README.txt"), readmeText()); + for (String s : stores) { + boolean apple = "apple".equals(s); + File storeDir = new File(dir, s); + File localeDir = new File(storeDir, locale); + mkdirs(localeDir); + for (String field : (apple ? APPLE_FIELDS : GOOGLE_FIELDS)) { + writeIfMissing(new File(localeDir, field), ""); + } + // An example screenshots folder for the store's default device type. + String type = apple ? "APP_IPHONE_67" : "phoneScreenshots"; + File shotsDir = new File(new File(localeDir, "screenshots"), type); + mkdirs(shotsDir); + writeIfMissing(new File(shotsDir, ".gitkeep"), ""); + } + } catch (IOException e) { + throw new MojoExecutionException("Could not scaffold metadata folder under " + dir, e); + } + getLog().info("Metadata scaffold ready under " + dir + + " -- fill in the .txt files and run 'mvn cn1:metadata-push'."); + } + + private void mkdirs(File d) throws IOException { + if (!d.isDirectory() && !d.mkdirs()) { + throw new IOException("could not create " + d); + } + } + + private void writeIfMissing(File f, String content) throws IOException { + if (f.exists()) { + getLog().debug("exists, skipping " + f); + return; + } + Files.write(f.toPath(), content.getBytes(StandardCharsets.UTF_8)); + getLog().info(" created " + f.getName() + " (" + f.getParentFile().getName() + ")"); + } + + private static String readmeText() { + return "cn1-metadata -- store listing metadata as code (cn1:metadata-push).\n\n" + + "Layout: //.txt and //screenshots//*.png\n" + + " store : apple | google\n" + + " locale : e.g. en-US, fr-FR (one folder per locale)\n" + + " fields : name, subtitle, description, keywords (apple), promotional_text (apple),\n" + + " whats_new, marketing_url, support_url, privacy_url\n" + + " screenshots type: Apple screenshotDisplayType (e.g. APP_IPHONE_67) /\n" + + " Google imageType (e.g. phoneScreenshots)\n\n" + + "Empty files are ignored (the store keeps its current value). Optional store-level\n" + + "files: primary_locale.txt, primary_category.txt, secondary_category.txt (apple).\n" + + "Push with: mvn cn1:metadata-push\n"; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/MetadataPushMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/MetadataPushMojo.java new file mode 100644 index 00000000000..095c01b3c54 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/MetadataPushMojo.java @@ -0,0 +1,415 @@ +package com.codename1.maven; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.prefs.Preferences; +import org.apache.commons.io.FileUtils; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +/** + * Pushes "metadata as code" (store listing text + screenshots) to the Codename + * One build cloud, which applies it to App Store Connect / Google Play at + * submission time. The descriptor lives in your project as a folder of plain text + * files (one file per listing field) so the listing is versioned in git and + * CI-drivable; this goal reads it, builds the descriptor, and PUTs it plus the + * screenshots over the same Codename One account the build uses. + * + *

    Folder layout (default {@code /cn1-metadata}): + *

    + *   cn1-metadata/
    + *     apple/
    + *       primary_locale.txt          (optional; default "en-US")
    + *       primary_category.txt        (optional; ASC appCategories id)
    + *       secondary_category.txt      (optional)
    + *       en-US/
    + *         name.txt subtitle.txt description.txt keywords.txt
    + *         promotional_text.txt whats_new.txt
    + *         marketing_url.txt support_url.txt privacy_url.txt
    + *         screenshots/APP_IPHONE_67/1.png 2.png ...
    + *     google/
    + *       en-US/
    + *         name.txt subtitle.txt description.txt whats_new.txt support_url.txt
    + *         screenshots/phoneScreenshots/1.png ...
    + * 
    + * + *

    For Apple, {@code screenshots/

    } names are ASC {@code screenshotDisplayType} + * values; for Google they are listing {@code imageType} values (e.g. + * {@code phoneScreenshots}). Unset files are simply omitted (the store keeps its + * current value). Metadata is a paid feature (Basic and up) and monthly-metered. + * + *

    Auth reuses the cached Codename One token (run {@code mvn cn1:certificatewizard} + * once, or {@code -Dtoken=...}); the app's bundle id comes from + * {@code codename1.packageName} in {@code codenameone_settings.properties}. + */ +@Mojo(name = "metadata-push", requiresProject = true) +public class MetadataPushMojo extends AbstractCN1Mojo { + + /** Base URL of the Codename One build cloud. */ + @Parameter(property = "baseUrl", defaultValue = "https://cloud.codenameone.com") + private String baseUrl; + + /** Keycloak bearer token; falls back to the cached CN1 login token. */ + @Parameter(property = "token") + private String token; + + /** {@code apple}, {@code google}, or {@code both} (default). */ + @Parameter(property = "store", defaultValue = "both") + private String store; + + /** Bundle/package id; defaults to {@code codename1.packageName}. */ + @Parameter(property = "package") + private String packageName; + + /** Metadata folder; defaults to {@code /cn1-metadata}. */ + @Parameter(property = "metadataDir") + private File metadataDir; + + /** Text file name -> descriptor field. */ + private static final Map FIELDS = new LinkedHashMap<>(); + static { + FIELDS.put("name.txt", "name"); + FIELDS.put("subtitle.txt", "subtitle"); + FIELDS.put("description.txt", "description"); + FIELDS.put("keywords.txt", "keywords"); + FIELDS.put("promotional_text.txt", "promotionalText"); + FIELDS.put("whats_new.txt", "whatsNew"); + FIELDS.put("release_notes.txt", "whatsNew"); // alias + FIELDS.put("marketing_url.txt", "marketingUrl"); + FIELDS.put("support_url.txt", "supportUrl"); + FIELDS.put("privacy_url.txt", "privacyPolicyUrl"); + } + + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + if (!isCN1ProjectDir()) { + getLog().warn("Not a Codename One project directory; skipping metadata-push."); + return; + } + String pkg = firstNonEmpty(packageName, + properties != null ? properties.getProperty("codename1.packageName") : null); + if (isEmpty(pkg)) { + throw new MojoFailureException("No package id. Set codename1.packageName in " + + "codenameone_settings.properties or pass -Dpackage=com.example.app"); + } + String jwt = resolveToken(); + File dir = metadataDir != null ? metadataDir : new File(getCN1ProjectDir(), "cn1-metadata"); + if (!dir.isDirectory()) { + throw new MojoFailureException("Metadata folder not found: " + dir + + " (create it, or pass -DmetadataDir=...)"); + } + List stores = "both".equalsIgnoreCase(store) + ? Arrays.asList("apple", "google") : Arrays.asList(store.toLowerCase()); + boolean pushedAny = false; + for (String s : stores) { + File storeDir = new File(dir, s); + if (!storeDir.isDirectory()) { + getLog().info("No " + s + "/ folder under " + dir + "; skipping " + s + "."); + continue; + } + pushStore(baseUrl, jwt, pkg, s, storeDir); + pushedAny = true; + } + if (!pushedAny) { + getLog().warn("Nothing to push -- no apple/ or google/ folder under " + dir); + } + } + + private void pushStore(String base, String jwt, String pkg, String store, File storeDir) + throws MojoExecutionException, MojoFailureException { + String descriptor = buildDescriptor(storeDir); + getLog().info("Pushing " + store + " metadata for " + pkg + " ..."); + String url = base + "/appsec/7.0/metadata?package=" + enc(pkg) + "&store=" + enc(store); + HttpResult put = httpJson("PUT", url, jwt, descriptor); + if (put.code < 200 || put.code >= 300) { + throw new MojoFailureException("Metadata push failed (HTTP " + put.code + "): " + put.body); + } + pushScreenshots(base, jwt, pkg, store, storeDir); + getLog().info(store + " metadata pushed."); + } + + /** Build the AppMetadata JSON from the store folder. */ + private String buildDescriptor(File storeDir) throws MojoExecutionException { + String primaryLocale = readOpt(new File(storeDir, "primary_locale.txt")); + String primaryCategory = readOpt(new File(storeDir, "primary_category.txt")); + String secondaryCategory = readOpt(new File(storeDir, "secondary_category.txt")); + + StringBuilder locales = new StringBuilder(); + String firstLocale = null; + File[] localeDirs = storeDir.listFiles(File::isDirectory); + if (localeDirs != null) { + Arrays.sort(localeDirs); + for (File localeDir : localeDirs) { + String loc = localeDir.getName(); + StringBuilder fields = new StringBuilder(); + for (Map.Entry e : FIELDS.entrySet()) { + String v = readOpt(new File(localeDir, e.getKey())); + if (v != null) { + appendField(fields, e.getValue(), v); + } + } + if (fields.length() == 0) { + continue; // a screenshots-only locale dir + } + if (firstLocale == null) { + firstLocale = loc; + } + if (locales.length() > 0) { + locales.append(','); + } + locales.append(jsonStr(loc)).append(":{").append(fields).append('}'); + } + } + if (primaryLocale == null) { + primaryLocale = firstLocale != null ? firstLocale : "en-US"; + } + StringBuilder json = new StringBuilder("{"); + appendField(json, "primaryLocale", primaryLocale); + if (primaryCategory != null) { + appendField(json, "primaryCategory", primaryCategory); + } + if (secondaryCategory != null) { + appendField(json, "secondaryCategory", secondaryCategory); + } + json.append(",\"locales\":{").append(locales).append("}}"); + return json.toString(); + } + + /** Replace the staged set, then upload every screenshots//*.png|jpg. */ + private void pushScreenshots(String base, String jwt, String pkg, String store, File storeDir) + throws MojoExecutionException, MojoFailureException { + List shots = collectShots(storeDir); + // Clear the previous staged set so a re-push is idempotent. + httpJson("DELETE", base + "/appsec/7.0/metadata/screenshots?package=" + enc(pkg) + + "&store=" + enc(store), jwt, null); + for (Shot shot : shots) { + String url = base + "/appsec/7.0/metadata/screenshots?package=" + enc(pkg) + + "&store=" + enc(store) + "&locale=" + enc(shot.locale) + + "&displayType=" + enc(shot.displayType) + "&ordinal=" + shot.ordinal; + HttpResult r = httpMultipart(url, jwt, shot.file); + if (r.code < 200 || r.code >= 300) { + throw new MojoFailureException("Screenshot upload failed for " + shot.file.getName() + + " (HTTP " + r.code + "): " + r.body); + } + getLog().info(" uploaded " + shot.locale + "/" + shot.displayType + "/" + shot.file.getName()); + } + } + + private List collectShots(File storeDir) { + List out = new ArrayList<>(); + File[] localeDirs = storeDir.listFiles(File::isDirectory); + if (localeDirs == null) { + return out; + } + Arrays.sort(localeDirs); + for (File localeDir : localeDirs) { + for (String dirName : new String[] {"screenshots", "images"}) { + File shotsRoot = new File(localeDir, dirName); + File[] typeDirs = shotsRoot.listFiles(File::isDirectory); + if (typeDirs == null) { + continue; + } + Arrays.sort(typeDirs); + for (File typeDir : typeDirs) { + File[] imgs = typeDir.listFiles((d, n) -> { + String l = n.toLowerCase(); + return l.endsWith(".png") || l.endsWith(".jpg") || l.endsWith(".jpeg"); + }); + if (imgs == null) { + continue; + } + Arrays.sort(imgs); + int ordinal = 0; + for (File img : imgs) { + out.add(new Shot(localeDir.getName(), typeDir.getName(), ordinal++, img)); + } + } + } + } + return out; + } + + // --- HTTP --- + + private HttpResult httpJson(String method, String url, String jwt, String body) + throws MojoExecutionException { + try { + HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection(); + con.setRequestMethod(method); + con.setConnectTimeout(20000); + con.setReadTimeout(60000); + con.setRequestProperty("Authorization", "Bearer " + jwt); + if (body != null) { + con.setDoOutput(true); + con.setRequestProperty("Content-Type", "application/json"); + try (OutputStream os = con.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + } + return read(con); + } catch (IOException e) { + throw new MojoExecutionException("HTTP " + method + " " + url + " failed", e); + } + } + + private HttpResult httpMultipart(String url, String jwt, File file) throws MojoExecutionException { + String boundary = "----cn1meta" + Long.toHexString(file.length()) + file.getName().hashCode(); + try { + HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection(); + con.setRequestMethod("POST"); + con.setConnectTimeout(20000); + con.setReadTimeout(120000); + con.setDoOutput(true); + con.setRequestProperty("Authorization", "Bearer " + jwt); + con.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + String type = file.getName().toLowerCase().endsWith(".jpg") + || file.getName().toLowerCase().endsWith(".jpeg") ? "image/jpeg" : "image/png"; + try (DataOutputStream out = new DataOutputStream(con.getOutputStream())) { + out.writeBytes("--" + boundary + "\r\n"); + out.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"" + + file.getName() + "\"\r\n"); + out.writeBytes("Content-Type: " + type + "\r\n\r\n"); + out.write(Files.readAllBytes(file.toPath())); + out.writeBytes("\r\n--" + boundary + "--\r\n"); + } + return read(con); + } catch (IOException e) { + throw new MojoExecutionException("Screenshot upload " + url + " failed", e); + } + } + + private static HttpResult read(HttpURLConnection con) throws IOException { + int code = con.getResponseCode(); + java.io.InputStream in = code >= 400 ? con.getErrorStream() : con.getInputStream(); + String body = ""; + if (in != null) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > -1) { + bos.write(buf, 0, n); + } + body = bos.toString("UTF-8"); + } + con.disconnect(); + return new HttpResult(code, body); + } + + // --- helpers --- + + private String resolveToken() throws MojoFailureException { + Preferences prefs = Preferences.userRoot().node("/com/codename1/ui"); + String t = firstNonEmpty(token, prefs.get("token", null)); + if (isEmpty(t)) { + throw new MojoFailureException("No Codename One token. Run 'mvn cn1:certificatewizard' " + + "to sign in once, or pass -Dtoken=."); + } + return t; + } + + private String readOpt(File f) throws MojoExecutionException { + if (f == null || !f.isFile()) { + return null; + } + try { + String s = FileUtils.readFileToString(f, "UTF-8"); + // Strip a single trailing newline (editors add one); keep internal newlines. + if (s.endsWith("\r\n")) { + s = s.substring(0, s.length() - 2); + } else if (s.endsWith("\n")) { + s = s.substring(0, s.length() - 1); + } + return s.isEmpty() ? null : s; + } catch (IOException e) { + throw new MojoExecutionException("Could not read " + f, e); + } + } + + private static void appendField(StringBuilder sb, String field, String value) { + if (sb.length() > 0 && sb.charAt(sb.length() - 1) != '{') { + sb.append(','); + } + sb.append(jsonStr(field)).append(':').append(jsonStr(value)); + } + + private static String jsonStr(String s) { + StringBuilder b = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': b.append("\\\""); break; + case '\\': b.append("\\\\"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: + if (c < 0x20) { + b.append(String.format("\\u%04x", (int) c)); + } else { + b.append(c); + } + } + } + return b.append('"').toString(); + } + + private static String enc(String s) { + try { + return URLEncoder.encode(s, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new RuntimeException(e); // UTF-8 is always present + } + } + + private static boolean isEmpty(String s) { + return s == null || s.trim().isEmpty(); + } + + private static String firstNonEmpty(String... vals) { + for (String v : vals) { + if (!isEmpty(v)) { + return v.trim(); + } + } + return null; + } + + private static final class Shot { + final String locale; + final String displayType; + final int ordinal; + final File file; + + Shot(String locale, String displayType, int ordinal, File file) { + this.locale = locale; + this.displayType = displayType; + this.ordinal = ordinal; + this.file = file; + } + } + + private static final class HttpResult { + final int code; + final String body; + + HttpResult(int code, String body) { + this.code = code; + this.body = body; + } + } +}