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