1
0
Fork 0
timelinize/datasources/googlelocation/ondevice.go
Matt Holt def05a6cfa
Revise location processing and improve place entities (#101)
* Revise location processing and place entities

- New, more dynamic, recursive clustering algorithm
- Place entities are globally unique by name
- Higher spatial tolerance for coordinate attributes if entity name is the same (i.e. don't insert new attribute row for coordinate if it's sort of close to another row for that attribute -- but if name is different, then points have to be closer to not insert new attribute row)

There is still a bug where clustering is too aggressive on some data. Looking into it...

* Fix overly aggressive clustering

(...lots of commits that fixed the CI environment which changed things without warning...)
2025-06-17 16:13:44 -06:00

281 lines
9 KiB
Go

/*
Timelinize
Copyright (c) 2013 Matthew Holt
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package googlelocation
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
)
/*
The 2024/2025 on-device location histories have the following differences between Android and iOS:
1. On iOS, semanticSegments that have a timelinePath are all grouped at the end of the array.
The chronological ordering is reset at the start of the group.
2. On iOS, timelinePath points are formatted like "geo:12.123456,-123.123456" but on Android
they are formatted like "12.1234567°, -123.1234567°" (notice one more digit of precision).
3. On iOS, timelinePath points are only timestampped with a "durationMinutesOffsetFromStartTime"
field (which is a string), whereas on Android, each point has its own complete timestamp.
*/
type onDeviceiOS2024Decoder struct {
*json.Decoder
// some objects have multiple location data points, so we "stick" the current
// one until we've gotten through all the points on it
multiLoc *onDeviceLocationiOS2024
// set this to true when we have started the TimelinePath portion of the data
doingPaths bool
}
func (dec *onDeviceiOS2024Decoder) NextLocation(_ context.Context) (*Location, error) {
handleTimelinePathPoint := func() (*Location, error) {
vertex := dec.multiLoc.TimelinePath[0]
coord, err := vertex.Point.parse()
if err != nil {
return nil, err
}
offsetMin, err := strconv.Atoi(vertex.DurationMinutesOffsetFromStartTime)
if err != nil {
return nil, err
}
loc := &Location{
Original: dec.multiLoc,
LatitudeE7: int64(*coord.Latitude * float64(placesMult)),
LongitudeE7: int64(*coord.Longitude * float64(placesMult)),
Timestamp: dec.multiLoc.StartTime.Add(time.Duration(offsetMin) * time.Minute),
}
// if this is the first path point, then indicate that to the
// location processor so that it resets its state and allows
// going back in time -- but we only indicate this once,
// when we actually step back in time to start the new part
// of the stream that goes forward in time again
if !dec.doingPaths {
dec.doingPaths = true // this sentinel ensures we only reset once
loc.NewTrack = true
}
// pop off the point we just retrieved
dec.multiLoc.TimelinePath = dec.multiLoc.TimelinePath[1:]
if len(dec.multiLoc.TimelinePath) == 0 {
dec.multiLoc = nil
}
return loc, nil
}
// see if a data point is "stickied" that has more locations we need to get through
if dec.multiLoc != nil {
switch {
case dec.multiLoc.Activity.End != "":
coord, err := dec.multiLoc.Activity.End.parse()
if err != nil {
return nil, err
}
loc := &Location{
Original: dec.multiLoc,
LatitudeE7: int64(*coord.Latitude * float64(placesMult)),
LongitudeE7: int64(*coord.Longitude * float64(placesMult)),
Timestamp: dec.multiLoc.EndTime,
}
dec.multiLoc = nil
return loc, nil
case len(dec.multiLoc.TimelinePath) > 0:
return handleTimelinePathPoint()
}
}
// decode next array element
for dec.More() {
var newLoc *onDeviceLocationiOS2024
if err := dec.Decode(&newLoc); err != nil {
return nil, fmt.Errorf("decoding on-device location element: %w", err)
}
// there are different kinds of data points
switch {
case newLoc.Visit.TopCandidate.PlaceLocation != "":
// this one is a single, self-contained location
coord, err := newLoc.Visit.TopCandidate.PlaceLocation.parse()
if err != nil {
return nil, err
}
return &Location{
Original: newLoc,
LatitudeE7: int64(*coord.Latitude * float64(placesMult)),
LongitudeE7: int64(*coord.Longitude * float64(placesMult)),
Timestamp: newLoc.StartTime,
Timespan: newLoc.EndTime,
}, nil
case newLoc.Activity.Start != "" && newLoc.Activity.End != "":
// two locations; the next location will be this one's End value
dec.multiLoc = newLoc
coord, err := dec.multiLoc.Activity.Start.parse()
if err != nil {
return nil, err
}
return &Location{
Original: dec.multiLoc,
LatitudeE7: int64(*coord.Latitude * float64(placesMult)),
LongitudeE7: int64(*coord.Longitude * float64(placesMult)),
Timestamp: dec.multiLoc.StartTime,
}, nil
case len(newLoc.TimelinePath) > 0:
// many locations; pop the first one and process it
dec.multiLoc = newLoc
return handleTimelinePathPoint()
}
}
return nil, nil
}
type onDeviceAndroid2025Decoder struct {
*json.Decoder
// some objects have multiple location data points (timelinePath) so
// we "stick" it here and just chop off each one as we process them
// TODO: group all points belonging to this segment, somehow (maybe a collection relation, like an item could be a Collection class that represents the path)
currentSegment *semanticSegmentAndroid2025
}
func (dec *onDeviceAndroid2025Decoder) NextLocation(_ context.Context) (*Location, error) {
handleTimelinePathSegment := func() (*Location, error) {
vertex := dec.currentSegment.TimelinePath[0]
coord, err := vertex.Point.parse()
if err != nil {
return nil, err
}
loc := &Location{
Original: dec.currentSegment,
LatitudeE7: int64(*coord.Latitude * float64(placesMult)),
LongitudeE7: int64(*coord.Longitude * float64(placesMult)),
Timestamp: vertex.Time,
}
// pop off the point we just retrieved
dec.currentSegment.TimelinePath = dec.currentSegment.TimelinePath[1:]
// clean up the stickied segment when we're through the whole path
if len(dec.currentSegment.TimelinePath) == 0 {
// we can assume the last point in the path ended at
// the end time of the segment containing this path
if dec.currentSegment.EndTime.After(loc.Timestamp) {
loc.Timespan = dec.currentSegment.EndTime
}
// finished with path, so clear stickied segment
dec.currentSegment = nil
}
return loc, nil
}
// see if a data point is "stickied" that has more locations we need to get through
if dec.currentSegment != nil {
switch {
case dec.currentSegment.Activity.End.LatLng != "":
coord, err := dec.currentSegment.Activity.End.LatLng.parse()
if err != nil {
return nil, err
}
loc := &Location{
Original: dec.currentSegment,
LatitudeE7: int64(*coord.Latitude * float64(placesMult)),
LongitudeE7: int64(*coord.Longitude * float64(placesMult)),
Timestamp: dec.currentSegment.EndTime,
}
dec.currentSegment = nil
return loc, nil
case len(dec.currentSegment.TimelinePath) > 0:
return handleTimelinePathSegment()
}
}
// decode next array element
for dec.More() {
var seg *semanticSegmentAndroid2025
if err := dec.Decode(&seg); err != nil {
return nil, fmt.Errorf("decoding Android 2025 on-device semanticSegment element: %w", err)
}
// shift the start and end times into their time zone, if specified
const secPerMin, minsPerHour = 60, 60
if seg.StartTimeTimezoneUTCOffsetMinutes != 0 {
startTimeZone := time.FixedZone(fmt.Sprintf("UTC-%d", seg.StartTimeTimezoneUTCOffsetMinutes/minsPerHour), seg.StartTimeTimezoneUTCOffsetMinutes*secPerMin)
seg.StartTime = seg.StartTime.In(startTimeZone)
}
if seg.EndTimeTimezoneUTCOffsetMinutes != 0 {
endTimeZone := time.FixedZone(fmt.Sprintf("UTC-%d", seg.EndTimeTimezoneUTCOffsetMinutes/minsPerHour), seg.EndTimeTimezoneUTCOffsetMinutes*secPerMin)
seg.EndTime = seg.EndTime.In(endTimeZone)
}
// there are different kinds of data points
switch {
case seg.Visit.TopCandidate.PlaceLocation.LatLng != "":
// this one is a single, self-contained location
coord, err := seg.Visit.TopCandidate.PlaceLocation.LatLng.parse()
if err != nil {
return nil, err
}
return &Location{
Original: seg,
LatitudeE7: int64(*coord.Latitude * float64(placesMult)),
LongitudeE7: int64(*coord.Longitude * float64(placesMult)),
Timestamp: seg.StartTime,
}, nil
case seg.Activity.Start.LatLng != "" && seg.Activity.End.LatLng != "":
// two locations; the next location will be this one's End value
dec.currentSegment = seg
coord, err := dec.currentSegment.Activity.Start.LatLng.parse()
if err != nil {
return nil, err
}
return &Location{
Original: dec.currentSegment,
LatitudeE7: int64(*coord.Latitude * float64(placesMult)),
LongitudeE7: int64(*coord.Longitude * float64(placesMult)),
Timestamp: dec.currentSegment.StartTime,
}, nil
case len(seg.TimelinePath) > 0:
// many locations; pop the first one and process it
dec.currentSegment = seg
return handleTimelinePathSegment()
}
}
return nil, nil
}