Skip to content

Commit aea02a4

Browse files
authored
Start & end location timestamps (#126)
* Start & end location timestamps * Remove comments * endTimestamp -> non-optional * StoredLocation Metadata * Small refactor * Fixes * Fixes * Small UI fixes * Handle out of order timestamps * Fix index
1 parent 5d1d85a commit aea02a4

18 files changed

Lines changed: 231 additions & 75 deletions

File tree

‎server/.idea/misc.xml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎server/src/it/scala/com/jackpf/locationhistory/server/AdminTest.scala‎

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.jackpf.locationhistory.admin_service.{
66
DeleteDeviceRequest,
77
DeleteDeviceResponse,
88
ListDevicesRequest,
9+
ListLocationsRequest,
910
LoginRequest,
1011
SendNotificationRequest,
1112
SendNotificationResponse
@@ -15,9 +16,11 @@ import com.jackpf.locationhistory.beacon_service.{
1516
RegisterDeviceRequest,
1617
RegisterDeviceResponse,
1718
RegisterPushHandlerRequest,
18-
RegisterPushHandlerResponse
19+
RegisterPushHandlerResponse,
20+
SetLocationRequest,
21+
SetLocationResponse
1922
}
20-
import com.jackpf.locationhistory.common.{Device, PushHandler}
23+
import com.jackpf.locationhistory.common.{Device, Location, PushHandler, StoredLocation}
2124
import com.jackpf.locationhistory.server.testutil.{GrpcMatchers, IntegrationTest, TestServer}
2225
import io.grpc.Status.Code
2326
import org.mockito.ArgumentMatchers.{any, anyString}
@@ -108,12 +111,10 @@ class AdminTest extends IntegrationTest with GrpcMatchers {
108111
}
109112

110113
"fail to approve already approved device" >> in(new RegisteredDeviceContext {}) { context =>
111-
// First approval should succeed
112114
context.adminClient.approveDevice(
113115
ApproveDeviceRequest(deviceId = context.device.id)
114116
) === ApproveDeviceResponse(success = true)
115117

116-
// Second approval should fail
117118
context.adminClient.approveDevice(
118119
ApproveDeviceRequest(deviceId = context.device.id)
119120
) must throwAGrpcRuntimeException(
@@ -200,5 +201,66 @@ class AdminTest extends IntegrationTest with GrpcMatchers {
200201
)
201202
}
202203
}
204+
205+
"list locations endpoint" >> {
206+
trait ApprovedDeviceContext extends RegisteredDeviceContext {
207+
adminClient.approveDevice(
208+
ApproveDeviceRequest(deviceId = device.id)
209+
) === ApproveDeviceResponse(success = true)
210+
}
211+
212+
"list locations with metadata fields" >> in(new ApprovedDeviceContext {}) { context =>
213+
val timestamp = System.currentTimeMillis()
214+
val location = Location(lat = 51.5007, lon = -0.1246, accuracy = 10.0)
215+
216+
context.client.setLocation(
217+
SetLocationRequest(
218+
timestamp = timestamp,
219+
deviceId = context.device.id,
220+
location = Some(location)
221+
)
222+
) === SetLocationResponse(success = true)
223+
224+
val response = context.adminClient.listLocations(
225+
ListLocationsRequest(deviceId = context.device.id)
226+
)
227+
228+
response.locations must haveSize(1)
229+
val storedLocation = response.locations.head
230+
storedLocation.location must beSome(location)
231+
storedLocation.startTimestamp === timestamp
232+
storedLocation.endTimestamp === timestamp
233+
storedLocation.count === 1L
234+
}
235+
236+
"list locations with updated metadata after duplicates" >> in(new ApprovedDeviceContext {}) {
237+
context =>
238+
context.client.setLocation(
239+
SetLocationRequest(
240+
timestamp = 1000L,
241+
deviceId = context.device.id,
242+
location = Some(Location(lat = 51.5007, lon = -0.1246, accuracy = 10.0))
243+
)
244+
) === SetLocationResponse(success = true)
245+
246+
context.client.setLocation(
247+
SetLocationRequest(
248+
timestamp = 2000L,
249+
deviceId = context.device.id,
250+
location = Some(Location(lat = 51.5007, lon = -0.1246, accuracy = 10.0))
251+
)
252+
) === SetLocationResponse(success = true)
253+
254+
val response = context.adminClient.listLocations(
255+
ListLocationsRequest(deviceId = context.device.id)
256+
)
257+
258+
response.locations must haveSize(1)
259+
val storedLocation = response.locations.head
260+
storedLocation.startTimestamp === 1000L
261+
storedLocation.endTimestamp === 2000L
262+
storedLocation.count === 2L
263+
}
264+
}
203265
}
204266
}

‎server/src/it/scala/com/jackpf/locationhistory/server/LocationTest.scala‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ class LocationTest extends IntegrationTest with GrpcMatchers {
103103
listLocationsResponse.locations must haveSize(1)
104104
listLocationsResponse.locations.head === StoredLocation(
105105
location = Some(location),
106-
timestamp = timestamp
106+
startTimestamp = timestamp,
107+
endTimestamp = timestamp,
108+
count = 1L
107109
)
108110
}
109111

@@ -134,11 +136,15 @@ class LocationTest extends IntegrationTest with GrpcMatchers {
134136
listLocationsResponse.locations === Seq(
135137
StoredLocation(
136138
location = Some(Location(lat = 51.500800, lon = -0.124500, accuracy = 0.2)),
137-
timestamp = 3L
139+
startTimestamp = 1L,
140+
endTimestamp = 3L,
141+
count = 3L
138142
),
139143
StoredLocation(
140144
location = Some(Location(lat = 35.659500, lon = 139.700500, accuracy = 0.1)),
141-
timestamp = 4L
145+
startTimestamp = 4L,
146+
endTimestamp = 4L,
147+
count = 1L
142148
)
143149
)
144150
}

‎server/src/main/scala/com/jackpf/locationhistory/server/model/StoredLocation.scala‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,42 @@ package com.jackpf.locationhistory.server.model
33
import com.jackpf.locationhistory.common.StoredLocation as ProtoStoredLocation
44

55
object StoredLocation {
6-
def fromLocation(location: Location, id: Long, timestamp: Long): StoredLocation =
7-
StoredLocation(id, location, timestamp)
6+
object Metadata {
7+
def initial(timestamp: Long): Metadata =
8+
Metadata(startTimestamp = timestamp, endTimestamp = timestamp, count = 1L)
9+
}
10+
11+
case class Metadata(
12+
startTimestamp: Long,
13+
endTimestamp: Long,
14+
count: Long
15+
) {
16+
def updated(newTimestamp: Long): Metadata = {
17+
// Handle mismatched newTimestamp (e.g. out-of-order events)
18+
val newStartTimestamp = math.min(startTimestamp, newTimestamp)
19+
val newEndTimestamp = math.max(endTimestamp, newTimestamp)
20+
21+
copy(startTimestamp = newStartTimestamp, endTimestamp = newEndTimestamp, count = count + 1)
22+
}
23+
}
24+
25+
def fromLocation(
26+
location: Location,
27+
id: Long,
28+
metadata: Metadata
29+
): StoredLocation =
30+
StoredLocation(id, location, metadata)
831
}
932

10-
case class StoredLocation(id: Long, location: Location, timestamp: Long) {
33+
case class StoredLocation(
34+
id: Long,
35+
location: Location,
36+
metadata: StoredLocation.Metadata
37+
) {
1138
def toProto: ProtoStoredLocation = ProtoStoredLocation(
1239
location = Some(location.toProto),
13-
timestamp = timestamp
40+
startTimestamp = metadata.startTimestamp,
41+
endTimestamp = metadata.endTimestamp,
42+
count = metadata.count
1443
)
1544
}

‎server/src/main/scala/com/jackpf/locationhistory/server/repo/InMemoryLocationRepo.scala‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ class InMemoryLocationRepo(maxItemsPerDevice: Long = DefaultMaxItemsPerDevice)
2828
override def storeDeviceLocation(
2929
deviceId: DeviceId.Type,
3030
location: Location,
31-
timestamp: Long
31+
metadata: StoredLocation.Metadata
3232
): Future[Try[Unit]] = Future.successful {
3333
val storedLocation =
34-
StoredLocation.fromLocation(location, id = generateId(), timestamp = timestamp)
34+
StoredLocation.fromLocation(location, id = generateId(), metadata = metadata)
3535

3636
storedLocations.updateWith(deviceId) {
3737
case Some(existingLocations) =>

‎server/src/main/scala/com/jackpf/locationhistory/server/repo/LocationRepo.scala‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ trait LocationRepo extends LocationRepoExtensions {
1111
def storeDeviceLocation(
1212
deviceId: DeviceId.Type,
1313
location: Location,
14-
timestamp: Long
14+
metadata: StoredLocation.Metadata
1515
): Future[Try[Unit]]
1616

1717
def getForDevice(deviceId: DeviceId.Type, limit: Option[Int]): Future[Vector[StoredLocation]]

‎server/src/main/scala/com/jackpf/locationhistory/server/repo/LocationRepoExtensions.scala‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,13 @@ object LocationRepoExtensions {
1111
}
1212

1313
trait LocationRepoExtensions { self: LocationRepo =>
14-
// TODO We might want to update an endTimestamp and count so we don't lose info of when the location was first seen
1514
private def updatePreviousLocation(
1615
newLocation: Location,
1716
newTimestamp: Long,
1817
storedLocation: StoredLocation
1918
): StoredLocation = storedLocation.copy(
2019
location = newLocation,
21-
timestamp = newTimestamp
20+
metadata = storedLocation.metadata.updated(newTimestamp)
2221
)
2322

2423
/** Note that this is a "best effort" approach and not strictly thread safe:
@@ -43,7 +42,7 @@ trait LocationRepoExtensions { self: LocationRepo =>
4342
storedLocation => updatePreviousLocation(location, timestamp, storedLocation)
4443
)
4544
case _ =>
46-
storeDeviceLocation(deviceId, location, timestamp)
45+
storeDeviceLocation(deviceId, location, StoredLocation.Metadata.initial(timestamp))
4746
}
4847
}
4948

‎server/src/main/scala/com/jackpf/locationhistory/server/repo/SQLiteLocationRepo.scala‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,19 @@ private case class StoredLocationRow(
1919
lat: Double,
2020
lon: Double,
2121
accuracy: Double,
22-
timestamp: Long,
22+
startTimestamp: Long,
23+
endTimestamp: Long,
24+
count: Long,
2325
metadata: JsonColumn[Map[String, String]]
2426
) {
2527
def toStoredLocation: StoredLocation = StoredLocation(
2628
id = id,
2729
location = Location(lat = lat, lon = lon, accuracy = accuracy, metadata.value),
28-
timestamp = timestamp
30+
metadata = StoredLocation.Metadata(
31+
startTimestamp = startTimestamp,
32+
endTimestamp = endTimestamp,
33+
count = count
34+
)
2935
)
3036
}
3137
private object StoredLocationTable extends SimpleTable[StoredLocationRow]
@@ -42,12 +48,14 @@ class SQLiteLocationRepo(db: DbClient.DataSource)(using executionContext: Execut
4248
lat DOUBLE,
4349
lon DOUBLE,
4450
accuracy DOUBLE,
45-
timestamp UNSIGNED BIG INT,
51+
start_timestamp UNSIGNED BIG INT,
52+
end_timestamp UNSIGNED BIG INT,
53+
count UNSIGNED BIG INT,
4654
metadata TEXT
4755
);"""
4856
)
4957
val _ = db.updateRaw(
50-
"""CREATE INDEX IF NOT EXISTS idx_device_time ON stored_location_table (device_id, timestamp);"""
58+
"""CREATE INDEX IF NOT EXISTS idx_device_time ON stored_location_table (device_id, end_timestamp);"""
5159
)
5260
}
5361
}
@@ -56,7 +64,7 @@ class SQLiteLocationRepo(db: DbClient.DataSource)(using executionContext: Execut
5664
override def storeDeviceLocation(
5765
deviceId: DeviceId.Type,
5866
location: Location,
59-
timestamp: Long
67+
metadata: StoredLocation.Metadata
6068
): Future[Try[Unit]] = Future {
6169
db.transaction { implicit db =>
6270
Try {
@@ -67,8 +75,10 @@ class SQLiteLocationRepo(db: DbClient.DataSource)(using executionContext: Execut
6775
_.lat := location.lat,
6876
_.lon := location.lon,
6977
_.accuracy := location.accuracy,
70-
_.timestamp := timestamp,
71-
_.metadata := JsonColumn(location.metadata)
78+
_.metadata := JsonColumn(location.metadata),
79+
_.startTimestamp := metadata.startTimestamp,
80+
_.endTimestamp := metadata.endTimestamp,
81+
_.count := metadata.count
7282
)
7383
)
7484
()
@@ -87,7 +97,7 @@ class SQLiteLocationRepo(db: DbClient.DataSource)(using executionContext: Execut
8797
{
8898
val q = StoredLocationTable.select
8999
.filter(_.deviceId === deviceId.toString)
90-
.sortBy(_.timestamp)
100+
.sortBy(_.endTimestamp)
91101
.desc
92102

93103
limit match {
@@ -129,8 +139,10 @@ class SQLiteLocationRepo(db: DbClient.DataSource)(using executionContext: Execut
129139
_.lat := updatedStoredDevice.location.lat,
130140
_.lon := updatedStoredDevice.location.lon,
131141
_.accuracy := updatedStoredDevice.location.accuracy,
132-
_.timestamp := updatedStoredDevice.timestamp,
133-
_.metadata := JsonColumn(updatedStoredDevice.location.metadata)
142+
_.metadata := JsonColumn(updatedStoredDevice.location.metadata),
143+
_.startTimestamp := updatedStoredDevice.metadata.startTimestamp,
144+
_.endTimestamp := updatedStoredDevice.metadata.endTimestamp,
145+
_.count := updatedStoredDevice.metadata.count
134146
)
135147
)
136148
}
@@ -175,7 +187,7 @@ class SQLiteLocationRepo(db: DbClient.DataSource)(using executionContext: Execut
175187
val result = db.runSql[StoredLocationRow](sql"""
176188
SELECT * FROM (
177189
SELECT *,
178-
ROW_NUMBER() OVER (PARTITION BY device_id ORDER BY timestamp DESC) as rn
190+
ROW_NUMBER() OVER (PARTITION BY device_id ORDER BY end_timestamp DESC) as rn
179191
FROM stored_location_table
180192
WHERE device_id IN ($deviceIds)
181193
)

‎server/src/test/scala/com/jackpf/locationhistory/server/grpc/AdminServiceImplTest.scala‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,13 @@ class AdminServiceImplTest(implicit ee: ExecutionEnv)
239239
1L,
240240
MockModels
241241
.location(lat = 0.1, lon = 0.2, accuracy = 0.3, metadata = Map("k1" -> "v1")),
242-
timestamp = 1L
242+
model.StoredLocation.Metadata(startTimestamp = 1L, endTimestamp = 2L, count = 3L)
243243
),
244244
MockModels.storedLocation(
245245
2L,
246246
MockModels
247247
.location(lat = 0.4, lon = 0.5, accuracy = 0.6, metadata = Map("k2" -> "v2")),
248-
timestamp = 2L
248+
model.StoredLocation.Metadata.initial(2L)
249249
)
250250
)
251251
)
@@ -255,11 +255,15 @@ class AdminServiceImplTest(implicit ee: ExecutionEnv)
255255
Seq(
256256
StoredLocation(
257257
Some(Location(lat = 0.1, lon = 0.2, accuracy = 0.3, metadata = Map("k1" -> "v1"))),
258-
timestamp = 1L
258+
startTimestamp = 1L,
259+
endTimestamp = 2L,
260+
count = 3L
259261
),
260262
StoredLocation(
261263
Some(Location(lat = 0.4, lon = 0.5, accuracy = 0.6, metadata = Map("k2" -> "v2"))),
262-
timestamp = 2L
264+
startTimestamp = 2L,
265+
endTimestamp = 2L,
266+
count = 1L
263267
)
264268
)
265269
)

‎server/src/test/scala/com/jackpf/locationhistory/server/repo/InMemoryLocationRepoTest.scala‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.jackpf.locationhistory.server.repo
22

3-
import com.jackpf.locationhistory.server.model.DeviceId
3+
import com.jackpf.locationhistory.server.model.{DeviceId, StoredLocation}
44
import com.jackpf.locationhistory.server.testutil.MockModels
55
import org.specs2.concurrent.ExecutionEnv
66

@@ -21,7 +21,7 @@ class InMemoryLocationRepoTest(implicit ee: ExecutionEnv) extends LocationRepoTe
2121
context.locationRepo.storeDeviceLocation(
2222
deviceId,
2323
MockModels.location(),
24-
ts
24+
StoredLocation.Metadata.initial(ts)
2525
)
2626

2727
{
@@ -35,7 +35,7 @@ class InMemoryLocationRepoTest(implicit ee: ExecutionEnv) extends LocationRepoTe
3535
locations <- context.locationRepo.getForDevice(deviceId, limit = None)
3636
} yield {
3737
locations must haveSize(4)
38-
locations.map(_.timestamp) must beEqualTo(
38+
locations.map(_.metadata.startTimestamp) must beEqualTo(
3939
Seq(
4040
3L,
4141
4L,

0 commit comments

Comments
 (0)