diff --git a/beam/tests/mobile/test_camera_scanner.py b/beam/tests/mobile/test_camera_scanner.py
new file mode 100644
index 00000000..75b9e4ec
--- /dev/null
+++ b/beam/tests/mobile/test_camera_scanner.py
@@ -0,0 +1,164 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+# Tests for camera scanner component.
+
+
+from playwright.sync_api import expect
+
+
+def test_camera_scanner_button_hidden(page, setup):
+ page.add_init_script(
+ """
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: {
+ enumerateDevices: async () => {
+ return []; // No cameras available
+ },
+ getUserMedia: async (constraints) => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 640;
+ canvas.height = 480;
+ return canvas.captureStream(30);
+ }
+ },
+ });
+ """
+ )
+
+ page.get_by_text("Move").click()
+ page.wait_for_url("**/beam#/move")
+
+ # Wait for component to check permissions and decide to hide itself
+ page.wait_for_timeout(1500)
+
+ # The component should not be visible when no cameras are found
+ camera_scanner = page.locator(".camera-scanner")
+ expect(camera_scanner).to_have_count(0)
+
+
+def test_camera_scanner_button_visible(page, setup):
+ page.add_init_script(
+ """
+ navigator.mediaDevices.enumerateDevices = async () => {
+ return [
+ {
+ kind: 'videoinput',
+ deviceId: 'mock-camera-1',
+ label: 'Mock Camera',
+ groupId: 'mock-group'
+ }
+ ];
+ };
+ """
+ )
+
+ page.get_by_text("Move").click()
+ page.wait_for_url("**/beam#/move")
+
+ page.wait_for_timeout(1000)
+
+ camera_button = page.locator("button:has-text('Open Camera')")
+ expect(camera_button).to_be_visible()
+
+
+def test_camera_scanner_activates_camera(page, setup):
+ page.add_init_script(
+ """
+ window.getUserMediaCalled = false;
+ window.getUserMediaConstraints = null;
+
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: {
+ enumerateDevices: async () => {
+ return [
+ {
+ kind: 'videoinput',
+ deviceId: 'mock-camera-1',
+ label: 'Mock Camera',
+ groupId: 'mock-group'
+ }
+ ];
+ },
+ getUserMedia: async (constraints) => {
+ window.getUserMediaCalled = true;
+ window.getUserMediaConstraints = constraints;
+ // Return a minimal fake stream
+ const canvas = document.createElement('canvas');
+ canvas.width = 640;
+ canvas.height = 480;
+ return canvas.captureStream(30);
+ }
+ },
+ writable: false,
+ configurable: true
+ });
+ """
+ )
+
+ page.get_by_text("Move").click()
+ page.wait_for_url("**/beam#/move")
+ page.wait_for_timeout(2000)
+
+ was_called_before = page.evaluate("window.getUserMediaCalled")
+ assert was_called_before == False, "getUserMedia should not be called before clicking button"
+
+ camera_button = page.locator("button:has-text('Open Camera')")
+ expect(camera_button).to_be_enabled(timeout=10000)
+
+ camera_button.click()
+ page.wait_for_timeout(1000)
+
+ was_called_after = page.evaluate("window.getUserMediaCalled")
+ assert was_called_after == True, "getUserMedia should be called after clicking 'Open Camera'"
+
+ # Verify correct constraints (should request video with back camera)
+ constraints = page.evaluate("window.getUserMediaConstraints")
+ assert constraints is not None, "getUserMedia should receive constraints"
+ assert "video" in constraints, "Should request video stream"
+ assert constraints["video"]["facingMode"] == "environment", "Should request back camera"
+
+
+def test_camera_scanner_permission_denied(page, setup):
+ # Mock camera APIs to simulate permission denial
+ page.add_init_script(
+ """
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: {
+ enumerateDevices: async () => {
+ return [
+ {
+ kind: 'videoinput',
+ deviceId: 'mock-camera-1',
+ label: 'Mock Camera',
+ groupId: 'mock-group'
+ }
+ ];
+ },
+ getUserMedia: async (constraints) => {
+ const error = new Error('Permission denied');
+ error.name = 'NotAllowedError';
+ throw error;
+ }
+ },
+ writable: false,
+ configurable: true
+ });
+ """
+ )
+
+ page.get_by_text("Move").click()
+ page.wait_for_url("**/beam#/move")
+ page.wait_for_timeout(2000)
+
+ camera_button = page.locator("button:has-text('Open Camera')")
+ expect(camera_button).to_be_enabled(timeout=10000)
+
+ camera_button.click()
+
+ page.wait_for_timeout(500)
+
+ # Verify error message is displayed
+ error_message = page.locator(".error-message")
+ expect(error_message).to_be_visible()
+ expect(error_message).to_contain_text("Permission denied")
diff --git a/beam/www/beam/components/CameraScanner.vue b/beam/www/beam/components/CameraScanner.vue
new file mode 100644
index 00000000..a3546a6b
--- /dev/null
+++ b/beam/www/beam/components/CameraScanner.vue
@@ -0,0 +1,210 @@
+
+ Last scanned: {{ lastScanned }}