blutrixx / nativephp-device-utils
Native device utilities for NativePHP Mobile — system insets, file picking, and app storage access
Package info
github.com/joelnjoshkibona/nativephp-device-utils
Language:Kotlin
Type:nativephp-plugin
pkg:composer/blutrixx/nativephp-device-utils
Requires
- php: ^8.1
- nativephp/mobile: ^3.3
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A NativePHP Mobile plugin exposing device-level primitives that don't belong to any one app: safe-area insets, the system file picker, app-private storage, runtime permission requests, and a camera overlay for photo capture or continuous barcode/QR scanning.
The routes, controller, and JS composables are all part of this package too (see below) — a consuming app needs zero PHP or JS code of its own to use scan, photo, warm, or requestPermissions. getInsets/pickFile/copyToStorage don't have a shipped route yet — call the facade directly from a route of your own (see the API reference).
Composer package: blutrixx/nativephp-device-utils
Repo: joelnjoshkibona/nativephp-device-utils
Current release: v1.3.0
Upgrading an existing consumer from before v1.3.0? Delete your app's own NativeDeviceController (or equivalent) and its /device/scan, /device/camera/warm, /device/photo, /device/photo/read, /device/request-permissions route lines — this package now registers those same paths itself. Leaving both in place means two controllers answer the same route; Laravel resolves to whichever registers first, which is confusing to debug and not a state you want to be in.
Requirements
- PHP ^8.1
- A Laravel app running under
nativephp/mobile^3.3 (enforced bycomposer.json; 4.x is not supported yet) - Android: adds the
android.permission.CAMERApermission and thecom.google.mlkit:barcode-scanning:17.2.0dependency to your app's build. Camera hardware is declaredrequired: false— the plugin degrades gracefully on devices without one, butSmartCameracalls will fail on those devices.
Installation
This package isn't on Packagist yet. Two ways to pull it in:
As a git submodule (how this repo itself consumes it):
git submodule add https://github.com/joelnjoshkibona/nativephp-device-utils.git packages/nativephp-device-utils
// composer.json { "repositories": [ {"type": "path", "url": "packages/nativephp-device-utils"} ], "require": { "blutrixx/nativephp-device-utils": "@dev" } }
Without a submodule, pointing straight at GitHub:
{
"repositories": [
{"type": "vcs", "url": "https://github.com/joelnjoshkibona/nativephp-device-utils"}
],
"require": {
"blutrixx/nativephp-device-utils": "^1.0"
}
}
Laravel auto-discovers DeviceUtilsServiceProvider — no manual registration needed. Its boot() loads this package's own routes/web.php (registered under the web middleware group), which wires /device/scan, /device/camera/warm, /device/photo, /device/photo/read, and /device/request-permissions straight to Http\Controllers\DeviceUtilsController — nothing to add to your own routes/web.php.
How the bridge works
Every call goes Vue → this package's own route → this package's facade → nativephp_call() → native Android code. Two shapes:
- Synchronous (
getInsets,copyToStorage): the native side does the work and returns a result immediately — a route can justreturnit. (These don't have a shipped route yet — call the facade directly from a route of your own, e.g.Route::get('/device/insets', fn () => DeviceUtils::getInsets());.) - Asynchronous (
pickFile,requestPermissions,SmartCamera::open): the call returns{launched: true}right away because the native side is opening a picker/overlay/dialog the user has to interact with. The actual result arrives later as anative-eventDOMCustomEventfired directly into your WebView — your frontend needs a listener for it, correlated where noted below.
This package ships the canonical JS-side listener composables for requestPermissions, SmartCamera::open('scan'), and SmartCamera::open('photo'|'gallery') directly, under resources/js/ — you don't need to hand-write the event-correlation logic yourself, and every consuming app stays on the same, race-free implementation instead of drifting apart. (pickFile/copyToStorage don't have shipped composables yet — the pattern below is a template for wiring those, or any future async bridge call, yourself.)
Wire up the alias (Vite resolves vendor/ paths fine — no build step or publish command needed; composer update alone keeps consuming apps current):
// vite.config.ts import path from 'node:path' export default defineConfig({ resolve: { alias: { '@blutrixx/device-utils': path.resolve(__dirname, './vendor/blutrixx/nativephp-device-utils/resources/js'), }, }, })
// your own resources/js/composables/useScanner.ts — a thin re-export, so // existing imports from '@/composables/useScanner' keep working unchanged export * from '@blutrixx/device-utils/useScanner'
import { useScanner } from '@/composables/useScanner' const { scan } = useScanner() const { data, format } = await scan()
If you consume this package via a composer "path" repository (local development — this shell's own composer.json does this), composer installs it as a symlink into vendor/, not a copy. Vite/Rollup then resolves this package's own imports (axios, inside useScanner.ts etc.) against the symlink's real target directory instead of your app's node_modules, and the build fails with Rollup failed to resolve import "axios". Fix: add resolve.preserveSymlinks: true to vite.config.ts. A real install (this package pulled via its GitHub VCS repo/tag, the normal case for a generated project) is a plain copy and needs no such flag.
The one rule every one of these composables follows, and yours should too if you add another: register the pending Promise in its correlation map before firing the axios.post(...) that starts the native call, not after await-ing its response. The native side can dispatch its result event in single-digit milliseconds — faster than the HTTP round-trip through the WebView resolves — and a listener that isn't registered yet silently drops the event. Confirmed live: this exact ordering bug made every scan()/capturePhoto() call eat the full 30-second requestPermissions() timeout before proceeding anyway, even when permission was already granted.
For requestPermissions specifically: generate a crypto.randomUUID() client-side, send it as id, and match it against the id field on the incoming PermissionsResult event — the native side supports multiple concurrent permission requests this way (already handled inside useDevicePermissions). pickFile and SmartCamera calls don't take an id; only one file-pick or one camera overlay can be open at a time.
API reference
Blutrixx\DeviceUtils\Facades\DeviceUtils
| Method | Sync? | Returns | Notes |
|---|---|---|---|
getInsets() |
Sync | array (status bar / nav bar heights in dp) |
|
pickFile(string $mimeType = 'application/pdf') |
Async | {launched: true} |
Fires FileSelected or FilePickCancelled |
copyToStorage(string $uri, string $filename, string $subfolder = 'books') |
Sync | {path, filename, size} |
Copies a SAF content URI into app-private storage |
requestPermissions(array $permissions, ?string $id = null) |
Async | {launched: true} |
Fires PermissionsResult, correlated by $id |
use Blutrixx\DeviceUtils\Facades\DeviceUtils; DeviceUtils::getInsets(); DeviceUtils::pickFile('application/pdf'); DeviceUtils::copyToStorage($uri, 'invoice.pdf', 'documents'); DeviceUtils::requestPermissions(['android.permission.CAMERA'], id: $requestId);
Blutrixx\DeviceUtils\Facades\SmartCamera
| Method | Sync? | Returns | Notes |
|---|---|---|---|
open(string $mode = 'scan', string $quality = 'high', bool $multiple = false, bool $autoClose = true) |
Async | {launched: true} |
mode: 'scan' decodes barcodes/QR and fires ScanResult per read (or ScanResults, plural, if $multiple, once the user taps Done); mode: 'photo' shows a shutter button and fires PhotoCaptured on capture. Either mode fires CaptureCancelled if the user backs out. quality ('high'|'medium'|'low') only affects 'photo' mode's JPEG output. $multiple/$autoClose apply to 'scan' mode only — see ScanOptions in useScanner.ts. |
warm() |
Async, fire-and-forget | {warming: true} |
Pre-binds CameraX in the background so a later open() shows its overlay faster — call once when a page that might scan/capture mounts. Fires no event; nothing to listen for. |
close() |
Sync | array |
Dismisses the overlay if one is open |
use Blutrixx\DeviceUtils\Facades\SmartCamera; SmartCamera::open(mode: 'scan'); // barcode/QR scanning SmartCamera::open(mode: 'photo', quality: 'medium'); SmartCamera::close();
Events
All under Blutrixx\DeviceUtils\Events\*:
| Event | Fired by | Payload |
|---|---|---|
FileSelected |
pickFile() |
file details from the SAF picker |
FilePickCancelled |
pickFile() |
— (user backed out) |
PermissionsResult |
requestPermissions() |
grant results, correlated by id |
PhotoCaptured |
SmartCamera::open(mode: 'photo') |
{path, base64, width, height, size} |
CaptureCancelled |
SmartCamera::open(...) |
— (user closed the overlay) |
ScanResult |
SmartCamera::open(mode: 'scan') |
{value, format} — format is one of QR_CODE, EAN_13, EAN_8, UPC_A, UPC_E, CODE_128, CODE_39, DATA_MATRIX |
ScanResults |
SmartCamera::open(mode: 'scan', multiple: true), once the user taps Done |
{results: [{value, format}, ...]} |
Quick start: scan a barcode
No PHP to write — /device/scan already exists once this package is installed. Just wire the alias (see "Wire up the alias" above), add the one-line re-export composable file, and call it:
// Vue -- useScanner() is shipped by this package, see "Wire up the alias" above import { useScanner } from '@/composables/useScanner' const { scan, scanning } = useScanner() const { data, format } = await scan() console.log(`Scanned ${format}: ${data}`)