package link.roadio.riff.huprovision; import android.content.ContentResolver; import android.database.Cursor; import android.net.Uri; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import org.json.JSONObject; /** Provider-issued locators only; configuration parsing belongs to the target's JVM registry. */ class ConfigurationDocuments { static final String NAME = "ui_config.json"; private static final int MAX_BYTES = 4 * 1024 * 1024; final ContentResolver resolver; ConfigurationDocuments(ContentResolver resolver) { this.resolver = resolver; } Uri child(Uri tree, Uri parent, String name, boolean directory) throws Exception { Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, DocumentsContract.getDocumentId(parent)); try (Cursor cursor = resolver.query(children, new String[]{Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE}, null, null, null)) { if (cursor == null || cursor.getExtras().getBoolean(DocumentsContract.EXTRA_LOADING, false) || cursor.getExtras().containsKey(DocumentsContract.EXTRA_ERROR)) { throw new IllegalStateException("Source listing is unavailable: " + parent); } Uri found = null; while (cursor.moveToNext()) { if (name.equals(cursor.getString(1)) && directory == Document.MIME_TYPE_DIR.equals(cursor.getString(2))) { if (found != null) throw new IllegalStateException("Ambiguous document: " + name); found = DocumentsContract.buildDocumentUriUsingTree(tree, cursor.getString(0)); } } return found; } } Uri directory(Uri tree) throws Exception { return child(tree, DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)), ".riff", true); } String read(Uri uri) throws Exception { try (InputStream input = resolver.openInputStream(uri)) { if (input == null) throw new IllegalStateException("Cannot read " + uri); ByteArrayOutputStream bytes = new ByteArrayOutputStream(); byte[] block = new byte[8192]; for (int count; (count = input.read(block)) != -1;) { if (bytes.size() > MAX_BYTES - count) throw new IllegalStateException("Configuration exceeds 4 MiB"); bytes.write(block, 0, count); } return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString(); } } void write(Uri uri, String text) throws Exception { try (OutputStream output = resolver.openOutputStream(uri, "rwt")) { if (output == null) throw new IllegalStateException("Cannot write " + uri); output.write(text.getBytes(StandardCharsets.UTF_8)); } if (!text.equals(read(uri))) throw new IllegalStateException("Provider read-back differs"); } static Object parseAll(ClassLoader loader, List texts) throws Exception { Class type = loader.loadClass("link.roadio.riff.config.ProjectConfiguration"); return type.getMethod("parseAll", Iterable.class).invoke(type.getField("INSTANCE").get(null), texts); } static Object value(Object snapshot, String path, boolean wide, boolean night) throws Exception { return snapshot.getClass().getMethod("value", String.class, boolean.class, boolean.class) .invoke(snapshot, path, wide, night); } static void requireSettings(Object snapshot) throws Exception { for (boolean wide : new boolean[]{false, true}) for (boolean night : new boolean[]{false, true}) { if (!Boolean.TRUE.equals(value(snapshot, "behavior.systemBrightnessWorkaroundEnabled", wide, night)) || ((Number) value(snapshot, "behavior.dimBacklightPercent", wide, night)).doubleValue() != (night ? 1.0 : 15.0)) { throw new IllegalStateException("Active skin overrides the requested HU settings; edit that skin explicitly"); } } } static JSONObject child(JSONObject parent, String key) throws Exception { if (!parent.has(key)) parent.put(key, new JSONObject()); return parent.getJSONObject(key); } JSONObject provision(ClassLoader loader, List trees, Uri selected, boolean dryRun) throws Exception { Uri folder = directory(selected); if (folder == null) throw new IllegalStateException("Selected source has no .riff directory; let Riff admit it first"); Uri target = child(selected, folder, NAME, false); String original = target == null ? null : read(target); List before = new ArrayList<>(); List others = new ArrayList<>(); for (Uri tree : trees) { Uri dir = directory(tree); if (dir == null) continue; Uri file = child(tree, dir, NAME, false); if (file == null) continue; String text = read(file); before.add(text); if (!tree.equals(selected)) { others.add(text); JSONObject skins = new JSONObject(text).optJSONObject("skins"); if (skins != null) for (java.util.Iterator names = skins.keys(); names.hasNext();) { String name = names.next(); JSONObject skin = skins.optJSONObject(name); JSONObject behavior = skin == null ? null : skin.optJSONObject("behavior"); if (behavior != null && (behavior.has("systemBrightnessWorkaroundEnabled") || behavior.has("dimBacklightPercent"))) { throw new IllegalStateException("Another source defines HU brightness settings; consolidate them before provisioning"); } } } } parseAll(loader, before); JSONObject user = new JSONObject(original == null ? "{}" : original); JSONObject behavior = child(child(child(user, "skins"), "default"), "behavior"); behavior.put("systemBrightnessWorkaroundEnabled", true); behavior.put("dimBacklightPercent", new JSONObject().put("day", 15).put("night", 1)); String replacement = user.toString(2) + "\n"; if (replacement.getBytes(StandardCharsets.UTF_8).length > MAX_BYTES) throw new IllegalStateException("Updated configuration exceeds 4 MiB"); others.add(replacement); requireSettings(parseAll(loader, others)); JSONObject report = new JSONObject().put("tree_uri", selected).put("original", original == null ? JSONObject.NULL : original) .put("replacement", replacement).put("dry_run", dryRun).put("readable_trees", trees.size()); if (dryRun) return report; // SAF does not promise atomic replacement. Keep the original for rollback, check concurrent // edits before truncation and verify read-back. Never claim power-cut atomicity for this tool. // AI-INTENT: https://wiki.271.pics/Riff/1-1-0/configuration#location-and-admission if (target != null && !original.equals(read(target))) throw new IllegalStateException("Configuration changed during provisioning"); if (target == null) { if (child(selected, folder, NAME, false) != null) throw new IllegalStateException("Configuration appeared during provisioning"); target = DocumentsContract.createDocument(resolver, folder, "application/json", NAME); if (target == null) throw new IllegalStateException("Provider did not create a document"); if (!target.equals(child(selected, folder, NAME, false))) { boolean removed = DocumentsContract.deleteDocument(resolver, target); throw new IllegalStateException("Provider changed the requested name; temporary document removed: " + removed); } } try { write(target, replacement); } catch (Exception failure) { try { if (original == null) { if (!DocumentsContract.deleteDocument(resolver, target)) throw new IllegalStateException("Rollback deletion failed"); } else write(target, original); } catch (Exception rollback) { failure.addSuppressed(rollback); } throw failure; } return report.put("configuration_uri", target); } }