diff --git a/parser/src/main/kotlin/Parser.kt b/parser/src/main/kotlin/Parser.kt index 16629d3..0052046 100644 --- a/parser/src/main/kotlin/Parser.kt +++ b/parser/src/main/kotlin/Parser.kt @@ -9,6 +9,7 @@ import dev.hossain.timeline.model.ActivityType import dev.hossain.timeline.model.Records import dev.hossain.timeline.model.SemanticTimeline import dev.hossain.timeline.model.Settings +import dev.hossain.timeline.model.timeline.TimelineEdits import okio.BufferedSource /** @@ -73,4 +74,20 @@ class Parser constructor() { val adapter: JsonAdapter = moshi.adapter(SemanticTimeline::class.java) return adapter.fromJson(bufferedSource)!! } + + /** + * Parse JSON string to [TimelineEdits] object. + */ + fun parseTimelineEdits(json: String): TimelineEdits { + val adapter: JsonAdapter = moshi.adapter(TimelineEdits::class.java) + return adapter.fromJson(json)!! + } + + /** + * Parse JSON buffered source to [TimelineEdits] object. + */ + fun parseTimelineEdits(bufferedSource: BufferedSource): TimelineEdits { + val adapter: JsonAdapter = moshi.adapter(TimelineEdits::class.java) + return adapter.fromJson(bufferedSource)!! + } } diff --git a/parser/src/main/kotlin/model/timeline/TimelineEdits.kt b/parser/src/main/kotlin/model/timeline/TimelineEdits.kt new file mode 100644 index 0000000..8863d87 --- /dev/null +++ b/parser/src/main/kotlin/model/timeline/TimelineEdits.kt @@ -0,0 +1,256 @@ +package dev.hossain.timeline.model.timeline + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * List of all timeline edits. + */ +@JsonClass(generateAdapter = true) +data class TimelineEdits( + /** + * Timeline Edits + */ + @Json(name = "timelineEdits") + val timelineEdits: List, +) + +/** + * Various information about the timeline edit, including device ID, place aggregates, and raw signal data. + */ +@JsonClass(generateAdapter = true) +data class TimelineEditsItem( + /** + * The unique identifier for the device that generated the data. + */ + @Json(name = "deviceId") + val deviceId: String, + /** + * Aggregated information about places visited by the device. + */ + @Json(name = "placeAggregates") + val placeAggregates: PlaceAggregates, +) + +@JsonClass(generateAdapter = true) +data class PlaceAggregates( + /** + * An array of place information, including score, location, and place ID. + */ + @Json(name = "placeAggregateInfo") + val placeAggregateInfo: List, + /** + * The size of the window in hours for the place aggregates. + */ + @Json(name = "windowSizeHrs") + val windowSizeHrs: Int, + /** + * An array of place IDs for the top-ranked places. + */ + @Json(name = "topRankedPlacesPlaceIds") + val topRankedPlacesPlaceIds: List, + /** + * The start and end time of the processing window. + */ + @Json(name = "processWindow") + val processWindow: ProcessWindow, + /** + * Metadata associated with the place aggregates. + */ + @Json(name = "metadata") + val metadata: Metadata, +) + +@JsonClass(generateAdapter = true) +data class PlaceAggregateInfoItem( + /** + * The score assigned to the place. + */ + @Json(name = "score") + val score: Double, + /** + * The number of location buckets associated with the place. + */ + @Json(name = "numBucketsWithLocation") + val numBucketsWithLocation: Int, + /** + * The span of the buckets in days. + */ + @Json(name = "bucketSpanDays") + val bucketSpanDays: Int, + /** + * The geographical point associated with the place. + */ + @Json(name = "point") + val point: Point, + /** + * The unique identifier for the place. + */ + @Json(name = "placeId") + val placeId: String, + /** + * The geographical point of the place. + */ + @Json(name = "placePoint") + val placePoint: Point, +) + +@JsonClass(generateAdapter = true) +data class ProcessWindow( + /** + * The start time of the processing window. + */ + @Json(name = "startTime") + val startTime: String, + /** + * The end time of the processing window. + */ + @Json(name = "endTime") + val endTime: String, +) + +/** + * The signal data, including position, activity record, and wifi scan. + */ +@JsonClass(generateAdapter = true) +data class Signal( + /** + * The position data, including point, accuracy, altitude, source, and timestamp. + */ + @Json(name = "position") + val position: Position, + /** + * The activity record data, including detected activities and timestamp. + */ + @Json(name = "activityRecord") + val activityRecord: ActivityRecord, + /** + * The wifi scan data, including delivery time, devices, and source. + */ + @Json(name = "wifiScan") + val wifiScan: WifiScan, +) + +@JsonClass(generateAdapter = true) +data class Position( + /** + * The geographical point associated with the position. + */ + @Json(name = "point") + val point: Point, + /** + * The accuracy of the position in millimeters. + */ + @Json(name = "accuracyMm") + val accuracyMm: Int, + /** + * The altitude of the position in meters. + */ + @Json(name = "altitudeMeters") + val altitudeMeters: Double, + /** + * The source of the position data. + */ + @Json(name = "source") + val source: String, + /** + * Speed in meters per second. + */ + @Json(name = "speedMetersPerSecond") + val speedMetersPerSecond: Double, + /** + * The timestamp of the position data. + */ + @Json(name = "timestamp") + val timestamp: String, +) + +@JsonClass(generateAdapter = true) +data class ActivityRecord( + /** + * An array of detected activities, including activity type and probability. + */ + @Json(name = "detectedActivities") + val detectedActivities: List, + /** + * The timestamp of the activity record. + */ + @Json(name = "timestamp") + val timestamp: String, +) + +@JsonClass(generateAdapter = true) +data class DetectedActivityItem( + /** + * The type of the detected activity. + */ + @Json(name = "activityType") + val activityType: String, + /** + * The probability of the detected activity. + */ + @Json(name = "probability") + val probability: Double, +) + +@JsonClass(generateAdapter = true) +data class WifiScan( + /** + * The delivery time of the wifi scan. + */ + @Json(name = "deliveryTime") + val deliveryTime: String, + /** + * An array of wifi devices, including mac address and raw RSSI. + */ + @Json(name = "devices") + val devices: List, + /** + * The source of the wifi scan data. + */ + @Json(name = "source") + val source: String, +) + +@JsonClass(generateAdapter = true) +data class WifiDevice( + /** + * The mac address of the wifi device. + */ + @Json(name = "mac") + val mac: String, + /** + * The raw RSSI of the wifi device. + */ + @Json(name = "rawRssi") + val rawRssi: Int, +) + +/** + * Metadata about the data object. + */ +@JsonClass(generateAdapter = true) +data class Metadata( + /** + * The platform that generated the data. + */ + @Json(name = "platform") + val platform: String, +) + +/** + * The geographical point. + */ +@JsonClass(generateAdapter = true) +data class Point( + /** + * Latitude coordinate of the point. Degrees multiplied by 10^7 and rounded to the nearest integer, in the range -900000000 to +900000000 (divide value by 10^7 for the usual range -90° to +90°). + */ + @Json(name = "latE7") + val latE7: Int, + /** + * Longitude coordinate of the point. Degrees multiplied by 10^7 and rounded to the nearest integer, in the range -1800000000 to +1800000000 (divide value by 10^7 for the usual range -180° to +180°). + */ + @Json(name = "lngE7") + val lngE7: Int, +) diff --git a/parser/src/test/kotlin/dev/hossain/timeline/model/TimelineEditsTest.kt b/parser/src/test/kotlin/dev/hossain/timeline/model/TimelineEditsTest.kt new file mode 100644 index 0000000..ab6cbc0 --- /dev/null +++ b/parser/src/test/kotlin/dev/hossain/timeline/model/TimelineEditsTest.kt @@ -0,0 +1,41 @@ +package dev.hossain.timeline.model + +import com.google.common.truth.Truth.assertThat +import dev.hossain.timeline.Parser +import dev.hossain.timeline.model.timeline.Point +import kotlin.test.Test + +class TimelineEditsTest { + private val parser = Parser() + + @Test + fun `given timeline edits json should parse all timeline edits data`() { + val json = javaClass.getResourceAsStream("/timeline-edits.json")!!.bufferedReader().readText() + val edits = parser.parseTimelineEdits(json) + + assertThat(edits.timelineEdits).hasSize(3) + } + + @Test + fun `given first timeline edit should parse all fields`() { + val json = javaClass.getResourceAsStream("/timeline-edits.json")!!.bufferedReader().readText() + val edits = parser.parseTimelineEdits(json) + + val firstEdit = edits.timelineEdits.first() + assertThat(firstEdit.deviceId).isEqualTo("0") + assertThat(firstEdit.placeAggregates.placeAggregateInfo).hasSize(3) + assertThat(firstEdit.placeAggregates.placeAggregateInfo.first().placeId).isEqualTo("ChIJaWUW8E4b1YkRLPJRTVf0RTw") + assertThat(firstEdit.placeAggregates.placeAggregateInfo.first().placePoint) + .isEqualTo(Point(latE7 = 439405376, lngE7 = -788457340)) + assertThat(firstEdit.placeAggregates.placeAggregateInfo.first().point) + .isEqualTo(Point(latE7 = 439406551, lngE7 = -788458768)) + assertThat(firstEdit.placeAggregates.placeAggregateInfo.first().score).isEqualTo(5.0) + assertThat(firstEdit.placeAggregates.placeAggregateInfo.first().bucketSpanDays).isEqualTo(4) + assertThat(firstEdit.placeAggregates.placeAggregateInfo.first().numBucketsWithLocation).isEqualTo(14) + assertThat(firstEdit.placeAggregates.windowSizeHrs).isEqualTo(2016) + assertThat(firstEdit.placeAggregates.topRankedPlacesPlaceIds).hasSize(3) + assertThat(firstEdit.placeAggregates.topRankedPlacesPlaceIds.first()).isEqualTo("ChIJV8SII64E1YkRvAqrnP5G_x8") + assertThat(firstEdit.placeAggregates.processWindow.startTime).isEqualTo("2023-09-20T08:01:15Z") + assertThat(firstEdit.placeAggregates.processWindow.endTime).isEqualTo("2023-12-13T08:01:15Z") + } +} diff --git a/parser/src/test/resources/timeline-edits.json b/parser/src/test/resources/timeline-edits.json new file mode 100644 index 0000000..2246f0b --- /dev/null +++ b/parser/src/test/resources/timeline-edits.json @@ -0,0 +1,162 @@ +{ + "timelineEdits": [{ + "deviceId": "0", + "placeAggregates": { + "placeAggregateInfo": [{ + "score": 5.0, + "numBucketsWithLocation": 14, + "bucketSpanDays": 4, + "point": { + "latE7": 439406551, + "lngE7": -788458768 + }, + "placeId": "ChIJaWUW8E4b1YkRLPJRTVf0RTw", + "placePoint": { + "latE7": 439405376, + "lngE7": -788457340 + } + }, { + "score": 862.0, + "numBucketsWithLocation": 0, + "bucketSpanDays": 0, + "point": { + "latE7": 439362266, + "lngE7": -788308334 + }, + "placeId": "ChIJV8SII64E1YkRvAqrnP5G_x8", + "placePoint": { + "latE7": 439362443, + "lngE7": -788308585 + } + }, { + "score": 15.0, + "numBucketsWithLocation": 0, + "bucketSpanDays": 0, + "point": { + "latE7": 439252743, + "lngE7": -788749588 + }, + "placeId": "ChIJNcJr94Qc1YkRXPX0m3SswPk", + "placePoint": { + "latE7": 439250956, + "lngE7": -788748381 + } + }], + "windowSizeHrs": 2016, + "topRankedPlacesPlaceIds": ["ChIJV8SII64E1YkRvAqrnP5G_x8", "ChIJNcJr94Qc1YkRXPX0m3SswPk", "ChIJaWUW8E4b1YkRLPJRTVf0RTw"], + "processWindow": { + "startTime": "2023-09-20T08:01:15Z", + "endTime": "2023-12-13T08:01:15Z" + }, + "metadata": { + "platform": "UNKNOWN" + } + } + }, { + "deviceId": "0", + "placeAggregates": { + "placeAggregateInfo": [{ + "score": 874.0, + "numBucketsWithLocation": 0, + "bucketSpanDays": 0, + "point": { + "latE7": 439362258, + "lngE7": -788308344 + }, + "placeId": "ChIJV8SII64E1YkRvAqrnP5G_x8", + "placePoint": { + "latE7": 439362443, + "lngE7": -788308585 + } + }, { + "score": 6.0, + "numBucketsWithLocation": 0, + "bucketSpanDays": 0, + "point": { + "latE7": 439396308, + "lngE7": -788515883 + }, + "placeId": "ChIJ93yZJEUb1YkRbIEKh6BVbqs", + "placePoint": { + "latE7": 439397622, + "lngE7": -788519525 + } + }, { + "score": 11.0, + "numBucketsWithLocation": 0, + "bucketSpanDays": 0, + "point": { + "latE7": 439253294, + "lngE7": -788749012 + }, + "placeId": "ChIJNcJr94Qc1YkRXPX0m3SswPk", + "placePoint": { + "latE7": 439250956, + "lngE7": -788748381 + } + }], + "windowSizeHrs": 2016, + "topRankedPlacesPlaceIds": ["ChIJV8SII64E1YkRvAqrnP5G_x8", "ChIJNcJr94Qc1YkRXPX0m3SswPk", "ChIJ93yZJEUb1YkRbIEKh6BVbqs"], + "processWindow": { + "startTime": "2023-09-18T08:01:10Z", + "endTime": "2023-12-11T08:01:10Z" + }, + "metadata": { + "platform": "UNKNOWN" + } + } + }, { + "deviceId": "0", + "placeAggregates": { + "placeAggregateInfo": [{ + "score": 870.0, + "numBucketsWithLocation": 0, + "bucketSpanDays": 0, + "point": { + "latE7": 439362272, + "lngE7": -788308381 + }, + "placeId": "ChIJV8SII64E1YkRvAqrnP5G_x8", + "placePoint": { + "latE7": 439362443, + "lngE7": -788308585 + } + }, { + "score": 21.0, + "numBucketsWithLocation": 0, + "bucketSpanDays": 0, + "point": { + "latE7": 439254671, + "lngE7": -788750135 + }, + "placeId": "ChIJNcJr94Qc1YkRXPX0m3SswPk", + "placePoint": { + "latE7": 439250956, + "lngE7": -788748381 + } + }, { + "score": 6.0, + "numBucketsWithLocation": 0, + "bucketSpanDays": 0, + "point": { + "latE7": 439352711, + "lngE7": -788262603 + }, + "placeId": "ChIJ44zPZ6UE1YkRJREVPALbKh8", + "placePoint": { + "latE7": 439349008, + "lngE7": -788262996 + } + }], + "windowSizeHrs": 2016, + "topRankedPlacesPlaceIds": ["ChIJV8SII64E1YkRvAqrnP5G_x8", "ChIJNcJr94Qc1YkRXPX0m3SswPk", "ChIJ44zPZ6UE1YkRJREVPALbKh8"], + "processWindow": { + "startTime": "2023-09-12T08:00:58Z", + "endTime": "2023-12-05T08:00:58Z" + }, + "metadata": { + "platform": "UNKNOWN" + } + } + }] +}