Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions Modules/Sources/Experiments/DefaultFeatureFlagService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
return true
case .pointOfSaleBarcodeScanningi1:
return true
case .pointOfSaleBarcodeScanningi2:
return buildConfig == .localDeveloper || buildConfig == .alpha
case .showPointOfSaleBarcodeSimulator:
// Enables a simulated barcode scanner in dev builds for testing. Do not ship this one!
return false
Expand Down
4 changes: 4 additions & 0 deletions Modules/Sources/Experiments/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ public enum FeatureFlag: Int {
///
case pointOfSaleBarcodeScanningi1

/// Enables further improvements to barcode scanning with an external scanner in POS
///
case pointOfSaleBarcodeScanningi2

/// Enables a simulated barcode scanner for testing in POS. Do not ship this one!
///
case showPointOfSaleBarcodeSimulator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,23 @@ struct BarcodeScannerContainerRepresentable: UIViewControllerRepresentable {
let configuration: HIDBarcodeParserConfiguration
let onScan: (Result<String, Error>) -> Void

func makeUIViewController(context: Context) -> BarcodeScannerHostingController {
let controller = BarcodeScannerHostingController(
configuration: configuration,
onScan: onScan
)
return controller
func makeUIViewController(context: Context) -> UIViewController {
let featureFlagService = ServiceLocator.featureFlagService

if featureFlagService.isFeatureFlagEnabled(.pointOfSaleBarcodeScanningi2) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wondered if we should keep this for now or replace it entirely.

We could remove BarcodeScannerHostingController, HIDBarcodeParser, and possibly most if not all the BarcodeScannerContainer code.

GameControllerBarcodeObserver theoretically allows us to observe all the input within the aggregate model, although we would lose the ability to control in which screens we want to receive the input.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I think we should keep the container. We already have the need to ignore scans in some screens (search, email receipt, everything in between building a cart and payment success.

The need for granular control is likely to increase over time – for example, any time we add a text field we need to think about it, and any control will be based on view state, what's focused. Quantity selectors, customer detail entry, configuring bookings or subscription purchases – there's lots of places we may end up having more complex text field management.

return GameControllerBarcodeScannerHostingController(
configuration: configuration,
onScan: onScan
)
} else {
return BarcodeScannerHostingController(
configuration: configuration,
onScan: onScan
)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let featureFlagService = ServiceLocator.featureFlagService
if featureFlagService.isFeatureFlagEnabled(.pointOfSaleBarcodeScanningi2) {
return GameControllerBarcodeScannerHostingController(
configuration: configuration,
onScan: onScan
)
} else {
return BarcodeScannerHostingController(
configuration: configuration,
onScan: onScan
)
}
return GameControllerBarcodeScannerHostingController(
configuration: configuration,
onScan: onScan
)

I don't actually think this needs to be feature flagged, we can just ship it – we're changing the app to match Android i1 after all.

It is helpful for testing the PR though. If you want to keep the flag, perhaps rename it to something other than i2, so we can ship this before the rest of i2.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it was helpful for testing. I didn't want to remove it right away. However, we could simply replace one with another without any feature flagging.

}

func updateUIViewController(_ uiViewController: BarcodeScannerHostingController, context: Context) {}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

/// A UIHostingController that handles keyboard input events for barcode scanning.
Expand Down Expand Up @@ -115,3 +123,26 @@ class BarcodeScannerHostingController: UIHostingController<EmptyView> {
super.pressesCancelled(presses, with: event)
}
}

/// A UIHostingController that handles GameController keyboard input events for barcode scanning.
/// This controller uses GameController framework exclusively for language-independent barcode scanning.
final class GameControllerBarcodeScannerHostingController: UIHostingController<EmptyView> {
private var gameControllerBarcodeObserver: GameControllerBarcodeObserver?

init(
configuration: HIDBarcodeParserConfiguration,
onScan: @escaping (Result<String, Error>) -> Void
) {
super.init(rootView: EmptyView())

gameControllerBarcodeObserver = GameControllerBarcodeObserver(configuration: configuration, onScan: onScan)
}

@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

deinit {
gameControllerBarcodeObserver = nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import Foundation
import GameController

/// An observer that uses the `GameController` framework to monitor for connected barcode scanners/keyboards
/// and parse their input into barcode strings.
///
/// This class handles the low-level details of observing keyboard connections, processing raw `GCKeyCode` inputs,
/// and using the `GameControllerBarcodeParser` to produce a final barcode.
///
final class GameControllerBarcodeObserver {
/// A closure that is called when a barcode scan is completed.
/// The result will be a `success` with the barcode string or a `failure` with an error.
let onScan: (Result<String, Error>) -> Void

/// Track the coalesced keyboard and its parser
/// According to Apple's documentation, all connected keyboards are coalesced into one keyboard object
/// (GCKeyboard.coalesced), so notification about connection/disconnection will only be delivered once
/// until the last keyboard disconnects.
private var coalescedKeyboard: GCKeyboard?
private var barcodeParser: GameControllerBarcodeParser?
private let configuration: HIDBarcodeParserConfiguration

/// Tracks current shift state to be applied to the next character key
private var isShiftPressed: Bool = false

/// Initializes a new barcode scanner observer.
/// - Parameters:
/// - configuration: The configuration to use for the barcode parser. Defaults to the standard configuration.
/// - onScan: The closure to be called when a scan is completed.
init(configuration: HIDBarcodeParserConfiguration = .default, onScan: @escaping (Result<String, Error>) -> Void) {
self.onScan = onScan
self.configuration = configuration
addObservers()
setupCoalescedKeyboard()
}

deinit {
removeObservers()
cleanupKeyboard()
}

/// Starts observing for keyboard connection and disconnection events.
private func addObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardDidConnect),
name: .GCKeyboardDidConnect,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardDidDisconnect),
name: .GCKeyboardDidDisconnect,
object: nil
)
}

/// Stops observing for keyboard events.
private func removeObservers() {
NotificationCenter.default.removeObserver(self, name: .GCKeyboardDidConnect, object: nil)
NotificationCenter.default.removeObserver(self, name: .GCKeyboardDidDisconnect, object: nil)
}

/// Sets up the coalesced keyboard if one is available at initialization.
private func setupCoalescedKeyboard() {
if let keyboard = GCKeyboard.coalesced {
setupKeyboard(keyboard)
}
}

/// Handles the connection of a keyboard (coalesced).
@objc private func handleKeyboardDidConnect(_ notification: Notification) {
guard let keyboard = notification.object as? GCKeyboard else {
return
}
setupKeyboard(keyboard)
}

/// Handles the disconnection of the keyboard (coalesced).
@objc private func handleKeyboardDidDisconnect(_ notification: Notification) {
cleanupKeyboard()
}

/// Sets up the coalesced keyboard to handle key press events.
/// - Parameter keyboard: The coalesced `GCKeyboard` to set up.
private func setupKeyboard(_ keyboard: GCKeyboard) {
// Clean up any existing setup first
cleanupKeyboard()

coalescedKeyboard = keyboard
barcodeParser = GameControllerBarcodeParser(configuration: configuration, onScan: onScan)

keyboard.keyboardInput?.keyChangedHandler = { [weak self] _, _, keyCode, pressed in
guard let self = self else { return }

if keyCode == .leftShift || keyCode == .rightShift {
self.isShiftPressed = pressed
return
}

guard pressed else { return }

self.barcodeParser?.processKeyPress(keyCode, isShiftPressed: isShiftPressed)
}
}

/// Cleans up the coalesced keyboard and its parser.
private func cleanupKeyboard() {
coalescedKeyboard?.keyboardInput?.keyChangedHandler = nil
barcodeParser?.cancel()
coalescedKeyboard = nil
barcodeParser = nil
isShiftPressed = false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import Foundation
import GameController

/// Parses GameController keyboard input into barcode scans.
/// This class handles the core logic for interpreting GameController GCKeyCode input as barcode data,
/// providing language-independent barcode scanning by bypassing iOS keyboard layout processing.
final class GameControllerBarcodeParser {
/// Configuration for the barcode scanner
let configuration: HIDBarcodeParserConfiguration
/// Callback that is triggered when a barcode scan completes (success or failure)
let onScan: (Result<String, Error>) -> Void

private let timeProvider: TimeProvider

private var buffer = ""
private var lastKeyPressTime: Date?

init(configuration: HIDBarcodeParserConfiguration,
onScan: @escaping (Result<String, Error>) -> Void,
timeProvider: TimeProvider = DefaultTimeProvider()) {
self.configuration = configuration
self.onScan = onScan
self.timeProvider = timeProvider
}

/// Process a GameController key press event
/// - Parameters:
/// - keyCode: The GameController key code that was pressed
/// - isShiftPressed: Whether shift key is currently pressed
func processKeyPress(_ keyCode: GCKeyCode, isShiftPressed: Bool = false) {
guard shouldRecogniseAsScanKeystroke(keyCode, isShiftPressed: isShiftPressed) else {
return
}

guard let character = characterForKeyCode(keyCode, isShiftPressed: isShiftPressed) else {
return
}

if configuration.terminatingStrings.contains(character) {
processScan()
} else {
guard !excludedKeyCodes.contains(keyCode) else { return }
checkForTimeoutBetweenKeystrokes()
buffer.append(character)
}
}

private func shouldRecogniseAsScanKeystroke(_ keyCode: GCKeyCode, isShiftPressed: Bool) -> Bool {
guard let character = characterForKeyCode(keyCode, isShiftPressed: isShiftPressed) else {
// This prevents a double-trigger-pull on a Star scanner from adding an error row –
// Star use this as a shortcut to switch to the software keyboard. They send keycode 174 0xAE, which is
// undefined and reserved in UIKeyboardHIDUsage. The scanner doesn't send a character with the code.
// There seems to be no reason to handle empty input when considering scans.
return false
}

guard character.isNotEmpty else {
// This prevents a double-trigger-pull on a Star scanner from adding an error row –
// Star use this as a shortcut to switch to the software keyboard. They send keycode 174 0xAE, which is
// undefined and reserved in UIKeyboardHIDUsage. The scanner doesn't send a character with the code.
// There seems to be no reason to handle empty input when considering scans.
return false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this comment block to be repeated? Perhaps it could all be in a single guard.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we don't. I'll clean it up.

}

if buffer.isEmpty && configuration.terminatingStrings.contains(character) {
// We prefer to show all partial scans, but if we just get an enter with no numbers, ignoring it makes testing easier
return false
}

return true
}

private func checkForTimeoutBetweenKeystrokes() {
// If characters are entered too slowly, it's probably typing and we should ignore the old input.
// The key we just received is still considered for adding to the buffer – we may simply reset the buffer first.
let currentTime = timeProvider.now()

if let lastTime = lastKeyPressTime,
currentTime.timeIntervalSince(lastTime) > configuration.maximumInterCharacterTime {
onScan(.failure(HIDBarcodeParserError.timedOut(barcode: buffer)))
resetScan()
}

lastKeyPressTime = currentTime
}

/// Convert GCKeyCode to ASCII character
/// Maps GameController key codes to their corresponding ASCII characters
/// - Parameters:
/// - keyCode: The GameController key code
/// - isShiftPressed: Whether shift key is pressed (affects letter case and symbols)
private func characterForKeyCode(_ keyCode: GCKeyCode, isShiftPressed: Bool) -> String? {
switch keyCode {
// Numbers and their shifted symbols
case .zero: return isShiftPressed ? ")" : "0"
case .one: return isShiftPressed ? "!" : "1"
case .two: return isShiftPressed ? "@" : "2"
case .three: return isShiftPressed ? "#" : "3"
case .four: return isShiftPressed ? "$" : "4"
case .five: return isShiftPressed ? "%" : "5"
case .six: return isShiftPressed ? "^" : "6"
case .seven: return isShiftPressed ? "&" : "7"
case .eight: return isShiftPressed ? "*" : "8"
case .nine: return isShiftPressed ? "(" : "9"

// Letters - lowercase without shift, uppercase with shift
case .keyA: return isShiftPressed ? "A" : "a"
case .keyB: return isShiftPressed ? "B" : "b"
case .keyC: return isShiftPressed ? "C" : "c"
case .keyD: return isShiftPressed ? "D" : "d"
case .keyE: return isShiftPressed ? "E" : "e"
case .keyF: return isShiftPressed ? "F" : "f"
case .keyG: return isShiftPressed ? "G" : "g"
case .keyH: return isShiftPressed ? "H" : "h"
case .keyI: return isShiftPressed ? "I" : "i"
case .keyJ: return isShiftPressed ? "J" : "j"
case .keyK: return isShiftPressed ? "K" : "k"
case .keyL: return isShiftPressed ? "L" : "l"
case .keyM: return isShiftPressed ? "M" : "m"
case .keyN: return isShiftPressed ? "N" : "n"
case .keyO: return isShiftPressed ? "O" : "o"
case .keyP: return isShiftPressed ? "P" : "p"
case .keyQ: return isShiftPressed ? "Q" : "q"
case .keyR: return isShiftPressed ? "R" : "r"
case .keyS: return isShiftPressed ? "S" : "s"
case .keyT: return isShiftPressed ? "T" : "t"
case .keyU: return isShiftPressed ? "U" : "u"
case .keyV: return isShiftPressed ? "V" : "v"
case .keyW: return isShiftPressed ? "W" : "w"
case .keyX: return isShiftPressed ? "X" : "x"
case .keyY: return isShiftPressed ? "Y" : "y"
case .keyZ: return isShiftPressed ? "Z" : "z"

// Punctuation and symbols with shift variants
case .spacebar: return " "
case .hyphen: return isShiftPressed ? "_" : "-"
case .equalSign: return isShiftPressed ? "+" : "="
case .openBracket: return isShiftPressed ? "{" : "["
case .closeBracket: return isShiftPressed ? "}" : "]"
case .backslash: return isShiftPressed ? "|" : "\\"
case .semicolon: return isShiftPressed ? ":" : ";"
case .quote: return isShiftPressed ? "\"" : "'"
case .comma: return isShiftPressed ? "<" : ","
case .period: return isShiftPressed ? ">" : "."
case .slash: return isShiftPressed ? "?" : "/"
case .graveAccentAndTilde: return isShiftPressed ? "~" : "`"
case .returnOrEnter: return "\r"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should \n be in this list?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's returnOrEnter, I think they are both treated the same way with GameController framework.

case .tab: return "\t"
default:
return nil
}
}

/// Key codes that should be excluded from barcode processing
private let excludedKeyCodes: Set<GCKeyCode> = [
.capsLock,
.leftShift,
.rightShift,
.leftControl,
.rightControl,
.upArrow,
.downArrow,
.leftArrow,
.rightArrow,
.pageUp,
.pageDown,
.home,
.end,
.insert,
.deleteForward,
.deleteOrBackspace,
.printScreen,
.scrollLock,
.pause,
.escape
]

/// Cancel the current scan and clear the buffer
func cancel() {
resetScan()
}

private func resetScan() {
buffer = ""
lastKeyPressTime = nil
}

private func processScan() {
checkForTimeoutBetweenKeystrokes()
if buffer.count >= configuration.minimumBarcodeLength {
onScan(.success(buffer))
} else {
onScan(.failure(HIDBarcodeParserError.scanTooShort(barcode: buffer)))
}
resetScan()
}
}
Loading