Create Free APK

JavaScript bridge API

Files: a folder the user granted

A directory chosen once, readable and writable afterwards, with permission that survives reboots.

WebToApkFS.clearAccess helper#

WebToApkFS.clearAccess()

Example

Forgets the granted folder and gives the permission back to Android.

Returns: nothing (synchronous). Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

Always offer a way to disconnect next to the folder's name:

document.getElementById('disconnect').addEventListener('click', function () {
  if (!window.WebToApkFS) return;
  window.WebToApkFS.clearAccess();
  console.log('still connected?', window.WebToApkFS.hasAccess());   // false
  document.getElementById('folder-name').textContent = 'No folder chosen';
  document.getElementById('choose-folder').hidden = false;
});

One button that connects or disconnects, depending on the current state:

var btn = document.getElementById('folder-toggle');
function paint() {
  btn.textContent = window.WebToApkFS.hasAccess() ? 'Disconnect folder' : 'Connect a folder';
}

btn.addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  if (window.WebToApkFS.hasAccess()) {
    if (!confirm('Stop saving to this folder? Your files stay where they are.')) return;
    window.WebToApkFS.clearAccess();
  } else {
    await window.WebToApkFS.requestAccess();
  }
  paint();
});
if (window.WebToApkFS) paint();

Switch accounts: forget the folder with the rest of the signed-in state:

function switchAccount() {
  sessionStorage.clear();
  if (window.WebToApkFS) window.WebToApkFS.clearAccess();   // the next account picks its own folder
  showLoginScreen();
}

Notes: The files stay where they are; the app simply cannot reach them any more. To use a folder again, call WebToApkFS.requestAccess() from a tap.

WebToApkFS.delete helper#

WebToApkFS.delete(relativePath, options)

Example

Deletes a file or folder inside the granted folder.

Returns: a boolean, synchronously: true when it was deleted. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build, and a granted folder.

Delete one file, after asking:

if (window.WebToApkFS && confirm('Delete notes/old.txt?')) {
  var ok = window.WebToApkFS.delete('notes/old.txt');
  if (!ok) alert('Could not delete the file.');
}

Delete a folder and everything inside it - pass { recursive: true }:

if (window.WebToApkFS && confirm('Delete the whole 2025 folder?')) {
  var gone = window.WebToApkFS.delete('notes/2025', { recursive: true });
  console.log(gone ? 'deleted' : 'not deleted');
}

Clean up exports older than 30 days, file by file:

function pruneOldExports() {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return 0;
  var cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
  var removed = 0;
  window.WebToApkFS.list('exports').forEach(function (e) {
    if (e.isDirectory || e.lastModified >= cutoff) return;
    if (window.WebToApkFS.delete('exports/' + e.name)) removed++;
  });
  return removed;
}

if (confirm('Delete exports older than 30 days?')) {
  document.getElementById('status').textContent = pruneOldExports() + ' old exports deleted';
}

Notes: Without { recursive: true } a folder is deleted only when it is empty. The granted folder itself ('') can never be deleted. Deletion is immediate and there is no trash, so always confirm with the user first.

WebToApkFS.getAccessUri helper#

WebToApkFS.getAccessUri()

Example

Gives the address of the granted folder, so you can show the user where files go.

Returns: a string, synchronously: a tree URI such as content://com.android.externalstorage.documents/tree/primary%3ADocuments%2FNotes, or '' when no folder is granted. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

Show a readable folder name next to a Disconnect button:

function connectedFolderName() {
  if (!window.WebToApkFS) return '';
  var uri = window.WebToApkFS.getAccessUri();
  if (!uri) return '';
  var tail = uri.split('/tree/')[1] || '';
  try { tail = decodeURIComponent(tail); } catch (e) {}
  return tail.split(':').pop() || tail;        // "Documents/Notes"
}

var name = connectedFolderName();
document.getElementById('folder-name').textContent = name ? 'Folder: ' + name : 'No folder chosen';

Notice that the user picked a different folder since the last visit, and rebuild what you cached about the old one:

function checkFolderChanged() {
  if (!window.WebToApkFS) return;
  var uri = window.WebToApkFS.getAccessUri();
  var last = '';
  try { last = localStorage.getItem('folderUri') || ''; } catch (e) {}
  if (uri && uri !== last) {
    rebuildFileIndex();                         // your app: re-read the new folder
    try { localStorage.setItem('folderUri', uri); } catch (e) {}
  }
}

checkFolderChanged();

A Disconnect button that forgets the folder and confirms it is gone:

document.getElementById('disconnect').addEventListener('click', function () {
  if (!window.WebToApkFS || !window.WebToApkFS.getAccessUri()) return;
  if (!confirm('Stop saving to this folder? Your files stay where they are.')) return;
  window.WebToApkFS.clearAccess();
  var still = window.WebToApkFS.getAccessUri();
  document.getElementById('folder-name').textContent = still ? 'Could not disconnect' : 'No folder chosen';
});

Notes: The file methods do not take this URI - they take paths relative to the granted folder ('notes/today.txt'). The URI format belongs to the Files provider the user picked from, so only use it for display.

WebToApkFS.hasAccess helper#

WebToApkFS.hasAccess()

Example

Tells you whether the user has already given the app a folder.

Returns: a boolean, synchronously. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

Show "Choose folder" or the file list, depending on the answer:

function renderFolderScreen() {
  if (!window.WebToApkFS) {
    document.getElementById('folder-panel').hidden = true;   // not in the app, or not enabled
    return;
  }
  var connected = window.WebToApkFS.hasAccess();
  document.getElementById('choose-folder').hidden = connected;
  document.getElementById('file-list').hidden = !connected;
  if (connected) {
    var entries = window.WebToApkFS.list('');
    document.getElementById('file-list').textContent =
      entries.map(function (e) { return e.name; }).join('\n');
  }
}

Autosave only while a folder is connected, and say so when it is not:

var saveTimer = null;
document.getElementById('editor').addEventListener('input', function () {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(function () {
    var status = document.getElementById('status');
    if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) {
      status.textContent = 'Choose a folder to turn on autosave';
      return;
    }
    var ok = window.WebToApkFS.writeText('notes/draft.txt', document.getElementById('editor').value);
    status.textContent = ok ? 'Saved' : 'Could not save';
  }, 800);
});

On start-up, reopen the last note when a folder is remembered, otherwise show the welcome screen:

window.addEventListener('load', function () {
  var fs = window.WebToApkFS;
  if (!fs || !fs.hasAccess()) {
    showWelcome();                                 // your app: explains "Choose folder"
    return;
  }
  var last = 'notes/today.txt';
  try { last = localStorage.getItem('lastNote') || last; } catch (e) {}
  document.getElementById('editor').value = fs.readText(last);
});

Notes: It only says the app remembers a folder. If the user deleted or moved that folder, list('') returns [] - offer "Choose folder" again. window.WebToApkFS appears when the page finishes loading; during load use the raw window.WebToApk.hasFolderAccess() === 'true'.

WebToApkFS.isAvailable helper#

WebToApkFS.isAvailable()

Example

Tells you whether this app can use a user-granted folder at all.

Returns: a boolean, synchronously: true when the build has folder access switched on. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build - without it (and without Secure Keys) window.WebToApkFS is not added to the page; with only Secure Keys it exists and answers false.

window.WebToApkFS is added before your page's first script runs; on WebViews older than about 2021 it arrives only when the page finishes loading. To cover those too, wait for it briefly before you decide:

function whenFolderHelperReady(done) {
  var tries = 0;
  (function check() {
    if (window.WebToApkFS) return done(window.WebToApkFS);
    if (++tries > 30) return done(null);       // in a browser, or the build did not enable it
    setTimeout(check, 100);
  })();
}

whenFolderHelperReady(function (fs) {
  var usable = !!(fs && fs.isAvailable());
  document.getElementById('folder-panel').hidden = !usable;
});

Inside a button tap the page has long finished loading, so a direct check is enough:

if (window.WebToApkFS && window.WebToApkFS.isAvailable()) {
  // show the folder features
}

Turn the two checks into one state that drives the whole folder screen:

function folderState() {
  var fs = window.WebToApkFS;
  if (!fs || !fs.isAvailable()) return 'unsupported';   // this build has no folder access
  return fs.hasAccess() ? 'ready' : 'needs-folder';
}

var state = folderState();
document.getElementById('folder-panel').hidden = state === 'unsupported';
document.getElementById('choose-folder').hidden = state !== 'needs-folder';
document.getElementById('export').disabled = state !== 'ready';

Notes: This says nothing about whether a folder is chosen yet - use WebToApkFS.hasAccess() for that. Before the page has loaded, the raw window.WebToApk.isFolderAccessEnabled() (string "true"/"false") answers the same question.

WebToApkFS.list helper#

WebToApkFS.list(relativePath)

Example

Lists the files and sub-folders inside a folder of the granted folder.

Returns: an array, synchronously: [{ name, uri, isDirectory, length, lastModified }] (length in bytes, lastModified in ms since 1970). Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build, and a granted folder.

A simple file browser ('' is the granted folder itself):

function renderFolder(path) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return;
  var ul = document.getElementById('files');
  ul.innerHTML = '';
  window.WebToApkFS.list(path)
    .sort(function (a, b) { return (b.isDirectory - a.isDirectory) || a.name.localeCompare(b.name); })
    .forEach(function (e) {
      var li = document.createElement('li');
      li.textContent = (e.isDirectory ? '[folder] ' : '') + e.name +
        (e.isDirectory ? '' : ' (' + e.length + ' bytes, ' + new Date(e.lastModified).toLocaleDateString() + ')');
      if (e.isDirectory) li.onclick = function () { renderFolder(path ? path + '/' + e.name : e.name); };
      ul.appendChild(li);
    });
}

renderFolder('');

Walk every sub-folder to collect all Markdown notes (paths you can pass to readText):

function findNotes(path, out) {
  out = out || [];
  window.WebToApkFS.list(path).forEach(function (e) {
    var full = path ? path + '/' + e.name : e.name;
    if (e.isDirectory) findNotes(full, out);
    else if (/\.md$/i.test(e.name)) out.push(full);
  });
  return out;
}

if (window.WebToApkFS && window.WebToApkFS.hasAccess()) {
  var notes = findNotes('');
  document.getElementById('count').textContent = notes.length + ' notes';
}

Find the newest backup and how much space the backups take:

function backupSummary() {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return null;
  var files = window.WebToApkFS.list('backups').filter(function (e) { return !e.isDirectory; });
  if (!files.length) return null;
  var newest = files.reduce(function (a, b) { return b.lastModified > a.lastModified ? b : a; });
  var bytes = files.reduce(function (sum, e) { return sum + e.length; }, 0);
  return { newest: 'backups/' + newest.name, count: files.length, kb: Math.round(bytes / 1024) };
}

var s = backupSummary();
document.getElementById('backup-info').textContent = s
  ? s.count + ' backups, ' + s.kb + ' KB. Latest: ' + s.newest
  : 'No backups yet';

Notes: Empty folder, missing folder, a path that is a file, a path with .., and no granted folder all return []. The list is not sorted. Each uri also works with WebToApkFS.stat() and WebToApkFS.readBytes().

WebToApkFS.mkdir helper#

WebToApkFS.mkdir(relativePath)

Example

Creates a folder inside the granted folder, including any missing parent folders.

Returns: a boolean, synchronously: true when the folder exists afterwards. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build, and a granted folder.

if (window.WebToApkFS && window.WebToApkFS.hasAccess()) {
  var month = new Date().toISOString().slice(0, 7);          // "2026-06"
  if (window.WebToApkFS.mkdir('journal/' + month)) {
    window.WebToApkFS.writeText('journal/' + month + '/readme.txt', 'One file per day.');
  } else {
    alert('Could not create the folder.');
  }
}

Set up a project's folder layout on first use, and report any folder that could not be made:

function createProjectFolders(project) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return false;
  var failed = ['images', 'exports', 'drafts'].filter(function (sub) {
    return !window.WebToApkFS.mkdir('projects/' + project + '/' + sub);
  });
  if (failed.length) alert('Could not create: ' + failed.join(', '));
  return failed.length === 0;
}

createProjectFolders('holiday-2026');

A "New folder" button that warns when the name is already taken (because mkdir answers true for an existing folder):

document.getElementById('new-folder').addEventListener('click', function () {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return;
  var name = (prompt('Folder name') || '').trim();
  if (!name || /[\\/]/.test(name) || name === '.' || name === '..') return;
  var taken = window.WebToApkFS.list('').some(function (e) { return e.name === name; });
  if (taken) { alert('"' + name + '" already exists.'); return; }
  if (window.WebToApkFS.mkdir(name)) renderFolder('');   // your app: refresh the list
  else alert('Could not create "' + name + '".');
});

Notes: It returns true if the folder already exists, so calling it before each save is fine. It returns false when a FILE with that name is in the way, the path contains .., or no folder is granted. WebToApkFS.writeText() already creates missing parents, so you only need mkdir for empty folders.

WebToApkFS.readText helper#

WebToApkFS.readText(relativePath)

Example

Reads a text file from the granted folder as UTF-8.

Returns: a string, synchronously: the file's text, or ''. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build, and a granted folder.

'' means "empty file" OR "no such file". Use WebToApkFS.stat() when you must know which:

function loadSettings() {
  if (!window.WebToApkFS) return null;
  var info = window.WebToApkFS.stat('data/settings.json');
  if (!info.ok) return null;                                // not saved yet
  try {
    return JSON.parse(window.WebToApkFS.readText('data/settings.json'));
  } catch (e) {
    alert('settings.json is damaged: ' + e.message);
    return null;
  }
}

Open a note into a text box:

if (window.WebToApkFS && window.WebToApkFS.hasAccess()) {
  document.getElementById('editor').value = window.WebToApkFS.readText('notes/today.txt');
}

Import a CSV the user dropped into the folder:

function importCsv(path) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return [];
  var text = window.WebToApkFS.readText(path);
  if (!text) return [];                                     // missing or empty
  var lines = text.replace(/\r/g, '').split('\n').filter(Boolean);
  var header = lines.shift().split(',');
  return lines.map(function (line) {
    var cells = line.split(',');
    var row = {};
    header.forEach(function (h, i) { row[h.trim()] = (cells[i] || '').trim(); });
    return row;
  });
}

var contacts = importCsv('import/contacts.csv');
console.log(contacts.length + ' rows imported');

Notes: The whole file is loaded at once - for big or binary files use WebToApkFS.readBytes(). Bytes that are not valid UTF-8 come back as the replacement character. A folder, a path with .., or no granted folder also returns ''.

WebToApkFS.rename helper#

WebToApkFS.rename(relativePath, newName)

Example

Renames a file or folder inside the granted folder.

Returns: a boolean, synchronously. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build, and a granted folder.

newName is only the new name, not a path. The entry stays in the same folder:

function renameNote(path, newName) {
  if (!window.WebToApkFS) return false;
  var ok = window.WebToApkFS.rename(path, newName);     // 'notes/a.txt', 'b.txt' -> notes/b.txt
  if (!ok) alert('Could not rename. A file called "' + newName + '" may already exist.');
  return ok;
}

renameNote('notes/today.txt', 'journal.txt');
renameNote('notes/2025', 'archive-2025');               // folders work too

Rename without clashing: try "name (2)", "name (3)" … until a free name is found:

function renameUnique(path, wanted) {
  if (!window.WebToApkFS) return '';
  var dot = wanted.lastIndexOf('.');
  var base = dot > 0 ? wanted.slice(0, dot) : wanted;
  var ext = dot > 0 ? wanted.slice(dot) : '';
  for (var n = 1; n <= 20; n++) {
    var name = n === 1 ? wanted : base + ' (' + n + ')' + ext;
    if (window.WebToApkFS.rename(path, name)) return name;
  }
  return '';
}

var finalName = renameUnique('photos/IMG_0042.jpg', 'Beach.jpg');

Save safely: write a temporary file first, then swap it in (rename refuses a name that is taken, so the old file is deleted just before):

function saveSafely(folder, name, text) {
  var fs = window.WebToApkFS;
  if (!fs || !fs.hasAccess()) return false;
  var tmp = folder + '/' + name + '.tmp';
  if (!fs.writeText(tmp, text)) return false;           // the original is untouched
  fs.delete(folder + '/' + name);                       // false when it did not exist yet
  return fs.rename(tmp, name);
}

saveSafely('notes', 'today.txt', document.getElementById('editor').value);

Notes: It returns false when another entry already has newName, when newName is empty, ./.., or contains / or \, when the path does not exist, or when no folder is granted. To move a file between folders, read it, write it in the new place, then delete the old one.

WebToApkFS.requestAccess helper#

WebToApkFS.requestAccess()

Example

Opens the system folder picker so the user can give the app one folder, kept across restarts.

Returns: a Promise that always resolves (never rejects) with { success, folderUri, requestId }, plus error when success is false. success is a boolean; folderUri is the granted content://…/tree/… address, or ''. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

Ask from a button the user taps, and show which folder is connected:

document.getElementById('choose-folder').addEventListener('click', async function () {
  if (!window.WebToApkFS) {
    alert('Folder access is not available in this app.');
    return;
  }
  var r = await window.WebToApkFS.requestAccess();
  if (!r.success) {
    document.getElementById('status').textContent = 'No folder chosen.';
    return;
  }
  var name = decodeURIComponent(r.folderUri.split('/tree/')[1] || '').split(':').pop();
  document.getElementById('status').textContent = 'Saving to: ' + name;
  window.WebToApkFS.writeText('hello.txt', 'Connected at ' + new Date().toISOString());
});

Only ask when no folder is granted yet:

async function ensureFolder() {
  if (!window.WebToApkFS) return false;
  if (window.WebToApkFS.hasAccess()) return true;
  var r = await window.WebToApkFS.requestAccess();
  return r.success;
}

A "Change folder" button that explains every way the request can fail:

var folderErrors = {
  cancelled: 'No folder chosen.',
  busy: 'The folder picker is already open.',
  not_persisted: 'Android would not keep access to that folder. Try another one.',
  disabled: 'Folder access is switched off in this app.',
  no_picker: 'This phone has no folder picker.',
  foreign_origin: 'This page is not allowed to use folders.'
};

document.getElementById('change-folder').addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  var r = await window.WebToApkFS.requestAccess();
  document.getElementById('status').textContent = r.success
    ? 'Folder changed.'
    : (folderErrors[r.error] || 'Could not open the folder picker.');
  if (r.success) renderFolder('');                      // your app: show the new folder
});

Notes: Call it from a tap, never on page load - the picker covers the app. success: false comes with error: 'cancelled' (the user backed out), 'not_persisted' (Android would not keep the grant), 'disabled', 'busy' (a picker is already open for an earlier request - that one still gets its own answer), 'no_picker' or 'foreign_origin'. Choosing again replaces the earlier folder.

WebToApkFS.writeText helper#

WebToApkFS.writeText(relativePath, content)

Example

Writes a UTF-8 text file into the granted folder, creating it (and missing parent folders) or replacing it.

Returns: a boolean, synchronously: true when the file was written. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build, and a granted folder.

Save from a button:

document.getElementById('save').addEventListener('click', async function () {
  if (!window.WebToApkFS) { alert('Saving to a folder is not available here.'); return; }
  if (!window.WebToApkFS.hasAccess()) {
    var r = await window.WebToApkFS.requestAccess();
    if (!r.success) return;                              // user cancelled
  }
  var ok = window.WebToApkFS.writeText('notes/today.txt', document.getElementById('editor').value);
  document.getElementById('status').textContent = ok ? 'Saved' : 'Could not save';
});

Save an object as JSON:

if (window.WebToApkFS && window.WebToApkFS.hasAccess()) {
  window.WebToApkFS.writeText('data/settings.json', JSON.stringify({ theme: 'dark', size: 16 }, null, 2));
}

Add a line to a daily log (there is no append, so read, add, and write back):

function logLine(message) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return false;
  var path = 'logs/' + new Date().toISOString().slice(0, 10) + '.log';   // one file per day
  var old = window.WebToApkFS.readText(path);
  var line = new Date().toLocaleTimeString() + '  ' + message + '\n';
  return window.WebToApkFS.writeText(path, old + line);
}

logLine('Order #1042 marked as shipped');

Notes: An existing file is replaced completely - there is no append. content is turned into a string (null becomes ''). It returns false when the path is an existing folder, contains .., or no folder is granted. The file type is taken from the extension, so the name on disk is exactly the name you gave.

clearFolderAccess bridge#

window.WebToApk.clearFolderAccess()

Releases the persisted folder grant. Refused (no-op) for a foreign page.

Example

Forgets the granted folder and gives the permission back to Android.

Returns: nothing (synchronous). WebToApkFS.clearAccess() does the same. Needs: Native Folder Access (SAF) ticked in Step 4 (Access); with the switch off it does nothing.

A "Disconnect" button:

document.getElementById('disconnect').addEventListener('click', function () {
  if (window.WebToApkFS) {
    window.WebToApkFS.clearAccess();
  } else if (window.WebToApk && typeof window.WebToApk.clearFolderAccess === 'function') {
    window.WebToApk.clearFolderAccess();
  }
  document.getElementById('folder-name').textContent = 'No folder chosen';
});

On sign-out, forget the folder too, so the next person on this phone chooses their own:

function signOut() {
  localStorage.clear();
  if (window.WebToApk && typeof window.WebToApk.clearFolderAccess === 'function') {
    window.WebToApk.clearFolderAccess();
  }
  location.href = 'login.html';
}

Forget a folder that was moved or deleted in the Files app, then offer to choose again (the app writes myapp-folder.json when a folder is first connected):

async function reconnectIfMissing() {
  var fs = window.WebToApkFS;
  if (!fs || !fs.hasAccess()) return;
  if (fs.stat('myapp-folder.json').ok) return;          // marker still there: the folder is fine
  if (typeof window.WebToApk.clearFolderAccess === 'function') {
    window.WebToApk.clearFolderAccess();                // drop the grant that points nowhere
  }
  if (confirm('The folder you chose earlier is gone. Choose a folder again?')) {
    var r = await fs.requestAccess();
    if (r.success) fs.writeText('myapp-folder.json', '{}');
  }
}

Notes: Files in the folder are NOT deleted; the app just cannot reach them any more. After this, hasFolderAccess() answers "false" and every file method fails until the user chooses a folder again.

deleteEntry bridge#

window.WebToApk.deleteEntry(relativePath: String, recursive: String): String

Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions. JavaScript usage: window.WebToApk.requestFolderAccess('myRequestId') window.WebToApk.hasFolderAccess() // "true" / "false" window.WebToApk.getFolderAccessUri() // "content://..." or "" window.WebToApk.clearFolderAccess() // releases persisted access Callback from Android → JS: window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request - that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

Described by its group, Native Folder Access (SAF) Bridge, rather than on its own.

Example

Deletes a file or folder inside the granted folder.

Returns: the STRING "true" or "false", synchronously. WebToApkFS.delete(path, { recursive }) returns a real boolean. Needs: Native Folder Access (SAF) ticked in Step 4 (Access) and a granted folder.

With the helper, after asking the user:

function removeEntry(path, isFolder) {
  if (!window.WebToApkFS) return false;
  if (!confirm('Delete ' + path + '? This cannot be undone.')) return false;
  return window.WebToApkFS.delete(path, { recursive: isFolder });
}

removeEntry('notes/old.txt', false);
removeEntry('notes/2025', true);      // the folder and everything in it

The raw call - the second argument is the STRING 'true' or 'false':

if (window.WebToApk && typeof window.WebToApk.deleteEntry === 'function') {
  var gone = window.WebToApk.deleteEntry('cache', 'true') === 'true';
}

Keep only the five newest backups and delete the rest:

function pruneBackups(keep) {
  var fs = window.WebToApkFS;
  if (!fs) return 0;
  var files = fs.list('backups').filter(function (e) { return !e.isDirectory; });
  files.sort(function (a, b) { return b.lastModified - a.lastModified; });   // newest first
  var removed = 0;
  files.slice(keep).forEach(function (e) {
    if (fs.delete('backups/' + e.name)) removed++;
  });
  return removed;
}

console.log(pruneBackups(5) + ' old backups deleted');

Notes: A folder that is not empty is only deleted with recursive 'true'. The granted folder itself ('') can never be deleted. Deleting is real and immediate: there is no trash.

getFolderAccessUri bridge#

window.WebToApk.getFolderAccessUri(): String

The granted folder's `content://` tree URI, or "" (none, disabled, or a foreign page).

Example

Gives the address of the folder the user granted, so you can show which folder is connected.

Returns: a string, synchronously: a tree URI such as content://com.android.externalstorage.documents/tree/primary%3ADocuments%2FNotes, or "" when no folder is granted or the switch is off. WebToApkFS.getAccessUri() returns the same string. Needs: Native Folder Access (SAF) ticked in Step 4 (Access).

Turn the URI into a name the user recognises:

function folderLabel() {
  var uri = '';
  if (window.WebToApkFS) {
    uri = window.WebToApkFS.getAccessUri();
  } else if (window.WebToApk && typeof window.WebToApk.getFolderAccessUri === 'function') {
    uri = window.WebToApk.getFolderAccessUri();
  }
  if (!uri) return '';
  var tail = uri.split('/tree/')[1] || '';
  try { tail = decodeURIComponent(tail); } catch (e) {}
  return tail.split(':').pop() || tail;          // "primary:Documents/Notes" -> "Documents/Notes"
}

var label = folderLabel();
document.getElementById('folder-name').textContent = label ? 'Saving to: ' + label : 'No folder chosen';

Reload the file list only when the user really picked a different folder:

document.getElementById('change-folder').addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  var before = window.WebToApkFS.getAccessUri();
  var r = await window.WebToApkFS.requestAccess();
  if (!r.success) return;                        // the old folder is still connected
  if (r.folderUri !== before) {
    renderFiles(window.WebToApkFS.list(''));     // a new place: show what is in it
  }
});

Say whether the folder is on the phone, on an SD card, or in another storage app:

function folderLocation() {
  var uri = window.WebToApkFS ? window.WebToApkFS.getAccessUri() : '';
  if (!uri) return 'none';
  var id = uri.split('/tree/')[1] || '';
  try { id = decodeURIComponent(id); } catch (e) {}
  if (uri.indexOf('com.android.externalstorage.documents') === -1) return 'other';   // e.g. a cloud drive app
  return id.indexOf('primary:') === 0 ? 'phone' : 'sd-card';                         // "1A2B-3C4D:Music" is a card
}

document.getElementById('folder-where').textContent =
  { none: '', phone: 'Phone storage', 'sd-card': 'SD card', other: 'Another storage app' }[folderLocation()];

Notes: You never pass this URI back to the file methods - they all take paths relative to this folder. Show the name next to a "Disconnect" button so the user knows where files go.

hasFolderAccess bridge#

window.WebToApk.hasFolderAccess(): String

Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions. JavaScript usage: window.WebToApk.requestFolderAccess('myRequestId') window.WebToApk.hasFolderAccess() // "true" / "false" window.WebToApk.getFolderAccessUri() // "content://..." or "" window.WebToApk.clearFolderAccess() // releases persisted access Callback from Android → JS: window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request - that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

Described by its group, Native Folder Access (SAF) Bridge, rather than on its own.

Example

Tells you whether the user has already given the app a folder.

Returns: the STRING "true" or "false" (not a boolean), synchronously. WebToApkFS.hasAccess() gives a real boolean. Needs: Native Folder Access (SAF) ticked in Step 4 (Access); without it the answer is always "false".

The raw method exists from the very first line of the page, so it is the right check while the page is still loading (WebToApkFS is added only when loading finishes):

function folderConnected() {
  if (window.WebToApkFS) return window.WebToApkFS.hasAccess();
  return !!(window.WebToApk && typeof window.WebToApk.hasFolderAccess === 'function' &&
    window.WebToApk.hasFolderAccess() === 'true');
}

document.getElementById('choose-folder').hidden = folderConnected();

Check before a save and open the picker only when no folder is connected yet:

async function saveBackup(json) {
  if (!window.WebToApkFS) return false;
  if (!window.WebToApkFS.hasAccess()) {
    var r = await window.WebToApkFS.requestAccess();   // asked once; the grant is remembered
    if (!r.success) return false;
  }
  return window.WebToApkFS.writeText('backup.json', json);
}

document.getElementById('backup').addEventListener('click', function () {
  saveBackup(JSON.stringify(appState));
});

An autosave timer that writes only when a folder is connected and never opens the picker itself:

setInterval(function () {
  if (!window.WebToApk || typeof window.WebToApk.hasFolderAccess !== 'function') return;
  if (window.WebToApk.hasFolderAccess() !== 'true') return;   // not connected: the Save button asks
  window.WebToApk.writeTextFile('drafts/autosave.txt', document.getElementById('editor').value);
}, 30000);

Notes: Compare with === 'true'; the string "false" is truthy. It only says the app remembers a folder. If that folder was deleted or moved in the Files app, listFolderEntries('') answers [] - ask the user to choose it again.

isFolderAccessEnabled bridge#

window.WebToApk.isFolderAccessEnabled(): String

Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions. JavaScript usage: window.WebToApk.requestFolderAccess('myRequestId') window.WebToApk.hasFolderAccess() // "true" / "false" window.WebToApk.getFolderAccessUri() // "content://..." or "" window.WebToApk.clearFolderAccess() // releases persisted access Callback from Android → JS: window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request - that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

Described by its group, Native Folder Access (SAF) Bridge, rather than on its own.

Example

Tells you whether this app was built with folder access switched on at all.

Returns: the STRING "true" or "false", synchronously. Needs: nothing to call it; it answers "true" only when Native Folder Access (SAF) was ticked in Step 4 (Access).

Use it to decide whether to show the folder feature. WebToApkFS.isAvailable() answers the same question as a boolean once the page has loaded:

function folderFeatureShipped() {
  if (window.WebToApkFS) return window.WebToApkFS.isAvailable();
  return !!(window.WebToApk && typeof window.WebToApk.isFolderAccessEnabled === 'function' &&
    window.WebToApk.isFolderAccessEnabled() === 'true');
}

if (!folderFeatureShipped()) {
  document.getElementById('folder-panel').hidden = true;   // in a browser, or the switch was off
}

Drive a settings row through its three states (not in this build, not connected, connected):

function folderSettingsState() {
  var fs = window.WebToApkFS;
  if (!fs || !fs.isAvailable()) return 'unavailable';
  return fs.hasAccess() ? 'connected' : 'not-connected';
}

var row = document.getElementById('folder-row');
switch (folderSettingsState()) {
  case 'unavailable':   row.hidden = true; break;
  case 'not-connected': row.querySelector('button').textContent = 'Choose a folder'; break;
  case 'connected':     row.querySelector('button').textContent = 'Disconnect'; break;
}

Mark the page before it is drawn, so CSS can show folder-only controls without a flash:

// In a <script> inside <head>: the raw method is already there.
if (window.WebToApk && typeof window.WebToApk.isFolderAccessEnabled === 'function' &&
    window.WebToApk.isFolderAccessEnabled() === 'true') {
  document.documentElement.classList.add('has-folders');   // .has-folders .folder-only { display: block }
}

Notes: This says nothing about whether a folder is chosen yet - use hasFolderAccess() for that. In a browser window.WebToApk does not exist, so the check above answers false without throwing.

listFolderEntries bridge#

window.WebToApk.listFolderEntries(relativePath: String): String

Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions. JavaScript usage: window.WebToApk.requestFolderAccess('myRequestId') window.WebToApk.hasFolderAccess() // "true" / "false" window.WebToApk.getFolderAccessUri() // "content://..." or "" window.WebToApk.clearFolderAccess() // releases persisted access Callback from Android → JS: window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request - that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

Described by its group, Native Folder Access (SAF) Bridge, rather than on its own.

Example

Lists the files and sub-folders inside a folder of the granted folder.

Returns: a JSON STRING, synchronously: an array of { name, uri, isDirectory, length, lastModified } (length in bytes, lastModified in ms since 1970). WebToApkFS.list(path) parses it for you. Needs: Native Folder Access (SAF) ticked in Step 4 (Access) and a granted folder.

With the helper ('' is the granted folder itself):

function showFolder(path) {
  if (!window.WebToApkFS) return;
  var entries = window.WebToApkFS.list(path);          // already an array
  entries.forEach(function (e) {
    console.log(e.isDirectory ? '[dir] ' : '      ', e.name, e.length + ' bytes');
  });
}
showFolder('');          // the root
showFolder('notes');     // a sub-folder

The raw call, which returns text you must parse:

if (window.WebToApk && typeof window.WebToApk.listFolderEntries === 'function') {
  var entries = JSON.parse(window.WebToApk.listFolderEntries('notes'));
  console.log(entries.length + ' entries');
}

Find the newest backup file and show when it was made:

function latestBackup() {
  if (!window.WebToApkFS) return null;
  var backups = window.WebToApkFS.list('backups').filter(function (e) {
    return !e.isDirectory && /\.json$/i.test(e.name);
  });
  backups.sort(function (a, b) { return b.lastModified - a.lastModified; });   // newest first
  return backups[0] || null;
}

var last = latestBackup();
document.getElementById('last-backup').textContent =
  last ? 'Last backup: ' + new Date(last.lastModified).toLocaleString() : 'No backups yet';

Notes: An empty folder, a missing folder, a path that is a file, a path with .., no granted folder, and a page from a foreign website all answer [] - call hasFolderAccess() first if you need to tell "no folder" apart. Paths are relative, use /, and never go above the granted folder.

mkdir bridge#

window.WebToApk.mkdir(relativePath: String): String

Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions. JavaScript usage: window.WebToApk.requestFolderAccess('myRequestId') window.WebToApk.hasFolderAccess() // "true" / "false" window.WebToApk.getFolderAccessUri() // "content://..." or "" window.WebToApk.clearFolderAccess() // releases persisted access Callback from Android → JS: window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request - that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

Described by its group, Native Folder Access (SAF) Bridge, rather than on its own.

Example

Creates a folder inside the granted folder, including any missing parent folders.

Returns: the STRING "true" or "false", synchronously. WebToApkFS.mkdir(path) returns a real boolean. Needs: Native Folder Access (SAF) ticked in Step 4 (Access) and a granted folder.

With the helper:

if (window.WebToApkFS && window.WebToApkFS.hasAccess()) {
  var ok = window.WebToApkFS.mkdir('photos/2026/june');   // creates photos, 2026 and june
  if (!ok) console.log('Could not create the folder');
}

The raw call:

if (window.WebToApk && typeof window.WebToApk.mkdir === 'function') {
  var made = window.WebToApk.mkdir('backups') === 'true';
}

Make a dated folder for one export and write every item into it:

function exportAll(items) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return false;
  var dir = 'exports/' + new Date().toISOString().slice(0, 10);   // e.g. exports/2026-09-25
  if (!window.WebToApkFS.mkdir(dir)) {
    alert('Could not create ' + dir + ' (is a file with that name in the way?)');
    return false;
  }
  items.forEach(function (item) {
    window.WebToApkFS.writeText(dir + '/' + item.id + '.json', JSON.stringify(item));
  });
  return true;
}

Notes: It answers "true" when the folder already exists, so it is safe to call before every save. It answers "false" when a FILE with that name is in the way, the path contains .., or no folder is granted.

readTextFile bridge#

window.WebToApk.readTextFile(relativePath: String): String

Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions. JavaScript usage: window.WebToApk.requestFolderAccess('myRequestId') window.WebToApk.hasFolderAccess() // "true" / "false" window.WebToApk.getFolderAccessUri() // "content://..." or "" window.WebToApk.clearFolderAccess() // releases persisted access Callback from Android → JS: window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request - that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

Described by its group, Native Folder Access (SAF) Bridge, rather than on its own.

Example

Reads a text file from the granted folder as UTF-8.

Returns: a string, synchronously: the file's text, or "". WebToApkFS.readText(path) returns the same. Needs: Native Folder Access (SAF) ticked in Step 4 (Access) and a granted folder.

"" means "empty file" OR "no such file", so check with WebToApkFS.stat() when the difference matters:

function loadNote(path) {
  if (!window.WebToApkFS) return null;
  var info = window.WebToApkFS.stat(path);    // { ok: true, name, size, ... } or { ok: false, error: 'not-found' }
  if (!info.ok) return null;                  // the file does not exist
  return window.WebToApkFS.readText(path);    // '' here really is an empty file
}

var text = loadNote('notes/today.txt');
document.getElementById('editor').value = text === null ? '' : text;

The raw call:

if (window.WebToApk && typeof window.WebToApk.readTextFile === 'function') {
  var raw = window.WebToApk.readTextFile('notes/today.txt');
  console.log(raw.length + ' characters');
}

Import every .txt and .md note from a folder:

function importNotes(folder) {
  if (!window.WebToApkFS) return [];
  return window.WebToApkFS.list(folder)
    .filter(function (e) { return !e.isDirectory && /\.(txt|md)$/i.test(e.name); })
    .map(function (e) {
      return {
        title: e.name.replace(/\.(txt|md)$/i, ''),
        body: window.WebToApkFS.readText(folder + '/' + e.name)
      };
    });
}

renderNotes(importNotes('notes'));

Notes: The whole file is read into memory - fine for notes and JSON, not for large files; use WebToApkFS.readBytes for those. Bytes that are not valid UTF-8 come back as the replacement character. A folder path, a path with .., no granted folder, or a page from a foreign website all answer "".

renameEntry bridge#

window.WebToApk.renameEntry(relativePath: String, newName: String): String

Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions. JavaScript usage: window.WebToApk.requestFolderAccess('myRequestId') window.WebToApk.hasFolderAccess() // "true" / "false" window.WebToApk.getFolderAccessUri() // "content://..." or "" window.WebToApk.clearFolderAccess() // releases persisted access Callback from Android → JS: window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request - that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

Described by its group, Native Folder Access (SAF) Bridge, rather than on its own.

Example

Renames a file or folder inside the granted folder.

Returns: the STRING "true" or "false", synchronously. WebToApkFS.rename(path, newName) returns a real boolean. Needs: Native Folder Access (SAF) ticked in Step 4 (Access) and a granted folder.

newName is a plain name, not a path - the entry stays in the same folder:

if (window.WebToApkFS) {
  var ok = window.WebToApkFS.rename('notes/today.txt', 'journal.txt');   // -> notes/journal.txt
  if (!ok) alert('Rename failed. Is there already a file called journal.txt?');
}

The raw call:

if (window.WebToApk && typeof window.WebToApk.renameEntry === 'function') {
  var renamed = window.WebToApk.renameEntry('old-folder', 'new-folder') === 'true';
}

Keep the previous backup by renaming it before the new one is written:

function rotateBackup(json) {
  var fs = window.WebToApkFS;
  if (!fs || !fs.hasAccess()) return false;
  if (fs.stat('backup.json').ok) {
    var stamp = new Date().toISOString().replace(/[:.]/g, '-');   // no characters a file name cannot hold
    fs.rename('backup.json', 'backup-' + stamp + '.json');        // stays in the same folder
  }
  return fs.writeText('backup.json', json);
}

Notes: It fails when another entry already has newName, when newName is empty or contains / or \, when the path does not exist, or when no folder is granted. To move a file to another folder, write it there and delete the old one.

requestFolderAccess bridge#

window.WebToApk.requestFolderAccess(requestId: String)

Opens the system folder picker and persists read/write access to the chosen folder. The answer arrives on `WebToApkOnFolderAccessResult`; only one picker can be open at a time, so a request made while one is open is answered at once with `error:"busy"` and the open one keeps its own answer.

Example

Opens the system folder picker so the user can give the app one folder. Pages normally use WebToApkFS.requestAccess(), which wraps this call in a Promise.

Returns: nothing. The answer comes back later through an internal callback that WebToApkFS owns; WebToApkFS.requestAccess() resolves with { success, folderUri, requestId }, plus error when success is false. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

Ask from a button tap, with the helper:

document.getElementById('choose-folder').addEventListener('click', async function () {
  if (!window.WebToApkFS) {
    alert('Folder access is not available in this app.');
    return;
  }
  var r = await window.WebToApkFS.requestAccess();
  if (r.success) {
    console.log('Folder granted:', r.folderUri);   // content://.../tree/...
  } else {
    console.log('No folder chosen');                 // user cancelled, or Android refused the grant
  }
});

Ask only the first time, then set up the app's own sub-folder:

async function ensureFolder() {
  if (!window.WebToApkFS || !window.WebToApkFS.isAvailable()) return false;
  if (window.WebToApkFS.hasAccess()) return true;          // granted on an earlier run
  var r = await window.WebToApkFS.requestAccess();
  if (!r.success) return false;
  window.WebToApkFS.mkdir('exports');                       // true even if it already exists
  return true;
}

document.getElementById('export').addEventListener('click', async function () {
  if (!(await ensureFolder())) return;
  window.WebToApkFS.writeText('exports/list.csv', buildCsv());
});

Tell the user exactly why no folder was connected, using the real error codes:

var folderMessages = {
  cancelled: 'No folder was chosen.',
  not_persisted: 'Android would not keep access to that folder. Please choose another one.',
  busy: 'The folder picker is already open.',
  disabled: 'This app was built without folder access.',
  foreign_origin: 'A folder can only be connected from the app\'s own pages.',
  no_picker: 'This phone has no folder picker.'
};

document.getElementById('choose-folder').addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  var r = await window.WebToApkFS.requestAccess();
  document.getElementById('status').textContent = r.success
    ? 'Folder connected.'
    : (folderMessages[r.error] || 'Could not connect a folder.');
});

Notes: Do not call the raw method yourself: its only answer is the callback the helper listens to. success is false when the user cancels (error:'cancelled'), when Android will not keep the grant ('not_persisted'), and at once when the build did not enable the switch ('disabled') or the page on screen is not the app's own ('foreign_origin'). The grant is kept across app restarts; choosing again replaces the folder. Only one picker can be open at a time - a request made while one is open is answered at once with error:'busy', and the open one keeps its own answer.

writeTextFile bridge#

window.WebToApk.writeTextFile(relativePath: String, content: String): String

Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions. JavaScript usage: window.WebToApk.requestFolderAccess('myRequestId') window.WebToApk.hasFolderAccess() // "true" / "false" window.WebToApk.getFolderAccessUri() // "content://..." or "" window.WebToApk.clearFolderAccess() // releases persisted access Callback from Android → JS: window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request - that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

Described by its group, Native Folder Access (SAF) Bridge, rather than on its own.

Example

Writes a text file (UTF-8) into the granted folder, creating it or replacing its content.

Returns: the STRING "true" or "false", synchronously. WebToApkFS.writeText(path, text) returns a real boolean. Needs: Native Folder Access (SAF) ticked in Step 4 (Access) and a granted folder.

With the helper:

function saveNote(path, text) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) {
    alert('Choose a folder first.');
    return false;
  }
  var ok = window.WebToApkFS.writeText(path, text);   // missing parent folders are created
  if (!ok) alert('Could not save ' + path);
  return ok;
}

saveNote('notes/2026/today.txt', document.getElementById('editor').value);

The raw call, whose answer is text:

if (window.WebToApk && typeof window.WebToApk.writeTextFile === 'function') {
  var saved = window.WebToApk.writeTextFile('data/settings.json', JSON.stringify({ theme: 'dark' })) === 'true';
}

Append a line to a daily log - writing replaces the file, so read it first:

function appendLog(line) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return false;
  var now = new Date().toISOString();
  var path = 'logs/' + now.slice(0, 10) + '.log';          // one file per day
  var old = window.WebToApkFS.readText(path);              // '' the first time
  return window.WebToApkFS.writeText(path, old + now + ' ' + line + '\n');
}

appendLog('Order #1042 marked as shipped');

Notes: An existing file is replaced completely. It fails when the path is an existing folder, contains .., or no folder is granted. The file type comes from the extension (.json, .txt, .md…), so the name you give is the name on disk.

Generated from the app runtime and its example files on every docs build. Read it as Markdown · All families.

Checked against the shipped bridge on 2026-10-03.