From 97ecebe9e8236463b5fea7effbb58fc77cbc9edf Mon Sep 17 00:00:00 2001 From: GTripathee Date: Mon, 23 Mar 2026 16:24:15 +0100 Subject: [PATCH 1/8] create required classes and dependencies to read flashlight output module from phyphox file into the code. --- .../phyphox/Experiments/Experiment.swift | 8 ++- .../Output/ExperimertFlashlightOutput.swift | 41 +++++++++++ .../Handlers/OutputElementHandler.swift | 72 ++++++++++++++++++- .../Handlers/PhyphoxElementHandler.swift | 38 +++++++++- phyphox-iOS/phyphox/Info.plist | 2 +- 5 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 phyphox-iOS/phyphox/Experiments/Output/ExperimertFlashlightOutput.swift diff --git a/phyphox-iOS/phyphox/Experiments/Experiment.swift b/phyphox-iOS/phyphox/Experiments/Experiment.swift index 1ce8d339..1c903ff3 100644 --- a/phyphox-iOS/phyphox/Experiments/Experiment.swift +++ b/phyphox-iOS/phyphox/Experiments/Experiment.swift @@ -132,6 +132,8 @@ final class Experiment { let audioOutput: ExperimentAudioOutput? + let flashlightOutput : ExperimentFlashlightOutput? + let bluetoothDevices: [ExperimentBluetoothDevice] let bluetoothInputs: [ExperimentBluetoothInput] let bluetoothOutputs: [ExperimentBluetoothOutput] @@ -152,7 +154,7 @@ final class Experiment { private let queue = DispatchQueue(label: "de.rwth-aachen.phyphox.analysis", attributes: []) - init(title: String, stateTitle: String?, description: String?, links: [ExperimentLink], category: String, icon: ExperimentIcon, color: UIColor?, appleBan: Bool, isLink: Bool, translation: ExperimentTranslationCollection?, buffers: [String: DataBuffer], timeReference: ExperimentTimeReference, sensorInputs: [ExperimentSensorInput], depthInput: ExperimentDepthInput?, cameraInput: ExperimentCameraInput?, gpsInputs: [ExperimentGPSInput], audioInputs: [ExperimentAudioInput], audioOutput: ExperimentAudioOutput?, bluetoothDevices: [ExperimentBluetoothDevice], bluetoothInputs: [ExperimentBluetoothInput], bluetoothOutputs: [ExperimentBluetoothOutput], networkConnections: [NetworkConnection], viewDescriptors: [ExperimentViewCollectionDescriptor]?, analysis: ExperimentAnalysis, export: ExperimentExport?) { + init(title: String, stateTitle: String?, description: String?, links: [ExperimentLink], category: String, icon: ExperimentIcon, color: UIColor?, appleBan: Bool, isLink: Bool, translation: ExperimentTranslationCollection?, buffers: [String: DataBuffer], timeReference: ExperimentTimeReference, sensorInputs: [ExperimentSensorInput], depthInput: ExperimentDepthInput?, cameraInput: ExperimentCameraInput?, gpsInputs: [ExperimentGPSInput], audioInputs: [ExperimentAudioInput], audioOutput: ExperimentAudioOutput?, flashlightOutput: ExperimentFlashlightOutput?, bluetoothDevices: [ExperimentBluetoothDevice], bluetoothInputs: [ExperimentBluetoothInput], bluetoothOutputs: [ExperimentBluetoothOutput], networkConnections: [NetworkConnection], viewDescriptors: [ExperimentViewCollectionDescriptor]?, analysis: ExperimentAnalysis, export: ExperimentExport?) { self.title = title self.stateTitle = stateTitle @@ -183,6 +185,8 @@ final class Experiment { self.audioOutput = audioOutput + self.flashlightOutput = flashlightOutput + self.bluetoothDevices = bluetoothDevices self.bluetoothInputs = bluetoothInputs self.bluetoothOutputs = bluetoothOutputs @@ -227,7 +231,7 @@ final class Experiment { } convenience init(file: String, error: String) { - self.init(title: file, stateTitle: nil, description: error, links: [], category: localize("unknown"), icon: ExperimentIcon.string("!"), color: UIColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0), appleBan: false, isLink: false, translation: nil, buffers: [:], timeReference: ExperimentTimeReference(), sensorInputs: [], depthInput: nil, cameraInput: nil, gpsInputs: [], audioInputs: [], audioOutput: nil, bluetoothDevices: [], bluetoothInputs: [], bluetoothOutputs: [], networkConnections: [], viewDescriptors: nil, analysis: ExperimentAnalysis(modules: [], sleep: 0.0, dynamicSleep: nil, onUserInput: false, requireFill: nil, requireFillThreshold: 1, requireFillDynamic: nil, timedRun: false, timedRunStartDelay: 0.0, timedRunStopDelay: 0.0, timeReference: ExperimentTimeReference(), sensorInputs: [], audioInputs: []), export: nil) + self.init(title: file, stateTitle: nil, description: error, links: [], category: localize("unknown"), icon: ExperimentIcon.string("!"), color: UIColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0), appleBan: false, isLink: false, translation: nil, buffers: [:], timeReference: ExperimentTimeReference(), sensorInputs: [], depthInput: nil, cameraInput: nil, gpsInputs: [], audioInputs: [], audioOutput: nil, flashlightOutput: nil, bluetoothDevices: [], bluetoothInputs: [], bluetoothOutputs: [], networkConnections: [], viewDescriptors: nil, analysis: ExperimentAnalysis(modules: [], sleep: 0.0, dynamicSleep: nil, onUserInput: false, requireFill: nil, requireFillThreshold: 1, requireFillDynamic: nil, timedRun: false, timedRunStartDelay: 0.0, timedRunStopDelay: 0.0, timeReference: ExperimentTimeReference(), sensorInputs: [], audioInputs: []), export: nil) invalid = true; } diff --git a/phyphox-iOS/phyphox/Experiments/Output/ExperimertFlashlightOutput.swift b/phyphox-iOS/phyphox/Experiments/Output/ExperimertFlashlightOutput.swift new file mode 100644 index 00000000..3c992b83 --- /dev/null +++ b/phyphox-iOS/phyphox/Experiments/Output/ExperimertFlashlightOutput.swift @@ -0,0 +1,41 @@ +// +// ExperimertFlashlightOutput.swift +// phyphox +// +// Created by Gaurav Tripathee on 23.03.26. +// Copyright © 2026 RWTH Aachen. All rights reserved. +// + +enum FlashlightParameter: Equatable { + case buffer(buffer: DataBuffer) + case value(value: Double?) + + func getValue() -> Double? { + switch self { + case .buffer(buffer: let buffer): + return buffer.last + case .value(value: let value): + return value + } + } + + var isBuffer: Bool { + switch self { + case .buffer(buffer: _): + return true + case .value(value: _): + return false + } + } +} + +class ExperimentFlashlightOutput { + let intensity: FlashlightParameter + let frequency: FlashlightParameter + + init(intensity: FlashlightParameter, frequency: FlashlightParameter) { + self.intensity = intensity + self.frequency = frequency + } +} + diff --git a/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/OutputElementHandler.swift b/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/OutputElementHandler.swift index 6fbbca6f..2bf1d6fe 100644 --- a/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/OutputElementHandler.swift +++ b/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/OutputElementHandler.swift @@ -257,9 +257,73 @@ private final class BluetoothElementHandler: ResultElementHandler, LookupElement } } +enum FlashlightOutputSubInputDescriptor { + case value(value: Double, usedAs: String) + case buffer(name: String, usedAs: String) +} + +final class FlashlightOutputSubInputElementHandler: ResultElementHandler, ChildlessElementHandler { + var results = [FlashlightOutputSubInputDescriptor]() + + func startElement(attributes: AttributeContainer) throws {} + + private enum Attribute: String, AttributeKey { + case type + case clear + case usedAs = "parameter" + } + + func endElement(text: String, attributes: AttributeContainer) throws { + let attributes = attributes.attributes(keyedBy: Attribute.self) + + let type = try attributes.optionalValue(for: .type) ?? DataInputTypeAttribute.buffer + let usedAs = attributes.optionalString(for: .usedAs) ?? "" + + switch type { + case .buffer: + guard !text.isEmpty else { throw ElementHandlerError.missingText } + + results.append(.buffer(name: text, usedAs: usedAs)) + case .value: + guard !text.isEmpty else { throw ElementHandlerError.missingText } + + guard let value = Double(text) else { + throw ElementHandlerError.unreadableData + } + + results.append(.value(value: value, usedAs: usedAs)) + case .empty: + break + } + } +} + +struct FlashlightOutputDescriptor { + let inputs: [FlashlightOutputSubInputDescriptor] +} + +private final class FlashlightElementHandler : ResultElementHandler, LookupElementHandler { + var results = [FlashlightOutputDescriptor]() + + private let inputHandler = FlashlightOutputSubInputElementHandler() + + var childHandlers: [String : ElementHandler] + + init() { + childHandlers = ["input": inputHandler] + } + + func startElement(attributes: AttributeContainer) throws {} + + func endElement(text: String, attributes: AttributeContainer) throws { + results.append(FlashlightOutputDescriptor(inputs: inputHandler.results)) + } + +} struct OutputDescriptor { let audioOutput: AudioOutputDescriptor? let bluetooth: [BluetoothOutputBlockDescriptor] + let flashlight: FlashlightOutputDescriptor? } final class OutputElementHandler: ResultElementHandler, LookupElementHandler, AttributelessElementHandler { @@ -269,14 +333,18 @@ final class OutputElementHandler: ResultElementHandler, LookupElementHandler, At private let audioHandler = AudioElementHandler() private let bluetoothHandler = BluetoothElementHandler() + private let flightlightHandler = FlashlightElementHandler() var childHandlers: [String : ElementHandler] init() { - childHandlers = ["audio": audioHandler, "bluetooth": bluetoothHandler] + childHandlers = ["audio": audioHandler, "bluetooth": bluetoothHandler, "flashlight": flightlightHandler ] } func endElement(text: String, attributes: AttributeContainer) throws { - results.append(OutputDescriptor(audioOutput: try audioHandler.expectOptionalResult(), bluetooth: bluetoothHandler.results)) + results.append(OutputDescriptor( + audioOutput: try audioHandler.expectOptionalResult(), + bluetooth: bluetoothHandler.results, + flashlight: try flightlightHandler.expectOptionalResult())) } } diff --git a/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/PhyphoxElementHandler.swift b/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/PhyphoxElementHandler.swift index 041d18ed..f99f29aa 100644 --- a/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/PhyphoxElementHandler.swift +++ b/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/PhyphoxElementHandler.swift @@ -238,6 +238,8 @@ final class PhyphoxElementHandler: ResultElementHandler, LookupElementHandler { let audioOutput = try makeAudioOutput(from: outputDescriptor?.audioOutput, buffers: buffers) + let flashlightOutput = try makeFlashlightOutput(from: outputDescriptor?.flashlight, buffers: buffers) + let analysisModules = try analysisDescriptor.modules.map({ try ExperimentAnalysisFactory.analysisModule(from: $1, for: $0, buffers: buffers) }) let analysis = ExperimentAnalysis(modules: analysisModules, sleep: analysisDescriptor.sleep, dynamicSleep: analysisDescriptor.dynamicSleepName.map { buffers[$0] } ?? nil, onUserInput: analysisDescriptor.onUserInput, requireFill: analysisDescriptor.requireFillName.map { buffers[$0] } ?? nil, requireFillThreshold: analysisDescriptor.requireFillThreshold, requireFillDynamic: analysisDescriptor.requireFillDynamicName.map { buffers[$0] } ?? nil, timedRun: analysisDescriptor.timedRun, timedRunStartDelay: analysisDescriptor.timedRunStartDelay, timedRunStopDelay: analysisDescriptor.timedRunStopDelay, timeReference: timeReference, sensorInputs: sensorInputs, audioInputs: audioInputs) @@ -377,7 +379,7 @@ final class PhyphoxElementHandler: ResultElementHandler, LookupElementHandler { } } - let experiment = Experiment(title: title, stateTitle: stateTitle, description: description, links: links, category: category, icon: icon, color: color, appleBan: appleBan, isLink: isLink, translation: translations, buffers: buffers, timeReference: timeReference, sensorInputs: sensorInputs, depthInput: depthInput, cameraInput: cameraInput, gpsInputs: gpsInputs, audioInputs: audioInputs, audioOutput: audioOutput, bluetoothDevices: bluetoothDevices, bluetoothInputs: bluetoothInputs, bluetoothOutputs: bluetoothOutputs, networkConnections: networkConnections, viewDescriptors: viewDescriptors, analysis: analysis, export: export) + let experiment = Experiment(title: title, stateTitle: stateTitle, description: description, links: links, category: category, icon: icon, color: color, appleBan: appleBan, isLink: isLink, translation: translations, buffers: buffers, timeReference: timeReference, sensorInputs: sensorInputs, depthInput: depthInput, cameraInput: cameraInput, gpsInputs: gpsInputs, audioInputs: audioInputs, audioOutput: audioOutput, flashlightOutput: flashlightOutput, bluetoothDevices: bluetoothDevices, bluetoothInputs: bluetoothInputs, bluetoothOutputs: bluetoothOutputs, networkConnections: networkConnections, viewDescriptors: viewDescriptors, analysis: analysis, export: export) results.append(experiment) } @@ -591,6 +593,40 @@ final class PhyphoxElementHandler: ResultElementHandler, LookupElementHandler { } } + + private func makeFlashlightOutput(from descriptor: FlashlightOutputDescriptor?, buffers: [String: DataBuffer]) throws -> ExperimentFlashlightOutput? { + + if let flashlightOutput = descriptor?.inputs { + var frequency = FlashlightParameter.value(value: 0.0) + var intensity = FlashlightParameter.value(value: 0) + for flashlightOutputDescriptor in flashlightOutput { + let target: String + let parameter: FlashlightParameter + switch flashlightOutputDescriptor { + case .buffer(name: let name, usedAs: let usedAs): + target = usedAs + guard let buffer = buffers[name] else { + throw ElementHandlerError.missingElement("data-container") + } + parameter = FlashlightParameter.buffer(buffer: buffer) + case .value(value: let value, usedAs: let usedAs): + target = usedAs + parameter = FlashlightParameter.value(value: value) + } + switch target { + case "frequency": frequency = parameter + case "intensity": intensity = parameter + default: throw ElementHandlerError.message("Invalid parameter of flashlight output.") + } + } + + + return ExperimentFlashlightOutput(intensity: intensity, frequency: frequency) + } + + return nil + + } private func makeAudioOutput(from descriptor: AudioOutputDescriptor?, buffers: [String: DataBuffer]) throws -> ExperimentAudioOutput? { diff --git a/phyphox-iOS/phyphox/Info.plist b/phyphox-iOS/phyphox/Info.plist index 89681c0a..2cd1e689 100644 --- a/phyphox-iOS/phyphox/Info.plist +++ b/phyphox-iOS/phyphox/Info.plist @@ -52,7 +52,7 @@ CFBundleVersion - 17276 + 17281 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace From bf505b440ea8686bee4348b5e09df6b1630012a6 Mon Sep 17 00:00:00 2001 From: GTripathee Date: Tue, 24 Mar 2026 13:44:23 +0100 Subject: [PATCH 2/8] By setting the value in buffer container in xml, user is able to start flashlight and strobe. --- .../phyphox/Experiments/Experiment.swift | 10 +- .../Experiments/FlashlightOutput.swift | 210 ++++++++++++++++++ .../Handlers/PhyphoxElementHandler.swift | 46 ++-- phyphox-iOS/phyphox/Info.plist | 2 +- 4 files changed, 245 insertions(+), 23 deletions(-) create mode 100644 phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift diff --git a/phyphox-iOS/phyphox/Experiments/Experiment.swift b/phyphox-iOS/phyphox/Experiments/Experiment.swift index 1c903ff3..07776d8d 100644 --- a/phyphox-iOS/phyphox/Experiments/Experiment.swift +++ b/phyphox-iOS/phyphox/Experiments/Experiment.swift @@ -132,8 +132,6 @@ final class Experiment { let audioOutput: ExperimentAudioOutput? - let flashlightOutput : ExperimentFlashlightOutput? - let bluetoothDevices: [ExperimentBluetoothDevice] let bluetoothInputs: [ExperimentBluetoothInput] let bluetoothOutputs: [ExperimentBluetoothOutput] @@ -152,9 +150,11 @@ final class Experiment { public var audioEngine: AudioEngine? + public var flashlightOutput : FlashlightOutput? + private let queue = DispatchQueue(label: "de.rwth-aachen.phyphox.analysis", attributes: []) - init(title: String, stateTitle: String?, description: String?, links: [ExperimentLink], category: String, icon: ExperimentIcon, color: UIColor?, appleBan: Bool, isLink: Bool, translation: ExperimentTranslationCollection?, buffers: [String: DataBuffer], timeReference: ExperimentTimeReference, sensorInputs: [ExperimentSensorInput], depthInput: ExperimentDepthInput?, cameraInput: ExperimentCameraInput?, gpsInputs: [ExperimentGPSInput], audioInputs: [ExperimentAudioInput], audioOutput: ExperimentAudioOutput?, flashlightOutput: ExperimentFlashlightOutput?, bluetoothDevices: [ExperimentBluetoothDevice], bluetoothInputs: [ExperimentBluetoothInput], bluetoothOutputs: [ExperimentBluetoothOutput], networkConnections: [NetworkConnection], viewDescriptors: [ExperimentViewCollectionDescriptor]?, analysis: ExperimentAnalysis, export: ExperimentExport?) { + init(title: String, stateTitle: String?, description: String?, links: [ExperimentLink], category: String, icon: ExperimentIcon, color: UIColor?, appleBan: Bool, isLink: Bool, translation: ExperimentTranslationCollection?, buffers: [String: DataBuffer], timeReference: ExperimentTimeReference, sensorInputs: [ExperimentSensorInput], depthInput: ExperimentDepthInput?, cameraInput: ExperimentCameraInput?, gpsInputs: [ExperimentGPSInput], audioInputs: [ExperimentAudioInput], audioOutput: ExperimentAudioOutput?, flashlightOutput: FlashlightOutput?, bluetoothDevices: [ExperimentBluetoothDevice], bluetoothInputs: [ExperimentBluetoothInput], bluetoothOutputs: [ExperimentBluetoothOutput], networkConnections: [NetworkConnection], viewDescriptors: [ExperimentViewCollectionDescriptor]?, analysis: ExperimentAnalysis, export: ExperimentExport?) { self.title = title self.stateTitle = stateTitle @@ -480,6 +480,8 @@ final class Experiment { try startAudio(countdown: false, stopExperimentDelegate: stopExperimentDelegate) + flashlightOutput?.start() + MotionSession.sharedSession().resetConfig() sensorInputs.forEach{ $0.configureMotionSession() } sensorInputs.forEach { $0.start(queue: queue) } @@ -510,6 +512,8 @@ final class Experiment { stopAudio() + flashlightOutput?.stop() + setKeepScreenOn(false) running = false diff --git a/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift new file mode 100644 index 00000000..019d5d19 --- /dev/null +++ b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift @@ -0,0 +1,210 @@ +// +// FlashlightOutput.swift +// phyphox +// +// Created by Gaurav Tripathee on 23.03.26. +// Copyright © 2026 RWTH Aachen. All rights reserved. +// + +import AVFoundation +import Foundation + + +class FlashlightOutput { + private let manager = FlashlightManager() + private var controllers: [FlashlightController] = [] + + func start() { + for controller in controllers { + controller.start() + } + } + + func stop() { + for controller in controllers { + controller.stop() + } + } + + func attachController(_ controller: FlashlightController) { + controllers.append(controller) + } + + public func getInternalManager() -> FlashlightManager{ + return manager + } + + protocol FlashlightController { + func start() + func stop() + var isActive: Bool { get } + } + + class StrobeController: FlashlightController { + private let manager: FlashlightManager + private let parameter: FlashlightParameter + + private(set) var isActive: Bool = false + + init(manager: FlashlightManager, parameter: FlashlightParameter) { + self.manager = manager + self.parameter = parameter + } + + func start() { + manager.startStrobe { [weak self] in + return self?.parameter.getValue() ?? 0.0 + } + isActive = true + } + + func stop() { + isActive = false + manager.stopStrobe() + } + } + + class IntensityController: FlashlightController { + private let manager: FlashlightManager + private let parameter: FlashlightParameter + + private(set) var isActive: Bool = false + + init(manager: FlashlightManager, parameter: FlashlightParameter) { + self.manager = manager + self.parameter = parameter + } + + func start() { + isActive = true + let val = Float(parameter.getValue() ?? 1.0) + manager.setIntensity(val) + } + + func stop() { + isActive = false + manager.turnOff() + } + } +} + + +class FlashlightManager { + private let device = AVCaptureDevice.default(for: .video) + + // To ensure hardware calls don't overlap + private let hardwareQueue = DispatchQueue(label: "de.rwth.flashlight.hardware", qos: .userInteractive) + + private var isStrobeActive = false + private var currentIntensity: Float = 1.0 + private var strobeFrequency: Double = 0 + private var frequencyProvider: (() -> Double)? + + var hasFlash: Bool { + return device?.hasTorch ?? false + } + + + func setIntensity(_ level: Float) { + hardwareQueue.async { [weak self] in + guard let self = self else { return } + self.currentIntensity = max(0.0, min(level, 1.0)) + + if !self.isStrobeActive { + if self.currentIntensity > 0 { + self.applyTorch(on: true, level: self.currentIntensity) + } else { + self.applyTorch(on: false, level: 0) + } + } + } + } + + func startStrobe(provider: @escaping () -> Double) { + hardwareQueue.async { [weak self] in + guard let self = self else { return } + let initialFreq = provider() + if initialFreq <= 0 { return } + + self.frequencyProvider = provider + if self.isStrobeActive { return } + + self.isStrobeActive = true + + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + self?.runStrobeLoop() + } + } + } + + private func runStrobeLoop() { + while true { + var frequency: Double = 0 + var shouldContinue = false + var level: Float = 1.0 + + // Atomic check of state variables + hardwareQueue.sync { + shouldContinue = self.isStrobeActive + frequency = self.frequencyProvider?() ?? 0 + level = self.currentIntensity + } + + if !shouldContinue || frequency <= 0 { break } + + let interval = 1.0 / frequency + let halfInterval = interval / 2.0 + + self.applyTorch(on: true, level: level) + Thread.sleep(forTimeInterval: halfInterval) + + self.applyTorch(on: false, level: 0) + Thread.sleep(forTimeInterval: halfInterval) + } + + self.applyTorch(on: false, level: 0) + } + + func stopStrobe() { + hardwareQueue.async { [weak self] in + self?.isStrobeActive = false + } + } + + func turnOff() { + stopStrobe() + hardwareQueue.async { [weak self] in + self?.applyTorch(on: false, level: 0) + } + } + + // Must be called from hardwareQueue + private func applyTorch(on: Bool, level: Float) { + guard let device = device, device.hasTorch else { + print("Flashlight: No device found") + return + } + + guard device.hasTorch else { + print("Flashlight: Hardware does not support torch.") + return + } + + guard device.isTorchAvailable else { + print("Flashlight: Torch is unavailable. Is the camera in use?") + return + } + + do { + try device.lockForConfiguration() + if on { + try device.setTorchModeOn(level: level) + } else { + device.torchMode = .off + } + device.unlockForConfiguration() + } catch { + print("Flashlight Error: \(error)") + } + } +} diff --git a/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/PhyphoxElementHandler.swift b/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/PhyphoxElementHandler.swift index f99f29aa..2a3e9510 100644 --- a/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/PhyphoxElementHandler.swift +++ b/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/PhyphoxElementHandler.swift @@ -240,6 +240,7 @@ final class PhyphoxElementHandler: ResultElementHandler, LookupElementHandler { let flashlightOutput = try makeFlashlightOutput(from: outputDescriptor?.flashlight, buffers: buffers) + let analysisModules = try analysisDescriptor.modules.map({ try ExperimentAnalysisFactory.analysisModule(from: $1, for: $0, buffers: buffers) }) let analysis = ExperimentAnalysis(modules: analysisModules, sleep: analysisDescriptor.sleep, dynamicSleep: analysisDescriptor.dynamicSleepName.map { buffers[$0] } ?? nil, onUserInput: analysisDescriptor.onUserInput, requireFill: analysisDescriptor.requireFillName.map { buffers[$0] } ?? nil, requireFillThreshold: analysisDescriptor.requireFillThreshold, requireFillDynamic: analysisDescriptor.requireFillDynamicName.map { buffers[$0] } ?? nil, timedRun: analysisDescriptor.timedRun, timedRunStartDelay: analysisDescriptor.timedRunStartDelay, timedRunStopDelay: analysisDescriptor.timedRunStopDelay, timeReference: timeReference, sensorInputs: sensorInputs, audioInputs: audioInputs) @@ -594,37 +595,44 @@ final class PhyphoxElementHandler: ResultElementHandler, LookupElementHandler { } - private func makeFlashlightOutput(from descriptor: FlashlightOutputDescriptor?, buffers: [String: DataBuffer]) throws -> ExperimentFlashlightOutput? { + private func makeFlashlightOutput(from descriptor: FlashlightOutputDescriptor?, buffers: [String: DataBuffer]) throws -> FlashlightOutput? { + guard let inputs = descriptor?.inputs else { return nil } + + let flashlightOutput = FlashlightOutput() + let manager = flashlightOutput.getInternalManager() - if let flashlightOutput = descriptor?.inputs { - var frequency = FlashlightParameter.value(value: 0.0) - var intensity = FlashlightParameter.value(value: 0) - for flashlightOutputDescriptor in flashlightOutput { - let target: String - let parameter: FlashlightParameter - switch flashlightOutputDescriptor { + for inputDescriptor in inputs { + let target: String + let parameter: FlashlightParameter + + switch inputDescriptor { case .buffer(name: let name, usedAs: let usedAs): target = usedAs guard let buffer = buffers[name] else { throw ElementHandlerError.missingElement("data-container") } - parameter = FlashlightParameter.buffer(buffer: buffer) + parameter = .buffer(buffer: buffer) + case .value(value: let value, usedAs: let usedAs): target = usedAs - parameter = FlashlightParameter.value(value: value) + parameter = .value(value: value) } - switch target { - case "frequency": frequency = parameter - case "intensity": intensity = parameter - default: throw ElementHandlerError.message("Invalid parameter of flashlight output.") + + switch target { + case "frequency": + let strobe = FlashlightOutput.StrobeController(manager: manager, parameter: parameter) + flashlightOutput.attachController(strobe) + + case "intensity": + let intensity = FlashlightOutput.IntensityController(manager: manager, parameter: parameter) + flashlightOutput.attachController(intensity) + + default: + throw ElementHandlerError.message("Invalid parameter of flashlight output.") } - } - - return ExperimentFlashlightOutput(intensity: intensity, frequency: frequency) } - - return nil + return flashlightOutput } diff --git a/phyphox-iOS/phyphox/Info.plist b/phyphox-iOS/phyphox/Info.plist index 2cd1e689..7fcf785b 100644 --- a/phyphox-iOS/phyphox/Info.plist +++ b/phyphox-iOS/phyphox/Info.plist @@ -52,7 +52,7 @@ CFBundleVersion - 17281 + 17288 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace From 767f04e5100fd83984e04299706fb7658d9e430b Mon Sep 17 00:00:00 2001 From: GTripathee Date: Wed, 25 Mar 2026 16:52:19 +0100 Subject: [PATCH 3/8] fix intensity control to control it as per intensity and run independent to strobe. --- .../phyphox/Experiments/Experiment.swift | 1 + .../phyphox/Experiments/FlashlightOutput.swift | 17 +++++++---------- phyphox-iOS/phyphox/Info.plist | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/phyphox-iOS/phyphox/Experiments/Experiment.swift b/phyphox-iOS/phyphox/Experiments/Experiment.swift index 07776d8d..1be6fa4a 100644 --- a/phyphox-iOS/phyphox/Experiments/Experiment.swift +++ b/phyphox-iOS/phyphox/Experiments/Experiment.swift @@ -565,6 +565,7 @@ extension Experiment: ExperimentAnalysisDelegate { networkConnection.pushDataToBuffers() networkConnection.doExecute() } + flashlightOutput?.start() } } diff --git a/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift index 019d5d19..d079c627 100644 --- a/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift +++ b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift @@ -104,19 +104,12 @@ class FlashlightManager { return device?.hasTorch ?? false } - func setIntensity(_ level: Float) { hardwareQueue.async { [weak self] in guard let self = self else { return } - self.currentIntensity = max(0.0, min(level, 1.0)) - if !self.isStrobeActive { - if self.currentIntensity > 0 { - self.applyTorch(on: true, level: self.currentIntensity) - } else { - self.applyTorch(on: false, level: 0) - } - } + self.currentIntensity = level + self.applyTorch(on: self.currentIntensity > 0, level: self.currentIntensity) } } @@ -194,11 +187,15 @@ class FlashlightManager { print("Flashlight: Torch is unavailable. Is the camera in use?") return } + guard device.isTorchModeSupported(.on) else { + return + } do { try device.lockForConfiguration() if on { - try device.setTorchModeOn(level: level) + let safeLevel = max(0.01, min(level, 1.0)) + try device.setTorchModeOn(level: safeLevel) } else { device.torchMode = .off } diff --git a/phyphox-iOS/phyphox/Info.plist b/phyphox-iOS/phyphox/Info.plist index 7fcf785b..c68ab959 100644 --- a/phyphox-iOS/phyphox/Info.plist +++ b/phyphox-iOS/phyphox/Info.plist @@ -52,7 +52,7 @@ CFBundleVersion - 17288 + 17306 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace From f1e09d317287421a592d20681efc99fdda7b4db2 Mon Sep 17 00:00:00 2001 From: GTripathee Date: Thu, 26 Mar 2026 16:05:50 +0100 Subject: [PATCH 4/8] fix: Make the strobe and intensity control to work smoothly and reactive to new changes. --- .../Experiments/FlashlightOutput.swift | 193 +++++++++++------- phyphox-iOS/phyphox/Info.plist | 2 +- 2 files changed, 120 insertions(+), 75 deletions(-) diff --git a/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift index d079c627..b5ccd44f 100644 --- a/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift +++ b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift @@ -1,36 +1,25 @@ -// -// FlashlightOutput.swift -// phyphox -// -// Created by Gaurav Tripathee on 23.03.26. -// Copyright © 2026 RWTH Aachen. All rights reserved. -// - import AVFoundation import Foundation - +/// A wrapper to manage flashlight output. +/// It coordinates different control modes (Intensity vs Strobe) and handles hardware safety. class FlashlightOutput { private let manager = FlashlightManager() private var controllers: [FlashlightController] = [] func start() { - for controller in controllers { - controller.start() - } + controllers.forEach { $0.start() } } func stop() { - for controller in controllers { - controller.stop() - } + controllers.forEach { $0.stop() } } func attachController(_ controller: FlashlightController) { controllers.append(controller) } - public func getInternalManager() -> FlashlightManager{ + public func getInternalManager() -> FlashlightManager { return manager } @@ -40,11 +29,12 @@ class FlashlightOutput { var isActive: Bool { get } } + // MARK: - Strobe Controller class StrobeController: FlashlightController { private let manager: FlashlightManager private let parameter: FlashlightParameter - private(set) var isActive: Bool = false + private var lastFrequency: Double = -1.0 init(manager: FlashlightManager, parameter: FlashlightParameter) { self.manager = manager @@ -52,23 +42,36 @@ class FlashlightOutput { } func start() { - manager.startStrobe { [weak self] in - return self?.parameter.getValue() ?? 0.0 + let currentFreq = parameter.getValue() ?? 0.0 + + if !isActive { + isActive = true + lastFrequency = currentFreq + // Passes a closure to the manager so the background thread can fetch fresh data + manager.startStrobe { [weak self] in + return self?.parameter.getValue() ?? 0.0 + } + } else if abs(currentFreq - lastFrequency) > 0.0001 { + // If freq changed while running, interrupt the loop's 'sleep' to apply new rate immediately. + lastFrequency = currentFreq + manager.pokeLoop() } - isActive = true } func stop() { + guard isActive else { return } isActive = false + lastFrequency = -1.0 manager.stopStrobe() } } + // MARK: - Intensity Controller class IntensityController: FlashlightController { private let manager: FlashlightManager private let parameter: FlashlightParameter - private(set) var isActive: Bool = false + private var lastValue: Float = -1.0 init(manager: FlashlightManager, parameter: FlashlightParameter) { self.manager = manager @@ -78,89 +81,129 @@ class FlashlightOutput { func start() { isActive = true let val = Float(parameter.getValue() ?? 1.0) - manager.setIntensity(val) + + // Value-tracking prevents redundant hardware commands if start() is called in a fast loop. + if abs(val - lastValue) > 0.001 { + lastValue = val + manager.setIntensity(val) + } } func stop() { isActive = false + lastValue = -1.0 manager.turnOff() } } } +// MARK: - Flashlight Manager Engine +/// The engine responsible for thread safety and direct interaction with AVCaptureDevice. class FlashlightManager { private let device = AVCaptureDevice.default(for: .video) - // To ensure hardware calls don't overlap + /// Dedicated queue for all hardware interactions to prevent UI blocking and race conditions. private let hardwareQueue = DispatchQueue(label: "de.rwth.flashlight.hardware", qos: .userInteractive) + /// Used to manage the strobe timing and allow immediate interruption of "sleep" states. + private let strobeCondition = NSCondition() private var isStrobeActive = false private var currentIntensity: Float = 1.0 - private var strobeFrequency: Double = 0 private var frequencyProvider: (() -> Double)? - var hasFlash: Bool { - return device?.hasTorch ?? false - } + // Hardware state cache used to guard the redudent calls + private var lastAppliedLevel: Float = -1.0 + private var lastAppliedOn: Bool = false + func setIntensity(_ level: Float) { hardwareQueue.async { [weak self] in guard let self = self else { return } - self.currentIntensity = level - self.applyTorch(on: self.currentIntensity > 0, level: self.currentIntensity) + self.pokeLoop() // Ensure the loop reacts to brightness changes instantly + + if !self.isStrobeActive { + self.applyTorch(on: level > 0, level: level) + } } } + /// Forces the background strobe thread to wake up from its current wait interval. + func pokeLoop() { + strobeCondition.lock() + strobeCondition.broadcast() // Wakes any thread currently at strobeCondition.wait() + strobeCondition.unlock() + } + + /// Initializes and starts the background strobe thread. func startStrobe(provider: @escaping () -> Double) { hardwareQueue.async { [weak self] in guard let self = self else { return } - let initialFreq = provider() - if initialFreq <= 0 { return } - self.frequencyProvider = provider - if self.isStrobeActive { return } - - self.isStrobeActive = true - DispatchQueue.global(qos: .userInteractive).async { [weak self] in - self?.runStrobeLoop() + if !self.isStrobeActive { + self.isStrobeActive = true + self.pokeLoop() + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + self?.runStrobeLoop() + } } } } + /// The core logic loop running on a background thread. private func runStrobeLoop() { + // Keeps track of which phase we are in so rapid changes don't "restart" the pulse + var isCurrentlyInOnPhase = false + while true { - var frequency: Double = 0 - var shouldContinue = false - var level: Float = 1.0 + strobeCondition.lock() - // Atomic check of state variables - hardwareQueue.sync { - shouldContinue = self.isStrobeActive - frequency = self.frequencyProvider?() ?? 0 - level = self.currentIntensity + if !self.isStrobeActive { + self.applyTorchInQueue(on: false, level: 0) + strobeCondition.unlock() + break } - if !shouldContinue || frequency <= 0 { break } + let freq = self.frequencyProvider?() ?? 0 + let level = self.currentIntensity + + // 1. STEADY MODE + if freq <= 0 { + isCurrentlyInOnPhase = true + self.applyTorchInQueue(on: level > 0, level: level) + strobeCondition.wait(until: Date().addingTimeInterval(0.5)) + strobeCondition.unlock() + continue + } - let interval = 1.0 / frequency + // 2. STROBE MODE + let interval = 1.0 / freq let halfInterval = interval / 2.0 - self.applyTorch(on: true, level: level) - Thread.sleep(forTimeInterval: halfInterval) + // Toggle Phase + isCurrentlyInOnPhase = !isCurrentlyInOnPhase + + if isCurrentlyInOnPhase { + self.applyTorchInQueue(on: level > 0, level: level) + } else { + self.applyTorchInQueue(on: false, level: 0) + } + + // Wait for half the interval. + // If pokeLoop() is called, this returns early and we immediately recalculate + // the NEXT phase using the NEW frequency. + strobeCondition.wait(until: Date().addingTimeInterval(halfInterval)) - self.applyTorch(on: false, level: 0) - Thread.sleep(forTimeInterval: halfInterval) + strobeCondition.unlock() } - - self.applyTorch(on: false, level: 0) } func stopStrobe() { hardwareQueue.async { [weak self] in self?.isStrobeActive = false + self?.pokeLoop() } } @@ -171,37 +214,39 @@ class FlashlightManager { } } - // Must be called from hardwareQueue - private func applyTorch(on: Bool, level: Float) { - guard let device = device, device.hasTorch else { - print("Flashlight: No device found") - return - } - - guard device.hasTorch else { - print("Flashlight: Hardware does not support torch.") - return - } - - guard device.isTorchAvailable else { - print("Flashlight: Torch is unavailable. Is the camera in use?") - return - } - guard device.isTorchModeSupported(.on) else { - return + /// Ensures that strobe-thread requests are passed through the serial hardwareQueue. + private func applyTorchInQueue(on: Bool, level: Float) { + hardwareQueue.sync { + self.applyTorch(on: on, level: level) } + } + + /// The only point in the code that talks to AVCaptureDevice. + private func applyTorch(on: Bool, level: Float) { + guard let device = device, device.hasTorch, device.isTorchAvailable else { return } + let safeLevel = max(0.01, min(level, 1.0)) + let targetOn = on && level > 0 + + // REDUNDANCY GUARD: + // Comparing current request with last applied hardware state. + // This prevents the "Lag" caused by spamming hardware locks. + if targetOn == lastAppliedOn && abs(safeLevel - lastAppliedLevel) < 0.001 && targetOn == true { return } + if targetOn == false && lastAppliedOn == false { return } + do { try device.lockForConfiguration() - if on { - let safeLevel = max(0.01, min(level, 1.0)) + if targetOn { try device.setTorchModeOn(level: safeLevel) + lastAppliedLevel = safeLevel + lastAppliedOn = true } else { device.torchMode = .off + lastAppliedOn = false } device.unlockForConfiguration() } catch { - print("Flashlight Error: \(error)") + // If the hardware is locked by another process, skip this frame to keep loop timing consistent } } } diff --git a/phyphox-iOS/phyphox/Info.plist b/phyphox-iOS/phyphox/Info.plist index c68ab959..6e44f19a 100644 --- a/phyphox-iOS/phyphox/Info.plist +++ b/phyphox-iOS/phyphox/Info.plist @@ -52,7 +52,7 @@ CFBundleVersion - 17306 + 17325 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace From f2d40ef2f8ee53bb123c391781af70649cb7d553 Mon Sep 17 00:00:00 2001 From: GTripathee Date: Mon, 30 Mar 2026 09:27:02 +0200 Subject: [PATCH 5/8] show users the photosensitivity alert. --- .../Experiments/FlashlightOutput.swift | 23 +++++++++++++++ phyphox-iOS/phyphox/Helper/Utility.swift | 13 +++++++++ phyphox-iOS/phyphox/Info.plist | 2 +- .../ExperimentPageViewController.swift | 28 +++++++++++++++++++ .../phyphox/en.lproj/Localizable.strings | 3 ++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift index b5ccd44f..a74b8db6 100644 --- a/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift +++ b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift @@ -22,6 +22,25 @@ class FlashlightOutput { public func getInternalManager() -> FlashlightManager { return manager } + + public func hasStrobeController() -> Bool { + return controllers.contains{ $0 is StrobeController} + } + + func isStrobeActiveWithFrequency() -> Bool { + guard let strobe = controllers.first(where: { $0 is StrobeController }) as? StrobeController else { + return false + } + + return strobe.getCurrentFrequency() > 0 + } + + func isStrobeUsingBuffer() -> Bool { + guard let strobe = controllers.first(where: { $0 is StrobeController }) as? StrobeController else { + return false + } + return strobe.isBufferSource() + } protocol FlashlightController { func start() @@ -40,6 +59,10 @@ class FlashlightOutput { self.manager = manager self.parameter = parameter } + + func getCurrentFrequency() -> Double { return parameter.getValue() ?? 0.0 } + + func isBufferSource() -> Bool { return parameter.isBuffer } func start() { let currentFreq = parameter.getValue() ?? 0.0 diff --git a/phyphox-iOS/phyphox/Helper/Utility.swift b/phyphox-iOS/phyphox/Helper/Utility.swift index fe9fa754..8e8b98d5 100644 --- a/phyphox-iOS/phyphox/Helper/Utility.swift +++ b/phyphox-iOS/phyphox/Helper/Utility.swift @@ -57,3 +57,16 @@ extension UIView } } + +struct SafetyManager { + static let strobeWarningKey = "didAcknowledgeStrobeWarning" + + static var needsWarning: Bool { + return !UserDefaults.standard.bool(forKey: strobeWarningKey) + } + + static func acknowledge() { + UserDefaults.standard.set(true, forKey: strobeWarningKey) + } +} + diff --git a/phyphox-iOS/phyphox/Info.plist b/phyphox-iOS/phyphox/Info.plist index 6e44f19a..3b8dd803 100644 --- a/phyphox-iOS/phyphox/Info.plist +++ b/phyphox-iOS/phyphox/Info.plist @@ -52,7 +52,7 @@ CFBundleVersion - 17325 + 17333 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift b/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift index ae656704..1291cfc9 100644 --- a/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift +++ b/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift @@ -524,6 +524,25 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } } + func showStrobeWarning() { + let alert = UIAlertController( + title: localize("warning_photosensitivity"), + message: localize("warning_photosensitivity_message"), + preferredStyle: .alert + ) + + let proceedAction = UIAlertAction(title: localize("dont_remind"), style: .default) { _ in + SafetyManager.acknowledge() + } + + let okAction = UIAlertAction(title: localize("ok"), style: .cancel, handler: nil) + + alert.addAction(proceedAction) + alert.addAction(okAction) + + self.present(alert, animated: true, completion: nil) + } + override func viewDidAppear(_ animated: Bool) { if #available(iOS 14.0, *) { for vc in experimentViewControllers { @@ -562,6 +581,15 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } else { showOptionalDialogsAndHints() } + + if let flashlightOutput = experiment.flashlightOutput { + if flashlightOutput.hasStrobeController() && SafetyManager.needsWarning{ + if flashlightOutput.isStrobeActiveWithFrequency() || flashlightOutput.isStrobeUsingBuffer() { + showStrobeWarning() + } + } + } + } override func viewDidDisappear(_ animated: Bool) { diff --git a/phyphox-iOS/phyphox/en.lproj/Localizable.strings b/phyphox-iOS/phyphox/en.lproj/Localizable.strings index bc732889..a2259954 100644 --- a/phyphox-iOS/phyphox/en.lproj/Localizable.strings +++ b/phyphox-iOS/phyphox/en.lproj/Localizable.strings @@ -180,6 +180,9 @@ "spectroscopy_calibration_info" = "Calibrated: λ = %.4f×pixel + %.2f"; "spectroscopy_calibration_message" = "Do you want to continue with this calibration point?"; +"warning_photosensitivity" = "Photosensitivity Warning"; +"warning_photosensitivity_message" = "This experiment may produces high-frequency light pulses. It may trigger discomfort or seizures for individuals with photosensitive epilepsy. \n\nAvoid staring directly at the light source."; +"dont_remind" = "I understand & don't remind me"; "remoteColorMapWarning" = "The color plot in the remote interface is only an approximation. In contrast to the in-app plot, at the moment it cannot handle non-equidistant data or logarithmic scaling on the x and y axis. So, data at varying intervals may appear at the wrong location."; "remoteDepthGUIWarning" = "Previewing and controlling the LiDAR/ToF sensor on the remote interface is not supported."; "remoteCameraWarning" = "Previewing and controlling the camera sensor on the remote interface is not supported."; From 3b4b183e2d8e0ebd629da2b851a41ae3aa870e70 Mon Sep 17 00:00:00 2001 From: GTripathee Date: Mon, 30 Mar 2026 14:14:52 +0200 Subject: [PATCH 6/8] when opening the experiment, make sure that all the required dialog are opened. --- .../Data-Input/ExperimentGPSInput.swift | 12 + .../phyphox/Experiments/Experiment.swift | 121 ++++---- phyphox-iOS/phyphox/Info.plist | 2 +- .../ExperimentPageViewController.swift | 293 +++++++++++------- 4 files changed, 250 insertions(+), 178 deletions(-) diff --git a/phyphox-iOS/phyphox/Experiments/Data-Input/ExperimentGPSInput.swift b/phyphox-iOS/phyphox/Experiments/Data-Input/ExperimentGPSInput.swift index e59938f9..b584d5ff 100644 --- a/phyphox-iOS/phyphox/Experiments/Data-Input/ExperimentGPSInput.swift +++ b/phyphox-iOS/phyphox/Experiments/Data-Input/ExperimentGPSInput.swift @@ -35,6 +35,8 @@ final class ExperimentGPSInput: NSObject, CLLocationManagerDelegate { private var queue: DispatchQueue? + var onAuthorizationChange: ((CLAuthorizationStatus) -> Void)? + init (latBuffer: DataBuffer?, lonBuffer: DataBuffer?, zBuffer: DataBuffer?, zWgs84Buffer: DataBuffer?, vBuffer: DataBuffer?, dirBuffer: DataBuffer?, accuracyBuffer: DataBuffer?, zAccuracyBuffer: DataBuffer?, tBuffer: DataBuffer?, statusBuffer: DataBuffer?, satellitesBuffer: DataBuffer?, timeReference: ExperimentTimeReference) { self.latBuffer = latBuffer @@ -81,6 +83,16 @@ final class ExperimentGPSInput: NSObject, CLLocationManagerDelegate { } } + @available(iOS 14.0, *) + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + onAuthorizationChange?(manager.authorizationStatus) + } + + // For iOS 13 and older + func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + onAuthorizationChange?(status) + } + func clear() { } diff --git a/phyphox-iOS/phyphox/Experiments/Experiment.swift b/phyphox-iOS/phyphox/Experiments/Experiment.swift index 1be6fa4a..63c3c873 100644 --- a/phyphox-iOS/phyphox/Experiments/Experiment.swift +++ b/phyphox-iOS/phyphox/Experiments/Experiment.swift @@ -246,9 +246,11 @@ final class Experiment { /** Called when the experiment view controller will be presented. */ - func willBecomeActive(_ dismiss: @escaping () -> Void) { + func willBecomeActive(onSuccess: @escaping () -> Void, _ dismiss: @escaping () -> Void) { if requiredPermissions != .none { - checkAndAskForPermissions(dismiss, locationManager: gpsInputs.first?.locationManager) + checkAndAskForPermissions(onSuccess: onSuccess, dismiss) + } else { + onSuccess() } analysis.queue = queue analysis.setNeedsUpdate(isPreRun: true) @@ -326,31 +328,23 @@ final class Experiment { } } - private func checkAndAskForPermissions(_ failed: @escaping () -> Void, locationManager: CLLocationManager?) { + private func checkAndAskForPermissions(onSuccess: @escaping () -> Void, _ failed: @escaping () -> Void) { if requiredPermissions.contains(.microphone) { let status = AVCaptureDevice.authorizationStatus(for: AVMediaType.audio) switch status { - case .denied: - failed() - let alert = UIAlertController(title: localize("permission_microphone_required"), message: localize("permission_microphone_denied"), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - UIApplication.shared.keyWindow!.rootViewController!.present(alert, animated: true, completion: nil) - - case .restricted: + case .authorized: + onSuccess() + case .denied, .restricted: failed() - let alert = UIAlertController(title: localize("permission_microphone_required"), message: "permission_microphone_restricted", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - UIApplication.shared.keyWindow!.rootViewController!.present(alert, animated: true, completion: nil) - + showPermissionAlert(title: localize("permission_microphone_required"), message: localize("permission_microphone_denied"), onDismiss: failed) case .notDetermined: AVCaptureDevice.requestAccess(for: AVMediaType.audio, completionHandler: { (allowed) in - if !allowed { - failed() + DispatchQueue.main.async { + if allowed { onSuccess() } else { failed() } } }) - - default: + @unknown default: break } } else if requiredPermissions.contains(.location) { @@ -358,48 +352,55 @@ final class Experiment { let status = CLLocationManager.authorizationStatus() switch status { - case .denied: + case .authorizedAlways, .authorizedWhenInUse: + onSuccess() + case .denied, .restricted: failed() - let alert = UIAlertController(title: localize("permission_location_required"), message: localize("permission_location_denied"), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - UIApplication.shared.keyWindow!.rootViewController!.present(alert, animated: true, completion: nil) - - case .restricted: - failed() - let alert = UIAlertController(title: localize("permission_location_required"), message: localize("permission_location_restricted"), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - UIApplication.shared.keyWindow!.rootViewController!.present(alert, animated: true, completion: nil) - + showPermissionAlert(title: localize("permission_location_required"), message: localize("permission_location_denied"),onDismiss: failed) case .notDetermined: - locationManager?.requestWhenInUseAuthorization() - break + guard let gpsInput = gpsInputs.first else { + onSuccess() + return + } + gpsInput.onAuthorizationChange = { [weak gpsInput] newStatus in + DispatchQueue.main.async { + switch newStatus { + case .authorizedAlways, .authorizedWhenInUse: + onSuccess() + case .denied, .restricted: + failed() // Stop waterfall! + self.showPermissionAlert(title: localize("permission_location_required"), message: localize("permission_location_denied"), onDismiss: failed) + case .notDetermined: + return + @unknown default: + break + } + gpsInput?.onAuthorizationChange = nil + } + } + + gpsInput.locationManager.requestWhenInUseAuthorization() - default: + @unknown default: break } } else if requiredPermissions.contains(.motionFitness) { - print("Motion and Fitness permission required.") let status = CMAltimeter.authorizationStatus() switch status { - case .denied: - failed() - let alert = UIAlertController(title: localize("permission_motion_required"), message: localize("permission_motion_denied"), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - UIApplication.shared.keyWindow!.rootViewController!.present(alert, animated: true, completion: nil) - case .restricted: + case .authorized: + onSuccess() + case .denied, .restricted: failed() - let alert = UIAlertController(title: localize("permission_motion_required"), message: localize("permission_motion_restricted"), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - UIApplication.shared.keyWindow!.rootViewController!.present(alert, animated: true, completion: nil) - + showPermissionAlert(title: localize("permission_motion_required"), message: localize("permission_motion_denied"), onDismiss: failed) case .notDetermined: let recorder = CMSensorRecorder() DispatchQueue.global().async { recorder.recordAccelerometer(forDuration: 0.1) + DispatchQueue.main.async { onSuccess() } } break - default: + @unknown default: break } } @@ -407,31 +408,33 @@ final class Experiment { else if requiredPermissions.contains(.camera) { let status = AVCaptureDevice.authorizationStatus(for: .video) switch status { - case .denied: - failed() - let alert = UIAlertController(title: localize("permission_camera_required"), message: localize("permission_camera_denied"), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - UIApplication.shared.keyWindow!.rootViewController!.present(alert, animated: true, completion: nil) - case .restricted: + case .denied, .restricted: failed() - let alert = UIAlertController(title: localize("permission_camera_required"), message: localize("permission_camera_restricted"), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - UIApplication.shared.keyWindow!.rootViewController!.present(alert, animated: true, completion: nil) - + showPermissionAlert(title: localize("permission_camera_required"), message: localize("permission_camera_denied"), onDismiss: failed) case .notDetermined: AVCaptureDevice.requestAccess(for: .video, completionHandler: { (allowed) in - if !allowed { - failed() - } + DispatchQueue.main.async { + if allowed { onSuccess() } else { failed() } + } }) - break - default: break } } } + private func showPermissionAlert(title: String, message: String, onDismiss: @escaping () -> Void) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + onDismiss() + })) + + DispatchQueue.main.async { + UIApplication.shared.keyWindow?.rootViewController?.present(alert, animated: true, completion: nil) + } + } + public func startAudio(countdown: Bool, stopExperimentDelegate: StopExperimentDelegate) throws { if audioEngine != nil { //Do not start twice. It could have been already started for a beeping countdown. audioEngine?.beepOnly = countdown diff --git a/phyphox-iOS/phyphox/Info.plist b/phyphox-iOS/phyphox/Info.plist index 3b8dd803..4935a338 100644 --- a/phyphox-iOS/phyphox/Info.plist +++ b/phyphox-iOS/phyphox/Info.plist @@ -52,7 +52,7 @@ CFBundleVersion - 17333 + 17345 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift b/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift index 1291cfc9..83013ed2 100644 --- a/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift +++ b/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift @@ -85,6 +85,19 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } } + private var hasCompletedInitialPermissionCheck = false + + private enum DialogSequence { + // Sequence is a per the priority + case systemPermissions + case dataPolicy + case bluetoothConnections + case networkConnections + case photosensitivity + case saveLocally + case hints + } + func updateTabScrollPosition(_ target: Int) { if segControl == nil { return @@ -199,14 +212,6 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - if isMovingToParent { - experiment.willBecomeActive { - DispatchQueue.main.async { - self.navigationController?.popToRootViewController(animated: true) - } - } - } - guard let navBar = self.navigationController?.navigationBar else { return } @@ -392,6 +397,15 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if let floatingBubble = hintBubble { + floatingBubble.dismiss(animated: false, completion: nil) + hintBubble = nil + } + } + @objc func handleDeviceRotationToControlSpectrumOrientation() { let device = UIDevice.current.orientation var newOrient: DeviceOrientation @@ -468,81 +482,6 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController return .none } - func showOptionalDialogsAndHints() { - - //Ask to save the experiment locally if it has been loaded from a remote source - if !experiment.local && !ExperimentManager.shared.experimentInCollection(crc32: experiment.crc32) { - UIAlertController.PhyphoxUIAlertBuilder() - .title(title: localize("save_locally")) - .message(message: localize("save_locally_message")) - .preferredStyle(style: .alert) - .addActionWithTitle(localize("save_locally_button"), style: .default, handler: { _ in - do { - try self.saveLocally() - } - catch { - print(error) - } - }) - .addCancelAction() - .show(in: self.navigationController!, animated: true) - - //Show a hint for the experiment info - } else { - if let playItem = playItem, hintBubble == nil { - let defaults = UserDefaults.standard - let key = "experiment_start_hint_dismiss_count" - if (defaults.integer(forKey: key) < 3) { - hintBubble = HintBubbleViewController(text: localize("start_hint"), onDismiss: {() -> Void in - }) - guard let hintBubble = hintBubble else { - return - } - hintBubble.popoverPresentationController?.delegate = self - hintBubble.popoverPresentationController?.barButtonItem = playItem - - self.present(hintBubble, animated: true, completion: nil) - } - } - - if let actionItem = actionItem, hintBubble == nil && (experiment.localizedCategory != localize("categoryRawSensor")) { - let defaults = UserDefaults.standard - let key = "experiment_info_hint_dismiss_count" - if (defaults.integer(forKey: key) < 3) { - hintBubble = HintBubbleViewController(text: localize("experimentinfo_hint"), onDismiss: {() -> Void in - defaults.set(defaults.integer(forKey: key) + 1, forKey: key) - }) - guard let hintBubble = hintBubble else { - return - } - hintBubble.popoverPresentationController?.delegate = self - hintBubble.popoverPresentationController?.barButtonItem = actionItem - - self.present(hintBubble, animated: true, completion: nil) - } - } - } - } - - func showStrobeWarning() { - let alert = UIAlertController( - title: localize("warning_photosensitivity"), - message: localize("warning_photosensitivity_message"), - preferredStyle: .alert - ) - - let proceedAction = UIAlertAction(title: localize("dont_remind"), style: .default) { _ in - SafetyManager.acknowledge() - } - - let okAction = UIAlertAction(title: localize("ok"), style: .cancel, handler: nil) - - alert.addAction(proceedAction) - alert.addAction(okAction) - - self.present(alert, animated: true, completion: nil) - } - override func viewDidAppear(_ animated: Bool) { if #available(iOS 14.0, *) { for vc in experimentViewControllers { @@ -570,28 +509,14 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } } } - if let networkConnection = experiment.networkConnections.first { - var sensorList: [String] = [] - for sensorInput in experiment.sensorInputs { - sensorList.append(sensorInput.sensorType.getLocalizedName()) - } - networkConnection.showDataAndPolicy(infoMicrophone: experiment.audioInputs.count > 0, infoLocation: experiment.gpsInputs.count > 0, infoSensorData: experiment.sensorInputs.count > 0, infoSensorDataList: sensorList, callback: self) - } else if experiment.bluetoothDevices.count > 0 { - connectToBluetoothDevices() - } else { - showOptionalDialogsAndHints() - } - if let flashlightOutput = experiment.flashlightOutput { - if flashlightOutput.hasStrobeController() && SafetyManager.needsWarning{ - if flashlightOutput.isStrobeActiveWithFrequency() || flashlightOutput.isStrobeUsingBuffer() { - showStrobeWarning() - } - } + if isMovingToParent && !hasCompletedInitialPermissionCheck { + executeSequence(from: .systemPermissions) } } + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) @@ -1495,17 +1420,158 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } } + //MARK: - Dialog waterfall flow + + private func executeSequence(from step: DialogSequence){ + switch step { + case .systemPermissions: + experiment.willBecomeActive( + onSuccess: { [weak self] in + DispatchQueue.main.async { + guard let self = self as? ExperimentPageViewController else { return } + self.hasCompletedInitialPermissionCheck = true + self.executeSequence(from: .dataPolicy) + } + }, + { [weak self] in + DispatchQueue.main.async { + self?.navigationController?.popToRootViewController(animated: true) + } + } + ) + + case .dataPolicy: + if let networkConnection = experiment.networkConnections.first { + let sensorList = experiment.sensorInputs.map { $0.sensorType.getLocalizedName() } + networkConnection.showDataAndPolicy( + infoMicrophone: experiment.audioInputs.count > 0, + infoLocation: experiment.gpsInputs.count > 0, + infoSensorData: experiment.sensorInputs.count > 0, + infoSensorDataList: sensorList, + callback: self + ) + } else { + executeSequence(from: .bluetoothConnections) + } + + case .bluetoothConnections: + if experiment.bluetoothDevices.count > 0 { + connectToBluetoothDevices() + } else { + executeSequence(from: .networkConnections) + } + case .networkConnections: + if experiment.networkConnections.count > 0 { + connectToNetworkDevices() + } else { + executeSequence(from: .photosensitivity) + } + case .photosensitivity: + if let flashlight = experiment.flashlightOutput, + flashlight.hasStrobeController() && SafetyManager.needsWarning { + if flashlight.isStrobeActiveWithFrequency() || flashlight.isStrobeUsingBuffer() { + showStrobeWarning { [weak self] in + self?.executeSequence(from: .saveLocally) + } + return + } + } + executeSequence(from: .saveLocally) + case .saveLocally: + if !experiment.local && !ExperimentManager.shared.experimentInCollection(crc32: experiment.crc32) { + UIAlertController.PhyphoxUIAlertBuilder() + .title(title: localize("save_locally")) + .message(message: localize("save_locally_message")) + .preferredStyle(style: .alert) + .addActionWithTitle(localize("save_locally_button"), style: .default, handler: { [weak self] _ in + do { try self?.saveLocally() } catch { print(error) } + self?.executeSequence(from: .hints) + }) + .addCancelAction { [weak self] _ in + self?.executeSequence(from: .hints) + } + .show(in: self.navigationController!, animated: true) + } else { + executeSequence(from: .hints) + } + case .hints: + presentNextHint() + } + } + + func showStrobeWarning(completion: @escaping () -> Void) { + let alert = UIAlertController( + title: localize("warning_photosensitivity"), + message: localize("warning_photosensitivity_message"), + preferredStyle: .alert + ) + + let proceedAction = UIAlertAction(title: localize("dont_remind"), style: .default) { _ in + SafetyManager.acknowledge() + completion() + } + + let okAction = UIAlertAction(title: localize("ok"), style: .cancel) { _ in + completion() + } + + alert.addAction(proceedAction) + alert.addAction(okAction) + + self.present(alert, animated: true, completion: nil) + } + + private var hasShownStartHintThisSession = false + private var hasShownInfoHintThisSession = false + + private func presentNextHint() { + let defaults = UserDefaults.standard + + if let playItem = playItem, hintBubble == nil, !hasShownStartHintThisSession { + hasShownStartHintThisSession = true + + let key = "experiment_start_hint_dismiss_count" + if defaults.integer(forKey: key) < 3 { + showHintBubble(text: localize("start_hint"), item: playItem, defaultsKey: key) + return // Pause for this hint + } + } + + if let actionItem = actionItem, hintBubble == nil, !hasShownInfoHintThisSession && (experiment.localizedCategory != localize("categoryRawSensor")) { + hasShownInfoHintThisSession = true + + let key = "experiment_info_hint_dismiss_count" + if defaults.integer(forKey: key) < 3 { + showHintBubble(text: localize("experimentinfo_hint"), item: actionItem, defaultsKey: key) + return + } + } + } + + private func showHintBubble(text: String, item: UIBarButtonItem, defaultsKey: String) { + hintBubble = HintBubbleViewController(text: text, onDismiss: { [weak self] in + let defaults = UserDefaults.standard + defaults.set(defaults.integer(forKey: defaultsKey) + 1, forKey: defaultsKey) + self?.hintBubble = nil + self?.presentNextHint() + }) + + hintBubble?.popoverPresentationController?.delegate = self + hintBubble?.popoverPresentationController?.barButtonItem = item + + if let bubble = hintBubble { + self.present(bubble, animated: true, completion: nil) + } + } + func connectToBluetoothDevices() { if experiment.bluetoothDevices.count == 1, let input = experiment.bluetoothDevices.first { if input.deviceAddress != nil { input.stopExperimentDelegate = self input.scanToConnect() - if (experiment.networkConnections.count > 0) { - connectToNetworkDevices() - } else { - showOptionalDialogsAndHints() - } + + executeSequence(from: .networkConnections) return } } @@ -1519,16 +1585,11 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } } - //No more dialogs shown. Now show any other dialog that had to wait. - if (experiment.networkConnections.count > 0) { - connectToNetworkDevices() - } else { - showOptionalDialogsAndHints() - } + executeSequence(from: .networkConnections) } func bluetoothScanDialogDismissed() { - connectToBluetoothDevices() + executeSequence(from: .networkConnections) } func disconnectFromBluetoothDevices(){ @@ -1544,7 +1605,7 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController return } } - showOptionalDialogsAndHints() + executeSequence(from: .photosensitivity) } func networkScanDialogDismissed() { @@ -1558,11 +1619,7 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } func dataPolicyInfoDismissed() { - if experiment.bluetoothDevices.count > 0 { - connectToBluetoothDevices() - } else { - connectToNetworkDevices() - } + executeSequence(from: .bluetoothConnections) } func refreshAppTheme(){ From bf5e3b23db4584a6c75f460f430614cc2617db39 Mon Sep 17 00:00:00 2001 From: GTripathee Date: Mon, 30 Mar 2026 14:17:27 +0200 Subject: [PATCH 7/8] refact: re structure the code. --- .../phyphox/Experiments/Experiment.swift | 174 +++++++++--------- .../ExperimentPageViewController.swift | 160 ++++++++-------- 2 files changed, 167 insertions(+), 167 deletions(-) diff --git a/phyphox-iOS/phyphox/Experiments/Experiment.swift b/phyphox-iOS/phyphox/Experiments/Experiment.swift index 63c3c873..3c3459e8 100644 --- a/phyphox-iOS/phyphox/Experiments/Experiment.swift +++ b/phyphox-iOS/phyphox/Experiments/Experiment.swift @@ -63,9 +63,9 @@ final class Experiment { } return translation?.selectedTranslation?.categoryString ?? category } - + weak var analysisDelegate: ExperimentAnalysisDelegate? - + let icon: ExperimentIcon let rawColor: UIColor? @@ -78,7 +78,7 @@ final class Experiment { return kHighlightColor } } - + var local: Bool = false var source: URL? var custom: Bool { @@ -123,7 +123,7 @@ final class Experiment { let viewDescriptors: [ExperimentViewCollectionDescriptor]? let translation: ExperimentTranslationCollection? - + let sensorInputs: [ExperimentSensorInput] let depthInput: ExperimentDepthInput? let cameraInput: ExperimentCameraInput? @@ -142,18 +142,18 @@ final class Experiment { let export: ExperimentExport? let buffers: [String: DataBuffer] - + private var requiredPermissions: ExperimentRequiredPermission = .none private(set) var running = false private(set) var hasStarted = false - + public var audioEngine: AudioEngine? public var flashlightOutput : FlashlightOutput? private let queue = DispatchQueue(label: "de.rwth-aachen.phyphox.analysis", attributes: []) - + init(title: String, stateTitle: String?, description: String?, links: [ExperimentLink], category: String, icon: ExperimentIcon, color: UIColor?, appleBan: Bool, isLink: Bool, translation: ExperimentTranslationCollection?, buffers: [String: DataBuffer], timeReference: ExperimentTimeReference, sensorInputs: [ExperimentSensorInput], depthInput: ExperimentDepthInput?, cameraInput: ExperimentCameraInput?, gpsInputs: [ExperimentGPSInput], audioInputs: [ExperimentAudioInput], audioOutput: ExperimentAudioOutput?, flashlightOutput: FlashlightOutput?, bluetoothDevices: [ExperimentBluetoothDevice], bluetoothInputs: [ExperimentBluetoothInput], bluetoothOutputs: [ExperimentBluetoothOutput], networkConnections: [NetworkConnection], viewDescriptors: [ExperimentViewCollectionDescriptor]?, analysis: ExperimentAnalysis, export: ExperimentExport?) { self.title = title self.stateTitle = stateTitle @@ -164,16 +164,16 @@ final class Experiment { self.description = description self.links = links - + self.localizedLinks = links.map { ExperimentLink(label: translation?.localizeString($0.label) ?? $0.label, url: translation?.localizeLink($0.label, fallback: $0.url) ?? $0.url, highlighted: $0.highlighted) } - + self.category = category self.icon = icon self.rawColor = color self.translation = translation - + self.timeReference = timeReference self.buffers = buffers @@ -229,7 +229,7 @@ final class Experiment { analysis.delegate = self } - + convenience init(file: String, error: String) { self.init(title: file, stateTitle: nil, description: error, links: [], category: localize("unknown"), icon: ExperimentIcon.string("!"), color: UIColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0), appleBan: false, isLink: false, translation: nil, buffers: [:], timeReference: ExperimentTimeReference(), sensorInputs: [], depthInput: nil, cameraInput: nil, gpsInputs: [], audioInputs: [], audioOutput: nil, flashlightOutput: nil, bluetoothDevices: [], bluetoothInputs: [], bluetoothOutputs: [], networkConnections: [], viewDescriptors: nil, analysis: ExperimentAnalysis(modules: [], sleep: 0.0, dynamicSleep: nil, onUserInput: false, requireFill: nil, requireFillThreshold: 1, requireFillDynamic: nil, timedRun: false, timedRunStartDelay: 0.0, timedRunStopDelay: 0.0, timeReference: ExperimentTimeReference(), sensorInputs: [], audioInputs: []), export: nil) invalid = true; @@ -273,7 +273,7 @@ final class Experiment { func saveLocally(quiet: Bool, presenter: UINavigationController?) throws { guard let source = self.source else { throw FileError.genericError } - + if !FileManager.default.fileExists(atPath: customExperimentsURL.path) { try FileManager.default.createDirectory(atPath: customExperimentsURL.path, withIntermediateDirectories: false, attributes: nil) } @@ -281,13 +281,13 @@ final class Experiment { var i = 1 let cleanedTitle = title.replacingOccurrences(of: "/", with: "") var experimentURL = customExperimentsURL.appendingPathComponent(cleanedTitle).appendingPathExtension(experimentFileExtension) - + while FileManager.default.fileExists(atPath: experimentURL.path) { experimentURL = customExperimentsURL.appendingPathComponent(cleanedTitle + "-\(i)").appendingPathExtension(experimentFileExtension) i += 1 } - + func moveFile(from fileURL: URL) throws { try FileManager.default.copyItem(at: fileURL, to: experimentURL) @@ -315,7 +315,7 @@ final class Experiment { } } } - + if source.isFileURL { try moveFile(from: source) } @@ -365,17 +365,17 @@ final class Experiment { gpsInput.onAuthorizationChange = { [weak gpsInput] newStatus in DispatchQueue.main.async { switch newStatus { - case .authorizedAlways, .authorizedWhenInUse: - onSuccess() - case .denied, .restricted: - failed() // Stop waterfall! + case .authorizedAlways, .authorizedWhenInUse: + onSuccess() + case .denied, .restricted: + failed() // Stop waterfall! self.showPermissionAlert(title: localize("permission_location_required"), message: localize("permission_location_denied"), onDismiss: failed) - case .notDetermined: - return - @unknown default: - break - } - gpsInput?.onAuthorizationChange = nil + case .notDetermined: + return + @unknown default: + break + } + gpsInput?.onAuthorizationChange = nil } } @@ -407,33 +407,33 @@ final class Experiment { else if requiredPermissions.contains(.camera) { let status = AVCaptureDevice.authorizationStatus(for: .video) - switch status { - case .denied, .restricted: - failed() - showPermissionAlert(title: localize("permission_camera_required"), message: localize("permission_camera_denied"), onDismiss: failed) - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video, completionHandler: { (allowed) in - DispatchQueue.main.async { + switch status { + case .denied, .restricted: + failed() + showPermissionAlert(title: localize("permission_camera_required"), message: localize("permission_camera_denied"), onDismiss: failed) + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video, completionHandler: { (allowed) in + DispatchQueue.main.async { if allowed { onSuccess() } else { failed() } } - }) - default: - break - } - } + }) + default: + break + } + } } private func showPermissionAlert(title: String, message: String, onDismiss: @escaping () -> Void) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in - onDismiss() - })) - - DispatchQueue.main.async { - UIApplication.shared.keyWindow?.rootViewController?.present(alert, animated: true, completion: nil) - } + + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + onDismiss() + })) + + DispatchQueue.main.async { + UIApplication.shared.keyWindow?.rootViewController?.present(alert, animated: true, completion: nil) } + } public func startAudio(countdown: Bool, stopExperimentDelegate: StopExperimentDelegate) throws { if audioEngine != nil { //Do not start twice. It could have been already started for a beeping countdown. @@ -471,14 +471,14 @@ final class Experiment { return } } - + timeReference.registerEvent(event: .START) bluetoothDevices.forEach { $0.writeEventCharacteristic(timeMapping: timeReference.timeMappings.last) } - + running = true - + hasStarted = true - + setKeepScreenOn(true) try startAudio(countdown: false, stopExperimentDelegate: stopExperimentDelegate) @@ -493,7 +493,7 @@ final class Experiment { gpsInputs.forEach { $0.start(queue: queue) } bluetoothInputs.forEach { $0.start(queue: queue) } networkConnections.forEach { $0.start() } - + analysis.running = true analysis.queue = queue analysis.setNeedsUpdate() @@ -505,7 +505,7 @@ final class Experiment { } analysis.running = false - + sensorInputs.forEach { $0.stop() } depthInput?.stop() cameraInput?.stop() @@ -531,13 +531,13 @@ final class Experiment { stop() timeReference.reset() hasStarted = false - + for buffer in buffers.values { if !buffer.attachedToTextField { buffer.clear(reset: true) } } - + sensorInputs.forEach { $0.clear() } depthInput?.clear() cameraInput?.clear() @@ -556,7 +556,7 @@ extension Experiment: ExperimentAnalysisDelegate { networkConnection.pushDataToBuffers() } } - + func analysisDidUpdate(_ analysis: ExperimentAnalysis) { analysisDelegate?.analysisDidUpdate(analysis) if running { @@ -581,45 +581,45 @@ extension Experiment { func metadataEqual(to rhs: Experiment?) -> Bool { guard let rhs = rhs else { return false } return localizedTitle == rhs.localizedTitle && - localizedCategory == rhs.localizedCategory && - localizedDescription == rhs.localizedDescription && - icon == rhs.icon && - color == rhs.color && - stateTitle == rhs.stateTitle && - appleBan == rhs.appleBan && - isLink == rhs.isLink && - localizedLinks == rhs.localizedLinks + localizedCategory == rhs.localizedCategory && + localizedDescription == rhs.localizedDescription && + icon == rhs.icon && + color == rhs.color && + stateTitle == rhs.stateTitle && + appleBan == rhs.appleBan && + isLink == rhs.isLink && + localizedLinks == rhs.localizedLinks } } extension Experiment: Equatable { static func ==(lhs: Experiment, rhs: Experiment) -> Bool { return lhs.title == rhs.title && - lhs.localizedDescription == rhs.localizedDescription && - lhs.localizedLinks == rhs.localizedLinks && - lhs.localizedCategory == rhs.localizedCategory && - lhs.icon == rhs.icon && - lhs.color == rhs.color && - lhs.local == rhs.local && - lhs.translation == rhs.translation && - lhs.buffers == rhs.buffers && - lhs.sensorInputs.elementsEqual(rhs.sensorInputs, by: { (l, r) -> Bool in - ExperimentSensorInput.valueEqual(lhs: l, rhs: r) - }) && - lhs.depthInput == rhs.depthInput && - lhs.gpsInputs == rhs.gpsInputs && - lhs.audioInputs == rhs.audioInputs && - lhs.audioOutput == rhs.audioOutput && - lhs.bluetoothDevices == rhs.bluetoothDevices && - lhs.bluetoothInputs == rhs.bluetoothInputs && - lhs.bluetoothOutputs == rhs.bluetoothOutputs && - lhs.networkConnections == rhs.networkConnections && - lhs.viewDescriptors == rhs.viewDescriptors && - lhs.analysis == rhs.analysis && - lhs.export == rhs.export && - lhs.stateTitle == rhs.stateTitle && - lhs.appleBan == rhs.appleBan && - lhs.isLink == rhs.isLink + lhs.localizedDescription == rhs.localizedDescription && + lhs.localizedLinks == rhs.localizedLinks && + lhs.localizedCategory == rhs.localizedCategory && + lhs.icon == rhs.icon && + lhs.color == rhs.color && + lhs.local == rhs.local && + lhs.translation == rhs.translation && + lhs.buffers == rhs.buffers && + lhs.sensorInputs.elementsEqual(rhs.sensorInputs, by: { (l, r) -> Bool in + ExperimentSensorInput.valueEqual(lhs: l, rhs: r) + }) && + lhs.depthInput == rhs.depthInput && + lhs.gpsInputs == rhs.gpsInputs && + lhs.audioInputs == rhs.audioInputs && + lhs.audioOutput == rhs.audioOutput && + lhs.bluetoothDevices == rhs.bluetoothDevices && + lhs.bluetoothInputs == rhs.bluetoothInputs && + lhs.bluetoothOutputs == rhs.bluetoothOutputs && + lhs.networkConnections == rhs.networkConnections && + lhs.viewDescriptors == rhs.viewDescriptors && + lhs.analysis == rhs.analysis && + lhs.export == rhs.export && + lhs.stateTitle == rhs.stateTitle && + lhs.appleBan == rhs.appleBan && + lhs.isLink == rhs.isLink } } diff --git a/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift b/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift index 83013ed2..6b71bd28 100644 --- a/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift +++ b/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift @@ -86,7 +86,7 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } private var hasCompletedInitialPermissionCheck = false - + private enum DialogSequence { // Sequence is a per the priority case systemPermissions @@ -1442,112 +1442,112 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController case .dataPolicy: if let networkConnection = experiment.networkConnections.first { - let sensorList = experiment.sensorInputs.map { $0.sensorType.getLocalizedName() } - networkConnection.showDataAndPolicy( - infoMicrophone: experiment.audioInputs.count > 0, - infoLocation: experiment.gpsInputs.count > 0, - infoSensorData: experiment.sensorInputs.count > 0, - infoSensorDataList: sensorList, - callback: self - ) - } else { - executeSequence(from: .bluetoothConnections) - } + let sensorList = experiment.sensorInputs.map { $0.sensorType.getLocalizedName() } + networkConnection.showDataAndPolicy( + infoMicrophone: experiment.audioInputs.count > 0, + infoLocation: experiment.gpsInputs.count > 0, + infoSensorData: experiment.sensorInputs.count > 0, + infoSensorDataList: sensorList, + callback: self + ) + } else { + executeSequence(from: .bluetoothConnections) + } case .bluetoothConnections: if experiment.bluetoothDevices.count > 0 { - connectToBluetoothDevices() - } else { - executeSequence(from: .networkConnections) - } + connectToBluetoothDevices() + } else { + executeSequence(from: .networkConnections) + } case .networkConnections: if experiment.networkConnections.count > 0 { - connectToNetworkDevices() - } else { - executeSequence(from: .photosensitivity) - } + connectToNetworkDevices() + } else { + executeSequence(from: .photosensitivity) + } case .photosensitivity: if let flashlight = experiment.flashlightOutput, - flashlight.hasStrobeController() && SafetyManager.needsWarning { - if flashlight.isStrobeActiveWithFrequency() || flashlight.isStrobeUsingBuffer() { - showStrobeWarning { [weak self] in - self?.executeSequence(from: .saveLocally) - } - return - } - } + flashlight.hasStrobeController() && SafetyManager.needsWarning { + if flashlight.isStrobeActiveWithFrequency() || flashlight.isStrobeUsingBuffer() { + showStrobeWarning { [weak self] in + self?.executeSequence(from: .saveLocally) + } + return + } + } executeSequence(from: .saveLocally) case .saveLocally: if !experiment.local && !ExperimentManager.shared.experimentInCollection(crc32: experiment.crc32) { - UIAlertController.PhyphoxUIAlertBuilder() - .title(title: localize("save_locally")) - .message(message: localize("save_locally_message")) - .preferredStyle(style: .alert) - .addActionWithTitle(localize("save_locally_button"), style: .default, handler: { [weak self] _ in - do { try self?.saveLocally() } catch { print(error) } - self?.executeSequence(from: .hints) - }) - .addCancelAction { [weak self] _ in - self?.executeSequence(from: .hints) - } - .show(in: self.navigationController!, animated: true) - } else { - executeSequence(from: .hints) - } + UIAlertController.PhyphoxUIAlertBuilder() + .title(title: localize("save_locally")) + .message(message: localize("save_locally_message")) + .preferredStyle(style: .alert) + .addActionWithTitle(localize("save_locally_button"), style: .default, handler: { [weak self] _ in + do { try self?.saveLocally() } catch { print(error) } + self?.executeSequence(from: .hints) + }) + .addCancelAction { [weak self] _ in + self?.executeSequence(from: .hints) + } + .show(in: self.navigationController!, animated: true) + } else { + executeSequence(from: .hints) + } case .hints: presentNextHint() } } func showStrobeWarning(completion: @escaping () -> Void) { - let alert = UIAlertController( - title: localize("warning_photosensitivity"), - message: localize("warning_photosensitivity_message"), - preferredStyle: .alert - ) - - let proceedAction = UIAlertAction(title: localize("dont_remind"), style: .default) { _ in - SafetyManager.acknowledge() - completion() - } - - let okAction = UIAlertAction(title: localize("ok"), style: .cancel) { _ in - completion() - } - - alert.addAction(proceedAction) - alert.addAction(okAction) - - self.present(alert, animated: true, completion: nil) + let alert = UIAlertController( + title: localize("warning_photosensitivity"), + message: localize("warning_photosensitivity_message"), + preferredStyle: .alert + ) + + let proceedAction = UIAlertAction(title: localize("dont_remind"), style: .default) { _ in + SafetyManager.acknowledge() + completion() } + + let okAction = UIAlertAction(title: localize("ok"), style: .cancel) { _ in + completion() + } + + alert.addAction(proceedAction) + alert.addAction(okAction) + + self.present(alert, animated: true, completion: nil) + } private var hasShownStartHintThisSession = false private var hasShownInfoHintThisSession = false private func presentNextHint() { - let defaults = UserDefaults.standard + let defaults = UserDefaults.standard + + if let playItem = playItem, hintBubble == nil, !hasShownStartHintThisSession { + hasShownStartHintThisSession = true - if let playItem = playItem, hintBubble == nil, !hasShownStartHintThisSession { - hasShownStartHintThisSession = true - - let key = "experiment_start_hint_dismiss_count" - if defaults.integer(forKey: key) < 3 { - showHintBubble(text: localize("start_hint"), item: playItem, defaultsKey: key) - return // Pause for this hint - } + let key = "experiment_start_hint_dismiss_count" + if defaults.integer(forKey: key) < 3 { + showHintBubble(text: localize("start_hint"), item: playItem, defaultsKey: key) + return // Pause for this hint } + } + + if let actionItem = actionItem, hintBubble == nil, !hasShownInfoHintThisSession && (experiment.localizedCategory != localize("categoryRawSensor")) { + hasShownInfoHintThisSession = true - if let actionItem = actionItem, hintBubble == nil, !hasShownInfoHintThisSession && (experiment.localizedCategory != localize("categoryRawSensor")) { - hasShownInfoHintThisSession = true - - let key = "experiment_info_hint_dismiss_count" - if defaults.integer(forKey: key) < 3 { - showHintBubble(text: localize("experimentinfo_hint"), item: actionItem, defaultsKey: key) - return - } + let key = "experiment_info_hint_dismiss_count" + if defaults.integer(forKey: key) < 3 { + showHintBubble(text: localize("experimentinfo_hint"), item: actionItem, defaultsKey: key) + return } } - + } + private func showHintBubble(text: String, item: UIBarButtonItem, defaultsKey: String) { hintBubble = HintBubbleViewController(text: text, onDismiss: { [weak self] in let defaults = UserDefaults.standard From cc686d469966492f08b51735b4b59a3c906a2d83 Mon Sep 17 00:00:00 2001 From: GTripathee Date: Mon, 30 Mar 2026 14:45:28 +0200 Subject: [PATCH 8/8] show warning when the device is getting hot and stop the flashlight. --- .../Experiments/FlashlightOutput.swift | 94 ++++++++++++++----- phyphox-iOS/phyphox/Info.plist | 2 +- .../ExperimentPageViewController.swift | 21 +++++ .../phyphox/en.lproj/Localizable.strings | 3 + 4 files changed, 94 insertions(+), 26 deletions(-) diff --git a/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift index a74b8db6..82aa0946 100644 --- a/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift +++ b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift @@ -6,15 +6,58 @@ import Foundation class FlashlightOutput { private let manager = FlashlightManager() private var controllers: [FlashlightController] = [] - + + var onThermalWarning: ((ProcessInfo.ThermalState) -> Void)? + private(set) var isOverheated: Bool = false + + init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(thermalStateDidChange), + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil + ) + checkThermalState() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + func start() { + guard !isOverheated else { + onThermalWarning?(ProcessInfo.processInfo.thermalState) + return + } + controllers.forEach { $0.start() } } - + func stop() { controllers.forEach { $0.stop() } } - + + @objc private func thermalStateDidChange() { + checkThermalState() + } + + private func checkThermalState() { + let state = ProcessInfo.processInfo.thermalState + + if state == .serious || state == .critical { + isOverheated = true + manager.isOverheated = true + stop() + + DispatchQueue.main.async { [weak self] in + self?.onThermalWarning?(state) + } + } else { + isOverheated = false + manager.isOverheated = false + } + } + func attachController(_ controller: FlashlightController) { controllers.append(controller) } @@ -41,29 +84,29 @@ class FlashlightOutput { } return strobe.isBufferSource() } - + protocol FlashlightController { func start() func stop() var isActive: Bool { get } } - + // MARK: - Strobe Controller class StrobeController: FlashlightController { private let manager: FlashlightManager private let parameter: FlashlightParameter private(set) var isActive: Bool = false private var lastFrequency: Double = -1.0 - + init(manager: FlashlightManager, parameter: FlashlightParameter) { self.manager = manager self.parameter = parameter } func getCurrentFrequency() -> Double { return parameter.getValue() ?? 0.0 } - + func isBufferSource() -> Bool { return parameter.isBuffer } - + func start() { let currentFreq = parameter.getValue() ?? 0.0 @@ -80,7 +123,7 @@ class FlashlightOutput { manager.pokeLoop() } } - + func stop() { guard isActive else { return } isActive = false @@ -88,30 +131,29 @@ class FlashlightOutput { manager.stopStrobe() } } - + // MARK: - Intensity Controller class IntensityController: FlashlightController { private let manager: FlashlightManager private let parameter: FlashlightParameter private(set) var isActive: Bool = false private var lastValue: Float = -1.0 - + init(manager: FlashlightManager, parameter: FlashlightParameter) { self.manager = manager self.parameter = parameter } - + func start() { isActive = true let val = Float(parameter.getValue() ?? 1.0) - // Value-tracking prevents redundant hardware commands if start() is called in a fast loop. if abs(val - lastValue) > 0.001 { lastValue = val manager.setIntensity(val) } } - + func stop() { isActive = false lastValue = -1.0 @@ -122,11 +164,10 @@ class FlashlightOutput { // MARK: - Flashlight Manager Engine -/// The engine responsible for thread safety and direct interaction with AVCaptureDevice. +/// The manager responsible for thread safety and direct interaction with AVCaptureDevice. class FlashlightManager { private let device = AVCaptureDevice.default(for: .video) - /// Dedicated queue for all hardware interactions to prevent UI blocking and race conditions. private let hardwareQueue = DispatchQueue(label: "de.rwth.flashlight.hardware", qos: .userInteractive) /// Used to manage the strobe timing and allow immediate interruption of "sleep" states. private let strobeCondition = NSCondition() @@ -138,7 +179,8 @@ class FlashlightManager { // Hardware state cache used to guard the redudent calls private var lastAppliedLevel: Float = -1.0 private var lastAppliedOn: Bool = false - + + var isOverheated: Bool = false func setIntensity(_ level: Float) { hardwareQueue.async { [weak self] in @@ -151,14 +193,14 @@ class FlashlightManager { } } } - + /// Forces the background strobe thread to wake up from its current wait interval. func pokeLoop() { strobeCondition.lock() strobeCondition.broadcast() // Wakes any thread currently at strobeCondition.wait() strobeCondition.unlock() } - + /// Initializes and starts the background strobe thread. func startStrobe(provider: @escaping () -> Double) { hardwareQueue.async { [weak self] in @@ -174,7 +216,7 @@ class FlashlightManager { } } } - + /// The core logic loop running on a background thread. private func runStrobeLoop() { // Keeps track of which phase we are in so rapid changes don't "restart" the pulse @@ -222,7 +264,7 @@ class FlashlightManager { strobeCondition.unlock() } } - + func stopStrobe() { hardwareQueue.async { [weak self] in self?.isStrobeActive = false @@ -236,27 +278,29 @@ class FlashlightManager { self?.applyTorch(on: false, level: 0) } } - + /// Ensures that strobe-thread requests are passed through the serial hardwareQueue. private func applyTorchInQueue(on: Bool, level: Float) { hardwareQueue.sync { self.applyTorch(on: on, level: level) } } - + /// The only point in the code that talks to AVCaptureDevice. private func applyTorch(on: Bool, level: Float) { guard let device = device, device.hasTorch, device.isTorchAvailable else { return } + if isOverheated { return } + let safeLevel = max(0.01, min(level, 1.0)) let targetOn = on && level > 0 - + // REDUNDANCY GUARD: // Comparing current request with last applied hardware state. // This prevents the "Lag" caused by spamming hardware locks. if targetOn == lastAppliedOn && abs(safeLevel - lastAppliedLevel) < 0.001 && targetOn == true { return } if targetOn == false && lastAppliedOn == false { return } - + do { try device.lockForConfiguration() if targetOn { diff --git a/phyphox-iOS/phyphox/Info.plist b/phyphox-iOS/phyphox/Info.plist index 4935a338..5e28f7ec 100644 --- a/phyphox-iOS/phyphox/Info.plist +++ b/phyphox-iOS/phyphox/Info.plist @@ -52,7 +52,7 @@ CFBundleVersion - 17345 + 17346 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift b/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift index 6b71bd28..b3cee58c 100644 --- a/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift +++ b/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift @@ -395,6 +395,8 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController ) } + setupThermalWarningHandler() + } override func viewWillDisappear(_ animated: Bool) { @@ -1657,6 +1659,25 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } } + private func setupThermalWarningHandler() { + experiment.flashlightOutput?.onThermalWarning = { [weak self] state in + + let title = localize("device_overheating") + let message: String + + if state == .critical { + message = localize("device_heating_critical") + } else { + message = localize("device_heating_serious") + } + + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + + self?.present(alert, animated: true, completion: nil) + } + } + } extension ExperimentPageViewController: ExperimentAnalysisDelegate { diff --git a/phyphox-iOS/phyphox/en.lproj/Localizable.strings b/phyphox-iOS/phyphox/en.lproj/Localizable.strings index a2259954..b45d7bf6 100644 --- a/phyphox-iOS/phyphox/en.lproj/Localizable.strings +++ b/phyphox-iOS/phyphox/en.lproj/Localizable.strings @@ -183,6 +183,9 @@ "warning_photosensitivity" = "Photosensitivity Warning"; "warning_photosensitivity_message" = "This experiment may produces high-frequency light pulses. It may trigger discomfort or seizures for individuals with photosensitive epilepsy. \n\nAvoid staring directly at the light source."; "dont_remind" = "I understand & don't remind me"; +"device_overheating" = "Device Overheating"; +"device_heating_critical" = "The device is critically hot. The flashlight has been disabled to prevent damage."; +"device_heating_serious" = "The device is getting very warm. The flashlight has been turned off to cool down."; "remoteColorMapWarning" = "The color plot in the remote interface is only an approximation. In contrast to the in-app plot, at the moment it cannot handle non-equidistant data or logarithmic scaling on the x and y axis. So, data at varying intervals may appear at the wrong location."; "remoteDepthGUIWarning" = "Previewing and controlling the LiDAR/ToF sensor on the remote interface is not supported."; "remoteCameraWarning" = "Previewing and controlling the camera sensor on the remote interface is not supported.";