diff --git a/tools/android/provider-lab/README.md b/tools/android/provider-lab/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..ad41eca538ecd18d99c65c29289a43f649261c7c
--- /dev/null
+++ b/tools/android/provider-lab/README.md
@@ -0,0 +1,56 @@
+# Android document-provider capability lab
+
+Two **test-only** APKs: a disposable `DocumentsProvider` and a separate-UID
+instrumentation client. No product app, notebook backend, JNI, Gradle, or product UI.
+Install only on an owned emulator; the exported controller intentionally grants,
+revokes, and changes synthetic fixtures without authentication.
+
+Build with an installed SDK and JDK (no downloads or global changes):
+
+```sh
+nice -n 10 python3 tools/android/provider-lab/build.py \
+ --sdk /path/to/sdk --java-home /path/to/jdk \
+ --output /absolute/private/build
+python3 tools/android/provider-lab/run.py \
+ --adb /path/to/sdk/platform-tools/adb --serial emulator-5556 \
+ --build /absolute/private/build --output /absolute/private/run
+```
+
+Defaults are SDK/build-tools 36, with API 26 as the lab APK minimum. This does
+not decide Snowbound's minimum Android version. Output directories must be new.
+The runner refuses physical devices, installs the two packages, runs bounded
+instrumentation, force-stops the client between phases, then uninstalls both.
+The caller owns emulator creation, launch, and cleanup. Evidence stays private.
+
+A test-only provider Activity returns an Intent with prefix and persistable URI
+grant flags; the client Activity persists only its actual returned read/write
+flags. This substitutes for a picker result. This does **not** test
+`ACTION_OPEN_DOCUMENT_TREE` UI. All data is synthetic bytes, not `.one` files.
+
+| Experiment | Assertion |
+| --- | --- |
+| Separate UID / tree grant | Ungranted read refused; read-only grant cannot widen to write; read/write grant persisted; available in a fresh client process; provider revocation refuses further reads and removes persisted grant. |
+| Opaque IDs / metadata | Slash and non-ASCII identities survive contract URI construction; null size and timestamp remain unknown. |
+| Local `rw` descriptor | Actual seek, partial overwrite, fsync, close and reopen retain the expected bytes. |
+| Stream `r` / `w` | Actual pipes reject seek/fsync; `rw` is rejected; bytes round-trip after a bounded provider completion event. |
+| Reliable peer failure | Provider `closeWithError` reaches client `checkError`. |
+| Rename | Returned document identity changes; old URI fails; fresh listing contains the returned identity and bytes remain intact. |
+| Delayed visibility | Writer's actual `rw` write/fsync/close/reopen succeed while the observer still reads the old bytes. Only an explicit provider publication event advances the observer. |
+| Availability | Injected offline open fails while the persisted grant remains; restoring availability restores the bytes. |
+
+The fixture assigns duplicate display names; the probe does not compare their
+display-name columns.
+
+Provider events are explicit barriers, not timing guesses. Pipe workers have a
+bounded completion wait; every external build/runtime command has a timeout.
+Reports contain actual operations, exceptions, fixture byte hashes, emulator
+fingerprint, process IDs, and APK/source hashes. A successful local fsync or
+close is **not a remote upload receipt**. This synthetic provider does not prove
+commercial-provider behavior, remote durability, file locking, atomicity, CAS,
+cloud-conflict recovery, or `.snowbound/` protocol correctness.
+
+Android contracts:
+[ContentResolver modes](https://developer.android.com/reference/android/content/ContentResolver#openFileDescriptor(android.net.Uri,%20java.lang.String)),
+[DocumentsProvider](https://developer.android.com/reference/android/provider/DocumentsProvider),
+[rename identity](https://developer.android.com/reference/android/provider/DocumentsContract#renameDocument(android.content.ContentResolver,%20android.net.Uri,%20java.lang.String)),
+[descriptor errors](https://developer.android.com/reference/android/os/ParcelFileDescriptor#checkError()).
diff --git a/tools/android/provider-lab/build.py b/tools/android/provider-lab/build.py
new file mode 100644
index 0000000000000000000000000000000000000000..2270f93f725a5683b9ef7e134edaad1329caa824
--- /dev/null
+++ b/tools/android/provider-lab/build.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Build two test-only APKs with an explicitly installed SDK/JDK, without Gradle."""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import subprocess
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--sdk", type=Path, required=True)
+ parser.add_argument("--java-home", type=Path, required=True)
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--platform", default="android-36")
+ parser.add_argument("--build-tools", default="36.0.0")
+ args = parser.parse_args()
+ source = Path(__file__).resolve().parent
+ output = args.output.resolve()
+ output.mkdir(parents=True, exist_ok=False)
+ java = args.java_home.resolve() / "bin"
+ tools = args.sdk.resolve() / "build-tools" / args.build_tools
+ android = args.sdk.resolve() / "platforms" / args.platform / "android.jar"
+ steps = []
+
+ def run(command):
+ command = [str(item) for item in command]
+ result = subprocess.run(command, capture_output=True, text=True, timeout=120)
+ steps.append({"command": command, "exitCode": result.returncode,
+ "stdout": result.stdout, "stderr": result.stderr})
+ (output / "build.json").write_text(json.dumps(steps, indent=2) + "\n")
+ result.check_returncode()
+
+ keystore = output / "test-only.jks"
+ run([java / "keytool", "-genkeypair", "-keystore", keystore, "-storepass", "android",
+ "-keypass", "android", "-alias", "lab", "-keyalg", "RSA", "-validity", "30",
+ "-dname", "CN=Disposable Snowbound Provider Lab"])
+ for name in ("provider", "client"):
+ classes = output / name / "classes"
+ classes.mkdir(parents=True)
+ run([java / "javac", "--release", "8", "-classpath", android,
+ "-d", classes, *sorted((source / name).glob("*.java"))])
+ dex = output / name / "dex"
+ dex.mkdir()
+ run([java / "java", "-Xmx512m", "-cp", tools / "lib" / "d8.jar", "com.android.tools.r8.D8",
+ "--min-api", "26", "--lib", android, "--output", dex, *sorted(classes.rglob("*.class"))])
+ unsigned = output / name / "unsigned.apk"
+ run([tools / "aapt2", "link", "-I", android, "--manifest", source / name / "AndroidManifest.xml",
+ "-o", unsigned])
+ import zipfile
+ with zipfile.ZipFile(unsigned, "a") as archive:
+ archive.write(dex / "classes.dex", "classes.dex")
+ aligned = output / name / "aligned.apk"
+ run([tools / "zipalign", "-p", "4", unsigned, aligned])
+ apk = output / (name + ".apk")
+ run([java / "java", "-Xmx256m", "-jar", tools / "lib" / "apksigner.jar", "sign",
+ "--ks", keystore, "--ks-pass", "pass:android", "--out", apk, aligned])
+ run([java / "java", "-jar", tools / "lib" / "apksigner.jar", "verify", "--verbose", apk])
+ manifest = {
+ "sourceSha256": {str(p.relative_to(source)): hashlib.sha256(p.read_bytes()).hexdigest()
+ for p in sorted(source.rglob("*")) if p.is_file() and "__pycache__" not in p.parts},
+ "apkSha256": {name: hashlib.sha256((output / name).read_bytes()).hexdigest()
+ for name in ("provider.apk", "client.apk")},
+ "runtimeTested": False,
+ }
+ (output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
+ print(json.dumps(manifest, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/android/provider-lab/client/AndroidManifest.xml b/tools/android/provider-lab/client/AndroidManifest.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4212f3ee7d1c0b81f458376ee01e6b38bb918ac7
--- /dev/null
+++ b/tools/android/provider-lab/client/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/tools/android/provider-lab/client/Grant.java b/tools/android/provider-lab/client/Grant.java
new file mode 100644
index 0000000000000000000000000000000000000000..02dccad2f8bfc3e5144a47c1deaa311a9282476c
--- /dev/null
+++ b/tools/android/provider-lab/client/Grant.java
@@ -0,0 +1,39 @@
+package net.paperclover.snowbound.providerprobe;
+
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.os.Bundle;
+import java.util.concurrent.CountDownLatch;
+
+public final class Grant extends Activity {
+ static volatile Bundle reply;
+ static CountDownLatch done;
+
+ @Override public void onCreate(Bundle state) {
+ super.onCreate(state);
+ startActivityForResult(new Intent().setComponent(new ComponentName(
+ "net.paperclover.snowbound.providerlab", "net.paperclover.snowbound.providerlab.Pick"))
+ .putExtra("write", getIntent().getBooleanExtra("write", false)), 1);
+ }
+
+ @Override protected void onActivityResult(int request, int result, Intent data) {
+ super.onActivityResult(request, result, data);
+ reply = new Bundle();
+ try {
+ if (request != 1 || result != RESULT_OK || data == null) throw new IllegalStateException("Grant result missing");
+ int flags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ getContentResolver().takePersistableUriPermission(data.getData(), flags);
+ reply.putBoolean("ok", true);
+ reply.putInt("receivedFlags", data.getFlags());
+ reply.putInt("persistedFlags", flags);
+ reply.putString("uri", data.getData().toString());
+ reply.putInt("providerUid", data.getIntExtra("providerUid", -1));
+ reply.putInt("providerPid", data.getIntExtra("providerPid", -1));
+ } catch (Exception e) {
+ reply.putString("error", e.toString());
+ }
+ done.countDown();
+ finish();
+ }
+}
diff --git a/tools/android/provider-lab/client/Probe.java b/tools/android/provider-lab/client/Probe.java
new file mode 100644
index 0000000000000000000000000000000000000000..fab3e27b0acbefdaf13d695350f47615c65862d4
--- /dev/null
+++ b/tools/android/provider-lab/client/Probe.java
@@ -0,0 +1,269 @@
+package net.paperclover.snowbound.providerprobe;
+
+import android.app.Instrumentation;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.Document;
+import android.system.Os;
+import android.system.OsConstants;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+public final class Probe extends Instrumentation {
+ private static final String AUTHORITY = "net.paperclover.snowbound.providerlab.documents";
+ private static final Uri TREE = DocumentsContract.buildTreeDocumentUri(AUTHORITY, "root:opaque/α");
+ private final JSONArray events = new JSONArray();
+ private ContentResolver resolver;
+ private String phase;
+
+ @Override public void onCreate(Bundle args) {
+ super.onCreate(args);
+ phase = args.getString("phase", "probe");
+ start();
+ }
+
+ private void require(boolean value, String message) {
+ if (!value) throw new AssertionError(message);
+ }
+
+ private void record(String operation, Object value) throws Exception {
+ events.put(new JSONObject().put("operation", operation).put("result", value));
+ }
+
+ private void control(String operation) throws Exception {
+ CountDownLatch done = new CountDownLatch(1);
+ Bundle[] reply = new Bundle[1];
+ Intent command = new Intent().setComponent(new ComponentName("net.paperclover.snowbound.providerlab",
+ "net.paperclover.snowbound.providerlab.Control")).putExtra("operation", operation);
+ getTargetContext().sendOrderedBroadcast(command, null, new BroadcastReceiver() {
+ @Override public void onReceive(Context context, Intent intent) {
+ reply[0] = getResultExtras(false);
+ done.countDown();
+ }
+ }, null, 0, null, null);
+ require(done.await(10, TimeUnit.SECONDS), "Provider control timed out: " + operation);
+ require(reply[0] != null && reply[0].getBoolean("ok"), "Provider control failed: " + reply[0]);
+ require(reply[0].getInt("uid") != android.os.Process.myUid(), "Provider shares client UID");
+ record("provider-control", new JSONObject().put("operation", operation)
+ .put("uid", reply[0].getInt("uid")).put("pid", reply[0].getInt("pid")));
+ }
+
+ private void grant(boolean write) throws Exception {
+ Grant.done = new CountDownLatch(1);
+ Grant.reply = null;
+ startActivitySync(new Intent(getTargetContext(), Grant.class)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK).putExtra("write", write));
+ require(Grant.done.await(10, TimeUnit.SECONDS), "Activity grant timed out");
+ Bundle reply = Grant.reply;
+ require(reply != null && reply.getBoolean("ok"), "Activity grant failed: " + reply);
+ require(reply.getInt("providerUid") != android.os.Process.myUid(), "Grant provider shares client UID");
+ require(reply.getString("uri").equals(TREE.toString()), "Grant URI changed");
+ int expected = Intent.FLAG_GRANT_READ_URI_PERMISSION | (write ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : 0);
+ require(reply.getInt("persistedFlags") == expected, "Returned grant flags changed");
+ record("activity-result-grant", new JSONObject().put("uri", reply.getString("uri"))
+ .put("receivedFlags", reply.getInt("receivedFlags")).put("persistedFlags", reply.getInt("persistedFlags"))
+ .put("providerUid", reply.getInt("providerUid")).put("providerPid", reply.getInt("providerPid")));
+ }
+
+ private static Uri document(String id) {
+ return DocumentsContract.buildDocumentUriUsingTree(TREE, id);
+ }
+
+ private byte[] read(Uri uri) throws Exception {
+ try (ParcelFileDescriptor.AutoCloseInputStream input = new ParcelFileDescriptor.AutoCloseInputStream(
+ java.util.Objects.requireNonNull(resolver.openFileDescriptor(uri, "r")))) {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ byte[] buffer = new byte[1024];
+ int length;
+ while ((length = input.read(buffer)) != -1) bytes.write(buffer, 0, length);
+ return bytes.toByteArray();
+ }
+ }
+
+ private static JSONObject bytes(byte[] data) throws Exception {
+ StringBuilder digest = new StringBuilder();
+ for (byte b : MessageDigest.getInstance("SHA-256").digest(data)) digest.append(String.format("%02x", b & 255));
+ return new JSONObject().put("length", data.length).put("sha256", digest.toString())
+ .put("syntheticText", new String(data, StandardCharsets.UTF_8));
+ }
+
+ private interface Operation { void run() throws Exception; }
+
+ private void failure(String name, Class> expected, Operation operation) throws Exception {
+ Throwable error = null;
+ try { operation.run(); } catch (Exception e) { error = e; }
+ require(error != null && expected.isInstance(error), name + " unexpected result: " + error);
+ record(name, new JSONObject().put("exception", error.getClass().getName()).put("message", error.toString()));
+ }
+
+ private void errno(String name, int expected, Operation operation) throws Exception {
+ android.system.ErrnoException error = null;
+ try { operation.run(); } catch (android.system.ErrnoException e) { error = e; }
+ require(error != null && error.errno == expected, name + " unexpected result: " + error);
+ record(name, new JSONObject().put("errno", error.errno).put("message", error.toString()));
+ }
+
+ private void probe() throws Exception {
+ control("revoke");
+ control("reset");
+ Uri local = document("note:opaque/β");
+ failure("ungranted-read", SecurityException.class, () -> read(local));
+ grant(false);
+ resolver.takePersistableUriPermission(TREE, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ require(resolver.getPersistedUriPermissions().size() == 1
+ && resolver.getPersistedUriPermissions().get(0).isReadPermission()
+ && !resolver.getPersistedUriPermissions().get(0).isWritePermission(), "Read-only grant widened");
+ require(new String(read(local), StandardCharsets.UTF_8).equals("ORIGINAL"), "Read-only content unavailable");
+ failure("read-only-persist-write", SecurityException.class,
+ () -> resolver.takePersistableUriPermission(TREE, Intent.FLAG_GRANT_WRITE_URI_PERMISSION));
+ failure("read-only-open-rw", SecurityException.class,
+ () -> { try (ParcelFileDescriptor ignored = resolver.openFileDescriptor(local, "rw")) { } });
+ record("read-only-persisted-grant", new JSONObject().put("uri", TREE).put("read", true).put("write", false));
+ control("revoke");
+ grant(true);
+ resolver.takePersistableUriPermission(TREE, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ require(resolver.getPersistedUriPermissions().size() == 1, "Persisted tree missing");
+ android.content.UriPermission grant = resolver.getPersistedUriPermissions().get(0);
+ require(grant.getUri().equals(TREE) && grant.isReadPermission() && grant.isWritePermission(), "Wrong persisted grant");
+ record("persisted-grant", new JSONObject().put("uri", TREE).put("read", grant.isReadPermission()).put("write", grant.isWritePermission()));
+
+ try (Cursor cursor = resolver.query(local, null, null, null, null)) {
+ require(cursor != null && cursor.moveToFirst(), "Metadata missing");
+ require(cursor.isNull(cursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)), "Unknown size became known");
+ require(cursor.isNull(cursor.getColumnIndexOrThrow(Document.COLUMN_LAST_MODIFIED)), "Unknown timestamp became known");
+ require(DocumentsContract.getDocumentId(local).equals("note:opaque/β"), "Opaque identity changed");
+ record("metadata", new JSONObject().put("uri", local).put("id", cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)))
+ .put("flags", cursor.getInt(cursor.getColumnIndexOrThrow(Document.COLUMN_FLAGS))).put("size", JSONObject.NULL).put("lastModified", JSONObject.NULL));
+ }
+ try (ParcelFileDescriptor fd = resolver.openFileDescriptor(local, "rw")) {
+ record("local-open", "rw");
+ record("local-seek", Os.lseek(fd.getFileDescriptor(), 2, OsConstants.SEEK_SET));
+ byte[] patch = "++".getBytes(StandardCharsets.UTF_8);
+ require(Os.write(fd.getFileDescriptor(), patch, 0, patch.length) == patch.length, "Short write");
+ record("local-write", patch.length);
+ Os.fsync(fd.getFileDescriptor());
+ record("local-fsync", "success");
+ }
+ record("local-close", "success");
+ byte[] patched = read(local);
+ require(new String(patched, StandardCharsets.UTF_8).equals("OR++INAL"), "Seek write was not retained");
+ record("local-reopen", bytes(patched));
+ Uri renamed = DocumentsContract.renameDocument(resolver, local, "renamed.one");
+ require(renamed != null && !renamed.equals(local), "Rename did not change URI");
+ require(DocumentsContract.getDocumentId(renamed).equals("identity:new/γ"), "Unexpected renamed ID");
+ failure("old-uri-after-rename", SecurityException.class, () -> read(local));
+ require(java.util.Arrays.equals(read(renamed), patched), "Renamed content changed");
+ java.util.List ids = new java.util.ArrayList<>();
+ Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(TREE, "root:opaque/α");
+ try (Cursor cursor = resolver.query(children, new String[] {Document.COLUMN_DOCUMENT_ID}, null, null, null)) {
+ require(cursor != null, "Listing null");
+ while (cursor.moveToNext()) ids.add(cursor.getString(0));
+ }
+ require(ids.contains("identity:new/γ") && !ids.contains("note:opaque/β"), "Rename listing stale");
+ record("rename", new JSONObject().put("old", local).put("returned", renamed).put("freshListing", new JSONArray(ids)));
+
+ Uri pipe = document("stream:opaque/δ");
+ failure("pipe-rw", UnsupportedOperationException.class, () -> { try (ParcelFileDescriptor ignored = resolver.openFileDescriptor(pipe, "rw")) { } });
+ try (ParcelFileDescriptor fd = resolver.openFileDescriptor(pipe, "r")) {
+ errno("pipe-read-seek", OsConstants.ESPIPE,
+ () -> Os.lseek(fd.getFileDescriptor(), 0, OsConstants.SEEK_SET));
+ errno("pipe-read-fsync", OsConstants.EINVAL, () -> Os.fsync(fd.getFileDescriptor()));
+ byte[] buffer = new byte[6];
+ require(Os.read(fd.getFileDescriptor(), buffer, 0, buffer.length) == 6, "Short pipe read");
+ require(new String(buffer, StandardCharsets.UTF_8).equals("STREAM"), "Wrong stream bytes");
+ record("pipe-read", bytes(buffer));
+ }
+ control("await-pipe");
+ try (ParcelFileDescriptor fd = resolver.openFileDescriptor(pipe, "w")) {
+ errno("pipe-write-seek", OsConstants.ESPIPE,
+ () -> Os.lseek(fd.getFileDescriptor(), 0, OsConstants.SEEK_SET));
+ byte[] data = "PIPE-WRITE".getBytes(StandardCharsets.UTF_8);
+ record("pipe-write", Os.write(fd.getFileDescriptor(), data, 0, data.length));
+ errno("pipe-write-fsync", OsConstants.EINVAL, () -> Os.fsync(fd.getFileDescriptor()));
+ }
+ record("pipe-write-close", "success");
+ control("await-pipe");
+ require(new String(read(pipe), StandardCharsets.UTF_8).equals("PIPE-WRITE"), "Pipe content not retained");
+ control("await-pipe");
+ try (ParcelFileDescriptor fd = resolver.openFileDescriptor(document("failed:opaque/ε"), "r")) {
+ control("await-pipe");
+ failure("reliable-peer-error", java.io.IOException.class, fd::checkError);
+ }
+ record("reliable-peer-close", "success");
+
+ Uri writer = document("view:writer/ζ");
+ Uri observer = document("view:observer/η");
+ try (ParcelFileDescriptor fd = resolver.openFileDescriptor(writer, "rw")) {
+ byte[] data = "NEW-VIEW".getBytes(StandardCharsets.UTF_8);
+ require(Os.write(fd.getFileDescriptor(), data, 0, data.length) == data.length, "Short staged write");
+ Os.fsync(fd.getFileDescriptor());
+ record("delayed-local-fsync", "success");
+ }
+ record("delayed-local-close", "success");
+ require(new String(read(writer), StandardCharsets.UTF_8).equals("NEW-VIEW"), "Writer did not retain bytes");
+ byte[] old = read(observer);
+ require(new String(old, StandardCharsets.UTF_8).equals("OLD-VIEW"), "Observer advanced without publication event");
+ record("observer-after-successful-fsync-close", bytes(old));
+ control("publish");
+ byte[] published = read(observer);
+ require(new String(published, StandardCharsets.UTF_8).equals("NEW-VIEW"), "Controlled publication failed");
+ record("observer-after-publication-event", bytes(published));
+ control("offline");
+ failure("offline-open", java.io.FileNotFoundException.class, () -> read(writer));
+ require(resolver.getPersistedUriPermissions().size() == 1, "Offline availability changed grant");
+ control("online");
+ require(java.util.Arrays.equals(read(writer), published), "Online restore lost content");
+ record("restored-online", bytes(read(writer)));
+ }
+
+ @Override public void onStart() {
+ resolver = getTargetContext().getContentResolver();
+ JSONObject report = new JSONObject();
+ boolean passed = false;
+ try {
+ if (phase.equals("probe")) probe();
+ else if (phase.equals("reopen")) {
+ require(resolver.getPersistedUriPermissions().size() == 1, "Grant did not survive client process exit");
+ android.content.UriPermission grant = resolver.getPersistedUriPermissions().get(0);
+ require(grant.getUri().equals(TREE) && grant.isReadPermission() && grant.isWritePermission(), "Reopened grant flags changed");
+ byte[] reopened = read(document("identity:new/γ"));
+ require(new String(reopened, StandardCharsets.UTF_8).equals("OR++INAL"), "Reopened bytes changed");
+ record("second-client-process", new JSONObject().put("bytes", bytes(reopened))
+ .put("uri", grant.getUri()).put("read", grant.isReadPermission()).put("write", grant.isWritePermission()));
+ control("revoke");
+ failure("revoked-read", SecurityException.class, () -> read(document("identity:new/γ")));
+ require(resolver.getPersistedUriPermissions().isEmpty(), "Revoked persisted grant retained");
+ record("persisted-after-revoke", 0);
+ } else throw new IllegalArgumentException(phase);
+ passed = true;
+ } catch (Throwable e) {
+ try { report.put("failure", e.toString()); } catch (Exception ignored) { }
+ }
+ try {
+ report.put("passed", passed).put("phase", phase).put("pid", android.os.Process.myPid())
+ .put("uid", android.os.Process.myUid()).put("sdk", android.os.Build.VERSION.SDK_INT)
+ .put("fingerprint", android.os.Build.FINGERPRINT).put("events", events);
+ try (FileOutputStream out = new FileOutputStream(new File(getTargetContext().getFilesDir(), phase + ".json"))) {
+ out.write(report.toString(2).getBytes(StandardCharsets.UTF_8));
+ }
+ } catch (Exception e) { passed = false; }
+ Bundle result = new Bundle();
+ result.putString("report", report.toString());
+ finish(passed ? -1 : 0, result);
+ }
+}
diff --git a/tools/android/provider-lab/provider/AndroidManifest.xml b/tools/android/provider-lab/provider/AndroidManifest.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a367d9ea5d05006d3ebd5fd6ad0801d4bacc5c82
--- /dev/null
+++ b/tools/android/provider-lab/provider/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/android/provider-lab/provider/Control.java b/tools/android/provider-lab/provider/Control.java
new file mode 100644
index 0000000000000000000000000000000000000000..6f4392c467143703fdab103816c03a334f1079af
--- /dev/null
+++ b/tools/android/provider-lab/provider/Control.java
@@ -0,0 +1,37 @@
+package net.paperclover.snowbound.providerlab;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.DocumentsContract;
+import java.nio.file.Files;
+
+public final class Control extends BroadcastReceiver {
+ @Override public void onReceive(Context context, Intent intent) {
+ Bundle result = new Bundle();
+ try {
+ // Force provider initialization before touching its disposable backing files.
+ try (android.content.ContentProviderClient ignored = context.getContentResolver()
+ .acquireContentProviderClient(LabProvider.AUTHORITY)) {
+ Uri tree = DocumentsContract.buildTreeDocumentUri(LabProvider.AUTHORITY, LabProvider.ROOT);
+ String action = intent.getStringExtra("operation");
+ if (action.equals("reset")) LabProvider.reset();
+ else if (action.equals("revoke")) {
+ context.revokeUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ } else if (action.equals("offline")) LabProvider.offline = true;
+ else if (action.equals("online")) LabProvider.offline = false;
+ else if (action.equals("publish")) Files.write(LabProvider.file("observer").toPath(), Files.readAllBytes(LabProvider.file("writer").toPath()));
+ else if (action.equals("await-pipe")) LabProvider.awaitPipe();
+ else throw new IllegalArgumentException(action);
+ }
+ result.putBoolean("ok", true);
+ result.putInt("uid", android.os.Process.myUid());
+ result.putInt("pid", android.os.Process.myPid());
+ } catch (Exception e) {
+ result.putString("error", e.toString());
+ }
+ setResultExtras(result);
+ }
+}
diff --git a/tools/android/provider-lab/provider/LabProvider.java b/tools/android/provider-lab/provider/LabProvider.java
new file mode 100644
index 0000000000000000000000000000000000000000..d836eb8c51d2e72530d5ad930da6ba9e2e82bf5b
--- /dev/null
+++ b/tools/android/provider-lab/provider/LabProvider.java
@@ -0,0 +1,167 @@
+package net.paperclover.snowbound.providerlab;
+
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract.Document;
+import android.provider.DocumentsContract.Root;
+import android.provider.DocumentsProvider;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+public final class LabProvider extends DocumentsProvider {
+ static final String AUTHORITY = "net.paperclover.snowbound.providerlab.documents";
+ static final String ROOT = "root:opaque/α";
+ static final String LOCAL = "note:opaque/β";
+ static final String RENAMED = "identity:new/γ";
+ static final String PIPE = "stream:opaque/δ";
+ static final String FAIL = "failed:opaque/ε";
+ static final String WRITER = "view:writer/ζ";
+ static final String OBSERVER = "view:observer/η";
+ static final String[] COLUMNS = {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED};
+ static volatile boolean offline;
+ static volatile byte[] pipeBytes;
+ static volatile CountDownLatch pipeDone = new CountDownLatch(0);
+ static volatile String pipeError;
+ static File directory;
+
+ @Override public boolean onCreate() {
+ directory = new File(getContext().getFilesDir(), "synthetic-documents");
+ directory.mkdirs();
+ return true;
+ }
+
+ static File file(String name) { return new File(directory, name); }
+
+ static void reset() throws IOException {
+ offline = false;
+ Files.deleteIfExists(file("renamed").toPath());
+ pipeBytes = "STREAM".getBytes(StandardCharsets.UTF_8);
+ pipeError = null;
+ pipeDone = new CountDownLatch(0);
+ Files.write(file("local").toPath(), "ORIGINAL".getBytes(StandardCharsets.UTF_8));
+ Files.write(file("writer").toPath(), "OLD-VIEW".getBytes(StandardCharsets.UTF_8));
+ Files.write(file("observer").toPath(), "OLD-VIEW".getBytes(StandardCharsets.UTF_8));
+ }
+
+ static boolean renamed() { return file("renamed").exists(); }
+
+ static void check(String id) throws FileNotFoundException {
+ if (!(id.equals(ROOT) || id.equals(renamed() ? RENAMED : LOCAL) || id.equals(PIPE)
+ || id.equals(FAIL) || id.equals(WRITER) || id.equals(OBSERVER))) {
+ throw new FileNotFoundException("Unknown opaque identity");
+ }
+ }
+
+ private static void row(MatrixCursor cursor, String id) {
+ MatrixCursor.RowBuilder row = cursor.newRow();
+ for (String column : cursor.getColumnNames()) {
+ Object value = null;
+ if (column.equals(Document.COLUMN_DOCUMENT_ID)) value = id;
+ if (column.equals(Document.COLUMN_DISPLAY_NAME)) value = id.equals(ROOT) ? "Disposable lab" : id.equals(RENAMED) ? "renamed.one" : "same-name.one";
+ if (column.equals(Document.COLUMN_MIME_TYPE)) value = id.equals(ROOT) ? Document.MIME_TYPE_DIR : "application/octet-stream";
+ if (column.equals(Document.COLUMN_FLAGS)) value = id.equals(ROOT) ? 0 : Document.FLAG_SUPPORTS_WRITE | (id.equals(LOCAL) ? Document.FLAG_SUPPORTS_RENAME : 0);
+ row.add(value);
+ }
+ }
+
+ @Override public Cursor queryRoots(String[] projection) {
+ MatrixCursor cursor = new MatrixCursor(projection != null ? projection : new String[] {
+ Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE, Root.COLUMN_FLAGS});
+ MatrixCursor.RowBuilder row = cursor.newRow();
+ for (String column : cursor.getColumnNames()) {
+ Object value = null;
+ if (column.equals(Root.COLUMN_ROOT_ID)) value = "synthetic";
+ if (column.equals(Root.COLUMN_DOCUMENT_ID)) value = ROOT;
+ if (column.equals(Root.COLUMN_TITLE)) value = "Disposable provider lab";
+ if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD;
+ row.add(value);
+ }
+ return cursor;
+ }
+
+ @Override public Cursor queryDocument(String id, String[] projection) throws FileNotFoundException {
+ check(id);
+ MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS);
+ row(cursor, id);
+ return cursor;
+ }
+
+ @Override public Cursor queryChildDocuments(String parent, String[] projection, String order) throws FileNotFoundException {
+ if (!parent.equals(ROOT)) throw new FileNotFoundException("Not a directory");
+ MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS);
+ for (String id : Arrays.asList(renamed() ? RENAMED : LOCAL, PIPE, FAIL, WRITER, OBSERVER)) row(cursor, id);
+ return cursor;
+ }
+
+ @Override public boolean isChildDocument(String parent, String child) {
+ try { check(child); } catch (FileNotFoundException e) { return false; }
+ return parent.equals(ROOT) && !child.equals(ROOT);
+ }
+
+ @Override public String renameDocument(String id, String name) throws FileNotFoundException {
+ check(id);
+ if (!id.equals(LOCAL)) throw new UnsupportedOperationException("Only local fixture renames");
+ if (!name.equals("renamed.one")) throw new UnsupportedOperationException("Fixture rename name");
+ if (!file("local").renameTo(file("renamed"))) throw new FileNotFoundException("Rename failed");
+ return RENAMED;
+ }
+
+ @Override public ParcelFileDescriptor openDocument(String id, String mode, CancellationSignal signal) throws FileNotFoundException {
+ check(id);
+ if (offline) throw new FileNotFoundException("Injected provider unavailable");
+ if (signal != null) signal.throwIfCanceled();
+ try {
+ if (id.equals(PIPE) || id.equals(FAIL)) {
+ if (!(mode.equals("r") || mode.equals("w"))) throw new UnsupportedOperationException("Streaming fixture rejects seeking modes");
+ ParcelFileDescriptor[] ends = ParcelFileDescriptor.createReliablePipe();
+ boolean reading = mode.equals("r");
+ pipeDone = new CountDownLatch(1);
+ pipeError = null;
+ new Thread(() -> {
+ try {
+ if (id.equals(FAIL)) {
+ ends[reading ? 1 : 0].closeWithError("Injected reliable peer failure");
+ } else if (reading) {
+ try (ParcelFileDescriptor.AutoCloseOutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(ends[1])) {
+ out.write(pipeBytes);
+ }
+ } else {
+ try (ParcelFileDescriptor.AutoCloseInputStream in = new ParcelFileDescriptor.AutoCloseInputStream(ends[0])) {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ byte[] buffer = new byte[1024];
+ int length;
+ while ((length = in.read(buffer)) != -1) bytes.write(buffer, 0, length);
+ pipeBytes = bytes.toByteArray();
+ }
+ }
+ } catch (IOException e) {
+ pipeError = e.toString();
+ } finally {
+ pipeDone.countDown();
+ }
+ }, "provider-lab-pipe").start();
+ return ends[reading ? 0 : 1];
+ }
+ if (id.equals(ROOT)) throw new FileNotFoundException("Directory has no byte stream");
+ File source = file(id.equals(WRITER) ? "writer" : id.equals(OBSERVER) ? "observer" : id.equals(RENAMED) ? "renamed" : "local");
+ return ParcelFileDescriptor.open(source, ParcelFileDescriptor.parseMode(mode));
+ } catch (IOException e) {
+ throw new FileNotFoundException(e.toString());
+ }
+ }
+
+ static void awaitPipe() throws Exception {
+ if (!pipeDone.await(5, TimeUnit.SECONDS)) throw new IOException("Pipe worker did not finish");
+ if (pipeError != null) throw new IOException(pipeError);
+ }
+}
diff --git a/tools/android/provider-lab/provider/Pick.java b/tools/android/provider-lab/provider/Pick.java
new file mode 100644
index 0000000000000000000000000000000000000000..587a7ad69a1e9f0037f2e4fd1bf0aff6595ddb92
--- /dev/null
+++ b/tools/android/provider-lab/provider/Pick.java
@@ -0,0 +1,19 @@
+package net.paperclover.snowbound.providerlab;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.provider.DocumentsContract;
+
+public final class Pick extends Activity {
+ @Override public void onCreate(Bundle state) {
+ super.onCreate(state);
+ setResult(RESULT_OK, new Intent()
+ .setData(DocumentsContract.buildTreeDocumentUri(LabProvider.AUTHORITY, LabProvider.ROOT))
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | (getIntent().getBooleanExtra("write", false) ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : 0)
+ | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
+ .putExtra("providerUid", android.os.Process.myUid()).putExtra("providerPid", android.os.Process.myPid()));
+ finish();
+ }
+}
diff --git a/tools/android/provider-lab/run.py b/tools/android/provider-lab/run.py
new file mode 100644
index 0000000000000000000000000000000000000000..0de9b42479f33804f4f3c63e91b17b452f11bcaf
--- /dev/null
+++ b/tools/android/provider-lab/run.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""Run the provider lab on an explicitly selected disposable emulator."""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import subprocess
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--adb", type=Path, required=True)
+ parser.add_argument("--serial", required=True)
+ parser.add_argument("--build", type=Path, required=True)
+ parser.add_argument("--output", type=Path, required=True)
+ args = parser.parse_args()
+ output = args.output.resolve()
+ output.mkdir(parents=True, exist_ok=False)
+ steps = []
+
+ def adb(*command, timeout=120):
+ result = subprocess.run([str(args.adb), "-s", args.serial, *command],
+ capture_output=True, text=True, timeout=timeout)
+ steps.append({"command": list(command), "exitCode": result.returncode,
+ "stdout": result.stdout, "stderr": result.stderr})
+ (output / "commands.json").write_text(json.dumps(steps, indent=2) + "\n")
+ result.check_returncode()
+ return result.stdout
+
+ if adb("shell", "getprop", "ro.kernel.qemu").strip() != "1":
+ raise RuntimeError("This lab only runs on a disposable emulator")
+ manifest = json.loads((args.build / "manifest.json").read_text())
+ for name, expected in manifest["apkSha256"].items():
+ if hashlib.sha256((args.build / name).read_bytes()).hexdigest() != expected:
+ raise RuntimeError(f"APK hash changed: {name}")
+ reports = {}
+ packages = ("net.paperclover.snowbound.providerlab", "net.paperclover.snowbound.providerprobe")
+ try:
+ for name in ("provider", "client"):
+ adb("install", "-t", str((args.build / (name + ".apk")).resolve()))
+ for phase in ("probe", "reopen"):
+ adb("shell", "am", "instrument", "-w", "-r", "-e", "phase", phase,
+ "net.paperclover.snowbound.providerprobe/.Probe")
+ report = json.loads(adb("shell", "run-as", packages[1], "cat", "files/" + phase + ".json"))
+ reports[phase] = report
+ (output / (phase + ".json")).write_text(json.dumps(report, indent=2) + "\n")
+ if not report["passed"]:
+ raise RuntimeError(f"{phase} failed: {report.get('failure')}")
+ adb("shell", "am", "force-stop", packages[1])
+ if reports["probe"]["pid"] == reports["reopen"]["pid"]:
+ raise RuntimeError("Second phase did not use a fresh client process")
+ result = {"passed": True, "serial": args.serial, "build": manifest,
+ "reportsSha256": {name + ".json": hashlib.sha256((output / (name + ".json")).read_bytes()).hexdigest()
+ for name in reports},
+ "scope": "Synthetic local DocumentsProvider; no commercial provider, picker UI, lock/CAS, network upload, OneNote, or production storage integration"}
+ (output / "verification.json").write_text(json.dumps(result, indent=2) + "\n")
+ print(json.dumps(result, indent=2))
+ finally:
+ errors = []
+ for package in reversed(packages):
+ try:
+ adb("uninstall", package, timeout=15)
+ except Exception as error:
+ errors.append({"package": package, "operation": "uninstall", "error": repr(error)})
+ retained = []
+ unknown = []
+ for package in packages:
+ try:
+ if adb("shell", "pm", "list", "packages", package, timeout=10).strip():
+ retained.append(package)
+ except Exception as error:
+ unknown.append(package)
+ errors.append({"package": package, "operation": "presence", "error": repr(error)})
+ cleanup = {"packagesAbsent": not (retained or unknown), "retained": retained,
+ "unknown": unknown, "errors": errors}
+ (output / "cleanup.json").write_text(json.dumps(cleanup, indent=2) + "\n")
+ if errors or retained or unknown:
+ raise RuntimeError(f"Lab cleanup failed: {cleanup}")
+
+
+if __name__ == "__main__":
+ main()