Skip to content

Commit

Permalink
[Feature] Add v4 backward compatibility (#309)
Browse files Browse the repository at this point in the history
* Add v4 backward compatibility

* Optimize backward compatibility for v4 JSON structures

* Remove uncessary @jsonkey in AlarmSettings
  • Loading branch information
gdelataillade authored Dec 22, 2024
1 parent 9a2f390 commit dd5c4dc
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 14 deletions.
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
) {
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 {
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')) {
// 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.

0 comments on commit dd5c4dc

Please sign in to comment.