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.
Package info
github.com/SandipGhimire/NativePHP-MobileFileAccess
Type:nativephp-plugin
pkg:composer/sghimire/mobile-file-access
Requires
- php: ^8.2
- nativephp/mobile: ^3.0
Requires (Dev)
- laravel/pint: ^1.29
- pestphp/pest: ^3.0
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 noInfo.plistusage description (iOS). - Fluent, chainable builders in both PHP and JavaScript.
FileSaved/SaveCancelled/FilePicked/PickCancelledLaravel 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.
- Request out.
FileAccess::save(...)->save()/FileAccess::pick()->pick()(PHP) andFileAccess.save(...)/FileAccess.pick()(JS) both reach the same bridge — JS viafetch('/_native/api/call', { method: 'FileAccess.Save' | 'FileAccess.Pick', params }), PHP vianativephp_call(...). The bridge router opens the native picker:ActivityResultContracts.CreateDocument/OpenDocumenton Android,UIDocumentPickerViewControlleron iOS. - Immediate ack. The call returns right away confirming the picker is open — not that the user has chosen anything yet.
- 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 anative-eventCustomEventondocument(what the JSOn()/Off()helpers listen for) and make a call back into Laravel that instantiates and dispatches the real event class (whatEvent::listen()/#[OnNative]pick up). The user backing out of the picker deliversSaveCancelled/PickCancelledthe 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 bool — true 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_DOCUMENTandIntent.ACTION_OPEN_DOCUMENT(the Storage Access Framework) viaActivityResultContracts.CreateDocument/OpenDocument. The system grants your app a scoped, per-URI permission for exactly the file the user chose — noWRITE_EXTERNAL_STORAGE/READ_EXTERNAL_STORAGEmanifest 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. NoInfo.plistusage-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