diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
index cd4f57f7b6..de4afebd9f 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
@@ -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
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ExploreSyncHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ExploreSyncHandler.java
new file mode 100644
index 0000000000..bb73cf9f1f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ExploreSyncHandler.java
@@ -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 . */
+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 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 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 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 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 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;
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java
index e1b1d36bad..5c2ffd4234 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java
@@ -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));
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java
index 54b9ef2d62..50949ce2cb 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java
@@ -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 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;
diff --git a/app/src/main/proto/garmin/gdi_explore_sync.proto b/app/src/main/proto/garmin/gdi_explore_sync.proto
new file mode 100644
index 0000000000..a0e1c6ed52
--- /dev/null
+++ b/app/src/main/proto/garmin/gdi_explore_sync.proto
@@ -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;
+}
diff --git a/app/src/main/proto/garmin/gdi_smart_proto.proto b/app/src/main/proto/garmin/gdi_smart_proto.proto
index d1fecf1ea9..4bfe21ef67 100644
--- a/app/src/main/proto/garmin/gdi_smart_proto.proto
+++ b/app/src/main/proto/garmin/gdi_smart_proto.proto
@@ -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;
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ExploreSyncHandlerTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ExploreSyncHandlerTest.java
new file mode 100644
index 0000000000..4607ada43b
--- /dev/null
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ExploreSyncHandlerTest.java
@@ -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 . */
+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 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;
+ }
+ }
+}