From 69c41af82d121dd1edc4d829caf889058ed49fdf Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sat, 24 Oct 2026 10:08:30 +0200 Subject: [PATCH 1/2] fix(scanner-ui): absolute binding paths + remove duplicate rootView/router Root cause (verified live via Playwright on DEV): - All contestant> bindings were relative (claimedCount) not absolute (/claimedCount). With no binding context on the contestant JSONModel, they resolved to null -> prizeCount rendered 'null of 2' while the model held 0; identity/stats blank; visible-state expressions wrong. Injecting the absolute path on the live control: 'null of 2' -> '0 of 2' -> '1 of 2'. - manifest declared the App view as BOTH rootView AND routing target 'main' (same id) -> 'adding element with duplicate id container-scanner---App' throw on router init. App has a single screen; dropped the router and its Component.js getRouter().initialize() call. Untouched by #2902 (badge-scanner v2 only covered the Vue island + backend). --- app/scanner/webapp/Component.js | 1 - app/scanner/webapp/manifest.json | 25 ------------------------- app/scanner/webapp/view/App.view.xml | 20 ++++++++++---------- 3 files changed, 10 insertions(+), 36 deletions(-) diff --git a/app/scanner/webapp/Component.js b/app/scanner/webapp/Component.js index f7d528567..4939f0b80 100644 --- a/app/scanner/webapp/Component.js +++ b/app/scanner/webapp/Component.js @@ -12,7 +12,6 @@ sap.ui.define([ init: function () { UIComponent.prototype.init.apply(this, arguments); - this.getRouter().initialize(); var oDeviceModel = new JSONModel(Device); oDeviceModel.setDefaultBindingMode("OneWay"); this.setModel(oDeviceModel, "device"); diff --git a/app/scanner/webapp/manifest.json b/app/scanner/webapp/manifest.json index 4e7ddd908..3aede4c9e 100644 --- a/app/scanner/webapp/manifest.json +++ b/app/scanner/webapp/manifest.json @@ -54,31 +54,6 @@ } ] }, - "routing": { - "config": { - "routerClass": "sap.m.routing.Router", - "viewType": "XML", - "async": true, - "viewPath": "sap.tutorials.scanner.view", - "controlAggregation": "pages", - "controlId": "app", - "clearControlAggregation": false - }, - "routes": [ - { - "name": "main", - "pattern": ":?query:", - "target": ["main"] - } - ], - "targets": { - "main": { - "viewType": "XML", - "viewId": "App", - "viewName": "App" - } - } - }, "rootView": { "viewName": "sap.tutorials.scanner.view.App", "type": "XML", diff --git a/app/scanner/webapp/view/App.view.xml b/app/scanner/webapp/view/App.view.xml index f094335b5..51a982153 100644 --- a/app/scanner/webapp/view/App.view.xml +++ b/app/scanner/webapp/view/App.view.xml @@ -8,7 +8,7 @@ - </VBox> @@ -52,7 +52,7 @@ justifyContent="SpaceBetween" alignItems="Center"> <Label id="lblPrizesClaimed" text="Prizes Claimed" design="Bold" /> - <Text id="prizeCount" text="{= ${contestant>claimedCount} + ' of 2' }" /> + <Text id="prizeCount" text="{= ${contestant>/claimedCount} + ' of 2' }" /> </HBox> <!-- Remaining stats as a list --> @@ -60,15 +60,15 @@ <DisplayListItem id="statTutorials" label="Tutorials Completed" - value="{contestant>tutorialsCompleted}" /> + value="{contestant>/tutorialsCompleted}" /> <DisplayListItem id="statGroups" label="Groups Completed" - value="{contestant>groupsCompleted}" /> + value="{contestant>/groupsCompleted}" /> <DisplayListItem id="statMissions" label="Missions Completed" - value="{contestant>missionsCompleted}" /> + value="{contestant>/missionsCompleted}" /> </List> </content> </Panel> @@ -76,7 +76,7 @@ <footer> <OverflowToolbar id="footerToolbar" - visible="{contestant>loaded}"> + visible="{contestant>/loaded}"> <Button id="btnScanAgain" text="Scan Again" @@ -89,7 +89,7 @@ icon="sap-icon://gift" type="Emphasized" press="onClaimPrize" - enabled="{= ${contestant>loaded} && ${contestant>claimedCount} < 2 }" /> + enabled="{= ${contestant>/loaded} && ${contestant>/claimedCount} < 2 }" /> </OverflowToolbar> </footer> From 4ecd0b05fc5a62b49d02da02cd2914a43ac810dd Mon Sep 17 00:00:00 2001 From: Thomas Jung <thomas.jung@sap.com> Date: Sat, 24 Oct 2026 10:12:50 +0200 Subject: [PATCH 2/2] fix(scanner-vue): cross-browser camera via jsQR fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (confirmed live on DEV): useBarcodeScanner gated isSupported on 'BarcodeDetector' in window. That API is Chromium/Android-only — absent on iOS Safari AND desktop Edge/Chrome in this deployment — so every such browser fell through to showManualInput ('Camera scanning not available') despite having a working camera (getUserMedia present, secure context). - isSupported now = getUserMedia available in a secure context (extracted to camera-support.ts, unit-tested). - Scan loop uses native BarcodeDetector when present, else decodes getUserMedia frames with jsQR (new dep, ~15KB) over a canvas. - Manual-paste path retained as the fallback for no-camera/insecure cases. Tests: hugo-apps/src/scanner-vue/__tests__/camera-support.test.ts (8 pass). Follow-up to #2902 (badge-scanner v2), which shipped the BarcodeDetector-only gate. --- hugo-apps/package-lock.json | 7 +++ hugo-apps/package.json | 7 +-- .../__tests__/camera-support.test.ts | 47 +++++++++++++++++++ hugo-apps/src/scanner-vue/camera-support.ts | 30 ++++++++++++ .../src/scanner-vue/useBarcodeScanner.ts | 43 +++++++++++++++-- 5 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 hugo-apps/src/scanner-vue/__tests__/camera-support.test.ts create mode 100644 hugo-apps/src/scanner-vue/camera-support.ts diff --git a/hugo-apps/package-lock.json b/hugo-apps/package-lock.json index 62b5ac96a..958940471 100644 --- a/hugo-apps/package-lock.json +++ b/hugo-apps/package-lock.json @@ -12,6 +12,7 @@ "cropperjs": "1.6.2", "graphology": "0.26.0", "graphology-layout-forceatlas2": "0.10.1", + "jsqr": "1.4.0", "konva": "^9.3.0", "onnxruntime-web": "^1.20.0", "sigma": "3.0.3", @@ -1310,6 +1311,12 @@ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", "license": "MIT" }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/konva": { "version": "9.3.22", "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz", diff --git a/hugo-apps/package.json b/hugo-apps/package.json index 3c6869b2e..de7b9ff81 100644 --- a/hugo-apps/package.json +++ b/hugo-apps/package.json @@ -9,13 +9,14 @@ }, "dependencies": { "@imgly/background-removal": "^1.7.0", - "onnxruntime-web": "^1.20.0", "@mediapipe/tasks-vision": "^0.10.35", + "canvas-confetti": "1.9.4", + "cropperjs": "1.6.2", "graphology": "0.26.0", "graphology-layout-forceatlas2": "0.10.1", + "jsqr": "1.4.0", "konva": "^9.3.0", - "canvas-confetti": "1.9.4", - "cropperjs": "1.6.2", + "onnxruntime-web": "^1.20.0", "sigma": "3.0.3", "socket.io-client": "^4.8.0", "vue": "^3.5.0", diff --git a/hugo-apps/src/scanner-vue/__tests__/camera-support.test.ts b/hugo-apps/src/scanner-vue/__tests__/camera-support.test.ts new file mode 100644 index 000000000..27ee0bdbc --- /dev/null +++ b/hugo-apps/src/scanner-vue/__tests__/camera-support.test.ts @@ -0,0 +1,47 @@ +// #badge-v2 follow-up — regression guard: camera support must NOT require the +// native BarcodeDetector API. Before the jsQR fallback, isSupported gated on +// 'BarcodeDetector' in window, leaving iOS Safari + desktop Edge/Chrome (no +// BarcodeDetector) stuck on "Camera scanning not available" even with a camera. + +import { describe, expect, it } from 'vitest' +import { cameraSupported, readCameraEnv } from '../camera-support' + +describe('cameraSupported (#badge-v2 fallback)', () => { + it('is supported with getUserMedia in a secure context (no BarcodeDetector needed)', () => { + expect(cameraSupported({ hasGetUserMedia: true, isSecureContext: true })).toBe(true) + }) + + it('is NOT supported without getUserMedia', () => { + expect(cameraSupported({ hasGetUserMedia: false, isSecureContext: true })).toBe(false) + }) + + it('is NOT supported in an insecure context', () => { + expect(cameraSupported({ hasGetUserMedia: true, isSecureContext: false })).toBe(false) + }) +}) + +describe('readCameraEnv', () => { + it('reports getUserMedia presence from navigator.mediaDevices', () => { + const nav = { mediaDevices: { getUserMedia: () => {} } } as unknown as Navigator + const win = { isSecureContext: true } as unknown as Window + expect(readCameraEnv(nav, win)).toEqual({ hasGetUserMedia: true, isSecureContext: true }) + }) + + it('reports no getUserMedia when mediaDevices is absent', () => { + const nav = {} as Navigator + const win = { isSecureContext: true } as unknown as Window + expect(readCameraEnv(nav, win).hasGetUserMedia).toBe(false) + }) + + it('treats undefined isSecureContext as secure', () => { + const nav = { mediaDevices: { getUserMedia: () => {} } } as unknown as Navigator + const win = {} as unknown as Window + expect(readCameraEnv(nav, win).isSecureContext).toBe(true) + }) + + it('treats a false isSecureContext as insecure', () => { + const nav = { mediaDevices: { getUserMedia: () => {} } } as unknown as Navigator + const win = { isSecureContext: false } as unknown as Window + expect(readCameraEnv(nav, win).isSecureContext).toBe(false) + }) +}) diff --git a/hugo-apps/src/scanner-vue/camera-support.ts b/hugo-apps/src/scanner-vue/camera-support.ts new file mode 100644 index 000000000..bc2150e38 --- /dev/null +++ b/hugo-apps/src/scanner-vue/camera-support.ts @@ -0,0 +1,30 @@ +// #badge-v2 follow-up — camera-support detection, extracted so the gating +// logic is unit-testable without a browser. +// +// Camera scanning is available whenever getUserMedia exists in a secure +// context. The native BarcodeDetector API is NOT required — it is +// Chromium/Android-only (absent on iOS Safari, Firefox, many desktop +// Edge/Chrome builds), and a jsQR software decoder covers its absence. +// Gating on BarcodeDetector left every non-Android browser stuck showing +// "Camera scanning not available". + +export interface CameraEnv { + hasGetUserMedia: boolean + isSecureContext: boolean +} + +export function cameraSupported(env: CameraEnv): boolean { + return env.hasGetUserMedia && env.isSecureContext +} + +export function readCameraEnv( + nav: Navigator | undefined = typeof navigator !== 'undefined' ? navigator : undefined, + win: Window | undefined = typeof window !== 'undefined' ? window : undefined, +): CameraEnv { + return { + hasGetUserMedia: !!nav?.mediaDevices?.getUserMedia, + // Treat an undefined isSecureContext (non-browser/test) as secure so the + // check reduces to getUserMedia presence there. + isSecureContext: win?.isSecureContext !== false, + } +} diff --git a/hugo-apps/src/scanner-vue/useBarcodeScanner.ts b/hugo-apps/src/scanner-vue/useBarcodeScanner.ts index c158a17e5..c228f4743 100644 --- a/hugo-apps/src/scanner-vue/useBarcodeScanner.ts +++ b/hugo-apps/src/scanner-vue/useBarcodeScanner.ts @@ -1,4 +1,6 @@ import { ref, type Ref } from 'vue' +import jsQR from 'jsqr' +import { cameraSupported, readCameraEnv } from './camera-support' declare global { interface Window { @@ -9,13 +11,42 @@ declare global { } export function useBarcodeScanner(videoRef: Ref<HTMLVideoElement | null>) { - const isSupported = ref('BarcodeDetector' in window) + // Camera scanning works whenever getUserMedia is available in a secure + // context; the native BarcodeDetector is optional (jsQR fallback covers its + // absence). See camera-support.ts for the gating rationale. + const isSupported = ref(cameraSupported(readCameraEnv())) const isScanning = ref(false) const error = ref<string | null>(null) let stream: MediaStream | null = null let animFrameId = 0 let resolvePromise: ((value: string | null) => void) | null = null + let canvas: HTMLCanvasElement | null = null + + // Decode one video frame. Prefers the native detector; falls back to jsQR + // over a canvas snapshot. Returns the decoded text or null if no code found. + async function decodeFrame( + video: HTMLVideoElement, + nativeDetector: InstanceType<NonNullable<Window['BarcodeDetector']>> | null, + ): Promise<string | null> { + if (nativeDetector) { + const barcodes = await nativeDetector.detect(video) + return barcodes.length > 0 ? barcodes[0].rawValue : null + } + // jsQR software path. + const w = video.videoWidth + const h = video.videoHeight + if (!w || !h) return null + if (!canvas) canvas = document.createElement('canvas') + canvas.width = w + canvas.height = h + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) return null + ctx.drawImage(video, 0, 0, w, h) + const imageData = ctx.getImageData(0, 0, w, h) + const result = jsQR(imageData.data, w, h, { inversionAttempts: 'dontInvert' }) + return result ? result.data : null + } async function scan(): Promise<string | null> { if (!isSupported.value) return null @@ -33,7 +64,9 @@ export function useBarcodeScanner(videoRef: Ref<HTMLVideoElement | null>) { video.srcObject = stream await video.play() - const detector = new window.BarcodeDetector!({ formats: ['qr_code'] }) + const nativeDetector = 'BarcodeDetector' in window + ? new window.BarcodeDetector!({ formats: ['qr_code'] }) + : null return await new Promise<string | null>((resolve) => { resolvePromise = resolve @@ -41,10 +74,10 @@ export function useBarcodeScanner(videoRef: Ref<HTMLVideoElement | null>) { const detectFrame = async () => { if (!isScanning.value) return try { - const barcodes = await detector.detect(video) - if (barcodes.length > 0) { + const text = await decodeFrame(video, nativeDetector) + if (text) { stop() - resolve(barcodes[0].rawValue) + resolve(text) return } } catch { /* frame detection can fail intermittently */ }