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 1ce8d339..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,17 +142,19 @@ 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?, 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 @@ -162,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 @@ -183,6 +185,8 @@ final class Experiment { self.audioOutput = audioOutput + self.flashlightOutput = flashlightOutput + self.bluetoothDevices = bluetoothDevices self.bluetoothInputs = bluetoothInputs self.bluetoothOutputs = bluetoothOutputs @@ -225,9 +229,9 @@ 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, 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; } @@ -242,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) @@ -267,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) } @@ -275,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) @@ -309,7 +315,7 @@ final class Experiment { } } } - + if source.isFileURL { try moveFile(from: source) } @@ -322,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: + case .authorized: + onSuccess() + case .denied, .restricted: 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: - 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) { @@ -354,78 +352,87 @@ 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 + } + } - default: + gpsInput.locationManager.requestWhenInUseAuthorization() + + @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 } } 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: - 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) - - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video, completionHandler: { (allowed) in - if !allowed { - failed() - } - }) - break - - default: - break - } - } + 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 + } + } + } + + 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 { @@ -464,18 +471,20 @@ 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) + flashlightOutput?.start() + MotionSession.sharedSession().resetConfig() sensorInputs.forEach{ $0.configureMotionSession() } sensorInputs.forEach { $0.start(queue: queue) } @@ -484,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() @@ -496,7 +505,7 @@ final class Experiment { } analysis.running = false - + sensorInputs.forEach { $0.stop() } depthInput?.stop() cameraInput?.stop() @@ -506,6 +515,8 @@ final class Experiment { stopAudio() + flashlightOutput?.stop() + setKeepScreenOn(false) running = false @@ -520,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() @@ -545,7 +556,7 @@ extension Experiment: ExperimentAnalysisDelegate { networkConnection.pushDataToBuffers() } } - + func analysisDidUpdate(_ analysis: ExperimentAnalysis) { analysisDelegate?.analysisDidUpdate(analysis) if running { @@ -557,6 +568,7 @@ extension Experiment: ExperimentAnalysisDelegate { networkConnection.pushDataToBuffers() networkConnection.doExecute() } + flashlightOutput?.start() } } @@ -569,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/Experiments/FlashlightOutput.swift b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift new file mode 100644 index 00000000..82aa0946 --- /dev/null +++ b/phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift @@ -0,0 +1,319 @@ +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] = [] + + 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) + } + + 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() + 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 + + 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() + } + } + + 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 + self.parameter = parameter + } + + func start() { + isActive = true + let val = Float(parameter.getValue() ?? 1.0) + + 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 manager responsible for thread safety and direct interaction with AVCaptureDevice. +class FlashlightManager { + private let device = AVCaptureDevice.default(for: .video) + + 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 frequencyProvider: (() -> Double)? + + // 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 + guard let self = self else { return } + self.currentIntensity = level + 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 } + self.frequencyProvider = provider + + 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 { + strobeCondition.lock() + + if !self.isStrobeActive { + self.applyTorchInQueue(on: false, level: 0) + strobeCondition.unlock() + 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 + } + + // 2. STROBE MODE + let interval = 1.0 / freq + let halfInterval = interval / 2.0 + + // 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)) + + strobeCondition.unlock() + } + } + + func stopStrobe() { + hardwareQueue.async { [weak self] in + self?.isStrobeActive = false + self?.pokeLoop() + } + } + + func turnOff() { + stopStrobe() + hardwareQueue.async { [weak self] in + 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 { + try device.setTorchModeOn(level: safeLevel) + lastAppliedLevel = safeLevel + lastAppliedOn = true + } else { + device.torchMode = .off + lastAppliedOn = false + } + device.unlockForConfiguration() + } catch { + // If the hardware is locked by another process, skip this frame to keep loop timing consistent + } + } +} 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..2a3e9510 100644 --- a/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/PhyphoxElementHandler.swift +++ b/phyphox-iOS/phyphox/Experiments/Serialization/Handlers/PhyphoxElementHandler.swift @@ -238,6 +238,9 @@ 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 +380,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 +594,47 @@ final class PhyphoxElementHandler: ResultElementHandler, LookupElementHandler { } } + + 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() + + 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 = .buffer(buffer: buffer) + + case .value(value: let value, usedAs: let usedAs): + target = usedAs + parameter = .value(value: value) + } + + 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 flashlightOutput + + } private func makeAudioOutput(from descriptor: AudioOutputDescriptor?, buffers: [String: DataBuffer]) throws -> ExperimentAudioOutput? { 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 89681c0a..5e28f7ec 100644 --- a/phyphox-iOS/phyphox/Info.plist +++ b/phyphox-iOS/phyphox/Info.plist @@ -52,7 +52,7 @@ CFBundleVersion - 17276 + 17346 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift b/phyphox-iOS/phyphox/UI/MainView/ExperimentView/ExperimentPageViewController.swift index ae656704..b3cee58c 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 } @@ -390,6 +395,17 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController ) } + setupThermalWarningHandler() + + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if let floatingBubble = hintBubble { + floatingBubble.dismiss(animated: false, completion: nil) + hintBubble = nil + } } @objc func handleDeviceRotationToControlSpectrumOrientation() { @@ -468,62 +484,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) - } - } - } - } - override func viewDidAppear(_ animated: Bool) { if #available(iOS 14.0, *) { for vc in experimentViewControllers { @@ -551,19 +511,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 isMovingToParent && !hasCompletedInitialPermissionCheck { + executeSequence(from: .systemPermissions) } + } + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) @@ -1467,17 +1422,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 } } @@ -1491,16 +1587,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(){ @@ -1516,7 +1607,7 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController return } } - showOptionalDialogsAndHints() + executeSequence(from: .photosensitivity) } func networkScanDialogDismissed() { @@ -1530,11 +1621,7 @@ final class ExperimentPageViewController: UIViewController, UIPageViewController } func dataPolicyInfoDismissed() { - if experiment.bluetoothDevices.count > 0 { - connectToBluetoothDevices() - } else { - connectToNetworkDevices() - } + executeSequence(from: .bluetoothConnections) } func refreshAppTheme(){ @@ -1572,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 bc732889..b45d7bf6 100644 --- a/phyphox-iOS/phyphox/en.lproj/Localizable.strings +++ b/phyphox-iOS/phyphox/en.lproj/Localizable.strings @@ -180,6 +180,12 @@ "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"; +"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.";