Crear APK gratis

API del puente de JavaScript

Archivos: un archivo a la vez

El selector del sistema y la lectura/escritura binaria por fragmentos, para archivos demasiado grandes para guardarlos en una cadena.

AppMint.downloadFile ayudante#

AppMint.downloadFile(data, fileName, mimeType?)

Ejemplo

Guarda un archivo que creó tu página (desde una cadena base64, una URL data:, una URL blob: o un Blob) con el nombre de archivo que elijas. Se abre la hoja "Guardar como…" de Android con ese nombre ya escrito.

Devuelve: (síncrono) true una vez que el guardado se entregó a Android (false solo para una URL data: que no pudo analizar). Lanza TypeError si data no es un Blob ni una cadena. El usuario aún elige la carpeta y puede cancelar. Necesita: nada: siempre está presente en la app.

Una cadena Base64 más un tipo MIME:

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();
  }
}

Una URL data: (el tipo MIME sale de la URL) o un Blob:

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

Un PDF en base64 que tu servidor devuelve en JSON; a un nombre sin extensión se le añade .pdf según el tipo MIME:

async function downloadInvoice(id) {
  const res = await fetch('/api/invoices/' + id + '?format=base64');
  const body = await res.json();                                  // { base64: 'JVBERi0x…' }
  if (!window.AppMint || typeof AppMint.downloadFile !== 'function') {
    location.href = 'data:application/pdf;base64,' + body.base64;
    return;
  }
  AppMint.downloadFile(body.base64, 'Invoice_' + id, 'application/pdf');   // saved as Invoice_<id>.pdf
}

Una URL blob: que ya creaste para una vista previa en pantalla:

// earlier: preview.src = URL.createObjectURL(editedImageBlob);
document.getElementById('save-photo').addEventListener('click', function () {
  const preview = document.getElementById('preview');
  if (window.AppMint && typeof AppMint.downloadFile === 'function') {
    AppMint.downloadFile(preview.src, 'Edited_Photo.jpg');
  }
});

Notas: Solo se usa la última parte de fileName (sin carpetas). A un nombre sin extensión se le añade una según el tipo MIME. El tercer argumento solo se usa para una cadena base64 simple. Si el teléfono no tiene ninguna pantalla "Guardar como…", el archivo se escribe directamente en Descargas. Tu código <a download> habitual también funciona; consulta downloads.

AppMint.saveBlob ayudante#

AppMint.saveBlob(blob, fileName)

Ejemplo

Guarda un Blob (o File) con el nombre que elijas. Se abre la hoja "Guardar como…" de Android con ese nombre ya escrito.

Devuelve: (síncrono) true una vez que el guardado se entregó a Android. Lanza TypeError cuando el primer argumento no es un Blob. Necesita: nada: siempre está presente en la app.

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);
}

Un File trae su propio nombre, así que puedes omitirlo:

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

Un archivo de tu servidor que necesita un encabezado de inicio de sesión, guardado con el nombre que elijas:

async function saveStatement(month, token) {
  const res = await fetch('/api/statements/' + month, {
    headers: { Authorization: 'Bearer ' + token }
  });
  if (!res.ok) { alert('Could not fetch the statement (' + res.status + ')'); return; }
  const blob = await res.blob();
  if (window.AppMint && typeof AppMint.saveBlob === 'function') {
    AppMint.saveBlob(blob, 'Statement_' + month + '.pdf');
  }
}

Varios archivos agrupados en un solo ZIP (aquí con JSZip) y guardados de una vez:

async function exportAll(notes) {
  const zip = new JSZip();
  notes.forEach(function (n) { zip.file(n.title + '.txt', n.body); });
  zip.file('index.json', JSON.stringify(notes.map(function (n) { return n.title; })));
  const blob = await zip.generateAsync({ type: 'blob' });
  if (window.AppMint && typeof AppMint.saveBlob === 'function') {
    AppMint.saveBlob(blob, 'Notes_' + new Date().toISOString().slice(0, 10) + '.zip');
  }
}

Notas: El Blob se lee en memoria y se envía a Android de una sola vez, así que los blobs muy grandes (cientos de MB) pueden quedarse sin memoria. El usuario elige la carpeta y puede cancelar; nada le dice a la página qué ocurrió.

AppMint.setDownloadName ayudante#

AppMint.setDownloadName(url, fileName)

Ejemplo

Le da a una URL blob: o data: el nombre de archivo con el que debe guardarse, antes de que tu código (o una biblioteca) inicie la descarga.

Devuelve: nada. Necesita: nada: siempre está presente en la app.

Útil cuando una biblioteca descarga una URL que creaste antes y no puedes pasarle un nombre:

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"

Un PDF que tu página abre con window.open va al visor de PDF del teléfono; ponle nombre antes para que no se llame "download.pdf":

function previewPdf(pdfBlob, invoiceNo) {
  const url = URL.createObjectURL(pdfBlob);
  if (window.AppMint && typeof AppMint.setDownloadName === 'function') {
    AppMint.setDownloadName(url, 'Invoice_' + invoiceNo + '.pdf');
  }
  window.open(url, '_blank');
}

Una URL data: funciona igual, por ejemplo una imagen de gráfico a la que enlaza una biblioteca sin atributo download:

const dataUrl = chart.toBase64Image();         // e.g. Chart.js: 'data:image/png;base64,…'
if (window.AppMint && typeof AppMint.setDownloadName === 'function') {
  AppMint.setDownloadName(dataUrl, 'Sales_by_month.png');
}
const link = document.getElementById('chart-link');
link.href = dataUrl;                           // a tap on this link saves Sales_by_month.png
link.textContent = 'Save chart';

Notas: El nombre se recuerda incluso después de URL.revokeObjectURL(url), porque algunas bibliotecas lo revocan antes de que termine el guardado. Rara vez lo necesitas: <a download="name">, URL.createObjectURL(file) (un File tiene nombre) y el saveAs(blob, name) de FileSaver se detectan automáticamente. El nombre 'download' se ignora como "sin nombre".

WebToApkFS.maxChunkSize ayudante#

WebToApkFS.maxChunkSize()

Ejemplo

Te indica el máximo de bytes que puede mover una llamada readBytes o writeBytes (4 MB), para que dimensiones tu bucle de fragmentos.

Devuelve: (síncrono) un número de bytes, o 0 cuando falta el método del puente. Requiere: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar (eso es lo que inyecta WebToApkFS).

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 };
  }
}

Muestra el progreso de lectura: el tamaño del bloque te dice cuántos pasos lleva un archivo:

function readWithProgress(uri, onProgress) {
  const info = WebToApkFS.stat(uri);
  if (!info.ok) return info;
  const step = WebToApkFS.maxChunkSize() || 1048576;
  const steps = Math.max(1, Math.ceil(info.size / step));
  for (let i = 0; i < steps; i++) {
    const r = WebToApkFS.readBytes(uri, i * step, step);
    if (!r.ok) return r;
    onProgress(Math.round(((i + 1) / steps) * 100), r.base64);
  }
  return { ok: true, size: info.size };
}

Añade un arreglo grande de bytes a un archivo de la carpeta con permiso, un fragmento permitido a la vez:

function appendBytes(path, bytes) {                    // bytes: a Uint8Array
  const step = WebToApkFS.maxChunkSize();
  if (!step) return { ok: false, error: 'not in the app' };
  for (let at = 0; at < bytes.length; at += step) {
    const piece = bytes.subarray(at, at + step);
    let bin = '';
    for (let i = 0; i < piece.length; i += 0x8000) bin += String.fromCharCode.apply(null, piece.subarray(i, i + 0x8000));
    const w = WebToApkFS.writeBytes(path, btoa(bin), 0, 'append');   // 'append' ignores the offset
    if (!w.ok) return w;
  }
  return { ok: true };
}

Notas: Un fragmento menor mantiene la página más ágil (cada llamada es síncrona); el tope solo indica qué tan grande puede ser una llamada.

WebToApkFS.pickFile ayudante#

WebToApkFS.pickFile(mimeFilter)

Ejemplo

Abre el selector de archivos de Android para que el usuario elija UN archivo; tu página puede entonces leer y escribir los bytes reales de ese archivo, incluso después de que la app se reinicie.

Devuelve: Promise<{ok, uri, name, size, mime, error}>. Si todo va bien, ok es true y uri es una dirección content:// que pasas a stat, readBytes, writeBytes. Si falla, ok es false y error es 'cancelled', 'disabled', 'no-picker', 'busy' (ya hay un selector abierto para una llamada anterior, que conserva su propia respuesta), 'foreign_origin' o 'unsupported' (sin app). Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

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
}

Usa de nuevo más tarde el uri guardado, sin volver a preguntar al usuario:

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);
}

El filtro admite UN solo patrón, así que elige de forma amplia y comprueba el nombre tú mismo:

async function importTable() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/*');        // CSV and TSV files are both text/*
  if (!f.ok) {
    if (f.error === 'busy') return;                     // a picker is already open
    if (f.error !== 'cancelled') alert('Cannot open a file: ' + f.error);
    return;
  }
  if (!/\.(csv|tsv)$/i.test(f.name)) {
    WebToApkFS.releaseFile(f.uri);
    alert(f.name + ' is not a CSV or TSV file.');
    return;
  }
  const r = WebToApkFS.readAll(f.uri);
  if (r.ok) showTable(atob(r.base64), f.name.toLowerCase().endsWith('.tsv') ? '\t' : ',');
  WebToApkFS.releaseFile(f.uri);
}

Notas: El filtro es un solo patrón de tipo MIME ('image/*', 'text/csv'), no una lista. El selector pide acceso de lectura Y escritura y lo conserva. Android limita cuántos archivos puede conservar así una app, por eso llama a WebToApkFS.releaseFile(uri) cuando termines con un archivo. Para una carpeta completa usa WebToApkFS.requestAccess(). WebToApkFS se instala cuando están marcados Acceso nativo a carpetas o Claves seguras; sus llamadas solo funcionan con Acceso nativo a carpetas (WebToApkFS.isAvailable()).

WebToApkFS.readAll ayudante#

WebToApkFS.readAll(pathOrUri)

Ejemplo

Lee un archivo completo en una sola cadena base64. Recorre readBytes por ti, de 4 MB en 4 MB.

Devuelve: (síncrono) {ok:true, base64, size}, o el {ok:false, error} del primer fragmento que falle. Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

Carga una imagen elegida en un <img>:

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;
}

Lee un archivo de texto UTF-8 (sirve para cualquier idioma, no solo inglés):

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);
}

Muestra la suma de comprobación SHA-256 de un archivo, por ejemplo para compararla con la publicada de una descarga:

async function checksum() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);
  if (!r.ok) { alert('Read failed: ' + r.error); return; }
  const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
  const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
  const hex = Array.from(hash, function (b) { return b.toString(16).padStart(2, '0'); }).join('');
  console.log(f.name, r.size + ' bytes', hex);
}

Notas: Todo el archivo acaba en memoria (como bytes y luego como base64), y la llamada bloquea la página mientras se ejecuta. Úsalo para archivos que caben fácilmente en memoria; para archivos grandes recorre WebToApkFS.readBytes y procesa cada fragmento. Para texto simple dentro de la carpeta con permiso, WebToApkFS.readText(path) es más sencillo.

WebToApkFS.readBytes ayudante#

WebToApkFS.readBytes(pathOrUri, offset, length)

Ejemplo

Lee los bytes reales de un archivo, desde una posición en bytes, como base64. Seguro para imágenes, PDF y ZIP: la decodificación de texto no cambia nada.

Devuelve: (síncrono) {ok:true, base64, bytesRead, offset, size, eof} o {ok:false, error} ('not-found', 'disabled', 'cannot-open', 'offset past end of file (N bytes)', 'foreign_origin', 'unsupported'). Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

Lee los primeros 64 KB, por ejemplo para comprobar el encabezado de un archivo:

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');
}

Lee un archivo grande pieza por pieza, para que nunca tenga que caber entero en memoria:

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 };
  }
}

Lee solo el final de un archivo, por ejemplo la etiqueta de 128 bytes al final de un MP3:

function readMp3Title(uri) {
  const info = WebToApkFS.stat(uri);
  if (!info.ok || info.size < 128) return null;
  const r = WebToApkFS.readBytes(uri, info.size - 128, 128);
  if (!r.ok) return null;
  const tail = atob(r.base64);
  if (tail.slice(0, 3) !== 'TAG') return null;          // no old-style tag in this file
  return tail.slice(3, 33).replace(/\0+$/, '').trim();  // bytes 3-32 hold the title
}

Notas: Una llamada lee como máximo WebToApkFS.maxChunkSize() bytes (4 MB); length 0 significa "tanto como permita una llamada". Repite con bytesRead y detente en eof. La llamada es síncrona, así que mantén los fragmentos moderados en el hilo de la interfaz. Para archivos pequeños WebToApkFS.readAll(uri) hace el bucle por ti.

WebToApkFS.readSealed ayudante#

WebToApkFS.readSealed(pathOrUri, alias)

Ejemplo

Lee un archivo escrito con WebToApkFS.writeSealed y lo descifra con la misma clave del keystore.

Devuelve: (síncrono) {ok:true, base64} (los bytes originales), o {ok:false, error}: 'keystore-disabled', 'corrupt-sealed-file', un código del keystore ('no-such-key' después de eliminar la clave, 'auth-required', 'key-invalidated') o un error de archivo ('not-found' …). Requiere: marcar Acceso nativo a carpetas (SAF) y Claves seguras (cifrado) en el Paso 4 (Acceso) al compilar.

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;
}

Guarda los ajustes de la app en un archivo JSON sellado, con valores predeterminados antes del primer guardado:

function loadSettings() {
  const defaults = { theme: 'light', pinHint: '' };
  const out = WebToApkFS.readSealed('settings.sealed', 'settings');
  if (!out.ok) {
    if (out.error === 'not-found') return defaults;      // nothing saved yet
    throw new Error('Settings could not be opened: ' + out.error);
  }
  const bytes = Uint8Array.from(atob(out.base64), function (c) { return c.charCodeAt(0); });
  return Object.assign(defaults, JSON.parse(new TextDecoder().decode(bytes)));
}

Mueve un archivo sellado a una clave nueva y luego destruye la clave anterior:

function rekey(path, oldAlias, newAlias) {
  const plain = WebToApkFS.readSealed(path, oldAlias);
  if (!plain.ok) return plain;
  if (!AppMintKeystore.hasKey(newAlias)) {
    const k = AppMintKeystore.generateKey(newAlias, {});
    if (!k.ok) return k;
  }
  const w = WebToApkFS.writeSealed(path, plain.base64, newAlias);
  if (!w.ok) return w;
  AppMintKeystore.deleteKey(oldAlias);                  // only after the new copy is written
  return { ok: true };
}

Notas: Falla para siempre una vez que se ha ejecutado AppMintKeystore.deleteKey(alias): ese es el objetivo. El archivo completo se lee y descifra en memoria, de forma síncrona; mantén pequeños los archivos sellados (notas, claves, tokens).

WebToApkFS.releaseFile ayudante#

WebToApkFS.releaseFile(uri)

Ejemplo

Devuelve el acceso a largo plazo que obtuvo tu app cuando el usuario eligió un archivo con WebToApkFS.pickFile.

Devuelve: (síncrono) true cuando se liberó el permiso, false en caso contrario (sin permiso, URI no válida, fuera de la app). Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

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
}

Cuando el usuario elige un nuevo archivo predeterminado, devuelve el permiso del anterior:

async function chooseDefaultFile() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('');
  if (!f.ok) return;
  const old = localStorage.getItem('defaultFile');
  if (old && old !== f.uri) WebToApkFS.releaseFile(old);
  localStorage.setItem('defaultFile', f.uri);
}

Un botón "Olvidar archivos recientes" que libera todos los archivos que la app recordaba:

function forgetRecentFiles() {
  const recent = JSON.parse(localStorage.getItem('recentFiles') || '[]');
  let released = 0;
  for (const uri of recent) {
    if (WebToApkFS.releaseFile(uri)) released++;       // false: already gone or never granted
  }
  localStorage.removeItem('recentFiles');
  console.log('released', released, 'of', recent.length);
}

Notas: Android limita a cuántos archivos elegidos puede conservar acceso una app. Una app que elige muchos archivos (un importador, un visor) debería liberar cada uno al terminar. Tras liberarlo, stat / readBytes / writeBytes sobre esa URI responden not-found. No elimina el archivo.

WebToApkFS.stat ayudante#

WebToApkFS.stat(pathOrUri)

Ejemplo

Te indica el nombre, tamaño, tipo y fecha de un archivo sin leer su contenido.

Devuelve: (síncrono) {ok:true, name, size, mime, lastModified, uri, isDirectory, canWrite}, o {ok:false, error} con error = 'not-found', 'disabled', 'foreign_origin' o 'unsupported'. Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

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');
}

Una ruta dentro de la carpeta que el usuario concedió con WebToApkFS.requestAccess() también funciona:

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

Vuelve a importar un archivo recordado solo si cambió desde la última importación:

function importIfChanged(uri) {
  const info = WebToApkFS.stat(uri);
  if (!info.ok) return 'gone: ' + info.error;
  const seen = JSON.parse(localStorage.getItem('lastImport') || '{}');
  if (seen.size === info.size && seen.lastModified === info.lastModified) return 'unchanged';
  const r = WebToApkFS.readAll(uri);
  if (!r.ok) return 'read failed: ' + r.error;
  importData(atob(r.base64));
  localStorage.setItem('lastImport', JSON.stringify({ size: info.size, lastModified: info.lastModified }));
  return 'imported';
}

Notas: pathOrUri es una URI content:// de pickFile() o una ruta relativa a la carpeta con permiso. Una URI que nunca se le concedió a tu app devuelve not-found. lastModified son milisegundos desde 1970 (0 cuando el proveedor no lo indica).

WebToApkFS.writeAll ayudante#

WebToApkFS.writeAll(pathOrUri, base64)

Ejemplo

Reemplaza todo el contenido de un archivo con tus bytes. Divide los datos en fragmentos de 4 MB por ti: el primero trunca el archivo y el resto se añaden a continuación.

Devuelve: (síncrono) {ok:true, bytesWritten}, o el {ok:false, error} del primer fragmento que falle. Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

Guarda texto UTF-8 (en cualquier idioma) de vuelta en un archivo que eligió el usuario:

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);
}

Escribe una imagen de un canvas en la carpeta con permiso (el archivo se crea si no existe):

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);

Guarda una copia de seguridad JSON en la carpeta concedida, pidiendo antes una carpeta si no hay ninguna, y luego confirma su tamaño en el disco:

async function backup(data) {
  if (!window.WebToApkFS || !WebToApkFS.isAvailable()) return;
  if (!WebToApkFS.hasAccess()) {
    const a = await WebToApkFS.requestAccess();
    if (!a.success) return;
  }
  const bytes = new TextEncoder().encode(JSON.stringify(data));
  let bin = '';
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  const r = WebToApkFS.writeAll('backup.json', btoa(bin));
  if (!r.ok) { alert('Backup failed: ' + r.error); return; }
  const info = WebToApkFS.stat('backup.json');
  console.log('backup is', info.ok ? info.size : '?', 'bytes');
}

Notas: Toda la entrada permanece en memoria mientras se escribe, y la llamada bloquea la página. Si falla un fragmento posterior, el archivo ya contiene los anteriores: revisa ok e informa al usuario. Para mantener un archivo en secreto, usa mejor WebToApkFS.writeSealed.

WebToApkFS.writeBytes ayudante#

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

Ejemplo

Escribe bytes reales en un archivo y te dice cuántos llegaron. Puede modificar bytes en medio, reemplazar todo el archivo o añadir al final.

Devuelve: (síncrono) {ok, bytesWritten, offset, size} (size se vuelve a leer del archivo después de volcar los datos al disco) o {ok:false, error}. Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

Modificar: cambia 4 bytes en la posición 100 y conserva el resto del archivo:

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);

Truncar: reemplaza todo el archivo (se ignora el offset):

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);

Añadir: agrega al final (se ignora el offset):

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

Notas: mode es 'patch' (predeterminado), 'truncate' o 'append'. Como máximo 4 MB por llamada (WebToApkFS.maxChunkSize()); para datos mayores usa WebToApkFS.writeAll. Una ruta dentro de la carpeta con permiso se crea si no existe; una URI content:// elegida debe ser una que el usuario eligió. btoa solo acepta texto Latin-1: para otro texto, codifícalo primero a bytes (consulta WebToApkFS.writeAll). Errores: disabled, foreign_origin (la página en pantalla no es la propia de la app), bad mode: x, bad base64, chunk too large (max N bytes), not-found, cannot-open, short write.

WebToApkFS.writeSealed ayudante#

WebToApkFS.writeSealed(pathOrUri, base64, alias)

Ejemplo

Escribe un archivo cifrado con una clave respaldada por hardware de AppMintKeystore. Eliminar esa clave después deja el archivo ilegible para todos: el único "borrado seguro" real en un teléfono.

Devuelve: (síncrono) {ok:true, bytesWritten} (tamaño cifrado en disco), o {ok:false, error}: 'keystore-disabled', un código del keystore como 'no-such-key', 'auth-required', 'key-invalidated', o un error de archivo. Necesita: marcar Acceso nativo a carpetas (SAF) y Claves seguras (cifrado) en el Paso 4 (Acceso) al compilar.

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;
}

Elimínalo de verdad: destruye la clave y luego quita el archivo:

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

Sella una foto que el usuario eligió, como bytes sin procesar, en la carpeta a la que dio permiso:

async function sealPhoto(file) {                  // a File from <input type="file" accept="image/*">
  if (!window.WebToApkFS || !window.AppMintKeystore || !AppMintKeystore.isAvailable()) return false;
  if (!WebToApkFS.hasAccess()) {
    const g = await WebToApkFS.requestAccess();
    if (!g.success) return false;
  }
  if (!AppMintKeystore.hasKey('vault')) AppMintKeystore.generateKey('vault', {});
  const dataUrl = await new Promise(function (resolve, reject) {
    const fr = new FileReader();
    fr.onload = function () { resolve(fr.result); };
    fr.onerror = reject;
    fr.readAsDataURL(file);
  });
  const b64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
  const r = WebToApkFS.writeSealed('photo-' + Date.now() + '.sealed', b64, 'vault');
  if (!r.ok) alert('Not saved: ' + r.error);
  return r.ok;
}

Una clave de diario que solo funciona durante 5 minutos después de desbloquear el teléfono, con cada error explicado:

if (!AppMintKeystore.hasKey('diary')) {
  AppMintKeystore.generateKey('diary', { requireAuth: true, authValiditySeconds: 300 });
}

function saveDiary(entry) {
  const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(entry))));   // UTF-8 safe
  const r = WebToApkFS.writeSealed('diary.bin', b64, 'diary');
  if (r.ok) return true;
  if (r.error === 'auth-required') showMessage('Unlock your phone again, then tap Save.');
  else if (r.error === 'key-invalidated') showMessage('Your screen lock changed, so this key no longer works.');
  else showMessage('Could not save: ' + r.error);
  return false;
}

Notas: No "sobrescribas con ceros" para borrar: el almacenamiento flash del teléfono conserva los bloques antiguos. El formato del archivo es una lista de fragmentos AES-GCM con prefijo de longitud; léelo de nuevo solo con WebToApkFS.readSealed y el mismo alias. Una clave creada con {requireAuth:true} responde auth-required hasta que el usuario haya desbloqueado el teléfono recientemente.

downloads estándar web#

<a download> and Blob downloads

Ejemplo

Tu código normal de descargas web funciona en la app, igual que en Chrome. Un WebView ignora por sí solo las descargas blob: y data:; la app las captura y guarda el archivo en la carpeta Descargas del teléfono con tu nombre de archivo, y luego muestra "Descargado <nombre> · Abrir" y una notificación de "Descarga completa".

Devuelve: nada a la página. Necesita: nada que activar. Las descargas desde direcciones web (https://…) también van a la carpeta Descargas (un PDF o una imagen primero pregunta "Ver o Descargar"), y lo mismo los archivos empaquetados dentro de la app (<a href="manual.pdf" download>); en Android 9 y anteriores la app pide antes permiso de almacenamiento. Los archivos grandes no son problema: el archivo se transmite a la app por partes.

<a download> con un Blob: el patrón más común:

<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, saved to Downloads
  a.remove();
});
</script>

Una URL data:, FileSaver.js y navigator.msSaveBlob también funcionan:

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 de una URL blob: o data: significa "muéstralo", no "guárdalo": un PDF se abre en el visor de PDF de la app y otros tipos en la app del teléfono para ese tipo:

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

Notas: Sin nombre, el archivo se guarda como download.<ext>; un segundo archivo con el mismo nombre pasa a ser name (1).ext. Un objeto File trae su propio nombre (URL.createObjectURL(file)). <a href="/files/x" download="Name.pdf"> en tu propio sitio conserva Name.pdf (Chrome ignora el atributo en enlaces de otros sitios, y la app también). Si ninguna app del teléfono puede mostrar un archivo window.open, se guarda en Descargas. Para que el usuario elija la carpeta, usa showSaveFilePicker(). Para guardar sin un elemento <a>, usa AppMint.saveBlob(blob, name) o AppMint.downloadFile(base64, name, mime).

input-file estándar web#

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

Ejemplo

Un campo de archivo normal funciona en la app igual que en Chrome: abre el selector de fotos, la cámara o el selector de archivos, y tu página recibe objetos File normales.

Devuelve: el evento change del campo con input.files (vacío cuando el usuario cancela). Necesita: las fotos y los archivos no necesitan nada. Para capture (la cámara) marca Cámara en el Paso 4 (Acceso) al compilar; la app pide el permiso de la cámara la primera vez.

Fotos: abre el selector de fotos de Android (sin solicitud de permiso; la página recibe solo lo que el usuario eligió):

<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>

Cámara: capture omite el selector y abre la cámara de inmediato:

<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>

Cualquier archivo, o varios tipos (las extensiones también sirven):

<input type="file" accept=".pdf,.csv,application/json" multiple>
EscribesQué se abre
accept="image/*" / "video/*" / "image/*,video/*"el selector de fotos (fotos, videos o ambos)
añadir multipleel usuario puede elegir varios
añade capture (con Cámara marcada)la cámara directamente: foto para image/*, video para video/*
accept="image/*,.pdf" o ninguno acceptel selector de archivos del sistema

Notas: Sin Cámara marcada, capture se ignora y se abre el selector. Cuando Acceso nativo a carpetas (SAF) está marcado, la app muestra en su lugar su propio menú "Seleccionar origen de la carga": Tomar foto / Grabar video (si Cámara está marcada), Elegir archivos y Subir carpeta (envía todos los archivos de una carpeta). Con Almacenamiento marcado en Android 12 y anteriores, elegir un archivo que no sea una foto puede pedir antes permiso de almacenamiento. Los teléfonos sin selector de fotos reciben el selector de archivos, que muestra las mismas fotos.

showOpenFilePicker estándar web#

showOpenFilePicker / showSaveFilePicker / showDirectoryPicker — File System Access

Ejemplo

La API estándar File System Access funciona dentro de la app, sobre el selector de documentos propio de Android: abre un archivo y guarda de vuelta EN ESE ARCHIVO, crea un archivo con una hoja Guardar como, u obtén una carpeta completa para leer, crear y eliminar archivos en ella.

Devuelve: showOpenFilePicker(options) → Promesa de un arreglo de FileSystemFileHandle; showSaveFilePicker(options) → un FileSystemFileHandle; showDirectoryPicker(options) → un FileSystemDirectoryHandle. Un selector cancelado rechaza con AbortError; si no hay gesto del usuario rechaza con SecurityError; un segundo selector mientras hay otro abierto rechaza con NotAllowedError. Necesita: nada que activar y ningún permiso: el usuario elige.

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);
});

Elige varias fotos a la vez, empezando en la carpeta Imágenes:

document.getElementById('add-photos').addEventListener('click', async () => {
  try {
    const handles = await showOpenFilePicker({
      multiple: true,
      startIn: 'pictures',
      types: [{ description: 'Images', accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
    });
    for (const h of handles) {
      const img = document.createElement('img');
      img.src = URL.createObjectURL(await h.getFile());
      gallery.appendChild(img);
    }
  } catch (e) {
    if (e.name !== 'AbortError') alert('Could not open: ' + e.message);
  }
});

Añade una línea al final de un archivo de registro sin reescribir lo que ya hay:

async function appendLine(handle, line) {      // handle from showOpenFilePicker / showSaveFilePicker
  const size = (await handle.getFile()).size;
  const w = await handle.createWritable({ keepExistingData: true });
  await w.seek(size);
  await w.write(line + '\n');
  await w.close();
}

Elige una carpeta de copia de seguridad UNA sola vez y guarda en ella automáticamente en cada inicio posterior: conserva el handle en IndexedDB, igual que en Chrome. La app mantiene el permiso de la carpeta entre reinicios, así que queryPermission() responde 'granted' y la copia no necesita ningún toque ni selector:

// Tiny IndexedDB helpers (idb-keyval's get/set work the same way).
function db() {
  return new Promise((ok, fail) => {
    const r = indexedDB.open('backup-settings', 1);
    r.onupgradeneeded = () => r.result.createObjectStore('kv');
    r.onsuccess = () => ok(r.result);
    r.onerror = () => fail(r.error);
  });
}
async function kvGet(key) {
  const d = await db();
  return new Promise((ok, fail) => {
    const r = d.transaction('kv').objectStore('kv').get(key);
    r.onsuccess = () => ok(r.result);
    r.onerror = () => fail(r.error);
  });
}
async function kvSet(key, value) {
  const d = await db();
  return new Promise((ok, fail) => {
    const t = d.transaction('kv', 'readwrite');
    t.objectStore('kv').put(value, key);
    t.oncomplete = () => ok();
    t.onerror = () => fail(t.error);
  });
}

// "Choose backup folder" button (the picker needs a tap).
document.getElementById('choose-folder').addEventListener('click', async () => {
  try {
    const dir = await showDirectoryPicker({ mode: 'readwrite', startIn: 'documents' });
    await kvSet('backupDir', dir);
    status.textContent = 'Backups go to ' + dir.name;
  } catch (e) {
    if (e.name !== 'AbortError') status.textContent = 'Could not choose: ' + e.message;
  }
});

// Called after every workout, and on a timer: no tap needed once the folder is chosen.
async function autoBackup(data) {
  const dir = await kvGet('backupDir');
  if (!dir) return 'no folder chosen yet';
  if ((await dir.queryPermission({ mode: 'readwrite' })) !== 'granted') {
    return 'folder access was removed: tap "Choose backup folder" again';
  }
  const file = await dir.getFileHandle('backup-' + new Date().toISOString().slice(0, 10) + '.json', { create: true });
  const w = await file.createWritable();
  await w.write(JSON.stringify(data));
  await w.close();
  return 'saved ' + file.name;
}

Si se quitó el permiso (el usuario borró el acceso de la app o eliminó la carpeta), queryPermission() responde 'prompt'. Desde un toque en un botón, requestPermission() reabre el selector de carpetas EN esa carpeta; elegir la misma carpeta responde 'granted', cualquier otra (o cancelar) 'denied':

document.getElementById('reconnect').addEventListener('click', async () => {
  const dir = await kvGet('backupDir');
  if (dir && (await dir.requestPermission({ mode: 'readwrite' })) === 'granted') {
    status.textContent = 'Backups go to ' + dir.name + ' again';
  } else {
    status.textContent = 'Choose a backup folder';
  }
});

Limpia una carpeta que eligió el usuario: elimina archivos .tmp antiguos y subcarpetas llamadas cache:

document.getElementById('clean').addEventListener('click', async () => {
  const dir = await showDirectoryPicker({ mode: 'readwrite' });
  let removed = 0;
  for await (const entry of dir.values()) {
    if (entry.kind === 'file' && entry.name.endsWith('.tmp')) {
      await dir.removeEntry(entry.name);
      removed++;
    } else if (entry.kind === 'directory' && entry.name === 'cache') {
      await dir.removeEntry(entry.name, { recursive: true });
      removed++;
    }
  }
  alert(removed + ' item(s) removed');
});

Notas: createWritable() sigue el modelo de archivo de intercambio de la especificación: abort() (o que la página desaparezca) deja el original intacto; { keepExistingData: true } parte del contenido actual; write({type:'seek'|'truncate'|'write', …}), seek() y truncate() funcionan. Un archivo que su app ofrece como solo lectura responde queryPermission({mode:'readwrite'}) con 'denied' y createWritable() rechaza con NoModificationAllowedError. getFile() transmite el documento, así que los archivos grandes no son problema. Los handles pueden guardarse en IndexedDB (también dentro de objetos y arreglos) y usarse de nuevo tras reiniciar la app: la app conserva el permiso de Android para cada carpeta y archivo que devolvió un selector (los 64 más recientes; el más antiguo se libera primero). queryPermission() responde 'granted' mientras se conserva ese permiso y 'prompt' cuando ya no existe; requestPermission() entonces necesita un toque y reabre el selector en la carpeta propia del handle. Las apps compiladas antes del 2026-10-03 perdían el handle al reiniciar: vuelve a compilar y el usuario elegirá la carpeta una vez más. types se convierten en el filtro de archivos del selector. Solo la página de nivel superior tiene los selectores. Transportes internos: __fsPick (respondido con el evento appmint:fs), __fsFile, __fsList, __fsChild, __fsRemove, __fsPermission, __fsWritableOpen/Write/Truncate/Close/Abort. Las apps creadas con IA comprueban can('fileHandles').

__dlAbort puente#

window.WebToApk.__dlAbort(token: String)

Descarta la descarga [token] (la lectura de la página falló o se rindió).

Ejemplo

Documentado junto con WebToApk.__dlBegin: el ejemplo de allí también muestra este.

Transporte interno detrás de <a download>, las descargas de Blob, window.open(blobUrl) y AppMint.saveBlob / AppMint.downloadFile: el archivo se transmite de la página a la app en porciones, de modo que una exportación grande nunca es una cadena gigante en memoria. Las páginas usan el código de descarga estándar; nunca llames a esto directamente, porque los métodos __ pueden cambiar sin previo aviso.

Devuelve: __dlBegin(name, type, size) devuelve un token (o "" cuando el teléfono no tiene espacio); __dlChunk(token, base64) devuelve false si no se pudo escribir una porción; __dlEnd(token, action) termina (save = Descargas, open = mostrarlo, share, copy). Necesita: nada.

Usa la API pública:

const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"

Exporta un dibujo de un canvas por la misma vía:

canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');

Guarda una copia de seguridad JSON que el usuario pueda conservar, con un mensaje al terminar:

function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}

Notas: Implementado en el shell de la app (ShellDownloads.kt).

__dlBegin puente#

window.WebToApk.__dlBegin(name: String, type: String, size: Double): String

Abre una descarga de [size] bytes llamada [name] ([type] = su tipo MIME) que la página transmitirá con __dlChunk. Devuelve un token, o "" cuando no hay espacio.

Ejemplo

Transporte interno detrás de <a download>, las descargas de Blob, window.open(blobUrl) y AppMint.saveBlob / AppMint.downloadFile: el archivo se transmite de la página a la app en porciones, de modo que una exportación grande nunca es una cadena gigante en memoria. Las páginas usan el código de descarga estándar; nunca llames a esto directamente, porque los métodos __ pueden cambiar sin previo aviso.

Devuelve: __dlBegin(name, type, size) devuelve un token (o "" cuando el teléfono no tiene espacio); __dlChunk(token, base64) devuelve false si no se pudo escribir una porción; __dlEnd(token, action) termina (save = Descargas, open = mostrarlo, share, copy). Necesita: nada.

Usa la API pública:

const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"

Exporta un dibujo de un canvas por la misma vía:

canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');

Guarda una copia de seguridad JSON que el usuario pueda conservar, con un mensaje al terminar:

function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}

Notas: Implementado en el shell de la app (ShellDownloads.kt).

__dlChunk puente#

window.WebToApk.__dlChunk(token: String, base64: String): Boolean

Una porción base64 de la descarga [token]; false cuando la transferencia falló.

Ejemplo

Documentado junto con WebToApk.__dlBegin: el ejemplo de allí también muestra este.

Transporte interno detrás de <a download>, las descargas de Blob, window.open(blobUrl) y AppMint.saveBlob / AppMint.downloadFile: el archivo se transmite de la página a la app en porciones, de modo que una exportación grande nunca es una cadena gigante en memoria. Las páginas usan el código de descarga estándar; nunca llames a esto directamente, porque los métodos __ pueden cambiar sin previo aviso.

Devuelve: __dlBegin(name, type, size) devuelve un token (o "" cuando el teléfono no tiene espacio); __dlChunk(token, base64) devuelve false si no se pudo escribir una porción; __dlEnd(token, action) termina (save = Descargas, open = mostrarlo, share, copy). Necesita: nada.

Usa la API pública:

const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"

Exporta un dibujo de un canvas por la misma vía:

canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');

Guarda una copia de seguridad JSON que el usuario pueda conservar, con un mensaje al terminar:

function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}

Notas: Implementado en el shell de la app (ShellDownloads.kt).

__dlEnd puente#

window.WebToApk.__dlEnd(token: String, action: String): Boolean

Termina la descarga [token] y actúa sobre ella: [action] "save" (Descargas, con la barra "Descargado · Abrir" y la notificación), "open" (visor de PDF o la app del teléfono para ese tipo), "share" (hoja para compartir) o "copy" (imagen al portapapeles).

Ejemplo

Documentado junto con WebToApk.__dlBegin: el ejemplo de allí también muestra este.

Transporte interno detrás de <a download>, las descargas de Blob, window.open(blobUrl) y AppMint.saveBlob / AppMint.downloadFile: el archivo se transmite de la página a la app en porciones, de modo que una exportación grande nunca es una cadena gigante en memoria. Las páginas usan el código de descarga estándar; nunca llames a esto directamente, porque los métodos __ pueden cambiar sin previo aviso.

Devuelve: __dlBegin(name, type, size) devuelve un token (o "" cuando el teléfono no tiene espacio); __dlChunk(token, base64) devuelve false si no se pudo escribir una porción; __dlEnd(token, action) termina (save = Descargas, open = mostrarlo, share, copy). Necesita: nada.

Usa la API pública:

const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"

Exporta un dibujo de un canvas por la misma vía:

canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');

Guarda una copia de seguridad JSON que el usuario pueda conservar, con un mensaje al terminar:

function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}

Notas: Implementado en el shell de la app (ShellDownloads.kt).

__fsChild puente#

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

getFileHandle / getDirectoryHandle (con `create`).

Ejemplo

Transporte interno detrás de getFileHandle(name, {create}) y getDirectoryHandle(name, {create}). Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: JSON {ok, kind, name, uri, canWrite} o {ok:false, error} (NotFoundError, TypeMismatchError, NoModificationAllowedError). Necesita: una carpeta de showDirectoryPicker().

Usa la API pública:

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

Comprueba si un archivo ya existe en la carpeta antes de crearlo:

async function exists(dir, name) {
  try {
    await dir.getFileHandle(name);                     // no {create} = look up only
    return true;
  } catch (e) {
    if (e.name === 'NotFoundError') return false;
    if (e.name === 'TypeMismatchError') return true;   // a folder already has that name
    throw e;
  }
}

Guarda los ajustes de la app como JSON en una subcarpeta "config", creando ambas según haga falta:

async function saveSettings(root, settings) {
  const config = await root.getDirectoryHandle('config', { create: true });
  const file = await config.getFileHandle('settings.json', { create: true });
  const w = await file.createWritable();
  await w.write(JSON.stringify(settings, null, 2));
  await w.close();
}
// root = await showDirectoryPicker()  (from a button tap)

Notas: Implementado en el shell de la app (FileSystemAccess.kt).

__fsFile puente#

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

getFile(): `{ok, name, type, size, lastModified, url}` (el cuerpo se transmite desde url).

Ejemplo

Transporte interno detrás de FileSystemFileHandle.getFile(). Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: JSON {ok, name, type, size, lastModified, url} (el cuerpo se transmite desde url, el origen propio de la app, con rangos) o {ok:false, error} (NotFoundError, TypeMismatchError, foreign_origin). Necesita: un handle de alguno de los selectores.

Usa la API pública:

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

Muestra en la página una imagen elegida sin leerla a una cadena:

document.querySelector('#choose').addEventListener('click', async function () {
  const [handle] = await showOpenFilePicker({ types: [{ accept: { 'image/*': [] } }] });
  const file = await handle.getFile();                    // a real File, streamed from the app
  const img = document.querySelector('#preview');
  if (img.src) URL.revokeObjectURL(img.src);
  img.src = URL.createObjectURL(file);
  img.alt = file.name + ' (' + file.type + ')';
});

Vuelve a leer un archivo que abriste antes y detecta cuándo cambió o se eliminó:

async function reloadIfChanged(handle, lastSeen) {
  try {
    const file = await handle.getFile();
    if (file.lastModified === lastSeen) return null;      // unchanged
    return { data: JSON.parse(await file.text()), lastModified: file.lastModified };
  } catch (e) {
    if (e.name === 'NotFoundError') alert(handle.name + ' was moved or deleted.');
    else throw e;
    return null;
  }
}

Notas: Implementado en el shell de la app (FileSystemAccess.kt).

__fsList puente#

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

Las entradas de un handle de directorio: `{ok, entries:[{kind, name, uri, canWrite}]}`.

Ejemplo

Transporte interno detrás de la iteración de un FileSystemDirectoryHandle (entries(), keys(), values(), for await). Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: JSON {ok, entries:[{kind, name, uri, canWrite}]} o {ok:false, error}. Necesita: una carpeta de showDirectoryPicker().

Usa la API pública:

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

Cuenta los archivos de una carpeta y de todas sus subcarpetas:

async function countFiles(dir) {
  let n = 0;
  for await (const entry of dir.values()) {
    if (entry.kind === 'file') n++;
    else n += await countFiles(entry);                 // entry is a FileSystemDirectoryHandle
  }
  return n;
}
document.querySelector('#scan').addEventListener('click', async function () {
  const dir = await showDirectoryPicker();
  console.log(dir.name, 'holds', await countFiles(dir), 'files');
});

Crea una lista ordenada de las fotos de una carpeta que eligió el usuario:

document.querySelector('#open-album').addEventListener('click', async function () {
  const dir = await showDirectoryPicker({ startIn: 'pictures' });
  const names = [];
  for await (const name of dir.keys()) {
    if (/\.(jpe?g|png|webp)$/i.test(name)) names.push(name);
  }
  names.sort(function (a, b) { return a.localeCompare(b); });
  renderAlbum(dir, names);                             // dir.getFileHandle(name) opens each one
});

Notas: Implementado en el shell de la app (FileSystemAccess.kt).

__fsPermission puente#

window.WebToApk.__fsPermission(rootUri: String, uri: String): String

queryPermission() / requestPermission(): `{ok, state:'granted'|'prompt'}` para un handle cuya raíz elegida es [rootUri]: 'granted' mientras la app conserve el permiso de esa selección, entre relanzamientos (el permiso se persiste; un handle vuelve desde IndexedDB).

Ejemplo

Transporte interno detrás de FileSystemHandle.queryPermission() y requestPermission(). Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: JSON {ok:true, state:'granted'} mientras la app conserve el permiso de la carpeta o del archivo elegido del que proviene el handle (se mantiene entre reinicios), {ok:true, state:'prompt'} cuando ya no existe, o {ok:false, error:'foreign_origin'}. Un archivo de solo lectura responde 'denied' para {mode:'readwrite'} en la página, sin hacer ninguna llamada. Necesita: un handle de alguno de los selectores, también uno leído de nuevo desde IndexedDB.

Usa la API pública:

const dir = await kvGet('backupDir');                 // a handle stored in IndexedDB earlier
if ((await dir.queryPermission({ mode: 'readwrite' })) === 'granted') {
  const f = await dir.getFileHandle('backup.json', { create: true });
  const w = await f.createWritable();
  await w.write(JSON.stringify(state));
  await w.close();
}

Vuelve a pedirlo con un toque cuando el permiso haya desaparecido: el selector de carpetas se abre en esa carpeta, y solo la misma carpeta concede el permiso:

document.getElementById('reconnect').addEventListener('click', async () => {
  const dir = await kvGet('backupDir');
  const state = await dir.requestPermission({ mode: 'readwrite' });   // 'granted' | 'denied'
  status.textContent = state === 'granted' ? 'Backups resumed' : 'Choose a backup folder';
});

Notas: Implementado en el shell de la app (FileSystemAccess.kt). Consulta showOpenFilePicker para ver toda la API File System Access.

__fsPick puente#

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

Abre el selector [kind] (open | save | directory); los handles llegan como `appmint:fs`.

Ejemplo

Transporte interno detrás de showOpenFilePicker(), showSaveFilePicker() y showDirectoryPicker(). Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: {ok:true} una vez que el selector está en pantalla, o {ok:false, error} (NotAllowedError cuando ya hay uno abierto, foreign_origin). Los handles llegan como el evento appmint:fs {requestId, handles:[{kind, name, uri, canWrite}]} o {requestId, error:'cancelled'}. Necesita: nada que activar.

Usa la API pública:

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

Guarda un informe con un nombre sugerido, desde un toque en un botón (los selectores necesitan un gesto del usuario):

document.querySelector('#export').addEventListener('click', async function () {
  try {
    const handle = await showSaveFilePicker({
      suggestedName: 'report.csv',
      types: [{ description: 'CSV', accept: { 'text/csv': ['.csv'] } }]
    });
    const w = await handle.createWritable();
    await w.write(buildCsv());
    await w.close();
  } catch (e) {
    if (e.name !== 'AbortError') alert('Export failed: ' + e.message);   // AbortError = cancelled
  }
});

Deja que el usuario elija varias fotos a la vez, empezando en la carpeta Imágenes:

document.querySelector('#add-photos').addEventListener('click', async function () {
  let handles;
  try {
    handles = await showOpenFilePicker({
      multiple: true,
      startIn: 'pictures',
      types: [{ accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
    });
  } catch (e) { return; }                                 // cancelled or already open
  for (const h of handles) addToGallery(await h.getFile());
});

Notas: Implementado en el shell de la app (FileSystemAccess.kt). El mismo código de página funciona sin cambios en Chrome de escritorio.

__fsRemove puente#

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

removeEntry(name, {recursive}).

Ejemplo

Transporte interno detrás de FileSystemDirectoryHandle.removeEntry(name, {recursive}). Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: JSON {ok:true} o {ok:false, error} (NotFoundError, InvalidModificationError para una carpeta no vacía sin recursive). Necesita: una carpeta de showDirectoryPicker().

Usa la API pública:

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

Elimina un archivo y considera "ya no existe" como hecho:

async function deleteFile(dir, name) {
  try {
    await dir.removeEntry(name);
  } catch (e) {
    if (e.name !== 'NotFoundError') throw e;   // NotFoundError: nothing left to delete
  }
}

Elimina una carpeta, preguntando antes solo cuando todavía tenga algo dentro:

async function removeFolder(dir, name) {
  try {
    await dir.removeEntry(name);                          // succeeds for an empty folder
  } catch (e) {
    if (e.name !== 'InvalidModificationError') throw e;
    if (!confirm('"' + name + '" is not empty. Delete it and everything inside?')) return false;
    await dir.removeEntry(name, { recursive: true });
  }
  return true;
}

Notas: Implementado en el shell de la app (FileSystemAccess.kt).

__fsWritableAbort puente#

window.WebToApk.__fsWritableAbort(token: String)

abort(): se descarta el intercambio; el documento queda intacto.

Ejemplo

Transporte interno detrás de FileSystemWritableFileStream.abort(): la copia de intercambio se descarta y el archivo queda como estaba. Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: nada. Necesita: un flujo de escritura abierto.

Usa la API pública:

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

Deja que el usuario cancele una exportación larga; nada a medio escribir llega al archivo:

let cancelled = false;
document.getElementById('cancel').onclick = function () { cancelled = true; };

async function exportAll(handle, pages) {
  const w = await handle.createWritable();
  for (const page of pages) {
    if (cancelled) { await w.abort(); return 'cancelled'; }   // the file keeps its old content
    await w.write(await renderPage(page));
  }
  await w.close();
  return 'saved';
}

Descarga directamente a un archivo con pipeTo; detener la descarga aborta la escritura:

const ctl = new AbortController();
document.getElementById('stop').onclick = function () { ctl.abort(); };

async function downloadInto(handle, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error('HTTP ' + res.status);
  const w = await handle.createWritable();
  try {
    await res.body.pipeTo(w, { signal: ctl.signal });     // closes the file when the body ends
    return 'saved';
  } catch (e) {
    return e.name === 'AbortError' ? 'stopped, file unchanged' : 'failed: ' + e.message;
  }
}

Notas: Implementado en el shell de la app (FileSystemAccess.kt).

__fsWritableClose puente#

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

close(): la copia de intercambio reemplaza el documento.

Ejemplo

Transporte interno detrás de FileSystemWritableFileStream.close(): la copia de intercambio reemplaza el archivo. Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: JSON {ok:true} o {ok:false, error} (NoModificationAllowedError, InvalidModificationError cuando la app del archivo no puede acortarlo). Requiere: un flujo de escritura abierto.

Usa la API pública:

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

Dile al usuario por qué no se completó un guardado (close() es donde puede fallar):

async function saveText(handle, text) {
  const w = await handle.createWritable();
  await w.write(text);
  try {
    await w.close();
    return true;
  } catch (e) {
    if (e.name === 'InvalidModificationError') alert('This file\'s app cannot make it shorter. Save a copy instead.');
    else if (e.name === 'NoModificationAllowedError') alert('This file cannot be changed.');
    else alert('Save failed: ' + e.message);
    return false;
  }
}

Escribe un archivo en varias partes; los lectores siguen viendo el contenido anterior hasta que la única close():

async function saveRows(handle, rows) {
  const w = await handle.createWritable();
  await w.write('id,name,total\n');
  for (const r of rows) await w.write(r.id + ',' + r.name + ',' + r.total + '\n');
  console.log('before close:', (await handle.getFile()).size, 'bytes (old file)');
  await w.close();
  console.log('after close:', (await handle.getFile()).size, 'bytes');
}

Notas: Implementado en el shell de la app (FileSystemAccess.kt).

__fsWritableOpen puente#

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

createWritable(): un archivo de intercambio, copiado del archivo cuando [keepExisting].

Ejemplo

Transporte interno detrás de FileSystemFileHandle.createWritable({keepExistingData}). Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: JSON {ok, token} para una copia de intercambio privada, o {ok:false, error} (NoModificationAllowedError para un archivo de solo lectura). Necesita: un handle de alguno de los selectores.

Usa la API pública:

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();

Añade una línea al final de un archivo de registro existente, conservando lo que ya contiene:

async function appendLine(handle, line) {
  const size = (await handle.getFile()).size;
  const w = await handle.createWritable({ keepExistingData: true });
  await w.seek(size);                                     // write after the old content
  await w.write(new Date().toISOString() + ' ' + line + '\n');
  await w.close();                                        // only now does the file change
}

Guarda un canvas como PNG y deja el archivo intacto si algo sale mal:

document.querySelector('#save-png').addEventListener('click', async function () {
  const handle = await showSaveFilePicker({
    suggestedName: 'drawing.png',
    types: [{ accept: { 'image/png': ['.png'] } }]
  });
  const blob = await new Promise(function (r) { document.querySelector('canvas').toBlob(r, 'image/png'); });
  const w = await handle.createWritable();
  try {
    await w.write(blob);
    await w.close();
  } catch (e) {
    try { await w.abort(); } catch (x) {}                 // nothing reaches the file before close()
    alert('Could not save: ' + e.message);
  }
});

Notas: Implementado en el shell de la app (FileSystemAccess.kt).

__fsWritableTruncate puente#

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

truncate(size) en un flujo de escritura.

Ejemplo

Transporte interno detrás de FileSystemWritableFileStream.truncate(size). Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: JSON {ok:true} o {ok:false, error}. Requiere: un flujo de escritura abierto.

Usa la API pública:

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

Conserva solo los primeros 1.000 bytes de un archivo y corta todo lo que sigue:

async function keepFirstKilobyte(handle) {
  const w = await handle.createWritable({ keepExistingData: true });
  await w.truncate(1000);   // a file shorter than this grows instead (see the next example)
  await w.close();
}

Reserva espacio: truncar a un tamaño mayor hace crecer el archivo, y los bytes nuevos se leen como ceros:

async function makeEmptySlotFile(handle, slots, slotSize) {
  const w = await handle.createWritable();
  await w.truncate(slots * slotSize);                  // e.g. 100 slots of 256 bytes, all zero
  await w.write({ type: 'write', position: 0, data: new Uint8Array([1, 0, 0, 0]) });
  await w.close();
  console.log('file is now', (await handle.getFile()).size, 'bytes');
}

Notas: Implementado en el shell de la app (FileSystemAccess.kt).

__fsWritableWrite puente#

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

Un fragmento del write() de un flujo de escritura, en [position].

Ejemplo

Transporte interno detrás de FileSystemWritableFileStream.write(): un fragmento en una posición de la copia de intercambio. Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: JSON {ok:true} o {ok:false, error}. Requiere: un flujo de escritura abierto.

Usa la API pública:

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

Añade una línea al final de un archivo de registro que eligió el usuario (un selector de archivos necesita un toque):

document.getElementById('addLine').addEventListener('click', async () => {
  const [handle] = await showOpenFilePicker({
    types: [{ description: 'Text', accept: { 'text/plain': ['.txt', '.log'] } }]
  });
  const size = (await handle.getFile()).size;
  const w = await handle.createWritable({ keepExistingData: true });
  await w.seek(size);                                   // start writing after the old content
  await w.write(new Date().toISOString() + ' checked in\n');
  await w.close();
});

Guarda un dibujo de un canvas como un PNG nuevo con "Guardar como" (un Blob se escribe como sus bytes sin procesar):

document.getElementById('saveDrawing').addEventListener('click', async () => {
  const handle = await showSaveFilePicker({
    suggestedName: 'drawing.png',
    types: [{ description: 'PNG image', accept: { 'image/png': ['.png'] } }]
  });
  const blob = await new Promise(function (ok) { document.querySelector('canvas').toBlob(ok, 'image/png'); });
  const w = await handle.createWritable();
  await w.write(blob);
  await w.close();
});

Notas: Implementado en el shell de la app (FileSystemAccess.kt). Los datos grandes se envían por bloques.

getMaxIoChunkSize puente#

window.WebToApk.getMaxIoChunkSize(): Long

El tope de bytes por llamada de [readFileBase64] / [writeFileBase64], para calcular fragmentos.

Ejemplo

El límite de bytes por llamada de readFileBase64 / writeFileBase64 (4 MB). Normalmente las páginas usan WebToApkFS.maxChunkSize().

Devuelve: (síncrono) un número (4194304). Necesita: nada para llamarlo; los métodos de lectura/escritura que describe necesitan Acceso nativo a carpetas (SAF) marcado en el Paso 4 (Acceso).

La forma pública:

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

La llamada en bruto:

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');

Calcula cuántas llamadas al puente necesitará un archivo, para dimensionar una barra de progreso antes de copiarlo:

function plannedSteps(fileUri) {
  const step = WebToApkFS.maxChunkSize();          // 0 when the helper is not available
  const info = WebToApkFS.stat(fileUri);
  if (!step || !info.ok) return 0;
  const steps = Math.max(1, Math.ceil(info.size / step));
  document.querySelector('#progress').max = steps;
  return steps;
}

Copia un archivo en otro, un fragmento del tamaño máximo a la vez:

function copyFile(fromUri, toPath) {
  const step = WebToApkFS.maxChunkSize() || 1048576;
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(fromUri, offset, step);
    if (!r.ok) return r;
    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, bytes: offset };
  }
}
// copyFile(pickedUri, 'photo-backup.jpg')  (a file in the granted folder)

Notas: Una escritura mayor que esto se rechaza con chunk too large (max N bytes); una lectura mayor se recorta a este tamaño.

pickFile puente#

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

Abre el selector de un solo archivo del sistema (ACTION_OPEN_DOCUMENT). A diferencia de [requestFolderAccess], esto concede acceso a exactamente un archivo, que es lo que debería pedir un editor o un destructor de archivos; el permiso de lectura/escritura se persiste para que la URI siga funcionando tras cerrarse el proceso. La respuesta llega en `WebToApkOnFilePickResult(success, fileJson, requestId)`; el `error` de un fallo es "cancelled", "disabled", "no-picker", "foreign_origin" o "busy" (ya hay un selector abierto para una solicitud anterior, que conserva su propia respuesta). @param mimeFilter p. ej. "image/\*", "application/pdf", o "" para cualquier archivo. (El asterisco lleva barra invertida solo porque los comentarios de bloque de Kotlin se anidan.)

Ejemplo

Abre el selector del sistema para UN archivo. Normalmente las páginas usan el asistente con Promise WebToApkFS.pickFile(mimeFilter); este método sin procesar responde mediante un callback.

Devuelve: nada por ahora. Más tarde el shell llama a window.WebToApkOnFilePickResult(success, fileJson, requestId), donde fileJson es una cadena JSON {ok, uri, name, size, mime} si todo va bien o {ok:false, uri:'', error} con error = 'cancelled', 'disabled', 'no-picker', 'busy' o 'foreign_origin'. Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

La forma pública (recomendada):

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

La llamada sin procesar, comparando por tu cuenta requestId:

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
  });
}

Adjunta un PDF y muestra su nombre y tamaño, distinguiendo una cancelación de un problema real:

document.querySelector('#attach').addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('application/pdf');
  if (!f.ok) {
    if (f.error === 'cancelled' || f.error === 'busy') return;   // user closed it / one already open
    alert('Could not open the file picker: ' + f.error);         // 'disabled', 'no-picker', ...
    return;
  }
  document.querySelector('#attachment').textContent =
    f.name + ' (' + Math.round(f.size / 1024) + ' KB)';
  localStorage.setItem('attachmentUri', f.uri);   // the grant is kept across restarts
});

Abre un archivo de texto y carga su contenido en un editor:

async function openTextFile() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/*');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);            // {ok, base64, size} or {ok:false, error}
  if (!r.ok) { alert('Could not read ' + f.name + ': ' + r.error); return; }
  const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
  document.querySelector('#editor').value = new TextDecoder('utf-8').decode(bytes);
  document.querySelector('#editor').dataset.uri = f.uri;   // save back to the same file later
}

Notas: Solo puede haber una selección abierta a la vez: una llamada hecha mientras hay una abierta se responde al instante con error:'busy', y la abierta conserva su propia respuesta. La concesión se conserva (lectura y escritura) hasta que llames a releaseFileAccess(uri). El archivo no se lee aquí: usa readFileBase64 / WebToApkFS.readBytes con el uri.

readFileBase64 puente#

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

Lee hasta [length] bytes sin procesar a partir de [offset] y los devuelve en base64. @param pathOrUri un URI `content://` de [pickFile], o una ruta relativa a la carpeta concedida por [requestFolderAccess]. @param length bytes a leer; limitado a 4 MB por llamada. 0 o negativo significa "todo lo que permita una llamada". @return JSON `{ok, base64, bytesRead, offset, size, eof, error}`. `eof` indica a un bucle por bloques cuándo parar sin tener que comparar con `size` por su cuenta.

Ejemplo

Lee bytes sin procesar de un archivo desde una posición en bytes y los devuelve como base64. Normalmente las páginas usan WebToApkFS.readBytes(pathOrUri, offset, length) (mismo resultado, ya analizado) o WebToApkFS.readAll(pathOrUri).

Devuelve: (síncrono) una cadena JSON {ok:true, base64, bytesRead, offset, size, eof} o {ok:false, error}. Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

La forma pública:

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

La llamada sin procesar: ten en cuenta que el resultado es una cadena a la que debes aplicar JSON.parse, no base64 simple:

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);
  }
}

Comprueba que un archivo elegido sea realmente un PNG leyendo solo sus primeros 8 bytes:

function isPng(fileUri) {
  const r = WebToApkFS.readBytes(fileUri, 0, 8);
  if (!r.ok || r.bytesRead < 8) return false;
  const head = atob(r.base64);
  const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
  return sig.every(function (b, i) { return head.charCodeAt(i) === b; });
}

Lee un archivo grande fragmento a fragmento con una barra de progreso, sin tenerlo nunca todo en memoria:

function scanInChunks(fileUri, onChunk) {
  const step = WebToApkFS.maxChunkSize() || 1048576;
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(fileUri, offset, step);
    if (!r.ok) throw new Error('read failed: ' + r.error);
    onChunk(r.base64);                                    // process this piece, then drop it
    offset += r.bytesRead;
    document.querySelector('#progress').value = r.size ? offset / r.size : 1;
    if (r.eof || r.bytesRead <= 0) break;
  }
  return offset;                                          // total bytes read
}

Notas: pathOrUri es una URI content:// de pickFile o una ruta relativa a la carpeta de requestFolderAccess. Como máximo 4 MB por llamada (getMaxIoChunkSize()); repite con bytesRead hasta eof. Errores: not-found, disabled, cannot-open, offset must be >= 0, offset past end of file (N bytes), foreign_origin (una página de un sitio distinto al propio de la app).

releaseFileAccess puente#

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

Libera un permiso de archivo único persistido que obtuvo [pickFile]. Android limita cuántos permisos de URI puede persistir una app, por eso una app que elige muchos archivos debería liberar los que ya terminó de usar.

Ejemplo

Libera el permiso de lectura/escritura conservado para un archivo elegido con pickFile. Normalmente las páginas usan WebToApkFS.releaseFile(uri), que devuelve un booleano real.

Devuelve: (síncrono) la cadena "true" o "false". Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar (si no, siempre "false").

La forma pública:

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

La llamada en bruto:

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');
}

Mantén una lista de "archivos recientes" de como máximo 10 y libera el permiso de cualquier archivo que salga de ella:

function rememberRecent(uri) {
  let recent = JSON.parse(localStorage.getItem('recentFiles') || '[]');
  recent = [uri].concat(recent.filter(function (u) { return u !== uri; }));
  while (recent.length > 10) {
    const dropped = recent.pop();
    if (window.WebToApkFS) WebToApkFS.releaseFile(dropped);   // Android caps kept grants
  }
  localStorage.setItem('recentFiles', JSON.stringify(recent));
}

Cierra el documento actual: libera su permiso y olvida la URI guardada:

document.querySelector('#close-doc').addEventListener('click', function () {
  const uri = localStorage.getItem('openDocUri');
  if (uri && window.WebToApkFS) {
    const released = WebToApkFS.releaseFile(uri);   // true / false, already a boolean
    console.log(released ? 'grant released' : 'no grant was held for this file');
  }
  localStorage.removeItem('openDocUri');
  document.querySelector('#editor').value = '';
});

Notas: Android limita cuántas concesiones de archivos puede conservar una app, así que libera los archivos que ya no necesites. El archivo en sí no se toca.

statFile puente#

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

Metadatos de un archivo o carpeta sin leer nada de él. Una ruta relativa nombra un archivo o una subcarpeta de la carpeta con permiso; una carpeta responde `isDirectory: true` con `size` 0 y `mime` `vnd.android.document/directory`. @return JSON `{ok, name, size, mime, lastModified, uri, isDirectory, canWrite}`, o `{ok:false, error}` con `not-found`, `disabled` o `foreign_origin`.

Ejemplo

Lee los metadatos de un archivo (nombre, tamaño, tipo) sin leer sus bytes. Normalmente las páginas usan WebToApkFS.stat(pathOrUri), que analiza el JSON por ti.

Devuelve: (síncrono) una cadena JSON {ok, name, size, mime, lastModified, uri, isDirectory, canWrite}, o {ok:false, error} ('not-found', 'disabled', 'foreign_origin'). Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

La forma pública:

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

La llamada en bruto:

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);
}

Rechaza un archivo demasiado grande antes de leer nada de él:

const MAX_BYTES = 20 * 1024 * 1024;
function canImport(fileUri) {
  const info = WebToApkFS.stat(fileUri);
  if (!info.ok) { alert('File not available: ' + info.error); return false; }
  if (info.size > MAX_BYTES) {
    alert(info.name + ' is ' + Math.round(info.size / 1048576) + ' MB; the limit is 20 MB.');
    return false;
  }
  return true;
}

Busca una subcarpeta de la carpeta con permiso y ofrece "Guardar aquí" solo cuando se pueda escribir en ella:

function checkExportFolder() {
  const info = WebToApkFS.stat('exports');          // a path relative to the granted folder
  const btn = document.querySelector('#save-here');
  if (!info.ok) {                                    // 'not-found' = the folder is not there yet
    btn.textContent = 'Create "exports" and save';
    return WebToApkFS.mkdir('exports');
  }
  btn.disabled = !(info.isDirectory && info.canWrite);
  console.log('exports last changed', new Date(info.lastModified).toLocaleString());
  return true;
}

Notas: Acepta una URI content:// de pickFile o una ruta relativa a la carpeta con permiso concedido con requestFolderAccess. Solo responde a las páginas propias de la app o a su sitio web configurado; una página de cualquier otro sitio recibe foreign_origin.

writeFileBase64 puente#

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

Escribe bytes sin procesar y confirma cuántos llegaron. @param mode `"patch"` escribe en [offset] dejando intacto el resto del archivo (lo que necesita una sobrescritura de varias pasadas), `"truncate"` reemplaza el archivo, `"append"` ignora [offset] y escribe al final. @return JSON `{ok, bytesWritten, offset, size, error}` - `size` se vuelve a leer del archivo tras un fsync, así que quien llama puede verificar la escritura de verdad en lugar de fiarse de un booleano.

Ejemplo

Escribe bytes en base64 en un archivo en una posición (patch), sobre todo el archivo (truncate) o al final (append). Normalmente las páginas usan WebToApkFS.writeBytes(pathOrUri, base64, offset, mode) o WebToApkFS.writeAll(pathOrUri, base64).

Devuelve: (síncrono) una cadena JSON {ok, bytesWritten, offset, size} (size releído tras un fsync) o {ok:false, error}. Necesita: marcar Acceso nativo a carpetas (SAF) en el Paso 4 (Acceso) al compilar.

La forma pública:

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

La llamada en bruto:

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');
}

Guarda un dibujo de un canvas como PNG en un archivo que eligió el usuario, reemplazando lo que había:

document.querySelector('#save').addEventListener('click', async function () {
  const f = await WebToApkFS.pickFile('image/png');
  if (!f.ok) return;
  const base64 = document.querySelector('canvas').toDataURL('image/png').split(',')[1];
  const r = WebToApkFS.writeAll(f.uri, base64);   // truncates, then writes in 4 MB chunks
  if (r.ok) console.log('saved', r.bytesWritten, 'bytes to', f.name);
  else alert('Save failed: ' + r.error);
});

Sobrescribe un archivo en su lugar con ceros antes de eliminarlo y confirma que cada pasada se completó:

function zeroFill(fileUri) {
  const info = WebToApkFS.stat(fileUri);
  if (!info.ok) return false;
  const slice = 32768;
  for (let at = 0; at < info.size; at += slice) {
    const len = Math.min(slice, info.size - at);
    const zeros = btoa(String.fromCharCode.apply(null, new Uint8Array(len)));
    const w = WebToApkFS.writeBytes(fileUri, zeros, at, 'patch');   // the rest of the file is kept
    if (!w.ok || w.bytesWritten !== len) { console.log('write failed at', at, w.error); return false; }
  }
  return true;   // each call is fsync'ed before it answers
}

Notas: El modo es 'patch' (predeterminado si está vacío), 'truncate' o 'append'. Una llamada admite como máximo 4 MB de bytes decodificados (getMaxIoChunkSize()). Una ruta relativa se crea dentro de la carpeta concedida si falta. Errores: disabled, foreign_origin (la página en pantalla no es la propia de la app), bad mode: x, offset must be >= 0, bad base64, chunk too large (max N bytes), not-found, cannot-open, short write.

Generado a partir del runtime de la app y sus archivos de ejemplo en cada compilación de la documentación. Léelo como Markdown · Todas las familias.

Comprobado con el puente publicado el 2026-10-22.