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.";