Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 93 additions & 109 deletions iosApp/iosApp/AlarmKitWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -445,73 +445,7 @@ class AlarmKitWrapperImpl: NSObject {
}
}

/// Schedule an alarm using AlarmKit
/// - Returns: true if successfully scheduled, false otherwise
func scheduleAlarm(request: AlarmScheduleRequest, completion: AlarmScheduleCompletion) {
scheduleWithAlarmKit(request: request, completion: completion)
}

private func occurrenceUUID(alarmId: Int64, key: String) -> UUID {
let bytes = Array(SHA256.hash(data: Data("mathalarm/\(alarmId)/\(key)".utf8)))
return UUID(uuid: (bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]))
}

private func recoveryIDs(alarmId: Int64) -> [UUID] {
["recovery", "recovery_standby"].map { occurrenceUUID(alarmId: alarmId, key: $0) }
}

func cancelOccurrence(alarmId: Int64, occurrenceKey: String) -> String? {
let priorRecovery = occurrenceKey == "recovery" ? AlarmRecoveryStore.shared.current(alarmId: alarmId) : nil
if occurrenceKey == "recovery", !AlarmRecoveryStore.shared.cancel(alarmId: alarmId) {
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
return "Could not persist recovery cancellation"
}
let ids = occurrenceKey == "recovery" ? recoveryIDs(alarmId: alarmId) :
[occurrenceUUID(alarmId: alarmId, key: occurrenceKey)]
do {
let manager = AlarmManager.shared
let nativeIDs = Set(try manager.alarms.map(\.id))
var failures: [String] = []
for id in ids {
do {
if nativeIDs.contains(id) { try manager.cancel(id: id) }
if !AlarmDataStore.shared.remove(alarmUUID: id.uuidString) {
failures.append("Could not persist native cancellation")
}
} catch { failures.append(error.localizedDescription) }
}
if !failures.isEmpty {
if let priorRecovery {
AlarmRecoveryStore.shared.restoreAfterFailedCancellation(alarmId: alarmId, session: priorRecovery)
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
}
return failures.joined(separator: "; ")
}
return nil
} catch {
if let priorRecovery {
AlarmRecoveryStore.shared.restoreAfterFailedCancellation(alarmId: alarmId, session: priorRecovery)
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
}
print("Failed to cancel occurrence: \(error)")
return error.localizedDescription
}
}

/// Cancel an alarm scheduled with AlarmKit
func cancelAlarm(alarmId: Int64) -> String? {
return cancelAlarmKitAlarm(alarmId: alarmId)
}

// MARK: - AlarmKit Implementation (iOS 26+)

// Typealias for AlarmConfiguration with our metadata type
@available(iOS 26, *)
typealias MathAlarmConfiguration = AlarmManager.AlarmConfiguration<MathAlarmData>

@available(iOS 26, *)
private func scheduleWithAlarmKit(request: AlarmScheduleRequest, completion: AlarmScheduleCompletion) {
let alarmId = request.alarmId
let hour = request.hour
let minute = request.minute
Expand Down Expand Up @@ -625,6 +559,99 @@ class AlarmKitWrapperImpl: NSObject {

}

private func occurrenceUUID(alarmId: Int64, key: String) -> UUID {
let bytes = Array(SHA256.hash(data: Data("mathalarm/\(alarmId)/\(key)".utf8)))
return UUID(uuid: (bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]))
}

private func recoveryIDs(alarmId: Int64) -> [UUID] {
["recovery", "recovery_standby"].map { occurrenceUUID(alarmId: alarmId, key: $0) }
}

func cancelOccurrence(alarmId: Int64, occurrenceKey: String) -> String? {
let priorRecovery = occurrenceKey == "recovery" ? AlarmRecoveryStore.shared.current(alarmId: alarmId) : nil
if occurrenceKey == "recovery", !AlarmRecoveryStore.shared.cancel(alarmId: alarmId) {
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
return "Could not persist recovery cancellation"
}
let ids = occurrenceKey == "recovery" ? recoveryIDs(alarmId: alarmId) :
[occurrenceUUID(alarmId: alarmId, key: occurrenceKey)]
do {
let manager = AlarmManager.shared
let nativeIDs = Set(try manager.alarms.map(\.id))
var failures: [String] = []
for id in ids {
do {
if nativeIDs.contains(id) { try manager.cancel(id: id) }
if !AlarmDataStore.shared.remove(alarmUUID: id.uuidString) {
failures.append("Could not persist native cancellation")
}
} catch { failures.append(error.localizedDescription) }
}
if !failures.isEmpty {
if let priorRecovery {
AlarmRecoveryStore.shared.restoreAfterFailedCancellation(alarmId: alarmId, session: priorRecovery)
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
}
return failures.joined(separator: "; ")
}
return nil
} catch {
if let priorRecovery {
AlarmRecoveryStore.shared.restoreAfterFailedCancellation(alarmId: alarmId, session: priorRecovery)
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
}
print("Failed to cancel occurrence: \(error)")
return error.localizedDescription
}
}

func cancelAlarm(alarmId: Int64) -> String? {
let priorRecovery = AlarmRecoveryStore.shared.current(alarmId: alarmId)
let recoveryID = occurrenceUUID(alarmId: alarmId, key: "recovery")
let priorRecoveryMetadata = AlarmDataStore.shared.retrieve(alarmUUID: recoveryID.uuidString)
guard AlarmRecoveryStore.shared.cancel(alarmId: alarmId) else {
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
return "Could not persist recovery cancellation"
}
do {
let manager = AlarmManager.shared
// Query OS alarms and persisted metadata so cancellation also works after relaunch.
let keys = (0...6).map { "day_\($0)" } + ["snooze", "recovery", "recovery_standby"]
let ids = Set(keys.map { occurrenceUUID(alarmId: alarmId, key: $0) })
var failures: [String] = []
for alarm in try manager.alarms {
if ids.contains(alarm.id) || AlarmDataStore.shared.retrieve(alarmUUID: alarm.id.uuidString)?.alarmId == alarmId {
do {
try manager.cancel(id: alarm.id)
if !AlarmDataStore.shared.remove(alarmUUID: alarm.id.uuidString) {
failures.append("\(alarm.id): Could not persist native cancellation")
}
} catch {
failures.append("\(alarm.id): \(error.localizedDescription)")
}
}
}
if !failures.isEmpty, let priorRecovery {
AlarmRecoveryStore.shared.restoreAfterFailedCancellation(alarmId: alarmId, session: priorRecovery)
if let priorRecoveryMetadata { AlarmDataStore.shared.store(alarmUUID: recoveryID, data: priorRecoveryMetadata) }
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
}
return failures.isEmpty ? nil : failures.joined(separator: "; ")
} catch {
if let priorRecovery {
AlarmRecoveryStore.shared.restoreAfterFailedCancellation(alarmId: alarmId, session: priorRecovery)
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
}
print("Failed to list alarms for cancellation: \(error)")
return error.localizedDescription
}
}

@available(iOS 26, *)
typealias MathAlarmConfiguration = AlarmManager.AlarmConfiguration<MathAlarmData>

private func configuration(schedule: AlarmKit.Alarm.Schedule, metadata: MathAlarmData, id: UUID) -> MathAlarmConfiguration {
let alert = AlarmPresentation.Alert(
title: metadata.title.isEmpty ? LocalizedStringResource("Solve Math") : LocalizedStringResource(stringLiteral: metadata.title),
Expand Down Expand Up @@ -932,49 +959,6 @@ class AlarmKitWrapperImpl: NSObject {
}
}

@available(iOS 26, *)
private func cancelAlarmKitAlarm(alarmId: Int64) -> String? {
let priorRecovery = AlarmRecoveryStore.shared.current(alarmId: alarmId)
let recoveryID = occurrenceUUID(alarmId: alarmId, key: "recovery")
let priorRecoveryMetadata = AlarmDataStore.shared.retrieve(alarmUUID: recoveryID.uuidString)
guard AlarmRecoveryStore.shared.cancel(alarmId: alarmId) else {
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
return "Could not persist recovery cancellation"
}
do {
let manager = AlarmManager.shared
// Query OS alarms and persisted metadata so cancellation also works after relaunch.
let keys = (0...6).map { "day_\($0)" } + ["snooze", "recovery", "recovery_standby"]
let ids = Set(keys.map { occurrenceUUID(alarmId: alarmId, key: $0) })
var failures: [String] = []
for alarm in try manager.alarms {
if ids.contains(alarm.id) || AlarmDataStore.shared.retrieve(alarmUUID: alarm.id.uuidString)?.alarmId == alarmId {
do {
try manager.cancel(id: alarm.id)
if !AlarmDataStore.shared.remove(alarmUUID: alarm.id.uuidString) {
failures.append("\(alarm.id): Could not persist native cancellation")
}
} catch {
failures.append("\(alarm.id): \(error.localizedDescription)")
}
}
}
if !failures.isEmpty, let priorRecovery {
AlarmRecoveryStore.shared.restoreAfterFailedCancellation(alarmId: alarmId, session: priorRecovery)
if let priorRecoveryMetadata { AlarmDataStore.shared.store(alarmUUID: recoveryID, data: priorRecoveryMetadata) }
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
}
return failures.isEmpty ? nil : failures.joined(separator: "; ")
} catch {
if let priorRecovery {
AlarmRecoveryStore.shared.restoreAfterFailedCancellation(alarmId: alarmId, session: priorRecovery)
IosApplication.shared.reportRecoveryFailure(alarmId: alarmId)
}
print("Failed to list alarms for cancellation: \(error)")
return error.localizedDescription
}
}

/// Convert repeat days string to array of Locale.Weekday
/// Input: "TFFFTFF" where T=true, F=false, index 0=Sunday
/// Output: Set<Locale.Weekday>
Expand Down
4 changes: 0 additions & 4 deletions iosApp/iosApp/NativeRootSessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,6 @@ final class NativeWindowSessions: ObservableObject {
selectedEditorID == id && navigationObservers[id] == observer
}

func endNavigation(id: String, observer: Int) {
if navigationObservers[id] == observer { navigationObservers.removeValue(forKey: id) }
}

func setEditorPath(_ path: [NativeEditorDestination], id: String, observer: Int? = nil) {
// SwiftUI can write an outgoing stack binding during detail teardown.
// An old observer must not clear the retained path of an inactive draft.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ data class AlarmScheduleRequest(

data class AlarmScheduleResult(
val success: Boolean,
val usedAlarmKit: Boolean,
val errorMessage: String? = null
)

Expand Down Expand Up @@ -50,12 +49,12 @@ object AlarmSchedulerBridge {
fun registerScheduler(scheduler: NativeAlarmScheduler) { nativeScheduler = scheduler }
fun isAlarmKitAvailable(): Boolean = nativeScheduler?.isAlarmKitAvailable() == true
suspend fun scheduleWithAlarmKit(request: AlarmScheduleRequest): AlarmScheduleResult {
val scheduler = nativeScheduler ?: return AlarmScheduleResult(false, false)
if (!scheduler.isAlarmKitAvailable()) return AlarmScheduleResult(false, false)
val scheduler = nativeScheduler ?: return AlarmScheduleResult(false)
if (!scheduler.isAlarmKitAvailable()) return AlarmScheduleResult(false)
return suspendCoroutine { continuation ->
scheduler.scheduleAlarm(request, object : AlarmScheduleCompletion {
override fun complete(success: Boolean, error: String?) {
continuation.resume(AlarmScheduleResult(success, true, error))
continuation.resume(AlarmScheduleResult(success, error))
}
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,6 @@ object IosAlarmAudioManager {
}
}

/**
* Check if alarm is currently active
*/
fun isPlaying(): Boolean = isAlarmActive

/** Silent PiP still needs a playback session. Real alarms and tone previews win. */
fun beginSettingsGuideAudio(ownerId: String, onInterrupted: () -> Unit): Boolean {
require(ownerId.isNotBlank())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,15 @@ import platform.UIKit.UIImpactFeedbackStyle
@OptIn(ExperimentalForeignApi::class)
actual class PlatformVibrator actual constructor() {
private val feedbackGenerator = UIImpactFeedbackGenerator(style = UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy)
private var isVibrating = false

actual fun startWaveform(pattern: LongArray, repeat: Int) {
isVibrating = true
// Use system vibration
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
// Also trigger haptic feedback for modern devices
feedbackGenerator.prepare()
feedbackGenerator.impactOccurred()
}

actual fun cancel() {
isVibrating = false
// iOS doesn't have a way to cancel vibration mid-vibrate
// but we can stop the loop by setting flag
// System vibration and impact feedback are one-shot and cannot be cancelled.
}
}

Expand Down
Loading