Compare commits

...

13 Commits

Author SHA1 Message Date
kuhy 85178d8d40 Garmin protocol: show AGPS data status in settings 2024-05-03 16:41:01 +02:00
kuhy b92e1ff947 Garmin protocol: add AGPS data checks 2024-05-03 16:41:01 +02:00
kuhy 00faf1be6b Garmin protocol: install AGPS data as firmware 2024-05-03 16:40:59 +02:00
kuhy e97d699c6f Garmin protocol: improve detection of successfully sent files (DataTransferHandler) 2024-05-03 16:38:37 +02:00
kuhy 13cf48a6c5 Garmin protocol: add support for AGPS data retrieval 2024-05-03 16:38:37 +02:00
Daniele Gobbetti 2bfbb75c0b Fixup: Introduce device specific writable directory (MAC address)
Add logic to not fetch again files which had the previously defined name
2024-05-03 12:22:15 +02:00
Daniele Gobbetti 598549b1f5 Introduce device specific writable directory (MAC address)
Also adds temporary method to move the fetched files from the legacy path to the new one which does not include the device name.
Also moves the FileIndex to the end of the cached files to allow for easier sorting.

Cherry-picked from 525b395c01 and adapted
2024-05-03 10:27:00 +02:00
José Rebelo 7dcefd1815 Garmin: Make fit header crc optional 2024-05-03 09:51:44 +02:00
Daniele Gobbetti f932dabc72 Garmin: enable unicode Emoji for all devices
This seems to be widely supported by garmin devices, hence enable it in the base coordinator. Specific devices not supporting Unicode Emojis can override this method and return false.
2024-05-03 09:45:04 +02:00
Daniele Gobbetti 36911b890f Garmin: harmonize device names
All device name strings start with manufacturer name.
Normalized the usage of accented i.
2024-05-03 09:36:36 +02:00
Andreas Schneider 1a07ad8ff1 Garmin: add coordinator for Instinct Crossover 2024-05-03 09:34:34 +02:00
Daniele Gobbetti 19095caa6e Garmin: fix regression in call handling
Add a fictitious action to the notification to enable reply/hangup/reject from the watch.
Also fixes the behavior on sms reply, which should also reject the incoming call.

Change the log level in case some of the canned messages types are left as default to info, as this is a supported scenario.
2024-05-03 09:30:38 +02:00
Daniele Gobbetti 533ce0441f Garmin: encode unknown weather codes as invalid 2024-05-01 16:51:56 +02:00
28 changed files with 994 additions and 74 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

@ -79,6 +79,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
@ -290,6 +291,18 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
return null;
}
@Override
public File getWritableExportDirectory(final GBDevice device) throws IOException {
File dir;
dir = new File(FileUtils.getExternalFilesDir() + File.separator + device.getAddress());
if (!dir.isDirectory()) {
if (!dir.mkdir()) {
throw new IOException("Cannot create device specific directory for " + device.getName());
}
}
return dir;
}
@Override
public String getAppCacheSortFilename() {
return null;

View File

@ -442,6 +442,11 @@ public interface DeviceCoordinator {
*/
File getAppCacheDir() throws IOException;
/**
* Returns the dedicated writable export directory for this device.
*/
File getWritableExportDirectory(GBDevice device) throws IOException;
/**
* Returns a String containing the device app sort order filename.
*/

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,25 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
protected static Prefs getPrefs(final GBDevice device) {
return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
}
@Override
public boolean supportsUnicodeEmojis() {
return true;
}
@Override
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

@ -0,0 +1,19 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinctcrossover;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import java.util.regex.Pattern;
public class GarminInstinctCrossoverCoordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("Instinct Crossover");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_instinct_crossover;
}
}

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

@ -54,6 +54,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinctsolar.GarminI
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2s.GarminInstinct2SCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2solar.GarminInstinct2SolarCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2soltac.GarminInstinct2SolTacCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinctcrossover.GarminInstinctCrossoverCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.venu3.GarminVenu3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivoactive4s.GarminVivoActive4SCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivoactive5.GarminVivoActive5Coordinator;
@ -335,6 +336,7 @@ public enum DeviceType {
GARMIN_INSTINCT_2S(GarminInstinct2SCoordinator.class),
GARMIN_INSTINCT_2_SOLAR(GarminInstinct2SolarCoordinator.class),
GARMIN_INSTINCT_2_SOLTAC(GarminInstinct2SolTacCoordinator.class),
GARMIN_INSTINCT_CROSSOVER(GarminInstinctCrossoverCoordinator.class),
GARMIN_VIVOMOVE_STYLE(GarminVivomoveStyleCoordinator.class),
GARMIN_VENU_3(GarminVenu3Coordinator.class),
GARMIN_VIVOACTIVE_4S(GarminVivoActive4SCoordinator.class),

View File

@ -336,6 +336,12 @@ public class FileTransferHandler implements MessageHandler {
}
public String getFileName() {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
String dateString = dateFormat.format(fileDate);
return getFiletype().name() + "_" + dateString + "_" + getFileIndex() + (getFiletype().isFitFile() ? ".fit" : ".bin");
}
public String getLegacyFileName() {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
String dateString = dateFormat.format(fileDate);
return getFiletype().name() + "_" + getFileIndex() + "_" + dateString + (getFiletype().isFitFile() ? ".fit" : ".bin");

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;
@ -445,6 +452,9 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
}
private void processDownloadQueue() {
moveFilesFromLegacyCache(); //TODO: remove before merging
if (!filesToDownload.isEmpty() && !fileTransferHandler.isDownloading()) {
if (!gbDevice.isBusy()) {
GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext());
@ -454,7 +464,7 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
try {
FileTransferHandler.DirectoryEntry directoryEntry = filesToDownload.remove();
while (checkFileExists(directoryEntry.getFileName())) {
while (checkFileExists(directoryEntry.getFileName()) || checkFileExists(directoryEntry.getLegacyFileName())) {
LOG.debug("File: {} already downloaded, not downloading again.", directoryEntry.getFileName());
if (!getKeepActivityDataOnDevice()) // delete file from watch if already downloaded
sendOutgoingMessage(new SetFileFlagsMessage(directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
@ -484,6 +494,36 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
}
}
private void moveFilesFromLegacyCache() { //TODO: remove before merging
File legacyDir;
try {
legacyDir = new File(FileUtils.getExternalFilesDir() + "/" + FileUtils.makeValidFileName(getDevice().getName() + "_" + getDevice().getAddress()));
if (legacyDir.isDirectory()) {
final File newDir = getWritableExportDirectory();
File[] files = legacyDir.listFiles();
for (File file : files) {
if (file.isFile()) {
File destFile = new File(newDir, file.getName());
boolean success = file.renameTo(destFile);
if (!success) {
LOG.error("Failed to move file {}", file.getName());
} else {
LOG.info("Moved file {} to new cache directory", file.getName());
}
}
}
boolean removed = legacyDir.delete();
if (!removed) {
LOG.error("Failed to remove legacy directory: {}", legacyDir);
}
}
} catch (IOException e) {
LOG.error(e.getMessage());
}
}
private void enableBatteryLevelUpdate() {
final ProtobufMessage batteryLevelProtobufRequest = protocolBufferHandler.prepareProtobufRequest(GdiSmartProto.Smart.newBuilder()
.setDeviceStatusService(
@ -583,6 +623,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 {
@ -597,14 +659,7 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
}
public File getWritableExportDirectory() throws IOException {
File dir;
dir = new File(FileUtils.getExternalFilesDir() + "/" + FileUtils.makeValidFileName(getDevice().getName() + "_" + getDevice().getAddress()));
if (!dir.isDirectory()) {
if (!dir.mkdir()) {
throw new IOException("Cannot create device specific directory for " + getDevice().getName());
}
}
return dir;
return getDevice().getDeviceCoordinator().getWritableExportDirectory(getDevice());
}
@Override
@ -621,4 +676,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

@ -84,6 +84,11 @@ public class NotificationsHandler implements MessageHandler {
callNotificationSpec.type = NotificationType.GENERIC_PHONE;
callNotificationSpec.body = StringUtils.isEmpty(callSpec.name) ? callSpec.number : callSpec.name;
// add an empty bogus action to toggle the hasActions boolean. The actions are hardcoded on the watch in case of incoming calls.
callNotificationSpec.attachedActions = new ArrayList<>();
callNotificationSpec.attachedActions.add(0, new NotificationSpec.Action());
return onNotification(callNotificationSpec);
} else {
if (callSpec.number != null) // this happens in debug screen
@ -180,6 +185,8 @@ public class NotificationsHandler implements MessageHandler {
final GBDeviceEventCallControl deviceEvtCallControl = new GBDeviceEventCallControl();
switch (message.getNotificationAction()) {
case REPLY_INCOMING_CALL:
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.REJECT;
message.addGbDeviceEvent(deviceEvtCallControl);
case REPLY_MESSAGES:
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY;
deviceEvtNotificationControl.reply = message.getActionString();
@ -188,23 +195,23 @@ public class NotificationsHandler implements MessageHandler {
} else {
deviceEvtNotificationControl.handle = mNotificationReplyAction.lookup(notificationSpec.getId()); //handle of wearable action is needed
}
message.setDeviceEvent(deviceEvtNotificationControl);
message.addGbDeviceEvent(deviceEvtNotificationControl);
break;
case ACCEPT_INCOMING_CALL:
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.ACCEPT;
message.setDeviceEvent(deviceEvtCallControl);
message.addGbDeviceEvent(deviceEvtCallControl);
break;
case REJECT_INCOMING_CALL:
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.REJECT;
message.setDeviceEvent(deviceEvtCallControl);
message.addGbDeviceEvent(deviceEvtCallControl);
break;
case DISMISS_NOTIFICATION:
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.DISMISS;
message.setDeviceEvent(deviceEvtNotificationControl);
message.addGbDeviceEvent(deviceEvtNotificationControl);
break;
case BLOCK_APPLICATION:
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.MUTE;
message.setDeviceEvent(deviceEvtNotificationControl);
message.addGbDeviceEvent(deviceEvtNotificationControl);
break;
}
}

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));
@ -349,7 +365,7 @@ public class ProtocolBufferHandler implements MessageHandler {
);
} else {
builder.setStatus(GdiSmsNotification.SmsNotificationService.ResponseStatus.GENERIC_ERROR);
LOG.error("Missing canned messages data for type {}", requestedType);
LOG.info("Missing canned messages data for type {}", requestedType);
}
}

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

@ -192,7 +192,7 @@ public class FitFile {
if (hasCRC) {
int incomingCrc = garminByteBufferReader.readShort();
if (incomingCrc != ChecksumCalculator.computeCrc(garminByteBufferReader.asReadOnlyBuffer(), 0, headerSize - 2)) {
if (incomingCrc != 0 && incomingCrc != ChecksumCalculator.computeCrc(garminByteBufferReader.asReadOnlyBuffer(), 0, headerSize - 2)) {
throw new IllegalArgumentException("Wrong CRC for header in FIT file");
}
// LOG.info("Fit File Header didn't have CRC, no check performed.");

View File

@ -138,7 +138,7 @@ public class FieldDefinitionWeatherCondition extends FieldDefinition {
case 902: //hurricane
case 962: //hurricane
default:
throw new IllegalArgumentException("Unknown weather code " + openWeatherCode);
return 255; //invalid
}
}

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

@ -1,5 +1,6 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
@ -21,7 +22,7 @@ public class NotificationControlMessage extends GFDIMessage {
private NotificationsHandler.LegacyNotificationAction legacyNotificationAction;
private NotificationsHandler.NotificationAction notificationAction;
private String actionString;
private GBDeviceEvent deviceEvent;
private List<GBDeviceEvent> gbDeviceEventList;
public NotificationControlMessage(GarminMessage garminMessage, NotificationsHandler.NotificationCommand command, int notificationId, NotificationsHandler.NotificationAction notificationAction, String actionString) {
this.garminMessage = garminMessage;
@ -108,13 +109,17 @@ public class NotificationControlMessage extends GFDIMessage {
return actionString;
}
public void setDeviceEvent(GBDeviceEvent deviceEvent) {
this.deviceEvent = deviceEvent;
public void addGbDeviceEvent(GBDeviceEvent gbDeviceEvent) {
if (null == this.gbDeviceEventList)
this.gbDeviceEventList = new ArrayList<>();
this.gbDeviceEventList.add(gbDeviceEvent);
}
@Override
public List<GBDeviceEvent> getGBDeviceEvent() {
return Collections.singletonList(deviceEvent);
if (null == this.gbDeviceEventList)
return Collections.emptyList();
return gbDeviceEventList;
}
public NotificationsHandler.LegacyNotificationAction getLegacyNotificationAction() {

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>
@ -1481,15 +1486,16 @@
<string name="devicetype_amazfit_gts2e">Amazfit GTS 2e</string>
<string name="devicetype_amazfit_x">Amazfit X</string>
<string name="devicetype_zepp_e">Zepp E</string>
<string name="devicetype_garmin_vivomove_hr">Garmin Vivomove HR</string>
<string name="devicetype_garmin_vivomove_style">Vívomove Style</string>
<string name="devicetype_garmin_vivomove_hr">Garmin Vívomove HR</string>
<string name="devicetype_garmin_vivomove_style">Garmin Vívomove Style</string>
<string name="devicetype_garmin_instinct_solar">Garmin Instinct Solar</string>
<string name="devicetype_garmin_instinct_2s">Garmin Instinct 2S</string>
<string name="devicetype_garmin_instinct_2_solar">Garmin Instinct 2 Solar</string>
<string name="devicetype_garmin_instinct_2_soltac">Instinct 2 SolTac</string>
<string name="devicetype_garmin_instinct_2_soltac">Garmin Instinct 2 SolTac</string>
<string name="devicetype_garmin_instinct_crossover">Garmin Instinct Crossover</string>
<string name="devicetype_garmin_forerunner_245">Garmin Forerunner 245</string>
<string name="devicetype_garmin_vivoactive_4s">Vívoactive 4S</string>
<string name="devicetype_garmin_vivoactive_5">Vivoactive 5</string>
<string name="devicetype_garmin_vivoactive_4s">Garmin Vívoactive 4S</string>
<string name="devicetype_garmin_vivoactive_5">Garmin Vívoactive 5</string>
<string name="devicetype_vibratissimo">Vibratissimo</string>
<string name="devicetype_um25">UM-25</string>
<string name="devicetype_liveview">LiveView</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>