Garmin: Allow agps updates for multiple URLs

This commit is contained in:
José Rebelo 2024-05-13 22:15:44 +01:00
parent e5f1c970af
commit a0d3af0bef
20 changed files with 315 additions and 323 deletions

View File

@ -18,6 +18,7 @@ package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings;
import android.content.Context;
import androidx.activity.result.ActivityResultCaller;
import androidx.annotation.NonNull;
import androidx.preference.Preference;
@ -27,7 +28,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
* A device-specific preference handler, that allows for {@link nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator}s to register
* their own preferences dynamically.
*/
public interface DeviceSpecificSettingsHandler {
public interface DeviceSpecificSettingsHandler extends ActivityResultCaller {
/**
* Finds a preference with the given key. Returns null if the preference is not found.
*

View File

@ -1,122 +0,0 @@
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,8 +1,5 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import java.util.List;
@ -14,7 +11,6 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpec
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.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
@ -114,7 +110,7 @@ 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()) {
if (supportsAgpsUpdates(device)) {
location.add(R.xml.devicesettings_garmin_agps);
}
@ -210,19 +206,7 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
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;
public boolean supportsAgpsUpdates(final GBDevice device) {
return !getPrefs(device).getString(GarminPreferences.PREF_AGPS_KNOWN_URLS, "").isEmpty();
}
}

View File

@ -1,6 +1,24 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
public class GarminPreferences {
public static final String PREF_GARMIN_CAPABILITIES = "garmin_capabilities";
public static final String PREF_FEAT_CANNED_MESSAGES = "feat_canned_messages";
public static final String PREF_AGPS_KNOWN_URLS = "garmin_agps_known_urls";
public static final String PREF_GARMIN_AGPS_STATUS = "garmin_agps_status_%s";
public static final String PREF_GARMIN_AGPS_UPDATE_TIME = "garmin_agps_update_time_%s";
public static final String PREF_GARMIN_AGPS_LOCAL_URI = "garmin_agps_local_uri_%s";
public static String agpsStatus(final String url) {
return String.format(GarminPreferences.PREF_GARMIN_AGPS_STATUS, CheckSums.md5(url));
}
public static String agpsUpdateTime(final String url) {
return String.format(GarminPreferences.PREF_GARMIN_AGPS_UPDATE_TIME, CheckSums.md5(url));
}
public static String agpsLocalUri(final String url) {
return String.format(GarminPreferences.PREF_GARMIN_AGPS_LOCAL_URI, CheckSums.md5(url));
}
}

View File

@ -1,13 +1,29 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import android.os.Parcel;
import static nodomain.freeyourgadget.gadgetbridge.util.GB.toast;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Parcel;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.documentfile.provider.DocumentFile;
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@ -16,31 +32,113 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSett
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.GB;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class GarminSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
public class GarminSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
private static final Logger LOG = LoggerFactory.getLogger(GarminSettingsCustomizer.class);
private final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
@Override
public void onPreferenceChange(Preference preference, DeviceSpecificSettingsHandler handler) {
public void onPreferenceChange(final Preference preference, final 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));
public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs) {
final PreferenceCategory prefAgpsHeader = handler.findPreference(DeviceSettingsPreferenceConst.PREF_HEADER_AGPS);
if (prefAgpsHeader != null) {
final List<String> urls = prefs.getList(GarminPreferences.PREF_AGPS_KNOWN_URLS, Collections.emptyList(), "\n");
if (urls.isEmpty()) {
return;
}
}
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()));
int i = 0;
for (final String url : urls) {
i++;
final Preference prefHeader = new PreferenceCategory(handler.getContext());
prefHeader.setKey("pref_agps_url_header_" + i);
prefHeader.setIconSpaceReserved(false);
prefHeader.setTitle(handler.getContext().getString(R.string.garmin_agps_url_i, i));
prefAgpsHeader.addPreference(prefHeader);
final Preference prefUrl = new Preference(handler.getContext());
prefUrl.setOnPreferenceClickListener(preference -> {
final ClipboardManager clipboard = (ClipboardManager) handler.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
final ClipData clip = ClipData.newPlainText(handler.getContext().getString(R.string.url), url);
clipboard.setPrimaryClip(clip);
toast(handler.getContext(), handler.getContext().getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT, GB.INFO);
return true;
});
prefUrl.setKey("pref_garmin_agps_url_" + i);
prefUrl.setIcon(R.drawable.ic_link);
prefUrl.setTitle(R.string.url);
prefUrl.setSummary(url);
prefAgpsHeader.addPreference(prefUrl);
final String currentPath = prefs.getString(GarminPreferences.agpsLocalUri(url), "");
final Preference prefLocalFile = new Preference(handler.getContext());
final ActivityResultLauncher<String[]> agpsFileChooser = handler.registerForActivityResult(
new ActivityResultContracts.OpenDocument(),
localUri -> {
LOG.info("Garmin agps file for {}: {}", url, localUri);
if (localUri != null) {
handler.getContext().getContentResolver().takePersistableUriPermission(localUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
prefs.getPreferences().edit()
.putString(GarminPreferences.agpsLocalUri(url), localUri.toString())
.putString(GarminPreferences.agpsStatus(url), GarminAgpsStatus.PENDING.toString())
.apply();
prefLocalFile.setSummary(localUri.toString());
}
}
);
prefLocalFile.setOnPreferenceClickListener(preference -> {
agpsFileChooser.launch(new String[]{"*/*"});
return true;
});
prefLocalFile.setKey(GarminPreferences.agpsLocalUri(url));
prefLocalFile.setIcon(R.drawable.ic_file_open);
prefLocalFile.setTitle(R.string.garmin_agps_local_file);
prefLocalFile.setSummary(currentPath);
prefAgpsHeader.addPreference(prefLocalFile);
final Preference prefStatus = new Preference(handler.getContext());
prefStatus.setKey(GarminPreferences.agpsStatus(url));
prefStatus.setIcon(R.drawable.ic_health);
prefStatus.setTitle(R.string.status);
final GarminAgpsStatus agpsStatus;
if (!currentPath.isEmpty()) {
final DocumentFile localFile = DocumentFile.fromSingleUri(handler.getContext(), Uri.parse(currentPath));
if (localFile != null && localFile.isFile() && localFile.canRead()) {
if (localFile.lastModified() > prefs.getLong(GarminPreferences.agpsUpdateTime(url), 0L)) {
agpsStatus = GarminAgpsStatus.CURRENT;
} else {
agpsStatus = GarminAgpsStatus.PENDING;
}
} else {
agpsStatus = GarminAgpsStatus.MISSING;
}
} else {
agpsStatus = GarminAgpsStatus.MISSING;
}
prefStatus.setSummary(handler.getContext().getString(agpsStatus.getText()));
prefAgpsHeader.addPreference(prefStatus);
final Preference prefUpdateTime = new Preference(handler.getContext());
prefUpdateTime.setKey(GarminPreferences.agpsUpdateTime(url));
prefUpdateTime.setIcon(R.drawable.ic_calendar_today);
prefUpdateTime.setTitle(R.string.pref_agps_update_time);
final long ts = prefs.getLong(GarminPreferences.agpsUpdateTime(url), 0L);
if (ts > 0) {
prefUpdateTime.setSummary(SDF.format(new Date(ts)));
} else {
prefUpdateTime.setSummary(handler.getContext().getString(R.string.unknown));
}
prefAgpsHeader.addPreference(prefUpdateTime);
}
}
}

View File

@ -15,14 +15,4 @@ public class GarminForerunner245Coordinator extends GarminCoordinator {
public int getDeviceNameResource() {
return R.string.devicetype_garmin_forerunner_245;
}
@Override
public boolean supportsFlashing() {
return true;
}
@Override
public boolean supportsAgpsUpdates() {
return true;
}
}

View File

@ -15,14 +15,4 @@ public class GarminInstinct2SCoordinator extends GarminCoordinator {
public int getDeviceNameResource() {
return R.string.devicetype_garmin_instinct_2s;
}
@Override
public boolean supportsFlashing() {
return true;
}
@Override
public boolean supportsAgpsUpdates() {
return true;
}
}

View File

@ -16,14 +16,4 @@ public class GarminInstinct2SolarCoordinator extends GarminCoordinator {
public int getDeviceNameResource() {
return R.string.devicetype_garmin_instinct_2_solar;
}
@Override
public boolean supportsFlashing() {
return true;
}
@Override
public boolean supportsAgpsUpdates() {
return true;
}
}

View File

@ -15,14 +15,4 @@ public class GarminInstinct2SolTacCoordinator extends GarminCoordinator {
public int getDeviceNameResource() {
return R.string.devicetype_garmin_instinct_2_soltac;
}
@Override
public boolean supportsFlashing() {
return true;
}
@Override
public boolean supportsAgpsUpdates() {
return true;
}
}

View File

@ -15,14 +15,4 @@ public class GarminVivoActive4SCoordinator extends GarminCoordinator {
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivoactive_4s;
}
@Override
public boolean supportsFlashing() {
return true;
}
@Override
public boolean supportsAgpsUpdates() {
return true;
}
}

View File

@ -6,11 +6,13 @@ import android.location.Location;
import android.net.Uri;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.documentfile.provider.DocumentFile;
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;
@ -29,12 +31,9 @@ import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
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.GarminCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
@ -57,7 +56,6 @@ 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;
@ -79,11 +77,10 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SetF
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SupportedFileTypesMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SystemEventMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.NotificationSubscriptionStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.Optional;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALLOW_HIGH_MTU;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GARMIN_DEFAULT_REPLY_SUFFIX;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS;
@ -652,28 +649,6 @@ 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 alreadyDownloaded(final FileTransferHandler.DirectoryEntry entry) {
final Optional<File> file = getFile(entry.getFileName());
if (file.isPresent()) {
@ -728,19 +703,25 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
sendOutgoingMessage(locationUpdatedNotificationRequest);
}
public File getAgpsFile() throws IOException {
return new File(getAgpsCacheDirectory(), "CPE.BIN");
}
@Nullable
public DocumentFile getAgpsFile(final String url) {
final Prefs prefs = getDevicePrefs();
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.");
final String localPathUri = prefs.getString(GarminPreferences.agpsLocalUri(url), "");
if (localPathUri.isEmpty()) {
LOG.debug("Local path not set for {}", url);
return null;
}
return agpsCacheDir;
final DocumentFile localFile = DocumentFile.fromSingleUri(getContext(), Uri.parse(localPathUri));
if (localFile == null) {
LOG.warn("Failed to load local file {} for {}", localPathUri, url);
return null;
}
if (!localFile.isFile() || !localFile.canRead()) {
LOG.warn("Local agps file {} for {} can't be read", localPathUri, url);
return null;
}
return localFile;
}
public GarminCoordinator getCoordinator() {

View File

@ -1,22 +1,25 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps;
import androidx.documentfile.provider.DocumentFile;
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.ArrayList;
import java.util.Collections;
import java.util.List;
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.devices.garmin.GarminPreferences;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GBTarFile;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class AgpsHandler {
private static final Logger LOG = LoggerFactory.getLogger(AgpsHandler.class);
@ -27,63 +30,71 @@ public class AgpsHandler {
this.deviceSupport = deviceSupport;
}
public byte[] handleAgpsRequest(final String path, final Map<String, String> query) {
public byte[] handleAgpsRequest(final String url, final String path, final Map<String, String> query) {
saveKnownUrl(url);
try {
if (!query.containsKey(QUERY_CONSTELLATIONS)) {
LOG.debug("Query does not contain information about constellations; skipping request.");
final DocumentFile agpsFile = deviceSupport.getAgpsFile(url);
if (agpsFile == null) {
LOG.warn("File with AGPS data for {} does not exist.", url);
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)) {
try (InputStream agpsIn = deviceSupport.getContext().getContentResolver().openInputStream(agpsFile.getUri())) {
if (agpsIn == null) {
LOG.error("Failed to open input stream for agps file {}", agpsFile.getUri());
return null;
}
// Run some sanity checks on known agps file formats
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()
));
final GarminAgpsFile garminAgpsFile = new GarminAgpsFile(rawBytes);
if (query.containsKey(QUERY_CONSTELLATIONS)) {
final String[] requestedConstellations = Objects.requireNonNull(query.get(QUERY_CONSTELLATIONS)).split(",");
if (!garminAgpsFile.isValidTar(requestedConstellations)) {
reportError(url);
return null;
}
} else if (path.contains(("/rxnetworks/"))) {
if (!garminAgpsFile.isValidRxNetworks()) {
reportError(url);
return null;
}
}
LOG.info("Sending new AGPS data to the device.");
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()
));
} catch (final IOException e) {
LOG.error("Unable to obtain AGPS data", e);
reportError(url);
return null;
}
}
public Callable<Void> getOnDataSuccessfullySentListener() {
public void saveKnownUrl(final String url) {
final Prefs devicePrefs = deviceSupport.getDevicePrefs();
final List<String> knownAgpsUrls = new ArrayList<>(devicePrefs.getList(GarminPreferences.PREF_AGPS_KNOWN_URLS, Collections.emptyList(), "\n"));
if (!knownAgpsUrls.contains(url)) {
knownAgpsUrls.add(url);
devicePrefs.getPreferences().edit()
.putString(GarminPreferences.PREF_AGPS_KNOWN_URLS, String.join("\n", knownAgpsUrls))
.apply();
}
}
private void reportError(final String url) {
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
GarminPreferences.agpsStatus(url), GarminAgpsStatus.ERROR.name()
));
}
public Callable<Void> getOnDataSuccessfullySentListener(final String urlString) {
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.");
}
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences()
.withPreference(GarminPreferences.agpsStatus(urlString), GarminAgpsStatus.CURRENT.name())
.withPreference(GarminPreferences.agpsUpdateTime(urlString), Instant.now().toEpochMilli())
);
return null;
};
}

View File

@ -3,27 +3,42 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.zip.GZIPInputStream;
import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.GBTarFile;
public class GarminAgpsFile {
private static final Logger LOG = LoggerFactory.getLogger(GarminAgpsFile.class);
private final byte[] tarBytes;
private final byte[] bytes;
public GarminAgpsFile(final byte[] tarBytes) {
this.tarBytes = tarBytes;
public static final byte[] GZ_HEADER = new byte[]{(byte) 0x1f, (byte) 0x8b};
public static final byte[] CPE_RXNETWORKS_HEADER = new byte[]{(byte) 0x01, (byte) 0x00, (byte) 0x66};
public GarminAgpsFile(final byte[] bytes) {
this.bytes = bytes;
}
public boolean isValid() {
if (!GBTarFile.isTarFile(tarBytes)) {
LOG.debug("Is not TAR file!");
public boolean isValidTar(final String[] constellations) {
if (!GBTarFile.isTarFile(bytes)) {
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);
final GBTarFile tarFile = new GBTarFile(bytes);
for (final String constellation : constellations) {
try {
final GarminAgpsDataType garminAgpsDataType = GarminAgpsDataType.valueOf(constellation);
if (!tarFile.containsFile(garminAgpsDataType.getFileName())) {
LOG.error("AGPS archive is missing requested file: {}", garminAgpsDataType.getFileName());
return false;
}
} catch (final IllegalArgumentException e) {
LOG.error("Device requested unsupported AGPS data type: {}", constellation);
return false;
}
}
@ -31,7 +46,27 @@ public class GarminAgpsFile {
return true;
}
public boolean isValidRxNetworks() {
if (!ArrayUtils.startsWith(bytes, GZ_HEADER)) {
return false;
}
try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(bytes))) {
final byte[] header = new byte[CPE_RXNETWORKS_HEADER.length];
int read = gzis.read(header);
if (read != CPE_RXNETWORKS_HEADER.length || !Arrays.equals(header, CPE_RXNETWORKS_HEADER)) {
LOG.error("Header in gz file is not agps rxnetworks: {}", GB.hexdump(header));
return false;
}
return true;
} catch (final IOException e) {
LOG.error("Failed to decompress file as gzip", e);
}
return false;
}
public byte[] getBytes() {
return tarBytes.clone();
return bytes.clone();
}
}

View File

@ -68,6 +68,7 @@ public class HttpHandler {
final String path = url.getPath();
final Map<String, String> query = HttpUtils.urlQueryParameters(url);
final Map<String, String> requestHeaders = headersToMap(rawRequest.getHeaderList());
if (path.startsWith("/weather/")) {
LOG.info("Got weather request for {}", path);
@ -80,12 +81,13 @@ public class HttpHandler {
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);
final byte[] agpsData = agpsHandler.handleAgpsRequest(urlString, path, query);
if (agpsData == null) {
return null;
}
LOG.debug("Successfully obtained AGPS data (length: {})", agpsData.length);
return createRawResponse(rawRequest, agpsData, "application/x-tar", agpsHandler.getOnDataSuccessfullySentListener());
final String contentType = requestHeaders.containsKey("accept") ? requestHeaders.get("accept") : "application/octet-stream";
return createRawResponse(rawRequest, agpsData, contentType, agpsHandler.getOnDataSuccessfullySentListener(urlString));
} else {
LOG.warn("Unhandled path {}", urlString);
return null;

View File

@ -26,8 +26,10 @@ import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Locale;
import java.util.zip.CRC32;
public class CheckSums {
@ -175,4 +177,17 @@ public class CheckSums {
md.update(data);
return md.digest();
}
@Nullable
public static String md5(final String str) {
final MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
} catch (final NoSuchAlgorithmException e) {
LOG.error("Failed to get md5 digest", e);
return null;
}
md.update(str.getBytes(StandardCharsets.UTF_8));
return GB.hexdump(md.digest()).toLowerCase(Locale.ROOT);
}
}

View File

@ -7,7 +7,7 @@ import org.slf4j.LoggerFactory;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
public class HttpUtils {
@ -22,7 +22,7 @@ public class HttpUtils {
if (StringUtils.isBlank(query)) {
return Collections.emptyMap();
}
final Map<String, String> queryParameters = new HashMap<>();
final Map<String, String> queryParameters = new LinkedHashMap<>();
final String[] pairs = query.split("&");
for (final String pair : pairs) {
final String[] parts = pair.split("=", 2);

View File

@ -163,14 +163,19 @@ public class Prefs {
* The preference is assumed to be a string, with each value separated by a comma.
* @param key the preference key
* @param defaultValue the default value to return if the preference value is unset
* @param separator the separator to use to split the string
* @return the saved preference value or the given defaultValue
*/
public List<String> getList(final String key, final List<String> defaultValue) {
public List<String> getList(final String key, final List<String> defaultValue, final String separatorRegex) {
final String stringValue = preferences.getString(key, null);
if (stringValue == null) {
return defaultValue;
}
return Arrays.asList(stringValue.split(","));
return Arrays.asList(stringValue.split(separatorRegex));
}
public List<String> getList(final String key, final List<String> defaultValue) {
return getList(key, defaultValue, ",");
}
public Date getTimePreference(final String key, final String defaultValue) {

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#7E7E7E"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.89,2 1.99,2H15v-8h5V8L14,2zM13,9V3.5L18.5,9H13zM17,21.66V16h5.66v2h-2.24l2.95,2.95l-1.41,1.41L19,19.41l0,2.24H17z" />
</vector>

View File

@ -2839,4 +2839,9 @@
<string name="pref_title_huawei_account">Huawei Account</string>
<string name="pref_summary_huawei_account">Huawei account used in pairing process. Setting it allows to pair without factory reset.</string>
<string name="watchface_resolution_doesnt_match">Watchface resolution doesnt match device screen. Watchface is %1$s device screen is %2$s</string>
<string name="garmin_agps_url_i">AGPS %1$d URL</string>
<string name="url">URL</string>
<string name="garmin_agps_local_file">Local file</string>
<string name="pref_garmin_agps_help">The list below contains all URLs requested by the watch for AGPS updates. You can select a file from the phone\'s storage that will be sent to the watch when it requests an update.</string>
<string name="copied_to_clipboard">Copied to clipboard</string>
</resources>

View File

@ -1,18 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<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" />-->
<Preference
android:key="pref_garmin_agps_help"
android:summary="@string/pref_garmin_agps_help"
app:iconSpaceReserved="false" />
</PreferenceCategory>
</androidx.preference.PreferenceScreen>
</androidx.preference.PreferenceScreen>