From c1946573fbe412a19dc2413694903670765228a7 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 28 Apr 2026 22:20:07 +0100 Subject: [PATCH] Garmin: ExploreSync historical activity import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../devices/garmin/GarminCoordinator.java | 16 +- .../devices/garmin/ExploreSyncHandler.java | 867 ++++++++++++++++++ .../service/devices/garmin/GarminSupport.java | 10 + .../devices/garmin/ProtocolBufferHandler.java | 14 + .../main/proto/garmin/gdi_explore_sync.proto | 318 +++++++ .../main/proto/garmin/gdi_smart_proto.proto | 2 + .../garmin/ExploreSyncHandlerTest.java | 647 +++++++++++++ 7 files changed, 1873 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ExploreSyncHandler.java create mode 100644 app/src/main/proto/garmin/gdi_explore_sync.proto create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ExploreSyncHandlerTest.java 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; + } + } +}