From 0b59cb6df276b2a641c5834c97618a1e6c34c734 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:01:15 +0300 Subject: [PATCH 1/5] Developer guide: five more sentences that promise code Three get a compiled listing and two get one that cannot compile here, for the same reason each time -- the class is not on the docs module's classpath. Compiled: the custom Mapper, which the JSON/XML chapter told the reader to hand-write for a type a third-party jar owns and then showed nothing. It implements all six methods, because the interface has six and an example missing writeXml is one a reader cannot follow. And the build-hint pair in Miscellaneous Features, guarded by isSimulator() -- setProjectBuildHint throws anywhere else, which the javadoc says and the chapter did not. Not compiled: the storage password migration, which drives EncryptedStorage from the bouncy castle cn1lib, and the SVG registry call, whose class only exists after a build has run the transcoder. Both are [listing] blocks with a sentence saying why. The migration also had a bug carried over from the original: it read storageFileName and wrote "TestEncryption", so it moved one entry into a different name. The fifth is rewritten instead. "There are two simple methods in the Util class:" was followed by two one-argument signatures; naming xorEncode and xorDecode in the sentence says the same thing without a listing. Ratchet drops from 34 to 29. Co-Authored-By: Claude Opus 5 (1M context) --- ...nnotationJsonXmlMappingJava003Snippet.java | 123 ++++++++++++++++++ .../MiscellaneousFeaturesJava010Snippet.java | 76 +++++++++++ .../Annotation-JSON-XML-Mapping.asciidoc | 5 + .../Miscellaneous-Features.asciidoc | 5 + docs/developer-guide/SVG-Transcoder.asciidoc | 16 ++- docs/developer-guide/security.asciidoc | 16 ++- .../missing-code-blocks-baseline.txt | 5 - 7 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnnotationJsonXmlMappingJava003Snippet.java create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MiscellaneousFeaturesJava010Snippet.java diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnnotationJsonXmlMappingJava003Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnnotationJsonXmlMappingJava003Snippet.java new file mode 100644 index 00000000000..c5f3df1927d --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnnotationJsonXmlMappingJava003Snippet.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import com.codename1.mapping.*; +import com.codename1.xml.*; +import java.util.*; +import com.codename1.annotations.*; +import com.codename1.properties.*; + +class AnnotationJsonXmlMappingJava003Snippet { + + // tag::annotation-json-xml-mapping-java-003[] + // A type from a third-party jar the build cannot annotate. + static class LatLon { + double lat; + double lon; + } + + static class LatLonMapper implements Mapper { + @Override + public Class type() { + return LatLon.class; + } + + @Override + public Map toMap(LatLon instance) { + Map m = new LinkedHashMap(); + m.put("lat", Double.valueOf(instance.lat)); + m.put("lon", Double.valueOf(instance.lon)); + return m; + } + + @Override + public LatLon fromMap(Map map) { + LatLon out = new LatLon(); + out.lat = readDouble(map.get("lat")); + out.lon = readDouble(map.get("lon")); + return out; + } + + @Override + public String xmlRootName() { + return "latLon"; + } + + @Override + public void writeXml(LatLon instance, Element root) { + root.setAttribute("lat", String.valueOf(instance.lat)); + root.setAttribute("lon", String.valueOf(instance.lon)); + } + + @Override + public LatLon readXml(Element root) { + LatLon out = new LatLon(); + out.lat = Double.parseDouble(root.getAttribute("lat")); + out.lon = Double.parseDouble(root.getAttribute("lon")); + return out; + } + + // JSONParser hands back a Double for every number, but a map that came + // from somewhere else may hold any Number. Read through the interface + // rather than casting to Double: a failed cast does not throw on iOS, + // so the catch you would write for it never runs. + private double readDouble(Object value) { + return value instanceof Number ? ((Number) value).doubleValue() : 0; + } + } + + void registerMappers() { + Mappers.register(new LatLonMapper()); + } + // end::annotation-json-xml-mapping-java-003[] +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MiscellaneousFeaturesJava010Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MiscellaneousFeaturesJava010Snippet.java new file mode 100644 index 00000000000..1740f1bb04c --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MiscellaneousFeaturesJava010Snippet.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class MiscellaneousFeaturesJava010Snippet { + + // tag::miscellaneous-features-java-010[] + void ensureLocationUsageDescription() { + // Both calls only work in the simulator, and setProjectBuildHint throws + // anywhere else -- so this belongs behind an isSimulator() check, run + // once during development rather than on every launch. + if (!Display.getInstance().isSimulator()) { + return; + } + + Map hints = Display.getInstance().getProjectBuildHints(); + String description = hints.get("ios.locationUsageDescription"); + if (description == null || description.length() == 0) { + Display.getInstance().setProjectBuildHint("ios.locationUsageDescription", + "Used to show nearby results on the map"); + } + } + // end::miscellaneous-features-java-010[] +} diff --git a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc index a2a49966320..46f5e04af83 100644 --- a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc +++ b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc @@ -119,6 +119,11 @@ persisted across builds. Sometimes a class lives in a third-party JAR the build can't annotate. Hand-write a `Mapper` and register it at startup: +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnnotationJsonXmlMappingJava003Snippet.java[tag=annotation-json-xml-mapping-java-003,indent=0] +---- + Hand-written mappers take precedence over generated ones for the same class. diff --git a/docs/developer-guide/Miscellaneous-Features.asciidoc b/docs/developer-guide/Miscellaneous-Features.asciidoc index df22cfdf57f..b44ce004fad 100644 --- a/docs/developer-guide/Miscellaneous-Features.asciidoc +++ b/docs/developer-guide/Miscellaneous-Features.asciidoc @@ -814,6 +814,11 @@ A good example for a common problem developers face is location code that doesn' To solve this sort of used case you have two APIs in `Display`: +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MiscellaneousFeaturesJava010Snippet.java[tag=miscellaneous-features-java-010,indent=0] +---- + Both of these allow you to detect if a build hint is set and if not (or if it's set incorrectly) set its value... diff --git a/docs/developer-guide/SVG-Transcoder.asciidoc b/docs/developer-guide/SVG-Transcoder.asciidoc index e65b7088333..f189da5a03b 100644 --- a/docs/developer-guide/SVG-Transcoder.asciidoc +++ b/docs/developer-guide/SVG-Transcoder.asciidoc @@ -152,8 +152,22 @@ no SVGs gets no weaving. === Calling the registry yourself If you don't use `theme.css` but still want a transcoded SVG, construct -the generated class directly: +the generated class directly. It lands in +`com.codename1.generated.svg` and is named after the file, so +`src/main/svg/logo.svg` becomes `com.codename1.generated.svg.Logo`. +The class exists only after a build has run the transcoder, which is why +this listing isn't one of the compiled examples: +[listing] +---- +import com.codename1.generated.svg.Logo; + +Logo logo = new Logo(); // the SVG's declared size at DENSITY_MEDIUM +Logo dense = new Logo(DENSITY_HIGH); // the same, read as a higher-density design +Logo sized = new Logo(12f, 12f); // 12mm square, converted per device + +myButton.setIcon(sized); +---- Constructors come in three flavours, matching the three CSS sizing mechanisms above. The two-`float` constructor takes millimeters; that's diff --git a/docs/developer-guide/security.asciidoc b/docs/developer-guide/security.asciidoc index 1ff252d89ab..e27a2d38058 100644 --- a/docs/developer-guide/security.asciidoc +++ b/docs/developer-guide/security.asciidoc @@ -35,8 +35,7 @@ Notice that this is a temporary roadblock as any savvy hacker would compile the NOTE: This isn't encoding or encryption — it's a simple obfuscation of the data. -There are two simple methods in the `Util` class: - +The `Util` class has two static methods for it: `xorEncode(String)` turns a string into the obfuscated form, and `xorDecode(String)` turns it back. They use a simple xor based obfuscation to make a String less readable. For example, if you've code like this: @@ -90,7 +89,18 @@ This works through a new mechanism in storage where you can replace the storage include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/SecurityJava030Snippet.java[tag=security-java-030,indent=0] ---- -You can leverage that knowledge to change the encryption password on the encryption storage using pseudo-code like this: +You can leverage that knowledge to change the encryption password on the encryption storage. This reads every entry through the old key and writes it back through the new one, so it belongs in a migration step rather than in ordinary app code. `EncryptedStorage` comes from the bouncy castle cn1lib, so this listing isn't part of the compiled examples: + +[listing] +---- +EncryptedStorage.install(oldKey); +InputStream is = Storage.getInstance().createInputStream(storageFileName); +byte[] data = Util.readInputStream(is); +EncryptedStorage.install(newKey); +OutputStream o = Storage.getInstance().createOutputStream(storageFileName); +o.write(data); +o.close(); +---- NOTE: It isn't a good idea to replace storage objects when an app is running so this is purely for this special case... diff --git a/scripts/developer-guide/missing-code-blocks-baseline.txt b/scripts/developer-guide/missing-code-blocks-baseline.txt index 371d76282ef..5dbbf872b7b 100644 --- a/scripts/developer-guide/missing-code-blocks-baseline.txt +++ b/scripts/developer-guide/missing-code-blocks-baseline.txt @@ -1,12 +1,9 @@ # Prose that promises a code block where none follows. # A ratchet: entries may be removed as holes are filled, never added. # Regenerate with check-missing-code-blocks.py --write-baseline. -Annotation-JSON-XML-Mapping.asciidoc Hand-write a `Mapper` and register it at startup: Authentication-And-Identity.asciidoc Firebase Auth isn't an OIDC provider -- it issues Google-Identity-Toolkit-style tokens via REST endpoints. `com.codename1.social.FirebaseAuth` wraps those endpoints: Maven-Creating-CN1Libs.adoc Now try it out. Try adding the following code to your application project's main class (or anywhere in the application project, for that matter): Maven-Creating-CN1Libs.adoc The simulator dispatches every action on the Codename One EDT through `Display.callSerially`, so your method can call `Display.getInstance()`, `Form.show()`, `Dialog.show()`, `ToastBar.showInfoMessage()` and any other CN1 API. Reflection uses the same classloader that loaded `Display`, so cn1lib internals (including package-private classes) resolve normally: -Miscellaneous-Features.asciidoc To solve this sort of used case you have two APIs in `Display`: -SVG-Transcoder.asciidoc the generated class directly: The-Components-Of-Codename-One.asciidoc Call the builder from a Maven plugin, an Ant task or a one-shot `main`: The-Components-Of-Codename-One.asciidoc This code should output "The result was 7" to the console. It's fully asynchronous, so you can include this code anywhere without worrying about it "bogging down" your code. The full signature of this form of the https://www.codenameone.com/javadoc/com/codename1/ui/BrowserComponent.html#execute(java.lang.String,com.codename1.util.SuccessCallback)[execute()] method is: io.asciidoc In the above code you do the following: @@ -16,5 +13,3 @@ io.asciidoc There are many methods of interest to keep an eye for: io.asciidoc database by name: performance.asciidoc In the new Contacts demo you have a share button for each contact, the code for constructing a `ShareButton` looks like this: performance.asciidoc These icons are in a shared resource file that you load and don't cache. The initial workaround was to cache this resource but a better solution was to convert this code: -security.asciidoc There are two simple methods in the `Util` class: -security.asciidoc You can leverage that knowledge to change the encryption password on the encryption storage using pseudo-code like this: From 70475a8b8f90430a637ddad83f6f051928db7aa9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:08:27 +0300 Subject: [PATCH 2/5] Developer guide: the key migration has to move every entry The listing decrypted one entry and re-encrypted it, and the key is installed for the whole of Storage -- so every other entry stayed under the old key and became unreadable the moment the new one went in. It was the shape the pre-extraction original had, and it would have cost somebody their data. It now reads them all, swaps the key, then writes them all back, and says what that costs: everything is held in memory between the two loops, which suits a handful of records and not a storage full of cached images. The SVG listing also named DENSITY_HIGH bare. The constant is on CN1Constants and inherited by Display, so neither the class import nor a wildcard brings it in -- Display.DENSITY_HIGH, with the import beside it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/developer-guide/SVG-Transcoder.asciidoc | 7 +++--- docs/developer-guide/security.asciidoc | 24 +++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/developer-guide/SVG-Transcoder.asciidoc b/docs/developer-guide/SVG-Transcoder.asciidoc index f189da5a03b..b9baa27fae3 100644 --- a/docs/developer-guide/SVG-Transcoder.asciidoc +++ b/docs/developer-guide/SVG-Transcoder.asciidoc @@ -161,10 +161,11 @@ this listing isn't one of the compiled examples: [listing] ---- import com.codename1.generated.svg.Logo; +import com.codename1.ui.Display; -Logo logo = new Logo(); // the SVG's declared size at DENSITY_MEDIUM -Logo dense = new Logo(DENSITY_HIGH); // the same, read as a higher-density design -Logo sized = new Logo(12f, 12f); // 12mm square, converted per device +Logo logo = new Logo(); // declared size at DENSITY_MEDIUM +Logo dense = new Logo(Display.DENSITY_HIGH); // read as a higher-density design +Logo sized = new Logo(12f, 12f); // 12mm square, converted per device myButton.setIcon(sized); ---- diff --git a/docs/developer-guide/security.asciidoc b/docs/developer-guide/security.asciidoc index e27a2d38058..61b10e54461 100644 --- a/docs/developer-guide/security.asciidoc +++ b/docs/developer-guide/security.asciidoc @@ -89,19 +89,31 @@ This works through a new mechanism in storage where you can replace the storage include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/SecurityJava030Snippet.java[tag=security-java-030,indent=0] ---- -You can leverage that knowledge to change the encryption password on the encryption storage. This reads every entry through the old key and writes it back through the new one, so it belongs in a migration step rather than in ordinary app code. `EncryptedStorage` comes from the bouncy castle cn1lib, so this listing isn't part of the compiled examples: +You can leverage that knowledge to change the encryption password on the encryption storage. Everything has to move at once: the key is installed for the whole of `Storage`, so any entry still written under the old one becomes unreadable the moment the new one goes in. Read them all first, swap the key, then write them all back. `EncryptedStorage` comes from the bouncy castle cn1lib, so this listing isn't part of the compiled examples: [listing] ---- EncryptedStorage.install(oldKey); -InputStream is = Storage.getInstance().createInputStream(storageFileName); -byte[] data = Util.readInputStream(is); +Map all = new LinkedHashMap<>(); +for (String name : Storage.getInstance().listEntries()) { + InputStream is = Storage.getInstance().createInputStream(name); + all.put(name, Util.readInputStream(is)); + Util.cleanup(is); +} + EncryptedStorage.install(newKey); -OutputStream o = Storage.getInstance().createOutputStream(storageFileName); -o.write(data); -o.close(); +for (Map.Entry e : all.entrySet()) { + OutputStream o = Storage.getInstance().createOutputStream(e.getKey()); + o.write(e.getValue()); + Util.cleanup(o); +} ---- +That holds every entry in memory between the two loops, which is fine for the +handful of records an app of this shape keeps and isn't fine for a storage +full of cached images. Run it while nothing else is touching `Storage`, and do +it once at startup rather than in response to anything a user can repeat. + NOTE: It isn't a good idea to replace storage objects when an app is running so this is purely for this special case... From 5d5f8cec5eb539082eeba4920c628c8b5a6964d2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:13:17 +0300 Subject: [PATCH 3/5] Developer guide: let the migration's close failures reach the caller Util.cleanup swallows a close failure by design, and on the write side of a key migration that is the one place it must not: a swallowed close means an entry was never finalized under the new key, and the loop carries on and reports success having lost it. Both loops use try-with-resources now, with the reason beside them. And the build-hint listing dereferenced getProjectBuildHints() straight away. JavaSEPort returns null from it when codenameone_settings.properties is missing or unreadable -- isSimulator() being true is not enough -- so the example crashed in the one environment it was written for. Co-Authored-By: Claude Opus 5 (1M context) --- .../MiscellaneousFeaturesJava010Snippet.java | 7 +++++++ docs/developer-guide/security.asciidoc | 18 ++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MiscellaneousFeaturesJava010Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MiscellaneousFeaturesJava010Snippet.java index 1740f1bb04c..d0a152df40e 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MiscellaneousFeaturesJava010Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/MiscellaneousFeaturesJava010Snippet.java @@ -66,6 +66,13 @@ void ensureLocationUsageDescription() { } Map hints = Display.getInstance().getProjectBuildHints(); + if (hints == null) { + // No codename1_settings.properties beside the running project, or + // it could not be read. Nothing to check against, and nothing to + // write into. + return; + } + String description = hints.get("ios.locationUsageDescription"); if (description == null || description.length() == 0) { Display.getInstance().setProjectBuildHint("ios.locationUsageDescription", diff --git a/docs/developer-guide/security.asciidoc b/docs/developer-guide/security.asciidoc index 61b10e54461..dd60c8d5b4a 100644 --- a/docs/developer-guide/security.asciidoc +++ b/docs/developer-guide/security.asciidoc @@ -96,16 +96,16 @@ You can leverage that knowledge to change the encryption password on the encrypt EncryptedStorage.install(oldKey); Map all = new LinkedHashMap<>(); for (String name : Storage.getInstance().listEntries()) { - InputStream is = Storage.getInstance().createInputStream(name); - all.put(name, Util.readInputStream(is)); - Util.cleanup(is); + try (InputStream is = Storage.getInstance().createInputStream(name)) { + all.put(name, Util.readInputStream(is)); + } } EncryptedStorage.install(newKey); for (Map.Entry e : all.entrySet()) { - OutputStream o = Storage.getInstance().createOutputStream(e.getKey()); - o.write(e.getValue()); - Util.cleanup(o); + try (OutputStream o = Storage.getInstance().createOutputStream(e.getKey())) { + o.write(e.getValue()); + } } ---- @@ -114,6 +114,12 @@ handful of records an app of this shape keeps and isn't fine for a storage full of cached images. Run it while nothing else is touching `Storage`, and do it once at startup rather than in response to anything a user can repeat. +Note the `try`-with-resources rather than `Util.cleanup`: `cleanup` swallows a +close failure, and on the write side a swallowed close means an entry was +never finalized under the new key. Let it throw, and let the caller decide +whether to retry or restore -- a migration that reports success having lost an +entry is worse than one that stops. + NOTE: It isn't a good idea to replace storage objects when an app is running so this is purely for this special case... From 917a87753f0f1d1fdf54aca4249c66e8b3c5f7d4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:19:07 +0300 Subject: [PATCH 4/5] Developer guide: an interrupted key migration must not strand the store The two-loop version overwrote entries in place under the new key. Killed part way through -- or failing on one write -- it left some entries under each key, and the plaintext it needed to recover went with the process. Neither key then reads the whole store. It is now staged. Phase one reads everything under the old key; phase two writes every entry to a ".migrating" copy under the new one, leaving the originals untouched and still readable with the old key, so a crash anywhere in there costs nothing. A marker entry listing the names is the commit point, and finishMigration -- which also runs at startup -- replaces the originals from the staged copies and removes them. Every step of that is idempotent, so a crash during recovery only means recovery runs again, and it needs the new key alone. Storage has no rename, so this is as close to atomic as the API allows. The costs are stated where the listing ends: everything is in memory between the two phases, and Storage has to be quiet throughout. Co-Authored-By: Claude Opus 5 (1M context) --- docs/developer-guide/security.asciidoc | 73 ++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/docs/developer-guide/security.asciidoc b/docs/developer-guide/security.asciidoc index dd60c8d5b4a..60f9cde1dee 100644 --- a/docs/developer-guide/security.asciidoc +++ b/docs/developer-guide/security.asciidoc @@ -89,36 +89,91 @@ This works through a new mechanism in storage where you can replace the storage include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/SecurityJava030Snippet.java[tag=security-java-030,indent=0] ---- -You can leverage that knowledge to change the encryption password on the encryption storage. Everything has to move at once: the key is installed for the whole of `Storage`, so any entry still written under the old one becomes unreadable the moment the new one goes in. Read them all first, swap the key, then write them all back. `EncryptedStorage` comes from the bouncy castle cn1lib, so this listing isn't part of the compiled examples: +You can leverage that knowledge to change the encryption password on the encryption storage. Everything has to move at once: the key is installed for the whole of `Storage`, so any entry still written under the old one becomes unreadable the moment the new one goes in. And the move has to survive being interrupted, because a half-converted store can't be read with either key, so it goes in three steps -- read everything under the old key, write it all to staging names under the new one, then replace the originals from staging. `EncryptedStorage` comes from the bouncy castle cn1lib, so this listing isn't part of the compiled examples: [listing] ---- +static final String MIGRATION_MARKER = "cn1KeyMigration.dat"; +static final String STAGED_SUFFIX = ".migrating"; + +// Phase 1 -- read everything under the old key, into memory. EncryptedStorage.install(oldKey); Map all = new LinkedHashMap<>(); for (String name : Storage.getInstance().listEntries()) { + if (name.equals(MIGRATION_MARKER) || name.endsWith(STAGED_SUFFIX)) { + continue; + } try (InputStream is = Storage.getInstance().createInputStream(name)) { all.put(name, Util.readInputStream(is)); } } +// Phase 2 -- with the new key in, write every entry to a staging name. +// The originals are untouched and still readable with the old key, so a +// crash anywhere in here loses nothing. EncryptedStorage.install(newKey); for (Map.Entry e : all.entrySet()) { - try (OutputStream o = Storage.getInstance().createOutputStream(e.getKey())) { + try (OutputStream o = + Storage.getInstance().createOutputStream(e.getKey() + STAGED_SUFFIX)) { o.write(e.getValue()); } } + +// The commit point. Past here the staged copies are the source of truth, +// and finishMigration below can complete the job with the new key alone. +Storage.getInstance().writeObject(MIGRATION_MARKER, + new ArrayList<>(all.keySet())); +finishMigration(); +---- + +`finishMigration` is the half that also runs at startup, so an interrupted +migration completes rather than leaving the store half-converted: + +[listing] ---- +void finishMigration() { + if (!Storage.getInstance().exists(MIGRATION_MARKER)) { + return; + } + Object stored = Storage.getInstance().readObject(MIGRATION_MARKER); + if (!(stored instanceof List)) { + Storage.getInstance().deleteStorageFile(MIGRATION_MARKER); + return; + } + for (Object entry : (List) stored) { + String name = (String) entry; + String staged = name + STAGED_SUFFIX; + if (!Storage.getInstance().exists(staged)) { + continue; // already replaced on an earlier attempt + } + try (InputStream is = Storage.getInstance().createInputStream(staged)) { + byte[] data = Util.readInputStream(is); + try (OutputStream o = Storage.getInstance().createOutputStream(name)) { + o.write(data); + } + } + Storage.getInstance().deleteStorageFile(staged); + } + Storage.getInstance().deleteStorageFile(MIGRATION_MARKER); +} +---- + +Call it with the new key installed, before anything else reads `Storage`. +Either the marker is absent and there is nothing to do, or it names the +entries whose new-key copies are already on disk and the loop finishes +replacing them. Each entry is idempotent, so a crash during recovery only +means recovery runs again. -That holds every entry in memory between the two loops, which is fine for the -handful of records an app of this shape keeps and isn't fine for a storage -full of cached images. Run it while nothing else is touching `Storage`, and do -it once at startup rather than in response to anything a user can repeat. +Two things this costs. It holds every entry in memory between phases one and +two, which suits the handful of records an app of this shape keeps and does +not suit a storage full of cached images. And it needs `Storage` quiet +throughout, so it belongs at startup rather than anywhere a user can trigger +it twice. Note the `try`-with-resources rather than `Util.cleanup`: `cleanup` swallows a close failure, and on the write side a swallowed close means an entry was -never finalized under the new key. Let it throw, and let the caller decide -whether to retry or restore -- a migration that reports success having lost an -entry is worse than one that stops. +never finalized under the new key. Let it throw -- a migration that reports +success having lost an entry is worse than one that stops. NOTE: It isn't a good idea to replace storage objects when an app is running so this is purely for this special case... From 86969397f31223304cd4ed4bc3cb663192ea6adf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:24:13 +0300 Subject: [PATCH 5/5] Developer guide: stop trying to make the key migration crash-safe in a listing The staging protocol added last round answered one failure mode and opened two more: the marker that makes recovery possible is itself written after the vulnerable phase and under the new key, so a crash before it lands leaves a store nothing can recover, and the staging names live in the same namespace as the app's own entries. Both are real, and chasing them is the wrong direction. Storage has no rename, so the converted copies cannot be swapped in atomically no matter how the loop is arranged, and every further step makes a documentation listing longer without making it correct. Designing a crash-safe key rotation protocol inside this chapter was scope I should not have taken. So the listing goes back to the plain two-phase form, and the guide says what it does not guarantee: if the process dies part way through, some entries are under each key and neither opens the whole store. And it says what to do instead, which is the answer that actually removes the problem: encrypt Storage with a random key the app generates once, keep that key wrapped under the password-derived one in a single record outside Storage, and a password change re-wraps that one record. Nothing else moves, so there is no half-converted state to recover from. Co-Authored-By: Claude Opus 5 (1M context) --- docs/developer-guide/security.asciidoc | 71 ++------------------------ 1 file changed, 5 insertions(+), 66 deletions(-) diff --git a/docs/developer-guide/security.asciidoc b/docs/developer-guide/security.asciidoc index 60f9cde1dee..53efd1e99c0 100644 --- a/docs/developer-guide/security.asciidoc +++ b/docs/developer-guide/security.asciidoc @@ -89,92 +89,31 @@ This works through a new mechanism in storage where you can replace the storage include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/SecurityJava030Snippet.java[tag=security-java-030,indent=0] ---- -You can leverage that knowledge to change the encryption password on the encryption storage. Everything has to move at once: the key is installed for the whole of `Storage`, so any entry still written under the old one becomes unreadable the moment the new one goes in. And the move has to survive being interrupted, because a half-converted store can't be read with either key, so it goes in three steps -- read everything under the old key, write it all to staging names under the new one, then replace the originals from staging. `EncryptedStorage` comes from the bouncy castle cn1lib, so this listing isn't part of the compiled examples: +You can leverage that knowledge to change the encryption password on the encryption storage. The key is installed for the whole of `Storage`, so every entry has to move together: read them all under the old key, install the new one, write them all back. `EncryptedStorage` comes from the bouncy castle cn1lib, so this listing isn't part of the compiled examples: [listing] ---- -static final String MIGRATION_MARKER = "cn1KeyMigration.dat"; -static final String STAGED_SUFFIX = ".migrating"; - -// Phase 1 -- read everything under the old key, into memory. EncryptedStorage.install(oldKey); Map all = new LinkedHashMap<>(); for (String name : Storage.getInstance().listEntries()) { - if (name.equals(MIGRATION_MARKER) || name.endsWith(STAGED_SUFFIX)) { - continue; - } try (InputStream is = Storage.getInstance().createInputStream(name)) { all.put(name, Util.readInputStream(is)); } } -// Phase 2 -- with the new key in, write every entry to a staging name. -// The originals are untouched and still readable with the old key, so a -// crash anywhere in here loses nothing. EncryptedStorage.install(newKey); for (Map.Entry e : all.entrySet()) { - try (OutputStream o = - Storage.getInstance().createOutputStream(e.getKey() + STAGED_SUFFIX)) { + try (OutputStream o = Storage.getInstance().createOutputStream(e.getKey())) { o.write(e.getValue()); } } - -// The commit point. Past here the staged copies are the source of truth, -// and finishMigration below can complete the job with the new key alone. -Storage.getInstance().writeObject(MIGRATION_MARKER, - new ArrayList<>(all.keySet())); -finishMigration(); ----- - -`finishMigration` is the half that also runs at startup, so an interrupted -migration completes rather than leaving the store half-converted: - -[listing] ---- -void finishMigration() { - if (!Storage.getInstance().exists(MIGRATION_MARKER)) { - return; - } - Object stored = Storage.getInstance().readObject(MIGRATION_MARKER); - if (!(stored instanceof List)) { - Storage.getInstance().deleteStorageFile(MIGRATION_MARKER); - return; - } - for (Object entry : (List) stored) { - String name = (String) entry; - String staged = name + STAGED_SUFFIX; - if (!Storage.getInstance().exists(staged)) { - continue; // already replaced on an earlier attempt - } - try (InputStream is = Storage.getInstance().createInputStream(staged)) { - byte[] data = Util.readInputStream(is); - try (OutputStream o = Storage.getInstance().createOutputStream(name)) { - o.write(data); - } - } - Storage.getInstance().deleteStorageFile(staged); - } - Storage.getInstance().deleteStorageFile(MIGRATION_MARKER); -} ----- - -Call it with the new key installed, before anything else reads `Storage`. -Either the marker is absent and there is nothing to do, or it names the -entries whose new-key copies are already on disk and the loop finishes -replacing them. Each entry is idempotent, so a crash during recovery only -means recovery runs again. -Two things this costs. It holds every entry in memory between phases one and -two, which suits the handful of records an app of this shape keeps and does -not suit a storage full of cached images. And it needs `Storage` quiet -throughout, so it belongs at startup rather than anywhere a user can trigger -it twice. +The `try`-with-resources is deliberate: `Util.cleanup` swallows a close failure, and on the write side a swallowed close means an entry was never finalized under the new key. Let it throw. -Note the `try`-with-resources rather than `Util.cleanup`: `cleanup` swallows a -close failure, and on the write side a swallowed close means an entry was -never finalized under the new key. Let it throw -- a migration that reports -success having lost an entry is worse than one that stops. +WARNING: This isn't crash-safe, and it can't be made crash-safe from the outside. If the process dies part way through the second loop, some entries are under each key and neither opens the whole store. `Storage` has no rename, so there is no way to stage the converted copies and swap them in atomically, and the recovery data lives only in that in-memory map. +Which is the real argument for not rotating the storage key at all. Encrypt `Storage` with a random key your app generates once, keep that key wrapped under the password-derived one in a single record outside `Storage`, and a password change re-wraps that one small record instead of rewriting every entry. Nothing else moves, so there is no half-converted state to recover from. If you do rewrite the store in place, do it once at startup with nothing else touching `Storage`, and treat the possibility of an interrupted run as something your app has to survive rather than something this loop prevents. NOTE: It isn't a good idea to replace storage objects when an app is running so this is purely for this special case...