1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-06-01 19:06:06 +02:00

Compare commits

...

5 Commits

19 changed files with 878 additions and 48 deletions

View File

@ -231,6 +231,7 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_AGPS_EXPIRY_REMINDER_TIME = "pref_agps_expiry_reminder_time";
public static final String PREF_AGPS_UPDATE_TIME = "pref_agps_update_time";
public static final String PREF_AGPS_EXPIRE_TIME = "pref_agps_expire_time";
public static final String PREF_AGPS_STATUS = "pref_agps_status";
public static final String PREF_FIND_PHONE = "prefs_find_phone";
public static final String PREF_FIND_PHONE_DURATION = "prefs_find_phone_duration";

View File

@ -0,0 +1,122 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import android.content.Context;
import android.net.Uri;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps.GarminAgpsFile;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
public class GarminAgpsInstallHandler implements InstallHandler {
private static final Logger LOG = LoggerFactory.getLogger(GarminAgpsInstallHandler.class);
protected final Context mContext;
private GarminAgpsFile file;
public GarminAgpsInstallHandler(final Uri uri, final Context context) {
this.mContext = context;
final UriHelper uriHelper;
try {
uriHelper = UriHelper.get(uri, context);
} catch (final IOException e) {
LOG.error("Failed to get uri", e);
return;
}
try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
final byte[] rawBytes = FileUtils.readAll(in, 1024 * 1024); // 1MB, they're usually ~60KB
final GarminAgpsFile agpsFile = new GarminAgpsFile(rawBytes);
if (agpsFile.isValid()) {
this.file = agpsFile;
}
} catch (final Exception e) {
LOG.error("Failed to read file", e);
}
}
@Override
public boolean isValid() {
return file != null;
}
@Override
public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
if (device.isBusy()) {
installActivity.setInfoText(device.getBusyTask());
installActivity.setInstallEnabled(false);
return;
}
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
if (!(coordinator instanceof GarminCoordinator)) {
LOG.warn("Coordinator is not a GarminCoordinator: {}", coordinator.getClass());
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
installActivity.setInstallEnabled(false);
return;
}
final GarminCoordinator garminCoordinator = (GarminCoordinator) coordinator;
if (!garminCoordinator.supportsAgpsUpdates()) {
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
installActivity.setInstallEnabled(false);
return;
}
if (!device.isInitialized()) {
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
installActivity.setInstallEnabled(false);
return;
}
final GenericItem fwItem = createInstallItem(device);
fwItem.setIcon(coordinator.getDefaultIconResource());
if (file == null) {
fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_incompatible_version));
installActivity.setInfoText(mContext.getString(R.string.fwinstaller_firmware_not_compatible_to_device));
installActivity.setInstallEnabled(false);
return;
}
final StringBuilder builder = new StringBuilder();
final String agpsBundle = mContext.getString(R.string.kind_agps_bundle);
builder.append(mContext.getString(R.string.fw_upgrade_notice, agpsBundle));
builder.append("\n\n").append(mContext.getString(R.string.miband_firmware_unknown_warning));
fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_untested_version));
installActivity.setInfoText(builder.toString());
installActivity.setInstallItem(fwItem);
installActivity.setInstallEnabled(true);
}
@Override
public void onStartInstall(final GBDevice device) {
}
public GarminAgpsFile getFile() {
return file;
}
private GenericItem createInstallItem(final GBDevice device) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
final String firmwareName = mContext.getString(
R.string.installhandler_firmware_name,
mContext.getString(coordinator.getDeviceNameResource()),
mContext.getString(R.string.kind_agps_bundle),
""
);
return new GenericItem(firmwareName);
}
}

View File

@ -1,5 +1,8 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import java.util.List;
@ -8,8 +11,10 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
@ -50,6 +55,9 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
final List<Integer> location = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.LOCATION);
location.add(R.xml.devicesettings_workout_send_gps_to_band);
if (supportsAgpsUpdates()) {
location.add(R.xml.devicesettings_garmin_agps);
}
final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
connection.add(R.xml.devicesettings_high_mtu);
@ -60,6 +68,11 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
return deviceSpecificSettings;
}
@Override
public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(GBDevice device) {
return new GarminSettingsCustomizer();
}
@Override
public boolean supportsActivityDataFetching() {
return true;
@ -87,4 +100,19 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
protected static Prefs getPrefs(final GBDevice device) {
return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
}
public InstallHandler findInstallHandler(final Uri uri, final Context context) {
if (supportsAgpsUpdates()) {
final GarminAgpsInstallHandler agpsInstallHandler = new GarminAgpsInstallHandler(uri, context);
if (agpsInstallHandler.isValid()) {
return agpsInstallHandler;
}
}
return null;
}
public boolean supportsAgpsUpdates() {
return false;
}
}

View File

@ -0,0 +1,72 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.preference.Preference;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.Locale;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps.GarminAgpsStatus;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class GarminSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
@Override
public void onPreferenceChange(Preference preference, DeviceSpecificSettingsHandler handler) {
}
@Override
public void customizeSettings(DeviceSpecificSettingsHandler handler, Prefs prefs) {
final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
final Preference prefAgpsUpdateTime = handler.findPreference(DeviceSettingsPreferenceConst.PREF_AGPS_UPDATE_TIME);
if (prefAgpsUpdateTime != null) {
final long ts = prefs.getLong(DeviceSettingsPreferenceConst.PREF_AGPS_UPDATE_TIME, 0L);
if (ts > 0) {
prefAgpsUpdateTime.setSummary(sdf.format(new Date(ts)));
} else {
prefAgpsUpdateTime.setSummary(handler.getContext().getString(R.string.unknown));
}
}
final Preference prefAgpsStatus = handler.findPreference(DeviceSettingsPreferenceConst.PREF_AGPS_STATUS);
if (prefAgpsStatus != null) {
final GarminAgpsStatus agpsStatus = GarminAgpsStatus.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.MISSING.name()));
prefAgpsStatus.setSummary(handler.getContext().getString(agpsStatus.getText()));
}
}
@Override
public Set<String> getPreferenceKeysWithSummary() {
return Collections.emptySet();
}
public static final Creator<GarminSettingsCustomizer> CREATOR = new Creator<GarminSettingsCustomizer>() {
@Override
public GarminSettingsCustomizer createFromParcel(final Parcel in) {
return new GarminSettingsCustomizer();
}
@Override
public GarminSettingsCustomizer[] newArray(final int size) {
return new GarminSettingsCustomizer[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
}
}

View File

@ -6,13 +6,23 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminVivoActive4SCoordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("vĂ­voactive 4S");
}
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("vĂ­voactive 4S");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivoactive_4s;
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivoactive_4s;
}
@Override
public boolean supportsFlashing() {
return true;
}
@Override
public boolean supportsAgpsUpdates() {
return true;
}
}

View File

@ -3,11 +3,14 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.location.Location;
import android.net.Uri;
import android.widget.Toast;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.DecimalFormat;
import java.util.ArrayList;
@ -24,7 +27,10 @@ import java.util.TimerTask;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminAgpsInstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService;
@ -44,6 +50,7 @@ import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps.GarminAgpsStatus;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v1.CommunicatorV1;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v2.CommunicatorV2;
@ -583,6 +590,28 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
}
}
@Override
public void onInstallApp(final Uri uri) {
final GarminAgpsInstallHandler agpsHandler = new GarminAgpsInstallHandler(uri, getContext());
if (agpsHandler.isValid()) {
try {
// Write the AGPS update to a temporary file in cache, so we can load it when requested
final File agpsFile = getAgpsFile();
try (FileOutputStream outputStream = new FileOutputStream(agpsFile)) {
outputStream.write(agpsHandler.getFile().getBytes());
evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.PENDING.name()
));
LOG.info("AGPS file successfully written to the cache directory.");
} catch (final IOException e) {
LOG.error("Failed to write AGPS bytes to temporary directory", e);
}
} catch (final Exception e) {
GB.toast(getContext(), "AGPS install error: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
}
}
}
private boolean checkFileExists(String fileName) {
File dir;
try {
@ -621,4 +650,20 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
);
sendOutgoingMessage(locationUpdatedNotificationRequest);
}
public File getAgpsFile() throws IOException {
return new File(getAgpsCacheDirectory(), "CPE.BIN");
}
private File getAgpsCacheDirectory() throws IOException {
final File cacheDir = getContext().getCacheDir();
final File agpsCacheDir = new File(cacheDir, "garmin-agps");
if (agpsCacheDir.mkdir()) {
LOG.info("AGPS cache directory for Garmin devices successfully created.");
} else if (!agpsCacheDir.exists() || !agpsCacheDir.isDirectory()) {
throw new IOException("Cannot create/locate AGPS directory for Garmin devices.");
}
return agpsCacheDir;
}
}

View File

@ -24,11 +24,13 @@ import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService
import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiCalendarService;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiCore;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDataTransferService;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDeviceStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiFindMyWatch;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiHttpService;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmsNotification;
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;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ProtobufMessage;
@ -45,12 +47,16 @@ public class ProtocolBufferHandler implements MessageHandler {
private final Map<Integer, ProtobufFragment> chunkedFragmentsMap;
private final int maxChunkSize = 375; //tested on VĂ­vomove Style
private int lastProtobufRequestId;
private final HttpHandler httpHandler;
private final DataTransferHandler dataTransferHandler;
private final Map<GdiSmsNotification.SmsNotificationService.CannedListType, String[]> cannedListTypeMap = new HashMap<>();
public ProtocolBufferHandler(GarminSupport deviceSupport) {
this.deviceSupport = deviceSupport;
chunkedFragmentsMap = new HashMap<>();
httpHandler = new HttpHandler(deviceSupport);
dataTransferHandler = new DataTransferHandler();
}
private int getNextProtobufRequestId() {
@ -91,12 +97,19 @@ public class ProtocolBufferHandler implements MessageHandler {
return prepareProtobufResponse(processProtobufSmsNotificationMessage(smart.getSmsNotificationService()), message.getRequestId());
}
if (smart.hasHttpService()) {
final GdiHttpService.HttpService response = HttpHandler.handle(smart.getHttpService());
final GdiHttpService.HttpService response = httpHandler.handle(smart.getHttpService());
if (response == null) {
return null;
}
return prepareProtobufResponse(GdiSmartProto.Smart.newBuilder().setHttpService(response).build(), message.getRequestId());
}
if (smart.hasDataTransferService()) {
final GdiDataTransferService.DataTransferService response = dataTransferHandler.handle(smart.getDataTransferService(), message.getRequestId());
if (response == null) {
return null;
}
return prepareProtobufResponse(GdiSmartProto.Smart.newBuilder().setDataTransferService(response).build(), message.getRequestId());
}
if (smart.hasDeviceStatusService()) {
processed = true;
processProtobufDeviceStatusResponse(smart.getDeviceStatusService());
@ -116,6 +129,9 @@ public class ProtocolBufferHandler implements MessageHandler {
private ProtobufMessage processIncoming(ProtobufStatusMessage statusMessage) {
LOG.info("Processing protobuf status message #{}@{}: status={}, error={}", statusMessage.getRequestId(), statusMessage.getDataOffset(), statusMessage.getProtobufChunkStatus(), statusMessage.getProtobufStatusCode());
//TODO: check status and react accordingly, right now we blindly proceed to next chunk
if (statusMessage.isOK()) {
DataTransferHandler.onDataChunkSuccessfullyReceived(statusMessage.getRequestId());
}
if (chunkedFragmentsMap.containsKey(statusMessage.getRequestId()) && statusMessage.isOK()) {
final ProtobufFragment protobufFragment = chunkedFragmentsMap.get(statusMessage.getRequestId());
LOG.debug("Protobuf message #{} found in queue: {}", statusMessage.getRequestId(), GB.hexdump(protobufFragment.fragmentBytes));

View File

@ -0,0 +1,90 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.threeten.bp.Instant;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Callable;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GBTarFile;
public class AgpsHandler {
private static final Logger LOG = LoggerFactory.getLogger(AgpsHandler.class);
private static final String QUERY_CONSTELLATIONS = "constellations";
private final GarminSupport deviceSupport;
public AgpsHandler(GarminSupport deviceSupport) {
this.deviceSupport = deviceSupport;
}
public byte[] handleAgpsRequest(final String path, final Map<String, String> query) {
try {
if (!query.containsKey(QUERY_CONSTELLATIONS)) {
LOG.debug("Query does not contain information about constellations; skipping request.");
return null;
}
final File agpsFile = deviceSupport.getAgpsFile();
if (!agpsFile.exists() || !agpsFile.isFile()) {
LOG.info("File with AGPS data does not exist.");
return null;
}
try(InputStream agpsIn = new FileInputStream(agpsFile)) {
final byte[] rawBytes = FileUtils.readAll(agpsIn, 1024 * 1024); // 1MB, they're usually ~60KB
final GBTarFile tarFile = new GBTarFile(rawBytes);
final String[] requestedConstellations = Objects.requireNonNull(query.get(QUERY_CONSTELLATIONS)).split(",");
for (final String constellation: requestedConstellations) {
try {
final GarminAgpsDataType garminAgpsDataType = GarminAgpsDataType.valueOf(constellation);
if (!tarFile.containsFile(garminAgpsDataType.getFileName())) {
LOG.error("AGPS archive is missing requested file: {}", garminAgpsDataType.getFileName());
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.ERROR.name()
));
return null;
}
} catch (IllegalArgumentException e) {
LOG.error("Device requested unsupported AGPS data type: {}", constellation);
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.ERROR.name()
));
return null;
}
}
LOG.info("Sending new AGPS data to the device.");
return rawBytes;
}
} catch (IOException e) {
LOG.error("Unable to obtain AGPS data.", e);
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.ERROR.name()
));
return null;
}
}
public Callable<Void> getOnDataSuccessfullySentListener() {
return () -> {
LOG.info("AGPS data successfully sent to the device.");
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
DeviceSettingsPreferenceConst.PREF_AGPS_UPDATE_TIME, Instant.now().toEpochMilli()
));
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.CURRENT.name()
));
if (deviceSupport.getAgpsFile().delete()) {
LOG.info("AGPS data was deleted from the cache folder.");
}
return null;
};
}
}

View File

@ -0,0 +1,25 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps;
public enum GarminAgpsDataType {
GLONASS("CPE_GLO.BIN"), QZSS("CPE_QZSS.BIN"), GPS("CPE_GPS.BIN"),
GALILEO("CPE_GAL.BIN");
private final String fileName;
GarminAgpsDataType(String fileName) {
this.fileName = fileName;
}
public String getFileName() {
return fileName;
}
public static boolean isValidAgpsDataFileName(String fileName) {
for (GarminAgpsDataType type: GarminAgpsDataType.values()) {
if (fileName.equals(type.fileName)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,37 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.util.GBTarFile;
public class GarminAgpsFile {
private static final Logger LOG = LoggerFactory.getLogger(GarminAgpsFile.class);
private final byte[] tarBytes;
public GarminAgpsFile(final byte[] tarBytes) {
this.tarBytes = tarBytes;
}
public boolean isValid() {
if (!GBTarFile.isTarFile(tarBytes)) {
LOG.debug("Is not TAR file!");
return false;
}
final GBTarFile tarFile = new GBTarFile(tarBytes);
for (final String fileName: tarFile.listFileNames()) {
if (!GarminAgpsDataType.isValidAgpsDataFileName(fileName)) {
LOG.error("Unknown file in TAR archive: {}", fileName);
return false;
}
}
return true;
}
public byte[] getBytes() {
return tarBytes.clone();
}
}

View File

@ -0,0 +1,23 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps;
import androidx.annotation.StringRes;
import nodomain.freeyourgadget.gadgetbridge.R;
public enum GarminAgpsStatus {
MISSING(R.string.agps_status_missing), // AGPS data file was not yet installed
PENDING(R.string.agps_status_pending), // AGPS data file is waiting for installation
CURRENT(R.string.agps_status_current), // AGPS data was successfully installed
ERROR(R.string.agps_status_error); // Unable to install AGPS data file
private final @StringRes int text;
GarminAgpsStatus(@StringRes int text) {
this.text = text;
}
public @StringRes int getText() {
return text;
}
}

View File

@ -0,0 +1,178 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http;
import com.google.protobuf.ByteString;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Random;
import java.util.TreeMap;
import java.util.concurrent.Callable;
import java.util.concurrent.atomic.AtomicInteger;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDataTransferService;
public class DataTransferHandler {
private static final Logger LOG = LoggerFactory.getLogger(DataTransferHandler.class);
private static final AtomicInteger idCounter = new AtomicInteger((new Random()).nextInt(Integer.MAX_VALUE / 2));
private static final Map<Integer, Data> dataById = new HashMap<>();
private static final Map<Integer, ChunkInfo> unprocessedChunksByRequestId = new HashMap<>();
public GdiDataTransferService.DataTransferService handle(
final GdiDataTransferService.DataTransferService dataTransferService,
final int requestId
) {
if (dataTransferService.hasDataDownloadRequest()) {
final GdiDataTransferService.DataTransferService.DataDownloadResponse dataDownloadResponse
= handleDataDownloadRequest(dataTransferService.getDataDownloadRequest(), requestId);
if (dataDownloadResponse != null) {
return GdiDataTransferService.DataTransferService.newBuilder()
.setDataDownloadResponse(dataDownloadResponse)
.build();
}
return null;
}
LOG.warn("Unsupported data transfer service request: {}", dataTransferService);
return null;
}
public GdiDataTransferService.DataTransferService.DataDownloadResponse handleDataDownloadRequest(
final GdiDataTransferService.DataTransferService.DataDownloadRequest dataDownloadRequest,
final int requestId
) {
final int dataId = dataDownloadRequest.getId();
final int offset = dataDownloadRequest.getOffset();
LOG.debug("Received data download request (id: {}, offset: {})", dataId, offset);
final Data data = dataById.get(dataId);
if (data == null) {
LOG.error("Device requested data with invalid id: {}", dataId);
return GdiDataTransferService.DataTransferService.DataDownloadResponse.newBuilder()
.setStatus(GdiDataTransferService.DataTransferService.Status.INVALID_ID)
.setId(dataId)
.setOffset(offset)
.build();
}
final int maxChunkSize = dataDownloadRequest.hasMaxChunkSize() ? dataDownloadRequest.getMaxChunkSize() : Integer.MAX_VALUE;
final byte[] chunk = data.getDataChunk(offset, maxChunkSize);
if (chunk == null) {
LOG.error("Device requested data with invalid offset: {}", offset);
return GdiDataTransferService.DataTransferService.DataDownloadResponse.newBuilder()
.setStatus(GdiDataTransferService.DataTransferService.Status.INVALID_OFFSET)
.setId(dataId)
.setOffset(offset)
.build();
}
unprocessedChunksByRequestId.put(requestId, new ChunkInfo(dataId, offset, offset + chunk.length));
return GdiDataTransferService.DataTransferService.DataDownloadResponse.newBuilder()
.setStatus(GdiDataTransferService.DataTransferService.Status.SUCCESS)
.setId(dataId)
.setOffset(offset)
.setPayload(ByteString.copyFrom(chunk))
.build();
}
public static int registerData(final byte[] data) {
int id = idCounter.getAndIncrement();
LOG.info("New data will be sent to the device (id: {}, size: {})", id, data.length);
dataById.put(id, new Data(data));
return id;
}
public static void onDataChunkSuccessfullyReceived(final int requestId) {
final ChunkInfo chunkInfo = unprocessedChunksByRequestId.get(requestId);
if (chunkInfo == null) {
return;
}
unprocessedChunksByRequestId.remove(requestId);
final Data data = dataById.get(chunkInfo.dataId);
if (data == null) {
return;
}
data.onDataChunkSuccessfullyReceived(chunkInfo);
if (data.isDataSuccessfullySent()) {
LOG.info("Data successfully sent to the device (id: {}, size: {})", chunkInfo.dataId, data.data.length);
for (Callable<Void> listener : data.onDataSuccessfullySentListeners) {
try {
listener.call();
} catch (Exception e) {
LOG.error("Data listener failed.", e);
}
}
dataById.remove(chunkInfo.dataId);
} else {
LOG.debug(
"Data chunk successfully sent to the device (dataId: {}, requestId: {}): {}-{}/{}",
chunkInfo.dataId, requestId, chunkInfo.start, chunkInfo.end, data.data.length
);
}
}
public static void addOnDataSuccessfullySentListener(final int dataId, final Callable<Void> listener) {
Objects.requireNonNull(dataById.get(dataId)).onDataSuccessfullySentListeners.add(listener);
}
private static class ChunkInfo {
private final int dataId;
private final int start;
private final int end;
private ChunkInfo(int dataId, int start, int end) {
this.dataId = dataId;
this.start = start;
this.end = end;
}
}
private static class Data {
// TODO Wouldn't it be better to store data as streams?
// Because now we have to store the whole data in RAM.
private final byte[] data;
private final TreeMap<Integer, ChunkInfo> chunksReceivedByDevice;
private final List<Callable<Void>> onDataSuccessfullySentListeners;
private Data(byte[] data) {
this.data = data;
chunksReceivedByDevice = new TreeMap<>();
onDataSuccessfullySentListeners = new ArrayList<>();
}
private byte[] getDataChunk(final int offset, final int maxChunkSize) {
if (offset < 0 || offset >= data.length) {
return null;
}
return Arrays.copyOfRange(data, offset, Math.min(offset + maxChunkSize, data.length));
}
private void onDataChunkSuccessfullyReceived(ChunkInfo newlyReceivedChunk) {
final ChunkInfo alreadyReceivedChunk = chunksReceivedByDevice.get(newlyReceivedChunk.start);
if (alreadyReceivedChunk == null || alreadyReceivedChunk.end < newlyReceivedChunk.end) {
chunksReceivedByDevice.put(newlyReceivedChunk.start, newlyReceivedChunk);
}
}
private boolean isDataSuccessfullySent() {
Integer previousChunkEnd = null;
for (Map.Entry<Integer, ChunkInfo> chunkEntry : chunksReceivedByDevice.entrySet()) {
if (previousChunkEnd == null && chunkEntry.getKey() != 0) {
// The head of the data wasn't received by the device.
return false;
}
if (previousChunkEnd != null && chunkEntry.getKey() > previousChunkEnd) {
// There is some gap between received chunks.
return false;
}
previousChunkEnd = chunkEntry.getValue().end;
}
// Check if the end of the last chunk matches the data size.
return previousChunkEnd != null && data.length == previousChunkEnd;
}
}
}

View File

@ -16,9 +16,12 @@ import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.zip.GZIPOutputStream;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiHttpService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps.AgpsHandler;
import nodomain.freeyourgadget.gadgetbridge.util.HttpUtils;
public class HttpHandler {
@ -28,7 +31,13 @@ public class HttpHandler {
//.serializeNulls()
.create();
public static GdiHttpService.HttpService handle(final GdiHttpService.HttpService httpService) {
private final AgpsHandler agpsHandler;
public HttpHandler(GarminSupport deviceSupport) {
agpsHandler = new AgpsHandler(deviceSupport);
}
public GdiHttpService.HttpService handle(final GdiHttpService.HttpService httpService) {
if (httpService.hasRawRequest()) {
final GdiHttpService.HttpService.RawResponse rawResponse = handleRawRequest(httpService.getRawRequest());
if (rawResponse != null) {
@ -44,7 +53,8 @@ public class HttpHandler {
return null;
}
public static GdiHttpService.HttpService.RawResponse handleRawRequest(final GdiHttpService.HttpService.RawRequest rawRequest) {
public GdiHttpService.HttpService.RawResponse handleRawRequest(final GdiHttpService.HttpService.RawRequest rawRequest) {
// TODO Return status code 304 (Not Modified) when we don't have newer data and "if-none-match" is set.
final String urlString = rawRequest.getUrl();
LOG.debug("Got rawRequest: {} - {}", rawRequest.getMethod(), urlString);
@ -58,53 +68,85 @@ public class HttpHandler {
final String path = url.getPath();
final Map<String, String> query = HttpUtils.urlQueryParameters(url);
final Map<String, String> requestHeaders = headersToMap(rawRequest.getHeaderList());
final byte[] responseBody;
final List<GdiHttpService.HttpService.Header> responseHeaders = new ArrayList<>();
if (path.startsWith("/weather/")) {
LOG.debug("Got weather request for {}", path);
final Object obj = WeatherHandler.handleWeatherRequest(path, query);
if (obj == null) {
LOG.info("Got weather request for {}", path);
final Object weatherData = WeatherHandler.handleWeatherRequest(path, query);
if (weatherData == null) {
return null;
}
final String json = GSON.toJson(obj);
final String json = GSON.toJson(weatherData);
LOG.debug("Weather response: {}", json);
final byte[] stringBytes = json.getBytes(StandardCharsets.UTF_8);
if ("gzip".equals(requestHeaders.get("accept-encoding"))) {
responseHeaders.add(
GdiHttpService.HttpService.Header.newBuilder()
.setKey("Content-Encoding")
.setValue("gzip")
.build()
);
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (GZIPOutputStream gzos = new GZIPOutputStream(baos)) {
gzos.write(stringBytes);
gzos.finish();
gzos.flush();
responseBody = baos.toByteArray();
} catch (final Exception e) {
LOG.error("Failed to compress response", e);
return null;
}
} else {
responseBody = stringBytes;
return createRawResponse(rawRequest, json.getBytes(StandardCharsets.UTF_8), "application/json", null);
} else if (path.startsWith("/ephemeris/")) {
LOG.info("Got AGPS request for {}", path);
final byte[] agpsData = agpsHandler.handleAgpsRequest(path, query);
if (agpsData == null) {
return null;
}
responseHeaders.add(
GdiHttpService.HttpService.Header.newBuilder()
.setKey("Content-Type")
.setValue("application/json")
.build()
);
LOG.debug("Successfully obtained AGPS data (length: {})", agpsData.length);
return createRawResponse(rawRequest, agpsData, "application/x-tar", agpsHandler.getOnDataSuccessfullySentListener());
} else {
LOG.warn("Unhandled path {}", urlString);
return null;
}
}
private static GdiHttpService.HttpService.RawResponse createRawResponse(
final GdiHttpService.HttpService.RawRequest rawRequest,
final byte[] data,
final String contentType,
final Callable<Void> onDataSuccessfullySentListener
) {
if (rawRequest.hasUseDataXfer() && rawRequest.getUseDataXfer()) {
LOG.debug("Data will be returned using data_xfer");
int id = DataTransferHandler.registerData(data);
if (onDataSuccessfullySentListener != null) {
DataTransferHandler.addOnDataSuccessfullySentListener(id, onDataSuccessfullySentListener);
}
return GdiHttpService.HttpService.RawResponse.newBuilder()
.setStatus(GdiHttpService.HttpService.Status.OK)
.setHttpStatus(200)
.setXferData(
GdiHttpService.HttpService.DataTransferItem.newBuilder()
.setId(id)
.setSize(data.length)
.build()
)
.build();
}
final Map<String, String> requestHeaders = headersToMap(rawRequest.getHeaderList());
final List<GdiHttpService.HttpService.Header> responseHeaders = new ArrayList<>();
final byte[] responseBody;
if ("gzip".equals(requestHeaders.get("accept-encoding"))) {
responseHeaders.add(
GdiHttpService.HttpService.Header.newBuilder()
.setKey("Content-Encoding")
.setValue("gzip")
.build()
);
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (GZIPOutputStream gzos = new GZIPOutputStream(baos)) {
gzos.write(data);
gzos.finish();
gzos.flush();
responseBody = baos.toByteArray();
} catch (final Exception e) {
LOG.error("Failed to compress response", e);
return null;
}
} else {
responseBody = data;
}
responseHeaders.add(
GdiHttpService.HttpService.Header.newBuilder()
.setKey("Content-Type")
.setValue(contentType)
.build()
);
return GdiHttpService.HttpService.RawResponse.newBuilder()
.setStatus(GdiHttpService.HttpService.Status.OK)

View File

@ -0,0 +1,79 @@
package nodomain.freeyourgadget.gadgetbridge.util;
import org.bouncycastle.shaded.util.Arrays;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class GBTarFile {
private static final Logger LOG = LoggerFactory.getLogger(GBTarFile.class);
private final byte[] tarBytes;
public static final int TAR_MAGIC_BYTES_OFFSET = 257;
public static final byte[] TAR_MAGIC_BYTES = new byte[]{
'u', 's', 't', 'a', 'r', '\0'
};
public static final int TAR_BLOCK_SIZE = 512;
public static final int TAR_HEADER_FILE_NAME_OFFSET = 0;
public static final int TAR_HEADER_FILE_NAME_LENGTH = 100;
public static final int TAR_HEADER_FILE_SIZE_OFFSET = 124;
public static final int TAR_HEADER_FILE_SIZE_LENGTH = 12;
public GBTarFile(byte[] tarBytes) {
this.tarBytes = tarBytes;
}
public static boolean isTarFile(byte[] data) {
return ArrayUtils.equals(data, TAR_MAGIC_BYTES, TAR_MAGIC_BYTES_OFFSET);
}
public List<String> listFileNames() {
final List<String> fileNames = new ArrayList<>();
for (TarHeader header: listHeaders()) {
fileNames.add(header.fileName);
}
return fileNames;
}
public boolean containsFile(String fileName) {
for (TarHeader header: listHeaders()) {
if (fileName.equals(header.fileName)) {
return true;
}
}
return false;
}
private List<TarHeader> listHeaders() {
final List<TarHeader> headers = new ArrayList<>();
int offset = 0;
while (ArrayUtils.equals(tarBytes, TAR_MAGIC_BYTES, offset + TAR_MAGIC_BYTES_OFFSET)) {
final TarHeader tarHeader = new TarHeader(Arrays.copyOfRange(tarBytes, offset, offset + TAR_BLOCK_SIZE));
headers.add(tarHeader);
offset += (((tarHeader.fileSize + TAR_BLOCK_SIZE - 1) / TAR_BLOCK_SIZE) + 1) * TAR_BLOCK_SIZE;
}
return headers;
}
private static class TarHeader {
final String fileName;
final int fileSize;
public TarHeader(byte[] header) {
fileName = parseString(header, TAR_HEADER_FILE_NAME_OFFSET, TAR_HEADER_FILE_NAME_LENGTH);
fileSize = Integer.parseInt(parseString(header, TAR_HEADER_FILE_SIZE_OFFSET, TAR_HEADER_FILE_SIZE_LENGTH).trim(), 8);
}
private static String parseString(final byte[] data, final int offset, final int maxLength) {
int length = 0;
while (length < maxLength && offset + length < data.length && data[offset + length] != 0) {
length++;
}
return new String(data, offset, length, StandardCharsets.US_ASCII);
}
}
}

View File

@ -0,0 +1,30 @@
syntax = "proto2";
package garmin_vivomovehr;
option java_package = "nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr";
message DataTransferService {
enum Status {
UNKNOWN = 0;
SUCCESS = 1;
INVALID_ID = 2;
INVALID_OFFSET = 3;
}
optional DataDownloadRequest dataDownloadRequest = 1;
optional DataDownloadResponse dataDownloadResponse = 2;
message DataDownloadRequest {
required uint32 id = 1;
required uint32 offset = 2;
optional uint32 maxChunkSize = 3;
}
message DataDownloadResponse {
required Status status = 1;
required uint32 id = 2;
required uint32 offset = 3;
optional bytes payload = 4;
}
}

View File

@ -30,15 +30,22 @@ message HttpService {
required string url = 1;
optional Method method = 3;
repeated Header header = 5;
optional bool useDataXfer = 6;
}
message RawResponse {
optional Status status = 1;
optional uint32 httpStatus = 2;
optional bytes body = 3;
optional DataTransferItem xferData = 4;
repeated Header header = 5;
}
message DataTransferItem {
required uint32 id = 1;
required uint32 size = 2;
}
message Header {
required string key = 1;
required string value = 2;

View File

@ -8,6 +8,7 @@ import "garmin_vivomovehr/gdi_device_status.proto";
import "garmin_vivomovehr/gdi_find_my_watch.proto";
import "garmin_vivomovehr/gdi_core.proto";
import "garmin_vivomovehr/gdi_http_service.proto";
import "garmin_vivomovehr/gdi_data_transfer_service.proto";
import "garmin_vivomovehr/gdi_sms_notification.proto";
import "garmin_vivomovehr/gdi_calendar_service.proto";
import "garmin_vivomovehr/gdi_settings_service.proto";
@ -15,6 +16,7 @@ import "garmin_vivomovehr/gdi_settings_service.proto";
message Smart {
optional CalendarService calendar_service = 1;
optional HttpService http_service = 2;
optional DataTransferService data_transfer_service = 7;
optional DeviceStatusService device_status_service = 8;
optional FindMyWatchService find_my_watch_service = 12;
optional CoreService core_service = 13;

View File

@ -531,6 +531,11 @@
<string name="pref_agps_expiry_reminder_time">AGPS Expiry Reminder Time</string>
<string name="pref_agps_update_time">AGPS Update Time</string>
<string name="pref_agps_expire_time">AGPS Expire Time</string>
<string name="pref_agps_status">AGPS Status</string>
<string name="agps_status_missing">Missing</string>
<string name="agps_status_pending">Pending</string>
<string name="agps_status_current">Current</string>
<string name="agps_status_error">Error</string>
<string name="pref_camera_remote_title">Camera Remote</string>
<string name="pref_camera_remote_summary">Allows the watch to trigger the phone\'s camera</string>
<string name="pref_morning_updates_title">Morning Updates</string>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
android:icon="@drawable/ic_gps_edit"
android:key="pref_header_agps"
android:title="@string/pref_agps_header">
<Preference
android:key="pref_agps_status"
android:title="@string/pref_agps_status" />
<Preference
android:key="pref_agps_update_time"
android:title="@string/pref_agps_update_time" />
<!-- <Preference-->
<!-- android:key="pref_agps_expire_time"-->
<!-- android:title="@string/pref_agps_expire_time" />-->
</PreferenceCategory>
</androidx.preference.PreferenceScreen>