# Files: one file at a time - JavaScript bridge API

> The system picker and chunked binary read/write, for files too large to hold in a string.

- **Applies to:** AppMint
- **Source:** extracted from the app runtime and its per-method example files; this page is generated from them.
- **HTML:** https://freewebtoapk.com/docs/api/files-io

### `AppMint.downloadFile`

```js
AppMint.downloadFile(data, fileName, mimeType?)
```

**Example**

Saves a file your page made - from a base64 string, a `data:` URL, a `blob:` URL or a Blob - with the file name you choose. Android's "Save as…" sheet opens with that name filled in.

**Returns:** (sync) `true` once the save was handed to Android (`false` only for a `data:` URL it could not parse). Throws `TypeError` if `data` is not a Blob or string. The user still picks the folder, and can cancel. **Needs:** nothing - it is always present in the app.

Base64 string plus a MIME type:

```js
function exportCsv(rows) {
  const csv = rows.map(function (r) { return r.join(','); }).join('\n');
  const b64 = btoa(unescape(encodeURIComponent(csv)));          // UTF-8 safe
  if (window.AppMint && typeof AppMint.downloadFile === 'function') {
    AppMint.downloadFile(b64, 'Ledger_Q3.csv', 'text/csv');
  } else {
    const a = document.createElement('a');                       // a normal browser
    a.href = 'data:text/csv;base64,' + b64;
    a.download = 'Ledger_Q3.csv';
    a.click();
  }
}
```

A `data:` URL (the MIME type comes from the URL) or a Blob:

```js
const png = document.querySelector('canvas').toDataURL('image/png');
AppMint.downloadFile(png, 'Chart.png');

const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
AppMint.downloadFile(blob, 'Backup.json');                        // same as AppMint.saveBlob
```

**Notes:** Only the last part of `fileName` is used (no folders). A name without an extension gets one from the MIME type. The third argument is used only for a plain base64 string. If the phone has no "Save as…" screen at all, the file is written straight into Downloads. Your normal `<a download>` code also works - see `downloads`.

### `AppMint.saveBlob`

```js
AppMint.saveBlob(blob, fileName)
```

**Example**

Saves a Blob (or File) with the name you choose. Android's "Save as…" sheet opens with that name filled in.

**Returns:** (sync) `true` once the save was handed to Android. Throws `TypeError` when the first argument is not a Blob. **Needs:** nothing - it is always present in the app.

```js
async function saveReport() {
  const blob = await makePdfBlob();                    // e.g. jsPDF: doc.output('blob')
  if (window.AppMint && typeof AppMint.saveBlob === 'function') {
    AppMint.saveBlob(blob, 'Site_Report_2026-08.pdf');
    return;
  }
  const url = URL.createObjectURL(blob);               // a normal browser
  const a = document.createElement('a');
  a.href = url;
  a.download = 'Site_Report_2026-08.pdf';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
}
```

A `File` brings its own name, so the name can be left out:

```js
const file = new File(['name,score\n'], 'Scores.csv', { type: 'text/csv' });
AppMint.saveBlob(file);            // saved as Scores.csv
```

**Notes:** The Blob is read into memory and sent to Android in one piece, so very large blobs (hundreds of MB) may run out of memory. The user picks the folder and may cancel; nothing tells the page which one happened.

### `AppMint.setDownloadName`

```js
AppMint.setDownloadName(url, fileName)
```

**Example**

Gives a `blob:` or `data:` URL the file name it should be saved under, before your code (or a library) triggers the download.

**Returns:** nothing. **Needs:** nothing - it is always present in the app.

Useful when a library downloads a URL you built earlier and you cannot pass it a name:

```js
const blob = new Blob([csvText], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
if (window.AppMint && typeof AppMint.setDownloadName === 'function') {
  AppMint.setDownloadName(url, 'Invoice_0042.csv');
}
location.href = url;           // saved as Invoice_0042.csv, not "download.csv"
```

**Notes:** The name is remembered even after `URL.revokeObjectURL(url)`, because some libraries revoke before the save finishes. You rarely need this: `<a download="name">`, `URL.createObjectURL(file)` (a `File` has a name) and FileSaver's `saveAs(blob, name)` are all picked up automatically. The name `'download'` is ignored as "no name".

### `WebToApkFS.maxChunkSize`

```js
WebToApkFS.maxChunkSize()
```

**Example**

Tells you the most bytes one `readBytes` or `writeBytes` call can move (4 MB), so you can size your chunk loop.

**Returns:** (sync) a number of bytes, or `0` when the bridge method is missing. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build (that is what injects `WebToApkFS`).

```js
function copyFile(fromUri, toPath) {
  if (!window.WebToApkFS) return { ok: false, error: 'not in the app' };
  const step = WebToApkFS.maxChunkSize() || 1048576;   // fall back to 1 MB pieces
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(fromUri, offset, step);
    if (!r.ok) return r;
    if (r.bytesRead > 0) {
      const w = WebToApkFS.writeBytes(toPath, r.base64, offset, offset === 0 ? 'truncate' : 'patch');
      if (!w.ok) return w;
    }
    offset += r.bytesRead;
    if (r.eof || r.bytesRead <= 0) return { ok: true, size: offset };
  }
}
```

**Notes:** A smaller chunk keeps the page more responsive (each call is synchronous); the cap only says how big one call may be.

### `WebToApkFS.pickFile`

```js
WebToApkFS.pickFile(mimeFilter)
```

**Example**

Opens the Android file picker so the user chooses ONE file; your page may then read and write that file's real bytes, even after the app restarts.

**Returns:** `Promise<{ok, uri, name, size, mime, error}>`. On success `ok` is `true` and `uri` is a `content://` address you pass to `stat`, `readBytes`, `writeBytes`. On failure `ok` is `false` and `error` is `'cancelled'`, `'disabled'`, `'no-picker'`, `'busy'` (a picker is already open for an earlier call, which keeps its own answer), `'foreign_origin'` or `'unsupported'` (no app). **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

```js
async function openOneFile() {
  if (!window.WebToApkFS) { alert('Open this page in the app to pick a file.'); return; }
  const f = await WebToApkFS.pickFile('application/pdf');   // '' = any file, 'image/*' = pictures
  if (!f.ok) {
    if (f.error !== 'cancelled') alert('Cannot open a file: ' + f.error);
    return;
  }
  console.log(f.name, f.size + ' bytes', f.mime);
  localStorage.setItem('lastFile', f.uri);   // the grant survives a restart
}
```

Use the saved `uri` again later, without asking the user a second time:

```js
const uri = localStorage.getItem('lastFile');
if (uri && window.WebToApkFS) {
  const info = WebToApkFS.stat(uri);
  if (info.ok) console.log('still here:', info.name);
  else console.log('file is gone or access was removed:', info.error);
}
```

**Notes:** The filter is one MIME type pattern (`'image/*'`, `'text/csv'`), not a list. The picker asks for read AND write access and keeps it. Android limits how many files an app may keep this way, so call `WebToApkFS.releaseFile(uri)` when you are done with a file. For a whole folder use `WebToApkFS.requestAccess()` instead. `WebToApkFS` is installed when Native Folder Access or Secure Keys is ticked; its calls work only with Native Folder Access (`WebToApkFS.isAvailable()`).

### `WebToApkFS.readAll`

```js
WebToApkFS.readAll(pathOrUri)
```

**Example**

Reads a whole file into one base64 string. It loops over `readBytes` for you, 4 MB at a time.

**Returns:** (sync) `{ok:true, base64, size}`, or the first failing chunk's `{ok:false, error}`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Load a picked image into an `<img>`:

```js
async function showPicture() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('image/*');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);
  if (!r.ok) { alert('Read failed: ' + r.error); return; }
  document.getElementById('photo').src = 'data:' + f.mime + ';base64,' + r.base64;
}
```

Read a UTF-8 text file (works for any language, not only English):

```js
function readUtf8(pathOrUri) {
  const r = WebToApkFS.readAll(pathOrUri);
  if (!r.ok) throw new Error(r.error);
  const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
  return new TextDecoder('utf-8').decode(bytes);
}
```

**Notes:** The whole file ends up in memory (as bytes and then as base64), and the call blocks the page while it runs. Use it for files that easily fit in memory; for large files loop `WebToApkFS.readBytes` and handle each chunk. For plain text inside the granted folder, `WebToApkFS.readText(path)` is simpler.

### `WebToApkFS.readBytes`

```js
WebToApkFS.readBytes(pathOrUri, offset, length)
```

**Example**

Reads the real bytes of a file, starting at a byte position, as base64. Safe for images, PDFs, ZIPs - nothing is changed by text decoding.

**Returns:** (sync) `{ok:true, base64, bytesRead, offset, size, eof}` or `{ok:false, error}` (`'not-found'`, `'disabled'`, `'cannot-open'`, `'offset past end of file (N bytes)'`, `'foreign_origin'`, `'unsupported'`). **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Read the first 64 KB, for example to check a file header:

```js
async function checkPng() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('image/*');
  if (!f.ok) return;
  const r = WebToApkFS.readBytes(f.uri, 0, 65536);
  if (!r.ok) { alert('Read failed: ' + r.error); return; }
  const head = atob(r.base64).slice(1, 4);
  console.log(head === 'PNG' ? 'a PNG' : 'not a PNG', '- file is', r.size, 'bytes');
}
```

Read a big file piece by piece, so it never has to fit in memory at once:

```js
function eachChunk(uri, onChunk) {
  const step = WebToApkFS.maxChunkSize() || 1048576;
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(uri, offset, step);
    if (!r.ok) return r;                 // stop on the first error
    onChunk(r.base64, offset);
    offset += r.bytesRead;
    if (r.eof || r.bytesRead <= 0) return { ok: true, size: offset };
  }
}
```

**Notes:** One call reads at most `WebToApkFS.maxChunkSize()` bytes (4 MB); `length` 0 means "as much as one call allows". Loop on `bytesRead` and stop on `eof`. The call is synchronous, so keep chunks moderate on the UI thread. For small files `WebToApkFS.readAll(uri)` does the loop for you.

### `WebToApkFS.readSealed`

```js
WebToApkFS.readSealed(pathOrUri, alias)
```

**Example**

Reads a file written by `WebToApkFS.writeSealed` and decrypts it with the same keystore key.

**Returns:** (sync) `{ok:true, base64}` (the original bytes), or `{ok:false, error}` - `'keystore-disabled'`, `'corrupt-sealed-file'`, a keystore code (`'no-such-key'` after the key was deleted, `'auth-required'`, `'key-invalidated'`) or a file error (`'not-found'` …). **Needs:** tick **Native Folder Access (SAF)** and **Secure Keys (encryption)** in Step 4 (Access) when you build.

```js
function loadSecret() {
  if (!window.WebToApkFS || !window.AppMintKeystore || !AppMintKeystore.isAvailable()) return null;
  const out = WebToApkFS.readSealed('secret.bin', 'vault');
  if (out.ok) {
    const bytes = Uint8Array.from(atob(out.base64), function (c) { return c.charCodeAt(0); });
    return new TextDecoder().decode(bytes);
  }
  if (out.error === 'auth-required') {
    alert('Unlock your phone, then try again.');
  } else if (out.error === 'no-such-key') {
    alert('This note was deleted.');
  } else {
    alert('Could not open the note: ' + out.error);
  }
  return null;
}
```

**Notes:** It fails for good once `AppMintKeystore.deleteKey(alias)` has run - that is the point. The whole file is read and decrypted in memory, synchronously; keep sealed files small (notes, keys, tokens).

### `WebToApkFS.releaseFile`

```js
WebToApkFS.releaseFile(uri)
```

**Example**

Gives back the long-term access your app got when the user picked a file with `WebToApkFS.pickFile`.

**Returns:** (sync) `true` when the grant was released, `false` otherwise (no grant, bad URI, not in the app). **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

```js
async function importOnce() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/csv');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);
  if (r.ok) importCsv(atob(r.base64));
  WebToApkFS.releaseFile(f.uri);       // done with it: do not keep the grant
}
```

**Notes:** Android caps how many picked files one app may keep access to. An app that picks many files (an importer, a viewer) should release each one when finished. After release, `stat` / `readBytes` / `writeBytes` on that URI answer `not-found`. It does not delete the file.

### `WebToApkFS.stat`

```js
WebToApkFS.stat(pathOrUri)
```

**Example**

Tells you a file's name, size, type and date without reading its contents.

**Returns:** (sync) `{ok:true, name, size, mime, lastModified, uri, isDirectory, canWrite}`, or `{ok:false, error}` with `error` = `'not-found'`, `'disabled'`, `'foreign_origin'` or `'unsupported'`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

```js
async function showInfo() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('');
  if (!f.ok) return;
  const info = WebToApkFS.stat(f.uri);
  if (!info.ok) { alert('Cannot read file info: ' + info.error); return; }
  console.log(info.name, info.size + ' bytes', info.mime,
              new Date(info.lastModified).toLocaleString(),
              info.canWrite ? 'writable' : 'read only');
}
```

A path inside the folder the user granted with `WebToApkFS.requestAccess()` works too:

```js
const info = WebToApkFS.stat('photos/cover.jpg');
if (info.ok && !info.isDirectory) console.log('cover is', info.size, 'bytes');
```

**Notes:** `pathOrUri` is a `content://` URI from `pickFile()` or a path relative to the granted folder. A URI your app was never given returns `not-found`. `lastModified` is milliseconds since 1970 (0 when the provider does not say).

### `WebToApkFS.writeAll`

```js
WebToApkFS.writeAll(pathOrUri, base64)
```

**Example**

Replaces a file's whole contents with your bytes. It splits the data into 4 MB pieces for you: the first piece truncates the file, the rest are patched in after it.

**Returns:** (sync) `{ok:true, bytesWritten}`, or the first failing piece's `{ok:false, error}`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Save UTF-8 text (any language) back to a file the user picked:

```js
function bytesToBase64(bytes) {
  let s = '';
  for (let i = 0; i < bytes.length; i += 0x8000) {
    s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  }
  return btoa(s);
}

async function saveNote(text) {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/plain');
  if (!f.ok) return;
  const r = WebToApkFS.writeAll(f.uri, bytesToBase64(new TextEncoder().encode(text)));
  if (r.ok) console.log('saved', r.bytesWritten, 'bytes');
  else alert('Could not save: ' + r.error);
}
```

Write a canvas image into the granted folder (the file is created if missing):

```js
const b64 = document.querySelector('canvas').toDataURL('image/png').split(',')[1];
const r = WebToApkFS.writeAll('drawings/sketch.png', b64);
if (!r.ok) alert('Could not save: ' + r.error);
```

**Notes:** The whole input sits in memory while it is written, and the call blocks the page. If a later piece fails, the file already holds the earlier pieces - check `ok` and tell the user. To keep a file secret, use `WebToApkFS.writeSealed` instead.

### `WebToApkFS.writeBytes`

```js
WebToApkFS.writeBytes(pathOrUri, base64, offset, mode)
```

**Example**

Writes real bytes into a file and tells you how many landed. It can patch bytes in the middle, replace the whole file, or append to the end.

**Returns:** (sync) `{ok, bytesWritten, offset, size}` - `size` is read back from the file after the data is flushed to disk - or `{ok:false, error}`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

**Patch** - change 4 bytes at position 100, keep the rest of the file:

```js
const r = WebToApkFS.writeBytes(fileUri, btoa('ABCD'), 100, 'patch');
if (r.ok) console.log('file is now', r.size, 'bytes');
else alert('Write failed: ' + r.error);
```

**Truncate** - replace the whole file (offset is ignored):

```js
const csv = 'name,score\nAsha,12\n';
const r = WebToApkFS.writeBytes('exports/scores.csv', btoa(csv), 0, 'truncate');
if (!r.ok) alert('Could not save: ' + r.error);
```

**Append** - add to the end (offset is ignored):

```js
const line = new Date().toISOString() + ' opened\n';
WebToApkFS.writeBytes('log.txt', btoa(line), 0, 'append');
```

**Notes:** `mode` is `'patch'` (default), `'truncate'` or `'append'`. At most 4 MB per call (`WebToApkFS.maxChunkSize()`); for bigger data use `WebToApkFS.writeAll`. A path inside the granted folder is created if it does not exist; a picked `content://` URI must be one the user chose. `btoa` only accepts Latin-1 text - for other text encode to bytes first (see `WebToApkFS.writeAll`). Errors: `disabled`, `foreign_origin` (the page on screen is not the app's own), `bad mode: x`, `bad base64`, `chunk too large (max N bytes)`, `not-found`, `cannot-open`, `short write`.

### `WebToApkFS.writeSealed`

```js
WebToApkFS.writeSealed(pathOrUri, base64, alias)
```

**Example**

Writes a file encrypted with a hardware-backed key from `AppMintKeystore`. Deleting that key later makes the file unreadable for everyone - the only real "secure delete" on a phone.

**Returns:** (sync) `{ok:true, bytesWritten}` (encrypted size on disk), or `{ok:false, error}` - `'keystore-disabled'`, a keystore code such as `'no-such-key'`, `'auth-required'`, `'key-invalidated'`, or a file error. **Needs:** tick **Native Folder Access (SAF)** and **Secure Keys (encryption)** in Step 4 (Access) when you build.

```js
function saveSecret(text) {
  if (!window.WebToApkFS || !window.AppMintKeystore || !AppMintKeystore.isAvailable()) {
    alert('Secure storage is not available in this build.');
    return false;
  }
  if (!AppMintKeystore.hasKey('vault')) {
    const k = AppMintKeystore.generateKey('vault', {});
    if (!k.ok) { alert('Could not create key: ' + k.error); return false; }
  }
  const bytes = new TextEncoder().encode(text);
  let bin = '';
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
  const r = WebToApkFS.writeSealed('secret.bin', btoa(bin), 'vault');
  if (!r.ok) alert('Could not save: ' + r.error);
  return r.ok;
}
```

Delete it for real - destroy the key, then remove the file:

```js
AppMintKeystore.deleteKey('vault');   // secret.bin can never be decrypted again
WebToApkFS.delete('secret.bin');
```

**Notes:** Do not "overwrite with zeros" to erase: phone flash storage keeps the old blocks. The file format is a list of length-prefixed AES-GCM pieces; read it back only with `WebToApkFS.readSealed` and the same alias. A key created with `{requireAuth:true}` answers `auth-required` until the user has unlocked recently.

### `downloads`

```js
<a download> and Blob downloads
```

**Example**

Your normal web download code works in the app. A WebView ignores `blob:` and `data:` downloads on its own; the app catches them and opens Android's "Save as…" sheet with **your** file name.

**Returns:** nothing to the page (the user picks a folder or cancels). **Needs:** nothing to switch on. Web-address downloads (`https://…`) go to the Downloads folder (a PDF or image first asks "View or Download"); on Android 9 and older the app asks for storage permission first.

`<a download>` with a Blob - the most common pattern:

```html
<button id="save">Download report</button>
<script>
document.getElementById('save').addEventListener('click', function () {
  const blob = new Blob(['Name,Total\nAsha,120\n'], { type: 'text/csv' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'Report_2026-08.csv';     // this is the name the user sees
  document.body.appendChild(a);
  a.click();                             // caught by the app, "Save as…" opens
  a.remove();
});
</script>
```

A `data:` URL, FileSaver.js, and `navigator.msSaveBlob` also work:

```js
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'Drawing.png';
a.click();

if (typeof saveAs === 'function') saveAs(blob, 'Backup.json');   // FileSaver.js
if (navigator.msSaveBlob) navigator.msSaveBlob(blob, 'Backup.json');
```

`window.open` of a `blob:` or `data:` URL means "show it", not "save it": a PDF opens in the app's PDF viewer, other types in the phone's app for that type:

```js
const pdfUrl = URL.createObjectURL(pdfBlob);
const w = window.open(pdfUrl);    // never null in the app, so no "popup blocked" message
```

**Notes:** Without a name the file is saved as `download.<ext>`. A `File` object brings its own name (`URL.createObjectURL(file)`). If no app on the phone can show a `window.open` file, the "Save as…" sheet opens instead. To save without an `<a>` element, use `AppMint.saveBlob(blob, name)` or `AppMint.downloadFile(base64, name, mime)`.

### `input-file`

```js
<input type="file"> — camera, photos and files
```

**Example**

A normal file input works in the app like in Chrome: it opens the photo picker, the camera, or the file picker, and your page gets ordinary `File` objects.

**Returns:** the input's `change` event with `input.files` (empty when the user cancels). **Needs:** photos and files need nothing. For `capture` (the camera) tick **Camera** in Step 4 (Access) when you build; the app asks for the camera permission the first time.

Photos - opens the Android photo picker (no permission prompt; the page gets only what the user chose):

```html
<input type="file" id="pics" accept="image/*" multiple>
<div id="preview"></div>
<script>
document.getElementById('pics').addEventListener('change', function (e) {
  const box = document.getElementById('preview');
  for (let i = 0; i < e.target.files.length; i++) {
    const img = document.createElement('img');
    img.src = URL.createObjectURL(e.target.files[i]);
    img.style.width = '100px';
    box.appendChild(img);
  }
});
</script>
```

Camera - `capture` skips the picker and opens the camera straight away:

```html
<input type="file" id="snap" accept="image/*" capture="environment">
<input type="file" id="clip" accept="video/*" capture="environment">
<script>
document.getElementById('snap').addEventListener('change', function (e) {
  const photo = e.target.files[0];
  if (!photo) return;                        // user closed the camera
  const form = new FormData();
  form.append('photo', photo);
  fetch('/api/upload', { method: 'POST', body: form });
});
</script>
```

Any file, or several types (extensions work too):

```html
<input type="file" accept=".pdf,.csv,application/json" multiple>
```

| You write | What opens |
|---|---|
| `accept="image/*"` / `"video/*"` / `"image/*,video/*"` | the photo picker (photos, videos, or both) |
| add `multiple` | the user can pick several |
| add `capture` (with **Camera** ticked) | the camera directly - photo for `image/*`, video for `video/*` |
| `accept="image/*,.pdf"` or no `accept` | the system file picker |

**Notes:** Without **Camera** ticked, `capture` is ignored and the picker opens. When **Native Folder Access (SAF)** is ticked, the app shows its own "Select Upload Source" menu instead: Take Photo / Record Video (if Camera is ticked), Choose Files, and Upload Folder (sends every file in a folder). With **Storage** ticked on Android 12 and older, choosing a non-photo file may ask for storage permission first. Phones without the photo picker get the file picker, which shows the same photos.

### `showOpenFilePicker`

```js
showOpenFilePicker / showSaveFilePicker / showDirectoryPicker — File System Access
```

**Example**

The standard File System Access API works inside the app, over Android's own document picker: open a file and save back INTO THAT FILE, create a file with a Save-as sheet, or get a whole folder to read, create and delete files in.

**Returns:** `showOpenFilePicker(options)` → Promise of an array of `FileSystemFileHandle`; `showSaveFilePicker(options)` → one `FileSystemFileHandle`; `showDirectoryPicker(options)` → one `FileSystemDirectoryHandle`. A cancelled picker rejects `AbortError`; no user gesture rejects `SecurityError`; a second picker while one is open rejects `NotAllowedError`. **Needs:** nothing to switch on and no permission - the user picks.

```js
let handle = null;

document.getElementById('open').addEventListener('click', async () => {
  try {
    [handle] = await showOpenFilePicker({
      types: [{ description: 'Text', accept: { 'text/plain': ['.txt', '.md'] } }]
    });
    const file = await handle.getFile();
    editor.value = await file.text();
    title.textContent = file.name;
  } catch (e) {
    if (e.name !== 'AbortError') alert('Could not open: ' + e.message);
  }
});

document.getElementById('save').addEventListener('click', async () => {
  if (!handle) handle = await showSaveFilePicker({ suggestedName: 'note.txt' });
  const w = await handle.createWritable();   // writes go to a copy…
  await w.write(editor.value);
  await w.close();                           // …that replaces the file only here
});

document.getElementById('export').addEventListener('click', async () => {
  const dir = await showDirectoryPicker({ mode: 'readwrite' });
  const out = await dir.getFileHandle('export.csv', { create: true });
  const w = await out.createWritable();
  await w.write(csvText);
  await w.close();
  for await (const [name, entry] of dir.entries()) console.log(entry.kind, name);
});
```

**Notes:** `createWritable()` follows the spec's swap-file model - `abort()` (or the page going away) leaves the original untouched; `{ keepExistingData: true }` starts from the current content; `write({type:'seek'|'truncate'|'write', …})`, `seek()` and `truncate()` work. A file its app offers read-only answers `queryPermission({mode:'readwrite'})` with `'denied'` and `createWritable()` rejects `NoModificationAllowedError`. `getFile()` streams the document, so large files are fine. Handles last as long as the page - they cannot be stored in IndexedDB and reused after a restart. `types` become the picker's file filter. Only the top-level page has the pickers. Internal transports: `__fsPick` (answered with the `appmint:fs` event), `__fsFile`, `__fsList`, `__fsChild`, `__fsRemove`, `__fsWritableOpen`/`Write`/`Truncate`/`Close`/`Abort`. AI-built apps check `can('fileHandles')`.

### `__fsChild`

```js
window.WebToApk.__fsChild(uri: String, name: String, kind: String, create: Boolean): String
```

getFileHandle / getDirectoryHandle (with `create`).

**Example**

Internal transport behind `getFileHandle(name, {create})` and `getDirectoryHandle(name, {create})`. Pages use the standard API; never call this directly - the `__` methods may change without notice.

**Returns:** JSON `{ok, kind, name, uri, canWrite}` or `{ok:false, error}` (`NotFoundError`, `TypeMismatchError`, `NoModificationAllowedError`). **Needs:** a folder from `showDirectoryPicker()`.

**Use the public API:**

```js
const dir = await showDirectoryPicker();
const photos = await dir.getDirectoryHandle('photos', { create: true });
const file = await photos.getFileHandle('list.json', { create: true });
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsFile`

```js
window.WebToApk.__fsFile(uri: String): String
```

getFile(): `{ok, name, type, size, lastModified, url}` (the body streams from url).

**Example**

Internal transport behind `FileSystemFileHandle.getFile()`. Pages use the standard API; never call this directly - the `__` methods may change without notice.

**Returns:** JSON `{ok, name, type, size, lastModified, url}` - the body streams from `url` (the app's own origin, with ranges) - or `{ok:false, error}` (`NotFoundError`, `TypeMismatchError`, `foreign_origin`). **Needs:** a handle from one of the pickers.

**Use the public API:**

```js
const [handle] = await showOpenFilePicker();
const file = await handle.getFile();
console.log(file.name, file.size, await file.text());
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsList`

```js
window.WebToApk.__fsList(uri: String): String
```

A directory handle's entries: `{ok, entries:[{kind, name, uri, canWrite}]}`.

**Example**

Internal transport behind iterating a `FileSystemDirectoryHandle` (`entries()`, `keys()`, `values()`, `for await`). Pages use the standard API; never call this directly - the `__` methods may change without notice.

**Returns:** JSON `{ok, entries:[{kind, name, uri, canWrite}]}` or `{ok:false, error}`. **Needs:** a folder from `showDirectoryPicker()`.

**Use the public API:**

```js
const dir = await showDirectoryPicker();
for await (const [name, entry] of dir.entries()) console.log(entry.kind, name);
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsPick`

```js
window.WebToApk.__fsPick(requestId: String, kind: String, optionsJson: String): String
```

Opens the [kind] picker (open | save | directory); handles arrive as `appmint:fs`.

**Example**

Internal transport behind `showOpenFilePicker()`, `showSaveFilePicker()` and `showDirectoryPicker()`. Pages use the standard API; never call this directly - the `__` methods may change without notice.

**Returns:** `{ok:true}` once the picker is on screen, or `{ok:false, error}` (`NotAllowedError` when one is already open, `foreign_origin`). The handles arrive as the `appmint:fs` event `{requestId, handles:[{kind, name, uri, canWrite}]}` or `{requestId, error:'cancelled'}`. **Needs:** nothing to switch on.

**Use the public API:**

```js
const [handle] = await showOpenFilePicker({ types: [{ accept: { 'image/*': ['.png', '.jpg'] } }] });
const file = await handle.getFile();
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt). The same page code works unchanged in desktop Chrome.

### `__fsRemove`

```js
window.WebToApk.__fsRemove(uri: String, name: String, recursive: Boolean): String
```

removeEntry(name, {recursive}).

**Example**

Internal transport behind `FileSystemDirectoryHandle.removeEntry(name, {recursive})`. Pages use the standard API; never call this directly - the `__` methods may change without notice.

**Returns:** JSON `{ok:true}` or `{ok:false, error}` (`NotFoundError`, `InvalidModificationError` for a non-empty folder without `recursive`). **Needs:** a folder from `showDirectoryPicker()`.

**Use the public API:**

```js
const dir = await showDirectoryPicker();
await dir.removeEntry('old-drafts', { recursive: true });
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsWritableAbort`

```js
window.WebToApk.__fsWritableAbort(token: String)
```

abort(): the swap is dropped; the document is untouched.

**Example**

Internal transport behind `FileSystemWritableFileStream.abort()` - the swap copy is dropped and the file stays as it was. Pages use the standard API; never call this directly - the `__` methods may change without notice.

**Returns:** nothing. **Needs:** an open writable stream.

**Use the public API:**

```js
const w = await handle.createWritable();
try { await w.write(await buildExport()); await w.close(); }
catch (e) { await w.abort(); }
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsWritableClose`

```js
window.WebToApk.__fsWritableClose(token: String): String
```

close(): the swap replaces the document.

**Example**

Internal transport behind `FileSystemWritableFileStream.close()` - the swap copy replaces the file. Pages use the standard API; never call this directly - the `__` methods may change without notice.

**Returns:** JSON `{ok:true}` or `{ok:false, error}` (`NoModificationAllowedError`, `InvalidModificationError` when the file's app cannot shorten it). **Needs:** an open writable stream.

**Use the public API:**

```js
const w = await handle.createWritable();
await w.write(json);
await w.close();   // nothing changes on disk before this
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsWritableOpen`

```js
window.WebToApk.__fsWritableOpen(uri: String, keepExisting: Boolean): String
```

createWritable(): a swap file, copied from the file when [keepExisting].

**Example**

Internal transport behind `FileSystemFileHandle.createWritable({keepExistingData})`. Pages use the standard API; never call this directly - the `__` methods may change without notice.

**Returns:** JSON `{ok, token}` for a private swap copy, or `{ok:false, error}` (`NoModificationAllowedError` for a read-only file). **Needs:** a handle from one of the pickers.

**Use the public API:**

```js
const handle = await showSaveFilePicker({ suggestedName: 'report.csv' });
const w = await handle.createWritable({ keepExistingData: false });
await w.write('a,b\n1,2\n');
await w.close();
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsWritableTruncate`

```js
window.WebToApk.__fsWritableTruncate(token: String, size: Double): String
```

truncate(size) on a writable stream.

**Example**

Internal transport behind `FileSystemWritableFileStream.truncate(size)`. Pages use the standard API; never call this directly - the `__` methods may change without notice.

**Returns:** JSON `{ok:true}` or `{ok:false, error}`. **Needs:** an open writable stream.

**Use the public API:**

```js
const w = await handle.createWritable({ keepExistingData: true });
await w.truncate(0);
await w.write('fresh start');
await w.close();
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsWritableWrite`

```js
window.WebToApk.__fsWritableWrite(token: String, position: Double, base64: String): String
```

One chunk of a writable stream's write(), at [position].

**Example**

Internal transport behind `FileSystemWritableFileStream.write()` - one chunk at a position in the swap copy. Pages use the standard API; never call this directly - the `__` methods may change without notice.

**Returns:** JSON `{ok:true}` or `{ok:false, error}`. **Needs:** an open writable stream.

**Use the public API:**

```js
const w = await handle.createWritable({ keepExistingData: true });
await w.write({ type: 'write', position: 0, data: new Blob(['HEADER\n']) });
await w.close();
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt). Large data is sent in chunks.

### `getMaxIoChunkSize`

```js
window.WebToApk.getMaxIoChunkSize(): Long
```

The per-call byte cap for [readFileBase64] / [writeFileBase64], for chunk maths.

**Example**

The per-call byte limit of `readFileBase64` / `writeFileBase64` (4 MB). Pages normally use `WebToApkFS.maxChunkSize()`.

**Returns:** (sync) a number (4194304). **Needs:** nothing to call it; the read/write methods it describes need **Native Folder Access (SAF)** ticked in Step 4 (Access).

The public way:

```js
const step = window.WebToApkFS ? WebToApkFS.maxChunkSize() : 0;
```

The raw call:

```js
let step = 1048576;
if (window.WebToApk && typeof WebToApk.getMaxIoChunkSize === 'function') {
  step = Number(WebToApk.getMaxIoChunkSize()) || step;
}
console.log('read/write up to', step, 'bytes per call');
```

**Notes:** A write larger than this is refused with `chunk too large (max N bytes)`; a larger read is cut to this size.

### `pickFile`

```js
window.WebToApk.pickFile(requestId: String, mimeFilter: String)
```

Opens the system single-file picker (ACTION_OPEN_DOCUMENT). Unlike [requestFolderAccess] this grants access to exactly one file, which is what an editor or a shredder should ask for; the read/write grant is persisted so the URI keeps working after the process is killed. The answer arrives on `WebToApkOnFilePickResult(success, fileJson, requestId)`; a failure's `error` is "cancelled", "disabled", "no-picker", "foreign_origin", or "busy" (a picker is already open for an earlier request, which keeps its own answer). @param mimeFilter e.g. "image/\*", "application/pdf", or "" for any file. (The star is backslash-escaped only because Kotlin block comments nest.)

**Example**

Opens the system picker for ONE file. Pages normally use the Promise helper `WebToApkFS.pickFile(mimeFilter)`; this raw method answers through a callback.

**Returns:** nothing now. Later the shell calls `window.WebToApkOnFilePickResult(success, fileJson, requestId)`, where `fileJson` is a JSON **string** `{ok, uri, name, size, mime}` on success or `{ok:false, uri:'', error}` with `error` = `'cancelled'`, `'disabled'`, `'no-picker'`, `'busy'` or `'foreign_origin'`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

The public way (recommended):

```js
const f = await WebToApkFS.pickFile('image/*');
if (f.ok) console.log('picked', f.name, f.uri);
```

The raw call, matching on your own `requestId`:

```js
function pickRaw(mime) {
  return new Promise(function (resolve) {
    if (!window.WebToApk || typeof WebToApk.pickFile !== 'function') { resolve(null); return; }
    const myId = 'pick_' + Date.now();
    window.WebToApkOnFilePickResult = function (success, fileJson, requestId) {
      if (requestId !== myId) return;
      const r = JSON.parse(fileJson);           // fileJson is a string
      resolve(success && r.ok ? r : null);      // null = cancelled or not allowed
    };
    WebToApk.pickFile(myId, mime || '');        // '' = any file
  });
}
```

**Notes:** Only one pick can be open at a time: a call made while one is open is answered at once with `error:'busy'`, and the open one keeps its own answer. The grant is kept (read and write) until you call `releaseFileAccess(uri)`. The file is not read here - use `readFileBase64` / `WebToApkFS.readBytes` with the `uri`.

### `readFileBase64`

```js
window.WebToApk.readFileBase64(pathOrUri: String, offset: Long, length: Long): String
```

Reads up to [length] raw bytes starting at [offset] and returns them base64. @param pathOrUri a `content://` URI from [pickFile], or a path relative to the folder granted by [requestFolderAccess]. @param length bytes to read; clamped to 4 MB per call. 0 or negative means "as much as one call allows". @return JSON `{ok, base64, bytesRead, offset, size, eof, error}`. `eof` tells a chunking loop when to stop without having to compare against `size` itself.

**Example**

Reads raw bytes from a file at a byte offset and returns them as base64. Pages normally use `WebToApkFS.readBytes(pathOrUri, offset, length)` (same result, already parsed) or `WebToApkFS.readAll(pathOrUri)`.

**Returns:** (sync) a JSON **string** `{ok:true, base64, bytesRead, offset, size, eof}` or `{ok:false, error}`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

The public way:

```js
const r = WebToApkFS.readBytes(fileUri, 0, 65536);
if (r.ok) console.log(r.bytesRead, 'bytes read, end of file:', r.eof);
```

The raw call - note the result is a string you must `JSON.parse`, not bare base64:

```js
if (window.WebToApk && typeof WebToApk.readFileBase64 === 'function') {
  const r = JSON.parse(WebToApk.readFileBase64(fileUri, 0, 0));   // length 0 = up to 4 MB
  if (r.ok) {
    const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
    console.log('first byte', bytes[0], 'of', r.size);
  } else {
    console.log('read failed:', r.error);
  }
}
```

**Notes:** `pathOrUri` is a `content://` URI from `pickFile` or a path relative to the folder from `requestFolderAccess`. At most 4 MB per call (`getMaxIoChunkSize()`); loop on `bytesRead` until `eof`. Errors: `not-found`, `disabled`, `cannot-open`, `offset must be >= 0`, `offset past end of file (N bytes)`, `foreign_origin` (a page from a site other than the app's own).

### `releaseFileAccess`

```js
window.WebToApk.releaseFileAccess(uriString: String): String
```

Releases a persisted single-file grant taken by [pickFile]. Android caps how many URI grants an app may persist, so an app that picks many files should release the ones it is done with.

**Example**

Releases the kept read/write grant for a file chosen with `pickFile`. Pages normally use `WebToApkFS.releaseFile(uri)`, which returns a real boolean.

**Returns:** (sync) the **string** `"true"` or `"false"`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build (always `"false"` otherwise).

The public way:

```js
if (window.WebToApkFS) WebToApkFS.releaseFile(fileUri);
```

The raw call:

```js
if (window.WebToApk && typeof WebToApk.releaseFileAccess === 'function') {
  const released = WebToApk.releaseFileAccess(fileUri) === 'true';   // a string, not a boolean
  console.log(released ? 'access released' : 'nothing to release');
}
```

**Notes:** Android limits how many file grants an app may keep, so release files you no longer need. The file itself is not touched.

### `statFile`

```js
window.WebToApk.statFile(pathOrUri: String): String
```

File or folder metadata without reading any of it. A relative path names a file or a sub-folder of the granted folder; a folder answers `isDirectory: true` with `size` 0 and `mime` `vnd.android.document/directory`. @return JSON `{ok, name, size, mime, lastModified, uri, isDirectory, canWrite}`, or `{ok:false, error}` with `not-found`, `disabled` or `foreign_origin`.

**Example**

Reads a file's metadata (name, size, type) without reading its bytes. Pages normally use `WebToApkFS.stat(pathOrUri)`, which parses the JSON for you.

**Returns:** (sync) a JSON **string** `{ok, name, size, mime, lastModified, uri, isDirectory, canWrite}`, or `{ok:false, error}` (`'not-found'`, `'disabled'`, `'foreign_origin'`). **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

The public way:

```js
const info = WebToApkFS.stat(fileUri);
if (info.ok) console.log(info.name, info.size);
```

The raw call:

```js
if (window.WebToApk && typeof WebToApk.statFile === 'function') {
  const info = JSON.parse(WebToApk.statFile(fileUri));   // or 'notes/today.txt' in the granted folder
  if (info.ok) console.log(info.name, info.size + ' bytes', info.mime, info.canWrite);
  else console.log('stat failed:', info.error);
}
```

**Notes:** Accepts a `content://` URI from `pickFile` or a path relative to the folder granted with `requestFolderAccess`. It only answers the app's own pages or its configured website; a page from any other site gets `foreign_origin`.

### `writeFileBase64`

```js
window.WebToApk.writeFileBase64(pathOrUri: String, base64: String, offset: Long, mode: String): String
```

Writes raw bytes and confirms how many landed. @param mode `"patch"` writes at [offset] leaving the rest of the file intact (what a multi-pass overwrite needs), `"truncate"` replaces the file, `"append"` ignores [offset] and writes at the end. @return JSON `{ok, bytesWritten, offset, size, error}` - `size` is re-read from the file after an fsync, so a caller can verify the write for real rather than trust a boolean.

**Example**

Writes base64 bytes into a file at an offset (`patch`), over the whole file (`truncate`), or at the end (`append`). Pages normally use `WebToApkFS.writeBytes(pathOrUri, base64, offset, mode)` or `WebToApkFS.writeAll(pathOrUri, base64)`.

**Returns:** (sync) a JSON **string** `{ok, bytesWritten, offset, size}` (`size` re-read after an fsync) or `{ok:false, error}`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

The public way:

```js
const r = WebToApkFS.writeBytes(fileUri, btoa('hello'), 0, 'truncate');
if (r.ok) console.log('saved', r.bytesWritten, 'bytes');
```

The raw call:

```js
if (window.WebToApk && typeof WebToApk.writeFileBase64 === 'function') {
  const r = JSON.parse(WebToApk.writeFileBase64('notes/today.txt', btoa('Buy milk\n'), 0, 'append'));
  if (!r.ok) console.log('write failed:', r.error);
  else console.log('file is now', r.size, 'bytes');
}
```

**Notes:** Mode is `'patch'` (default when empty), `'truncate'` or `'append'`. One call takes at most 4 MB of decoded bytes (`getMaxIoChunkSize()`). A relative path is created inside the granted folder when missing. Errors: `disabled`, `foreign_origin` (the page on screen is not the app's own), `bad mode: x`, `offset must be >= 0`, `bad base64`, `chunk too large (max N bytes)`, `not-found`, `cannot-open`, `short write`.

