diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/models/AlarmSettings.kt b/android/src/main/kotlin/com/gdelataillade/alarm/models/AlarmSettings.kt index dd147b8..d852aee 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/models/AlarmSettings.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/models/AlarmSettings.kt @@ -2,12 +2,17 @@ package com.gdelataillade.alarm.models import com.gdelataillade.alarm.generated.AlarmSettingsWire import kotlinx.serialization.KSerializer -import java.util.Date import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* +import java.time.Duration +import kotlin.time.toKotlinDuration +import java.util.Date @Serializable data class AlarmSettings( @@ -21,7 +26,7 @@ data class AlarmSettings( val vibrate: Boolean, val warningNotificationOnKill: Boolean, val androidFullScreenIntent: Boolean, - val allowAlarmOverlap: Boolean = false + val allowAlarmOverlap: Boolean = false // Defaults to false for backward compatibility ) { companion object { fun fromWire(e: AlarmSettingsWire): AlarmSettings { @@ -38,11 +43,74 @@ data class AlarmSettings( e.allowAlarmOverlap ) } + + /** + * Handles backward compatibility for missing fields like `volumeSettings` and `allowAlarmOverlap`. + */ + fun fromJson(json: String): AlarmSettings { + val jsonObject = Json.parseToJsonElement(json).jsonObject + + val id = jsonObject.primitiveInt("id") ?: throw SerializationException("Missing 'id'") + val dateTimeMillis = jsonObject.primitiveLong("dateTime") ?: throw SerializationException("Missing 'dateTime'") + val assetAudioPath = jsonObject.primitiveString("assetAudioPath") ?: throw SerializationException("Missing 'assetAudioPath'") + val notificationSettings = jsonObject["notificationSettings"]?.let { + Json.decodeFromJsonElement(NotificationSettings.serializer(), it) + } ?: throw SerializationException("Missing 'notificationSettings'") + val loopAudio = jsonObject.primitiveBoolean("loopAudio") ?: throw SerializationException("Missing 'loopAudio'") + val vibrate = jsonObject.primitiveBoolean("vibrate") ?: throw SerializationException("Missing 'vibrate'") + val warningNotificationOnKill = jsonObject.primitiveBoolean("warningNotificationOnKill") + ?: throw SerializationException("Missing 'warningNotificationOnKill'") + val androidFullScreenIntent = jsonObject.primitiveBoolean("androidFullScreenIntent") + ?: throw SerializationException("Missing 'androidFullScreenIntent'") + + // Handle backward compatibility for `allowAlarmOverlap` + val allowAlarmOverlap = jsonObject.primitiveBoolean("allowAlarmOverlap") ?: false + + // Handle backward compatibility for `volumeSettings` + val volumeSettings = jsonObject["volumeSettings"]?.let { + Json.decodeFromJsonElement(VolumeSettings.serializer(), it) + } ?: run { + val volume = jsonObject.primitiveDouble("volume") + val fadeDurationSeconds = jsonObject.primitiveDouble("fadeDuration") + val fadeDuration = fadeDurationSeconds?.let { Duration.ofMillis((it * 1000).toLong()) } + val volumeEnforced = jsonObject.primitiveBoolean("volumeEnforced") ?: false + + VolumeSettings( + volume = volume, + fadeDuration = fadeDuration?.toKotlinDuration(), + fadeSteps = emptyList(), // No equivalent for older models + volumeEnforced = volumeEnforced + ) + } + + return AlarmSettings( + id = id, + dateTime = Date(dateTimeMillis), + assetAudioPath = assetAudioPath, + volumeSettings = volumeSettings, + notificationSettings = notificationSettings, + loopAudio = loopAudio, + vibrate = vibrate, + warningNotificationOnKill = warningNotificationOnKill, + androidFullScreenIntent = androidFullScreenIntent, + allowAlarmOverlap = allowAlarmOverlap + ) + } } } +/** + * Custom serializer for Java's `Date` type. + */ object DateSerializer : KSerializer { - override val descriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.LONG) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.LONG) override fun serialize(encoder: Encoder, value: Date) = encoder.encodeLong(value.time) override fun deserialize(decoder: Decoder): Date = Date(decoder.decodeLong()) } + +// Extension functions for safer primitive extraction from JsonObject +private fun JsonObject.primitiveInt(key: String): Int? = this[key]?.jsonPrimitive?.content?.toIntOrNull() +private fun JsonObject.primitiveLong(key: String): Long? = this[key]?.jsonPrimitive?.content?.toLongOrNull() +private fun JsonObject.primitiveDouble(key: String): Double? = this[key]?.jsonPrimitive?.content?.toDoubleOrNull() +private fun JsonObject.primitiveString(key: String): String? = this[key]?.jsonPrimitive?.content +private fun JsonObject.primitiveBoolean(key: String): Boolean? = this[key]?.jsonPrimitive?.content?.toBooleanStrictOrNull() \ No newline at end of file diff --git a/ios/Classes/models/AlarmSettings.swift b/ios/Classes/models/AlarmSettings.swift index 969b0b9..4caf32a 100644 --- a/ios/Classes/models/AlarmSettings.swift +++ b/ios/Classes/models/AlarmSettings.swift @@ -1,3 +1,5 @@ +import Foundation + struct AlarmSettings: Codable { let id: Int let dateTime: Date @@ -13,25 +15,62 @@ struct AlarmSettings: Codable { enum CodingKeys: String, CodingKey { case id, dateTime, assetAudioPath, volumeSettings, notificationSettings, loopAudio, vibrate, warningNotificationOnKill, androidFullScreenIntent, - allowAlarmOverlap + allowAlarmOverlap, volume, fadeDuration, volumeEnforced } - // Custom initializer to handle missing keys + /// Custom initializer to handle backward compatibility for older models init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + + // Decode mandatory fields id = try container.decode(Int.self, forKey: .id) dateTime = try container.decode(Date.self, forKey: .dateTime) assetAudioPath = try container.decode(String.self, forKey: .assetAudioPath) - volumeSettings = try container.decode(VolumeSettings.self, forKey: .volumeSettings) notificationSettings = try container.decode(NotificationSettings.self, forKey: .notificationSettings) loopAudio = try container.decode(Bool.self, forKey: .loopAudio) vibrate = try container.decode(Bool.self, forKey: .vibrate) warningNotificationOnKill = try container.decode(Bool.self, forKey: .warningNotificationOnKill) androidFullScreenIntent = try container.decode(Bool.self, forKey: .androidFullScreenIntent) + + // Backward compatibility for `allowAlarmOverlap` allowAlarmOverlap = try container.decodeIfPresent(Bool.self, forKey: .allowAlarmOverlap) ?? false + + // Backward compatibility for `volumeSettings` + if let volumeSettingsDecoded = try? container.decode(VolumeSettings.self, forKey: .volumeSettings) { + volumeSettings = volumeSettingsDecoded + } else { + // Reconstruct `volumeSettings` from older fields + let volume = try container.decodeIfPresent(Double.self, forKey: .volume) + let fadeDurationSeconds = try container.decodeIfPresent(Double.self, forKey: .fadeDuration) + let fadeDuration = fadeDurationSeconds.map { TimeInterval($0) } + let volumeEnforced = try container.decodeIfPresent(Bool.self, forKey: .volumeEnforced) ?? false + + volumeSettings = VolumeSettings( + volume: volume, + fadeDuration: fadeDuration, + fadeSteps: [], // No equivalent for fadeSteps in older models + volumeEnforced: volumeEnforced + ) + } + } + + /// Encode method to support `Encodable` protocol + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(dateTime, forKey: .dateTime) + try container.encode(assetAudioPath, forKey: .assetAudioPath) + try container.encode(volumeSettings, forKey: .volumeSettings) + try container.encode(notificationSettings, forKey: .notificationSettings) + try container.encode(loopAudio, forKey: .loopAudio) + try container.encode(vibrate, forKey: .vibrate) + try container.encode(warningNotificationOnKill, forKey: .warningNotificationOnKill) + try container.encode(androidFullScreenIntent, forKey: .androidFullScreenIntent) + try container.encode(allowAlarmOverlap, forKey: .allowAlarmOverlap) } - // Memberwise initializer + /// Memberwise initializer init( id: Int, dateTime: Date, @@ -56,6 +95,7 @@ struct AlarmSettings: Codable { self.allowAlarmOverlap = allowAlarmOverlap } + /// Converts from wire model to `AlarmSettings`. static func from(wire: AlarmSettingsWire) -> AlarmSettings { return AlarmSettings( id: Int(truncatingIfNeeded: wire.id), diff --git a/lib/model/alarm_settings.dart b/lib/model/alarm_settings.dart index 88d6bff..d066b9d 100644 --- a/lib/model/alarm_settings.dart +++ b/lib/model/alarm_settings.dart @@ -24,8 +24,51 @@ class AlarmSettings { }); /// Constructs an `AlarmSettings` instance from the given JSON data. - factory AlarmSettings.fromJson(Map json) => - _$AlarmSettingsFromJson(json); + /// + /// This factory adds backward compatibility for v4 JSON structures + /// by detecting the absence of certain fields and adjusting them. + factory AlarmSettings.fromJson(Map json) { + // Check if 'volumeSettings' key is absent, indicating v4 data + if (!json.containsKey('volumeSettings')) { + // Process volume settings for v4 + final volume = (json['volume'] as num?)?.toDouble(); + final fadeDurationSeconds = (json['fadeDuration'] as num?)?.toDouble(); + final fadeDurationMillis = + (fadeDurationSeconds != null && fadeDurationSeconds > 0) + ? (fadeDurationSeconds * 1000).toInt() + : null; + final volumeEnforced = json['volumeEnforced'] as bool? ?? false; + + json['volumeSettings'] = { + 'volume': volume, + 'fadeDuration': fadeDurationMillis, + 'fadeSteps': >[], + 'volumeEnforced': volumeEnforced, + }; + + // Default `allowAlarmOverlap` to false for v4 + json['allowAlarmOverlap'] = json['allowAlarmOverlap'] ?? false; + + // Adjust `dateTime` field for v4 + if (json['dateTime'] != null) { + if (json['dateTime'] is int) { + final dateTimeValue = json['dateTime'] as int; + // In v4, dateTime was stored in microseconds, convert to milliseconds + json['dateTime'] = dateTimeValue ~/ 1000; + } else if (json['dateTime'] is String) { + // Parse ISO 8601 date string + json['dateTime'] = + DateTime.parse(json['dateTime'] as String).millisecondsSinceEpoch; + } else { + throw ArgumentError('Invalid dateTime value: ${json['dateTime']}'); + } + } else { + throw ArgumentError('dateTime is missing in the JSON data'); + } + } + // Parse using the default parser (v5) + return _$AlarmSettingsFromJson(json); + } /// Converts from wire datatype. AlarmSettings.fromWire(AlarmSettingsWire wire) @@ -40,9 +83,9 @@ class AlarmSettings { vibrate = wire.vibrate, warningNotificationOnKill = wire.warningNotificationOnKill, androidFullScreenIntent = wire.androidFullScreenIntent, - allowAlarmOverlap = false; + allowAlarmOverlap = wire.allowAlarmOverlap; - /// Unique identifier assiocated with the alarm. Cannot be 0 or -1; + /// Unique identifier associated with the alarm. Cannot be 0 or -1. final int id; /// Date and time when the alarm will be triggered. @@ -163,4 +206,10 @@ class AlarmSettings { allowAlarmOverlap: allowAlarmOverlap ?? this.allowAlarmOverlap, ); } + + static DateTime _dateTimeFromJson(int millisecondsSinceEpoch) => + DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch); + + static int _dateTimeToJson(DateTime dateTime) => + dateTime.millisecondsSinceEpoch; } diff --git a/lib/model/alarm_settings.g.dart b/lib/model/alarm_settings.g.dart index e90a6f4..237fba2 100644 --- a/lib/model/alarm_settings.g.dart +++ b/lib/model/alarm_settings.g.dart @@ -13,8 +13,8 @@ AlarmSettings _$AlarmSettingsFromJson(Map json) => ($checkedConvert) { final val = AlarmSettings( id: $checkedConvert('id', (v) => (v as num).toInt()), - dateTime: - $checkedConvert('dateTime', (v) => DateTime.parse(v as String)), + dateTime: $checkedConvert('dateTime', + (v) => AlarmSettings._dateTimeFromJson((v as num).toInt())), assetAudioPath: $checkedConvert('assetAudioPath', (v) => v as String), volumeSettings: $checkedConvert('volumeSettings', (v) => VolumeSettings.fromJson(v as Map)), @@ -26,6 +26,8 @@ AlarmSettings _$AlarmSettingsFromJson(Map json) => 'warningNotificationOnKill', (v) => v as bool? ?? true), androidFullScreenIntent: $checkedConvert( 'androidFullScreenIntent', (v) => v as bool? ?? true), + allowAlarmOverlap: + $checkedConvert('allowAlarmOverlap', (v) => v as bool? ?? false), ); return val; }, @@ -34,7 +36,7 @@ AlarmSettings _$AlarmSettingsFromJson(Map json) => Map _$AlarmSettingsToJson(AlarmSettings instance) => { 'id': instance.id, - 'dateTime': instance.dateTime.toIso8601String(), + 'dateTime': AlarmSettings._dateTimeToJson(instance.dateTime), 'assetAudioPath': instance.assetAudioPath, 'volumeSettings': instance.volumeSettings.toJson(), 'notificationSettings': instance.notificationSettings.toJson(), @@ -42,4 +44,5 @@ Map _$AlarmSettingsToJson(AlarmSettings instance) => 'vibrate': instance.vibrate, 'warningNotificationOnKill': instance.warningNotificationOnKill, 'androidFullScreenIntent': instance.androidFullScreenIntent, + 'allowAlarmOverlap': instance.allowAlarmOverlap, };