| 1 | package net.paperclover.snowbound.providerlab; |
| 2 | |
| 3 | import android.database.Cursor; |
| 4 | import android.database.MatrixCursor; |
| 5 | import android.os.CancellationSignal; |
| 6 | import android.os.ParcelFileDescriptor; |
| 7 | import android.provider.DocumentsContract.Document; |
| 8 | import android.provider.DocumentsContract.Root; |
| 9 | import android.provider.DocumentsProvider; |
| 10 | import java.io.ByteArrayOutputStream; |
| 11 | import java.io.File; |
| 12 | import java.io.FileNotFoundException; |
| 13 | import java.io.IOException; |
| 14 | import java.nio.charset.StandardCharsets; |
| 15 | import java.nio.file.Files; |
| 16 | import java.util.Arrays; |
| 17 | import java.util.concurrent.CountDownLatch; |
| 18 | import java.util.concurrent.TimeUnit; |
| 19 | |
| 20 | public final class LabProvider extends DocumentsProvider { |
| 21 | static final String AUTHORITY = "net.paperclover.snowbound.providerlab.documents"; |
| 22 | static final String ROOT = "root:opaque/α"; |
| 23 | static final String LOCAL = "note:opaque/β"; |
| 24 | static final String RENAMED = "identity:new/γ"; |
| 25 | static final String PIPE = "stream:opaque/δ"; |
| 26 | static final String FAIL = "failed:opaque/ε"; |
| 27 | static final String WRITER = "view:writer/ζ"; |
| 28 | static final String OBSERVER = "view:observer/η"; |
| 29 | /** A second root: a real notebook folder, its documents named by path below it. */ |
| 30 | static final String NOTEBOOK = "notebook:"; |
| 31 | static final String[] COLUMNS = {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, |
| 32 | Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED}; |
| 33 | static volatile boolean offline; |
| 34 | static volatile byte[] pipeBytes; |
| 35 | static volatile CountDownLatch pipeDone = new CountDownLatch(0); |
| 36 | static volatile String pipeError; |
| 37 | static File directory; |
| 38 | |
| 39 | @Override public boolean onCreate() { |
| 40 | directory = new File(getContext().getFilesDir(), "synthetic-documents"); |
| 41 | directory.mkdirs(); |
| 42 | return true; |
| 43 | } |
| 44 | |
| 45 | static File file(String name) { return new File(directory, name); } |
| 46 | |
| 47 | /** The notebook folder, which `adb shell run-as` fills with a disposable notebook. */ |
| 48 | static File notebook(String id) { |
| 49 | return new File(new File(directory.getParentFile(), "notebook"), id.substring(NOTEBOOK.length())); |
| 50 | } |
| 51 | |
| 52 | private static void notebookRow(MatrixCursor cursor, String id) { |
| 53 | File file = notebook(id); |
| 54 | MatrixCursor.RowBuilder row = cursor.newRow(); |
| 55 | for (String column : cursor.getColumnNames()) { |
| 56 | Object value = null; |
| 57 | if (column.equals(Document.COLUMN_DOCUMENT_ID)) value = id; |
| 58 | if (column.equals(Document.COLUMN_DISPLAY_NAME)) value = id.equals(NOTEBOOK) ? "Lab notebook" : file.getName(); |
| 59 | if (column.equals(Document.COLUMN_MIME_TYPE)) value = file.isDirectory() ? Document.MIME_TYPE_DIR : "application/octet-stream"; |
| 60 | if (column.equals(Document.COLUMN_FLAGS)) value = file.isDirectory() ? 0 : Document.FLAG_SUPPORTS_WRITE; |
| 61 | if (column.equals(Document.COLUMN_SIZE)) value = file.isDirectory() ? null : file.length(); |
| 62 | if (column.equals(Document.COLUMN_LAST_MODIFIED)) value = file.lastModified(); |
| 63 | row.add(value); |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | static void reset() throws IOException { |
| 68 | offline = false; |
| 69 | Files.deleteIfExists(file("renamed").toPath()); |
| 70 | pipeBytes = "STREAM".getBytes(StandardCharsets.UTF_8); |
| 71 | pipeError = null; |
| 72 | pipeDone = new CountDownLatch(0); |
| 73 | Files.write(file("local").toPath(), "ORIGINAL".getBytes(StandardCharsets.UTF_8)); |
| 74 | Files.write(file("writer").toPath(), "OLD-VIEW".getBytes(StandardCharsets.UTF_8)); |
| 75 | Files.write(file("observer").toPath(), "OLD-VIEW".getBytes(StandardCharsets.UTF_8)); |
| 76 | } |
| 77 | |
| 78 | static boolean renamed() { return file("renamed").exists(); } |
| 79 | |
| 80 | static void check(String id) throws FileNotFoundException { |
| 81 | if (id.startsWith(NOTEBOOK)) { |
| 82 | if (offline) throw new FileNotFoundException("Injected provider unavailable"); |
| 83 | if (!notebook(id).exists()) throw new FileNotFoundException("No such notebook document"); |
| 84 | return; |
| 85 | } |
| 86 | if (!(id.equals(ROOT) || id.equals(renamed() ? RENAMED : LOCAL) || id.equals(PIPE) |
| 87 | || id.equals(FAIL) || id.equals(WRITER) || id.equals(OBSERVER))) { |
| 88 | throw new FileNotFoundException("Unknown opaque identity"); |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | private static void row(MatrixCursor cursor, String id) { |
| 93 | MatrixCursor.RowBuilder row = cursor.newRow(); |
| 94 | for (String column : cursor.getColumnNames()) { |
| 95 | Object value = null; |
| 96 | if (column.equals(Document.COLUMN_DOCUMENT_ID)) value = id; |
| 97 | if (column.equals(Document.COLUMN_DISPLAY_NAME)) value = id.equals(ROOT) ? "Disposable lab" : id.equals(RENAMED) ? "renamed.one" : "same-name.one"; |
| 98 | if (column.equals(Document.COLUMN_MIME_TYPE)) value = id.equals(ROOT) ? Document.MIME_TYPE_DIR : "application/octet-stream"; |
| 99 | if (column.equals(Document.COLUMN_FLAGS)) value = id.equals(ROOT) ? 0 : Document.FLAG_SUPPORTS_WRITE | (id.equals(LOCAL) ? Document.FLAG_SUPPORTS_RENAME : 0); |
| 100 | row.add(value); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | @Override public Cursor queryRoots(String[] projection) { |
| 105 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : new String[] { |
| 106 | Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE, Root.COLUMN_FLAGS}); |
| 107 | MatrixCursor.RowBuilder row = cursor.newRow(); |
| 108 | for (String column : cursor.getColumnNames()) { |
| 109 | Object value = null; |
| 110 | if (column.equals(Root.COLUMN_ROOT_ID)) value = "synthetic"; |
| 111 | if (column.equals(Root.COLUMN_DOCUMENT_ID)) value = ROOT; |
| 112 | if (column.equals(Root.COLUMN_TITLE)) value = "Disposable provider lab"; |
| 113 | if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD; |
| 114 | row.add(value); |
| 115 | } |
| 116 | row = cursor.newRow(); |
| 117 | for (String column : cursor.getColumnNames()) { |
| 118 | Object value = null; |
| 119 | if (column.equals(Root.COLUMN_ROOT_ID)) value = "notebook"; |
| 120 | if (column.equals(Root.COLUMN_DOCUMENT_ID)) value = NOTEBOOK; |
| 121 | if (column.equals(Root.COLUMN_TITLE)) value = "Disposable lab notebook"; |
| 122 | if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD; |
| 123 | row.add(value); |
| 124 | } |
| 125 | return cursor; |
| 126 | } |
| 127 | |
| 128 | @Override public Cursor queryDocument(String id, String[] projection) throws FileNotFoundException { |
| 129 | check(id); |
| 130 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); |
| 131 | if (id.startsWith(NOTEBOOK)) notebookRow(cursor, id); |
| 132 | else row(cursor, id); |
| 133 | return cursor; |
| 134 | } |
| 135 | |
| 136 | @Override public Cursor queryChildDocuments(String parent, String[] projection, String order) throws FileNotFoundException { |
| 137 | if (parent.startsWith(NOTEBOOK)) { |
| 138 | check(parent); |
| 139 | File[] files = notebook(parent).listFiles(); |
| 140 | if (files == null) throw new FileNotFoundException("Not a directory"); |
| 141 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); |
| 142 | String prefix = parent.equals(NOTEBOOK) ? NOTEBOOK : parent + "/"; |
| 143 | for (File file : files) notebookRow(cursor, prefix + file.getName()); |
| 144 | return cursor; |
| 145 | } |
| 146 | if (!parent.equals(ROOT)) throw new FileNotFoundException("Not a directory"); |
| 147 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); |
| 148 | for (String id : Arrays.asList(renamed() ? RENAMED : LOCAL, PIPE, FAIL, WRITER, OBSERVER)) row(cursor, id); |
| 149 | return cursor; |
| 150 | } |
| 151 | |
| 152 | @Override public boolean isChildDocument(String parent, String child) { |
| 153 | if (parent.startsWith(NOTEBOOK)) return child.startsWith(parent) && !child.equals(parent); |
| 154 | try { check(child); } catch (FileNotFoundException e) { return false; } |
| 155 | return parent.equals(ROOT) && !child.equals(ROOT); |
| 156 | } |
| 157 | |
| 158 | @Override public String renameDocument(String id, String name) throws FileNotFoundException { |
| 159 | check(id); |
| 160 | if (!id.equals(LOCAL)) throw new UnsupportedOperationException("Only local fixture renames"); |
| 161 | if (!name.equals("renamed.one")) throw new UnsupportedOperationException("Fixture rename name"); |
| 162 | if (!file("local").renameTo(file("renamed"))) throw new FileNotFoundException("Rename failed"); |
| 163 | return RENAMED; |
| 164 | } |
| 165 | |
| 166 | @Override public ParcelFileDescriptor openDocument(String id, String mode, CancellationSignal signal) throws FileNotFoundException { |
| 167 | check(id); |
| 168 | if (offline) throw new FileNotFoundException("Injected provider unavailable"); |
| 169 | if (signal != null) signal.throwIfCanceled(); |
| 170 | try { |
| 171 | if (id.equals(PIPE) || id.equals(FAIL)) { |
| 172 | if (!(mode.equals("r") || mode.equals("w"))) throw new UnsupportedOperationException("Streaming fixture rejects seeking modes"); |
| 173 | ParcelFileDescriptor[] ends = ParcelFileDescriptor.createReliablePipe(); |
| 174 | boolean reading = mode.equals("r"); |
| 175 | pipeDone = new CountDownLatch(1); |
| 176 | pipeError = null; |
| 177 | new Thread(() -> { |
| 178 | try { |
| 179 | if (id.equals(FAIL)) { |
| 180 | ends[reading ? 1 : 0].closeWithError("Injected reliable peer failure"); |
| 181 | } else if (reading) { |
| 182 | try (ParcelFileDescriptor.AutoCloseOutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(ends[1])) { |
| 183 | out.write(pipeBytes); |
| 184 | } |
| 185 | } else { |
| 186 | try (ParcelFileDescriptor.AutoCloseInputStream in = new ParcelFileDescriptor.AutoCloseInputStream(ends[0])) { |
| 187 | ByteArrayOutputStream bytes = new ByteArrayOutputStream(); |
| 188 | byte[] buffer = new byte[1024]; |
| 189 | int length; |
| 190 | while ((length = in.read(buffer)) != -1) bytes.write(buffer, 0, length); |
| 191 | pipeBytes = bytes.toByteArray(); |
| 192 | } |
| 193 | } |
| 194 | } catch (IOException e) { |
| 195 | pipeError = e.toString(); |
| 196 | } finally { |
| 197 | pipeDone.countDown(); |
| 198 | } |
| 199 | }, "provider-lab-pipe").start(); |
| 200 | return ends[reading ? 0 : 1]; |
| 201 | } |
| 202 | if (id.startsWith(NOTEBOOK)) return ParcelFileDescriptor.open(notebook(id), ParcelFileDescriptor.parseMode(mode)); |
| 203 | if (id.equals(ROOT)) throw new FileNotFoundException("Directory has no byte stream"); |
| 204 | File source = file(id.equals(WRITER) ? "writer" : id.equals(OBSERVER) ? "observer" : id.equals(RENAMED) ? "renamed" : "local"); |
| 205 | return ParcelFileDescriptor.open(source, ParcelFileDescriptor.parseMode(mode)); |
| 206 | } catch (IOException e) { |
| 207 | throw new FileNotFoundException(e.toString()); |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | static void awaitPipe() throws Exception { |
| 212 | if (!pipeDone.await(5, TimeUnit.SECONDS)) throw new IOException("Pipe worker did not finish"); |
| 213 | if (pipeError != null) throw new IOException(pipeError); |
| 214 | } |
| 215 | } |