1
0
Fork 0
timelinize/datasources/googlelocation/models.go
2026-01-16 23:32:10 -07:00

568 lines
19 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 (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/timelinize/timelinize/timeline"
)
type onDeviceLocationiOS2024 struct {
EndTime time.Time `json:"endTime"` // e.g. 2024-06-21T19:51:13.014-06:00
StartTime time.Time `json:"startTime"`
Activity struct {
Start geoString `json:"start"`
End geoString `json:"end"`
TopCandidate topCandidateiOS2024 `json:"topCandidate"`
DistanceMeters string `json:"distanceMeters"`
} `json:"activity,omitempty"`
Visit struct {
HierarchyLevel string `json:"hierarchyLevel"`
TopCandidate topCandidateiOS2024 `json:"topCandidate"`
Probability string `json:"probability"`
} `json:"visit,omitempty"`
TimelinePath []struct {
Point geoString `json:"point"`
DurationMinutesOffsetFromStartTime string `json:"durationMinutesOffsetFromStartTime"`
} `json:"timelinePath"`
}
func (l *onDeviceLocationiOS2024) toItem(result *Location, opt *Options) *timeline.Item {
entity := timeline.Entity{ID: opt.OwnerEntityID}
if opt.Device != "" {
attr := timeline.Attribute{
Name: "google_location_device",
Value: opt.Device,
}
entity.Attributes = []timeline.Attribute{attr}
}
meta := make(timeline.Metadata)
switch {
case l.Visit.TopCandidate.PlaceLocation != "":
meta["Hierarchy level"] = l.Visit.HierarchyLevel
meta["Visit probability"] = l.Visit.Probability
meta["Visit probability"] = l.Visit.TopCandidate.Probability
meta["Visit semantic type"] = l.Visit.TopCandidate.SemanticType
meta["Visited place ID"] = l.Visit.TopCandidate.PlaceID
case l.Activity.Start != "":
meta["Distance"] = l.Activity.DistanceMeters
if l.Activity.TopCandidate.Type != "unknown" || l.Activity.TopCandidate.Probability != "0.000000" {
meta["Activity type"] = l.Activity.TopCandidate.Type
meta["Activity probability"] = l.Activity.TopCandidate.Probability
}
}
meta.Merge(result.Metadata, timeline.MetaMergeReplace)
return &timeline.Item{
Classification: timeline.ClassLocation,
Timestamp: result.Timestamp,
Timespan: result.Timespan,
Location: result.Location(),
Owner: entity,
Metadata: meta,
}
}
type topCandidateiOS2024 struct {
Type string `json:"type"`
Probability string `json:"probability"`
SemanticType string `json:"semanticType"`
PlaceID string `json:"placeID"`
PlaceLocation geoString `json:"placeLocation"`
}
type geoString string // EXAMPLE: "geo:30.123456,-105.987654"
func (g geoString) parse() (timeline.Location, error) {
const prefix = "geo:"
if !strings.HasPrefix(string(g), prefix) {
return timeline.Location{}, errors.New("not a valid geo string: missing prefix")
}
latStr, lonStr, ok := strings.Cut(string(g[len(prefix):]), ",")
if !ok {
return timeline.Location{}, errors.New("not a valid geo string: missing comma separator")
}
lat, err := strconv.ParseFloat(latStr, 64)
if err != nil {
return timeline.Location{}, fmt.Errorf("not a valid geo string: bad latitude: %s: %w", latStr, err)
}
lon, err := strconv.ParseFloat(lonStr, 64)
if err != nil {
return timeline.Location{}, fmt.Errorf("not a valid geo string: bad longitude: %s: %w", lonStr, err)
}
return timeline.Location{
Latitude: &lat,
Longitude: &lon,
}, nil
}
// semanticSegmentAndroid2025 contains the primary majority of on-device location history from Android devices starting in about 2025.
type semanticSegmentAndroid2025 struct {
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
TimelinePath []struct {
Point degreeString `json:"point"`
Time time.Time `json:"time"`
} `json:"timelinePath,omitempty"`
StartTimeTimezoneUTCOffsetMinutes int `json:"startTimeTimezoneUtcOffsetMinutes,omitempty"`
EndTimeTimezoneUTCOffsetMinutes int `json:"endTimeTimezoneUtcOffsetMinutes,omitempty"`
Visit struct {
HierarchyLevel int `json:"hierarchyLevel"`
Probability float64 `json:"probability"`
TopCandidate topCandidateAndroid2025 `json:"topCandidate"`
} `json:"visit,omitempty"`
Activity struct {
Start struct {
LatLng degreeString `json:"latLng"`
} `json:"start"`
End struct {
LatLng degreeString `json:"latLng"`
} `json:"end"`
DistanceMeters float64 `json:"distanceMeters"`
Probability float64 `json:"probability"`
TopCandidate topCandidateAndroid2025 `json:"topCandidate"`
Parking struct {
Location struct {
LatLng degreeString `json:"latLng"`
} `json:"location"`
StartTime time.Time `json:"startTime"`
} `json:"parking"`
} `json:"activity,omitempty"`
TimelineMemory struct {
Trip struct {
DistanceFromOriginKms int `json:"distanceFromOriginKms"`
Destinations []struct {
Identifier struct {
PlaceID string `json:"placeId"`
} `json:"identifier"`
} `json:"destinations"`
} `json:"trip"`
} `json:"timelineMemory,omitempty"`
}
// TODO: Use this?
//
//nolint:unused
type rawSignalAndroid2025 struct {
Position struct {
LatLng degreeString `json:"LatLng"`
AccuracyMeters int `json:"accuracyMeters"`
AltitudeMeters float64 `json:"altitudeMeters"`
Source string `json:"source"`
Timestamp string `json:"timestamp"`
SpeedMetersPerSecond float64 `json:"speedMetersPerSecond"`
} `json:"position,omitempty"`
ActivityRecord struct {
ProbableActivities []struct {
Type string `json:"type"`
Confidence float64 `json:"confidence"`
} `json:"probableActivities"`
Timestamp time.Time `json:"timestamp"`
} `json:"activityRecord,omitempty"`
WifiScan struct {
DeliveryTime time.Time `json:"deliveryTime"`
DevicesRecords []struct {
Mac int64 `json:"mac"`
RawRSSI int `json:"rawRssi"`
} `json:"devicesRecords"`
} `json:"wifiScan,omitempty"`
}
// TODO: Use this?
//
//nolint:unused
type userLocationProfileAndroid2025 struct {
FrequentPlaces []struct {
PlaceID string `json:"placeId"`
PlaceLocation string `json:"placeLocation"`
Label string `json:"label"`
} `json:"frequentPlaces"`
}
type topCandidateAndroid2025 struct {
Type string `json:"type"`
PlaceID string `json:"placeId"`
SemanticType string `json:"semanticType"`
Probability float64 `json:"probability"`
PlaceLocation struct {
LatLng degreeString `json:"latLng"`
} `json:"placeLocation"`
}
func (l *semanticSegmentAndroid2025) toItem(result *Location, opt *Options) *timeline.Item {
entity := timeline.Entity{ID: opt.OwnerEntityID}
if opt.Device != "" {
attr := timeline.Attribute{
Name: "google_location_device",
Value: opt.Device,
}
entity.Attributes = []timeline.Attribute{attr}
}
meta := make(timeline.Metadata)
switch {
case l.Visit.TopCandidate.PlaceLocation.LatLng != "":
meta["Hierarchy level"] = l.Visit.HierarchyLevel
meta["Visit probability"] = l.Visit.Probability
meta["Visit probability"] = l.Visit.TopCandidate.Probability
meta["Visit semantic type"] = l.Visit.TopCandidate.SemanticType
meta["Visited place ID"] = l.Visit.TopCandidate.PlaceID
case l.Activity.Start.LatLng != "":
meta["Distance"] = l.Activity.DistanceMeters
if l.Activity.TopCandidate.Type != "unknown" || l.Activity.TopCandidate.Probability != 0 {
meta["Activity type"] = l.Activity.TopCandidate.Type
meta["Activity probability"] = l.Activity.TopCandidate.Probability
}
}
// if the result wasn't a cluster (which has a timespan), then we may know a timespan of
// this point if it came with an EndTime.
if result.Timespan.IsZero() && !l.EndTime.IsZero() {
result.Timespan = l.EndTime
}
meta.Merge(result.Metadata, timeline.MetaMergeReplace)
return &timeline.Item{
Classification: timeline.ClassLocation,
Timestamp: result.Timestamp,
Timespan: result.Timespan,
Location: result.Location(),
Owner: entity,
Metadata: meta,
}
}
type degreeString string // EXAMPLE: "31.1234567°, -73.1234567°"
func (d degreeString) parse() (timeline.Location, error) {
str := strings.ReplaceAll(string(d), "°", "") // remove degree symbols
latStr, lonStr, ok := strings.Cut(str, ",") // split lat and lon (could still have spaces for now)
if !ok {
return timeline.Location{}, errors.New("not a valid degree string: missing comma separator")
}
lat, err := strconv.ParseFloat(strings.TrimSpace(latStr), 64) // parse longitude (w/o spaces)
if err != nil {
return timeline.Location{}, fmt.Errorf("not a valid degree string: bad latitude: %s: %w", latStr, err)
}
lon, err := strconv.ParseFloat(strings.TrimSpace(lonStr), 64) // parse longitude (w/o spaces)
if err != nil {
return timeline.Location{}, fmt.Errorf("not a valid degree string: bad longitude: %s: %w", lonStr, err)
}
return timeline.Location{
Latitude: &lat,
Longitude: &lon,
}, nil
}
///////////////////////////////
// Awesome unofficial documentation: https://locationhistoryformat.com/
// FINALLY! Official docs!
// https://developers.google.com/data-portability/schema-reference/location_history (Update: Since disappeared...)
// TODO: Add more fields from the official docs to the item metadata
type location struct {
Accuracy int `json:"accuracy"` // meters; higher values are less accurate (should probably be called "error" instead)
Activity []struct {
Activity []struct {
Confidence int `json:"confidence"`
Type string `json:"type"`
} `json:"activity"`
Timestamp time.Time `json:"timestamp"`
} `json:"activity"`
Altitude int `json:"altitude"` // meters
DeviceTag int64 `json:"deviceTag"` // may correspond with a device in Settings.json
Heading int `json:"heading"` // degrees
LatitudeE7 int64 `json:"latitudeE7"` // latitude times 1e7
LongitudeE7 int64 `json:"longitudeE7"` // longitude times 1e7
LocationMetadata []struct {
TimestampMs string `json:"timestampMs"`
WifiScan struct {
AccessPoints []struct {
MAC string `json:"mac"`
Strength int `json:"strength"`
} `json:"accessPoints"`
} `json:"wifiScan"`
} `json:"locationMetadata"`
Platform string `json:"platform"`
PlatformType string `json:"platformType"` // ANDROID, IOS, or UNKNOWN
Source string `json:"source"` // WIFI, CELL, GPS, or UNKNOWN (may also be lowercase sometimes)
Timestamp time.Time `json:"timestamp"` // old exports used to call this timestampMs, in milliseconds
Velocity int `json:"velocity"` // meters/second
VerticalAccuracy int `json:"verticalAccuracy"`
// Fields added in early 2024
DeviceTimestamp time.Time `json:"deviceTimestamp"` // "Timestamp at which the device uploaded the location batch containing this record."
ServerTimestamp time.Time `json:"serverTimestamp"` // "Timestamp at which the server received and created this record."
BatteryCharging bool `json:"batteryCharging"`
FormFactor string `json:"formFactor"` // PHONE, TABLET, ...
// added after processing (but before becoming an item)
timespan time.Time
meta timeline.Metadata
}
func (l location) toItem(opt *Options) *timeline.Item {
entity := timeline.Entity{ID: opt.OwnerEntityID}
if l.DeviceTag != 0 {
attr := timeline.Attribute{
Name: "google_location_device",
Value: strconv.FormatInt(l.DeviceTag, 10),
Identity: true, // I think the DeviceTag is basically a unique ID, but I am not 100% sure
}
if device, ok := opt.devices[l.DeviceTag]; ok {
attr.AltValue = device.DevicePrettyName
attr.Metadata = timeline.Metadata{
"Creation time": device.DeviceCreationTime,
"Platform": device.PlatformType,
"Android OS API level": device.AndroidOSLevel,
"Manufacturer": device.DeviceSpec.Manufacturer,
"Brand": device.DeviceSpec.Brand,
"Product": device.DeviceSpec.Product,
"Device": device.DeviceSpec.Device,
"Model": device.DeviceSpec.Model,
"Low RAM": device.DeviceSpec.IsLowRAM,
}
}
entity.Attributes = []timeline.Attribute{attr}
}
return &timeline.Item{
Timestamp: l.Timestamp,
Timespan: l.timespan,
Owner: entity,
Classification: timeline.ClassLocation,
Location: l.location(),
Metadata: l.metadata(),
}
}
func (l location) location() timeline.Location {
lat := float64(l.LatitudeE7) / placesMult
lon := float64(l.LongitudeE7) / placesMult
alt := float64(l.Altitude)
unc := metersToApproxDegrees(float64(l.Accuracy))
var loc timeline.Location
if lat != 0 {
loc.Latitude = &lat
}
if lon != 0 {
loc.Longitude = &lon
}
if alt != 0 {
loc.Altitude = &alt
}
if unc != 0 {
loc.CoordinateUncertainty = &unc
}
return loc
}
// metersToApproxDegrees converts the number of meters to an approximate number
// of degrees.
func metersToApproxDegrees(m float64) float64 {
// Ok, hear me out.
//
// We need to convert a vector offset in meters to the approximate
// degrees lat/lon -- both you say? yes, approximately; we're talking
// usually small amounts anyway and not right at the poles.
//
// A reverse haversine sounds complicated. But APPARENTLY, "the French
// originally defined the meter so that 10^7 (1e7) meters would be the
// distance along the Paris meridian from the equator to the north pole.
// Thus, 10^7 / 90 = 111,111.1 meters equals one degree of latitude to
// within the capabilities of French surveyors two centuries ago."
// https://gis.stackexchange.com/questions/2951/algorithm-for-offsetting-a-latitude-longitude-by-some-amount-of-meters#comment3018_2964
//
// And apparently the error of this approximatation stays below 10m
// until you get beyond 89.6 degrees latitude, which is well within
// our bounds; and once you get that far you're basically just "at
// the poles" for our purposes anyway.
//
// More context: https://gis.stackexchange.com/a/2964/5599
const oneDegOfLatInParisIn1789 = 1e7 / 90
return m / oneDegOfLatInParisIn1789
}
func (l location) metadata() timeline.Metadata {
meta := l.meta
if meta == nil {
meta = make(timeline.Metadata)
}
meta.Merge(timeline.Metadata{
"Velocity": l.Velocity,
"Heading": l.Heading,
"Vertical accuracy": l.VerticalAccuracy,
"Device tag": l.DeviceTag,
"Platform": l.Platform,
"Platform type": l.PlatformType,
"Source": l.Source,
"Server timestamp": l.ServerTimestamp,
"Device timestamp": l.DeviceTimestamp,
"Battery charging": l.BatteryCharging,
"Form factor": l.FormFactor,
}, timeline.MetaMergeSkip)
// activities are often duplicated, so eliminate duplicates first... we lose the order, but oh well
actsMap := make(map[string]int)
for _, act1 := range l.Activity {
for _, act2 := range act1.Activity {
if conf, ok := actsMap[act2.Type]; !ok || act2.Confidence > conf {
actsMap[act2.Type] = act2.Confidence
}
}
}
acts := make([]string, 0, len(actsMap))
for activityType, activityConfidence := range actsMap {
acts = append(acts, fmt.Sprintf("%s (%d%%)", activityType, activityConfidence))
}
if len(acts) > 0 {
meta["Activities"] = strings.Join(acts, ", ")
}
var wifis []string //nolint:prealloc
for _, lm := range l.LocationMetadata {
for _, ap := range lm.WifiScan.AccessPoints {
wifis = append(wifis, fmt.Sprintf("[%s %d]", ap.MAC, ap.Strength))
}
}
if len(wifis) > 0 {
meta["WiFi APs"] = strings.Join(wifis, " ")
}
// TODO: if combining more than 1, maybe add to the metadata how many we combined here
return meta
}
// TODO: are we going to use these functions?
// func (l location) primaryMovement() string {
// if len(l.Activity) == 0 {
// return ""
// }
// counts := make(map[string]int)
// confidences := make(map[string]int)
// for _, a := range l.Activity {
// for _, aa := range a.Activity {
// counts[aa.Type]++
// confidences[aa.Type] += aa.Confidence
// }
// }
// // turn confidence into average confidence,
// // (ensure all activities are represented),
// // and keep activities with high enough score
// var top []activity
// var hasOnFoot, hasWalking, hasRunning bool
// for _, a := range movementActivities {
// count := counts[a]
// if count == 0 {
// count = 1 // for the purposes of division
// }
// avg := confidences[a] / len(l.Activity)
// avgSeen := confidences[a] / count
// if avgSeen > 50 {
// switch a {
// case "ON_FOOT":
// hasOnFoot = true
// case "WALKING":
// hasWalking = true
// case "RUNNING":
// hasRunning = true
// }
// top = append(top, activity{Type: a, Confidence: avg})
// }
// }
// sort.Slice(top, func(i, j int) bool {
// return top[i].Confidence > top[j].Confidence
// })
// // consolidate ON_FOOT, WALKING, and RUNNING if more than one is present
// if hasOnFoot && (hasWalking || hasRunning) {
// for i := 0; i < len(top); i++ {
// if hasWalking && hasRunning &&
// (top[i].Type == "WALKING" || top[i].Type == "RUNNING") {
// // if both WALKING and RUNNING, prefer more general ON_FOOT
// top = append(top[:i], top[i+1:]...)
// } else if top[i].Type == "ON_FOOT" {
// // if only one of WALKING or RUNNING, prefer that over ON_FOOT
// top = append(top[:i], top[i+1:]...)
// }
// }
// }
// if len(top) > 0 {
// return top[0].Type
// }
// return ""
// }
// func (l location) hasActivity(act string) bool {
// for _, a := range l.Activity {
// for _, aa := range a.Activity {
// if aa.Type == act && aa.Confidence > 50 {
// return true
// }
// }
// }
// return false
// }
// type activities struct {
// TimestampMs string `json:"timestampMs"`
// Activity []activity `json:"activity"`
// }
// type activity struct {
// Type string `json:"type"`
// Confidence int `json:"confidence"`
// }
// // movementActivities is the list of activities we care about
// // for drawing relationships between two locations. For example,
// // we don't care about TILTING (sudden accelerometer adjustment,
// // like phone set down or person standing up), UNKNOWN, or STILL
// // (where there is no apparent movement detected).
// //
// // https://developers.google.com/android/reference/com/google/android/gms/location/DetectedActivity
// var movementActivities = []string{
// "WALKING",
// "RUNNING",
// "IN_VEHICLE",
// "ON_FOOT",
// "ON_BICYCLE",
// }