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..85565a2 --- /dev/null +++ b/app/NativeComponents/ScannerBenchmark.php @@ -0,0 +1,91 @@ + */ + 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() + ->scan(); + } + + #[OnNative(CodeScanned::class)] + public function codeScanned(CodeScanned $event): 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', [ + '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, + ]); + } + + 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/app/Providers/NativeServiceProvider.php b/app/Providers/NativeServiceProvider.php index df0cf85..38bd2d0 100644 --- a/app/Providers/NativeServiceProvider.php +++ b/app/Providers/NativeServiceProvider.php @@ -3,6 +3,8 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; +use Native\Mobile\UI\NativeUIServiceProvider; +use Sandip\Scanner\Native\ScannerServiceProvider; class NativeServiceProvider extends ServiceProvider { @@ -29,13 +31,13 @@ public function boot(): void * This is a security measure to prevent transitive dependencies from * automatically registering plugins without your explicit consent. * - * @return array> + * @return array> */ public function plugins(): array { return [ - \Native\Mobile\UI\NativeUIServiceProvider::class, - + NativeUIServiceProvider::class, + ScannerServiceProvider::class, ]; } -} \ No newline at end of file +} diff --git a/composer.json b/composer.json index a7b1f21..c5555b6 100644 --- a/composer.json +++ b/composer.json @@ -19,9 +19,10 @@ "laravel/mcp": "^0.9.5", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", - "nativephp/mobile": "^4.4", + "nativephp/mobile": "4.4.0", "nativephp/mobile-ui": "^0.4.0", - "nativephp/web-ui": "^0.0.1@alpha" + "nativephp/web-ui": "^0.0.1@alpha", + "sghimire/mobile-scanner": "1.0.3" }, "require-dev": { "fakerphp/faker": "^1.23", @@ -86,6 +87,15 @@ "extra": { "laravel": [] }, + "repositories": [ + { + "type": "path", + "url": "packages/mobile-scanner", + "options": { + "symlink": false + } + } + ], "config": { "optimize-autoloader": true, "preferred-install": "dist", diff --git a/composer.lock b/composer.lock index c13bf8c..a8e5312 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2179d7f6522fc06fe5d2ba2f3afd5a0a", + "content-hash": "1980ca4127c9a0fd5a8754b9541bb1fe", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -278,16 +278,16 @@ }, { "name": "brick/math", - "version": "0.18.0", + "version": "0.19.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + "reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "url": "https://api.github.com/repos/brick/math/zipball/a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce", + "reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce", "shasum": "" }, "require": { @@ -325,7 +325,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.18.0" + "source": "https://github.com/brick/math/tree/0.19.1" }, "funding": [ { @@ -333,7 +333,7 @@ "type": "github" } ], - "time": "2026-06-14T18:21:03+00:00" + "time": "2026-08-08T23:03:16+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -2319,16 +2319,16 @@ }, { "name": "laravel/framework", - "version": "v13.31.0", + "version": "v13.32.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "7c75fbf93f91fa077d3df1c820cc14f4e59a9774" + "reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/7c75fbf93f91fa077d3df1c820cc14f4e59a9774", - "reference": "7c75fbf93f91fa077d3df1c820cc14f4e59a9774", + "url": "https://api.github.com/repos/laravel/framework/zipball/cdd8b33c246719acdd118c705ce8c7ab5ef48a96", + "reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96", "shasum": "" }, "require": { @@ -2447,15 +2447,17 @@ "orchestra/testbench-core": "^11.0.0", "pda/pheanstalk": "^7.0.0 || ^8.0.0", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.2.14", "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", "predis/predis": "^2.3 || ^3.0", "rector/rector": "2.6.3", "resend/resend-php": "^1.0", "symfony/cache": "^7.4.0 || ^8.0.0", "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/mercure": "^0.8.0", "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", - "symfony/translation": "^7.4.0 || ^8.0.0" + "symfony/translation": "^7.4.0 || ^8.0.0", + "web-token/jwt-library": "^4.1" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", @@ -2491,8 +2493,10 @@ "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).", "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).", + "web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)." }, "type": "library", "extra": { @@ -2542,7 +2546,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-09-08T14:22:55+00:00" + "time": "2026-09-15T14:55:30+00:00" }, { "name": "laravel/mcp", @@ -4009,16 +4013,16 @@ }, { "name": "nesbot/carbon", - "version": "3.13.2", + "version": "3.14.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + "reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", - "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2", + "reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2", "shasum": "" }, "require": { @@ -4110,7 +4114,7 @@ "type": "tidelift" } ], - "time": "2026-08-08T11:40:35+00:00" + "time": "2026-09-12T20:45:19+00:00" }, { "name": "nette/php-generator", @@ -5426,20 +5430,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -5498,9 +5502,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "react/cache", @@ -6032,6 +6036,70 @@ ], "time": "2022-12-17T21:53:22+00:00" }, + { + "name": "sghimire/mobile-scanner", + "version": "1.0.3", + "dist": { + "type": "path", + "url": "packages/mobile-scanner", + "reference": "21dfdf71b8ff19aa9d8c8a61648a754a9d3ab63e" + }, + "require": { + "nativephp/mobile": "^3.0|^4.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.29", + "pestphp/pest": "^3.0" + }, + "suggest": { + "livewire/livewire": "Required only if you use the #[OnNative] attribute to bind scanner events directly to component methods." + }, + "type": "nativephp-plugin", + "extra": { + "laravel": { + "providers": [ + "Sandip\\Scanner\\Native\\ScannerServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "autoload": { + "psr-4": { + "Sandip\\Scanner\\Native\\": "src/" + } + }, + "scripts": { + "test": [ + "pest" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sandip Ghimire", + "email": "sandipghimire2076@gmail.com" + } + ], + "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.", + "keywords": [ + "barcode", + "camera", + "laravel", + "mobile", + "nativephp", + "qr-code", + "scanner" + ], + "transport-options": { + "symlink": false, + "relative": true + } + }, { "name": "spatie/invade", "version": "2.1.1", @@ -6296,16 +6364,16 @@ }, { "name": "symfony/console", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac" + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/eb7d9957d66739649e931ce7a9d05dab69f8abac", - "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac", + "url": "https://api.github.com/repos/symfony/console/zipball/29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", "shasum": "" }, "require": { @@ -6372,7 +6440,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.6" + "source": "https://github.com/symfony/console/tree/v8.1.7" }, "funding": [ { @@ -6392,7 +6460,7 @@ "type": "tidelift" } ], - "time": "2026-08-25T14:18:42+00:00" + "time": "2026-09-13T10:55:57+00:00" }, { "name": "symfony/css-selector", @@ -6783,16 +6851,16 @@ }, { "name": "symfony/finder", - "version": "v8.1.5", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8d7acede2b2ae07605783d1c43e49b5767036474" + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8d7acede2b2ae07605783d1c43e49b5767036474", - "reference": "8d7acede2b2ae07605783d1c43e49b5767036474", + "url": "https://api.github.com/repos/symfony/finder/zipball/4fbe46a3eb64abf8a57f0364075b91f4a233e062", + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062", "shasum": "" }, "require": { @@ -6827,7 +6895,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.5" + "source": "https://github.com/symfony/finder/tree/v8.1.7" }, "funding": [ { @@ -6847,7 +6915,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-09-10T19:14:39+00:00" }, { "name": "symfony/html-sanitizer", @@ -6923,16 +6991,16 @@ }, { "name": "symfony/http-foundation", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb" + "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/093b78326f649c3a9db922b9f17123b6aeb3b8fb", - "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", + "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", "shasum": "" }, "require": { @@ -6980,7 +7048,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.6" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.7" }, "funding": [ { @@ -7000,20 +7068,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:55+00:00" + "time": "2026-09-14T17:47:24+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355" + "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", - "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", + "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", "shasum": "" }, "require": { @@ -7090,7 +7158,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.6" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.7" }, "funding": [ { @@ -7110,20 +7178,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T21:40:49+00:00" + "time": "2026-09-15T07:12:52+00:00" }, { "name": "symfony/mailer", - "version": "v8.1.5", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "89f43137da74b8f1aab37c99926482b7084f51b9" + "reference": "8783380ecdafa23d36fc90c5873fd44b635c6e10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/89f43137da74b8f1aab37c99926482b7084f51b9", - "reference": "89f43137da74b8f1aab37c99926482b7084f51b9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/8783380ecdafa23d36fc90c5873fd44b635c6e10", + "reference": "8783380ecdafa23d36fc90c5873fd44b635c6e10", "shasum": "" }, "require": { @@ -7170,7 +7238,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.1.5" + "source": "https://github.com/symfony/mailer/tree/v8.1.7" }, "funding": [ { @@ -7190,20 +7258,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-09-15T06:01:24+00:00" }, { "name": "symfony/mime", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f" + "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", - "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", + "url": "https://api.github.com/repos/symfony/mime/zipball/773ac57f20e2795bdadb65b5b1b5f4850d498283", + "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283", "shasum": "" }, "require": { @@ -7256,7 +7324,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.6" + "source": "https://github.com/symfony/mime/tree/v8.1.7" }, "funding": [ { @@ -7276,7 +7344,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:06:25+00:00" + "time": "2026-09-04T11:02:17+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8189,16 +8257,16 @@ }, { "name": "symfony/process", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d863f5e70d7c87abb906ac11b61f83036093000b" + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d863f5e70d7c87abb906ac11b61f83036093000b", - "reference": "d863f5e70d7c87abb906ac11b61f83036093000b", + "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3", + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3", "shasum": "" }, "require": { @@ -8230,7 +8298,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.1.6" + "source": "https://github.com/symfony/process/tree/v8.1.7" }, "funding": [ { @@ -8250,7 +8318,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-09-02T12:40:29+00:00" }, { "name": "symfony/routing", @@ -8421,16 +8489,16 @@ }, { "name": "symfony/string", - "version": "v8.1.2", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", "shasum": "" }, "require": { @@ -8487,7 +8555,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.2" + "source": "https://github.com/symfony/string/tree/v8.1.7" }, "funding": [ { @@ -8507,7 +8575,7 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:35:25+00:00" + "time": "2026-09-11T14:51:16+00:00" }, { "name": "symfony/translation", @@ -8764,16 +8832,16 @@ }, { "name": "symfony/var-dumper", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "3783365b58972f4779254d98372af80fbf15e170" + "reference": "741a8c4c948b0bb9bd3653daacd3644c73c40ea5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3783365b58972f4779254d98372af80fbf15e170", - "reference": "3783365b58972f4779254d98372af80fbf15e170", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/741a8c4c948b0bb9bd3653daacd3644c73c40ea5", + "reference": "741a8c4c948b0bb9bd3653daacd3644c73c40ea5", "shasum": "" }, "require": { @@ -8827,7 +8895,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.6" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.7" }, "funding": [ { @@ -8847,7 +8915,7 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:55+00:00" + "time": "2026-09-03T16:02:27+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -12894,5 +12962,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } 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..6c3b846 --- /dev/null +++ b/docs/scanner-benchmark.md @@ -0,0 +1,38 @@ +# 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. + +The Android implementation is the MIT-licensed `sghimire/mobile-scanner` plugin, +registered explicitly in `app/Providers/NativeServiceProvider.php`. It uses +CameraX and ML Kit in the APK and emits the `CodeScanned` event through the +NativePHP bridge. The benchmark does not use the paid scanner plugin or claim +that NativePHP and React Native have identical camera internals. + +## 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 generated payload is in `public/benchmark-qr.png`. Android runtime evidence +is written to the Laravel log as `scanner_benchmark.scan`; the value itself is +never logged, only its SHA-256 digest. + +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/packages/mobile-scanner/.gitignore b/packages/mobile-scanner/.gitignore new file mode 100644 index 0000000..7c36855 --- /dev/null +++ b/packages/mobile-scanner/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/node_modules/ +.DS_Store +*.log diff --git a/packages/mobile-scanner/LICENSE b/packages/mobile-scanner/LICENSE new file mode 100644 index 0000000..ac0863d --- /dev/null +++ b/packages/mobile-scanner/LICENSE @@ -0,0 +1,21 @@ +MIT License Copyright (c) 2026 Sandip Ghimire + +Permission is hereby +granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice +(including the next paragraph) shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/mobile-scanner/README.md b/packages/mobile-scanner/README.md new file mode 100644 index 0000000..ba3f9db --- /dev/null +++ b/packages/mobile-scanner/README.md @@ -0,0 +1,556 @@ +# Mobile Scanner + +Native QR code / barcode scanning for [NativePHP Mobile](https://nativephp.com) apps, powered by CameraX + ML Kit on Android and AVFoundation on iOS. + +This package is a **free, self-contained alternative** to the paid `nativephp/mobile-scanner` plugin. It ships its own Laravel facade, a fluent scan builder, Laravel events, JS/TypeScript bindings, and the native Kotlin/Swift implementation — with no dependency on the paid plugin. + +## Features + +- Full-screen native camera scanner for QR codes and common barcode formats. +- Built-in gallery button on the scanner overlay — pick an existing photo instead of the live camera, decoded on-device (Android Photo Picker + ML Kit, iOS `PHPickerViewController` + Vision). No extra permissions or setup required. +- Fluent, chainable scan builder in both PHP and JavaScript. +- Single-shot or continuous (multi-scan) sessions. +- Programmatically stop an open scan session. +- `CodeScanned` and `Cancelled` Laravel events, or bind straight to Livewire with `#[OnNative]`. +- Works from PHP (Blade/Livewire) and from JavaScript (Vue, React, Inertia, or plain JS). + +## Requirements + +- PHP ^8.2 +- [`nativephp/mobile`](https://nativephp.com) ^3.0 +- `livewire/livewire` — only needed if you use the `#[OnNative]` attribute + +## Installation + +```bash +composer require sghimire/mobile-scanner +``` + +Laravel's package auto-discovery registers `ScannerServiceProvider` for you. Then register the plugin with NativePHP: + +```bash +php artisan native:plugin:register +``` + +This wires up the plugin's `nativephp.json` manifest (bridge functions, Android camera/vibrate permissions, iOS `NSCameraUsageDescription`) into your native build. Rebuild/reinstall the native shell afterwards (`php artisan native:install` or `native:run`) so the permissions and bridge functions are picked up. + +## How It Works (Under the Hood) + +Same two-phase pattern as every async call in this plugin family — a synchronous "start" acknowledgement, then the real result delivered later through two parallel channels. + +1. **Request out.** `Scanner::scan()->scan()` (PHP) and `Scanner.scan()` (JS) both reach the same bridge — JS via `fetch('/_native/api/call', { method: 'MobileScanner.Scan', params })`, PHP via `nativephp_call('MobileScanner.Scan', json_encode($params))`. The bridge router matches `"MobileScanner.Scan"` to `ScannerFunctions.Scan`, which opens the full-screen camera UI (CameraX + ML Kit on Android, AVFoundation on iOS). +2. **Immediate ack.** The call returns right away confirming the scanner UI is open — not that anything has been decoded yet. +3. **Result comes back later, twice, per scan.** Each time the camera decodes a matching code, the native side injects one script into the webview that fires a `native-event` `CustomEvent` on `document` (what the JS `On()`/`Off()` helpers listen for) *and* makes a second call back into Laravel that instantiates and dispatches the real `CodeScanned` event (what `Event::listen()` / `#[OnNative]` pick up). Closing without a match delivers `Cancelled` the same way. +4. **Continuous mode** just means the scanner stays open and repeats step 3 for every new code, debounced natively so the same code isn't reported multiple times per second, until you call `Scanner::stop()` / `Scanner.stop()` or the user closes it. + +Because results are asynchronous and delivered to PHP and JS independently, always drive your UI from the `CodeScanned` / `Cancelled` events — never from the return value of `scan()`. + +### Scanning from the photo gallery + +Every scanner overlay opened by `MobileScanner.Scan` includes a gallery button (🖼) next to the close/torch buttons by default. No PHP/JS code or extra config is needed — it's part of the same overlay: + +- Tapping it opens the OS photo picker (Android Photo Picker, iOS `PHPickerViewController`) — neither requires a photo-library permission. +- The chosen image is decoded on-device against the same `formats()` you passed to `scan()` (Android: ML Kit; iOS: the Vision framework). +- A match fires `CodeScanned` exactly like a camera match, and closes the scanner — even in `continuous()` mode, since picking a photo is a deliberate one-off action. +- If no supported code is found in the picture, a short native message is shown and the scanner stays open so the user can try the camera or pick another photo — no event fires for that case. + +Turn it off per scan with `->gallery(false)` (PHP) / `.gallery(false)` (JS) — the button is simply omitted from the overlay, camera-only: + +```php +Scanner::scan()->gallery(false)->scan(); +``` + +```js +Scanner.scan().gallery(false); +``` + +--- + +## PHP Usage + +### The `Scanner` facade + +```php +use Sandip\Scanner\Native\Facades\Scanner; + +// Scan a single QR code with defaults +Scanner::scan()->scan(); + +// Fully configured +Scanner::scan() + ->id('ticket-scanner') // correlate this scan with its events + ->prompt('Scan your ticket') // text shown above the camera viewfinder + ->formats(['qr', 'ean13']) // one or more barcode formats to detect + ->continuous() // keep scanning after each match + ->scan(); +``` + +`scan()` on the builder returns `bool` — `true` once the request reached the native bridge, `false` if it couldn't be started (e.g. running outside the native shell, or the builder was already started once). + +If you never call `->scan()` explicitly, it fires automatically when the builder object is destructed — so `Scanner::scan()->prompt('Scan Code');` alone is enough to trigger it. Calling `->scan()` yourself is recommended so you can check the return value. + +#### Builder methods + +| Method | Description | +|---|---| +| `prompt(string $text)` | Instruction text shown above the camera viewfinder. Defaults to `"Scan Code"`. | +| `continuous(bool $continuous = true)` | Keep the scanner open and keep firing `CodeScanned` after each match instead of closing on the first one. | +| `gallery(bool $allow = true)` | Show/hide the gallery button on the overlay. Defaults to `true`; pass `false` to force camera-only scanning. | +| `formats(array $formats)` | Restrict detection to one or more formats (see table below). Throws `InvalidArgumentException` if empty or unknown. | +| `haptics(bool $enabled = true)` | Vibrate/impact-feedback on a successful scan. Defaults to `true`. | +| `zoom(float $ratio = 1.0)` | Initial camera zoom ratio, clamped to what the device supports. Defaults to `1.0`. Throws `InvalidArgumentException` if not positive. | +| `maxZoom(float $ratio = 3.0)` | Upper bound of the on-screen zoom slider, clamped to what the device supports. Defaults to `3.0`. Throws `InvalidArgumentException` if not positive. | +| `zoomControl(bool $enabled = true)` | Show/hide the on-screen zoom slider. Defaults to `true`. | +| `focusOnTap(bool $enabled = true)` | Let the user tap the preview to refocus the camera. Defaults to `true`. | +| `timeout(int $seconds = 0)` | Auto-cancel the scan after N seconds, firing `Cancelled` with reason `timeout`. Disabled (`0`) by default. Throws `InvalidArgumentException` if negative. | +| `id(string $id)` | Custom correlation ID for this scan session (not auto-generated — `null` unless set). | +| `getId()` | Get this scan's correlation ID, or `null`. | +| `scan()` | Send the scan request to the native bridge. Returns `bool`. | + +#### Supported formats + +`qr`, `ean13`, `ean8`, `code128`, `code39`, `upca`, `upce`, `all` (also available as `PendingScan::FORMATS`). Defaults to `['qr']` if `formats()` is never called. + +### Stopping a scan + +Useful for dismissing a `continuous()` session programmatically (e.g. from a "Done" button elsewhere in the UI): + +```php +use Sandip\Scanner\Native\Facades\Scanner; + +Scanner::stop(); // stop whatever scanner is open +Scanner::stop('ticket-scanner'); // stop a specific session by id +``` + +This fires `Cancelled` with `reason: 'stopped_by_app'`. + +### Listening for results + +```php +use Sandip\Scanner\Native\Events\Scanner\CodeScanned; +use Sandip\Scanner\Native\Events\Scanner\Cancelled; +use Illuminate\Support\Facades\Event; + +Event::listen(function (CodeScanned $event) { + $event->data; // string — the decoded value + $event->format; // string — which format matched, e.g. "qr" + $event->id; // ?string — matches the id you passed to ->id(), if any +}); + +Event::listen(function (Cancelled $event) { + $event->reason; // ?string — e.g. "user_cancelled", "stopped_by_app", "timeout", "camera_error" + $event->id; // ?string +}); +``` + +### Livewire: `#[OnNative]` + +```php +use Livewire\Component; +use Sandip\Scanner\Native\Attributes\OnNative; +use Sandip\Scanner\Native\Events\Scanner\CodeScanned; +use Sandip\Scanner\Native\Events\Scanner\Cancelled; +use Sandip\Scanner\Native\Facades\Scanner; + +class TicketScanner extends Component +{ + public ?string $result = null; + + public function startScan(): void + { + Scanner::scan()->id('ticket-scanner')->prompt('Scan your ticket')->scan(); + } + + #[OnNative(CodeScanned::class)] + public function onCodeScanned(string $data, string $format, ?string $id): void + { + $this->result = $data; + } + + #[OnNative(Cancelled::class)] + public function onCancelled(?string $reason, ?string $id): void + { + // handle the user closing the scanner + } +} +``` + +--- + +## JavaScript Usage + +### Importing + +This package doesn't publish a `#nativephp` import alias (that's reserved for NativePHP's first-party plugins). Import the file directly — either from the vendor path, or copy it into your own `resources/js/` and import it from there: + +```js +import { Scanner, On, Off, Events } from '../../vendor/sghimire/mobile-scanner/resources/js/scanner.js'; +``` + +Full TypeScript types (including the `BarcodeFormat` union) are included in `scanner.d.ts` alongside it. + +### Basic scan + +`Scanner.scan()` returns a thenable builder — `await` it directly, or chain builder methods first: + +```js +import { Scanner } from '../../vendor/sghimire/mobile-scanner/resources/js/scanner.js'; + +await Scanner.scan(); + +// or fully configured +await Scanner.scan() + .id('ticket-scanner') + .prompt('Scan your ticket') + .formats(['qr', 'ean13']) + .continuous(); +``` + +### Stopping a scan + +```js +import { Scanner } from '../../vendor/sghimire/mobile-scanner/resources/js/scanner.js'; + +await Scanner.stop(); // stop whatever scanner is open +await Scanner.stop('ticket-scanner'); // stop a specific session by id +``` + +### Listening for events + +```js +import { Scanner, On, Off, Events } from '../../vendor/sghimire/mobile-scanner/resources/js/scanner.js'; + +function handleScanned(payload) { + // payload: { data: string, format: string, id: string | null } + console.log('Scanned:', payload.data, payload.format); +} + +function handleCancelled(payload) { + // payload: { reason: string | null, id: string | null } +} + +On(Events.Scanner.CodeScanned, handleScanned); +On(Events.Scanner.Cancelled, handleCancelled); + +// later, e.g. on component unmount +Off(Events.Scanner.CodeScanned, handleScanned); +Off(Events.Scanner.Cancelled, handleCancelled); +``` + +### Vue 3 example + +```vue + + + +``` + +### React example + +```jsx +import { useState, useEffect, useCallback } from 'react'; +import { Scanner, On, Off, Events } from '../../vendor/sghimire/mobile-scanner/resources/js/scanner.js'; + +export function TicketScanner() { + const [scannedValue, setScannedValue] = useState(null); + + useEffect(() => { + const handleScanned = (payload) => setScannedValue(payload.data); + On(Events.Scanner.CodeScanned, handleScanned); + return () => Off(Events.Scanner.CodeScanned, handleScanned); + }, []); + + const startScan = useCallback(() => { + Scanner.scan().prompt('Scan your ticket').formats(['qr']); + }, []); + + return ( + <> + + {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 ( + <> + + + + + ); +} +``` + +`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/public/benchmark-qr.png b/public/benchmark-qr.png new file mode 100644 index 0000000..9959eec Binary files /dev/null and b/public/benchmark-qr.png differ 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 @@ + +