Compare commits
13 Commits
b09d00d555
...
85178d8d40
Author | SHA1 | Date |
---|---|---|
kuhy | 85178d8d40 | |
kuhy | b92e1ff947 | |
kuhy | 00faf1be6b | |
kuhy | e97d699c6f | |
kuhy | 13cf48a6c5 | |
Daniele Gobbetti | 2bfbb75c0b | |
Daniele Gobbetti | 598549b1f5 | |
José Rebelo | 7dcefd1815 | |
Daniele Gobbetti | f932dabc72 | |
Daniele Gobbetti | 36911b890f | |
Andreas Schneider | 1a07ad8ff1 | |
Daniele Gobbetti | 19095caa6e | |
Daniele Gobbetti | 533ce0441f |
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue