Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Add v4 backward compatibility #309

Merged
merged 3 commits into from
Dec 22, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
gdelataillade marked this conversation as resolved.
Show resolved Hide resolved
) {
companion object {
fun fromWire(e: AlarmSettingsWire): AlarmSettings {
Expand All @@ -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<Date> {
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()
48 changes: 44 additions & 4 deletions ios/Classes/models/AlarmSettings.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Foundation

struct AlarmSettings: Codable {
let id: Int
let dateTime: Date
Expand All @@ -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 {
gdelataillade marked this conversation as resolved.
Show resolved Hide resolved
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,
Expand All @@ -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),
Expand Down
57 changes: 53 additions & 4 deletions lib/model/alarm_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,51 @@ class AlarmSettings {
});

/// Constructs an `AlarmSettings` instance from the given JSON data.
factory AlarmSettings.fromJson(Map<String, dynamic> 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<String, dynamic> json) {
// Check if 'volumeSettings' key is absent, indicating v4 data
if (!json.containsKey('volumeSettings')) {
gdelataillade marked this conversation as resolved.
Show resolved Hide resolved
// 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': <Map<String, dynamic>>[],
'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)
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
9 changes: 6 additions & 3 deletions lib/model/alarm_settings.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.