diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 00f3e3819..35b5d565c 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -805,6 +805,10 @@ final class ScanViewState { var isReplay = false /// True when the autopilot is driving the intents (UI tests, demos). Show a small badge. var isAutopilot = false + /// True for a practice scan (HouseScanKit `PracticeMeter`): a drawn sample meter stands in + /// for the electric meter and its close-up photo. Every screen that could pass for a real scan + /// shows a "Practice meter" badge. Set when the scan starts, from the developer options. + var isPracticeScan = false /// True when no server is configured and the result will be the bundled sample: nothing is /// sent, and every screen that talks about the upload or shows the spot must say so. var usesSampleResult = false diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index e5deeb9c6..58e7c8f4d 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -20,6 +20,9 @@ import OSLog /// marking the ends (`Autopilot.endWalkByCantGetThere`). /// - `-autopilotSomethingThere`: the autopilot answers the first spot check "Something's there" /// instead of "It's clear", so the scan is checked again without that area. +/// - `-simulateAppStore`: run as an App Store install would, so the developer options and practice +/// meter are unavailable whatever the stored switch says (`DeveloperSettings`). It can only take +/// the switch away, never offer it. struct LaunchOptions: Equatable { var replayFolder: URL? var autopilot = false @@ -29,6 +32,7 @@ struct LaunchOptions: Equatable { var autopilotGate: URL? var autopilotCantGetThere = false var autopilotSomethingThere = false + var simulateAppStore = false init( arguments: [String] = ProcessInfo.processInfo.arguments, @@ -44,6 +48,7 @@ struct LaunchOptions: Equatable { autopilot = arguments.contains("-autopilot") autopilotCantGetThere = arguments.contains("-autopilotCantGetThere") autopilotSomethingThere = arguments.contains("-autopilotSomethingThere") + simulateAppStore = arguments.contains("-simulateAppStore") serverURL = (value(after: "-serverURL") ?? defaultServerURL).flatMap(Self.serverURL) sampleResult = arguments.contains("-sampleResult") if let gate = value(after: "-autopilotGate") { autopilotGate = URL(fileURLWithPath: gate, isDirectory: true) } diff --git a/ios/HouseScan/Runtime/Practice/DeveloperSettings.swift b/ios/HouseScan/Runtime/Practice/DeveloperSettings.swift new file mode 100644 index 000000000..5e2423867 --- /dev/null +++ b/ios/HouseScan/Runtime/Practice/DeveloperSettings.swift @@ -0,0 +1,66 @@ +import Foundation +import HouseScanKit +import Observation +import StoreKit + +/// The developer options sheet's settings, and whether this install may show them. +/// +/// The switch lives in the app, not in the iOS Settings app: a Settings.bundle is a file in the +/// app bundle, and a TestFlight build is the same binary the App Store later ships, so its +/// switch would show in App Store installs too. Here the sheet and the switch appear only where +/// `PracticeMeter.isAvailable` says, decided at run time from where the install came from. +@MainActor +@Observable +final class DeveloperSettings { + static let shared = DeveloperSettings(simulateAppStore: LaunchOptions().simulateAppStore) + + /// The UserDefaults key the sheet's switch writes. A launch argument `-practiceMeter NO` + /// overrides it for one run (UserDefaults' argument domain), which the UI tests use. + static let practiceMeterKey = "practiceMeter" + + /// Where this install came from; `.unknown` until StoreKit answers. + private(set) var environment: PracticeMeter.InstallEnvironment + + init(simulateAppStore: Bool) { + if simulateAppStore { + environment = .appStore + return + } + #if DEBUG + environment = .development + #else + environment = .unknown + Task { [weak self] in + let found = await Self.storeEnvironment() + self?.environment = found + RuntimeLog.engine.info("install environment: \(found.rawValue, privacy: .public)") + } + #endif + } + + /// Whether the developer options are offered at all. + var isAvailable: Bool { PracticeMeter.isAvailable(in: environment) } + + /// Whether a scan started now is a practice scan. + var practiceMeterOn: Bool { + PracticeMeter.isOn(requested: UserDefaults.standard.bool(forKey: Self.practiceMeterKey), in: environment) + } + + /// StoreKit's signed app transaction says which server signed it: the sandbox for TestFlight, + /// production for the App Store, Xcode for StoreKit testing. Anything unverified or missing is + /// `.unknown`, which offers nothing. + nonisolated private static func storeEnvironment() async -> PracticeMeter.InstallEnvironment { + do { + guard case .verified(let transaction) = try await AppTransaction.shared else { return .unknown } + switch transaction.environment { + case .sandbox: return .testFlight + case .production: return .appStore + case .xcode: return .development + default: return .unknown + } + } catch { + RuntimeLog.engine.error("install environment unknown: \(String(describing: error), privacy: .public)") + return .unknown + } + } +} diff --git a/ios/HouseScan/Runtime/Practice/SampleMeterArt.swift b/ios/HouseScan/Runtime/Practice/SampleMeterArt.swift new file mode 100644 index 000000000..07d78cfab --- /dev/null +++ b/ios/HouseScan/Runtime/Practice/SampleMeterArt.swift @@ -0,0 +1,198 @@ +import CoreGraphics +import CoreText +import Foundation +import HouseScanKit +import ImageIO +import UniformTypeIdentifiers + +/// The practice meter's picture, drawn in code: a gray socket-meter box with a glass dome, the +/// made-up maker and the fake number (`PracticeMeter.printedLines`). The camera view draws it on +/// the wall at the tap (`PracticeMeterOverlay`), and a practice close-up stores a photo of it on a +/// sided wall (`closeUpJPEG`), which the meter-number reader then reads. +/// +/// Only Core Graphics, Core Text and ImageIO, with fonts both iOS and macOS ship, so the same +/// drawing renders on either. +enum SampleMeterArt { + /// Width over height of the drawn box, as `PracticeMeter.plateSize`. + static let aspect = CGFloat(PracticeMeter.plateSize.x / PracticeMeter.plateSize.y) + + /// The box alone on a clear background, upright, for the camera view. 600 px wide: sharp at + /// the size the close-up holds it on screen, about half the screen's width. + static let face: CGImage? = { + let width = 600 + let height = Int((CGFloat(width) / aspect).rounded()) + guard let context = uprightContext(width: width, height: height, opaque: false) else { return nil } + drawMeter(in: context, rect: CGRect(x: 0, y: 0, width: width, height: height)) + return context.makeImage() + }() + + /// A close-up photo of the sample on a sided wall, as the camera stores one: the landscape + /// sensor image of `width` by `height` pixels, unrotated, whose upright view is it turned 90° + /// clockwise (`ImageWork.uprightThumbnail`, and the reader's EXIF `.right`). The practice + /// close-up passes the live frame's size, so the stored photo's pixels still match the + /// frame's intrinsics. Nil when a context can't be made. + static func closeUpJPEG(width: Int, height: Int) -> Data? { + // Upright, the photo is portrait: the sensor's height across, its width down. + let uprightWidth = height + let uprightHeight = width + guard width > 0, height > 0, let upright = uprightContext(width: uprightWidth, height: uprightHeight, opaque: true) else { return nil } + let size = CGSize(width: uprightWidth, height: uprightHeight) + drawWall(in: upright, size: size) + // The box fills 78% of the photo's height, as a close-up held at the gate's distance does. + let boxHeight = size.height * 0.78 + let box = CGRect(x: (size.width - boxHeight * aspect) / 2, y: size.height * 0.47 - boxHeight / 2, width: boxHeight * aspect, height: boxHeight) + drawConduit(in: upright, below: box, size: size) + upright.saveGState() + upright.setShadow(offset: CGSize(width: 0, height: box.height * 0.02), blur: box.height * 0.05, color: CGColor(gray: 0, alpha: 0.35)) + upright.addPath(boxPath(box)) + upright.setFillColor(gray: 0.6, alpha: 1) + upright.fillPath() + upright.restoreGState() + drawMeter(in: upright, rect: box) + guard let image = upright.makeImage(), + let sensor = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return nil } + // Turned 90° counterclockwise: the inverse of `ImageWork.rotatedClockwise`, so turning the + // stored image clockwise gives the upright photo back. + sensor.translateBy(x: CGFloat(width), y: 0) + sensor.rotate(by: .pi / 2) + sensor.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + guard let stored = sensor.makeImage() else { return nil } + return jpeg(stored) + } + + // MARK: Drawing + + /// An sRGB context whose user space runs y down from the top left, as the layout below is + /// written, with Core Text set to draw upright glyphs in it. + private static func uprightContext(width: Int, height: Int, opaque: Bool) -> CGContext? { + guard let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: (opaque ? CGImageAlphaInfo.noneSkipLast : CGImageAlphaInfo.premultipliedLast).rawValue + ) else { return nil } + context.translateBy(x: 0, y: CGFloat(height)) + context.scaleBy(x: 1, y: -1) + context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) + return context + } + + private static func boxPath(_ box: CGRect) -> CGPath { + CGPath(roundedRect: box, cornerWidth: box.width * 0.08, cornerHeight: box.width * 0.08, transform: nil) + } + + /// Beige lap siding: a board every 4.5% of the height, each with a shadow line at its lower + /// edge. + private static func drawWall(in context: CGContext, size: CGSize) { + context.setFillColor(red: 0.86, green: 0.83, blue: 0.77, alpha: 1) + context.fill(CGRect(origin: .zero, size: size)) + let board = size.height * 0.045 + var y = board + while y < size.height { + context.setFillColor(red: 0.74, green: 0.71, blue: 0.65, alpha: 1) + context.fill(CGRect(x: 0, y: y - board * 0.08, width: size.width, height: board * 0.08)) + context.setFillColor(red: 0.91, green: 0.89, blue: 0.84, alpha: 1) + context.fill(CGRect(x: 0, y: y, width: size.width, height: board * 0.06)) + y += board + } + } + + /// The conduit that feeds the meter, from under the box to the bottom of the photo. + private static func drawConduit(in context: CGContext, below box: CGRect, size: CGSize) { + let pipe = CGRect(x: box.midX - box.width * 0.07, y: box.maxY - box.height * 0.05, width: box.width * 0.14, height: size.height - box.maxY + box.height * 0.05) + context.setFillColor(gray: 0.55, alpha: 1) + context.fill(pipe) + context.setFillColor(gray: 0.68, alpha: 1) + context.fill(CGRect(x: pipe.minX + pipe.width * 0.2, y: pipe.minY, width: pipe.width * 0.18, height: pipe.height)) + } + + /// The meter box filling `rect` (2:3), in an upright context. + static func drawMeter(in context: CGContext, rect box: CGRect) { + let w = box.width + let h = box.height + func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: box.minX + x * w, y: box.minY + y * h) } + let space = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + + // Box: brushed gray, lighter at the top, with a dark edge and a pale inner bevel. + context.saveGState() + context.addPath(boxPath(box)) + context.clip() + if let gradient = CGGradient(colorsSpace: space, colors: [CGColor(gray: 0.80, alpha: 1), CGColor(gray: 0.62, alpha: 1)] as CFArray, locations: [0, 1]) { + context.drawLinearGradient(gradient, start: point(0.5, 0), end: point(0.5, 1), options: []) + } + context.restoreGState() + context.addPath(boxPath(box.insetBy(dx: w * 0.03, dy: w * 0.03))) + context.setStrokeColor(gray: 1, alpha: 0.35) + context.setLineWidth(w * 0.008) + context.strokePath() + context.addPath(boxPath(box.insetBy(dx: w * 0.006, dy: w * 0.006))) + context.setStrokeColor(gray: 0.38, alpha: 1) + context.setLineWidth(w * 0.012) + context.strokePath() + for (x, y) in [(0.09, 0.06), (0.91, 0.06), (0.09, 0.94), (0.91, 0.94)] { + let center = point(x, y) + let r = w * 0.025 + context.setFillColor(gray: 0.5, alpha: 1) + context.fillEllipse(in: CGRect(x: center.x - r, y: center.y - r, width: 2 * r, height: 2 * r)) + context.setStrokeColor(gray: 0.3, alpha: 1) + context.setLineWidth(r * 0.35) + context.strokeLineSegments(between: [CGPoint(x: center.x - r * 0.6, y: center.y), CGPoint(x: center.x + r * 0.6, y: center.y)]) + } + + // Glass dome on a metal collar. + let dome = point(0.5, 0.36) + let collar = w * 0.44 + let glass = w * 0.40 + context.setFillColor(gray: 0.5, alpha: 1) + context.fillEllipse(in: CGRect(x: dome.x - collar, y: dome.y - collar, width: 2 * collar, height: 2 * collar)) + context.saveGState() + context.addEllipse(in: CGRect(x: dome.x - glass, y: dome.y - glass, width: 2 * glass, height: 2 * glass)) + context.clip() + if let gradient = CGGradient(colorsSpace: space, colors: [CGColor(gray: 0.99, alpha: 1), CGColor(gray: 0.86, alpha: 1)] as CFArray, locations: [0, 1]) { + context.drawRadialGradient(gradient, startCenter: CGPoint(x: dome.x - glass * 0.3, y: dome.y - glass * 0.35), startRadius: 0, endCenter: dome, endRadius: glass, options: []) + } + context.restoreGState() + + // The face: maker, practice warning, display and ratings. + let lines = PracticeMeter.printedLines + drawText(lines[0], font: "HelveticaNeue-Bold", size: w * 0.07, color: CGColor(gray: 0.12, alpha: 1), centeredAt: point(0.5, 0.228), in: context) + drawText(lines[1], font: "HelveticaNeue-Bold", size: w * 0.045, color: CGColor(srgbRed: 0.78, green: 0.30, blue: 0.05, alpha: 1), centeredAt: point(0.5, 0.27), in: context) + let display = CGRect(x: box.minX + w * 0.25, y: box.minY + h * 0.3, width: w * 0.5, height: h * 0.07) + context.setFillColor(red: 0.16, green: 0.20, blue: 0.18, alpha: 1) + context.fill(display) + drawText(lines[2], font: "Menlo-Bold", size: w * 0.055, color: CGColor(srgbRed: 0.62, green: 0.93, blue: 0.70, alpha: 1), centeredAt: CGPoint(x: display.midX, y: display.midY), in: context) + drawText(lines[3], font: "HelveticaNeue", size: w * 0.045, color: CGColor(gray: 0.25, alpha: 1), centeredAt: point(0.5, 0.435), in: context) + + // The number on its own white label under the dome, as utilities stick it on. + let label = CGRect(x: box.minX + w * 0.05, y: box.minY + h * 0.74, width: w * 0.9, height: h * 0.13) + context.addPath(CGPath(roundedRect: label, cornerWidth: w * 0.02, cornerHeight: w * 0.02, transform: nil)) + context.setFillColor(gray: 0.98, alpha: 1) + context.fillPath() + drawText(lines[4], font: "Menlo-Bold", size: w * 0.115, color: CGColor(gray: 0.08, alpha: 1), centeredAt: CGPoint(x: label.midX, y: label.midY), in: context) + } + + /// One line centered on `center`, its baseline placed so the line's ink box is centered. + private static func drawText(_ text: String, font name: String, size: CGFloat, color: CGColor, centeredAt center: CGPoint, in context: CGContext) { + let font = CTFontCreateWithName(name as CFString, size, nil) + let attributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key(kCTFontAttributeName as String): font, + NSAttributedString.Key(kCTForegroundColorAttributeName as String): color, + ] + let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: attributes)) + var ascent: CGFloat = 0 + var descent: CGFloat = 0 + let width = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, nil)) + context.textPosition = CGPoint(x: center.x - width / 2, y: center.y + (ascent - descent) / 2) + CTLineDraw(line, context) + } + + private static func jpeg(_ image: CGImage) -> Data? { + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { return nil } + CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.9] as CFDictionary) + return CGImageDestinationFinalize(destination) ? data as Data : nil + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index de442d529..b527ba078 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -8,6 +8,7 @@ import SwiftUI extension ScanEngine: ScanActions { func finishOnboarding() { guard state.phase == .onboarding else { return } + startPracticeIfOn() go(.findMeter) } diff --git a/ios/HouseScan/Runtime/ScanEngine+Practice.swift b/ios/HouseScan/Runtime/ScanEngine+Practice.swift new file mode 100644 index 000000000..d1f2396ab --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine+Practice.swift @@ -0,0 +1,36 @@ +import Foundation +import HouseScanKit + +/// Practice meter (HouseScanKit `PracticeMeter`). The meter tap is the usual one: the same raycast +/// and refusals, since they guard the wall's geometry, and the tapped spot becomes the meter's +/// anchor, where the camera view draws the sample (`PracticeMeterOverlay`). Only the close-up's +/// photo changes; its pose, depth and the view coverage takes from it stay the live frame's. +extension ScanEngine { + /// Reads the developer switch when a scan starts; the scan keeps the answer to its end. + func startPracticeIfOn() { + state.isPracticeScan = DeveloperSettings.shared.practiceMeterOn + guard state.isPracticeScan else { return } + RuntimeLog.engine.info("practice meter: a drawn sample stands in for the meter and its close-up photo") + } + + /// The frame whose photo the close-up stores and the reader reads: `frame` itself, or on a + /// practice scan `frame` with a photo of the sample in place of the camera's, at the camera + /// image's size so the frame's intrinsics still fit it. Without a photo when the sample can't + /// be drawn, so the save fails and the close-up asks for a retake rather than storing a photo + /// of whatever the camera saw. + func closeUpPhoto(_ frame: SourceFrame) async -> SourceFrame { + guard state.isPracticeScan else { return frame } + let size = frame.camera.imageSize + let jpeg = await Task.detached(priority: .userInitiated) { + SampleMeterArt.closeUpJPEG(width: Int(size.x), height: Int(size.y)) + }.value + var photo = frame + if let jpeg { + photo.jpeg = .data(jpeg) + } else { + RuntimeLog.engine.error("practice meter: the sample photo could not be drawn at \(Int(size.x))x\(Int(size.y))") + photo.jpeg = .none + } + return photo + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Stamp.swift b/ios/HouseScan/Runtime/ScanEngine+Stamp.swift index 8bff50086..2effa67f4 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Stamp.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Stamp.swift @@ -16,7 +16,8 @@ extension ScanEngine { // Written into the built Info.plist by the "Stamp the git commit" build phase. commit: info["HouseScanGitCommit"] as? String ?? "unknown"), server: .init(url: (resultClient as? HTTPResultClient)?.serverURL.absoluteString), - answer: answer.map { ScanStamp.Answer($0, sample: resultClient.isSample) }) + answer: answer.map { ScanStamp.Answer($0, sample: resultClient.isSample) }, + practice: state.isPracticeScan) let file = store.directory.appending(path: ScanStamp.fileName) do { try stamp.jsonData().write(to: file, options: .atomic) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 438ad6ae4..6d9ed5825 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -511,7 +511,10 @@ final class ScanEngine { Task { // The task can start after a reset or after the flow left the close-up. guard scan == generation, state.phase == .meterCloseUp else { return } - let saved = await store.saveStill(frame, name: "meter_close.jpg") + let photo = await closeUpPhoto(frame) + // Drawing a practice photo suspends: a reset meanwhile must not save into the new scan. + guard scan == generation, state.phase == .meterCloseUp else { return } + let saved = await store.saveStill(photo, name: "meter_close.jpg") guard scan == generation, state.phase == .meterCloseUp else { return } if !saved { retakeCloseUp(.blurry) @@ -1653,6 +1656,7 @@ final class ScanEngine { state.meterNumber = nil closeUpRetake = nil meterReadout = nil + state.isPracticeScan = false go(.onboarding) replay?.show(index: 0) } diff --git a/ios/HouseScan/UI/Components/PhotoCounter.swift b/ios/HouseScan/UI/Components/PhotoCounter.swift index f2d72420d..9e0615bd2 100644 --- a/ios/HouseScan/UI/Components/PhotoCounter.swift +++ b/ios/HouseScan/UI/Components/PhotoCounter.swift @@ -39,11 +39,31 @@ struct PhotoCounter: View { /// quiet pill rather than an amber one: amber means "look at this" on the camera screens, and a /// test mode shouldn't compete with the instruction. Ink at 0.7 keeps Chalk text about 6.9:1 on /// the light Canvas; at 0.55 it measured about 4.2:1, under the 4.5:1 small text needs. +/// +/// "Practice meter" beside it stays amber: the meter and its number are a drawn sample, which +/// is worth looking at, like the result's "Sample result" badge. struct ModeBadge: View { var isReplay: Bool var isAutopilot: Bool + /// Set once for every screen by `ScanRootView`, so each badge says a practice scan is one. + @Environment(\.isPracticeScan) private var isPracticeScan var body: some View { + // Side by side, or stacked when the largest text sizes leave no room for both. + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { capsules } + VStack(alignment: .leading, spacing: 4) { capsules } + } + } + + @ViewBuilder + private var capsules: some View { + if isPracticeScan { + // Its own capsule, first: of everything on the screen, this is what says the meter + // and its number aren't real. + capsule("Practice meter") + .accessibilityIdentifier("practiceBadge") + } if let text { Text(text) .font(.caption2.weight(.medium)) @@ -56,6 +76,19 @@ struct ModeBadge: View { } } + /// The practice badge: amber, bold capitals. + private func capsule(_ text: String) -> some View { + Text(text) + .font(.caption2.weight(.bold)) + .textCase(.uppercase) + .tracking(0.6) + .foregroundStyle(Palette.ink) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Palette.caution, in: .capsule) + .accessibilityLabel(text) + } + private var text: String? { switch (isReplay, isAutopilot) { case (true, true): "Replay · Autopilot" diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 725f7c823..f5d4ce14b 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -14,6 +14,13 @@ struct Instruction: Hashable { enum ScanCopy { // MARK: Guidance + /// Finding the meter on a practice scan (`ScanViewState.isPracticeScan`): any wall will do. + static let practiceFindMeter = Instruction( + title: "Tap a spot on a wall", + detail: "A sample meter goes there, so you can practice the scan without a real one." + ) + static let practiceMarkMeter = "Put the sample meter here" + static func guidance(_ step: GuidanceStep) -> Instruction { switch step { case .findMeter: diff --git a/ios/HouseScan/UI/Practice/DeveloperOptions.swift b/ios/HouseScan/UI/Practice/DeveloperOptions.swift new file mode 100644 index 000000000..6eb0dacb0 --- /dev/null +++ b/ios/HouseScan/UI/Practice/DeveloperOptions.swift @@ -0,0 +1,84 @@ +import HouseScanKit +import SwiftUI + +/// The way into the developer options, on the first screen. Only development and TestFlight +/// installs show it (`DeveloperSettings.isAvailable`); an App Store install shows nothing here. +/// While practice meter is on it says so, so nobody starts a scan without knowing. +struct DeveloperOptionsButton: View { + @AppStorage(DeveloperSettings.practiceMeterKey) private var practiceMeter = false + @State private var showing = false + private let settings = DeveloperSettings.shared + + var body: some View { + if settings.isAvailable { + Button { + showing = true + } label: { + HStack(spacing: 6) { + Image(systemName: "wrench.and.screwdriver.fill") + if practiceMeter { + // Wraps at the largest text sizes rather than dropping out. + Text("Practice meter on") + .fixedSize(horizontal: false, vertical: true) + } + } + .font(Typeface.caption) + .foregroundStyle(practiceMeter ? Palette.ink : Palette.muted) + .padding(.horizontal, practiceMeter ? 10 : 0) + .padding(.vertical, 5) + .background { + if practiceMeter { Capsule().fill(Palette.caution) } + } + .frame(minWidth: Metrics.minTarget, minHeight: Metrics.minTarget) + .contentShape(.rect) + } + .buttonStyle(PressableStyle()) + .accessibilityLabel(practiceMeter ? "Developer options. Practice meter is on." : "Developer options") + .accessibilityIdentifier("action.developerOptions") + .sheet(isPresented: $showing) { + DeveloperOptionsSheet(environment: settings.environment) + } + } + } +} + +/// One switch for now: practice meter. +private struct DeveloperOptionsSheet: View { + var environment: PracticeMeter.InstallEnvironment + @AppStorage(DeveloperSettings.practiceMeterKey) private var practiceMeter = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section { + Toggle("Practice meter", isOn: $practiceMeter) + .accessibilityIdentifier("developer.practiceMeter") + } footer: { + Text("For trying the scan where there's no electric meter. Tap any spot on a wall and a sample meter is drawn there. A photo of it stands in for the meter close-up, and the rest of the scan runs for real. Each screen and the scan's stamp say it's practice. Starts with the next scan.") + } + Section { + LabeledContent("Build", value: Self.name(environment)) + } + } + .navigationTitle("Developer options") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .accessibilityIdentifier("action.closeDeveloperOptions") + } + } + } + .presentationDetents([.medium, .large]) + } + + private static func name(_ environment: PracticeMeter.InstallEnvironment) -> String { + switch environment { + case .development: "Development" + case .testFlight: "TestFlight" + case .appStore: "App Store" + case .unknown: "Unknown" + } + } +} diff --git a/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift b/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift new file mode 100644 index 000000000..6d63127a6 --- /dev/null +++ b/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift @@ -0,0 +1,73 @@ +import HouseScanKit +import simd +import SwiftUI + +/// On a practice scan, the sample meter drawn on the wall at the tapped spot, in perspective, on +/// every camera screen after the tap. It follows `state.wall.meter`, which the engine moves with +/// the meter's anchor as ARKit corrects it, so the drawing stays where the anchor is, as the fog +/// and marks do. Painted just above the camera image, so the fog and marks draw over it as they +/// would over a real meter. +struct PracticeMeterOverlay: View { + let state: ScanViewState + + var body: some View { + GeometryReader { proxy in + // Hidden while tracking isn't normal, like the other overlays: from a pose the phone + // doesn't trust, it would sit in the wrong place. + if state.tracking == .normal, let face = SampleMeterArt.face, let placed = placement(in: proxy.size) { + ZStack(alignment: .topLeading) { + Image(decorative: face, scale: 1) + .resizable() + .frame(width: CGFloat(face.width), height: CGFloat(face.height)) + .projectionEffect(placed.transform) + .accessibilityHidden(true) + // The camera shows a real meter to anyone who can see it; this one exists only + // in the app, so VoiceOver finds it where it is drawn. + Color.clear + .frame(width: placed.bounds.width, height: placed.bounds.height) + .position(x: placed.bounds.midX, y: placed.bounds.midY) + .accessibilityElement() + .accessibilityLabel("Sample meter") + .accessibilityAddTraits(.isImage) + .accessibilityIdentifier("practiceMeter") + } + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .topLeading) + } + } + .ignoresSafeArea() + .allowsHitTesting(false) + } + + /// The transform that takes the face image's rectangle onto the sample's four corners on + /// screen, and the corners' bounding box; nil when the wall isn't set yet or any corner is + /// behind the camera. + private func placement(in size: CGSize) -> (transform: ProjectionTransform, bounds: CGRect)? { + guard let wall = state.wall, let projection = state.projection, let face = SampleMeterArt.face else { return nil } + var quad: [SIMD2] = [] + for corner in PracticeMeter.plateCorners(meter: wall.meter, along: wall.along, outward: wall.outward) { + guard let point = projection.viewPoint(for: corner, in: size) else { return nil } + quad.append(SIMD2(Double(point.x), Double(point.y))) + } + guard let m = QuadHomography.mapping(width: Double(face.width), height: Double(face.height), to: quad) else { return nil } + // `m[column][row]`; ProjectionTransform names its entries row first. + var transform = ProjectionTransform() + transform.m11 = m[0][0] + transform.m12 = m[1][0] + transform.m13 = m[2][0] + transform.m21 = m[0][1] + transform.m22 = m[1][1] + transform.m23 = m[2][1] + transform.m31 = m[0][2] + transform.m32 = m[1][2] + transform.m33 = m[2][2] + let xs = quad.map(\.x) + let ys = quad.map(\.y) + let bounds = CGRect(x: xs.min() ?? 0, y: ys.min() ?? 0, width: (xs.max() ?? 0) - (xs.min() ?? 0), height: (ys.max() ?? 0) - (ys.min() ?? 0)) + return (transform, bounds) + } +} + +extension EnvironmentValues { + /// True on every screen of a practice scan (`ScanViewState.isPracticeScan`). + @Entry var isPracticeScan = false +} diff --git a/ios/HouseScan/UI/ScanRootView.swift b/ios/HouseScan/UI/ScanRootView.swift index 2d58932cc..9cc143054 100644 --- a/ios/HouseScan/UI/ScanRootView.swift +++ b/ios/HouseScan/UI/ScanRootView.swift @@ -21,6 +21,7 @@ struct ScanRootView: View { .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.screen, value: state.phase) .preferredColorScheme(Self.showsCamera(state.phase) ? .dark : nil) .modifier(ScanHaptics(state: state)) + .environment(\.isPracticeScan, state.isPracticeScan) } private var stack: some View { @@ -28,6 +29,9 @@ struct ScanRootView: View { if Self.showsCamera(state.phase) { CameraBackdrop(feed: state.feed, actions: actions) .transition(.opacity) + if state.isPracticeScan { + PracticeMeterOverlay(state: state) + } CameraEdgeShade() } // Camera screens crossfade over the live feed. The light screens cut: a crossfade diff --git a/ios/HouseScan/UI/Screens/FindMeterScreen.swift b/ios/HouseScan/UI/Screens/FindMeterScreen.swift index ccaf76cba..a949545dd 100644 --- a/ios/HouseScan/UI/Screens/FindMeterScreen.swift +++ b/ios/HouseScan/UI/Screens/FindMeterScreen.swift @@ -32,10 +32,12 @@ struct FindMeterScreen: View { ripple(at: CGPoint(x: cameraSize.width / 2, y: cameraSize.height / 2)) actions.markMeter(at: nil, viewSize: cameraSize) } label: { - Label("This is my meter", systemImage: "mappin.and.ellipse") + Label(state.isPracticeScan ? ScanCopy.practiceMarkMeter : "This is my meter", systemImage: "mappin.and.ellipse") } .buttonStyle(.primary) - .accessibilityHint("Pins your electric meter at the circle in the middle of the screen") + .accessibilityHint(state.isPracticeScan + ? "Puts the sample meter on the wall at the circle in the middle of the screen" + : "Pins your electric meter at the circle in the middle of the screen") .accessibilityIdentifier("action.markMeter") } } @@ -44,7 +46,7 @@ struct FindMeterScreen: View { private var instruction: Instruction { if let coaching = state.coaching { return ScanCopy.coaching(coaching) } if state.guidance == .aimAtWallForMeter { return ScanCopy.guidance(.aimAtWallForMeter) } - return ScanCopy.guidance(.findMeter) + return state.isPracticeScan ? ScanCopy.practiceFindMeter : ScanCopy.guidance(.findMeter) } private var tone: InstructionCard.Tone { diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index 86bc4bd51..60bf4c97b 100644 --- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift +++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift @@ -36,6 +36,7 @@ struct OnboardingScreen: View { VStack(spacing: 0) { HStack { ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot) + DeveloperOptionsButton() Spacer() if page < pages.count - 1 { Button("Skip") { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/QuadHomography.swift b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/QuadHomography.swift new file mode 100644 index 000000000..1b9a19059 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/QuadHomography.swift @@ -0,0 +1,54 @@ +import Foundation +import simd + +/// The perspective transform that takes a `width` by `height` rectangle onto a quadrilateral, +/// for drawing a flat picture onto a wall seen at an angle. Heckbert's square-to-quad mapping +/// ("Fundamentals of Texture Mapping and Image Warping", 1989, section 2.2.3) with the rectangle +/// scaled to the unit square first. +/// +/// The matrix is for row vectors, as SwiftUI's `ProjectionTransform` and `CGAffineTransform` +/// use: `[x y 1] * m` gives `[X Y W]`, and the point is `(X / W, Y / W)`. Its columns are +/// `m.columns.0` = (m11, m21, m31) and so on, so `m[column][row]`. +public enum QuadHomography { + /// Corners are the rectangle's top left (0, 0), top right (width, 0), bottom right + /// (width, height) and bottom left (0, height), in that order. Nil when the rectangle is empty + /// or the quad is degenerate (three corners in a line), where no such transform exists. + public static func mapping(width: Double, height: Double, to quad: [SIMD2]) -> simd_double3x3? { + guard quad.count == 4, width > 0, height > 0 else { return nil } + let (p0, p1, p2, p3) = (quad[0], quad[1], quad[2], quad[3]) + let sum = p0 - p1 + p2 - p3 + var g = 0.0 + var h = 0.0 + if abs(sum.x) > 1e-12 || abs(sum.y) > 1e-12 { + let d1 = p1 - p2 + let d2 = p3 - p2 + let det = d1.x * d2.y - d2.x * d1.y + guard abs(det) > 1e-12 else { return nil } + g = (sum.x * d2.y - d2.x * sum.y) / det + h = (d1.x * sum.y - sum.x * d1.y) / det + } + // Unit square (u, v) to the quad: X = a u + b v + c, Y = d u + e v + f, W = g u + h v + 1. + let a = p1.x - p0.x + g * p1.x + let b = p3.x - p0.x + h * p3.x + let d = p1.y - p0.y + g * p1.y + let e = p3.y - p0.y + h * p3.y + let unit = simd_double3x3(rows: [ + SIMD3(a, d, g), + SIMD3(b, e, h), + SIMD3(p0.x, p0.y, 1), + ]) + guard abs(unit.determinant) > 1e-12 else { return nil } + // u = x / width, v = y / height, applied first. + let scale = simd_double3x3(diagonal: SIMD3(1 / width, 1 / height, 1)) + return scale * unit + } + + /// Where `m` sends the point (x, y); nil when it goes to infinity. + public static func apply(_ m: simd_double3x3, to point: SIMD2) -> SIMD2? { + let row = SIMD3(point.x, point.y, 1) + // Row vector times matrix: component i is the dot product with column i. + let out = SIMD3(simd_dot(row, m.columns.0), simd_dot(row, m.columns.1), simd_dot(row, m.columns.2)) + guard abs(out.z) > 1e-12 else { return nil } + return SIMD2(out.x / out.z, out.y / out.z) + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Practice/PracticeMeter.swift b/ios/HouseScanKit/Sources/HouseScanKit/Practice/PracticeMeter.swift new file mode 100644 index 000000000..1b1ebdcf2 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Practice/PracticeMeter.swift @@ -0,0 +1,74 @@ +import Foundation +import simd + +/// Practice meter: a scan with a drawn sample meter standing in for the electric meter, so the +/// whole flow can be tried indoors, where there is no meter. The homeowner taps any spot on a +/// wall, the sample is drawn there, and the close-up's photo is a picture of the sample while its +/// pose stays the live camera's. Everything after the close-up runs as in a real scan. +/// +/// Only development and TestFlight installs may turn it on. An App Store install never can, even +/// when the stored setting says so: a scan of a fake meter must not reach a real homeowner. +public enum PracticeMeter { + /// Where this copy of the app came from, which decides whether practice meter is offered. + public enum InstallEnvironment: String, Sendable, Equatable, CaseIterable { + /// A Debug build, or one Xcode installed with StoreKit testing. + case development + /// A TestFlight build: StoreKit's app transaction comes from the sandbox. + case testFlight + /// An App Store build: the app transaction comes from production. + case appStore + /// Not determined yet, or StoreKit couldn't say. Treated like the App Store. + case unknown + } + + /// Whether the switch is shown at all. + public static func isAvailable(in environment: InstallEnvironment) -> Bool { + switch environment { + case .development, .testFlight: true + case .appStore, .unknown: false + } + } + + /// Whether a scan started now is a practice scan: the stored switch, and only where the + /// switch is offered. A setting left on by a TestFlight build is ignored once the same install + /// is updated from the App Store. + public static func isOn(requested: Bool, in environment: InstallEnvironment) -> Bool { + requested && isAvailable(in: environment) + } + + // MARK: The sample meter + + /// The drawn meter's size on the wall, meters: about a US socket meter's box, 8 by 12 in. The + /// close-up gate's framing checks were set for a real meter, so the stand-in is the same size. + public static let plateSize = SIMD2(0.20, 0.30) + /// How far in front of the wall the drawing sits: 5 mm, so the fog and the wall's own + /// outline don't cut through it. The server never sees it. + public static let plateStandOff: Float = 0.005 + + /// The drawn meter's corners in world meters, centered on the tapped point and upright on the + /// wall: top left, top right, bottom right, bottom left, as someone facing the wall sees them. + /// `along` is +x of the meter frame (to the right when facing the wall) and `outward` its +z. + public static func plateCorners(meter: SIMD3, along: SIMD3, outward: SIMD3) -> [SIMD3] { + let up = SIMD3(0, 1, 0) + let right = simd_normalize(along) * plateSize.x / 2 + let top = up * plateSize.y / 2 + let center = meter + simd_normalize(outward) * plateStandOff + return [center - right + top, center + right + top, center + right - top, center - right - top] + } + + /// The made-up maker printed on the sample. No real maker's name is one edit from it, so the + /// brand reader names none (`MeterBrand.read`). + public static let brand = "SAMPLEWORKS" + /// The sample's meter number: plainly not a real one. + public static let number = "12345678" + + /// Every line of text on the sample, top to bottom, as the close-up photo prints it. The + /// drawing prints exactly these strings; a test runs them through the reader's ranking. + public static let printedLines: [String] = [ + brand, + "PRACTICE ONLY", + "0042.7 kWh", + "CL200 240V FORM 2S", + "No. \(number)", + ] +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanStamp.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanStamp.swift index dadd0ce5a..cdcf8d0c6 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanStamp.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanStamp.swift @@ -61,13 +61,18 @@ public struct ScanStamp: Codable, Sendable, Equatable { public var server: Server /// The latest answer for this scan; nil before one arrives. public var answer: Answer? + /// True for a practice scan (`PracticeMeter`): a drawn sample stood in for the electric + /// meter and its close-up photo, so the meter position and number describe no real meter. + /// The packet and scene.json don't say so; this file does. + public var practice: Bool public static let fileName = "scan-stamp.json" - public init(app: App, server: Server, answer: Answer? = nil) { + public init(app: App, server: Server, answer: Answer? = nil, practice: Bool = false) { self.app = app self.server = server self.answer = answer + self.practice = practice } /// Sorted keys, and a missing answer, URL or version written as null, so the file always has @@ -83,10 +88,11 @@ public struct ScanStamp: Codable, Sendable, Equatable { try c.encode(app, forKey: .app) try c.encode(server, forKey: .server) try c.encode(answer, forKey: .answer) + try c.encode(practice, forKey: .practice) } enum CodingKeys: String, CodingKey { - case app, server, answer + case app, server, answer, practice } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PracticeMeterTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PracticeMeterTests.swift new file mode 100644 index 000000000..e9f72cfc7 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PracticeMeterTests.swift @@ -0,0 +1,120 @@ +import Foundation +import simd +import Testing +@testable import HouseScanKit + +@Suite struct PracticeMeterTests { + // MARK: Who may turn it on + + /// Only development and TestFlight installs offer the switch; an App Store install, or one + /// StoreKit couldn't place, never does. + @Test(arguments: PracticeMeter.InstallEnvironment.allCases) + func onlyDevelopmentAndTestFlightOfferTheSwitch(environment: PracticeMeter.InstallEnvironment) { + let offered: Set = [.development, .testFlight] + #expect(PracticeMeter.isAvailable(in: environment) == offered.contains(environment)) + } + + /// A switch left on by a TestFlight build does nothing once the install comes from the App + /// Store, and nothing while the environment is still unknown. + @Test func aStoredSwitchIsIgnoredOutsideDevelopmentAndTestFlight() { + #expect(!PracticeMeter.isOn(requested: true, in: .appStore)) + #expect(!PracticeMeter.isOn(requested: true, in: .unknown)) + #expect(PracticeMeter.isOn(requested: true, in: .testFlight)) + #expect(PracticeMeter.isOn(requested: true, in: .development)) + #expect(!PracticeMeter.isOn(requested: false, in: .development)) + } + + // MARK: The drawn meter + + /// Centered on the tap, upright, the size of a meter box, and just in front of the wall. + @Test func theSampleStandsUprightOnTheWallAtTheTap() throws { + let meter = SIMD3(1, 1.5, -2) + // A wall facing +x: someone facing it looks along -x, and their right is -z. + let outward = SIMD3(1, 0, 0) + let along = simd_normalize(simd_cross(-outward, SIMD3(0, 1, 0))) + #expect(simd_distance(along, SIMD3(0, 0, -1)) < 1e-6) + let corners = PracticeMeter.plateCorners(meter: meter, along: along, outward: outward) + try #require(corners.count == 4) + let (topLeft, topRight, bottomRight, bottomLeft) = (corners[0], corners[1], corners[2], corners[3]) + let center = corners.reduce(SIMD3.zero, +) / 4 + #expect(simd_distance(center, meter + outward * PracticeMeter.plateStandOff) < 1e-6) + // Top above bottom by the plate's height; right is +along from left by its width. + #expect(abs((topLeft.y - bottomLeft.y) - PracticeMeter.plateSize.y) < 1e-6) + #expect(simd_distance(topRight - topLeft, along * PracticeMeter.plateSize.x) < 1e-6) + #expect(simd_distance(bottomRight - bottomLeft, along * PracticeMeter.plateSize.x) < 1e-6) + // Every corner sits the stand-off in front of the wall's plane through the tap. + for corner in corners { + #expect(abs(simd_dot(corner - meter, outward) - PracticeMeter.plateStandOff) < 1e-6) + } + } + + // MARK: What the reader makes of it + + /// The sample's printed lines as Vision would return them, top to bottom, with the number on + /// its own label: the ranking offers the sample's number first, the size check passes it on a + /// replay-sized photo (640 px tall upright), and no real maker is named. + @Test func theReaderOffersTheSampleNumberFirstAndNamesNoMaker() throws { + let heights: [Double] = [0.045, 0.025, 0.035, 0.022, 0.070] + try #require(heights.count == PracticeMeter.printedLines.count) + var y = 0.15 + let lines = zip(PracticeMeter.printedLines, heights).map { text, height -> MeterTextLine in + defer { y += height + 0.04 } + return MeterTextLine(text: text, box: MeterBox(x: 0.25, y: y, width: 0.5, height: height)) + } + let found = MeterNumberRanking.candidates(lines: lines, barcodePayloads: []) + let choices = try #require(MeterPhotoChecks.choices(from: found, photoHeight: 640)) + #expect(choices.candidates.first?.core == PracticeMeter.number) + #expect(!choices.numberTooSmall) + #expect(MeterBrand.read(lines) == nil) + #expect(MeterBrand.match(PracticeMeter.brand) == nil) + } + + // MARK: Drawing it in perspective + + @Test func theHomographySendsTheRectangleCornersToTheQuad() throws { + let quad: [SIMD2] = [SIMD2(120, 80), SIMD2(310, 110), SIMD2(290, 420), SIMD2(100, 380)] + let m = try #require(QuadHomography.mapping(width: 400, height: 600, to: quad)) + let rectangle: [SIMD2] = [SIMD2(0, 0), SIMD2(400, 0), SIMD2(400, 600), SIMD2(0, 600)] + for (corner, target) in zip(rectangle, quad) { + let mapped = try #require(QuadHomography.apply(m, to: corner)) + #expect(simd_distance(mapped, target) < 1e-9, "\(corner) went to \(mapped), not \(target)") + } + // A perspective map sends the rectangle's center to where the quad's diagonals cross. + let center = try #require(QuadHomography.apply(m, to: SIMD2(200, 300))) + let crossing = try #require(Self.intersection(quad[0], quad[2], quad[1], quad[3])) + #expect(simd_distance(center, crossing) < 1e-9) + } + + @Test func aParallelogramNeedsNoPerspective() throws { + let quad: [SIMD2] = [SIMD2(10, 10), SIMD2(110, 30), SIMD2(130, 230), SIMD2(30, 210)] + let m = try #require(QuadHomography.mapping(width: 100, height: 200, to: quad)) + #expect(abs(m.columns.2.x) < 1e-12 && abs(m.columns.2.y) < 1e-12 && m.columns.2.z == 1) + #expect(try simd_distance(#require(QuadHomography.apply(m, to: SIMD2(100, 200))), quad[2]) < 1e-9) + } + + @Test func aFlattenedQuadHasNoMapping() { + let line: [SIMD2] = [SIMD2(0, 0), SIMD2(1, 1), SIMD2(2, 2), SIMD2(3, 3)] + #expect(QuadHomography.mapping(width: 1, height: 1, to: line) == nil) + #expect(QuadHomography.mapping(width: 0, height: 1, to: [SIMD2(0, 0), SIMD2(1, 0), SIMD2(1, 1), SIMD2(0, 1)]) == nil) + } + + // MARK: The stamp + + /// A practice scan says so in its stamp, and every stamp has the field. + @Test func theStampSaysWhetherTheScanWasPractice() throws { + let app = ScanStamp.App(version: "0.1.0", build: "1", commit: "abc123def456") + let practice = try JSONSchemaValidator.Value.parse(ScanStamp(app: app, server: .init(url: nil), practice: true).jsonData()) + #expect(practice["practice"] == .bool(true)) + let real = try JSONSchemaValidator.Value.parse(ScanStamp(app: app, server: .init(url: nil)).jsonData()) + #expect(real["practice"] == .bool(false)) + } + + private static func intersection(_ a: SIMD2, _ b: SIMD2, _ c: SIMD2, _ d: SIMD2) -> SIMD2? { + let r = b - a + let s = d - c + let denominator = r.x * s.y - r.y * s.x + guard abs(denominator) > 1e-12 else { return nil } + let t = ((c - a).x * s.y - (c - a).y * s.x) / denominator + return a + r * t + } +} diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index afc9a8e38..582b549e7 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -216,6 +216,113 @@ final class FullFlowUITests: XCTestCase { } } + /// Practice meter on the synthetic replay: the switch is turned on in the developer options on + /// the first screen, the close-up stores the drawn sample's photo and the app's own reader + /// reads its number, which the autopilot confirms, and the rest of the flow runs to the AR + /// result. Every screen after the first carries the "Practice meter" badge. Afterwards Start + /// over clears the badge. The switch is turned off again for the tests that follow, also when + /// this one fails partway. + @MainActor + func testPracticeMeterFromReplay() throws { + addTeardownBlock { @MainActor in + let demo = XCUIApplication() + demo.launchArguments = ["-uiDemo"] + demo.launch() + XCTAssertTrue(demo.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 15)) + Self.setPracticeMeter(false, in: demo) + demo.terminate() + } + var readNumber: String? + var unbadged: [String] = [] + var drawn: CGRect? + var app: XCUIApplication? + try runFlow(replay: Self.fixture, practice: true, onAppear: { running, phase in + // The close-up replays the recording from the start, and the replay runs on at 3x + // while this polls, so which frame shows the sample first varies from run to run (runs + // 36310614834 and 36320980419 caught it on walk frames, high and off to the right). The + // check is that it is drawn on screen, the plate's shape; where the corners land is + // HouseScanKit's PracticeMeterTests. + guard phase == "meterCloseUp" else { return } + let sample = running.descendants(matching: .any)["practiceMeter"] + let screen = running.frame + let deadline = Date().addingTimeInterval(10) + repeat { + if let frame = ElementRead.snapshot(sample)?.frame, frame.width > 40, screen.contains(CGPoint(x: frame.midX, y: frame.midY)) { + drawn = frame + let shot = XCTAttachment(screenshot: running.screenshot()) + shot.name = "meterCloseUp-sampleMeter" + shot.lifetime = .keepAlways + self.add(shot) + break + } + Thread.sleep(forTimeInterval: 0.1) + } while Date() < deadline + }, beforeLeaving: { running, phase in + app = running + if phase == "onboarding" { + Self.setPracticeMeter(true, in: running) + return + } + if !running.descendants(matching: .any)["practiceBadge"].exists { unbadged.append(phase) } + if phase == "meterCloseUp" { + // Offered as a candidate, or already confirmed ("Meter number saved"): either way + // the reader read it from the sample's photo. + let number = running.descendants(matching: .any) + .matching(NSPredicate(format: "label CONTAINS %@", Self.sampleNumber)).firstMatch + readNumber = number.waitForExistence(timeout: 30) ? number.label : nil + } + }) + XCTAssertNotNil(readNumber, "the reader never offered the sample meter's number \(Self.sampleNumber)") + XCTAssertEqual(unbadged, [], "screens without the Practice meter badge") + let sample = try XCTUnwrap(drawn, "the sample meter was never drawn on screen at the close-up") + // A 0.20 by 0.30 m plate seen nearly square on: about 2:3, whatever the distance. + XCTAssertEqual(sample.width / sample.height, 2.0 / 3.0, accuracy: 0.12, "sample meter drawn \(sample.width) by \(sample.height)") + + let running = try XCTUnwrap(app) + // Start over sits under Details, last, as ScreenStatesUITests reaches it. + let details = running.descendants(matching: .any)["result.details"].firstMatch + XCTAssertTrue(details.waitForExistence(timeout: 20), "no Details on the result") + details.tap() + let startOver = running.descendants(matching: .any)["action.startOver"].firstMatch + XCTAssertTrue(startOver.waitForExistence(timeout: 10), "no Start over under Details") + running.swipeUp() + running.swipeUp() + startOver.tap() + XCTAssertTrue(running.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 15)) + XCTAssertFalse(running.descendants(matching: .any)["practiceBadge"].exists, "the badge outlived the practice scan") + } + + /// `PracticeMeter.number` in HouseScanKit, which the test bundle doesn't link. + static let sampleNumber = "12345678" + + /// Opens the developer options from the first screen and sets the practice meter switch. + @MainActor + static func setPracticeMeter(_ on: Bool, in app: XCUIApplication) { + let open = app.buttons["action.developerOptions"] + XCTAssertTrue(open.waitForExistence(timeout: 10), "no developer options on the first screen") + open.tap() + let row = app.switches["developer.practiceMeter"] + XCTAssertTrue(row.waitForExistence(timeout: 10), "the developer options have no practice meter switch") + let wanted = on ? "1" : "0" + let set = NSPredicate(format: "value == %@", wanted) + // The sheet slides up first; a tap while it moves can miss. The row's own center is its + // label, which doesn't flip it, so the tap goes to the switch at the row's trailing end, + // and once more if the first didn't take (run 36315397676 missed one). + let settled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: row) + XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 10), .completed, "the practice meter switch never became tappable") + for _ in 0..<2 where row.value as? String != wanted { + row.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap() + _ = XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: set, object: row)], timeout: 3) + } + XCTAssertEqual(row.value as? String, wanted, "the switch didn't turn \(on ? "on" : "off")") + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = "developerOptions-\(on ? "on" : "off")" + shot.lifetime = .keepAlways + XCTContext.runActivity(named: shot.name ?? "") { $0.add(shot) } + app.buttons["action.closeDeveloperOptions"].tap() + XCTAssertTrue(app.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 10)) + } + /// Waits for the wall map's accessibility summary to mention hidden cells; false on timeout. @MainActor private static func wallTapeShowsHidden(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { @@ -272,9 +379,18 @@ final class FullFlowUITests: XCTestCase { /// `beforeLeaving` runs on each screen after its screenshot and audit, while the app still /// waits to leave it. `onScene` gets the scene.json the autopilot leaves in the gate folder /// once the result shows, and the gate folder for the other files it leaves there. + /// + /// `onAppear` runs as soon as a screen appears, before it settles, for what only its first + /// moments show. + /// + /// The practice meter switch is stored in the app's settings, which outlive a run in the + /// Simulator. Unless `practice` is set, `-practiceMeter NO` holds it off for this run whatever + /// an earlier test left there; with it, the test sets the switch itself. @MainActor private func runFlow( - replay: String, extraArguments: [String] = [], beforeLeaving: (XCUIApplication, String) -> Void = { _, _ in }, + replay: String, extraArguments: [String] = [], practice: Bool = false, + onAppear: (XCUIApplication, String) -> Void = { _, _ in }, + beforeLeaving: (XCUIApplication, String) -> Void = { _, _ in }, onScene: ((Data, URL) throws -> Void)? = nil ) throws { let app = XCUIApplication() @@ -284,6 +400,7 @@ final class FullFlowUITests: XCTestCase { try FileManager.default.createDirectory(at: gate, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: gate) } var arguments = ["-replay", replay, "-autopilot", "-autopilotHold", "1.5", "-autopilotGate", gate.path] + extraArguments + if !practice { arguments += ["-practiceMeter", "NO"] } if let server = Self.environment["HOUSESCAN_SERVER_URL"], !server.isEmpty { arguments += ["-serverURL", server] } else { @@ -298,6 +415,7 @@ final class FullFlowUITests: XCTestCase { // The walk replays the whole recording; everything else takes seconds. let timeout: TimeInterval = phase == "markFeatures" || phase == "result" ? 150 : 60 XCTAssertTrue(screen.waitForExistence(timeout: timeout), "screen.\(phase) never appeared") + onAppear(app, phase) // Let the entrance animation finish so the screenshot and audit see the settled screen. Thread.sleep(forTimeInterval: 1.0) let shot = XCTAttachment(screenshot: app.screenshot()) diff --git a/ios/HouseScanUITests/PracticeMeterUITests.swift b/ios/HouseScanUITests/PracticeMeterUITests.swift new file mode 100644 index 000000000..2c0b067d1 --- /dev/null +++ b/ios/HouseScanUITests/PracticeMeterUITests.swift @@ -0,0 +1,44 @@ +import XCTest + +/// An App Store install can't reach practice meter. `-simulateAppStore` runs the Debug build as +/// an App Store install would, and `-practiceMeter YES` stands in for a switch a TestFlight build +/// left on before the App Store update. The first screen offers no developer options, and the +/// scan is a real one: no badge, the real meter's words, and the close-up reads the replay's own +/// photo, whose painted meter has no number, so it asks for a retake instead of offering the +/// sample's number. +final class PracticeMeterUITests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + @MainActor + func testAppStoreInstallHasNoPracticeMeter() throws { + let app = XCUIApplication() + app.launchArguments = ["-replay", FullFlowUITests.fixture, "-sampleResult", "-simulateAppStore", "-practiceMeter", "YES"] + app.launch() + let any = app.descendants(matching: .any) + + XCTAssertTrue(any["screen.onboarding"].waitForExistence(timeout: 30)) + // The Debug build shows the entry at once; give an App Store one the same chance. + XCTAssertFalse(app.buttons["action.developerOptions"].waitForExistence(timeout: 3), "an App Store install offers the developer options") + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = "appStore-onboarding" + shot.lifetime = .keepAlways + add(shot) + + if app.buttons["action.onboardingSkip"].waitForExistence(timeout: 5) { app.buttons["action.onboardingSkip"].tap() } + XCTAssertTrue(app.buttons["action.finishOnboarding"].waitForExistence(timeout: 10)) + app.buttons["action.finishOnboarding"].tap() + XCTAssertTrue(any["screen.findMeter"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["action.markMeter"].waitForExistence(timeout: 10)) + XCTAssertEqual(app.buttons["action.markMeter"].label, "This is my meter") + XCTAssertFalse(any["practiceBadge"].exists, "the find-meter screen says practice meter") + + app.buttons["action.markMeter"].tap() + XCTAssertTrue(any["screen.meterCloseUp"].waitForExistence(timeout: 15)) + XCTAssertTrue(any["closeUp.problem"].waitForExistence(timeout: 60), "the close-up never asked for a retake of the replay's unreadable meter") + let sample = any.matching(NSPredicate(format: "label CONTAINS %@", FullFlowUITests.sampleNumber)).firstMatch + XCTAssertFalse(sample.exists, "the close-up read the sample meter's number on an App Store install") + XCTAssertFalse(any["practiceBadge"].exists, "the close-up says practice meter") + } +} diff --git a/ios/HouseScanUITests/SourceRestartUITests.swift b/ios/HouseScanUITests/SourceRestartUITests.swift index a11f0db2e..da86e7d43 100644 --- a/ios/HouseScanUITests/SourceRestartUITests.swift +++ b/ios/HouseScanUITests/SourceRestartUITests.swift @@ -19,7 +19,9 @@ final class SourceRestartUITests: XCTestCase { try Data("{}".utf8).write(to: folder.appending(path: "session.json")) let app = XCUIApplication() - app.launchArguments = ["-replay", folder.path, "-sampleResult"] + // The practice meter switch can be left on by an earlier test; this one needs the real + // close-up, whose unreadable photo asks for a retake. + app.launchArguments = ["-replay", folder.path, "-sampleResult", "-practiceMeter", "NO"] app.launch() let any = app.descendants(matching: .any) XCTAssertTrue(any["screen.unsupported"].waitForExistence(timeout: 30), "the unreadable replay never showed the failure") diff --git a/ios/README.md b/ios/README.md index 5a1d44c50..c4b04523d 100644 --- a/ios/README.md +++ b/ios/README.md @@ -20,10 +20,14 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-autopilotCantGetThere` | With `-autopilot`, ends the walk the way device run 1 did. It answers "Can't get there" to the walk's first request, the ground in front of the meter, before the replay has shown it. Then, instead of marking the ends, it plays again the frame where the walk went farthest on each side and answers "Can't get there" when the walk asks to go on that way, so the end lands where the phone stands. `FullFlowUITests.testCantGetThereEndsWhereThePhoneIs` runs it. | | `-autopilotSomethingThere` | With `-autopilot`, answers the first spot check "Something's in the way" instead of "It's clear". The scan stops claiming that area, is uploaded again, and later checks get "It's clear". The stretch taken out (by this answer or by Continue) goes to the `-autopilotGate` folder as `spot-refusal.json` (`withdrawn_span_ft`, feet along the wall as in scene.json). `FullFlowUITests.testSomethingThereChecksTheWallAgain` runs it. | | `-autopilotGate ` | Used by the UI tests. Before leaving a screen, the app waits for a file named after that phase in this folder. Once the result shows, the autopilot writes the scan's scene.json there. | +| `-simulateAppStore` | Runs as an App Store install would: no developer options, and practice meter stays off whatever the stored switch says. `PracticeMeterUITests` uses it. | +| `-practiceMeter YES` or `NO` | Overrides the stored practice meter switch for one run (UserDefaults' argument domain). The full-flow UI tests pass `NO`, since the switch outlives a run in the Simulator. | | `-uiDemo` | Runs the screens on a scripted fake engine instead of the capture engine, for design work and for auditing states a replay can't reach. The arguments it takes are listed in `HouseScan/UI/Preview/UIDemo.swift`; for example `-uiDemoPhase spotConfirm -uiDemoSpotStep ground` opens the spot check's ground question, `-uiDemoPhase meterCloseUp -uiDemoMeterChoose` shows the meter number picker, and `-uiDemoPhase result -uiDemoCorner` shows the result model of a wall that turns a corner. | **Spot check.** Before the result (`STATE=spotConfirm`), the app shows the kept photo that best sees the answer's spot and its front clearance, outlined, and asks at most two questions on it. First: is anything in the marked area? Photos without depth and the walked path claim wall, ground and clear space they never saw, and the server keeps clearances only from marked things. "It's clear" backs those claims for that spot; "Something's in the way" takes them back over the area, so scene.json reports it unseen; "A gas meter, AC, window or door" opens the camera to mark it. Then, once it is clear: what is the ground where the battery would stand? The answer goes to the server as a ground patch over the footprint and its error margin, the only ground patch the app sends. Every answer but "It's clear" with "Not sure" sends the scan again. When no kept photo shows the whole area, nothing is asked and the area is left out. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. +**Practice meter.** For trying the whole scan where there is no electric meter, such as indoors. Debug and TestFlight builds show a wrench button at the top of the first screen. Tap it, turn on **Practice meter**, tap Done, then start the scan. At "find the meter", tap any spot on a wall: the usual raycast and refusals apply, and a drawn sample meter appears there, fixed to the meter's anchor, on every camera screen after that. The close-up keeps the live camera's pose and depth, but stores a photo of the sample (made-up maker SAMPLEWORKS, number 12345678), and the real reader reads it. The walk, fog, marks, requests, spot check, upload, result and AR all run for real. Every screen shows a "Practice meter" badge, and `scan-stamp.json` has `"practice": true`; the packet and scene.json are unchanged. The switch is kept until turned off, and the button reads "Practice meter on" while it is. App Store installs never show the button and ignore a stored switch: the app asks StoreKit's `AppTransaction` which server signed the install (sandbox means TestFlight) and treats no answer as the App Store. It is not in the iOS Settings app, because a Settings.bundle ships in the same binary that TestFlight later promotes to the App Store. The code is in `Runtime/Practice/`, `UI/Practice/` and HouseScanKit's `Practice/PracticeMeter.swift`. + Every screen change is logged as `STATE=` under subsystem `dev.housescanning.housescan`, category `state`. ## Layout