diff --git a/app/build.gradle b/app/build.gradle
index e8db9f9a71..3c20bd434b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -248,6 +248,13 @@ dependencies {
implementation 'com.android.volley:volley:1.2.1'
implementation 'org.msgpack:msgpack-core:0.9.9'
+ implementation 'com.github.mapsforge.mapsforge:mapsforge-core:0.21.0'
+ implementation 'com.github.mapsforge.mapsforge:mapsforge-map:0.21.0'
+ implementation 'com.github.mapsforge.mapsforge:mapsforge-map-reader:0.21.0'
+ implementation 'com.github.mapsforge.mapsforge:mapsforge-themes:0.21.0'
+ implementation 'com.github.mapsforge.mapsforge:mapsforge-map-android:0.21.0'
+ implementation 'com.caverock:androidsvg:1.4'
+
// Bouncy Castle is included directly in GB, to avoid pulling the entire dependency
// It's included in the org.bouncycastle.shaded package, to fix conflicts with roboelectric
//implementation 'org.bouncycastle:bcpkix-jdk18on:1.76'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7523d51543..9c281d6bb3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -173,6 +173,14 @@
android:name=".activities.DashboardPreferencesActivity"
android:label="@string/dashboard_settings"
android:parentActivityName=".activities.SettingsActivity" />
+
+
. */
package nodomain.freeyourgadget.gadgetbridge.activities;
+import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Canvas;
-import android.graphics.Paint;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@@ -27,6 +27,11 @@ import android.widget.ImageView;
import androidx.annotation.Nullable;
+import org.mapsforge.core.graphics.Paint;
+import org.mapsforge.core.model.LatLong;
+import org.mapsforge.map.android.graphics.AndroidGraphicFactory;
+import org.mapsforge.map.android.view.MapView;
+import org.mapsforge.map.layer.overlay.Polyline;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -38,9 +43,11 @@ import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
+import java.util.stream.Collectors;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsTrackActivity;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
@@ -48,21 +55,28 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDat
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParseException;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParser;
+import nodomain.freeyourgadget.gadgetbridge.util.maps.MapsManager;
import static android.graphics.Bitmap.createBitmap;
public class ActivitySummariesGpsFragment extends AbstractGBFragment {
private static final Logger LOG = LoggerFactory.getLogger(ActivitySummariesGpsFragment.class);
- private ImageView gpsView;
private final int CANVAS_SIZE = 360;
+ private MapView mapView;
private File inputFile;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_gps, container, false);
- gpsView = rootView.findViewById(R.id.activitygpsview);
+ mapView = rootView.findViewById(R.id.activitygpsview);
+ //mapView.getMapScaleBar().setVisible(true);
+ mapView.setBuiltInZoomControls(false);
+
+ MapsManager mapsManager = new MapsManager(requireContext());
+ mapsManager.loadMaps(mapView);
+
if (inputFile != null) {
processInBackgroundThread();
}
@@ -71,88 +85,83 @@ public class ActivitySummariesGpsFragment extends AbstractGBFragment {
public void set_data(File inputFile) {
this.inputFile = inputFile;
- if (gpsView != null) { //first fragment inflate is AFTER this is called
+ if (mapView != null) { //first fragment inflate is AFTER this is called
processInBackgroundThread();
}
}
private void processInBackgroundThread() {
- final Canvas canvas = createCanvas(gpsView);
new Thread(() -> {
- final List points = getActivityPoints(inputFile)
- .stream()
- .map(ActivityPoint::getLocation)
- .filter(Objects::nonNull)
- .collect(Collectors.toList());
+ final ArrayList points = new ArrayList<>();
+ if (inputFile.getName().endsWith(".gpx")) {
+ try (FileInputStream inputStream = new FileInputStream(inputFile)) {
+ final GpxParser gpxParser = new GpxParser(inputStream);
+ points.addAll(gpxParser.getGpxFile().getPoints());
+ } catch (final IOException e) {
+ LOG.error("Failed to open {}", inputFile, e);
+ return;
+ } catch (final GpxParseException e) {
+ LOG.error("Failed to parse gpx file", e);
+ return;
+ }
+ } else if (inputFile.getName().endsWith(".fit")) {
+ try {
+ FitFile fitFile = FitFile.parseIncoming(inputFile);
+ for (final RecordData record : fitFile.getRecords()) {
+ if (record instanceof FitRecord) {
+ final ActivityPoint activityPoint = ((FitRecord) record).toActivityPoint();
+ if (activityPoint.getLocation() != null) {
+ points.add(activityPoint.getLocation());
+ }
+ }
+ }
+ } catch (final IOException e) {
+ LOG.error("Failed to open {}", inputFile, e);
+ return;
+ } catch (final Exception e) {
+ LOG.error("Failed to parse fit file", e);
+ return;
+ }
+ } else {
+ LOG.warn("Unknown file type {}", inputFile.getName());
+ return;
+ }
if (!points.isEmpty()) {
- drawTrack(canvas, points);
+ drawTrack(mapView, points);
}
}).start();
}
- public static List getActivityPoints(final File trackFile) {
- final List points = new ArrayList<>();
- if (trackFile == null) {
- return points;
- }
- if (trackFile.getName().endsWith(".gpx")) {
- try (FileInputStream inputStream = new FileInputStream(trackFile)) {
- final GpxParser gpxParser = new GpxParser(inputStream);
- points.addAll(gpxParser.getGpxFile().getActivityPoints());
- } catch (final IOException e) {
- LOG.error("Failed to open {}", trackFile, e);
- } catch (final GpxParseException e) {
- LOG.error("Failed to parse gpx file", e);
- }
- } else if (trackFile.getName().endsWith(".fit")) {
- try {
- FitFile fitFile = FitFile.parseIncoming(trackFile);
- for (final RecordData record : fitFile.getRecords()) {
- if (record instanceof FitRecord) {
- points.add(((FitRecord) record).toActivityPoint());
- }
- }
- } catch (final IOException e) {
- LOG.error("Failed to open {}", trackFile, e);
- } catch (final Exception e) {
- LOG.error("Failed to parse fit file", e);
- }
- } else {
- LOG.warn("Unknown file type {}", trackFile.getName());
- }
-
- return points;
- }
-
- private void drawTrack(Canvas canvas, List extends GPSCoordinate> trackPoints) {
+ private void drawTrack(MapView mapView, final ArrayList extends GPSCoordinate> trackPoints) {
double maxLat = (Collections.max(trackPoints, new GPSCoordinate.compareLatitude())).getLatitude();
double minLat = (Collections.min(trackPoints, new GPSCoordinate.compareLatitude())).getLatitude();
double maxLon = (Collections.max(trackPoints, new GPSCoordinate.compareLongitude())).getLongitude();
double minLon = (Collections.min(trackPoints, new GPSCoordinate.compareLongitude())).getLongitude();
- double maxAlt = (Collections.max(trackPoints, new GPSCoordinate.compareElevation())).getAltitude();
- double minAlt = (Collections.min(trackPoints, new GPSCoordinate.compareElevation())).getAltitude();
- float scale_factor_w = (float) ((maxLon - minLon) / (maxLat - minLat));
- float scale_factor_h = (float) ((maxLat - minLat) / (maxLon - minLon));
+ List latlongs = trackPoints.stream()
+ .map(p -> new LatLong(p.getLatitude(), p.getLongitude()))
+ .collect(Collectors.toList());
- if (scale_factor_h > scale_factor_w) { //scaling to draw proportionally
- scale_factor_h = 1;
- } else {
- scale_factor_w = 1;
- }
+ Paint p = AndroidGraphicFactory.INSTANCE.createPaint();
+ p.setColor(R.color.hrv_status_char_line_color);
+ Polyline polyline = new Polyline(p, AndroidGraphicFactory.INSTANCE);
+ polyline.setPoints(latlongs);
+ mapView.addLayer(polyline);
+ mapView.setCenter(new LatLong(minLat + (maxLat - minLat) / 2, minLon + (maxLon - minLon) / 2));
+ mapView.setZoomLevel((byte) 12);
- Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
- paint.setStrokeWidth(1);
- paint.setColor(getResources().getColor(R.color.chart_activity_light));
-
- for (GPSCoordinate p : trackPoints) {
- float lat = (float) ((p.getLatitude() - minLat) / (maxLat - minLat));
- float lon = (float) ((p.getLongitude() - minLon) / (maxLon - minLon));
- float alt = (float) ((p.getAltitude() - minAlt) / (maxAlt - minAlt));
- paint.setStrokeWidth(1 + alt); //make thicker with higher altitude, we could do more here
- canvas.drawPoint(CANVAS_SIZE * lon * scale_factor_w, CANVAS_SIZE * lat * scale_factor_h, paint);
- }
+ mapView.setOnTouchListener((a, b) -> {
+ final Intent startIntent = new Intent(requireContext(), MapsTrackActivity.class);
+ startIntent.putParcelableArrayListExtra("points", trackPoints);
+ requireContext().startActivity(startIntent);
+ return true;
+ });
+ mapView.setOnClickListener(v -> {
+ final Intent startIntent = new Intent(requireContext(), MapsTrackActivity.class);
+ startIntent.putParcelableArrayListExtra("points", trackPoints);
+ requireContext().startActivity(startIntent);
+ });
}
private Canvas createCanvas(ImageView imageView) {
@@ -178,6 +187,4 @@ public class ActivitySummariesGpsFragment extends AbstractGBFragment {
protected CharSequence getTitle() {
return null;
}
-
}
-
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
index 4483dc1725..2e0d22fb71 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
@@ -65,6 +65,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter;
import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver;
import nodomain.freeyourgadget.gadgetbridge.model.Weather;
@@ -372,6 +373,15 @@ public class SettingsActivity extends AbstractSettingsActivityV2 {
});
}
+ pref = findPreference("pref_category_maps");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ Intent enableIntent = new Intent(requireContext(), MapsSettingsActivity.class);
+ startActivity(enableIntent);
+ return true;
+ });
+ }
+
pref = findPreference("pref_category_sleepasandroid");
if (pref != null) {
pref.setOnPreferenceClickListener(preference -> {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/maps/MapsSettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/maps/MapsSettingsActivity.java
new file mode 100644
index 0000000000..5c2ec45a6d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/maps/MapsSettingsActivity.java
@@ -0,0 +1,33 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities.maps;
+
+import androidx.preference.PreferenceFragmentCompat;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2;
+
+public class MapsSettingsActivity extends AbstractSettingsActivityV2 {
+ @Override
+ protected String fragmentTag() {
+ return MapsSettingsFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return new MapsSettingsFragment();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/maps/MapsSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/maps/MapsSettingsFragment.java
new file mode 100644
index 0000000000..f68367ee37
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/maps/MapsSettingsFragment.java
@@ -0,0 +1,78 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities.maps;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment;
+import nodomain.freeyourgadget.gadgetbridge.util.maps.MapsManager;
+
+public class MapsSettingsFragment extends AbstractPreferenceFragment {
+ static final String FRAGMENT_TAG = "MAP_SETTINGS_FRAGMENT";
+
+ @Override
+ public void onCreatePreferences(@Nullable final Bundle savedInstanceState, @Nullable final String rootKey) {
+ setPreferencesFromResource(R.xml.map_settings, rootKey);
+
+ final SharedPreferences prefs = getPreferenceManager().getSharedPreferences();
+ if (prefs == null) {
+ requireActivity().finish();
+ return;
+ }
+
+ final Preference prefDownload = Objects.requireNonNull(findPreference("maps_download"));
+ prefDownload.setOnPreferenceClickListener(preference -> {
+ startActivity(new Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("https://ftp-stud.hs-esslingen.de/pub/Mirrors/download.mapsforge.org/maps/v5/")
+ ));
+ return true;
+ });
+
+ final Preference prefFolder = Objects.requireNonNull(findPreference(MapsManager.PREF_MAPS_FOLDER));
+ final ActivityResultLauncher mapsFolderChooser = registerForActivityResult(
+ new ActivityResultContracts.OpenDocumentTree(),
+ localUri -> {
+ LOG.info("Maps folder: {}", localUri);
+ if (localUri != null) {
+ requireContext().getContentResolver().takePersistableUriPermission(localUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ prefs.edit()
+ .putString(MapsManager.PREF_MAPS_FOLDER, localUri.toString())
+ .apply();
+ prefFolder.setSummary(localUri.toString());
+ }
+ }
+ );
+ final String currentFolder = prefs.getString(MapsManager.PREF_MAPS_FOLDER, "");
+ prefFolder.setSummary(currentFolder);
+ prefFolder.setOnPreferenceClickListener(preference -> {
+ mapsFolderChooser.launch(null);
+ return true;
+ });
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/maps/MapsTrackActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/maps/MapsTrackActivity.java
new file mode 100644
index 0000000000..09536114b5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/maps/MapsTrackActivity.java
@@ -0,0 +1,92 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities.maps;
+
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.annotation.NonNull;
+
+import org.mapsforge.core.graphics.Paint;
+import org.mapsforge.core.model.LatLong;
+import org.mapsforge.map.android.graphics.AndroidGraphicFactory;
+import org.mapsforge.map.android.view.MapView;
+import org.mapsforge.map.layer.overlay.Polyline;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
+import nodomain.freeyourgadget.gadgetbridge.util.maps.MapsManager;
+
+public class MapsTrackActivity extends AbstractGBActivity {
+ private MapView mapView;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_maps_track);
+
+ mapView = findViewById(R.id.activitygpsview);
+ MapsManager mapsManager = new MapsManager(this);
+ mapsManager.loadMaps(mapView);
+
+ final ArrayList extends GPSCoordinate> trackPoints = getIntent().getParcelableArrayListExtra("points");
+
+ double maxLat = (Collections.max(trackPoints, new GPSCoordinate.compareLatitude())).getLatitude();
+ double minLat = (Collections.min(trackPoints, new GPSCoordinate.compareLatitude())).getLatitude();
+ double maxLon = (Collections.max(trackPoints, new GPSCoordinate.compareLongitude())).getLongitude();
+ double minLon = (Collections.min(trackPoints, new GPSCoordinate.compareLongitude())).getLongitude();
+ List latlongs = trackPoints.stream()
+ .map(p -> new LatLong(p.getLatitude(), p.getLongitude()))
+ .collect(Collectors.toList());
+
+ Paint p = AndroidGraphicFactory.INSTANCE.createPaint();
+ p.setColor(R.color.hrv_status_char_line_color);
+ Polyline polyline = new Polyline(p, AndroidGraphicFactory.INSTANCE);
+ polyline.setPoints(latlongs);
+ mapView.addLayer(polyline);
+
+ mapView.setCenter(new LatLong(minLat + (maxLat - minLat) / 2, minLon + (maxLon - minLon) / 2));
+ mapView.setZoomLevel((byte) 12);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull final Bundle state) {
+ super.onSaveInstanceState(state);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java
index 14744ab0b2..63a966a822 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java
@@ -17,6 +17,10 @@
package nodomain.freeyourgadget.gadgetbridge.model;
import android.location.Location;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
import androidx.annotation.NonNull;
@@ -24,7 +28,7 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
-public class GPSCoordinate {
+public class GPSCoordinate implements Parcelable {
private final double latitude;
private final double longitude;
private final double altitude;
@@ -50,6 +54,12 @@ public class GPSCoordinate {
this(longitude, latitude, UNKNOWN_ALTITUDE);
}
+ protected GPSCoordinate(Parcel in) {
+ latitude = in.readDouble();
+ longitude = in.readDouble();
+ altitude = in.readDouble();
+ }
+
public double getLatitude() {
return latitude;
}
@@ -156,6 +166,30 @@ public class GPSCoordinate {
return "lon: " + formatLocation(longitude) + ", lat: " + formatLocation(latitude) + ", alt: " + formatLocation(altitude) + "m";
}
+ @Override
+ public void writeToParcel(@NonNull final Parcel dest, final int flags) {
+ dest.writeDouble(latitude);
+ dest.writeDouble(longitude);
+ dest.writeDouble(altitude);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public GPSCoordinate createFromParcel(Parcel in) {
+ return new GPSCoordinate(in);
+ }
+
+ @Override
+ public GPSCoordinate[] newArray(int size) {
+ return new GPSCoordinate[size];
+ }
+ };
+
public static class compareLatitude implements Comparator {
@Override
public int compare(GPSCoordinate trkPt1, GPSCoordinate trkPt2) {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/maps/MapTheme.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/maps/MapTheme.java
new file mode 100644
index 0000000000..7e71f37954
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/maps/MapTheme.java
@@ -0,0 +1,50 @@
+package nodomain.freeyourgadget.gadgetbridge.util.maps;
+
+import org.mapsforge.map.rendertheme.XmlRenderTheme;
+import org.mapsforge.map.rendertheme.XmlRenderThemeMenuCallback;
+import org.mapsforge.map.rendertheme.XmlThemeResourceProvider;
+
+import java.io.InputStream;
+
+public enum MapTheme implements XmlRenderTheme {
+ DEFAULT("/assets/mapsforge/default.xml"),
+ OSMARENDER("/assets/mapsforge/osmarender.xml"),
+ ;
+
+ private XmlRenderThemeMenuCallback menuCallback;
+ private final String path;
+
+ MapTheme(String path) {
+ this.path = path;
+ }
+
+ @Override
+ public XmlRenderThemeMenuCallback getMenuCallback() {
+ return menuCallback;
+ }
+
+ @Override
+ public String getRelativePathPrefix() {
+ return "/assets/";
+ }
+
+ @Override
+ public InputStream getRenderThemeAsStream() {
+ return getClass().getResourceAsStream(this.path);
+ }
+
+ @Override
+ public XmlThemeResourceProvider getResourceProvider() {
+ return null;
+ }
+
+ @Override
+ public void setMenuCallback(final XmlRenderThemeMenuCallback menuCallback) {
+ this.menuCallback = menuCallback;
+ }
+
+ @Override
+ public void setResourceProvider(final XmlThemeResourceProvider resourceProvider) {
+
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/maps/MapsManager.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/maps/MapsManager.java
new file mode 100644
index 0000000000..158d721b74
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/maps/MapsManager.java
@@ -0,0 +1,109 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.maps;
+
+import android.content.Context;
+import android.net.Uri;
+import android.widget.Toast;
+
+import androidx.documentfile.provider.DocumentFile;
+
+import org.mapsforge.map.android.graphics.AndroidGraphicFactory;
+import org.mapsforge.map.android.util.AndroidUtil;
+import org.mapsforge.map.android.view.MapView;
+import org.mapsforge.map.datastore.MultiMapDataStore;
+import org.mapsforge.map.layer.cache.TileCache;
+import org.mapsforge.map.layer.renderer.TileRendererLayer;
+import org.mapsforge.map.reader.MapFile;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+
+public final class MapsManager {
+ private static final Logger LOG = LoggerFactory.getLogger(MapsManager.class);
+
+ public static final String PREF_MAPS_FOLDER = "maps_folder";
+
+ private final Context mContext;
+
+ public MapsManager(final Context context) {
+ this.mContext = context;
+ }
+
+ public void loadMaps(final MapView mapView) {
+ AndroidGraphicFactory.createInstance(GBApplication.app());
+ GBPrefs prefs = GBApplication.getPrefs();
+
+ String folderUri = prefs.getString(PREF_MAPS_FOLDER, "");
+ if (folderUri.isEmpty()) {
+ return;
+ }
+
+ final DocumentFile folder = DocumentFile.fromTreeUri(mContext, Uri.parse(folderUri));
+ if (folder == null || folder.listFiles().length == 0) {
+ return;
+ }
+
+ MultiMapDataStore multiMapDataStore = new MultiMapDataStore(MultiMapDataStore.DataPolicy.RETURN_ALL);
+
+ final DocumentFile[] documentFiles = folder.listFiles();
+
+ LOG.debug("Got {} map files", documentFiles.length);
+
+ for (final DocumentFile documentFile : documentFiles) {
+ if (!documentFile.canRead()) {
+ continue;
+ }
+ assert documentFile.getName() != null;
+ if (!documentFile.getName().endsWith(".map")) {
+ continue;
+ }
+
+ LOG.debug("Loading {}", documentFile.getName());
+
+ try {
+ FileInputStream inputStream = (FileInputStream) mContext.getContentResolver().openInputStream(documentFile.getUri());
+ assert inputStream != null;
+ MapFile mapFile = new MapFile(inputStream, 0, null);
+ multiMapDataStore.addMapDataStore(mapFile, true, true);
+ } catch (FileNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ TileCache tileCache = AndroidUtil.createTileCache(mContext, "mapcache",
+ mapView.getModel().displayModel.getTileSize(), 1f,
+ mapView.getModel().frameBufferModel.getOverdrawFactor());
+
+ TileRendererLayer tileRendererLayer = new TileRendererLayer(tileCache, multiMapDataStore,
+ mapView.getModel().mapViewPosition, AndroidGraphicFactory.INSTANCE);
+ tileRendererLayer.setXmlRenderTheme(MapTheme.DEFAULT);
+
+ mapView.getLayerManager().getLayers().add(tileRendererLayer);
+
+ // TODO on exit mapView.destroyAll();
+ //AndroidGraphicFactory.clearResourceMemoryCache();
+ }
+}
diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml
new file mode 100644
index 0000000000..e539b62dd8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_download.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_maps_track.xml b/app/src/main/res/layout/activity_maps_track.xml
new file mode 100644
index 0000000000..dc63e48701
--- /dev/null
+++ b/app/src/main/res/layout/activity_maps_track.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_gps.xml b/app/src/main/res/layout/fragment_gps.xml
index 40109eede0..deb8a46d72 100644
--- a/app/src/main/res/layout/fragment_gps.xml
+++ b/app/src/main/res/layout/fragment_gps.xml
@@ -18,11 +18,33 @@
android:text="@string/gps_track"
android:textSize="16sp" />
-
+ android:layout_marginBottom="20dp"
+ android:orientation="vertical">
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index af29aab1bd..c7aa1aedad 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -3786,7 +3786,6 @@
Activate airplane mode?
airplane mode
received unhandled response code 0x%x for operation 0x%x
-
Automatic stress test
Enable or disable automatic stress test
No initial data - calibrate first
@@ -3801,10 +3800,12 @@
Finish
Calibrate again
Adjust score
-
Missed call
Incoming call
Unknown caller. ID is suppressed
-
Voice Boost
+ Maps
+ Maps settings
+ Download Maps
+ Click here to download map files. They should be saved to the folder selected in the preference below.
diff --git a/app/src/main/res/xml/map_settings.xml b/app/src/main/res/xml/map_settings.xml
new file mode 100644
index 0000000000..20211e7069
--- /dev/null
+++ b/app/src/main/res/xml/map_settings.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index b5729f5f54..59ad341bce 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -326,6 +326,11 @@
android:summary="@string/pref_summary_opentracks_packagename"
android:title="@string/pref_title_opentracks_packagename" />
+
+