Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/scanner/webapp/Component.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
25 changes: 0 additions & 25 deletions app/scanner/webapp/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 10 additions & 10 deletions app/scanner/webapp/view/App.view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<content>
<!-- Idle state: shown when no scan result is present -->
<VBox id="vboxIdle"
visible="{= !${contestant>loaded} &amp;&amp; !${contestant>loading} }"
visible="{= !${contestant>/loaded} &amp;&amp; !${contestant>/loading} }"
class="sapUiLargeMarginTop sapUiMediumMarginBottom">
<VBox id="vboxIdleContent" alignItems="Center" class="sapUiMediumMarginBottom">
<core:Icon
Expand Down Expand Up @@ -36,13 +36,13 @@
<!-- Data panel: shown while loading and after load -->
<Panel
id="contestantCard"
visible="{= ${contestant>loaded} || ${contestant>loading} }"
busy="{contestant>loading}"
visible="{= ${contestant>/loaded} || ${contestant>/loading} }"
busy="{contestant>/loading}"
class="sapUiTinyMarginTop">
<content>
<!-- Identity header -->
<VBox id="vboxIdentity" class="sapUiSmallMarginBeginEnd sapUiSmallMarginTopBottom">
<Title id="resultUid" text="{contestant>uid}" level="H5"
<Title id="resultUid" text="{contestant>/uid}" level="H5"
class="sapUiTinyMarginBottom" />
</VBox>

Expand All @@ -52,31 +52,31 @@
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 -->
<List id="listStats" showSeparators="Inner">
<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>
</content>

<footer>
<OverflowToolbar id="footerToolbar"
visible="{contestant>loaded}">
visible="{contestant>/loaded}">
<Button
id="btnScanAgain"
text="Scan Again"
Expand All @@ -89,7 +89,7 @@
icon="sap-icon://gift"
type="Emphasized"
press="onClaimPrize"
enabled="{= ${contestant>loaded} &amp;&amp; ${contestant>claimedCount} &lt; 2 }" />
enabled="{= ${contestant>/loaded} &amp;&amp; ${contestant>/claimedCount} &lt; 2 }" />
</OverflowToolbar>
</footer>

Expand Down
7 changes: 7 additions & 0 deletions hugo-apps/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions hugo-apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions hugo-apps/src/scanner-vue/__tests__/camera-support.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
30 changes: 30 additions & 0 deletions hugo-apps/src/scanner-vue/camera-support.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
43 changes: 38 additions & 5 deletions hugo-apps/src/scanner-vue/useBarcodeScanner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ref, type Ref } from 'vue'
import jsQR from 'jsqr'
import { cameraSupported, readCameraEnv } from './camera-support'

declare global {
interface Window {
Expand All @@ -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
Expand All @@ -33,18 +64,20 @@ 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

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 */ }
Expand Down
Loading