sghimire/mobile-file-access

Generic native file save/open (Storage Access Framework / UIDocumentPicker) for NativePHP Mobile — pick any file into your app or save arbitrary bytes to a user-chosen location, with no storage permissions required.

Maintainers

Package info

github.com/SandipGhimire/NativePHP-MobileFileAccess

Type:nativephp-plugin

pkg:composer/sghimire/mobile-file-access

Transparency log

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-07-27 20:07 UTC

This package is auto-updated.

Last update: 2026-07-27 20:10:03 UTC


README

Generic native "save file" / "open file" for NativePHP Mobile apps, powered by the Storage Access Framework on Android and UIDocumentPickerViewController on iOS.

Not tied to any particular file type or app — use it to import/export a backup, save a generated PDF, let a user attach an arbitrary document, or read any file the user picks from their device or cloud storage (Files app / Google Drive / Dropbox, wherever the system document picker can reach).

Features

  • Save arbitrary bytes to a location the user chooses, with a suggested file name.
  • Read any file the user picks, back as base64 — including from cloud-backed providers exposed through the system Files app.
  • No storage permissions required on either platform. The document picker grants per-file access on the user's behalf; this plugin never requests WRITE_EXTERNAL_STORAGE/READ_EXTERNAL_STORAGE (Android) and declares no Info.plist usage description (iOS).
  • Fluent, chainable builders in both PHP and JavaScript.
  • FileSaved / SaveCancelled / FilePicked / PickCancelled Laravel events, or bind straight to Livewire with #[OnNative].
  • Works from PHP (Blade/Livewire) and from JavaScript (Vue, React, Inertia, or plain JS).

Requirements

  • PHP ^8.2
  • nativephp/mobile ^3.0
  • iOS 14+ / Android API 23+
  • livewire/livewire — only needed if you use the #[OnNative] attribute

Installation

composer require sghimire/mobile-file-access

Laravel's package auto-discovery registers FileAccessServiceProvider for you. Then register the plugin with NativePHP:

php artisan native:plugin:register

This wires up the plugin's nativephp.json manifest (bridge functions — no permissions to merge, there aren't any) into your native build. Rebuild/reinstall the native shell afterwards (php artisan native:install or native:run) so the bridge functions are picked up.

How It Works (Under the Hood)

Same two-phase pattern as every async call in this plugin family — a synchronous "start" acknowledgement, then the real result delivered later through two parallel channels.

  1. Request out. FileAccess::save(...)->save() / FileAccess::pick()->pick() (PHP) and FileAccess.save(...) / FileAccess.pick() (JS) both reach the same bridge — JS via fetch('/_native/api/call', { method: 'FileAccess.Save' | 'FileAccess.Pick', params }), PHP via nativephp_call(...). The bridge router opens the native picker: ActivityResultContracts.CreateDocument/OpenDocument on Android, UIDocumentPickerViewController on iOS.
  2. Immediate ack. The call returns right away confirming the picker is open — not that the user has chosen anything yet.
  3. Result comes back later, once, per call. On Android, the native side calls NativeActionCoordinator.dispatchEvent(...); on iOS, LaravelBridge.shared.send?(...). Both inject a script that fires a native-event CustomEvent on document (what the JS On()/Off() helpers listen for) and make a call back into Laravel that instantiates and dispatches the real event class (what Event::listen() / #[OnNative] pick up). The user backing out of the picker delivers SaveCancelled/PickCancelled the same way.

Because results are asynchronous and delivered to PHP and JS independently, always drive your UI from the events — never from the return value of save()/pick().

PHP Usage

The FileAccess facade

use Sandip\FileAccess\Native\Facades\FileAccess;

// Save some bytes
FileAccess::save('report.pdf', $pdfBytes)
    ->mimeType('application/pdf')  // hint only — the file name's extension is what matters
    ->id('export-report')          // correlate this save with its events
    ->save();

// Open any file
FileAccess::pick()
    ->mimeTypes(['application/pdf'])  // restrict the picker, or omit for any file (default ['*/*'])
    ->id('import-report')
    ->pick();

save()/pick() on the builder return booltrue once the request reached the native bridge, false if it couldn't be started (e.g. running outside the native shell, or the builder was already started once).

If you never call ->save()/->pick() explicitly, it fires automatically when the builder object is destructed — so FileAccess::save('report.pdf', $bytes); alone is enough to trigger it. Calling it yourself is recommended so you can check the return value.

PendingFileSave methods

Method Description
mimeType(string $mimeType) MIME type hint. Defaults to application/octet-stream.
id(string $id) Custom correlation ID, delivered back on the event.
getId() Read the current correlation ID, or null.
save() Send the save request to the native bridge. Returns bool.

PendingFilePick methods

Method Description
mimeTypes(array $mimeTypes) Restrict the picker to one or more MIME types. Throws InvalidArgumentException if empty. Defaults to ['*/*'].
id(string $id) Custom correlation ID, delivered back on the event.
getId() Read the current correlation ID, or null.
pick() Send the pick request to the native bridge. Returns bool.

Listening for results

use Sandip\FileAccess\Native\Events\FileAccess\FileSaved;
use Sandip\FileAccess\Native\Events\FileAccess\SaveCancelled;
use Sandip\FileAccess\Native\Events\FileAccess\FilePicked;
use Sandip\FileAccess\Native\Events\FileAccess\PickCancelled;
use Illuminate\Support\Facades\Event;

Event::listen(function (FileSaved $event) {
    $event->fileName; // string — the name the file was saved as
    $event->size;     // int — bytes written
    $event->id;       // ?string
});

Event::listen(function (SaveCancelled $event) {
    $event->reason; // ?string — "user_cancelled", "write_failed", ...
    $event->id;     // ?string
});

Event::listen(function (FilePicked $event) {
    $event->fileName;      // string
    $event->mimeType;      // string
    $event->size;          // int — bytes
    $event->contentBase64; // string — base64-encoded file contents
    $event->id;            // ?string
});

Event::listen(function (PickCancelled $event) {
    $event->reason; // ?string — "user_cancelled", "read_failed", ...
    $event->id;     // ?string
});

Livewire: #[OnNative]

use Livewire\Component;
use Sandip\FileAccess\Native\Attributes\OnNative;
use Sandip\FileAccess\Native\Events\FileAccess\FilePicked;
use Sandip\FileAccess\Native\Facades\FileAccess;

class DocumentImporter extends Component
{
    public function startImport(): void
    {
        FileAccess::pick()->id('import')->mimeTypes(['application/pdf'])->pick();
    }

    #[OnNative(FilePicked::class)]
    public function onFilePicked(string $fileName, string $mimeType, int $size, string $contentBase64, ?string $id): void
    {
        file_put_contents(storage_path("app/imports/{$fileName}"), base64_decode($contentBase64));
    }
}

JavaScript Usage

Importing

This package doesn't publish a #nativephp import alias (that's reserved for NativePHP's first-party plugins). Import the file directly — either from the vendor path, or copy it into your own resources/js/ and import it from there:

import { FileAccess, On, Off, Events } from '../../vendor/sghimire/mobile-file-access/resources/js/file-access.js';

Full TypeScript types are included in file-access.d.ts alongside it.

Saving a file

FileAccess.save(fileName, content) returns a thenable builder — await it directly, or chain builder methods first. content accepts a string (assumed already base64), a Uint8Array, or an ArrayBuffer.

import { FileAccess } from '../../vendor/sghimire/mobile-file-access/resources/js/file-access.js';

await FileAccess.save('backup.json', JSON.stringify(data));

// or fully configured, with binary content
await FileAccess.save('report.pdf', pdfBytes)
  .mimeType('application/pdf')
  .id('export-report');

Picking a file

import { FileAccess } from '../../vendor/sghimire/mobile-file-access/resources/js/file-access.js';

await FileAccess.pick();

// or restrict to specific types
await FileAccess.pick().mimeTypes(['application/pdf']).id('import-report');

Listening for events

import { FileAccess, On, Off, Events, fromBase64 } from '../../vendor/sghimire/mobile-file-access/resources/js/file-access.js';

function handleSaved(payload) {
  // payload: { fileName: string, size: number, id: string | null }
}

function handleSaveCancelled(payload) {
  // payload: { reason: string | null, message?: string, id: string | null }
}

function handlePicked(payload) {
  // payload: { fileName: string, mimeType: string, size: number, contentBase64: string, id: string | null }
  const bytes = fromBase64(payload.contentBase64);
}

function handlePickCancelled(payload) {
  // payload: { reason: string | null, message?: string, id: string | null }
}

On(Events.FileAccess.FileSaved, handleSaved);
On(Events.FileAccess.SaveCancelled, handleSaveCancelled);
On(Events.FileAccess.FilePicked, handlePicked);
On(Events.FileAccess.PickCancelled, handlePickCancelled);

// later, e.g. on component unmount
Off(Events.FileAccess.FileSaved, handleSaved);
Off(Events.FileAccess.FilePicked, handlePicked);

Vue 3 example: encrypted backup export/import

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import {
  FileAccess,
  On,
  Off,
  Events,
  fromBase64,
} from '../../vendor/sghimire/mobile-file-access/resources/js/file-access.js';

const status = ref('');

function handleSaved(payload) {
  status.value = `Saved ${payload.fileName} (${payload.size} bytes)`;
}

function handlePicked(payload) {
  const bytes = fromBase64(payload.contentBase64);
  status.value = `Picked ${payload.fileName}: ${bytes.length} bytes`;
}

onMounted(() => {
  On(Events.FileAccess.FileSaved, handleSaved);
  On(Events.FileAccess.FilePicked, handlePicked);
});

onUnmounted(() => {
  Off(Events.FileAccess.FileSaved, handleSaved);
  Off(Events.FileAccess.FilePicked, handlePicked);
});

async function exportBackup(bytes) {
  await FileAccess.save('backup.bin', bytes).id('backup-export');
}

async function importBackup() {
  await FileAccess.pick().id('backup-import');
}
</script>

<template>
  <button @click="importBackup">Import backup</button>
  <p v-if="status">{{ status }}</p>
</template>

JS API reference

Export Signature Description
FileAccess.save(fileName, content) (string, string | Uint8Array | ArrayBuffer) => PendingFileSave Start building a save request.
.mimeType(mimeType) (string) => this MIME type hint. Defaults to application/octet-stream.
.id(id) (string) => this Custom correlation ID.
.getId() () => string | null Read the current correlation ID.
FileAccess.pick() () => PendingFilePick Start building a pick request.
.mimeTypes(types) (string[]) => this Restrict the picker to given MIME types. Throws if empty.
On(event, callback) (string, (payload, eventName) => void) => void Subscribe to a native event.
Off(event, callback) (string, (payload, eventName) => void) => void Unsubscribe.
toBase64(bytes) (string | Uint8Array | ArrayBuffer) => string Encode bytes to base64 (chunked, safe for large files).
fromBase64(base64) (string) => Uint8Array Decode a base64 string back to bytes.
Events.FileAccess.FileSaved string Event name constant.
Events.FileAccess.SaveCancelled string Event name constant.
Events.FileAccess.FilePicked string Event name constant.
Events.FileAccess.PickCancelled string Event name constant.

await-ing (or .then-ing) a PendingFileSave/PendingFilePick sends the request to the native bridge exactly once — awaiting it twice is a no-op the second time.

Events reference

FileSaved

Property Type Description
fileName string The name the file was saved as.
size int / number Bytes written.
id ?string / string | null The correlation ID from .id(), if any.

SaveCancelled

Property Type Description
reason ?string / string | null "user_cancelled", "write_failed", or "no_root_view_controller" (iOS).
message ?string Present alongside write_failed.
id ?string / string | null The correlation ID from .id(), if any.

FilePicked

Property Type Description
fileName string The picked file's display name.
mimeType string Best-effort MIME type from the content provider.
size int / number Bytes read.
contentBase64 string The file's contents, base64-encoded.
id ?string / string | null The correlation ID from .id(), if any.

PickCancelled

Property Type Description
reason ?string / string | null "user_cancelled", "read_failed", or "no_root_view_controller" (iOS).
message ?string Present alongside read_failed.
id ?string / string | null The correlation ID from .id(), if any.
  • PHP classes: Sandip\FileAccess\Native\Events\FileAccess\{FileSaved,SaveCancelled,FilePicked,PickCancelled}
  • JS event name constants: Events.FileAccess.{FileSaved,SaveCancelled,FilePicked,PickCancelled}

Why no permissions?

Both platforms delegate file access entirely to a system-owned picker:

  • Android uses Intent.ACTION_CREATE_DOCUMENT and Intent.ACTION_OPEN_DOCUMENT (the Storage Access Framework) via ActivityResultContracts.CreateDocument/OpenDocument. The system grants your app a scoped, per-URI permission for exactly the file the user chose — no WRITE_EXTERNAL_STORAGE/READ_EXTERNAL_STORAGE manifest entry is requested or needed, on any supported API level.
  • iOS uses UIDocumentPickerViewController, which similarly hands back a security-scoped URL for just the chosen file/destination. No Info.plist usage-description key applies here (those are only for Camera/Photo Library/Microphone-style device resources).

If you need something this plugin doesn't cover — e.g. picking straight from the photo library — pair it with a plugin built for that (like sghimire/mobile-scanner for camera/QR, or NativePHP's own media picker), which will declare whatever permission that flow actually needs.

Platform notes

Android iOS
Min OS version API 23 14.0
Permission none none
Native implementation resources/android/FileAccessFunctions.kt (Storage Access Framework via ActivityResultContracts) resources/ios/FileAccessFunctions.swift (UIDocumentPickerViewController)

Both are configured automatically by nativephp.json — you don't need to edit native project files by hand.

Testing

composer install
composer test

Outside of a compiled native shell, FileAccess::save(...)->save() and FileAccess::pick()->pick() return false (there's no bridge to call) — this is expected and is exactly what the test suite asserts.

License

MIT