From 3495292e82390cca28d47fa0910348c72ad62c2d Mon Sep 17 00:00:00 2001 From: pratik bhujel Date: Sun, 20 Sep 2026 13:13:40 +0545 Subject: [PATCH 1/3] Add observable NativePHP scanner benchmark --- app/NativeComponents/Home.php | 17 +++- app/NativeComponents/ScannerBenchmark.php | 88 ++++++++++++++++++ config/nativephp.php | 1 - docs/scanner-benchmark.md | 28 ++++++ public/benchmark-qr.png | Bin 0 -> 707 bytes resources/views/native/home.blade.php | 2 + .../views/native/scanner-benchmark.blade.php | 46 +++++++++ routes/web.php | 2 + tests/Feature/ScannerBenchmarkTest.php | 33 +++++++ tools/generate-benchmark-qr.php | 16 ++++ 10 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 app/NativeComponents/ScannerBenchmark.php create mode 100644 docs/scanner-benchmark.md create mode 100644 public/benchmark-qr.png create mode 100644 resources/views/native/scanner-benchmark.blade.php create mode 100644 tests/Feature/ScannerBenchmarkTest.php create mode 100644 tools/generate-benchmark-qr.php diff --git a/app/NativeComponents/Home.php b/app/NativeComponents/Home.php index 4fa964b..d51496a 100644 --- a/app/NativeComponents/Home.php +++ b/app/NativeComponents/Home.php @@ -2,6 +2,8 @@ namespace App\NativeComponents; +use Composer\InstalledVersions; +use Illuminate\Foundation\Application; use Illuminate\View\View; use Native\Mobile\Edge\NativeComponent; @@ -12,16 +14,21 @@ public function navTitle(): string return 'Super Stack'; } + public function openScannerBenchmark(): void + { + $this->navigate('/scanner-benchmark'); + } + public function render(): View { return view('native.home', [ 'app' => config('app.name'), 'packages' => [ - 'Laravel' => \Illuminate\Foundation\Application::VERSION, - 'Filament' => \Composer\InstalledVersions::getPrettyVersion('filament/filament'), - 'NativePHP Mobile' => \Composer\InstalledVersions::getPrettyVersion('nativephp/mobile'), - 'Web UI' => \Composer\InstalledVersions::getPrettyVersion('nativephp/web-ui'), - 'Laravel MCP' => \Composer\InstalledVersions::getPrettyVersion('laravel/mcp'), + 'Laravel' => Application::VERSION, + 'Filament' => InstalledVersions::getPrettyVersion('filament/filament'), + 'NativePHP Mobile' => InstalledVersions::getPrettyVersion('nativephp/mobile'), + 'Web UI' => InstalledVersions::getPrettyVersion('nativephp/web-ui'), + 'Laravel MCP' => InstalledVersions::getPrettyVersion('laravel/mcp'), ], ]); } diff --git a/app/NativeComponents/ScannerBenchmark.php b/app/NativeComponents/ScannerBenchmark.php new file mode 100644 index 0000000..71aab12 --- /dev/null +++ b/app/NativeComponents/ScannerBenchmark.php @@ -0,0 +1,88 @@ + */ + protected array $seen = []; + + protected ?float $scanRequestedAt = null; + + public function navTitle(): string + { + return 'Scanner benchmark'; + } + + public function startScanner(): void + { + $sessionId = 'benchmark-'.bin2hex(random_bytes(6)); + $this->scanRequestedAt = microtime(true); + + Scanner::scan() + ->id($sessionId) + ->prompt('Scan the benchmark code') + ->continuous() + ->formats(['qr']) + ->codeScanned(function ($event) use ($sessionId): void { + $phpStartedAt = microtime(true); + $this->totalScans++; + $this->lastValue = $event->data; + $this->lastFormat = $event->format; + $this->lastBridgeToPhpMs = $this->scanRequestedAt === null + ? null + : round((microtime(true) - $this->scanRequestedAt) * 1000, 3); + + if (! isset($this->seen[$event->data])) { + $this->seen[$event->data] = true; + $this->uniqueScans++; + } + + $this->lastPhpMs = round((microtime(true) - $phpStartedAt) * 1000, 3); + + logger()->info('scanner_benchmark.scan', [ + 'session_id' => $sessionId, + 'value_sha256' => hash('sha256', $event->data), + 'format' => $event->format, + 'total_scans' => $this->totalScans, + 'unique_scans' => $this->uniqueScans, + 'bridge_to_php_ms' => $this->lastBridgeToPhpMs, + 'php_ms' => $this->lastPhpMs, + ]); + }) + ->scan(); + } + + public function reset(): void + { + $this->totalScans = 0; + $this->uniqueScans = 0; + $this->lastValue = null; + $this->lastFormat = null; + $this->lastBridgeToPhpMs = null; + $this->lastPhpMs = null; + $this->seen = []; + $this->scanRequestedAt = null; + } + + public function render(): View + { + return view('native.scanner-benchmark'); + } +} diff --git a/config/nativephp.php b/config/nativephp.php index 9c4d875..7250442 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -269,7 +269,6 @@ 'app/Providers/Filament', 'app/Mcp', 'routes/ai.php', - 'routes/api.php', 'config/sanctum.php', 'public/css/filament', 'public/js/filament', diff --git a/docs/scanner-benchmark.md b/docs/scanner-benchmark.md new file mode 100644 index 0000000..1c1d046 --- /dev/null +++ b/docs/scanner-benchmark.md @@ -0,0 +1,28 @@ +# Scanner benchmark + +This benchmark exercises the real NativePHP scanner path on one Android device. +It deliberately keeps the workload small and observable: + +1. Android camera and barcode decoder detect a QR value. +2. NativePHP delivers the `CodeScanned` event to the persistent PHP runtime. +3. PHP records the value, hashes it for logs, and deduplicates it in memory. +4. The screen reports the scan-to-PHP and PHP-only portions separately. + +## Why these numbers matter + +`scan-to-PHP` is a coarse end-to-end measurement from the scan request until PHP handles the result. It includes camera/decoder time and the native-to-PHP bridge, so it is not a claim about any one layer. + +`PHP dedupe timing` measures only the PHP-side bookkeeping after the callback has arrived. The log stores a SHA-256 digest rather than the scanned value so benchmark artifacts do not leak the QR contents. + +For a useful comparison, run the same QR values and number of scans on the same phone, with the same build mode and runtime mode. Record at least 10 successful scans, discard the first warm-up scan, and report median and p95 rather than a single best result. + +## Reproduction + +```bash +php artisan native:install android --no-interaction +php artisan native:run android +``` + +Open **Scanner benchmark**, scan the generated QR code repeatedly, and capture the screen plus the log output. The phone must be the same device for every comparison. + +The first version intentionally does not claim React Native parity. A fair comparison app must use the same QR payloads, camera format, scan count, warm-up policy, and release/debug mode before numbers are compared. diff --git a/public/benchmark-qr.png b/public/benchmark-qr.png new file mode 100644 index 0000000000000000000000000000000000000000..9959eecfd92a0dece4e3dce7d8392335bf3b5176 GIT binary patch literal 707 zcmeAS@N?(olHy`uVBq!ia0y~yU={#jMrNQ$uesnpAjKBo6XMFk#Pa9gAJ2Wqi6i(^Q|oVT|e^NuL+FgU2lO*?InDN888b|~%b z`A-wzrgV2&{JXZn^4}qYIajB_Me`MsX4@uB>pLFfc3v6gI_{3$8i`Bh>3vL_4_CqA6<)}t%SN>4kR#VXC;l?IO%E2Vp#v!nkv{WyPkjXBI^9}SOK z*t#cK*M04+af8K+U5m%_uFCCK%ziJ2+x5Cb;;CEK%^%DDiNZ~kN|M^RA#QtRYX3sy jn8<{g19tn5>qo>}{51ko6PCCE(;$PVtDnm{r-UW|jSKtC literal 0 HcmV?d00001 diff --git a/resources/views/native/home.blade.php b/resources/views/native/home.blade.php index fb381e0..121e00a 100644 --- a/resources/views/native/home.blade.php +++ b/resources/views/native/home.blade.php @@ -35,5 +35,7 @@ + + + {scannedValue &&

Scanned: {scannedValue}

} + + ); +} +``` + +### JS API reference + +| Export | Signature | Description | +|---|---|---| +| `Scanner.scan()` | `() => PendingScan` | Start building a scan session. | +| `.prompt(text)` | `(string) => this` | Instruction text above the viewfinder. | +| `.continuous(continuous?)` | `(boolean = true) => this` | Keep scanning after each match. | +| `.gallery(allow?)` | `(boolean = true) => this` | Show/hide the gallery button. Defaults to `true`; pass `false` for camera-only. | +| `.formats(formats)` | `(BarcodeFormat[]) => this` | Restrict detection to given formats. Throws if empty/invalid. | +| `.haptics(enabled?)` | `(boolean = true) => this` | Vibrate/impact-feedback on a successful scan. Defaults to `true`. | +| `.zoom(ratio?)` | `(number = 1.0) => this` | Initial camera zoom ratio, clamped to what the device supports. Throws if not positive. | +| `.maxZoom(ratio?)` | `(number = 3.0) => this` | Upper bound of the on-screen zoom slider, clamped to what the device supports. Throws if not positive. | +| `.zoomControl(enabled?)` | `(boolean = true) => this` | Show/hide the on-screen zoom slider. Defaults to `true`. | +| `.focusOnTap(enabled?)` | `(boolean = true) => this` | Let the user tap the preview to refocus the camera. Defaults to `true`. | +| `.timeout(seconds?)` | `(number = 0) => this` | Auto-cancel the scan after N seconds, firing `Cancelled` with reason `timeout`. Disabled (`0`) by default. Throws if negative. | +| `.id(id)` | `(string) => this` | Custom correlation ID. | +| `.getId()` | `() => string \| null` | Read the current correlation ID. | +| `Scanner.stop(id?)` | `(string?) => Promise<{ stopped: boolean }>` | Dismiss the open scanner. | +| `On(event, callback)` | `(string, (payload, eventName) => void) => void` | Subscribe to a native event. | +| `Off(event, callback)` | `(string, (payload, eventName) => void) => void` | Unsubscribe. | +| `Events.Scanner.CodeScanned` | `string` | Event name constant. | +| `Events.Scanner.Cancelled` | `string` | Event name constant. | + +`await`-ing (or `.then`-ing) a `PendingScan` sends the request to the native bridge exactly once — awaiting it twice is a no-op the second time. + +--- + +## Events reference + +### `CodeScanned` + +Dispatched every time the camera successfully decodes a matching code. + +| Property | Type | Description | +|---|---|---| +| `data` | `string` / `string` | The decoded value. | +| `format` | `string` / `string` | Which format matched, e.g. `"qr"`, `"ean13"`. | +| `id` | `?string` / `string \| null` | The correlation ID from `.id()`, if one was set. | + +### `Cancelled` + +Dispatched when the scanner closes without (another) match — the user tapped close, or `Scanner::stop()` / `Scanner.stop()` was called. + +| Property | Type | Description | +|---|---|---| +| `reason` | `?string` / `string \| null` | `"user_cancelled"` when the user taps close, `"stopped_by_app"` when closed via `stop()`, `"timeout"` when `.timeout()` elapsed, `"camera_error"` if the camera failed to start, `"permission_denied"` / `"permission_required"` after a permission prompt resolves (retry `.scan()` on `permission_required`). Never `null` in practice. | +| `id` | `?string` / `string \| null` | The correlation ID from `.id()`, if one was set. | + +- PHP classes: `Sandip\Scanner\Native\Events\Scanner\CodeScanned`, `Sandip\Scanner\Native\Events\Scanner\Cancelled` +- JS event name constants: `Events.Scanner.CodeScanned`, `Events.Scanner.Cancelled` + +## Implementation Guide: Building a Ticket Check-In Scanner + +A typical staff-facing feature: keep the camera open, scan every ticket that passes by, validate each one against the backend, and show a running result list — without reopening the scanner between tickets. + +### 1. Backend: validate a ticket code + +```php +// routes/api.php +Route::post('/tickets/check-in', CheckInTicketController::class); +``` + +```php +// app/Http/Controllers/CheckInTicketController.php +namespace App\Http\Controllers; + +use App\Models\Ticket; +use Illuminate\Http\Request; + +class CheckInTicketController extends Controller +{ + public function __invoke(Request $request) + { + $ticket = Ticket::where('code', $request->string('code'))->first(); + + if (! $ticket || $ticket->checked_in_at) { + return response()->json(['valid' => false], 422); + } + + $ticket->update(['checked_in_at' => now()]); + + return response()->json(['valid' => true, 'name' => $ticket->holder_name]); + } +} +``` + +### 2a. Livewire scanner + +```php +// app/Livewire/TicketScanner.php +namespace App\Livewire; + +use App\Models\Ticket; +use Livewire\Component; +use Sandip\Scanner\Native\Attributes\OnNative; +use Sandip\Scanner\Native\Events\Scanner\CodeScanned; +use Sandip\Scanner\Native\Facades\Scanner; + +class TicketScanner extends Component +{ + public array $log = []; + + public function startScanning(): void + { + Scanner::scan() + ->id('check-in') + ->prompt('Scan a ticket') + ->formats(['qr']) + ->continuous() + ->scan(); + } + + public function stopScanning(): void + { + Scanner::stop('check-in'); + } + + #[OnNative(CodeScanned::class)] + public function onCodeScanned(string $data): void + { + $ticket = Ticket::where('code', $data)->first(); + $valid = $ticket && ! $ticket->checked_in_at; + + if ($valid) { + $ticket->update(['checked_in_at' => now()]); + } + + array_unshift($this->log, [ + 'name' => $ticket->holder_name ?? $data, + 'valid' => $valid, + ]); + } + + public function render() + { + return view('livewire.ticket-scanner'); + } +} +``` + +```blade +{{-- resources/views/livewire/ticket-scanner.blade.php --}} +
+ + + +
    + @foreach ($log as $entry) +
  • + {{ $entry['name'] }} — {{ $entry['valid'] ? 'Checked in' : 'Invalid/duplicate' }} +
  • + @endforeach +
+
+``` + +### 2b. Vue/React scanner (calling the API endpoint) + +```vue + + + + +``` + +```jsx +// resources/js/Pages/TicketScanner.jsx +import { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import { Scanner, On, Off, Events } from '../../vendor/sghimire/mobile-scanner/resources/js/scanner.js'; + +export function TicketScanner() { + const [log, setLog] = useState([]); + + useEffect(() => { + const handleScanned = async (payload) => { + const { data: result } = await axios.post('/tickets/check-in', { code: payload.data }); + setLog((prev) => [{ name: result.name ?? payload.data, valid: result.valid }, ...prev]); + }; + On(Events.Scanner.CodeScanned, handleScanned); + return () => Off(Events.Scanner.CodeScanned, handleScanned); + }, []); + + const startScanning = useCallback(() => { + Scanner.scan().id('check-in').prompt('Scan a ticket').formats(['qr']).continuous(); + }, []); + + const stopScanning = useCallback(() => Scanner.stop('check-in'), []); + + return ( + <> + + +
    + {log.map((entry, i) => ( +
  • + {entry.name} — {entry.valid ? 'Checked in' : 'Invalid/duplicate'} +
  • + ))} +
+ + ); +} +``` + +`continuous()` keeps the same scanner session open across many tickets — each decode fires its own `CodeScanned`, debounced natively so a ticket held in frame for a second isn't logged twice. Call `Scanner::stop('check-in')` / `Scanner.stop('check-in')` from a "Done" button to close the camera when the shift ends. + +## Platform notes + +| | Android | iOS | +|---|---|---| +| Min OS version | API 23 | 15.0 | +| Permission | `android.permission.CAMERA`, `android.permission.VIBRATE` | `NSCameraUsageDescription` in `Info.plist` (haptics need no entitlement) | +| Native implementation | `resources/android/ScannerFunctions.kt` (CameraX + ML Kit barcode scanning) | `resources/ios/ScannerFunctions.swift` (AVFoundation) | +| Gallery picker | Android Photo Picker (`ActivityResultContracts.PickVisualMedia`) — no permission needed | `PHPickerViewController` — no permission needed | +| Gallery decoding | ML Kit (`InputImage.fromFilePath`) | Vision (`VNDetectBarcodesRequest`) | + +Both are configured automatically by `nativephp.json` — you don't need to edit native project files by hand. Continuous mode uses a debounce window on both platforms so the same code isn't reported multiple times per second. + +## Testing + +```bash +composer install +composer test +``` + +Outside of a compiled native shell, `Scanner::scan()->scan()` and `Scanner::stop()` return `false` (there's no bridge to call) — this is expected and is exactly what the test suite asserts. + +## License + +MIT diff --git a/packages/mobile-scanner/composer.json b/packages/mobile-scanner/composer.json new file mode 100644 index 0000000..08ba0f4 --- /dev/null +++ b/packages/mobile-scanner/composer.json @@ -0,0 +1,48 @@ +{ + "name": "sghimire/mobile-scanner", + "version": "1.0.3", + "description": "Self-contained native QR/barcode scanner (CameraX + ML Kit / AVFoundation) for NativePHP Mobile — own facade, fluent multi-format API, events, and JS bindings, with no dependency on the paid nativephp/mobile-scanner plugin.", + "type": "nativephp-plugin", + "keywords": ["nativephp", "scanner", "qr-code", "barcode", "camera", "laravel", "mobile"], + "license": "MIT", + "authors": [ + { + "name": "Sandip Ghimire", + "email": "sandipghimire2076@gmail.com" + } + ], + "require": { + "php": "^8.2", + "nativephp/mobile": "^3.0|^4.0" + }, + "suggest": { + "livewire/livewire": "Required only if you use the #[OnNative] attribute to bind scanner events directly to component methods." + }, + "autoload": { + "psr-4": { + "Sandip\\Scanner\\Native\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Sandip\\Scanner\\Native\\ScannerServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "require-dev": { + "pestphp/pest": "^3.0", + "laravel/pint": "^1.29" + }, + "scripts": { + "test": "pest" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/packages/mobile-scanner/nativephp.json b/packages/mobile-scanner/nativephp.json new file mode 100644 index 0000000..99fd5df --- /dev/null +++ b/packages/mobile-scanner/nativephp.json @@ -0,0 +1,87 @@ +{ + "name": "sghimire/mobile-scanner", + "version": "1.0.0", + "description": "Native QR/barcode scanner (CameraX + ML Kit / AVFoundation), free alternative to the paid nativephp/mobile-scanner plugin. Ships its own facade, fluent multi-format scan builder, events, and JS bindings.", + "namespace": "Scanner", + "keywords": ["scanner", "qr-code", "barcode", "camera"], + "category": "media", + "license": "MIT", + "pricing": { + "type": "free" + }, + "author": { + "name": "Sandip Ghimire", + "email": "sandipghimire2076@gmail.com", + "url": "" + }, + "homepage": "", + "repository": "", + "funding": [], + "platforms": [ + "android", + "ios" + ], + "icon": "resources/icon.png", + "screenshots": [], + "bridge_functions": [ + { + "name": "MobileScanner.Scan", + "android": "com.sandip.plugins.scanner.ScannerFunctions.Scan", + "ios": "ScannerFunctions.Scan", + "description": "Present a full-screen native camera scanner for one or more barcode formats. Unless disabled via allowGallery=false, the overlay includes a gallery button that lets the user pick a photo instead of using the live camera; the picked image is decoded on-device for the same requested formats. Returns immediately once the UI is shown; results (from the camera, continuous re-scans, or a gallery pick) arrive later via the CodeScanned event, and closing without a match fires Cancelled." + }, + { + "name": "MobileScanner.Stop", + "android": "com.sandip.plugins.scanner.ScannerFunctions.Stop", + "ios": "ScannerFunctions.Stop", + "description": "Programmatically dismiss the currently open scanner (e.g. a continuous session), as if the user tapped close. Fires Cancelled with reason 'stopped_by_app'." + } + ], + "android": { + "min_version": 23, + "permissions": [ + "android.permission.CAMERA", + "android.permission.VIBRATE" + ], + "repositories": [], + "dependencies": { + "implementation": [ + "androidx.camera:camera-core:1.3.4", + "androidx.camera:camera-camera2:1.3.4", + "androidx.camera:camera-lifecycle:1.3.4", + "androidx.camera:camera-view:1.3.4", + "androidx.activity:activity-ktx:1.9.3", + "com.google.mlkit:barcode-scanning:17.3.0" + ] + }, + "activities": [], + "services": [], + "receivers": [], + "providers": [] + }, + "ios": { + "min_version": "15.0", + "permissions": [], + "info_plist": { + "NSCameraUsageDescription": "Used to scan QR codes and barcodes." + }, + "repositories": [], + "dependencies": { + "swift_packages": [], + "pods": [] + } + }, + "assets": { + "android": [], + "ios": [] + }, + "secrets": [], + "events": [ + "Sandip\\Scanner\\Native\\Events\\Scanner\\CodeScanned", + "Sandip\\Scanner\\Native\\Events\\Scanner\\Cancelled" + ], + "service_provider": "Sandip\\Scanner\\Native\\ScannerServiceProvider", + "hooks": { + "copy_assets": "nativephp:mobile-scanner:copy-assets" + } +} diff --git a/packages/mobile-scanner/phpunit.xml b/packages/mobile-scanner/phpunit.xml new file mode 100644 index 0000000..19c6e3c --- /dev/null +++ b/packages/mobile-scanner/phpunit.xml @@ -0,0 +1,12 @@ + + + + + tests + + + diff --git a/packages/mobile-scanner/resources/android/ScannerFunctions.kt b/packages/mobile-scanner/resources/android/ScannerFunctions.kt new file mode 100644 index 0000000..6717874 --- /dev/null +++ b/packages/mobile-scanner/resources/android/ScannerFunctions.kt @@ -0,0 +1,1209 @@ +@file:androidx.annotation.OptIn(markerClass = [ExperimentalGetImage::class]) + +package com.sandip.plugins.scanner + +import android.Manifest +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.drawable.GradientDrawable +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.util.Log +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import android.widget.FrameLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.FocusMeteringAction +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import com.nativephp.mobile.bridge.BridgeFunction +import com.nativephp.mobile.bridge.BridgeResponse +import com.nativephp.mobile.lifecycle.NativePHPLifecycle +import com.nativephp.mobile.utils.NativeActionCoordinator +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import org.json.JSONObject + +object ScannerFunctions { + + private const val TAG = "ScannerFunctions" + const val CAMERA_PERMISSION_REQUEST_CODE = 4272 + private const val CODE_SCANNED_EVENT = "Sandip\\Scanner\\Native\\Events\\Scanner\\CodeScanned" + private const val CANCELLED_EVENT = "Sandip\\Scanner\\Native\\Events\\Scanner\\Cancelled" + + private const val REPEAT_DEBOUNCE_MS = 2000L + private const val SUCCESS_PULSE_MS = 1000L + private const val ACCENT_GREEN = 0xFF34D399.toInt() + + @Volatile private var activeOverlay: ScannerOverlay? = null + + private data class PendingScan(val id: String?, val activity: FragmentActivity) + + @Volatile private var pendingScan: PendingScan? = null + + init { + // Runtime permission results only reach plugins via this lifecycle bus - the + // OS-level Activity.onRequestPermissionsResult callback is not forwarded per-plugin. + NativePHPLifecycle.on(NativePHPLifecycle.Events.ON_PERMISSION_RESULT) { data -> + if (data["permission"] as? String == Manifest.permission.CAMERA) { + handleCameraPermissionResult(data["granted"] as? Boolean ?: false) + } + } + } + + private val FORMAT_MAP: Map = + mapOf( + "qr" to Barcode.FORMAT_QR_CODE, + "ean13" to Barcode.FORMAT_EAN_13, + "ean8" to Barcode.FORMAT_EAN_8, + "code128" to Barcode.FORMAT_CODE_128, + "code39" to Barcode.FORMAT_CODE_39, + "upca" to Barcode.FORMAT_UPC_A, + "upce" to Barcode.FORMAT_UPC_E, + ) + + private val REVERSE_FORMAT_MAP: Map = + mapOf( + Barcode.FORMAT_QR_CODE to "qr", + Barcode.FORMAT_EAN_13 to "ean13", + Barcode.FORMAT_EAN_8 to "ean8", + Barcode.FORMAT_CODE_128 to "code128", + Barcode.FORMAT_CODE_39 to "code39", + Barcode.FORMAT_UPC_A to "upca", + Barcode.FORMAT_UPC_E to "upce", + Barcode.FORMAT_CODE_93 to "code93", + Barcode.FORMAT_CODABAR to "codabar", + Barcode.FORMAT_ITF to "itf", + Barcode.FORMAT_DATA_MATRIX to "data_matrix", + Barcode.FORMAT_PDF417 to "pdf417", + Barcode.FORMAT_AZTEC to "aztec", + ) + + private fun barcodeFormatOptions(names: List): BarcodeScannerOptions { + if (names.contains("all")) { + return BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS) + .build() + } + + val formats = names.mapNotNull { FORMAT_MAP[it] }.distinct() + val first = formats.firstOrNull() ?: Barcode.FORMAT_QR_CODE + val rest = formats.drop(1).toIntArray() + + return BarcodeScannerOptions.Builder().setBarcodeFormats(first, *rest).build() + } + + private object PermissionPrefs { + private const val PREFS_NAME = "scanner_permission_prefs" + private const val KEY_ASKED = "camera_permission_asked" + + fun hasAskedBefore(context: Context): Boolean = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(KEY_ASKED, false) + + fun markAsked(context: Context) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_ASKED, true) + .apply() + } + } + + class GalleryPickerHost : Fragment() { + private var callback: ((Uri?) -> Unit)? = null + + private val launcher = + registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + val cb = callback + callback = null + cb?.invoke(uri) + } + + fun pickImage(onPicked: (Uri?) -> Unit) { + callback = onPicked + launcher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + + companion object { + private const val TAG = "ScannerGalleryPicker" + + fun install(activity: FragmentActivity): GalleryPickerHost = + activity.supportFragmentManager.findFragmentByTag(TAG) as? GalleryPickerHost + ?: GalleryPickerHost().also { + activity.supportFragmentManager + .beginTransaction() + .add(it, TAG) + .commitNow() + } + } + } + + private fun startScan( + activity: FragmentActivity, + prompt: String, + continuous: Boolean, + allowGallery: Boolean, + formats: List, + haptics: Boolean, + zoom: Float, + maxZoom: Float, + zoomControl: Boolean, + focusOnTap: Boolean, + timeoutSeconds: Int, + id: String?, + ) { + activity.runOnUiThread { + activeOverlay?.finish(cancelled = true) + val overlay = + ScannerOverlay( + activity, + prompt, + continuous, + allowGallery, + formats, + haptics, + zoom, + maxZoom, + zoomControl, + focusOnTap, + timeoutSeconds, + id + ) + activeOverlay = overlay + overlay.show() + } + } + + private fun handleCameraPermissionResult(granted: Boolean) { + val pending = pendingScan ?: return + pendingScan = null + + pending.activity.runOnUiThread { + val payload = JSONObject() + payload.put("reason", if (granted) "permission_required" else "permission_denied") + if (pending.id != null) payload.put("id", pending.id) + NativeActionCoordinator.dispatchEvent( + pending.activity, + CANCELLED_EVENT, + payload.toString() + ) + } + } + + class Scan(private val activity: FragmentActivity) : BridgeFunction { + override fun execute(parameters: Map): Map { + val prompt = parameters["prompt"] as? String ?: "" + val continuous = parameters["continuous"] as? Boolean ?: false + val allowGallery = parameters["allowGallery"] as? Boolean ?: true + val id = parameters["id"] as? String + + @Suppress("UNCHECKED_CAST") + val requestedFormats = + (parameters["formats"] as? List)?.filter { it.isNotBlank() }?.takeIf { + it.isNotEmpty() + } + ?: listOf("qr") + + val haptics = parameters["haptics"] as? Boolean ?: true + val zoom = (parameters["zoom"] as? Number)?.toFloat() ?: 1.0f + val maxZoom = (parameters["maxZoom"] as? Number)?.toFloat() ?: 3.0f + val zoomControl = parameters["zoomControl"] as? Boolean ?: true + val focusOnTap = parameters["focusOnTap"] as? Boolean ?: true + val timeoutSeconds = (parameters["timeout"] as? Number)?.toInt() ?: 0 + + val unknown = requestedFormats.filter { it != "all" && !FORMAT_MAP.containsKey(it) } + if (unknown.isNotEmpty()) { + return BridgeResponse.error( + "INVALID_FORMAT", + "Unknown barcode format(s): ${unknown.joinToString(", ")}. Valid formats are: ${(FORMAT_MAP.keys + "all").joinToString(", ")}." + ) + } + + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) != + PackageManager.PERMISSION_GRANTED + ) { + val askedBefore = PermissionPrefs.hasAskedBefore(activity) + val canShowRationale = + ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.CAMERA + ) + + if (askedBefore && !canShowRationale) { + return BridgeResponse.error( + "PERMISSION_DENIED", + "Camera access is denied. Enable it in Settings to use the scanner." + ) + } + + PermissionPrefs.markAsked(activity) + pendingScan = PendingScan(id, activity) + + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.CAMERA), + CAMERA_PERMISSION_REQUEST_CODE + ) + + return BridgeResponse.success(mapOf("permissionRequested" to true)) + } + + startScan( + activity, + prompt, + continuous, + allowGallery, + requestedFormats, + haptics, + zoom, + maxZoom, + zoomControl, + focusOnTap, + timeoutSeconds, + id + ) + + return BridgeResponse.success(mapOf("started" to true)) + } + } + + class Stop(private val activity: FragmentActivity) : BridgeFunction { + override fun execute(parameters: Map): Map { + val id = parameters["id"] as? String + val overlay = activeOverlay + + if (overlay == null || (id != null && overlay.id != id)) { + return BridgeResponse.success(mapOf("stopped" to false)) + } + + activity.runOnUiThread { overlay.finish(cancelled = true, reason = "stopped_by_app") } + + return BridgeResponse.success(mapOf("stopped" to true)) + } + } + + private class IconButtonView( + context: Context, + initialGlyph: (Canvas, Float, Float, Float) -> Unit, + ) : View(context) { + var glyph: (Canvas, Float, Float, Float) -> Unit = initialGlyph + set(value) { + field = value + invalidate() + } + + private val circlePaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + } + + init { + isClickable = true + isFocusable = true + } + + override fun onDraw(canvas: Canvas) { + val cx = width / 2f + val cy = height / 2f + val radius = minOf(width, height) / 2f + canvas.drawCircle(cx, cy, radius, circlePaint) + glyph(canvas, cx, cy, radius * 0.5f) + } + } + + private fun drawCloseIcon(canvas: Canvas, cx: Float, cy: Float, r: Float) { + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + style = Paint.Style.STROKE + strokeWidth = r * 0.24f + strokeCap = Paint.Cap.ROUND + } + val d = r * 0.72f + canvas.drawLine(cx - d, cy - d, cx + d, cy + d, paint) + canvas.drawLine(cx - d, cy + d, cx + d, cy - d, paint) + } + + private fun drawBoltIcon(canvas: Canvas, cx: Float, cy: Float, r: Float) { + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + style = Paint.Style.FILL + } + val s = (r * 2.1f) / 24f + val ox = cx - 12f * s + val oy = cy - 12f * s + val path = + Path().apply { + moveTo(ox + 7f * s, oy + 2f * s) + lineTo(ox + 7f * s, oy + 13f * s) + lineTo(ox + 10f * s, oy + 13f * s) + lineTo(ox + 10f * s, oy + 22f * s) + lineTo(ox + 17f * s, oy + 10f * s) + lineTo(ox + 13f * s, oy + 10f * s) + lineTo(ox + 17f * s, oy + 2f * s) + close() + } + canvas.drawPath(path, paint) + } + + private fun drawBoltSlashIcon(canvas: Canvas, cx: Float, cy: Float, r: Float) { + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + style = Paint.Style.FILL + } + val s = (r * 2.1f) / 24f + val ox = cx - 12f * s + val oy = cy - 12f * s + + val body = + Path().apply { + moveTo(ox + 3.27f * s, oy + 3f * s) + lineTo(ox + 2f * s, oy + 4.27f * s) + lineTo(ox + 7.18f * s, oy + 9.45f * s) + lineTo(ox + 7f * s, oy + 10f * s) + lineTo(ox + 10f * s, oy + 10f * s) + lineTo(ox + 10f * s, oy + 20f * s) + lineTo(ox + 13.58f * s, oy + 13.86f * s) + lineTo(ox + 17.73f * s, oy + 18f * s) + lineTo(ox + 19f * s, oy + 16.73f * s) + close() + } + canvas.drawPath(body, paint) + + val tip = + Path().apply { + moveTo(ox + 17f * s, oy + 10f * s) + lineTo(ox + 13f * s, oy + 10f * s) + lineTo(ox + 17f * s, oy + 2f * s) + lineTo(ox + 7f * s, oy + 2f * s) + lineTo(ox + 7f * s, oy + 4.18f * s) + lineTo(ox + 14.46f * s, oy + 11.64f * s) + close() + } + canvas.drawPath(tip, paint) + } + + private class ViewfinderOverlayView( + context: Context, + private val windowRect: RectF, + ) : View(context) { + private val borderPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = 6f + } + private val pulsePaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = ACCENT_GREEN + style = Paint.Style.STROKE + } + + private val cornerRadius = 24f + private var pulseProgress = 0f + private var pulseActive = false + private var pulseTarget: RectF? = null + private var pulseAnimator: ValueAnimator? = null + + fun playSuccessPulse(codeRect: RectF?) { + pulseTarget = codeRect + pulseAnimator?.cancel() + pulseAnimator = + ValueAnimator.ofFloat(0f, 1f).apply { + duration = SUCCESS_PULSE_MS + interpolator = DecelerateInterpolator() + addUpdateListener { + pulseProgress = it.animatedValue as Float + invalidate() + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + pulseActive = false + invalidate() + } + } + ) + pulseActive = true + start() + } + } + + override fun onDraw(canvas: Canvas) { + if (!pulseActive) { + canvas.drawRoundRect(windowRect, cornerRadius, cornerRadius, borderPaint) + return + } + + val target = pulseTarget ?: windowRect + val radius = minOf(target.width(), target.height()) * 0.12f + + val growT = (pulseProgress / 0.25f).coerceIn(0f, 1f) + val scale = 1.3f + (1f - 1.3f) * growT + val hw = target.width() / 2f * scale + val hh = target.height() / 2f * scale + val cx = target.centerX() + val cy = target.centerY() + + val fadeT = ((pulseProgress - 0.7f) / 0.3f).coerceIn(0f, 1f) + pulsePaint.alpha = ((1f - fadeT) * 255).toInt() + pulsePaint.strokeWidth = 6f + + canvas.drawRoundRect( + RectF(cx - hw, cy - hh, cx + hw, cy + hh), + radius, + radius, + pulsePaint + ) + } + } + + private class ZoomSliderView(context: Context) : View(context) { + var minValue = 1f + var maxValue = 3f + var onValueChanged: ((Float) -> Unit)? = null + + var value = 1f + set(newValue) { + field = newValue.coerceIn(minValue, maxValue) + invalidate() + } + + private val density = context.resources.displayMetrics.density + private val thumbRadius = density * 9f + + private val trackPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#4DFFFFFF") + strokeWidth = density * 2f + strokeCap = Paint.Cap.ROUND + } + private val progressPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + strokeWidth = density * 2f + strokeCap = Paint.Cap.ROUND + } + private val thumbPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + } + private val thumbShadowPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#40000000") + style = Paint.Style.FILL + } + + private fun trackLeft() = paddingLeft + thumbRadius + private fun trackRight() = width - paddingRight - thumbRadius + + override fun onDraw(canvas: Canvas) { + val left = trackLeft() + val right = trackRight() + if (right <= left) return + + val cy = height / 2f + val ratio = ((value - minValue) / (maxValue - minValue)).coerceIn(0f, 1f) + val thumbX = left + (right - left) * ratio + + canvas.drawLine(left, cy, right, cy, trackPaint) + canvas.drawLine(left, cy, thumbX, cy, progressPaint) + canvas.drawCircle(thumbX, cy + density, thumbRadius, thumbShadowPaint) + canvas.drawCircle(thumbX, cy, thumbRadius, thumbPaint) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + updateFromTouch(event.x) + performClick() + parent?.requestDisallowInterceptTouchEvent(false) + return true + } + } + updateFromTouch(event.x) + return true + } + + override fun performClick(): Boolean { + super.performClick() + return true + } + + private fun updateFromTouch(x: Float) { + val left = trackLeft() + val right = trackRight() + if (right <= left) return + val ratio = ((x - left) / (right - left)).coerceIn(0f, 1f) + val newValue = minValue + (maxValue - minValue) * ratio + value = newValue + onValueChanged?.invoke(newValue) + } + } + + private class ScannerOverlay( + private val activity: FragmentActivity, + private val prompt: String, + private val continuous: Boolean, + private val allowGallery: Boolean, + private val formatNames: List, + private val haptics: Boolean, + private val initialZoom: Float, + private val maxZoomConfigured: Float, + private val zoomControl: Boolean, + private val focusOnTap: Boolean, + private val timeoutSeconds: Int, + val id: String?, + ) { + private val root = activity.findViewById(android.R.id.content) + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private val finished = AtomicBoolean(false) + private val matched = AtomicBoolean(false) + private var overlayView: FrameLayout? = null + private var viewfinderView: ViewfinderOverlayView? = null + private var previewView: PreviewView? = null + private var cameraProvider: ProcessCameraProvider? = null + private var camera: Camera? = null + private var torchOn = false + private var scanner: BarcodeScanner? = null + + private val timeoutHandler = Handler(Looper.getMainLooper()) + private var timeoutRunnable: Runnable? = null + + private var zoomLowerBound = 1f + private var zoomUpperBound = maxZoomConfigured.coerceAtLeast(1f) + private var zoomSliderView: ZoomSliderView? = null + private var zoomLabel: TextView? = null + + private var lastValue: String? = null + private var lastFiredAt: Long = 0L + + private fun dp(value: Int): Int = + (value * activity.resources.displayMetrics.density).toInt() + + fun show() { + val previewView = + PreviewView(activity).apply { + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } + + val dm = activity.resources.displayMetrics + val squareSide = (minOf(dm.widthPixels, dm.heightPixels) * 0.68f) + val squareLeft = (dm.widthPixels - squareSide) / 2f + val squareTop = dm.heightPixels * 0.26f + val squareRect = + RectF(squareLeft, squareTop, squareLeft + squareSide, squareTop + squareSide) + + val viewfinder = + ViewfinderOverlayView(activity, squareRect).apply { + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } + viewfinderView = viewfinder + this.previewView = previewView + + val titleLabel = + TextView(activity).apply { + text = "Scan Code" + setTextColor(Color.WHITE) + textSize = 17f + setTypeface(typeface, android.graphics.Typeface.BOLD) + gravity = Gravity.CENTER + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.TOP or Gravity.CENTER_HORIZONTAL + ) + .apply { topMargin = dp(56) } + } + + val promptBottomMargin = dp(40) + val promptLabel = + if (prompt.isBlank()) null + else + TextView(activity).apply { + text = prompt + setTextColor(Color.WHITE) + textSize = 14f + gravity = Gravity.CENTER + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM + ) + .apply { + bottomMargin = promptBottomMargin + leftMargin = dp(32) + rightMargin = dp(32) + } + } + + val closeButton = + IconButtonView(activity) { canvas, cx, cy, r -> + drawCloseIcon(canvas, cx, cy, r) + } + .apply { + layoutParams = + FrameLayout.LayoutParams( + dp(44), + dp(44), + Gravity.TOP or Gravity.START + ) + .apply { + topMargin = dp(44) + leftMargin = dp(20) + } + setOnClickListener { + finish(cancelled = true, reason = "user_cancelled") + } + } + + val torchButton = + IconButtonView(activity) { canvas, cx, cy, r -> + drawBoltSlashIcon(canvas, cx, cy, r) + } + .apply { + visibility = View.INVISIBLE + layoutParams = + FrameLayout.LayoutParams( + dp(44), + dp(44), + Gravity.TOP or Gravity.END + ) + .apply { + topMargin = dp(44) + rightMargin = dp(20) + } + setOnClickListener { + val cam = camera ?: return@setOnClickListener + torchOn = !torchOn + cam.cameraControl.enableTorch(torchOn) + glyph = if (torchOn) ::drawBoltIcon else ::drawBoltSlashIcon + } + } + + val galleryButton = + if (!allowGallery) null + else + TextView(activity).apply { + text = "Choose from Gallery" + setTextColor(Color.WHITE) + textSize = 14f + setTypeface(typeface, android.graphics.Typeface.BOLD) + gravity = Gravity.CENTER + includeFontPadding = false + setPadding(dp(24), dp(12), dp(24), dp(12)) + background = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(24).toFloat() + setColor(Color.parseColor("#33FFFFFF")) + setStroke(dp(2), Color.WHITE) + } + isClickable = true + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.TOP or Gravity.CENTER_HORIZONTAL + ) + .apply { + topMargin = squareRect.bottom.toInt() + dp(28) + } + setOnClickListener { pickFromGallery() } + } + + val zoomLabel = + if (!zoomControl) null + else + TextView(activity).apply { + text = formatZoomLabel(initialZoom.coerceIn(zoomLowerBound, zoomUpperBound)) + setTextColor(Color.WHITE) + textSize = 13f + setTypeface(typeface, android.graphics.Typeface.BOLD) + gravity = Gravity.CENTER + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + ) + .apply { bottomMargin = promptBottomMargin + dp(90) } + } + this.zoomLabel = zoomLabel + + val zoomSlider = + if (!zoomControl) null + else + ZoomSliderView(activity).apply { + minValue = zoomLowerBound + maxValue = zoomUpperBound + value = initialZoom.coerceIn(zoomLowerBound, zoomUpperBound) + layoutParams = + FrameLayout.LayoutParams( + dp(200), + dp(32), + Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + ) + .apply { bottomMargin = promptBottomMargin + dp(54) } + onValueChanged = { newValue -> + camera?.cameraControl?.setZoomRatio(newValue) + this@ScannerOverlay.zoomLabel?.text = formatZoomLabel(newValue) + } + } + this.zoomSliderView = zoomSlider + + val overlay = + FrameLayout(activity).apply { + setBackgroundColor(Color.BLACK) + addView(previewView) + addView(viewfinder) + addView(titleLabel) + promptLabel?.let { addView(it) } + galleryButton?.let { addView(it) } + zoomLabel?.let { addView(it) } + zoomSlider?.let { addView(it) } + addView(closeButton) + addView(torchButton) + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } + + overlayView = overlay + root.addView(overlay) + + if (zoomControl && (zoomSlider != null || zoomLabel != null)) { + overlay.viewTreeObserver.addOnGlobalLayoutListener( + object : android.view.ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + overlay.viewTreeObserver.removeOnGlobalLayoutListener(this) + val slider = zoomSlider ?: return + val bottomLimit = + if (promptLabel != null) { + promptLabel.top - dp(14) + } else { + overlay.height - dp(28) + } + slider.y = (bottomLimit - slider.height).toFloat() + zoomLabel?.let { it.y = slider.y - dp(4) - it.height } + } + } + ) + } + + if (focusOnTap) { + previewView.setOnTouchListener { view, event -> + if (event.action == MotionEvent.ACTION_UP) { + val cam = camera + if (cam != null) { + val point = + previewView.meteringPointFactory.createPoint( + event.x, + event.y + ) + val action = + FocusMeteringAction.Builder( + point, + FocusMeteringAction.FLAG_AF or + FocusMeteringAction.FLAG_AE + ) + .setAutoCancelDuration(3, TimeUnit.SECONDS) + .build() + cam.cameraControl.startFocusAndMetering(action) + showFocusIndicator(event.x, event.y) + } + view.performClick() + } + true + } + } + + if (timeoutSeconds > 0) { + val runnable = Runnable { finish(cancelled = true, reason = "timeout") } + timeoutRunnable = runnable + timeoutHandler.postDelayed(runnable, timeoutSeconds * 1000L) + } + + val scanner = BarcodeScanning.getClient(barcodeFormatOptions(formatNames)) + this.scanner = scanner + + val cameraProviderFuture = ProcessCameraProvider.getInstance(activity) + cameraProviderFuture.addListener( + { + val provider = cameraProviderFuture.get() + cameraProvider = provider + + val preview = + Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val analysis = + ImageAnalysis.Builder() + .setBackpressureStrategy( + ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST + ) + .build() + .also { + it.setAnalyzer(executor) { imageProxy -> + processFrame(imageProxy, scanner) + } + } + + try { + provider.unbindAll() + val boundCamera = + provider.bindToLifecycle( + activity, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis + ) + camera = boundCamera + if (boundCamera.cameraInfo.hasFlashUnit()) { + torchButton.visibility = View.VISIBLE + } + applyZoom(boundCamera) + } catch (e: Exception) { + Log.e(TAG, "Failed to bind camera", e) + finish(cancelled = true, reason = "camera_error") + } + }, + ContextCompat.getMainExecutor(activity) + ) + } + + private fun processFrame(imageProxy: ImageProxy, scanner: BarcodeScanner) { + val mediaImage = imageProxy.image + if (mediaImage == null) { + imageProxy.close() + return + } + + val rotationDegrees = imageProxy.imageInfo.rotationDegrees + val image = InputImage.fromMediaImage(mediaImage, rotationDegrees) + val imageWidth = imageProxy.width + val imageHeight = imageProxy.height + + scanner.process(image) + .addOnSuccessListener { barcodes -> + val barcode = barcodes.firstOrNull { !it.rawValue.isNullOrEmpty() } + val value = barcode?.rawValue + + if (value != null) { + val mappedBox = + mapBoundingBoxToView( + barcode.boundingBox, + imageWidth, + imageHeight, + rotationDegrees + ) + handleMatch( + value, + REVERSE_FORMAT_MAP[barcode.format] ?: "unknown", + mappedBox + ) + } + } + .addOnFailureListener { Log.e(TAG, "Barcode scan failed", it) } + .addOnCompleteListener { imageProxy.close() } + } + + private fun mapBoundingBoxToView( + box: Rect?, + imageWidth: Int, + imageHeight: Int, + rotationDegrees: Int, + ): RectF? { + box ?: return null + val pv = previewView ?: return null + if (pv.width == 0 || pv.height == 0) return null + + val rotatedWidth = + if (rotationDegrees == 90 || rotationDegrees == 270) imageHeight else imageWidth + val rotatedHeight = + if (rotationDegrees == 90 || rotationDegrees == 270) imageWidth else imageHeight + if (rotatedWidth <= 0 || rotatedHeight <= 0) return null + + val scale = + maxOf(pv.width.toFloat() / rotatedWidth, pv.height.toFloat() / rotatedHeight) + val offsetX = (pv.width - rotatedWidth * scale) / 2f + val offsetY = (pv.height - rotatedHeight * scale) / 2f + + return RectF( + box.left * scale + offsetX, + box.top * scale + offsetY, + box.right * scale + offsetX, + box.bottom * scale + offsetY + ) + } + + private fun formatZoomLabel(ratio: Float): String = String.format("%.1fx", ratio) + + private fun applyZoom(boundCamera: Camera) { + val zoomState = boundCamera.cameraInfo.zoomState.value + val deviceMin = zoomState?.minZoomRatio ?: 1f + val deviceMax = zoomState?.maxZoomRatio ?: maxZoomConfigured + zoomLowerBound = deviceMin.coerceAtLeast(1f) + zoomUpperBound = maxZoomConfigured.coerceIn(zoomLowerBound, deviceMax) + + val clampedInitial = initialZoom.coerceIn(zoomLowerBound, zoomUpperBound) + boundCamera.cameraControl.setZoomRatio(clampedInitial) + + zoomSliderView?.minValue = zoomLowerBound + zoomSliderView?.maxValue = zoomUpperBound + zoomSliderView?.value = clampedInitial + zoomLabel?.text = formatZoomLabel(clampedInitial) + } + + private fun showFocusIndicator(x: Float, y: Float) { + val overlay = overlayView ?: return + val size = dp(64) + val ring = + View(activity).apply { + background = + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setStroke(dp(2), Color.WHITE) + setColor(Color.TRANSPARENT) + } + layoutParams = + FrameLayout.LayoutParams(size, size).apply { + leftMargin = (x - size / 2f).toInt() + topMargin = (y - size / 2f).toInt() + } + alpha = 0f + scaleX = 1.3f + scaleY = 1.3f + } + overlay.addView(ring) + ring.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(180) + .withEndAction { + ring.animate() + .alpha(0f) + .setStartDelay(400) + .setDuration(300) + .withEndAction { overlay.removeView(ring) } + .start() + } + .start() + } + + private fun vibrate() { + if (!haptics) return + try { + val vibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = + activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? + VibratorManager + manager?.defaultVibrator + } else { + @Suppress("DEPRECATION") + activity.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + + if (vibrator == null || !vibrator.hasVibrator()) return + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate( + VibrationEffect.createOneShot(40, VibrationEffect.DEFAULT_AMPLITUDE) + ) + } else { + @Suppress("DEPRECATION") vibrator.vibrate(40) + } + } catch (e: SecurityException) { + Log.w(TAG, "Vibrate skipped: android.permission.VIBRATE not granted", e) + } + } + + private fun pickFromGallery() { + if (!allowGallery) return + GalleryPickerHost.install(activity).pickImage { uri -> + if (uri == null) return@pickImage + decodeGalleryImage(uri) + } + } + + private fun decodeGalleryImage(uri: Uri) { + if (finished.get()) return + val scanner = this.scanner ?: return + + val image = + try { + InputImage.fromFilePath(activity, uri) + } catch (e: Exception) { + Log.e(TAG, "Failed to load picked image", e) + showGalleryToast("Couldn't read that image.") + return + } + + scanner.process(image) + .addOnSuccessListener { barcodes -> + val barcode = barcodes.firstOrNull { !it.rawValue.isNullOrEmpty() } + val value = barcode?.rawValue + + if (value != null) { + vibrate() + finish( + cancelled = false, + data = value, + format = REVERSE_FORMAT_MAP[barcode.format] ?: "unknown" + ) + } else { + showGalleryToast("No code found in that image.") + } + } + .addOnFailureListener { + Log.e(TAG, "Gallery barcode scan failed", it) + showGalleryToast("No code found in that image.") + } + } + + private fun showGalleryToast(message: String) { + activity.runOnUiThread { Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() } + } + + private fun handleMatch(value: String, format: String, boundingBox: RectF?) { + if (!continuous) { + if (!matched.compareAndSet(false, true)) return + + activity.runOnUiThread { + vibrate() + viewfinderView?.playSuccessPulse(boundingBox) + overlayView?.postDelayed( + { finish(cancelled = false, data = value, format = format) }, + SUCCESS_PULSE_MS + ) + } + return + } + + val now = System.currentTimeMillis() + if (value == lastValue && now - lastFiredAt < REPEAT_DEBOUNCE_MS) { + return + } + lastValue = value + lastFiredAt = now + + activity.runOnUiThread { + vibrate() + viewfinderView?.playSuccessPulse(boundingBox) + + val payload = JSONObject() + payload.put("data", value) + payload.put("format", format) + if (id != null) payload.put("id", id) + NativeActionCoordinator.dispatchEvent( + activity, + CODE_SCANNED_EVENT, + payload.toString() + ) + } + } + + fun finish( + cancelled: Boolean, + data: String? = null, + format: String? = null, + reason: String? = null + ) { + if (!finished.compareAndSet(false, true)) { + return + } + + if (activeOverlay === this) { + activeOverlay = null + } + + timeoutRunnable?.let { timeoutHandler.removeCallbacks(it) } + + activity.runOnUiThread { + cameraProvider?.unbindAll() + overlayView?.let { root.removeView(it) } + executor.shutdown() + + val payload = JSONObject() + if (cancelled) { + if (reason == null) return@runOnUiThread + payload.put("reason", reason) + if (id != null) payload.put("id", id) + NativeActionCoordinator.dispatchEvent( + activity, + CANCELLED_EVENT, + payload.toString() + ) + } else { + payload.put("data", data) + payload.put("format", format ?: "unknown") + if (id != null) payload.put("id", id) + NativeActionCoordinator.dispatchEvent( + activity, + CODE_SCANNED_EVENT, + payload.toString() + ) + } + } + } + } +} \ No newline at end of file diff --git a/packages/mobile-scanner/resources/ios/ScannerFunctions.swift b/packages/mobile-scanner/resources/ios/ScannerFunctions.swift new file mode 100644 index 0000000..8f5266c --- /dev/null +++ b/packages/mobile-scanner/resources/ios/ScannerFunctions.swift @@ -0,0 +1,953 @@ +import AVFoundation +import PhotosUI +import UIKit +import Vision + +enum ScannerFunctions { + + static let codeScannedEvent = "Sandip\\Scanner\\Native\\Events\\Scanner\\CodeScanned" + static let cancelledEvent = "Sandip\\Scanner\\Native\\Events\\Scanner\\Cancelled" + + static weak var activeController: ScannerViewController? + + static let validFormats: Set = ["qr", "ean13", "ean8", "code128", "code39", "upca", "upce"] + + private static func metadataObjectTypes(for names: [String]) -> [AVMetadataObject.ObjectType] { + if names.contains("all") { + return [.qr, .ean13, .ean8, .code128, .code39, .upce, .code93, .pdf417, .aztec, .dataMatrix, .interleaved2of5, .itf14, .codabar] + } + + var types = Set() + for name in names { + switch name { + case "qr": types.insert(.qr) + case "ean13": types.insert(.ean13) + case "ean8": types.insert(.ean8) + case "code128": types.insert(.code128) + case "code39": types.insert(.code39) + case "upce": types.insert(.upce) + case "upca": types.insert(.ean13) + default: break + } + } + return Array(types) + } + + static func barcodeSymbologies(for names: [String]) -> [VNBarcodeSymbology] { + if names.contains("all") { + return [.qr, .ean13, .ean8, .code128, .code39, .upce, .code93, .pdf417, .aztec, .dataMatrix, .itf14, .codabar] + } + + var symbologies = Set() + for name in names { + switch name { + case "qr": symbologies.insert(.qr) + case "ean13": symbologies.insert(.ean13) + case "ean8": symbologies.insert(.ean8) + case "code128": symbologies.insert(.code128) + case "code39": symbologies.insert(.code39) + case "upce": symbologies.insert(.upce) + case "upca": symbologies.insert(.ean13) + default: break + } + } + return symbologies.isEmpty ? [.qr] : Array(symbologies) + } + + class Scan: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + let prompt = parameters["prompt"] as? String ?? "" + let continuous = parameters["continuous"] as? Bool ?? false + let allowGallery = parameters["allowGallery"] as? Bool ?? true + let id = parameters["id"] as? String + let requestedFormats = (parameters["formats"] as? [String])?.filter { !$0.isEmpty } ?? ["qr"] + let haptics = parameters["haptics"] as? Bool ?? true + let zoom = (parameters["zoom"] as? NSNumber)?.doubleValue ?? 1.0 + let maxZoom = (parameters["maxZoom"] as? NSNumber)?.doubleValue ?? 3.0 + let zoomControl = parameters["zoomControl"] as? Bool ?? true + let focusOnTap = parameters["focusOnTap"] as? Bool ?? true + let timeoutSeconds = (parameters["timeout"] as? NSNumber)?.intValue ?? 0 + + let unknown = requestedFormats.filter { $0 != "all" && !ScannerFunctions.validFormats.contains($0) } + if !unknown.isEmpty { + return BridgeResponse.error( + code: "INVALID_FORMAT", + message: "Unknown barcode format(s): \(unknown.joined(separator: ", ")). Valid formats are: \(ScannerFunctions.validFormats.sorted().joined(separator: ", ")), all." + ) + } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + DispatchQueue.main.async { + ScannerFunctions.present( + prompt: prompt, + continuous: continuous, + allowGallery: allowGallery, + formats: requestedFormats, + haptics: haptics, + zoom: zoom, + maxZoom: maxZoom, + zoomControl: zoomControl, + focusOnTap: focusOnTap, + timeoutSeconds: timeoutSeconds, + id: id + ) + } + return BridgeResponse.success(data: ["started": true]) + + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + LaravelBridge.shared.send?(ScannerFunctions.cancelledEvent, [ + "reason": granted ? "permission_required" : "permission_denied", + "id": id, + ]) + } + } + + return BridgeResponse.success(data: ["permissionRequested": true]) + + case .denied, .restricted: + return BridgeResponse.error( + code: "PERMISSION_DENIED", + message: "Camera access is denied. Enable it in Settings to use the scanner." + ) + + @unknown default: + return BridgeResponse.error(code: "PERMISSION_DENIED", message: "Camera access is unavailable.") + } + } + } + + class Stop: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + let id = parameters["id"] as? String + + guard let controller = ScannerFunctions.activeController, + id == nil || controller.sessionId == id else { + return BridgeResponse.success(data: ["stopped": false]) + } + + DispatchQueue.main.async { + controller.finish(cancelled: true, reason: "stopped_by_app") + } + + return BridgeResponse.success(data: ["stopped": true]) + } + } + + private static func present(prompt: String, continuous: Bool, allowGallery: Bool, formats: [String], haptics: Bool, zoom: Double, maxZoom: Double, zoomControl: Bool, focusOnTap: Bool, timeoutSeconds: Int, id: String?) { + let rootViewController = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first(where: { $0.isKeyWindow })?.rootViewController + + guard let presenter = rootViewController else { + LaravelBridge.shared.send?(cancelledEvent, [ + "reason": "no_root_view_controller", + "id": id, + ]) + return + } + + activeController?.finish(cancelled: true, reason: nil) + + let types = metadataObjectTypes(for: formats) + let controller = ScannerViewController( + prompt: prompt, + continuous: continuous, + allowGallery: allowGallery, + formats: formats, + types: types, + haptics: haptics, + zoom: zoom, + maxZoom: maxZoom, + zoomControl: zoomControl, + focusOnTap: focusOnTap, + timeoutSeconds: timeoutSeconds, + id: id + ) + controller.modalPresentationStyle = .fullScreen + activeController = controller + presenter.present(controller, animated: true) + } +} + +final class ViewfinderOverlayView: UIView { + + private static let successColor = UIColor(red: 0.20, green: 0.83, blue: 0.60, alpha: 1) + private static let cornerRadius: CGFloat = 24 + + private let borderLayer = CAShapeLayer() + private let pulseLayer = CAShapeLayer() + + private(set) var scanWindowFrame: CGRect = .zero + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + + borderLayer.fillColor = UIColor.clear.cgColor + borderLayer.strokeColor = UIColor.white.cgColor + borderLayer.lineWidth = 3 + layer.addSublayer(borderLayer) + + pulseLayer.fillColor = UIColor.clear.cgColor + pulseLayer.strokeColor = ViewfinderOverlayView.successColor.cgColor + pulseLayer.lineWidth = 4 + pulseLayer.opacity = 0 + layer.addSublayer(pulseLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + let side = min(bounds.width, bounds.height) * 0.68 + let rect = CGRect( + x: (bounds.width - side) / 2, + y: bounds.height * 0.26, + width: side, + height: side + ) + scanWindowFrame = rect + borderLayer.path = UIBezierPath(roundedRect: rect, cornerRadius: ViewfinderOverlayView.cornerRadius).cgPath + } + + func playSuccessPulse(duration: TimeInterval, targetRect: CGRect?) { + let target = targetRect ?? scanWindowFrame + guard target.width > 0, target.height > 0 else { return } + + borderLayer.opacity = 0 + + let radius = min(target.width, target.height) * 0.12 + let inflated = target.insetBy(dx: -target.width * 0.15, dy: -target.height * 0.15) + let startPath = UIBezierPath(roundedRect: inflated, cornerRadius: radius * 1.3).cgPath + let exactPath = UIBezierPath(roundedRect: target, cornerRadius: radius).cgPath + + pulseLayer.removeAllAnimations() + pulseLayer.path = exactPath + pulseLayer.opacity = 0 + + let pathAnimation = CAKeyframeAnimation(keyPath: "path") + pathAnimation.values = [startPath, exactPath, exactPath, exactPath] + pathAnimation.keyTimes = [0, 0.25, 0.7, 1.0] + pathAnimation.timingFunctions = [ + CAMediaTimingFunction(name: .easeOut), + CAMediaTimingFunction(name: .linear), + CAMediaTimingFunction(name: .linear), + ] + + let fadeAnimation = CAKeyframeAnimation(keyPath: "opacity") + fadeAnimation.values = [1.0, 1.0, 1.0, 0.0] + fadeAnimation.keyTimes = [0, 0.25, 0.7, 1.0] + + let group = CAAnimationGroup() + group.animations = [pathAnimation, fadeAnimation] + group.duration = duration + + pulseLayer.add(group, forKey: "successPulse") + + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + self?.borderLayer.opacity = 1 + } + } +} + +final class FocusIndicatorView: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func animateFocus(at point: CGPoint) { + let size: CGFloat = 72 + let ring = UIView(frame: CGRect(x: point.x - size / 2, y: point.y - size / 2, width: size, height: size)) + ring.layer.borderColor = UIColor.white.cgColor + ring.layer.borderWidth = 1.5 + ring.layer.cornerRadius = size / 2 + ring.alpha = 0 + ring.transform = CGAffineTransform(scaleX: 1.3, y: 1.3) + addSubview(ring) + + UIView.animate(withDuration: 0.18, animations: { + ring.alpha = 1 + ring.transform = .identity + }) { _ in + UIView.animate(withDuration: 0.3, delay: 0.4, options: [], animations: { + ring.alpha = 0 + }) { _ in + ring.removeFromSuperview() + } + } + } +} + +final class CameraZoomSliderView: UIView { + + var minValue: CGFloat = 1.0 + var maxValue: CGFloat = 3.0 + var onValueChanged: ((CGFloat) -> Void)? + + var value: CGFloat = 1.0 { + didSet { + let clamped = min(max(value, minValue), maxValue) + if clamped != value { + value = clamped + return + } + setNeedsDisplay() + } + } + + private let thumbRadius: CGFloat = 9 + private let trackColor = UIColor.white.withAlphaComponent(0.3) + private let progressColor = UIColor.white + private let thumbColor = UIColor.white + private let thumbShadowColor = UIColor.black.withAlphaComponent(0.25) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + isUserInteractionEnabled = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var trackLeft: CGFloat { thumbRadius } + private var trackRight: CGFloat { bounds.width - thumbRadius } + + override func draw(_ rect: CGRect) { + guard let ctx = UIGraphicsGetCurrentContext() else { return } + let cy = bounds.height / 2 + let left = trackLeft + let right = trackRight + guard right > left else { return } + + let range = max(maxValue - minValue, 0.001) + let ratio = (value - minValue) / range + let thumbX = left + (right - left) * ratio + + ctx.setLineCap(.round) + ctx.setLineWidth(2) + + ctx.setStrokeColor(trackColor.cgColor) + ctx.move(to: CGPoint(x: left, y: cy)) + ctx.addLine(to: CGPoint(x: right, y: cy)) + ctx.strokePath() + + ctx.setStrokeColor(progressColor.cgColor) + ctx.move(to: CGPoint(x: left, y: cy)) + ctx.addLine(to: CGPoint(x: thumbX, y: cy)) + ctx.strokePath() + + ctx.setFillColor(thumbShadowColor.cgColor) + ctx.fillEllipse(in: CGRect(x: thumbX - thumbRadius, y: cy - thumbRadius + 1, width: thumbRadius * 2, height: thumbRadius * 2)) + + ctx.setFillColor(thumbColor.cgColor) + ctx.fillEllipse(in: CGRect(x: thumbX - thumbRadius, y: cy - thumbRadius, width: thumbRadius * 2, height: thumbRadius * 2)) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + updateValue(fromTouchX: touch.location(in: self).x) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + updateValue(fromTouchX: touch.location(in: self).x) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + updateValue(fromTouchX: touch.location(in: self).x) + } + + private func updateValue(fromTouchX x: CGFloat) { + let left = trackLeft + let right = trackRight + guard right > left else { return } + let ratio = min(max((x - left) / (right - left), 0), 1) + let newValue = minValue + (maxValue - minValue) * ratio + value = newValue + onValueChanged?(newValue) + } +} + +final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + + private let prompt: String + private let continuous: Bool + private let allowGallery: Bool + private let requestedFormats: [String] + private let types: [AVMetadataObject.ObjectType] + private let hapticsEnabled: Bool + private let initialZoom: Double + private let maxZoomConfigured: Double + private let zoomControlEnabled: Bool + private let focusOnTap: Bool + private let timeoutSeconds: Int + let sessionId: String? + + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + private var captureDevice: AVCaptureDevice? + private var finished = false + private var matched = false + private var torchOn = false + private let torchButton = UIButton(type: .system) + private let galleryButton = UIButton(type: .system) + private var promptLabel: UILabel? + private let viewfinderOverlay = ViewfinderOverlayView() + private let focusIndicatorView = FocusIndicatorView() + private let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + private var timeoutWorkItem: DispatchWorkItem? + + private let zoomSliderView = CameraZoomSliderView() + private let zoomValueLabel = UILabel() + private var zoomLowerBound: CGFloat = 1.0 + private var zoomUpperBound: CGFloat = 3.0 + private var currentZoom: CGFloat = 1.0 + + private var lastValue: String? + private var lastFiredAt: TimeInterval = 0 + private let repeatDebounceSeconds: TimeInterval = 2.0 + private static let successPulseDuration: TimeInterval = 1.0 + + init(prompt: String, continuous: Bool, allowGallery: Bool, formats: [String], types: [AVMetadataObject.ObjectType], haptics: Bool, zoom: Double, maxZoom: Double, zoomControl: Bool, focusOnTap: Bool, timeoutSeconds: Int, id: String?) { + self.prompt = prompt + self.continuous = continuous + self.allowGallery = allowGallery + self.requestedFormats = formats + self.types = types + self.hapticsEnabled = haptics + self.initialZoom = zoom + self.maxZoomConfigured = maxZoom + self.zoomControlEnabled = zoomControl + self.focusOnTap = focusOnTap + self.timeoutSeconds = timeoutSeconds + self.sessionId = id + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + setUpCamera() + setUpOverlay() + + if hapticsEnabled { + impactFeedback.prepare() + } + + if timeoutSeconds > 0 { + let workItem = DispatchWorkItem { [weak self] in + self?.finish(cancelled: true, reason: "timeout") + } + timeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeoutSeconds), execute: workItem) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard !session.isRunning else { return } + DispatchQueue.global(qos: .userInitiated).async { [session] in + session.startRunning() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + guard session.isRunning else { return } + session.stopRunning() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + + viewfinderOverlay.frame = view.bounds + viewfinderOverlay.layoutIfNeeded() + + let square = viewfinderOverlay.scanWindowFrame + + if allowGallery { + var size = galleryButton.bounds.size + if size == .zero { + galleryButton.sizeToFit() + size = galleryButton.bounds.size + } + galleryButton.frame = CGRect( + x: (view.bounds.width - size.width) / 2, + y: square.maxY + 28, + width: size.width, + height: size.height + ) + } + + if zoomControlEnabled { + let sliderWidth: CGFloat = 200 + let sliderHeight: CGFloat = 32 + let labelHeight: CGFloat = 18 + let labelGap: CGFloat = 4 + + let bottomLimit: CGFloat + if let promptLabel = promptLabel, !prompt.isEmpty { + bottomLimit = promptLabel.frame.minY - 14 + } else { + bottomLimit = view.bounds.height - view.safeAreaInsets.bottom - 28 + } + + zoomSliderView.frame = CGRect( + x: (view.bounds.width - sliderWidth) / 2, + y: bottomLimit - sliderHeight, + width: sliderWidth, + height: sliderHeight + ) + zoomValueLabel.frame = CGRect( + x: (view.bounds.width - sliderWidth) / 2, + y: zoomSliderView.frame.minY - labelGap - labelHeight, + width: sliderWidth, + height: labelHeight + ) + } + } + + private func setUpCamera() { + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { + finish(cancelled: true, reason: "camera_error") + return + } + session.addInput(input) + captureDevice = device + + if zoomControlEnabled || initialZoom != 1.0 { + let deviceMin = max(device.minAvailableVideoZoomFactor, 1.0) + let deviceMax = device.maxAvailableVideoZoomFactor + zoomLowerBound = deviceMin + zoomUpperBound = max(min(CGFloat(maxZoomConfigured), deviceMax), deviceMin) + + let clampedZoom = min(max(CGFloat(initialZoom), zoomLowerBound), zoomUpperBound) + do { + try device.lockForConfiguration() + device.videoZoomFactor = clampedZoom + device.unlockForConfiguration() + } catch { + } + currentZoom = clampedZoom + } + + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { + finish(cancelled: true, reason: "camera_error") + return + } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = types.filter { output.availableMetadataObjectTypes.contains($0) } + + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + layer.frame = view.bounds + view.layer.insertSublayer(layer, at: 0) + previewLayer = layer + } + + private func setUpOverlay() { + viewfinderOverlay.frame = view.bounds + viewfinderOverlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + viewfinderOverlay.isUserInteractionEnabled = false + view.addSubview(viewfinderOverlay) + + focusIndicatorView.frame = view.bounds + focusIndicatorView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + focusIndicatorView.isUserInteractionEnabled = false + view.addSubview(focusIndicatorView) + + if focusOnTap { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleFocusTap(_:))) + tapGesture.delegate = self + view.addGestureRecognizer(tapGesture) + } + + let titleLabel = UILabel() + titleLabel.text = "Scan Code" + titleLabel.textColor = .white + titleLabel.textAlignment = .center + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(titleLabel) + + let closeButton = UIButton(type: .system) + closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal) + styleIconButton(closeButton) + closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) + view.addSubview(closeButton) + + NSLayoutConstraint.activate([ + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), + closeButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), + closeButton.widthAnchor.constraint(equalToConstant: 40), + closeButton.heightAnchor.constraint(equalToConstant: 40), + + titleLabel.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), + titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + ]) + + if allowGallery { + galleryButton.setTitle("Choose from Gallery", for: .normal) + galleryButton.setTitleColor(.white, for: .normal) + galleryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold) + galleryButton.backgroundColor = UIColor.white.withAlphaComponent(0.14) + galleryButton.layer.borderWidth = 1.5 + galleryButton.layer.borderColor = UIColor.white.cgColor + galleryButton.layer.cornerRadius = 22 + galleryButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 24, bottom: 12, right: 24) + galleryButton.addTarget(self, action: #selector(galleryTapped), for: .touchUpInside) + view.addSubview(galleryButton) + galleryButton.sizeToFit() + } + + if zoomControlEnabled { + zoomValueLabel.text = String(format: "%.1fx", currentZoom) + zoomValueLabel.textColor = .white + zoomValueLabel.font = .systemFont(ofSize: 13, weight: .semibold) + zoomValueLabel.textAlignment = .center + view.addSubview(zoomValueLabel) + + zoomSliderView.minValue = zoomLowerBound + zoomSliderView.maxValue = zoomUpperBound + zoomSliderView.value = currentZoom + zoomSliderView.onValueChanged = { [weak self] newValue in + self?.applyZoomFromSlider(newValue) + } + view.addSubview(zoomSliderView) + } + + if !prompt.isEmpty { + let promptLabel = UILabel() + promptLabel.text = prompt + promptLabel.textColor = .white + promptLabel.textAlignment = .center + promptLabel.numberOfLines = 0 + promptLabel.font = .systemFont(ofSize: 14, weight: .regular) + promptLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(promptLabel) + self.promptLabel = promptLabel + + NSLayoutConstraint.activate([ + promptLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 32), + promptLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -32), + promptLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -32), + ]) + } + + guard let device = captureDevice, device.hasTorch else { return } + + torchButton.setImage(UIImage(systemName: "bolt.slash.fill")?.withRenderingMode(.alwaysTemplate), for: .normal) + styleIconButton(torchButton) + torchButton.addTarget(self, action: #selector(torchTapped), for: .touchUpInside) + view.addSubview(torchButton) + + NSLayoutConstraint.activate([ + torchButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), + torchButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), + torchButton.widthAnchor.constraint(equalToConstant: 40), + torchButton.heightAnchor.constraint(equalToConstant: 40), + ]) + } + + private func styleIconButton(_ button: UIButton) { + button.backgroundColor = .white + button.tintColor = .black + button.translatesAutoresizingMaskIntoConstraints = false + button.layer.cornerRadius = 20 + button.clipsToBounds = true + button.setPreferredSymbolConfiguration( + UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold), + forImageIn: .normal + ) + } + + @objc private func closeTapped() { + finish(cancelled: true, reason: "user_cancelled") + } + + @objc private func galleryTapped() { + guard allowGallery else { return } + + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 1 + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } + + @objc private func handleFocusTap(_ gesture: UITapGestureRecognizer) { + guard let device = captureDevice, let layer = previewLayer else { return } + + let location = gesture.location(in: view) + let devicePoint = layer.captureDevicePointConverted(fromLayerPoint: location) + + do { + try device.lockForConfiguration() + if device.isFocusPointOfInterestSupported { + device.focusPointOfInterest = devicePoint + device.focusMode = .autoFocus + } + if device.isExposurePointOfInterestSupported { + device.exposurePointOfInterest = devicePoint + device.exposureMode = .autoExpose + } + device.unlockForConfiguration() + focusIndicatorView.animateFocus(at: location) + } catch { + } + } + + private func applyZoomFromSlider(_ value: CGFloat) { + guard let device = captureDevice else { return } + + do { + try device.lockForConfiguration() + device.videoZoomFactor = value + device.unlockForConfiguration() + currentZoom = value + zoomValueLabel.text = String(format: "%.1fx", value) + } catch { + } + } + + @objc private func torchTapped() { + guard let device = captureDevice, device.hasTorch else { return } + + do { + try device.lockForConfiguration() + torchOn.toggle() + device.torchMode = torchOn ? .on : .off + device.unlockForConfiguration() + let symbolName = torchOn ? "bolt.fill" : "bolt.slash.fill" + torchButton.setImage(UIImage(systemName: symbolName)?.withRenderingMode(.alwaysTemplate), for: .normal) + } catch { + } + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let value = object.stringValue else { + return + } + + let format = ScannerViewController.formatName(for: object.type, value: value, requested: requestedFormats) + + let codeRect = previewLayer?.transformedMetadataObject(for: object)?.bounds + + if !continuous { + guard !matched else { return } + matched = true + + triggerHapticFeedback() + viewfinderOverlay.playSuccessPulse(duration: ScannerViewController.successPulseDuration, targetRect: codeRect) + DispatchQueue.main.asyncAfter(deadline: .now() + ScannerViewController.successPulseDuration) { [weak self] in + self?.finish(cancelled: false, data: value, format: format) + } + return + } + + let now = Date().timeIntervalSince1970 + if value == lastValue, now - lastFiredAt < repeatDebounceSeconds { + return + } + + triggerHapticFeedback() + viewfinderOverlay.playSuccessPulse(duration: ScannerViewController.successPulseDuration, targetRect: codeRect) + lastValue = value + lastFiredAt = now + + LaravelBridge.shared.send?(ScannerFunctions.codeScannedEvent, [ + "data": value, + "format": format, + "id": sessionId, + ]) + } + + private func triggerHapticFeedback() { + guard hapticsEnabled else { return } + impactFeedback.impactOccurred() + } + + private static func formatName(for type: AVMetadataObject.ObjectType, value: String, requested: [String]) -> String { + if type == .ean13, value.count == 13, value.hasPrefix("0"), + requested.contains("upca") || requested.contains("all"), !requested.contains("ean13") { + return "upca" + } + + switch type { + case .qr: return "qr" + case .ean13: return "ean13" + case .ean8: return "ean8" + case .code128: return "code128" + case .code39: return "code39" + case .upce: return "upce" + case .code93: return "code93" + case .pdf417: return "pdf417" + case .aztec: return "aztec" + case .dataMatrix: return "data_matrix" + case .interleaved2of5: return "itf" + case .itf14: return "itf14" + case .codabar: return "codabar" + default: return "unknown" + } + } + + private static func formatName(forSymbology symbology: VNBarcodeSymbology, value: String, requested: [String]) -> String { + if symbology == .ean13, value.count == 13, value.hasPrefix("0"), + requested.contains("upca") || requested.contains("all"), !requested.contains("ean13") { + return "upca" + } + + switch symbology { + case .qr: return "qr" + case .ean13: return "ean13" + case .ean8: return "ean8" + case .code128: return "code128" + case .code39: return "code39" + case .upce: return "upce" + case .code93: return "code93" + case .pdf417: return "pdf417" + case .aztec: return "aztec" + case .dataMatrix: return "data_matrix" + case .itf14: return "itf14" + case .codabar: return "codabar" + default: return "unknown" + } + } + + fileprivate func loadAndDecode(provider: NSItemProvider) { + provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in + guard let self = self else { return } + guard let image = object as? UIImage, let cgImage = image.cgImage else { + DispatchQueue.main.async { + self.showGalleryAlert(message: "Couldn't read that image.") + } + return + } + self.decodeGalleryImage(cgImage) + } + } + + private func decodeGalleryImage(_ cgImage: CGImage) { + let request = VNDetectBarcodesRequest() + request.symbologies = ScannerFunctions.barcodeSymbologies(for: requestedFormats) + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + + do { + try handler.perform([request]) + } catch { + DispatchQueue.main.async { [weak self] in + self?.showGalleryAlert(message: "Couldn't read that image.") + } + return + } + + guard let match = request.results?.first(where: { $0.payloadStringValue != nil }), + let value = match.payloadStringValue else { + DispatchQueue.main.async { [weak self] in + self?.showGalleryAlert(message: "No code found in that image.") + } + return + } + + let format = ScannerViewController.formatName(forSymbology: match.symbology, value: value, requested: requestedFormats) + + DispatchQueue.main.async { [weak self] in + self?.triggerHapticFeedback() + self?.finish(cancelled: false, data: value, format: format) + } + } + + private func showGalleryAlert(message: String) { + guard !finished else { return } + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + + func finish(cancelled: Bool, data: String? = nil, format: String? = nil, reason: String? = nil) { + guard !finished else { return } + finished = true + + if ScannerFunctions.activeController === self { + ScannerFunctions.activeController = nil + } + + timeoutWorkItem?.cancel() + + if session.isRunning { + session.stopRunning() + } + + dismiss(animated: true) + + if cancelled { + guard reason != nil else { return } + + LaravelBridge.shared.send?(ScannerFunctions.cancelledEvent, [ + "reason": reason, + "id": sessionId, + ]) + } else { + LaravelBridge.shared.send?(ScannerFunctions.codeScannedEvent, [ + "data": data, + "format": format ?? "unknown", + "id": sessionId, + ]) + } + } +} + +extension ScannerViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + !(touch.view is UIControl) + } +} + +extension ScannerViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { + picker.dismiss(animated: true) + return + } + + picker.dismiss(animated: true) { [weak self] in + self?.loadAndDecode(provider: provider) + } + } +} \ No newline at end of file diff --git a/packages/mobile-scanner/resources/js/scanner.d.ts b/packages/mobile-scanner/resources/js/scanner.d.ts new file mode 100644 index 0000000..9d2acb7 --- /dev/null +++ b/packages/mobile-scanner/resources/js/scanner.d.ts @@ -0,0 +1,109 @@ +export type BarcodeFormat = + | "qr" + | "ean13" + | "ean8" + | "code128" + | "code39" + | "upca" + | "upce" + | "all"; + +export interface BridgeError extends Error { + code?: string; +} + +export interface ScanStartedResult { + started: true; +} + +export interface StopResult { + stopped: boolean; +} + +export interface ScannerCodeScannedPayload { + data: string; + format: string; + id: string | null; +} + +export interface ScannerCancelledPayload { + reason: string | null; + id: string | null; +} + +export declare class PendingScan implements PromiseLike< + ScanStartedResult | undefined +> { + prompt(prompt: string): this; + continuous(continuous?: boolean): this; + gallery(allow?: boolean): this; + formats(formats: BarcodeFormat[]): this; + haptics(enabled?: boolean): this; + zoom(ratio?: number): this; + maxZoom(ratio?: number): this; + zoomControl(enabled?: boolean): this; + focusOnTap(enabled?: boolean): this; + timeout(seconds?: number): this; + id(id: string): this; + getId(): string | null; + then( + onfulfilled?: + | (( + value: ScanStartedResult | undefined, + ) => TResult1 | PromiseLike) + | undefined + | null, + onrejected?: + | ((reason: BridgeError) => TResult2 | PromiseLike) + | undefined + | null, + ): PromiseLike; +} + +export declare const Scanner: { + scan(): PendingScan; + stop(id?: string | null): Promise; +}; + +export declare const Events: { + Scanner: { + CodeScanned: "Sandip\\Scanner\\Native\\Events\\Scanner\\CodeScanned"; + Cancelled: "Sandip\\Scanner\\Native\\Events\\Scanner\\Cancelled"; + }; +}; + +export declare function On( + eventName: typeof Events.Scanner.CodeScanned, + callback: (payload: ScannerCodeScannedPayload, eventName: string) => void, +): void; +export declare function On( + eventName: typeof Events.Scanner.Cancelled, + callback: (payload: ScannerCancelledPayload, eventName: string) => void, +): void; +export declare function On( + eventName: string, + callback: (payload: any, eventName: string) => void, +): void; + +export declare function Off( + eventName: typeof Events.Scanner.CodeScanned, + callback: (payload: ScannerCodeScannedPayload, eventName: string) => void, +): void; +export declare function Off( + eventName: typeof Events.Scanner.Cancelled, + callback: (payload: ScannerCancelledPayload, eventName: string) => void, +): void; +export declare function Off( + eventName: string, + callback: (payload: any, eventName: string) => void, +): void; + +declare const _default: { + Scanner: typeof Scanner; + On: typeof On; + Off: typeof Off; + Events: typeof Events; + PendingScan: typeof PendingScan; +}; + +export default _default; diff --git a/packages/mobile-scanner/resources/js/scanner.js b/packages/mobile-scanner/resources/js/scanner.js new file mode 100644 index 0000000..1559442 --- /dev/null +++ b/packages/mobile-scanner/resources/js/scanner.js @@ -0,0 +1,202 @@ +const baseUrl = "/_native/api/call"; + +const VALID_FORMATS = [ + "qr", + "ean13", + "ean8", + "code128", + "code39", + "upca", + "upce", + "all", +]; + +async function bridgeCall(method, params = {}) { + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-TOKEN": + document.querySelector('meta[name="csrf-token"]')?.content || "", + }, + body: JSON.stringify({ method, params }), + }); + + const result = await response.json(); + + if (result.status === "error") { + const error = new Error( + result.message || "The scanner could not be started.", + ); + error.code = result.code; + throw error; + } + + return result.data; +} + +class PendingScan { + constructor() { + this._id = null; + this._prompt = null; + this._continuous = false; + this._allowGallery = true; + this._formats = ["qr"]; + this._haptics = true; + this._zoom = 1.0; + this._maxZoom = 3.0; + this._zoomControl = true; + this._focusOnTap = true; + this._timeout = 0; + this._started = false; + } + + prompt(prompt) { + this._prompt = prompt; + return this; + } + + continuous(continuous = true) { + this._continuous = continuous; + return this; + } + + gallery(allow = true) { + this._allowGallery = allow; + return this; + } + + haptics(enabled = true) { + this._haptics = enabled; + return this; + } + + zoom(ratio = 1.0) { + if (typeof ratio !== "number" || ratio <= 0) { + throw new Error("Zoom ratio must be a positive number."); + } + this._zoom = ratio; + return this; + } + + maxZoom(ratio = 3.0) { + if (typeof ratio !== "number" || ratio <= 0) { + throw new Error("Max zoom ratio must be a positive number."); + } + this._maxZoom = ratio; + return this; + } + + zoomControl(enabled = true) { + this._zoomControl = enabled; + return this; + } + + focusOnTap(enabled = true) { + this._focusOnTap = enabled; + return this; + } + + timeout(seconds = 0) { + if (typeof seconds !== "number" || seconds < 0) { + throw new Error("Timeout must be zero (disabled) or a positive number of seconds."); + } + this._timeout = seconds; + return this; + } + + formats(formats) { + if (!Array.isArray(formats) || formats.length === 0) { + throw new Error("At least one barcode format must be specified."); + } + + const invalid = formats.filter((format) => !VALID_FORMATS.includes(format)); + if (invalid.length > 0) { + throw new Error( + `Invalid barcode format(s): ${invalid.join(", ")}. Valid formats are: ${VALID_FORMATS.join(", ")}.`, + ); + } + + this._formats = [...new Set(formats)]; + return this; + } + + id(id) { + this._id = id; + return this; + } + + getId() { + return this._id; + } + + then(resolve, reject) { + if (this._started) { + return resolve(); + } + this._started = true; + + return bridgeCall("MobileScanner.Scan", { + prompt: this._prompt ?? "", + continuous: this._continuous, + allowGallery: this._allowGallery, + formats: this._formats, + haptics: this._haptics, + zoom: this._zoom, + maxZoom: this._maxZoom, + zoomControl: this._zoomControl, + focusOnTap: this._focusOnTap, + timeout: this._timeout, + id: this._id, + }).then(resolve, reject); + } +} + +export const Scanner = { + scan: () => new PendingScan(), + + stop: (id) => bridgeCall("MobileScanner.Stop", { id: id ?? null }), +}; + +export { PendingScan }; + +const _eventListeners = {}; +let _listenerInstalled = false; + +function installListener() { + if (_listenerInstalled) { + return; + } + + document.addEventListener("native-event", (e) => { + const eventName = e.detail.event.replace(/^(\\)+/, ""); + const payload = e.detail.payload; + (_eventListeners[eventName] || []).forEach((callback) => + callback(payload, eventName), + ); + }); + + _listenerInstalled = true; +} + +export function On(eventName, callback) { + installListener(); + (_eventListeners[eventName] ??= []).push(callback); +} + +export function Off(eventName, callback) { + if (_eventListeners[eventName]) { + _eventListeners[eventName] = _eventListeners[eventName].filter( + (cb) => cb !== callback, + ); + } +} + +export const Events = { + Scanner: { + CodeScanned: "Sandip\\Scanner\\Native\\Events\\Scanner\\CodeScanned", + Cancelled: "Sandip\\Scanner\\Native\\Events\\Scanner\\Cancelled", + }, +}; + +export default { Scanner, On, Off, Events, PendingScan }; diff --git a/packages/mobile-scanner/src/Attributes/OnNative.php b/packages/mobile-scanner/src/Attributes/OnNative.php new file mode 100644 index 0000000..c6212c3 --- /dev/null +++ b/packages/mobile-scanner/src/Attributes/OnNative.php @@ -0,0 +1,15 @@ +event = 'native:'.$event; + } +} diff --git a/packages/mobile-scanner/src/Commands/CopyAssetsCommand.php b/packages/mobile-scanner/src/Commands/CopyAssetsCommand.php new file mode 100644 index 0000000..7b15112 --- /dev/null +++ b/packages/mobile-scanner/src/Commands/CopyAssetsCommand.php @@ -0,0 +1,35 @@ +isAndroid()) { + $this->copyAndroidAssets(); + } + + if ($this->isIos()) { + $this->copyIosAssets(); + } + + return self::SUCCESS; + } + + protected function copyAndroidAssets(): void + { + $this->info('No Android assets to copy for Scanner'); + } + + protected function copyIosAssets(): void + { + $this->info('No iOS assets to copy for Scanner'); + } +} diff --git a/packages/mobile-scanner/src/Events/Scanner/Cancelled.php b/packages/mobile-scanner/src/Events/Scanner/Cancelled.php new file mode 100644 index 0000000..507a0f6 --- /dev/null +++ b/packages/mobile-scanner/src/Events/Scanner/Cancelled.php @@ -0,0 +1,16 @@ +prompt = $prompt; + + return $this; + } + + public function continuous(bool $continuous = true): self + { + $this->continuous = $continuous; + + return $this; + } + + public function gallery(bool $allow = true): self + { + $this->allowGallery = $allow; + + return $this; + } + + public function haptics(bool $enabled = true): self + { + $this->haptics = $enabled; + + return $this; + } + + public function zoom(float $ratio = 1.0): self + { + if ($ratio <= 0) { + throw new InvalidArgumentException('Zoom ratio must be a positive number.'); + } + + $this->zoom = $ratio; + + return $this; + } + + public function maxZoom(float $ratio = 3.0): self + { + if ($ratio <= 0) { + throw new InvalidArgumentException('Max zoom ratio must be a positive number.'); + } + + $this->maxZoom = $ratio; + + return $this; + } + + public function zoomControl(bool $enabled = true): self + { + $this->zoomControl = $enabled; + + return $this; + } + + public function focusOnTap(bool $enabled = true): self + { + $this->focusOnTap = $enabled; + + return $this; + } + + public function timeout(int $seconds = 0): self + { + if ($seconds < 0) { + throw new InvalidArgumentException('Timeout must be zero (disabled) or a positive number of seconds.'); + } + + $this->timeout = $seconds; + + return $this; + } + + public function formats(array $formats): self + { + if ($formats === []) { + throw new InvalidArgumentException('At least one barcode format must be specified.'); + } + + $invalid = array_diff($formats, self::FORMATS); + + if ($invalid !== []) { + throw new InvalidArgumentException(sprintf( + 'Invalid barcode format(s): %s. Valid formats are: %s.', + implode(', ', $invalid), + implode(', ', self::FORMATS) + )); + } + + $this->formats = array_values(array_unique($formats)); + + return $this; + } + + public function id(string $id): self + { + $this->id = $id; + + return $this; + } + + public function getId(): ?string + { + return $this->id; + } + + public function scan(): bool + { + if ($this->started) { + return false; + } + + $this->started = true; + + if (! function_exists('nativephp_call')) { + return false; + } + + $result = nativephp_call('MobileScanner.Scan', json_encode([ + 'prompt' => $this->prompt ?? '', + 'continuous' => $this->continuous, + 'allowGallery' => $this->allowGallery, + 'formats' => $this->formats, + 'haptics' => $this->haptics, + 'zoom' => $this->zoom, + 'maxZoom' => $this->maxZoom, + 'zoomControl' => $this->zoomControl, + 'focusOnTap' => $this->focusOnTap, + 'timeout' => $this->timeout, + 'id' => $this->id, + ])); + + if (! $result) { + return false; + } + + $decoded = json_decode($result, true); + + return ! (isset($decoded['status']) && $decoded['status'] === 'error'); + } + + public function __destruct() + { + if (! $this->started) { + $this->scan(); + } + } +} diff --git a/packages/mobile-scanner/src/Scanner.php b/packages/mobile-scanner/src/Scanner.php new file mode 100644 index 0000000..553b26a --- /dev/null +++ b/packages/mobile-scanner/src/Scanner.php @@ -0,0 +1,28 @@ + $id])); + + if (! $result) { + return false; + } + + $decoded = json_decode($result, true); + + return ! (isset($decoded['status']) && $decoded['status'] === 'error'); + } +} diff --git a/packages/mobile-scanner/src/ScannerServiceProvider.php b/packages/mobile-scanner/src/ScannerServiceProvider.php new file mode 100644 index 0000000..4e0260b --- /dev/null +++ b/packages/mobile-scanner/src/ScannerServiceProvider.php @@ -0,0 +1,23 @@ +app->singleton(Scanner::class, fn () => new Scanner); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + CopyAssetsCommand::class, + ]); + } + } +} diff --git a/packages/mobile-scanner/tests/Pest.php b/packages/mobile-scanner/tests/Pest.php new file mode 100644 index 0000000..88a51b7 --- /dev/null +++ b/packages/mobile-scanner/tests/Pest.php @@ -0,0 +1,3 @@ +in('.'); diff --git a/packages/mobile-scanner/tests/PluginTest.php b/packages/mobile-scanner/tests/PluginTest.php new file mode 100644 index 0000000..3a5a0a9 --- /dev/null +++ b/packages/mobile-scanner/tests/PluginTest.php @@ -0,0 +1,273 @@ +pluginPath = dirname(__DIR__); + $this->manifestPath = $this->pluginPath.'/nativephp.json'; +}); + +describe('Plugin Manifest', function () { + it('has a valid nativephp.json file', function () { + expect(file_exists($this->manifestPath))->toBeTrue(); + + json_decode(file_get_contents($this->manifestPath), true); + + expect(json_last_error())->toBe(JSON_ERROR_NONE); + }); + + it('has required fields', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest)->toHaveKeys(['name', 'namespace', 'bridge_functions']); + expect($manifest['name'])->toBe('sghimire/mobile-scanner'); + expect($manifest['namespace'])->toBe('Scanner'); + }); + + it('registers its own bridge functions, distinct from the paid plugin\'s Scanner.Scan', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + $names = array_column($manifest['bridge_functions'], 'name'); + + expect($names)->toBe(['MobileScanner.Scan', 'MobileScanner.Stop']); + expect($names)->not->toContain('Scanner.Scan'); + + foreach ($manifest['bridge_functions'] as $function) { + expect($function)->toHaveKeys(['name']); + expect(isset($function['android']) || isset($function['ios']))->toBeTrue(); + } + }); + + it('requests camera permission on Android', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['android']['permissions'])->toContain('android.permission.CAMERA'); + }); + + it('declares the NSCameraUsageDescription iOS requires', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['ios']['info_plist'] ?? [])->toHaveKey('NSCameraUsageDescription'); + }); + + it('declares the events it dispatches', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['events'])->toBe([ + 'Sandip\\Scanner\\Native\\Events\\Scanner\\CodeScanned', + 'Sandip\\Scanner\\Native\\Events\\Scanner\\Cancelled', + ]); + }); +}); + +describe('Native Code', function () { + it('has Android Kotlin file', function () { + $kotlinFile = $this->pluginPath.'/resources/android/ScannerFunctions.kt'; + + expect(file_exists($kotlinFile))->toBeTrue(); + + $content = file_get_contents($kotlinFile); + expect($content)->toContain('package com.sandip.plugins.scanner'); + expect($content)->toContain('object ScannerFunctions'); + expect($content)->toContain('class Scan('); + expect($content)->toContain('class Stop('); + expect($content)->toContain('BridgeFunction'); + }); + + it('has iOS Swift file', function () { + $swiftFile = $this->pluginPath.'/resources/ios/ScannerFunctions.swift'; + + expect(file_exists($swiftFile))->toBeTrue(); + + $content = file_get_contents($swiftFile); + expect($content)->toContain('enum ScannerFunctions'); + expect($content)->toContain('class Scan:'); + expect($content)->toContain('class Stop:'); + expect($content)->toContain('BridgeFunction'); + }); + + it('has matching bridge function classes in native code', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + $kotlinContent = file_get_contents($this->pluginPath.'/resources/android/ScannerFunctions.kt'); + $swiftContent = file_get_contents($this->pluginPath.'/resources/ios/ScannerFunctions.swift'); + + foreach ($manifest['bridge_functions'] as $function) { + if (isset($function['android'])) { + $parts = explode('.', $function['android']); + $className = end($parts); + expect($kotlinContent)->toContain("class {$className}("); + } + + if (isset($function['ios'])) { + $parts = explode('.', $function['ios']); + $className = end($parts); + expect($swiftContent)->toContain("class {$className}:"); + } + } + }); + + it('supports continuous mode with a debounce window on both platforms', function () { + $kotlinContent = file_get_contents($this->pluginPath.'/resources/android/ScannerFunctions.kt'); + $swiftContent = file_get_contents($this->pluginPath.'/resources/ios/ScannerFunctions.swift'); + + expect($kotlinContent)->toContain('REPEAT_DEBOUNCE_MS'); + expect($swiftContent)->toContain('repeatDebounceSeconds'); + }); + + it('dispatches events asynchronously instead of blocking the bridge thread', function () { + $kotlinContent = file_get_contents($this->pluginPath.'/resources/android/ScannerFunctions.kt'); + $swiftContent = file_get_contents($this->pluginPath.'/resources/ios/ScannerFunctions.swift'); + + expect($kotlinContent)->toContain('NativeActionCoordinator.dispatchEvent'); + expect($swiftContent)->toContain('LaravelBridge.shared.send'); + }); +}); + +describe('PHP Classes', function () { + it('has service provider', function () { + $file = $this->pluginPath.'/src/ScannerServiceProvider.php'; + expect(file_exists($file))->toBeTrue(); + + $content = file_get_contents($file); + expect($content)->toContain('namespace Sandip\Scanner\Native'); + expect($content)->toContain('class ScannerServiceProvider'); + }); + + it('has facade', function () { + $file = $this->pluginPath.'/src/Facades/Scanner.php'; + expect(file_exists($file))->toBeTrue(); + + $content = file_get_contents($file); + expect($content)->toContain('namespace Sandip\Scanner\Native\Facades'); + expect($content)->toContain('class Scanner extends Facade'); + }); + + it('has main implementation class, builder, events, and attribute', function () { + expect(file_exists($this->pluginPath.'/src/Scanner.php'))->toBeTrue(); + expect(file_exists($this->pluginPath.'/src/PendingScan.php'))->toBeTrue(); + expect(file_exists($this->pluginPath.'/src/Events/Scanner/CodeScanned.php'))->toBeTrue(); + expect(file_exists($this->pluginPath.'/src/Events/Scanner/Cancelled.php'))->toBeTrue(); + expect(file_exists($this->pluginPath.'/src/Attributes/OnNative.php'))->toBeTrue(); + }); +}); + +describe('Scanner manager', function () { + it('returns a fluent PendingScan from scan()', function () { + expect((new Scanner)->scan())->toBeInstanceOf(PendingScan::class); + }); + + it('stop() returns false outside a native runtime', function () { + expect((new Scanner)->stop())->toBeFalse(); + }); +}); + +describe('PendingScan', function () { + it('defaults to a single qr format and non-continuous mode', function () { + $prompt = new PendingScan; + + expect($prompt->getId())->toBeNull(); + }); + + it('accepts a valid single format', function () { + expect((new PendingScan)->formats(['ean13']))->toBeInstanceOf(PendingScan::class); + }); + + it('accepts every documented format, including all', function () { + foreach (PendingScan::FORMATS as $format) { + expect((new PendingScan)->formats([$format]))->toBeInstanceOf(PendingScan::class); + } + }); + + it('rejects an empty formats list', function () { + (new PendingScan)->formats([]); + })->throws(InvalidArgumentException::class); + + it('rejects an unknown format', function () { + (new PendingScan)->formats(['not-a-real-format']); + })->throws(InvalidArgumentException::class); + + it('chains fluent configuration methods', function () { + $prompt = (new PendingScan) + ->prompt('Scan your ticket') + ->continuous(true) + ->formats(['qr', 'ean13']) + ->id('ticket-scanner'); + + expect($prompt)->toBeInstanceOf(PendingScan::class); + expect($prompt->getId())->toBe('ticket-scanner'); + }); + + it('returns false when started outside a native runtime', function () { + expect((new PendingScan)->scan())->toBeFalse(); + }); + + it('refuses to start twice', function () { + $prompt = new PendingScan; + $prompt->scan(); + + expect($prompt->scan())->toBeFalse(); + }); +}); + +describe('Events', function () { + it('CodeScanned carries data, format, and an optional id', function () { + $event = new CodeScanned(data: 'otpauth://totp/example', format: 'qr', id: 'abc'); + + expect($event->data)->toBe('otpauth://totp/example'); + expect($event->format)->toBe('qr'); + expect($event->id)->toBe('abc'); + }); + + it('Cancelled defaults reason and id to null', function () { + $event = new Cancelled; + + expect($event->reason)->toBeNull(); + expect($event->id)->toBeNull(); + }); +}); + +describe('Composer Configuration', function () { + it('has valid composer.json', function () { + $composerPath = $this->pluginPath.'/composer.json'; + expect(file_exists($composerPath))->toBeTrue(); + + $composer = json_decode(file_get_contents($composerPath), true); + + expect(json_last_error())->toBe(JSON_ERROR_NONE); + expect($composer['name'])->toBe('sghimire/mobile-scanner'); + expect($composer['type'])->toBe('nativephp-plugin'); + expect($composer['extra']['nativephp']['manifest'])->toBe('nativephp.json'); + expect($composer['autoload']['psr-4'])->toHaveKey('Sandip\\Scanner\\Native\\'); + }); +}); + +describe('Lifecycle Hooks', function () { + it('has copy_assets hook command', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['hooks']['copy_assets'] ?? null)->not->toBeNull(); + + $commandFile = $this->pluginPath.'/src/Commands/CopyAssetsCommand.php'; + expect(file_exists($commandFile))->toBeTrue(); + }); + + it('copy_assets command extends NativePluginHookCommand', function () { + $content = file_get_contents($this->pluginPath.'/src/Commands/CopyAssetsCommand.php'); + + expect($content)->toContain('extends NativePluginHookCommand'); + expect($content)->toContain('use Native\Mobile\Plugins\Commands\NativePluginHookCommand'); + }); + + it('copy_assets command has correct signature', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + $expectedSignature = $manifest['hooks']['copy_assets']; + + $content = file_get_contents($this->pluginPath.'/src/Commands/CopyAssetsCommand.php'); + + expect($content)->toContain('$signature = \''.$expectedSignature.'\''); + }); +}); diff --git a/tests/Feature/ScannerBenchmarkTest.php b/tests/Feature/ScannerBenchmarkTest.php index 1b1ec6d..9ade204 100644 --- a/tests/Feature/ScannerBenchmarkTest.php +++ b/tests/Feature/ScannerBenchmarkTest.php @@ -12,14 +12,13 @@ it('starts a continuous QR scan with an explicit session id', function () { $screen = Native::test(ScannerBenchmark::class) ->call('startScanner') - ->assertNativeCalled('Scanner.Scan'); + ->assertNativeCalled('MobileScanner.Scan'); - $call = $screen->bridge()->callsTo('Scanner.Scan')[0]['params']; + $call = $screen->bridge()->callsTo('MobileScanner.Scan')[0]['params']; expect($call['continuous'])->toBeTrue() ->and($call['formats'])->toBe(['qr']) - ->and($call['id'])->toStartWith('benchmark-') - ->and($call['event'])->toBe('Native\\Mobile\\Events\\Scanner\\CodeScanned'); + ->and($call['id'])->toStartWith('benchmark-'); }); it('resets all displayed measurements', function () { From 1824dd92ddc96b228ce85745ca74d4461320b3df Mon Sep 17 00:00:00 2001 From: pratik bhujel Date: Sun, 20 Sep 2026 13:30:26 +0545 Subject: [PATCH 3/3] Cover scanner event handling --- tests/Feature/ScannerBenchmarkTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Feature/ScannerBenchmarkTest.php b/tests/Feature/ScannerBenchmarkTest.php index 9ade204..27818fb 100644 --- a/tests/Feature/ScannerBenchmarkTest.php +++ b/tests/Feature/ScannerBenchmarkTest.php @@ -2,6 +2,7 @@ use App\NativeComponents\ScannerBenchmark; use Native\Mobile\Testing\Native; +use Sandip\Scanner\Native\Events\Scanner\CodeScanned; it('renders the scanner benchmark screen', function () { Native::test(ScannerBenchmark::class) @@ -30,3 +31,15 @@ ->assertSet('uniqueScans', 0) ->assertSet('lastValue', null); }); + +it('records native scan events and deduplicates repeated values', function () { + $screen = Native::test(ScannerBenchmark::class) + ->call('codeScanned', new CodeScanned('nativephp-benchmark-001', 'qr')) + ->call('codeScanned', new CodeScanned('nativephp-benchmark-001', 'qr')); + + $screen + ->assertSet('totalScans', 2) + ->assertSet('uniqueScans', 1) + ->assertSet('lastValue', 'nativephp-benchmark-001') + ->assertSet('lastFormat', 'qr'); +});