Garmin: ExploreSync historical activity import

Wires the new ExploreSync sub-service into ProtocolBufferHandler and GarminSupport so a paired device's historical-line catalog can be fetched independently of FIT — which Garmin Connect Mobile irreversibly archives files out of after the first sync (see issues like #5694 and #5994). Activities the FIT path can no longer see become visible again here.

Each historical line is reconstructed into an ActivityTrack, written as GPX, and persisted as a BaseActivitySummary keyed on the first-point timestamp — the same surface FIT-imported workouts use, so UI and exporters need no ExploreSync awareness. GarminCoordinator now picks the activity track provider from the summary contents (gpxTrack vs rawDetailsPath) instead of hard-coding the FIT one.
This commit is contained in:
Ingvar Stepanyan
2026-06-06 21:26:51 +02:00
committed by José Rebelo
parent 1cfcd0772c
commit c1946573fb
7 changed files with 1873 additions and 1 deletions
@@ -90,6 +90,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrackProvider;
import nodomain.freeyourgadget.gadgetbridge.model.BodyEnergySample;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
import nodomain.freeyourgadget.gadgetbridge.model.FitActivityTrackProvider;
import nodomain.freeyourgadget.gadgetbridge.model.GpxActivityTrackProvider;
import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
@@ -182,7 +183,20 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
@Override
@Nullable
public ActivityTrackProvider getActivityTrackProvider(@NonNull final GBDevice device, @NonNull final Context context) {
return new FitActivityTrackProvider();
// Pick the richer source when both are present:
// - FIT (rawDetailsPath) carries detailed sensor streams.
// - GPX (gpxTrack) is the polyline-only fallback that
// ExploreSync writes when no FIT has arrived yet.
// If a later FIT delivery dedup-links onto an existing
// ExploreSync row, both fields will be set; we should return
// the FIT view rather than hide its richer data behind GPX.
return summary -> {
final ActivityTrackProvider provider = summary.getRawDetailsPath() != null
? new FitActivityTrackProvider()
: new GpxActivityTrackProvider();
return provider.getActivityTrack(summary);
};
}
@Override
@@ -0,0 +1,867 @@
/* Copyright (C) 2026 Ingvar Stepanyan
This file is part of Gadgetbridge.
Gadgetbridge 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.
Gadgetbridge 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 nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import androidx.annotation.Nullable;
import com.google.protobuf.ByteString;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminActivitySampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySample;
import nodomain.freeyourgadget.gadgetbridge.export.GPXExporter;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums.GarminSport;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.ExploreSyncService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.Line;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDataReadyRequest;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDataReadyResponse;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDataRequestOp;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDigest;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDigestReadResponse;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDigestWriteRequest;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDigestWriteResponse;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LinePart;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineReadResponse;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineType;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.ReadStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.StartSyncStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.SyncFinishedStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.WriteStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.notifications.GBProgressNotification;
/**
* Handler for the GDI Smart {@code ExploreSyncService} extension (field 22).
*
* Walks the watch's historical activity catalog: each completed
* {@code LINE_TYPE_ACTIVITY} entry is fetched as a polyline, written as GPX,
* and persisted as a {@link BaseActivitySummary} — the same surface FIT imports
* use, so downstream consumers need no ExploreSync awareness.
*/
class ExploreSyncHandler {
private static final Logger LOG = LoggerFactory.getLogger(ExploreSyncHandler.class);
private final GarminSupport deviceSupport;
/**
* Drop session state on BT disconnect; in-flight POINTS pages won't match
* the watch's offset on reconnect.
*/
void onDisconnected() {
disposeSession();
LOG.info("ExploreSync state cleared on BT disconnect");
}
private void disposeSession() {
if (state != null) {
state.dispose();
state = null;
}
}
/**
* Returns false if the DB is unavailable — caller decides whether to skip
* an outgoing or reject an incoming StartSync.
*/
private boolean replaceSession() {
disposeSession();
try {
state = new SyncSession();
return true;
} catch (final GBException e) {
LOG.warn("ExploreSync: failed to (re)create session — DB unavailable", e);
return false;
}
}
// Per-field "no value" sentinels: the values the watch ships when
// a metric is unmeasured. Each constant is field-specific because
// FIT's per-type max (see {@link
// nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType})
// leaks through after the proto pipeline applies the field's unit
// scaling — blanket filtering would mask unrelated real values.
/** Speeds, altitudes, temperatures — protobuf-pipeline convention
* (FIT's float32 NaN doesn't survive the wire). */
private static final float SENTINEL_FLOAT = 1.0e25f;
/** Distance, meters. FIT uint32 max cm divided by 100. */
private static final float SENTINEL_DISTANCE_M = 0xFFFFFFFFL / 100f;
/** Total ascent and descent, meters. FIT uint16 max widened to float. */
private static final float SENTINEL_ASCENT_DESCENT_M = 0xFFFF;
/** Calories, kcal. FIT uint16 max in a uint32 proto field. */
private static final int SENTINEL_CALORIES_KCAL = 0xFFFF;
/** Per-point HR and cadence, plus avg HR. FIT uint8 max widened to int. */
private static final int SENTINEL_HR_CADENCE = 0xFF;
private static final int PROTOCOL_VERSION = 2;
/**
* Stable per-app identity. The watch keys its saved_app_transaction_id
* on this; sending a different one (or none) gets START_SYNC_REJECTED
* and resets the cursor on the watch side.
*/
private static final ByteString APP_UUID = ByteString.copyFrom(
"Gadgetbridge GB!".getBytes(StandardCharsets.US_ASCII));
/**
* Flipped on the first SyncFinished(OK) after a FULL. Stays unset
* across interrupted FULLs so the next session retries from scratch.
*/
private static final String PREF_BASELINE_ESTABLISHED
= "garmin_explore_sync_baseline_established";
// Pre-built stateless ack responses.
private static final ExploreSyncService LINE_DIGEST_READ_ACK
= ExploreSyncService.newBuilder()
.setLineDigestReadResponse(LineDigestReadResponse.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS))
.build();
private static final ExploreSyncService LINE_READ_ACK
= ExploreSyncService.newBuilder()
.setLineReadResponse(LineReadResponse.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS))
.build();
// Collection / Waypoint stubs: the watch stalls if we don't ack
// these, but we don't surface any of them — pre-built SUCCESS.
private static final ExploreSyncService COLLECTION_LIST_WRITE_ACK
= ExploreSyncService.newBuilder()
.setCollectionListWriteResponse(
GdiExploreSyncService.CollectionListWriteResponse.newBuilder()
.setStatus(WriteStatus.WRITE_STATUS_SUCCESS))
.build();
private static final ExploreSyncService COLLECTION_DIGEST_WRITE_ACK
= ExploreSyncService.newBuilder()
.setCollectionDigestWriteResponse(
GdiExploreSyncService.CollectionDigestWriteResponse.newBuilder()
.setStatus(WriteStatus.WRITE_STATUS_SUCCESS))
.build();
private static final ExploreSyncService COLLECTION_DIGEST_READ_ACK
= ExploreSyncService.newBuilder()
.setCollectionDigestReadResponse(
GdiExploreSyncService.CollectionDigestReadResponse.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS))
.build();
private static final ExploreSyncService COLLECTION_READ_ACK
= ExploreSyncService.newBuilder()
.setCollectionReadResponse(
GdiExploreSyncService.CollectionReadResponse.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS))
.build();
private static final ExploreSyncService WAYPOINT_DIGEST_WRITE_ACK
= ExploreSyncService.newBuilder()
.setWaypointDigestWriteResponse(
GdiExploreSyncService.WaypointDigestWriteResponse.newBuilder()
.setStatus(WriteStatus.WRITE_STATUS_SUCCESS))
.build();
private static final ExploreSyncService WAYPOINT_DIGEST_READ_ACK
= ExploreSyncService.newBuilder()
.setWaypointDigestReadResponse(
GdiExploreSyncService.WaypointDigestReadResponse.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS))
.build();
private static final ExploreSyncService WAYPOINT_READ_ACK
= ExploreSyncService.newBuilder()
.setWaypointReadResponse(
GdiExploreSyncService.WaypointReadResponse.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS))
.build();
private static final ExploreSyncService ACTIVE_LINE_DIGEST_WRITE_ACK
= ExploreSyncService.newBuilder()
.setActiveLineDigestWriteResponse(
GdiExploreSyncService.ActiveLineDigestWriteResponse.newBuilder()
.setStatus(WriteStatus.WRITE_STATUS_SUCCESS))
.build();
/** Read-only client; we never have line changes to report. */
private static final ExploreSyncService CHANGE_SUMMARY_ACK
= ExploreSyncService.newBuilder()
.setChangeSummaryResponse(
GdiExploreSyncService.ChangeSummaryResponse.getDefaultInstance())
.build();
/**
* One historical-sync run. Created by {@link #startSession} or by
* a watch-initiated {@code StartSyncRequest}; disposed on
* {@code SyncFinished} or BT disconnect.
*/
private final class SyncSession {
/**
* Drained one entry at a time: pop → promote to currentBuf → fetch →
* flush → pop next.
*/
private final Deque<LineDigest.LineReference> pending = new ArrayDeque<>();
@Nullable
private HistoricalBuffer currentBuf;
private final long deviceId;
private final long userId;
/** Drives FULL vs INCREMENTAL for the outgoing StartSyncRequest. */
private final boolean needsFullSync;
private final GBProgressNotification progress = new GBProgressNotification(
GBApplication.getContext(), GB.NOTIFICATION_CHANNEL_ID_TRANSFER);
/** Tracks whether we own the device's busy-task slot. GBDevice
* doesn't support nesting, so we can't rely on isBusy() to
* decide whether to unset on dispose — another subsystem may
* have taken the slot in the meantime. */
private boolean weMarkedBusy = false;
private SyncSession() throws GBException {
// Resolve and cache the (deviceId, userId) ids up front so
// per-op write paths can re-acquireDB() briefly instead of
// holding the global write lock for the whole sync.
try (DBHandler dbHandler = GBApplication.acquireDB()) {
final DaoSession daoSession = dbHandler.getDaoSession();
this.deviceId = DBHelper.getDevice(deviceSupport.getDevice(), daoSession).getId();
this.userId = DBHelper.getUser(daoSession).getId();
} catch (final Exception e) {
throw new GBException("Failed to resolve device/user for ExploreSync session", e);
}
this.needsFullSync = !deviceSupport.getDevicePrefs().getBoolean(PREF_BASELINE_ESTABLISHED, false);
progress.start(R.string.busy_task_processing_files, /* textRes */ 0, /* totalSize */ 0L);
// WorkoutListActivity refreshes on the busy→idle edge;
// dispose() clears the busy task to trigger that.
final GBDevice device = deviceSupport.getDevice();
device.setBusyTask(R.string.busy_task_fetch_activity_data, deviceSupport.getContext());
weMarkedBusy = true;
device.sendDeviceUpdateIntent(deviceSupport.getContext());
}
private void dispose() {
progress.finish();
if (weMarkedBusy) {
final GBDevice device = deviceSupport.getDevice();
device.unsetBusyTask();
weMarkedBusy = false;
device.sendDeviceUpdateIntent(deviceSupport.getContext());
}
}
/**
* Queue every non-deleted activity UUID from the digest and
* reply with a {@code line_read_op} for the first one; an
* empty-queue digest gets a bare ack.
*/
private ExploreSyncService handleLineDigestWriteRequest(final LineDigestWriteRequest req) {
// Routes, courses, tracking events aren't surfaced by GB.
final List<LineDigest.LineReference> lines = new ArrayList<>(req.getDigest().getLinesList());
lines.removeIf(line -> line.getLineType() != LineType.LINE_TYPE_ACTIVITY);
progress.incrementTotalSize(lines.size());
int queued = 0;
for (final LineDigest.LineReference line : lines) {
if (line.getDeleted()) {
// TODO: propagate watch-side deletes. Needs a
// uuid_hex column on BaseActivitySummary — the watch
// doesn't serve SUMMARY for a tombstoned UUID, so we
// can't learn the activity's start_time at delete
// time and have no other key to find the row.
continue;
}
pending.add(line);
queued++;
}
if (queued > 0) {
LOG.info("ExploreSync: queued {} activities for fetch", queued);
}
final LineDigestWriteResponse.Builder responseBuilder = LineDigestWriteResponse.newBuilder()
.setStatus(WriteStatus.WRITE_STATUS_SUCCESS);
final LineDataRequestOp readOp = popNextRead();
if (readOp != null) {
responseBuilder.setLineReadOp(readOp);
LOG.info("ExploreSync: requesting historical line {} (offset={})",
GB.hexdump(readOp.getUuid().toByteArray()), readOp.getPointOffset());
}
return ExploreSyncService.newBuilder().setLineDigestWriteResponse(responseBuilder).build();
}
/**
* Promote next pending → currentBuf and return its first read op; null
* when the queue is drained.
*/
@Nullable
private LineDataRequestOp popNextRead() {
final LineDigest.LineReference line = pending.poll();
if (line == null) {
currentBuf = null;
return null;
}
currentBuf = new HistoricalBuffer(line);
return currentBuf.buildReadOp(0);
}
/**
* Watch's reply to our outgoing {@code StartSyncRequest}. A
* non-accepted status (e.g. capability mismatch) means no
* digest is coming — tear down so the progress notification
* doesn't hang on traffic that won't arrive.
*/
@Nullable
private ExploreSyncService handleStartSyncResponse(
final GdiExploreSyncService.StartSyncResponse resp) {
final StartSyncStatus status = resp.getStatus();
if (status != StartSyncStatus.START_SYNC_ACCEPTED) {
LOG.warn("ExploreSync: watch rejected our StartSyncRequest with {}, disposing session",
status);
disposeSession();
}
return null;
}
/**
* Watch's end-of-sync notification. On the first clean FULL we
* flip the baseline pref so the next session can use
* INCREMENTAL. Either way the session is done — dispose.
*/
@Nullable
private ExploreSyncService handleSyncFinishedNotification(
final GdiExploreSyncService.SyncFinishedNotification notif) {
final SyncFinishedStatus status = notif.getStatus();
LOG.info("ExploreSync session ended: {}", status);
if (status == SyncFinishedStatus.SYNC_FINISHED_OK && needsFullSync) {
deviceSupport.getDevicePrefs().getPreferences().edit()
.putBoolean(PREF_BASELINE_ESTABLISHED, true).apply();
}
disposeSession();
return null;
}
/**
* Drive one {@code LineDataReady} into the in-flight buffer. The
* next read op (chained on the response) is the same UUID for
* in-line pagination (POINTS pages, then SUMMARY etc.), then the
* next queued UUID once the current one drains; absent only when
* the queue is also empty.
*/
private ExploreSyncService handleLineDataReadyRequest(final LineDataReadyRequest req) {
LineDataRequestOp next = currentBuf.absorb(req);
if (next == null) {
try {
currentBuf.flush();
} catch (final Exception e) {
LOG.warn("Failed to write historical summary/GPX for {}", currentBuf.uuidHex, e);
}
progress.incrementTotalProgress(1);
next = popNextRead();
}
final LineDataReadyResponse.Builder responseBuilder = LineDataReadyResponse.newBuilder();
if (next != null) {
responseBuilder.setNextDataRequest(next);
}
return ExploreSyncService.newBuilder()
.setLineDataReadyResponse(responseBuilder)
.build();
}
/**
* Per-UUID in-flight import state. At most one alive at a time; the
* rest of the digest sits in {@link #pending}.
*/
private final class HistoricalBuffer {
/** Wire byte form — needed on every read op. */
private final ByteString uuid;
/** Hex form for logging. */
private final String uuidHex;
/** Parts still owed by the watch, in fetch order. */
private final LinkedHashSet<LinePart> partsToFetch = new LinkedHashSet<>(Arrays.asList(
LinePart.LINE_PART_POINTS,
LinePart.LINE_PART_SUMMARY,
LinePart.LINE_PART_STAT,
LinePart.LINE_PART_SPORT));
private Line.StatPart statPart = Line.StatPart.getDefaultInstance();
private Line.SportPart sportPart = Line.SportPart.getDefaultInstance();
/** Generic fallback, overwritten by absorb when SUMMARY has a name. */
private String summaryName = "Activity";
private final List<ActivityPoint> points = new ArrayList<>();
private boolean hasGpsPoints = false;
private boolean hasHrPoints = false;
private HistoricalBuffer(final LineDigest.LineReference line) {
this.uuid = line.getUuid();
this.uuidHex = GB.hexdump(uuid.toByteArray());
}
/**
* True if any BaseActivitySummary exists at ({@code deviceId},
* {@code startTimeSeconds}). Whether it came from us, a FIT
* import, or another path, we treat it as authoritative and
* skip the re-fetch. On DB error we also return true — skip
* this attempt, retry on the next session — rather than
* paying the POINTS round-trip for a flush() that's likely
* to fail the same way.
*/
private boolean alreadyImported(final long startTimeSeconds) {
try (DBHandler dbHandler = GBApplication.acquireDB()) {
return ActivitySummaryParser.findBaseActivitySummary(dbHandler.getDaoSession(),
deviceSupport.getDevice(), startTimeSeconds) != null;
} catch (final Exception e) {
LOG.warn("ExploreSync: skip-check failed for {}, deferring to next session",
uuidHex, e);
return true;
}
}
/**
* Reads the highest-priority still-needed part. Caller must ensure
* partsToFetch is non-empty.
*/
private LineDataRequestOp buildReadOp(final int pointOffset) {
return LineDataRequestOp.newBuilder()
.setUuid(uuid)
.setPart(partsToFetch.iterator().next())
.setPointOffset(pointOffset)
.build();
}
/**
* Absorb the watch's reply and return the next read op, or
* null when the line is drained (caller then flushes).
*/
@Nullable
private LineDataRequestOp absorb(final LineDataReadyRequest req) {
if (req.getStatus() == ReadStatus.READ_STATUS_ERROR) {
// Fatal for the line — discard any partial pagination;
// the catalog will list the UUID again next session.
LOG.warn("ExploreSync historical line {} returned READ_STATUS_ERROR — dropping",
uuidHex);
points.clear();
return null;
}
// Opportunistically absorb whichever parts the watch
// volunteered. Each branch prunes its own part from
// partsToFetch.
final Line line = req.getLine();
if (line.hasPointPart()) {
final Line.PointPart pointPart = line.getPointPart();
if (points.isEmpty() && alreadyImported(pointPart.getTimestamps(0))) {
// POINTS is fetched first so we can bail here:
// timestamps[0] is the row's startTime key, and
// SummaryPart.creation_time can't replace it
// (that's activity-end + ~10s, not start).
// flush() sees empty points and short-circuits.
return null;
}
appendPoints(pointPart);
if (req.getStatus() == ReadStatus.READ_STATUS_MORE) {
return buildReadOp(req.getNextPointOffset());
}
partsToFetch.remove(LinePart.LINE_PART_POINTS);
}
if (line.hasSummaryPart()) {
final Line.SummaryPart summaryPart = line.getSummaryPart();
if (summaryPart.hasName()) {
summaryName = summaryPart.getName();
}
partsToFetch.remove(LinePart.LINE_PART_SUMMARY);
}
if (line.hasStatPart()) {
statPart = line.getStatPart();
partsToFetch.remove(LinePart.LINE_PART_STAT);
}
if (line.hasSportPart()) {
sportPart = line.getSportPart();
partsToFetch.remove(LinePart.LINE_PART_SPORT);
}
if (partsToFetch.isEmpty()) {
return null;
}
return buildReadOp(0);
}
/**
* Each per-point side array (altitudes, heart_rates, ...) is
* either empty or parallel to generic_positions, so one
* non-empty check up front gates the per-index reads.
*/
private void appendPoints(final Line.PointPart pointPart) {
final int pointCount = pointPart.getGenericPositionsCount();
final boolean hasAltitude = pointPart.getAltitudesCount() > 0;
final ByteString heartRates = pointPart.getHeartRates();
final boolean hasHr = !heartRates.isEmpty();
final ByteString cadences = pointPart.getCadences();
final boolean hasCadence = !cadences.isEmpty();
final boolean hasSpeed = pointPart.getSpeedsCount() > 0;
final boolean hasTemp = pointPart.getTemperaturesCount() > 0;
final boolean hasDepth = pointPart.getWaterDepthCount() > 0;
for (int i = 0; i < pointCount; i++) {
final ActivityPoint point = new ActivityPoint(new Date(pointPart.getTimestamps(i) * 1000L));
// fixed64 packs two int32 semicircles: low=lat, high=lon.
final long encodedPosition = pointPart.getGenericPositions(i);
final int latSemi = (int) (encodedPosition & 0xFFFFFFFFL);
final int lonSemi = (int) (encodedPosition >>> 32);
// INT_MIN and INT_MAX in either half are the watch's
// invalid-coordinate sentinels.
final boolean validCoords = latSemi != Integer.MIN_VALUE
&& latSemi != Integer.MAX_VALUE
&& lonSemi != Integer.MIN_VALUE
&& lonSemi != Integer.MAX_VALUE;
if (validCoords) {
final double altitude = hasAltitude && pointPart.getAltitudes(i) != SENTINEL_FLOAT
? pointPart.getAltitudes(i) : GPSCoordinate.UNKNOWN_ALTITUDE;
point.setLocation(new GPSCoordinate(
GarminUtils.semicirclesToDegrees(lonSemi),
GarminUtils.semicirclesToDegrees(latSemi),
altitude));
hasGpsPoints = true;
}
if (hasHr) {
final int heartRate = heartRates.byteAt(i) & 0xFF;
if (heartRate != SENTINEL_HR_CADENCE) {
point.setHeartRate(heartRate);
hasHrPoints = true;
}
}
if (hasCadence) {
// Unmeasured cadence on e.g. XC ski would
// chart as a flat line at 255 spm; drop it.
final int cadence = cadences.byteAt(i) & 0xFF;
if (cadence != SENTINEL_HR_CADENCE) {
point.setCadence(cadence);
}
}
if (hasSpeed) {
final float speed = pointPart.getSpeeds(i);
if (speed != SENTINEL_FLOAT) {
point.setSpeed(speed);
}
}
if (hasTemp) {
final float temperature = pointPart.getTemperatures(i);
if (temperature != SENTINEL_FLOAT) {
point.setTemperature(temperature);
}
}
if (hasDepth) {
final float depth = pointPart.getWaterDepth(i);
if (depth != SENTINEL_FLOAT) {
point.setDepth(depth);
}
}
points.add(point);
}
}
/**
* StatPart → chips. Units match {@code GarminWorkoutParser}
* so FIT and ExploreSync render identically.
*/
private ActivitySummaryData buildSummaryData() {
// Per-field sentinel filter: hasX() is useless because the
// watch sets the field even when its value is the "no
// value" marker — we have to compare to the sentinel.
final ActivitySummaryData summaryData = new ActivitySummaryData();
if (statPart.getDistance() != SENTINEL_DISTANCE_M) {
summaryData.add(ActivitySummaryEntries.DISTANCE_METERS, statPart.getDistance(),
ActivitySummaryEntries.UNIT_METERS);
}
if (statPart.getCalories() != SENTINEL_CALORIES_KCAL) {
summaryData.add(ActivitySummaryEntries.CALORIES_BURNT, statPart.getCalories(),
ActivitySummaryEntries.UNIT_KCAL);
}
if (statPart.getTotalAscent() != SENTINEL_ASCENT_DESCENT_M) {
summaryData.add(ActivitySummaryEntries.TOTAL_ASCENT, statPart.getTotalAscent(),
ActivitySummaryEntries.UNIT_METERS);
}
if (statPart.getTotalDescent() != SENTINEL_ASCENT_DESCENT_M) {
summaryData.add(ActivitySummaryEntries.TOTAL_DESCENT, statPart.getTotalDescent(),
ActivitySummaryEntries.UNIT_METERS);
}
if (statPart.getMaxElevation() != SENTINEL_FLOAT) {
summaryData.add(ActivitySummaryEntries.ALTITUDE_MAX, statPart.getMaxElevation(),
ActivitySummaryEntries.UNIT_METERS);
}
if (statPart.getMinElevation() != SENTINEL_FLOAT) {
summaryData.add(ActivitySummaryEntries.ALTITUDE_MIN, statPart.getMinElevation(),
ActivitySummaryEntries.UNIT_METERS);
}
// uint32 proto field but ships the uint8 sentinel —
// observed as 255 bpm on activities with no HR data.
if (statPart.getAvgHeartRate() != SENTINEL_HR_CADENCE) {
summaryData.add(ActivitySummaryEntries.HR_AVG, statPart.getAvgHeartRate(),
ActivitySummaryEntries.UNIT_BPM);
}
summaryData.add(ActivitySummaryEntries.ACTIVE_SECONDS, statPart.getTimerTime(),
ActivitySummaryEntries.UNIT_SECONDS);
if (statPart.getMovingSpeed() != SENTINEL_FLOAT) {
summaryData.add(ActivitySummaryEntries.SPEED_AVG, statPart.getMovingSpeed(),
ActivitySummaryEntries.UNIT_METERS_PER_SECOND);
}
if (statPart.getMaxSpeed() != SENTINEL_FLOAT) {
summaryData.add(ActivitySummaryEntries.SPEED_MAX, statPart.getMaxSpeed(),
ActivitySummaryEntries.UNIT_METERS_PER_SECOND);
}
return summaryData;
}
/**
* Persist the buffered line: GPX file (if any GPS points), a
* new BaseActivitySummary row keyed on the first-point
* timestamp, plus per-point HR samples for indoor activities.
*/
private void flush() throws Exception {
if (points.isEmpty()) {
// Upfront skip-check matched in absorb().
LOG.info("Historical line {}: skipped (already imported)", uuidHex);
return;
}
// GPX before DB: a file-write failure aborts the line
// without ever taking the DB write lock.
final String gpxPath = hasGpsPoints ? writeGpx() : null;
try (DBHandler dbHandler = GBApplication.acquireDB()) {
final DaoSession daoSession = dbHandler.getDaoSession();
final BaseActivitySummary summary = ActivitySummaryParser.createBaseActivitySummary(
daoSession, deviceId, points.get(0).getTime().getTime() / 1000L);
summary.setEndTime(points.get(points.size() - 1).getTime());
summary.setName(summaryName);
summary.setGpxTrack(gpxPath);
if (sportPart.hasSport()) {
// Set before persistActivitySamples — it reads
// summary.getActivityKind() into each sample.
GarminSport.fromCodes(sportPart.getSport(), sportPart.getSubSport())
.ifPresent(sport -> summary.setActivityKind(sport.getActivityKind().getCode()));
}
final ActivitySummaryData chips = buildSummaryData();
if (!chips.getKeys().isEmpty()) {
summary.setSummaryData(chips.toString());
}
if (!hasGpsPoints && hasHrPoints) {
// Indoor activity: no GPX polyline, so the
// workout chart falls back to ActivitySample.
persistActivitySamples(daoSession, summary);
}
// Insert last — the row appears in the workout list
// only after every field is set and the GPX exists.
daoSession.getBaseActivitySummaryDao().insertOrReplace(summary);
LOG.info("Historical line {} → {} pts, summary id={}",
uuidHex, points.size(), summary.getId());
}
GB.signalActivityDataFinish(deviceSupport.getDevice());
}
/**
* Export the buffered points to a GPX under the device's
* export dir, named by activity start + UUID prefix.
*/
private String writeGpx() throws Exception {
final Date startDate = points.get(0).getTime();
final File gpxFile = new File(deviceSupport.getWritableExportDirectory(),
GarminUtils.buildExportPath(FileType.FILETYPE.ACTIVITY, startDate,
uuidHex.substring(0, 8), "gpx"));
//noinspection ResultOfMethodCallIgnored
gpxFile.getParentFile().mkdirs();
final ActivityTrack track = new ActivityTrack();
track.setBaseTime(startDate);
track.setName(summaryName);
track.addTrackPoints(points);
new GPXExporter().performExport(track, gpxFile, /* summary */ null);
return gpxFile.getAbsolutePath();
}
/**
* Write one {@link GarminActivitySample} per buffered ActivityPoint
* with a real HR reading — the workout details fragment reads these
* via getAllSamples() when the activity track is empty (indoor
* path).
*
* Distance / calories / intensity / steps stay at NOT_MEASURED so
* readers can distinguish "ExploreSync didn't carry this" from
* "watch recorded zero".
*/
private void persistActivitySamples(final DaoSession daoSession,
final BaseActivitySummary summary) {
final List<GarminActivitySample> samples = new ArrayList<>(points.size());
for (final ActivityPoint point : points) {
final int heartRate = point.getHeartRate();
if (heartRate <= 0) {
continue;
}
samples.add(new GarminActivitySample(
(int) (point.getTime().getTime() / 1000L),
deviceId,
userId,
ActivitySample.NOT_MEASURED, // rawIntensity
ActivitySample.NOT_MEASURED, // steps
summary.getActivityKind(),
heartRate,
ActivitySample.NOT_MEASURED, // distanceCm
ActivitySample.NOT_MEASURED // activeCalories
));
}
if (samples.isEmpty()) {
return;
}
final GarminActivitySampleProvider provider
= new GarminActivitySampleProvider(deviceSupport.getDevice(), daoSession);
provider.addGBActivitySamples(samples.toArray(new GarminActivitySample[0]));
LOG.debug("ExploreSync line {}: persisted {} activity samples",
uuidHex, samples.size());
}
}
}
@Nullable
private SyncSession state;
ExploreSyncHandler(final GarminSupport deviceSupport) {
this.deviceSupport = deviceSupport;
}
/**
* Begin a historical-catalog sync: replace any prior session, send
* a {@code StartSyncRequest} so the watch starts pushing digests.
* Called by {@link GarminSupport} on initial connect and on user
* pull-to-refresh.
*/
void startSession() {
if (!replaceSession()) {
return;
}
final GdiExploreSyncService.SyncType syncType = state.needsFullSync
? GdiExploreSyncService.SyncType.SYNC_TYPE_FULL
: GdiExploreSyncService.SyncType.SYNC_TYPE_INCREMENTAL;
deviceSupport.sendProtobufRequest("explore-sync start",
GdiSmartProto.Smart.newBuilder()
.setExploreSyncService(ExploreSyncService.newBuilder()
.setStartSyncRequest(
GdiExploreSyncService.StartSyncRequest.newBuilder()
.setSyncType(syncType)
.setProtocolVersion(PROTOCOL_VERSION)
.setAppUuid(APP_UUID)))
.build());
LOG.info("ExploreSync StartSyncRequest sent (sync_type={})", syncType);
}
/**
* Handle an incoming ExploreSync message and produce an ExploreSyncService
* response (or null if no immediate response is needed). The caller wraps
* the result in a {@code Smart} envelope.
*/
@Nullable
ExploreSyncService handle(final ExploreSyncService req) {
if (req.hasStartSyncRequest()) {
// Watch-initiated session. The watch rejects the response
// (SYNC_FINISHED_FAILED) without an echoed app_uuid.
return ExploreSyncService.newBuilder()
.setStartSyncResponse(GdiExploreSyncService.StartSyncResponse.newBuilder()
.setSupportedProtocolVersion(PROTOCOL_VERSION)
.setAppUuid(APP_UUID)
.setStatus(replaceSession()
? StartSyncStatus.START_SYNC_ACCEPTED
: StartSyncStatus.START_SYNC_REJECTED))
.build();
}
// Stateless ACKs for parts we don't surface in GB — cached
// empty-body responses returned verbatim.
if (req.hasLineDigestReadRequest()) {
return LINE_DIGEST_READ_ACK;
}
if (req.hasLineReadRequest()) {
return LINE_READ_ACK;
}
if (req.hasCollectionListWriteRequest()) {
return COLLECTION_LIST_WRITE_ACK;
}
if (req.hasCollectionDigestWriteRequest()) {
return COLLECTION_DIGEST_WRITE_ACK;
}
if (req.hasCollectionDigestReadRequest()) {
return COLLECTION_DIGEST_READ_ACK;
}
if (req.hasCollectionReadRequest()) {
return COLLECTION_READ_ACK;
}
if (req.hasWaypointDigestWriteRequest()) {
return WAYPOINT_DIGEST_WRITE_ACK;
}
if (req.hasWaypointDigestReadRequest()) {
return WAYPOINT_DIGEST_READ_ACK;
}
if (req.hasWaypointReadRequest()) {
return WAYPOINT_READ_ACK;
}
if (req.hasActiveLineDigestWriteRequest()) {
return ACTIVE_LINE_DIGEST_WRITE_ACK;
}
if (req.hasChangeSummaryRequest()) {
return CHANGE_SUMMARY_ACK;
}
// Everything below is intra-session and needs an active
// StartSync; drop if state is gone (protocol violation or BT
// race).
if (state == null) {
LOG.warn("ExploreSync intra-session message arrived without an active session, ignoring: {}", req);
return null;
}
if (req.hasStartSyncResponse()) {
return state.handleStartSyncResponse(req.getStartSyncResponse());
}
if (req.hasLineDataReadyRequest()) {
return state.handleLineDataReadyRequest(req.getLineDataReadyRequest());
}
if (req.hasLineDigestWriteRequest()) {
return state.handleLineDigestWriteRequest(req.getLineDigestWriteRequest());
}
if (req.hasSyncFinishedNotification()) {
return state.handleSyncFinishedNotification(req.getSyncFinishedNotification());
}
LOG.debug("ExploreSync message not yet handled: {}", req);
return null;
}
}
@@ -216,6 +216,10 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
if (communicator != null) {
communicator.dispose();
}
// BT disconnect: tell ExploreSync to drop in-flight buffers.
if (protocolBufferHandler != null && protocolBufferHandler.getExploreSyncHandler() != null) {
protocolBufferHandler.getExploreSyncHandler().onDisconnected();
}
GBLocationService.stop(getContext(), getDevice());
try {
LocalBroadcastManager.getInstance(GBApplication.getContext()).unregisterReceiver(broadcastReceiver);
@@ -534,6 +538,12 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
// otherwise we might get incomplete monitor files
sendOutgoingMessage("fetch recorded data", fileTransferHandler.initiateDownload());
// Re-arm the ExploreSync historical catalog walk so any activities
// recorded since the initial connect get picked up. Watches that
// don't support the service reject our StartSyncRequest and the
// handler tears the session down on its own.
protocolBufferHandler.getExploreSyncHandler().startSession();
//TODO: ask the watch to initiate the sync? Something like:
// sendOutgoingMessage("set sync ready", new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_READY, 0));
// sendOutgoingMessage("set foreground", new SystemEventMessage(SystemEventMessage.GarminSystemEventType.HOST_DID_ENTER_FOREGROUND, 0));
@@ -51,6 +51,7 @@ import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiInstalledAppsService
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmsNotification;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiEcgService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http.DataTransferHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http.HttpHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
@@ -74,6 +75,7 @@ public class ProtocolBufferHandler implements MessageHandler {
private final DataTransferHandler dataTransferHandler;
private final FileSyncServiceHandler fileSyncServiceHandler;
private final EcgServiceHandler ecgServiceHandler;
private final ExploreSyncHandler exploreSyncHandler;
private final Map<GdiSmsNotification.SmsNotificationService.CannedListType, String[]> cannedListTypeMap = new HashMap<>();
@@ -84,6 +86,7 @@ public class ProtocolBufferHandler implements MessageHandler {
dataTransferHandler = new DataTransferHandler();
fileSyncServiceHandler = new FileSyncServiceHandler(deviceSupport);
ecgServiceHandler = new EcgServiceHandler(deviceSupport);
exploreSyncHandler = new ExploreSyncHandler(deviceSupport);
}
public void setContext(final GBDevice gbDevice, final BluetoothAdapter btAdapter, final Context context) {
@@ -232,6 +235,13 @@ public class ProtocolBufferHandler implements MessageHandler {
if (smart.hasAppConfigService()) {
processed = appConfigHandler.process(smart.getAppConfigService());
}
if (smart.hasExploreSyncService()) {
processed = true;
final GdiExploreSyncService.ExploreSyncService response = exploreSyncHandler.handle(smart.getExploreSyncService());
if (response != null) {
return prepareProtobufResponse(GdiSmartProto.Smart.newBuilder().setExploreSyncService(response).build(), message.getRequestId());
}
}
if (processed) {
message.setStatusMessage(new ProtobufStatusMessage(
message.getMessageType(),
@@ -676,6 +686,10 @@ public class ProtocolBufferHandler implements MessageHandler {
return appConfigHandler;
}
ExploreSyncHandler getExploreSyncHandler() {
return exploreSyncHandler;
}
private class ProtobufFragment {
private final byte[] fragmentBytes;
private final int totalLength;
@@ -0,0 +1,318 @@
syntax = "proto2";
package garmin_vivomovehr;
option java_package = "nodomain.freeyourgadget.gadgetbridge.proto.garmin";
option java_outer_classname = "GdiExploreSyncService";
// GDI ExploreSync sub-service definitions, restricted to what GB actually
// parses. Messages we don't read are stubbed to {status} so we can still
// ack the watch; messages we don't even ack (Collection bodies, Waypoint
// bodies, asset-tracking, sync limits) are omitted.
enum SyncType {
SYNC_TYPE_FULL = 1;
SYNC_TYPE_INCREMENTAL = 2;
}
enum StartSyncStatus {
START_SYNC_ACCEPTED = 1;
START_SYNC_REJECTED = 2;
START_SYNC_UNSUPPORTED_PROTOCOL_VERSION = 3;
}
enum SyncFinishedStatus {
SYNC_FINISHED_OK = 1;
SYNC_FINISHED_FAILED = 2;
SYNC_FINISHED_FAILED_STORAGE_OUT = 3;
SYNC_FINISHED_FAILED_DATA_LIMIT = 4;
}
enum WriteStatus {
WRITE_STATUS_SUCCESS = 0;
WRITE_STATUS_ERROR = 1;
WRITE_STATUS_ABORT = 2;
}
enum ReadStatus {
READ_STATUS_SUCCESS = 0;
READ_STATUS_MORE = 1;
READ_STATUS_ERROR = 2;
}
enum LinePart {
LINE_PART_SUMMARY = 1;
LINE_PART_POINTS = 2;
LINE_PART_STAT = 4;
LINE_PART_DISPLAY = 8;
LINE_PART_SPORT = 16;
LINE_PART_TRACKING_EVENT = 32;
}
enum SyncCapability {
SYNC_CAPABILITY_BASIC = 0;
SYNC_CAPABILITY_GCJ02 = 1;
SYNC_CAPABILITY_ACTIVE_LINES = 2;
SYNC_CAPABILITY_TRACKING_EVENT = 4;
SYNC_CAPABILITY_COURSES = 8;
SYNC_CAPABILITY_SINGLE_ITEM = 16;
SYNC_CAPABILITY_SYNC_LIMITS = 32;
}
enum ActiveLineType {
ACTIVE_LINE_TYPE_ACTIVE_RECORDING = 0;
ACTIVE_LINE_TYPE_ACTIVE_NAVIGATION = 1;
ACTIVE_LINE_TYPE_TRACKING_EVENT = 2;
}
enum LineType {
LINE_TYPE_TRACK = 0;
LINE_TYPE_ROUTE = 1;
LINE_TYPE_ACTIVITY = 2;
LINE_TYPE_CURRENT_RECORDING = 3;
LINE_TYPE_TRACBACK = 4;
LINE_TYPE_GOTO_COURSE = 5;
LINE_TYPE_GOTO_BEARING = 6;
LINE_TYPE_ON_ROAD_ROUTE = 7;
LINE_TYPE_TRACKING_EVENT = 8;
LINE_TYPE_COURSE = 9;
}
message ExploreSyncService {
optional StartSyncRequest start_sync_request = 1;
optional StartSyncResponse start_sync_response = 2;
optional SyncFinishedNotification sync_finished_notification = 3;
optional CollectionListWriteRequest collection_list_write_request = 4;
optional CollectionListWriteResponse collection_list_write_response = 5;
optional CollectionReadRequest collection_read_request = 6;
optional CollectionReadResponse collection_read_response = 7;
optional CollectionDigestWriteRequest collection_digest_write_request = 8;
optional CollectionDigestWriteResponse collection_digest_write_response = 9;
optional CollectionDigestReadRequest collection_digest_read_request = 10;
optional CollectionDigestReadResponse collection_digest_read_response = 11;
optional WaypointReadRequest waypoint_read_request = 12;
optional WaypointReadResponse waypoint_read_response = 13;
optional WaypointDigestWriteRequest waypoint_digest_write_request = 14;
optional WaypointDigestWriteResponse waypoint_digest_write_response = 15;
optional WaypointDigestReadRequest waypoint_digest_read_request = 16;
optional WaypointDigestReadResponse waypoint_digest_read_response = 17;
optional ChangeSummaryRequest change_summary_request = 18;
optional ChangeSummaryResponse change_summary_response = 19;
optional LineDigestWriteRequest line_digest_write_request = 24;
optional LineDigestWriteResponse line_digest_write_response = 25;
optional LineDigestReadRequest line_digest_read_request = 26;
optional LineDigestReadResponse line_digest_read_response = 27;
optional LineDataReadyRequest line_data_ready_request = 28;
optional LineDataReadyResponse line_data_ready_response = 29;
optional LineReadRequest line_read_request = 30;
optional LineReadResponse line_read_response = 31;
optional ActiveLineDigestWriteRequest active_line_digest_write_request = 32;
optional ActiveLineDigestWriteResponse active_line_digest_write_response = 33;
}
message VersionStamp {
required uint32 edit_time = 1;
optional bool derived = 2;
}
message StartSyncRequest {
optional SyncType sync_type = 1;
optional int32 protocol_version = 2;
optional bytes app_uuid = 3;
optional uint32 sync_capabilities = 4;
optional uint32 actual_capabilities = 6;
}
message StartSyncResponse {
optional StartSyncStatus status = 1;
optional int32 supported_protocol_version = 3;
optional bytes app_uuid = 4;
optional uint32 sync_capabilities = 5;
optional uint32 actual_capabilities = 7;
}
message SyncFinishedNotification {
optional SyncFinishedStatus status = 1;
}
// CollectionList / Collection / Waypoint stubs — we ack the watch with a
// success status but do nothing with the contents.
message CollectionListWriteRequest {
}
message CollectionListWriteResponse {
required WriteStatus status = 1;
}
message CollectionDigestWriteRequest {
}
message CollectionDigestWriteResponse {
required WriteStatus status = 1;
}
message CollectionDigestReadRequest {
}
message CollectionDigestReadResponse {
required ReadStatus status = 1;
}
message CollectionReadRequest {
}
message CollectionReadResponse {
required ReadStatus status = 1;
}
message WaypointDigestWriteRequest {
}
message WaypointDigestWriteResponse {
required WriteStatus status = 1;
}
message WaypointDigestReadRequest {
}
message WaypointDigestReadResponse {
required ReadStatus status = 1;
}
message WaypointReadRequest {
}
message WaypointReadResponse {
required ReadStatus status = 1;
}
message ActiveLineDigest {
message ActiveLineReference {
required ActiveLineType active_line_type = 1;
required bool valid = 2;
optional bytes uuid = 3;
optional uint32 point_cnt = 5;
}
repeated ActiveLineDigest.ActiveLineReference lines = 1;
}
message ActiveLineDigestWriteRequest {
optional ActiveLineDigest digest = 1;
}
message ActiveLineDigestWriteResponse {
required WriteStatus status = 1;
optional LineDataRequestOp line_read_op = 2;
}
message LineDigest {
message LineReference {
required bytes uuid = 1;
required LineType line_type = 2;
optional bool deleted = 3;
optional VersionStamp summary_part_version_stamp = 4;
optional VersionStamp stat_part_version_stamp = 5;
optional VersionStamp display_part_version_stamp = 6;
optional VersionStamp sport_part_version_stamp = 7;
optional VersionStamp point_part_version_stamp = 8;
}
repeated LineDigest.LineReference lines = 1;
}
message LineDigestWriteRequest {
optional LineDigest digest = 1;
}
message LineDigestWriteResponse {
required WriteStatus status = 1;
optional LineDataRequestOp line_read_op = 2;
}
message LineDigestReadRequest {
}
message LineDigestReadResponse {
required ReadStatus status = 1;
}
message Line {
message PointPart {
optional VersionStamp version_stamp = 1;
// All arrays below are either zero-length or have exactly one
// entry per point in generic_positions.
repeated fixed64 generic_positions = 2;
repeated float altitudes = 3;
repeated uint32 timestamps = 4;
optional bytes heart_rates = 5;
optional bytes cadences = 6;
repeated float temperatures = 7;
repeated float speeds = 8;
optional uint32 point_offset = 9;
repeated float water_depth = 19;
}
message SummaryPart {
optional VersionStamp version_stamp = 1;
optional string name = 2;
optional uint32 creation_time = 3;
}
message StatPart {
optional VersionStamp version_stamp = 1;
optional float distance = 2;
optional float total_ascent = 3;
optional float total_descent = 4;
optional uint32 timer_time = 5;
optional uint32 elapsed_time = 6;
optional uint32 moving_time = 7;
optional uint32 stopped_time = 8;
optional float moving_speed = 9;
optional float max_speed = 10;
optional float max_elevation = 11;
optional float min_elevation = 12;
optional uint32 calories = 13;
optional uint32 avg_heart_rate = 14;
optional uint32 avg_cadence = 15;
optional float avg_speed = 16;
}
message SportPart {
optional VersionStamp version_stamp = 1;
optional uint32 sport = 2;
optional uint32 sub_sport = 3;
}
required bytes uuid = 1;
required LineType line_type = 2;
optional Line.SummaryPart summary_part = 4;
optional Line.StatPart stat_part = 5;
optional Line.SportPart sport_part = 7;
optional Line.PointPart point_part = 8;
}
message LineDataRequestOp {
required bytes uuid = 1;
optional LinePart part = 2;
optional uint32 point_offset = 3;
}
message LineReadRequest {
}
message LineReadResponse {
required ReadStatus status = 1;
}
message LineDataReadyRequest {
required ReadStatus status = 1;
optional Line line = 2;
optional uint32 next_point_offset = 3;
}
message LineDataReadyResponse {
optional LineDataRequestOp next_data_request = 1;
}
message ChangeSummaryRequest {
optional uint32 starting_transaction_id = 1;
}
message ChangeSummaryResponse {
optional uint32 ending_transaction_id = 1;
}
@@ -18,6 +18,7 @@ import "garmin/gdi_calendar_service.proto";
import "garmin/gdi_settings_service.proto";
import "garmin/gdi_notifications_service.proto";
import "garmin/gdi_ecg_service.proto";
import "garmin/gdi_explore_sync.proto";
message Smart {
optional CalendarService calendar_service = 1;
@@ -30,6 +31,7 @@ message Smart {
optional CoreService core_service = 13;
optional SmsNotificationService sms_notification_service = 16;
optional AuthenticationService authenticationService = 27;
optional ExploreSyncService explore_sync_service = 22;
optional EcgService ecg_service = 39;
optional SettingsService settings_service = 42;
optional FileSyncService file_sync_service = 43;
@@ -0,0 +1,647 @@
/* Copyright (C) 2026 Ingvar Stepanyan
This file is part of Gadgetbridge.
Gadgetbridge 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.
Gadgetbridge 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 nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import android.bluetooth.BluetoothGattCharacteristic;
import com.google.protobuf.ByteString;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.ChangeSummaryRequest;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.ExploreSyncService;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.Line;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDataReadyRequest;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDataRequestOp;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDigest;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineDigestWriteRequest;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LinePart;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.LineType;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.ReadStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.StartSyncRequest;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.StartSyncResponse;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.StartSyncStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.SyncFinishedNotification;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.SyncFinishedStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.SyncType;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiExploreSyncService.VersionStamp;
import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.test.TestBase;
/**
* Protocol-level tests for {@link ExploreSyncHandler}: drives the
* dispatcher with synthetic requests and asserts the response shape
* and any persisted side-effects.
*/
public class ExploreSyncHandlerTest extends TestBase {
private static final String DEVICE_ADDRESS = "00:11:22:33:44:55";
private static final String PREF_BASELINE_ESTABLISHED = "garmin_explore_sync_baseline_established";
private RecordingGarminSupport support;
private ExploreSyncHandler handler;
@Before
@Override
public void setUp() throws Exception {
super.setUp();
support = new RecordingGarminSupport();
support.setContext(
new GBDevice(DEVICE_ADDRESS, "TestFenix", null, null, DeviceType.GARMIN_FENIX_7_PRO),
/*btAdapter*/ null,
org.robolectric.RuntimeEnvironment.getApplication());
handler = new ExploreSyncHandler(support);
}
// ---- StartSync / baseline ---------------------------------------------------
@Test
public void startSession_freshDevice_sendsFullSync() {
handler.startSession();
Assert.assertEquals(1, support.outgoing.size());
final StartSyncRequest req = support.outgoing.get(0)
.getExploreSyncService().getStartSyncRequest();
Assert.assertEquals(SyncType.SYNC_TYPE_FULL, req.getSyncType());
Assert.assertEquals(2, req.getProtocolVersion());
// Historical-only; live-activity capability lives on a separate branch.
Assert.assertEquals(0, req.getSyncCapabilities());
// Stable per-app id keeps the watch's saved_app_transaction_id
// across reinstalls — without it, the watch rejects us.
Assert.assertTrue(req.hasAppUuid());
Assert.assertEquals(16, req.getAppUuid().size());
}
@Test
public void startSession_baselineEstablished_sendsIncrementalSync() {
setBaselineEstablished(true);
handler.startSession();
Assert.assertEquals(SyncType.SYNC_TYPE_INCREMENTAL,
support.outgoing.get(0).getExploreSyncService()
.getStartSyncRequest().getSyncType());
}
@Test
public void syncFinishedOk_afterFullSync_flipsBaseline() {
Assert.assertFalse(isBaselineEstablished());
handler.startSession();
handler.handle(syncFinished(SyncFinishedStatus.SYNC_FINISHED_OK));
Assert.assertTrue(isBaselineEstablished());
}
@Test
public void syncFinishedFailed_leavesBaselineUnset() {
handler.startSession();
handler.handle(syncFinished(SyncFinishedStatus.SYNC_FINISHED_FAILED_DATA_LIMIT));
// Interrupted FULL must retry next time, so the pref must stay off.
Assert.assertFalse(isBaselineEstablished());
}
@Test
public void syncFinishedOk_afterIncremental_keepsBaselineTrue() {
setBaselineEstablished(true);
handler.startSession();
handler.handle(syncFinished(SyncFinishedStatus.SYNC_FINISHED_OK));
Assert.assertTrue(isBaselineEstablished());
}
@Test
public void watchInitiatedStartSync_replacesExistingSession() {
handler.startSession();
final ExploreSyncService resp = handler.handle(ExploreSyncService.newBuilder()
.setStartSyncRequest(StartSyncRequest.newBuilder()
.setSyncType(SyncType.SYNC_TYPE_FULL)
.setProtocolVersion(2))
.build());
// Echoed app_uuid is mandatory; watch rejects responses without it.
final StartSyncResponse sr = resp.getStartSyncResponse();
Assert.assertEquals(StartSyncStatus.START_SYNC_ACCEPTED, sr.getStatus());
Assert.assertEquals(16, sr.getAppUuid().size());
}
@Test
public void startSyncRejected_disposesSession() {
handler.startSession();
handler.handle(ExploreSyncService.newBuilder()
.setStartSyncResponse(StartSyncResponse.newBuilder()
.setStatus(StartSyncStatus.START_SYNC_REJECTED))
.build());
// Intra-session message after disposal returns null (state == null).
Assert.assertNull(handler.handle(emptyLineDigestWrite()));
}
@Test
public void onDisconnected_disposesSession() {
handler.startSession();
handler.onDisconnected();
Assert.assertNull(handler.handle(emptyLineDigestWrite()));
}
// ---- LineDigest enqueue -----------------------------------------------------
@Test
public void lineDigestWrite_emptyDigest_acksWithNoReadOp() {
handler.startSession();
final ExploreSyncService resp = handler.handle(emptyLineDigestWrite());
// Nothing to fetch — response carries no nextDataRequest.
Assert.assertEquals(GdiExploreSyncService.WriteStatus.WRITE_STATUS_SUCCESS,
resp.getLineDigestWriteResponse().getStatus());
Assert.assertFalse(resp.getLineDigestWriteResponse().hasLineReadOp());
}
@Test
public void lineDigestWrite_nonDeletedActivity_returnsReadOpForFirstUuid() {
handler.startSession();
final ByteString uuid = uuid("activity-aaaa");
final ExploreSyncService resp = handler.handle(lineDigestWrite(
activityRef(uuid, /*deleted*/ false)));
// Read op points at the queued UUID's POINTS part. POINTS is
// fetched first (matching Garmin Explore's flow); the first
// page's timestamps[0] is our skip-lookup key.
final LineDataRequestOp op = resp.getLineDigestWriteResponse().getLineReadOp();
Assert.assertEquals(uuid, op.getUuid());
Assert.assertEquals(LinePart.LINE_PART_POINTS, op.getPart());
Assert.assertEquals(0, op.getPointOffset());
}
@Test
public void lineDigestWrite_deletedEntriesSkipped_noReadOp() {
handler.startSession();
final ExploreSyncService resp = handler.handle(lineDigestWrite(
activityRef(uuid("dead-1"), /*deleted*/ true),
activityRef(uuid("dead-2"), /*deleted*/ true)));
// All deleted → queue stays empty → ack carries no nextDataRequest.
// Future work: propagate watch-side deletes to BaseActivitySummary.
Assert.assertFalse(resp.getLineDigestWriteResponse().hasLineReadOp());
}
@Test
public void lineDigestWrite_nonActivityLineTypesIgnored() {
handler.startSession();
final ExploreSyncService resp = handler.handle(lineDigestWrite(
LineDigest.LineReference.newBuilder()
.setUuid(uuid("route"))
.setLineType(LineType.LINE_TYPE_ROUTE)
.build()));
// Only LINE_TYPE_ACTIVITY entries are surfaced as BaseActivitySummary.
Assert.assertFalse(resp.getLineDigestWriteResponse().hasLineReadOp());
}
// ---- Per-line fetch + flush -------------------------------------------------
/** Encode the all-INT_MIN sentinel position the watch sends for
* GPS-less laps (indoor / pool).*/
private static long sentinelPosition() {
final long lat = Integer.MIN_VALUE & 0xFFFFFFFFL;
final long lon = ((long) Integer.MIN_VALUE) << 32;
return lat | lon;
}
/** Pack two int32 semicircles into the watch's fixed64 position
* encoding: low=lat, high=lon.*/
private static long packPosition(final int latSemi, final int lonSemi) {
return (latSemi & 0xFFFFFFFFL) | (((long) lonSemi) << 32);
}
@Test
public void pointsReply_statusMore_requestsNextOffset() {
final ByteString uuid = uuid("paged");
final int t0 = 1_700_000_500;
handler.startSession();
handler.handle(lineDigestWrite(activityRef(uuid, false)));
final ExploreSyncService resp = handler.handle(pointsReply(uuid,
ReadStatus.READ_STATUS_MORE,
/*nextOffset*/ 100,
/*timestamps*/ new int[]{t0},
/*positions*/ new long[]{sentinelPosition()},
/*heartRates*/ new byte[]{50}));
// Mid-pagination: re-request POINTS at the watch's offset.
final LineDataRequestOp next = resp.getLineDataReadyResponse().getNextDataRequest();
Assert.assertEquals(uuid, next.getUuid());
Assert.assertEquals(LinePart.LINE_PART_POINTS, next.getPart());
Assert.assertEquals(100, next.getPointOffset());
}
@Test
public void pointsReply_statusSuccess_advancesToSummary() {
final ByteString uuid = uuid("done");
final int t0 = 1_700_000_750;
handler.startSession();
handler.handle(lineDigestWrite(activityRef(uuid, false)));
final ExploreSyncService resp = handler.handle(pointsReply(uuid,
ReadStatus.READ_STATUS_SUCCESS,
/*nextOffset*/ 0,
new int[]{t0},
new long[]{sentinelPosition()},
new byte[]{50}));
// POINTS drained → next part owed: SUMMARY.
Assert.assertEquals(LinePart.LINE_PART_SUMMARY,
resp.getLineDataReadyResponse().getNextDataRequest().getPart());
}
@Test
public void pointsReply_firstPageMatchesFitImportedRow_skipsRemainingFetch() {
// FIT importer ran first and populated rawDetailsPath; that
// row counts as authoritative — the ExploreSync digest's same
// activity must skip without re-fetching POINTS.
final int t0 = 1_700_010_000;
final BaseActivitySummary preexisting = ActivitySummaryParser
.findOrCreateBaseActivitySummary(daoSession, support.getDevice(), (long) t0);
preexisting.setRawDetailsPath("/sdcard/.../ACTIVITY_2024-12-27.fit");
daoSession.getBaseActivitySummaryDao().insertOrReplace(preexisting);
final ByteString uuid = uuid("already-have");
handler.startSession();
handler.handle(lineDigestWrite(activityRef(uuid, false)));
final ExploreSyncService resp = handler.handle(pointsReply(uuid,
ReadStatus.READ_STATUS_MORE,
/*nextOffset*/ 50,
new int[]{t0},
new long[]{sentinelPosition()},
new byte[]{50}));
// Line drained → response carries no nextDataRequest.
Assert.assertFalse(resp.getLineDataReadyResponse().hasNextDataRequest());
}
@Test
public void readStatusError_dropsCurrentLine_movesToNextQueuedUuid() {
final ByteString first = uuid("err-1");
final ByteString second = uuid("ok-2");
handler.startSession();
handler.handle(lineDigestWrite(
activityRef(first, false),
activityRef(second, false)));
final ExploreSyncService resp = handler.handle(ExploreSyncService.newBuilder()
.setLineDataReadyRequest(LineDataReadyRequest.newBuilder()
.setStatus(ReadStatus.READ_STATUS_ERROR)
.setLine(Line.newBuilder().setUuid(first).setLineType(LineType.LINE_TYPE_ACTIVITY)))
.build());
// Watch returned an error for the head — drop it, move on to the
// next queued UUID. Next part for a fresh line is POINTS.
final LineDataRequestOp next = resp.getLineDataReadyResponse().getNextDataRequest();
Assert.assertEquals(second, next.getUuid());
Assert.assertEquals(LinePart.LINE_PART_POINTS, next.getPart());
}
@Test
public void indoorActivity_persistsSummaryWithoutGpx() {
final ByteString uuid = uuid("indoor");
final int t0 = 1_700_000_000;
runOneLine(uuid, t0, /*indoor*/ true, "Indoor Run", /*sport*/ null);
final BaseActivitySummary summary = findSummary(t0);
Assert.assertNotNull(summary);
// Sentinel coords give no polyline, so no GPX file.
Assert.assertNull(summary.getGpxTrack());
Assert.assertEquals("Indoor Run", summary.getName());
// End time is the last point.
Assert.assertEquals((t0 + 60) * 1000L, summary.getEndTime().getTime());
}
@Test
public void outdoorActivity_writesGpxAndSetsActivityKind() {
final ByteString uuid = uuid("outdoor");
final int t0 = 1_700_001_000;
// SportPart with running sport so flush() can map → ActivityKind.
final Line.SportPart sport = Line.SportPart.newBuilder()
.setSport(1).setSubSport(0).build();
runOneLine(uuid, t0, /*indoor*/ false, "Morning Run", sport);
final BaseActivitySummary summary = findSummary(t0);
Assert.assertNotNull(summary);
Assert.assertNotNull("outdoor line must produce a GPX path", summary.getGpxTrack());
Assert.assertNotEquals(ActivityKind.UNKNOWN.getCode(), summary.getActivityKind());
}
@Test
public void statPartWithRealValues_populatesSummaryDataChips() {
// StatPart fields above their sentinels should land in
// summaryData; sentinel-valued fields stay out.
final ByteString uuid = uuid("chips");
final int t0 = 1_700_003_000;
handler.startSession();
handler.handle(lineDigestWrite(activityRef(uuid, false)));
handler.handle(pointsReply(uuid, ReadStatus.READ_STATUS_SUCCESS, 0,
new int[]{t0, t0 + 60}, new long[]{sentinelPosition(), sentinelPosition()},
new byte[]{(byte) 100, (byte) 110}));
handler.handle(summaryReply(uuid, "With Stats", t0 + 70));
handler.handle(ExploreSyncService.newBuilder()
.setLineDataReadyRequest(LineDataReadyRequest.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS)
.setLine(Line.newBuilder()
.setUuid(uuid).setLineType(LineType.LINE_TYPE_ACTIVITY)
.setStatPart(Line.StatPart.newBuilder()
.setDistance(5000f)
.setCalories(420)
.setTimerTime(3600))))
.build());
handler.handle(ExploreSyncService.newBuilder()
.setLineDataReadyRequest(LineDataReadyRequest.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS)
.setLine(Line.newBuilder()
.setUuid(uuid).setLineType(LineType.LINE_TYPE_ACTIVITY)
.setSportPart(Line.SportPart.getDefaultInstance())))
.build());
final String chips = findSummary(t0).getSummaryData();
Assert.assertNotNull("StatPart values must produce summaryData chips", chips);
Assert.assertTrue("distance chip", chips.contains("\"distanceMeters\""));
Assert.assertTrue("calories chip", chips.contains("\"active_calories\""));
Assert.assertTrue("timer chip", chips.contains("\"activeSeconds\""));
}
@Test
public void summaryWithoutName_fallsBackToActivity() {
// SUMMARY reply without a name leaves summaryName at its
// default "Activity" fallback.
final ByteString uuid = uuid("nameless");
final int t0 = 1_700_004_000;
handler.startSession();
handler.handle(lineDigestWrite(activityRef(uuid, false)));
handler.handle(pointsReply(uuid, ReadStatus.READ_STATUS_SUCCESS, 0,
new int[]{t0}, new long[]{sentinelPosition()}, new byte[]{(byte) 80}));
// SUMMARY with creation_time but no name.
handler.handle(ExploreSyncService.newBuilder()
.setLineDataReadyRequest(LineDataReadyRequest.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS)
.setLine(Line.newBuilder()
.setUuid(uuid).setLineType(LineType.LINE_TYPE_ACTIVITY)
.setSummaryPart(Line.SummaryPart.newBuilder()
.setCreationTime(t0 + 10))))
.build());
// STAT + SPORT defaults in one reply to drain the line.
handler.handle(ExploreSyncService.newBuilder()
.setLineDataReadyRequest(LineDataReadyRequest.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS)
.setLine(Line.newBuilder()
.setUuid(uuid).setLineType(LineType.LINE_TYPE_ACTIVITY)
.setStatPart(Line.StatPart.getDefaultInstance())
.setSportPart(Line.SportPart.getDefaultInstance())))
.build());
Assert.assertEquals("Activity", findSummary(t0).getName());
}
// ---- Stateless ACKs ---------------------------------------------------------
@Test
public void changeSummaryRequest_repliesWithEmptyResponse() {
// Read-only ChangeSummaryResponse — we have nothing to report so
// the watch shouldn't gate its sync on our ending_transaction_id.
final ExploreSyncService resp = handler.handle(ExploreSyncService.newBuilder()
.setChangeSummaryRequest(ChangeSummaryRequest.newBuilder()
.setStartingTransactionId(42))
.build());
Assert.assertEquals(0, resp.getChangeSummaryResponse().getEndingTransactionId());
}
@Test
public void readOnlyStubs_returnSuccessStatus() {
// The watch may probe any of these read-only services. We reply
// SUCCESS with no body so it doesn't stall — no startSession needed.
Assert.assertEquals(ReadStatus.READ_STATUS_SUCCESS,
handler.handle(ExploreSyncService.newBuilder()
.setLineDigestReadRequest(GdiExploreSyncService.LineDigestReadRequest.getDefaultInstance())
.build())
.getLineDigestReadResponse().getStatus());
Assert.assertEquals(ReadStatus.READ_STATUS_SUCCESS,
handler.handle(ExploreSyncService.newBuilder()
.setLineReadRequest(GdiExploreSyncService.LineReadRequest.getDefaultInstance())
.build())
.getLineReadResponse().getStatus());
}
@Test
public void writeOnlyStubs_returnSuccessStatus() {
// The watch may push catalogue/collection writes regardless of
// whether we're in a session. Stateless WRITE_STATUS_SUCCESS replies.
Assert.assertEquals(GdiExploreSyncService.WriteStatus.WRITE_STATUS_SUCCESS,
handler.handle(ExploreSyncService.newBuilder()
.setCollectionListWriteRequest(GdiExploreSyncService.CollectionListWriteRequest.getDefaultInstance())
.build())
.getCollectionListWriteResponse().getStatus());
Assert.assertEquals(GdiExploreSyncService.WriteStatus.WRITE_STATUS_SUCCESS,
handler.handle(ExploreSyncService.newBuilder()
.setActiveLineDigestWriteRequest(GdiExploreSyncService.ActiveLineDigestWriteRequest.getDefaultInstance())
.build())
.getActiveLineDigestWriteResponse().getStatus());
}
// ---- helpers ----------------------------------------------------------------
private void setBaselineEstablished(final boolean v) {
support.getDevicePrefs().getPreferences().edit()
.putBoolean(PREF_BASELINE_ESTABLISHED, v).apply();
}
private boolean isBaselineEstablished() {
return support.getDevicePrefs().getBoolean(PREF_BASELINE_ESTABLISHED, false);
}
private static ByteString uuid(final String tag) {
return ByteString.copyFromUtf8(tag);
}
private static LineDigest.LineReference activityRef(final ByteString uuid, final boolean deleted) {
return LineDigest.LineReference.newBuilder()
.setUuid(uuid)
.setLineType(LineType.LINE_TYPE_ACTIVITY)
.setDeleted(deleted)
.setSummaryPartVersionStamp(VersionStamp.newBuilder().setEditTime(100))
.setPointPartVersionStamp(VersionStamp.newBuilder().setEditTime(200))
.build();
}
private static ExploreSyncService lineDigestWrite(final LineDigest.LineReference... lines) {
final LineDigest.Builder digest = LineDigest.newBuilder();
for (final LineDigest.LineReference line : lines) {
digest.addLines(line);
}
return ExploreSyncService.newBuilder()
.setLineDigestWriteRequest(LineDigestWriteRequest.newBuilder().setDigest(digest))
.build();
}
private static ExploreSyncService emptyLineDigestWrite() {
return ExploreSyncService.newBuilder()
.setLineDigestWriteRequest(LineDigestWriteRequest.newBuilder()
.setDigest(LineDigest.getDefaultInstance()))
.build();
}
private static ExploreSyncService syncFinished(final SyncFinishedStatus status) {
return ExploreSyncService.newBuilder()
.setSyncFinishedNotification(SyncFinishedNotification.newBuilder().setStatus(status))
.build();
}
/** Build a SUMMARY reply for one UUID. {@code creationTimeSeconds}
* is the value the handler keys its skip-lookup off. */
private static ExploreSyncService summaryReply(final ByteString uuid,
final String name,
final int creationTimeSeconds) {
return ExploreSyncService.newBuilder()
.setLineDataReadyRequest(LineDataReadyRequest.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS)
.setLine(Line.newBuilder()
.setUuid(uuid)
.setLineType(LineType.LINE_TYPE_ACTIVITY)
.setSummaryPart(Line.SummaryPart.newBuilder()
.setName(name)
.setCreationTime(creationTimeSeconds))))
.build();
}
/** Build a POINTS reply for one UUID with parallel timestamp /
* position / HR arrays — all three must have the same length. */
private static ExploreSyncService pointsReply(final ByteString uuid,
final ReadStatus status,
final int nextOffset,
final int[] timestamps,
final long[] positions,
final byte[] heartRates) {
final Line.PointPart.Builder pp = Line.PointPart.newBuilder()
.setHeartRates(ByteString.copyFrom(heartRates));
for (final int t : timestamps) {
pp.addTimestamps(t);
}
for (final long p : positions) {
pp.addGenericPositions(p);
}
final LineDataReadyRequest.Builder req = LineDataReadyRequest.newBuilder()
.setStatus(status)
.setLine(Line.newBuilder()
.setUuid(uuid)
.setLineType(LineType.LINE_TYPE_ACTIVITY)
.setPointPart(pp));
if (status == ReadStatus.READ_STATUS_MORE) {
req.setNextPointOffset(nextOffset);
}
return ExploreSyncService.newBuilder().setLineDataReadyRequest(req).build();
}
/** Drive one complete UUID through the fetcher: digest → POINTS
* → SUMMARY → empty STAT/SPORT to drain the fetch list and
* trigger flush(). */
private void runOneLine(final ByteString uuid,
final int t0,
final boolean indoor,
final String name,
final Line.SportPart sport) {
handler.startSession();
handler.handle(lineDigestWrite(activityRef(uuid, false)));
// POINTS first.
final long pos = indoor ? sentinelPosition() : packPosition(
/*lat=45°*/ (int) (45.0 / (180.0 / 0x80000000L)),
/*lon=10°*/ (int) (10.0 / (180.0 / 0x80000000L)));
handler.handle(pointsReply(uuid,
ReadStatus.READ_STATUS_SUCCESS, 0,
new int[]{t0, t0 + 60},
new long[]{pos, pos},
new byte[]{(byte) 120, (byte) 125}));
// SUMMARY with a creation_time at activity-end+10s to mimic
// the real watch (which finalizes the SummaryPart after the
// session ends, not at start).
handler.handle(summaryReply(uuid, name, t0 + 60 + 10));
// STAT reply — explicit default so hasStatPart() is true and
// the handler advances past STAT.
handler.handle(ExploreSyncService.newBuilder()
.setLineDataReadyRequest(LineDataReadyRequest.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS)
.setLine(Line.newBuilder()
.setUuid(uuid).setLineType(LineType.LINE_TYPE_ACTIVITY)
.setStatPart(Line.StatPart.getDefaultInstance())))
.build());
// SPORT reply — caller's sport or explicit default.
handler.handle(ExploreSyncService.newBuilder()
.setLineDataReadyRequest(LineDataReadyRequest.newBuilder()
.setStatus(ReadStatus.READ_STATUS_SUCCESS)
.setLine(Line.newBuilder()
.setUuid(uuid).setLineType(LineType.LINE_TYPE_ACTIVITY)
.setSportPart(sport != null ? sport : Line.SportPart.getDefaultInstance())))
.build());
}
private BaseActivitySummary findSummary(final int t0Seconds) {
return ActivitySummaryParser.findBaseActivitySummary(
daoSession, support.getDevice(), (long) t0Seconds);
}
/** Bare-bones GarminSupport that records outgoing protobuf requests
* instead of dispatching them over BLE. */
private static class RecordingGarminSupport extends GarminSupport {
final List<GdiSmartProto.Smart> outgoing = new ArrayList<>();
@Override
public BluetoothGattCharacteristic getCharacteristic(final UUID uuid) {
return new BluetoothGattCharacteristic(null, 0, 0);
}
@Override
void sendProtobufRequest(final String taskName, final GdiSmartProto.Smart payload) {
outgoing.add(payload);
}
@Override
public java.io.File getWritableExportDirectory() throws java.io.IOException {
// Robolectric has no real coordinator setup; route GPX writes
// through the JVM tmpdir so flush() can complete.
final java.io.File dir = new java.io.File(
System.getProperty("java.io.tmpdir"), "gb-explore-test");
if (!dir.exists() && !dir.mkdirs()) {
throw new java.io.IOException("mkdirs failed: " + dir);
}
return dir;
}
}
}