package paris.tourolle.darkroom; import android.content.ContentResolver; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.provider.DocumentsContract; import android.util.Log; import java.io.IOException; import java.io.OutputStream; /** * Writing an export into a folder the user granted through * {@link FolderPicker} — the Storage Access Framework, which is the only way * this app reaches a folder on the device (FR-PLAT-AND-1). * *

A tree URI is not a path: a child is found by listing the folder and * matching its display name, and created through the provider, which may * rename it on a collision. So the name that was actually written is handed * back, and the album records that one. * *

Two static calls, strings and a byte array in, a string out, for the * reason {@link Intents} gives: every call here would be a signature typed as * a string on the Rust side, and the fewer of those the better. */ public final class Saf { private static final String TAG = "DarkRoom"; private Saf() { } /** Whether {@code name} already exists in the folder. False on any error. */ public static boolean exists(Context context, String tree, String name) { try { return find(context.getContentResolver(), Uri.parse(tree), name) != null; } catch (RuntimeException e) { Log.w(TAG, "checking " + name + " in " + tree, e); return false; } } /** * Write {@code bytes} as {@code name} in the folder, replacing a file of * that name when {@code replace} is set. * * @return the name the file has in the folder — the provider may have * added " (1)" — or null on failure, with the reason in the log. */ public static String write(Context context, String tree, String name, String mime, byte[] bytes, boolean replace) { ContentResolver resolver = context.getContentResolver(); Uri treeUri = Uri.parse(tree); try { Uri target = replace ? find(resolver, treeUri, name) : null; if (target == null) { Uri folder = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)); target = DocumentsContract.createDocument(resolver, folder, mime, name); } if (target == null) { Log.w(TAG, "the folder refused to create " + name + " in " + tree); return null; } // "wt": truncate. A replacement shorter than what it replaces // must not keep the old file's tail. try (OutputStream out = resolver.openOutputStream(target, "wt")) { if (out == null) { Log.w(TAG, "no stream for " + target); return null; } out.write(bytes); } String written = displayName(resolver, target); return written != null ? written : name; } catch (IOException | RuntimeException e) { Log.w(TAG, "writing " + name + " to " + tree, e); return null; } } /** The document for {@code name} directly in the tree's folder, or null. */ private static Uri find(ContentResolver resolver, Uri tree, String name) { String folderId = DocumentsContract.getTreeDocumentId(tree); Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, folderId); String[] columns = { DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, }; try (Cursor c = resolver.query(children, columns, null, null, null)) { if (c == null) { return null; } while (c.moveToNext()) { if (name.equals(c.getString(1))) { return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0)); } } } return null; } private static String displayName(ContentResolver resolver, Uri document) { String[] columns = {DocumentsContract.Document.COLUMN_DISPLAY_NAME}; try (Cursor c = resolver.query(document, columns, null, null, null)) { if (c != null && c.moveToFirst()) { return c.getString(0); } } catch (RuntimeException e) { Log.w(TAG, "reading the name of " + document, e); } return null; } }