diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ebb75eabd6..360a96224c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1099,6 +1099,27 @@
android:launchMode="singleInstance"
android:exported="true" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
notifications.add(R.xml.devicesettings_send_app_notifications);
@@ -368,6 +372,12 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
return !getPrefs(device).getString(GarminPreferences.PREF_AGPS_KNOWN_URLS, "").isEmpty();
}
+ public boolean supportsSendWaypoint(@NonNull final GBDevice device){
+ return supports(device, GarminCapability.WAYPOINT_TRANSFER)
+ || supports(device, GarminCapability.EXPLORE_SYNC)
+ || GBApplication.getDevicePrefs(device).installUnsupportedFiles();
+ }
+
public boolean supports(final GBDevice device, final GarminCapability capability) {
return getPrefs(device).getStringSet(GarminPreferences.PREF_GARMIN_CAPABILITIES, Collections.emptySet())
.contains(capability.name());
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java
index 1e5a885794..2d0b77a906 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java
@@ -12,6 +12,7 @@ public class GarminPreferences {
public static final String PREF_GARMIN_AGPS_FOLDER = "garmin_agps_folder";
public static final String PREF_GARMIN_AGPS_FILENAME = "garmin_agps_filename_%s";
public static final String PREF_GARMIN_REALTIME_SETTINGS = "garmin_realtime_settings";
+ public static final String PREF_GARMIN_SEND_WAYPOINT = "garmin_send_waypoint";
public static String agpsStatus(final String url) {
return String.format(GarminPreferences.PREF_GARMIN_AGPS_STATUS, CheckSums.md5(url));
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java
index d934be4296..c38830ea2c 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java
@@ -59,6 +59,7 @@ 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.devices.garmin.actions.GarminSendWaypointActivity;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType;
@@ -278,6 +279,12 @@ public class GarminSettingsCustomizer implements DeviceSpecificSettingsCustomize
if (prefSleepSend != null) {
prefSleepSend.setOnPreferenceClickListener(dummy -> sendSleep(handler));
}
+
+ final Preference prefSendWaypoint = handler.findPreference(GarminPreferences.PREF_GARMIN_SEND_WAYPOINT);
+ if (prefSendWaypoint != null) {
+ prefSendWaypoint.setOnPreferenceClickListener(dummy ->
+ GarminSendWaypointActivity.Companion.handlePreferenceClick(handler));
+ }
}
private boolean sendSleep(DeviceSpecificSettingsHandler handler) {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/actions/GarminSendWaypointActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/actions/GarminSendWaypointActivity.kt
new file mode 100644
index 0000000000..bb41535ea1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/actions/GarminSendWaypointActivity.kt
@@ -0,0 +1,378 @@
+/* Copyright (C) 2026 Thomas Kuehne
+
+ 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.devices.garmin.actions
+
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_OPTIONS
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Intent
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.View
+import android.widget.TextView
+import android.widget.Toast
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.R
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler
+import nodomain.freeyourgadget.gadgetbridge.activities.install.FileInstallerActivity
+import nodomain.freeyourgadget.gadgetbridge.databinding.ActivitySendWaypointBinding
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.DistanceUnit
+import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport.BUNDLE_EXTRA_INSTALL_BYTES
+import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport.BUNDLE_EXTRA_INSTALL_TASK_NAME
+import nodomain.freeyourgadget.gadgetbridge.util.GB
+import nodomain.freeyourgadget.gadgetbridge.util.WaypointHelper
+import org.slf4j.LoggerFactory
+
+class GarminSendWaypointActivity : AbstractGBActivity() {
+ private var device: GBDevice? = null
+ private lateinit var binding: ActivitySendWaypointBinding
+
+ private var elevationInFeet = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivitySendWaypointBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ val distanceUnit = GBApplication.getPrefs().distanceUnit
+ val uom: String
+ if (distanceUnit == DistanceUnit.IMPERIAL) {
+ uom = getString(R.string.activity_send_waypoint_feet)
+ elevationInFeet = true
+ } else {
+ uom = getString(R.string.activity_send_waypoint_meter)
+ elevationInFeet = false
+ }
+ binding.waypointElevationLabel.text =
+ getString(R.string.activity_send_waypoint_elevation_label, uom)
+
+ val copyListener = ClickToCopy()
+ binding.waypointInfo0.setOnClickListener(copyListener)
+ binding.waypointInfo1.setOnClickListener(copyListener)
+ binding.waypointInfo2.setOnClickListener(copyListener)
+ binding.waypointInfo3.setOnClickListener(copyListener)
+
+ device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE)
+
+ if (savedInstanceState != null) {
+ binding.waypointName.setText(savedInstanceState.getString(ITEM_NAME, ""))
+ binding.waypointLatitude.setText(savedInstanceState.getString(ITEM_LATITUDE, ""))
+ binding.waypointLongitude.setText(savedInstanceState.getString(ITEM_LONGITUDE, ""))
+ binding.waypointElevation.setText(savedInstanceState.getString(ITEM_ELEVATION, ""))
+ }
+
+ val noLocationShared = intent.getBooleanExtra(EXTRA_NO_LOCATION_SHARED, false)
+ if (!noLocationShared && binding.waypointLatitude.length() < 1) {
+ val waypointHelper = WaypointHelper.fromIntent(intent)
+ if (waypointHelper != null) {
+ binding.waypointName.setText(waypointHelper.name)
+ if (waypointHelper.latitude != null) {
+ binding.waypointLatitude.setText(waypointHelper.latitude.toString())
+ }
+ if (waypointHelper.longitude != null) {
+ binding.waypointLongitude.setText(waypointHelper.longitude.toString())
+ }
+ if (waypointHelper.elevation != null) {
+ var elevation = waypointHelper.elevation
+ if (elevationInFeet) {
+ elevation = DistanceUnit.meterToFeet(elevation)
+ }
+ binding.waypointElevation.setText(elevation.toString())
+ }
+ }
+ }
+
+ val watcher = WaypointWatcher()
+ binding.waypointName.addTextChangedListener(watcher)
+ binding.waypointLatitude.addTextChangedListener(watcher)
+ binding.waypointLongitude.addTextChangedListener(watcher)
+ binding.waypointElevation.addTextChangedListener(watcher)
+ watcher.afterTextChanged(null)
+
+ binding.waypointSend.setOnClickListener(SendListener())
+ binding.waypointPaste.setOnClickListener(ClickToPaste())
+
+ if (!noLocationShared && binding.waypointLatitude.length() < 1) {
+ noLocationFound()
+ }
+ }
+
+ fun noLocationFound() {
+ MaterialAlertDialogBuilder(this)
+ .setIcon(R.drawable.ic_not_listed_location)
+ .setTitle(R.string.activity_send_waypoint_no_location)
+ .setMessage(R.string.activity_send_waypoint_no_location_details)
+ .setNeutralButton(android.R.string.ok, null)
+ .show()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putString(ITEM_NAME, binding.waypointName.text.toString())
+ outState.putString(ITEM_LATITUDE, binding.waypointLatitude.text.toString())
+ outState.putString(ITEM_LONGITUDE, binding.waypointLongitude.text.toString())
+ outState.putString(ITEM_ELEVATION, binding.waypointElevation.text.toString())
+ }
+
+
+ inner class ClickToPaste : View.OnClickListener {
+ override fun onClick(view: View?) {
+ val clipboard = view?.context?.getSystemService(
+ CLIPBOARD_SERVICE
+ ) as ClipboardManager?
+
+ val clipData = clipboard?.primaryClip
+ if (clipData != null) {
+ var index = 0
+ while (index < clipData.itemCount) {
+ val item = clipData.getItemAt(index)
+ val text = item.coerceToText(view.context)
+ if (text.length > 0) {
+ val waypointHelper = WaypointHelper.fromText(text)
+ if (waypointHelper != null) {
+ binding.waypointName.setText(waypointHelper.name)
+ if (waypointHelper.latitude != null) {
+ binding.waypointLatitude.setText(waypointHelper.latitude.toString())
+ }
+ if (waypointHelper.longitude != null) {
+ binding.waypointLongitude.setText(waypointHelper.longitude.toString())
+ }
+ var elevation = waypointHelper.elevation
+ if (elevation != null) {
+ if (elevationInFeet) {
+ elevation = DistanceUnit.meterToFeet(elevation)
+ }
+ binding.waypointElevation.setText(elevation.toString())
+ }
+ return
+ }
+ }
+ index++
+ }
+ }
+ noLocationFound()
+ }
+
+ }
+
+ inner class ClickToCopy : View.OnClickListener {
+ override fun onClick(view: View?) {
+ val textView = view as TextView?
+ if (textView != null) {
+ val text = textView.text
+ val clipboard = view.context.getSystemService(
+ CLIPBOARD_SERVICE
+ ) as ClipboardManager?
+ val clip = ClipData.newPlainText(binding.waypointName.text, text)
+ clipboard?.setPrimaryClip(clip)
+ }
+ }
+ }
+
+ inner class SendListener : View.OnClickListener {
+ override fun onClick(view: View?) {
+ if (binding.waypointName.error != null
+ || binding.waypointLatitude.error != null
+ || binding.waypointLongitude.error != null
+ || binding.waypointElevation.error != null
+ || binding.waypointSend.error != null
+ ) {
+ return
+ }
+ val label = binding.waypointName.text.toString()
+ val latitude = binding.waypointLatitude.text.toString().toDoubleOrNull()
+ val longitude = binding.waypointLongitude.text.toString().toDoubleOrNull()
+ var elevation = binding.waypointElevation.text.toString().toDoubleOrNull()
+
+ if (elevationInFeet && elevation != null) {
+ elevation = DistanceUnit.feetToMeter(elevation)
+ }
+
+ if (!label.isEmpty() && latitude?.isFinite() == true && longitude?.isFinite() == true) {
+ val waypoint = WaypointHelper(label, latitude, longitude, elevation)
+ val fitFile = WaypointHelper.generateFitLocationFile(waypoint)
+
+ val options = Bundle()
+ options.putByteArray(BUNDLE_EXTRA_INSTALL_BYTES, fitFile.outgoingMessage)
+ options.putString(BUNDLE_EXTRA_INSTALL_TASK_NAME, "send waypoint")
+
+ if (device == null || !device!!.state.equalsOrHigherThan(GBDevice.State.INITIALIZED)) {
+ val intent = Intent(
+ this@GarminSendWaypointActivity, FileInstallerActivity::class.java
+ )
+ intent.data = waypoint.toUri()
+ intent.putExtra(EXTRA_OPTIONS, options)
+ LOG.debug("startActivity {}", intent.component)
+ this@GarminSendWaypointActivity.startActivity(intent)
+ } else {
+ LOG.debug("send waypoint to device")
+ GBApplication.deviceService(device).onInstallApp(waypoint.toUri(), options)
+ }
+ }
+ }
+ }
+
+ inner class WaypointWatcher : TextWatcher {
+ override fun afterTextChanged(s: Editable?) {
+ // name
+ if (binding.waypointName.length() < 1) {
+ binding.waypointName.error = getString(R.string.activity_send_waypoint_name_missing)
+ } else {
+ val name = binding.waypointName.text.toString()
+ val buffer = Charsets.UTF_8.encode(name)
+ val overflow = buffer.limit() - 32
+ if (overflow > 0) {
+ binding.waypointName.error = resources.getQuantityString(
+ R.plurals.activity_send_waypoint_name_length, overflow, overflow
+ )
+ } else {
+ binding.waypointName.error = null
+ }
+ }
+
+ // latitude
+ if (binding.waypointLatitude.length() < 1) {
+ binding.waypointLatitude.error =
+ getString(R.string.activity_send_waypoint_latitude_missing)
+ } else {
+ val raw = binding.waypointLatitude.text.toString()
+ val actual = raw.toDoubleOrNull()
+ if (actual == null || !actual.isFinite()) {
+ binding.waypointLatitude.error =
+ getString(R.string.activity_send_waypoint_latitude_number)
+ } else if (actual < -90.0 || actual > 90.0) {
+ binding.waypointLatitude.error =
+ getString(R.string.activity_send_waypoint_latitude_range)
+ } else {
+ binding.waypointLatitude.error = null
+ }
+ }
+
+ // longitude
+ if (binding.waypointLongitude.length() < 1) {
+ binding.waypointLongitude.error =
+ getString(R.string.activity_send_waypoint_longitude_missing)
+ } else {
+ val raw = binding.waypointLongitude.text.toString()
+ val actual = raw.toDoubleOrNull()
+ if (actual == null || !actual.isFinite()) {
+ binding.waypointLongitude.error =
+ getString(R.string.activity_send_waypoint_longitude_number)
+ } else if (actual < -180.0 || actual > 180.0) {
+ binding.waypointLongitude.error =
+ getString(R.string.activity_send_waypoint_longitude_range)
+ } else {
+ binding.waypointLongitude.error = null
+ }
+ }
+
+ // elevation
+ if (binding.waypointElevation.length() < 1) {
+ binding.waypointElevation.error = null
+ } else {
+ val raw = binding.waypointElevation.text.toString()
+ var actual = raw.toDoubleOrNull()
+ if (actual == null || !actual.isFinite()) {
+ binding.waypointElevation.error =
+ getString(R.string.activity_send_waypoint_elevation_number)
+ } else {
+ if (elevationInFeet) {
+ actual = DistanceUnit.feetToMeter(actual)
+ }
+
+ if (actual < -500.0 || actual > 11827.0) {
+ val stringRes = if (elevationInFeet) {
+ R.string.activity_send_waypoint_elevation_range_imperial
+ } else {
+ R.string.activity_send_waypoint_elevation_range_metric
+ }
+ binding.waypointElevation.error = getString(stringRes)
+ } else {
+ binding.waypointElevation.error = null
+ }
+ }
+ }
+
+ updateWpSend()
+ }
+
+ private fun updateWpSend() {
+ val latitude = binding.waypointLatitude.text.toString().toDoubleOrNull()
+ val longitude = binding.waypointLongitude.text.toString().toDoubleOrNull()
+ val formated = WaypointHelper.formatPosition(latitude, longitude)
+ if (formated != null) {
+ binding.waypointInfo0.text = formated[0]
+ binding.waypointInfo1.text = formated[1]
+ binding.waypointInfo2.text = formated[2]
+ binding.waypointInfo3.text = formated[3]
+ } else {
+ binding.waypointInfo0.text = null
+ binding.waypointInfo1.text = null
+ binding.waypointInfo2.text = null
+ binding.waypointInfo3.text = null
+ }
+
+ binding.waypointSend.isEnabled =
+ (binding.waypointName.error == null) && (binding.waypointLatitude.error == null) && (binding.waypointLongitude.error == null) && (binding.waypointSend.error == null)
+ }
+
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
+ }
+
+ override fun onTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
+ }
+ }
+
+ companion object {
+ private val LOG = LoggerFactory.getLogger(GarminSendWaypointActivity::class.java)
+
+ private const val ITEM_NAME = "waypointName"
+ private const val ITEM_LATITUDE = "waypointLatitude"
+ private const val ITEM_LONGITUDE = "waypointLongitude"
+ private const val ITEM_ELEVATION = "waypointElevation"
+
+ const val EXTRA_NO_LOCATION_SHARED: String = "extra_no_location_shared"
+
+ fun handlePreferenceClick(
+ handler: DeviceSpecificSettingsHandler
+ ): Boolean {
+ val device = handler.getDevice()
+ if(!device.state.equalsOrHigherThan(GBDevice.State.INITIALIZED)){
+ GB.toast(
+ handler.getContext(),
+ R.string.device_not_connected,
+ Toast.LENGTH_LONG,
+ GB.ERROR
+ )
+ return false
+ }
+
+ val intent = Intent(handler.context, GarminSendWaypointActivity::class.java)
+ intent.putExtra(GBDevice.EXTRA_DEVICE, handler.getDevice())
+ intent.putExtra(EXTRA_NO_LOCATION_SHARED, true)
+ handler.getContext().startActivity(intent)
+ return true
+ }
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DistanceUnit.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DistanceUnit.java
index cca7aa07d0..0feba719b5 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DistanceUnit.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DistanceUnit.java
@@ -2,5 +2,15 @@ package nodomain.freeyourgadget.gadgetbridge.model;
public enum DistanceUnit {
METRIC,
- IMPERIAL,
+ IMPERIAL;
+
+ private static final double METER_TO_FEET = 3.28084;
+
+ public static double meterToFeet(double meter){
+ return meter * METER_TO_FEET;
+ }
+
+ public static double feetToMeter(double feet){
+ return feet / METER_TO_FEET;
+ }
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WaypointHelper.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WaypointHelper.kt
new file mode 100644
index 0000000000..7158f26f9d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WaypointHelper.kt
@@ -0,0 +1,439 @@
+/* Copyright (C) 2026 Thomas Kuehne
+
+ 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
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Parcelable
+import androidx.core.net.toUri
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileCreator
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileId
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLocation
+import java.util.Locale
+import kotlin.math.abs
+
+@Parcelize
+@Serializable
+data class WaypointHelper(
+ val name: String?,
+ val latitude: Double?,
+ val longitude: Double?,
+ val elevation: Double?
+) : Parcelable {
+ fun toUri(): Uri {
+ return toString().toUri()
+ }
+
+ override fun toString(): String {
+ val n = Uri.encode(name?.trim())
+ val lat = latitude ?: 0.0
+ val long = longitude ?: 0.0
+ val ele = elevation
+
+ val format = if (ele != null) {
+ if (n != null && n.isNotEmpty()) {
+ $$"geo:%1$.6f,%2$.6f,%3$.6f?q=%1$.6f,%2$.6f(%4$s)"
+ } else {
+ "geo:%1$.6f,%2$.6f,%3$.6f?q=%1$.6f,%2$.6f"
+ }
+ } else if (lat != 0.0 || long != 0.0) {
+ if (n != null && n.isNotEmpty()) {
+ $$"geo:%1$.6f,%2$.6f?q=%1$.6f,%2$.6f(%4$s)"
+ } else {
+ "geo:%1$.6f,%2$.6f?q=%1$.6f,%2$.6f"
+ }
+ } else {
+ $$"geo:0,0?q=%4$s"
+ }
+
+ val root = String.format(Locale.ROOT, format, lat, long, ele, n)
+
+ return root
+ }
+
+ companion object {
+ // can't use named regex groups due too low min API
+ private val REGEX_GEO by lazy { Regex("""/([0-9.+-]+),([0-9.+-]+)(?:,([0-9.+-]*))?(?:$|;|[(](.*)[)])""") }
+ private val REGEX_GEO_LABEL by lazy { Regex("""^([0-9.+-]+),([0-9.+-]+)(?:,([0-9.+-]*))?(?:[(](.*)[)])?$""") }
+ private val REGEX_FIND_GEO by lazy { Regex("""(?:^|\s)(geo:[0-9.+-]+,[0-9.+-]+\S*)""") }
+ private val REGEX_OSMAND by lazy { Regex("""osmand[.]net/map[/?]\S*#[0-9]*/([+-]?[0-9.]+)/([+-]?[0-9.]+)""") }
+ private val REGEX_GOOGLE by lazy { Regex("""(?:https?://)?(?:maps[.]google[.]\w+/|(?:www[.])?google[.]\w+/maps|goo.gl/maps)\S*(?:[/=]@|[&?](?:query=|query=loc:|center=|viewpoint=|q=loc:|q=))([0-9.+-]*)(?:,|%2C)([0-9.+-]*)""") }
+
+ private val REGEX_DMS by lazy { Regex("""^([NnSs+-])?\s*(\d+)[°ºd:_\s/-]\s*(\d+)[’'′:_\s/-]\s*(\d+(?:[.]\d+)?)[″"¨˝]?\s*([NnSs]?)[/\\|,;\s]*([EeWw+-])?\s*(\d+)[°ºd:_\s/-]\s*(\d+)[’'′:_\s/-]\s*(\d+(?:[.]\d+)?)[″"¨˝]?\s*([EeWw]?)""") }
+ private val REGEX_DM by lazy { Regex("""^([NnSs+-])?\s*(\d+)[°ºd:_\s/-]\s*(\d+(?:[.]\d+)?)[’'′:]?\s*([NnSs]?)[/\\|,;\s]*([EeWw+-])?\s*(\d+)[°ºd:_\s/-]\s*(\d+(?:[.]\d+)?)[’'′:]?\s*([EeWw]?)""") }
+ private val REGEX_DD by lazy { Regex("""^([NnSs+-])?\s*(\d+[.]\d+)[°º]?\s*([NnSs]?)[/\\|,;\s]*([EeWw+-])?\s*(\d+[.]\d+)[°º]?\s*([EeWw]?)""") }
+
+ fun fromIntent(intent: Intent?): WaypointHelper? {
+ if (intent == null) {
+ return null
+ }
+
+ var uri = intent.data
+ if (uri == null) {
+ uri = intent.getParcelableExtra(Intent.EXTRA_STREAM)
+ }
+
+ if (uri != null) {
+ val wh = fromGeoUri(uri)
+ if (wh != null) {
+ return wh
+ }
+ }
+
+ var text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)
+ if (text == null) {
+ text = intent.getStringExtra(Intent.EXTRA_TEXT)
+ }
+
+ return fromText(text)
+ }
+
+ // see WaypointHelperTest for example text formats
+ fun fromText(text: CharSequence?): WaypointHelper? {
+ if (text == null || text.length < 1) {
+ return null
+ }
+
+ val clean = text.trim().toString()
+ val geoMatchResult = REGEX_FIND_GEO.find(clean)
+ if(geoMatchResult != null) {
+ try {
+ val uriText = geoMatchResult.groupValues[1]
+ val pastedUri = uriText.toUri()
+ val wh = fromGeoUri(pastedUri)
+ if (wh != null) {
+ return wh
+ }
+ } catch (_: Exception) {
+ // ignore
+ }
+ }
+
+ var ns1 = ""
+ var ns2 = ""
+ var latDeg: Double? = null
+ var latMin: Double? = null
+ var latSec: Double? = null
+
+ var ew1 = ""
+ var ew2 = ""
+ var lonDeg: Double? = null
+ var lonMin: Double? = null
+ var lonSec: Double? = null
+
+ val dmsMatch = REGEX_DMS.matchAt(clean, 0)
+ if (dmsMatch != null) {
+ val groups = dmsMatch.groupValues
+ ns1 = groups[1]
+ latDeg = groups[2].toDoubleOrNull()
+ latMin = groups[3].toDoubleOrNull()
+ latSec = groups[4].toDoubleOrNull()
+ ns2 = groups[5]
+
+ ew1 = groups[6]
+ lonDeg = groups[7].toDoubleOrNull()
+ lonMin = groups[8].toDoubleOrNull()
+ lonSec = groups[9].toDoubleOrNull()
+ ew2 = groups[10]
+ } else {
+ val dmMatch = REGEX_DM.matchAt(clean, 0)
+ if (dmMatch != null) {
+ val groups = dmMatch.groupValues
+ ns1 = groups[1]
+ latDeg = groups[2].toDoubleOrNull()
+ latMin = groups[3].toDoubleOrNull()
+ ns2 = groups[4]
+
+ ew1 = groups[5]
+ lonDeg = groups[6].toDoubleOrNull()
+ lonMin = groups[7].toDoubleOrNull()
+ ew2 = groups[8]
+ } else {
+ val ddMatch = REGEX_DD.matchAt(clean, 0)
+ if (ddMatch != null) {
+ val groups = ddMatch.groupValues
+ ns1 = groups[1]
+ latDeg = groups[2].toDoubleOrNull()
+ ns2 = groups[3]
+
+ ew1 = groups[4]
+ lonDeg = groups[5].toDoubleOrNull()
+ ew2 = groups[6]
+ }
+ }
+ }
+
+ if (latDeg == null || lonDeg == null) {
+ val osmand = REGEX_OSMAND.find(clean)
+ if (osmand != null) {
+ latDeg = osmand.groupValues[1].toDoubleOrNull();
+ lonDeg = osmand.groupValues[2].toDoubleOrNull()
+ }
+ }
+
+ if (latDeg == null || lonDeg == null) {
+ val google = REGEX_GOOGLE.find(clean)
+ if (google != null) {
+ latDeg = google.groupValues[1].toDoubleOrNull();
+ lonDeg = google.groupValues[2].toDoubleOrNull()
+ }
+ }
+
+ if (latDeg == null || lonDeg == null) {
+ return null
+ }
+
+ var lat: Double = latDeg
+ if (latMin != null) {
+ lat += latMin / 60.0
+ }
+ if (latSec != null) {
+ lat += latSec / 3600.0
+ }
+ if (ns1.length == 1 && "Ss-".contains(ns1)) {
+ lat *= -1.0
+ } else if (ns2.length == 1 && "Ss-".contains(ns2)) {
+ lat *= -1.0
+ }
+
+ var lon: Double = lonDeg
+ if (lonMin != null) {
+ lon += lonMin / 60.0
+ }
+ if (lonSec != null) {
+ lon += lonSec / 3600.0
+ }
+ if (ew1.length == 1 && "Ww-".contains(ew1)) {
+ lon *= -1.0
+ } else if (ew2.length == 1 && "Ww-".contains(ew2)) {
+ lon *= -1.0
+ }
+
+ return WaypointHelper(null, lat, lon, null)
+ }
+
+ // see WaypointHelperTest for example Uris
+ fun fromGeoUri(uri: Uri?): WaypointHelper? {
+ if (uri == null) {
+ return null
+ }
+ if (!"geo".contentEquals(uri.scheme)) {
+ return null
+ }
+
+ // Uri class has some rather strange limitations ...
+ val cleanUri = ("http://dummy/" + uri.encodedSchemeSpecificPart).toUri()
+
+ var label: String? = null
+ var lat: Double? = null
+ var long: Double? = null
+ var ele: Double? = null
+
+ val path = cleanUri.path
+ if (!(path.isNullOrEmpty() || "/".contentEquals(path))) {
+ val geoMatch = REGEX_GEO.matchAt(path, 0)
+ if (geoMatch != null) {
+ val geoGroups = geoMatch.groupValues
+
+ lat = geoGroups[1].toDoubleOrNull()
+ long = geoGroups[2].toDoubleOrNull()
+ ele = geoGroups[3].toDoubleOrNull()
+ label = geoGroups[4]
+ }
+ }
+
+ if (label.isNullOrEmpty()) {
+ label = cleanUri.getQueryParameter("q")?.trim()
+ if (!label.isNullOrEmpty()) {
+ val labelMatch = REGEX_GEO_LABEL.matchEntire(label)
+
+ if (labelMatch != null) {
+ val labelGroups = labelMatch.groupValues
+
+ val latLabel = labelGroups[1].toDoubleOrNull()
+ val longLabel = labelGroups[2].toDoubleOrNull()
+ val eleLabel = labelGroups[3].toDoubleOrNull()
+ label = labelGroups[4].trim()
+
+ if ((latLabel == null || !latLabel.isFinite()) && (longLabel == null || !longLabel.isFinite())) {
+ // ignore
+ } else if (latLabel != 0.0 || longLabel != 0.0 || eleLabel != 0.0) {
+ lat = latLabel
+ long = longLabel
+ if (eleLabel != null) {
+ ele = eleLabel
+ }
+ }
+ }
+ }
+ }
+
+ if (label.isNullOrEmpty()) {
+ // fun label encoding
+ // geo:37.78918,-122.40335?z=14&(Wikimedia+Foundation)
+ for (name in cleanUri.queryParameterNames) {
+ if (name.startsWith('(') && name.endsWith(')')) {
+ label = Uri.decode(name.substring(1, name.length - 1))
+ break
+ }
+ }
+ }
+
+ if (lat?.isFinite() == true || long?.isFinite() == true || ele?.isFinite() == true || !label.isNullOrEmpty()) {
+ return WaypointHelper(label, lat, long, ele)
+ }
+ return null
+ }
+
+ fun formatPosition(latitude: Double?, longitude: Double?): Array? {
+ if (latitude == null || longitude == null) {
+ return null
+ } else if (!latitude.isFinite() || !longitude.isFinite()) {
+ return null
+ }
+
+ var x = abs(latitude)
+ var y = abs(longitude)
+ val lats = x
+ val longs = y
+
+ val latDeg = x.toInt()
+ val longDeg = y.toInt()
+ x = (x - latDeg) * 60.0
+ y = (y - longDeg) * 60.0
+
+ val latMins = x
+ val longMins = y
+ val latMin = x.toInt()
+ val longMin = y.toInt()
+ x = (x - latMin) * 60
+ y = (y - longMin) * 60
+
+ val latSec = x
+ val longSec = y
+
+ val ns = if (latitude < 0.0) {
+ 'S'
+ } else {
+ 'N'
+ }
+
+ val ew = if (longitude < 0.0) {
+ 'W'
+ } else {
+ 'E'
+ }
+
+ val dms = String.format(
+ Locale.ROOT,
+ "%d° %02d′ %04.1f″ %s, %d° %02d′ %04.1f″ %s",
+ latDeg,
+ latMin,
+ latSec,
+ ns,
+ longDeg,
+ longMin,
+ longSec,
+ ew
+ )
+
+ val dm = String.format(
+ Locale.ROOT,
+ "%d° %06.3f′ %s, %d° %06.3f′ %s",
+ latDeg,
+ latMins,
+ ns,
+ longDeg,
+ longMins,
+ ew
+ )
+
+ val dh = String.format(
+ Locale.ROOT, "%.6f°%s %.6f°%s", lats, ns, longs, ew
+ )
+
+ val dd = String.format(
+ Locale.ROOT, "%.6f; %.6f", latitude, longitude
+ )
+
+ val formated = arrayOf(
+ dms,
+ dm,
+ dh,
+ dd,
+ )
+
+ return formated
+ }
+
+ fun generateFitLocationFile(
+ waypoint: WaypointHelper
+ ): FitFile {
+ val timestamp = (System.currentTimeMillis() / 1000L)
+ val version = BuildConfig.VERSION_CODE
+ val waypoints = Array(1) { waypoint }
+ return generateFitLocationFile(waypoints, timestamp, version)
+ }
+
+ fun generateFitLocationFile(
+ waypoints: Array, timestamp: Long, softwareVersion: Int
+ ): FitFile {
+ val dataRecords: MutableList = ArrayList(2 + waypoints.size)
+
+ dataRecords.add(
+ FitFileId.Builder()
+ .setSerialNumber(1L)
+ .setTimeCreated(timestamp)
+ .setManufacturer(1) // Garmin
+ .setProduct(65534) // Connect
+ .setNumber(1)
+ .setType(FileType.FILETYPE.LOCATION)
+ .setProductName("Gadgetbridge")
+ .build()
+ )
+
+ dataRecords.add(
+ FitFileCreator.Builder()
+ .setSoftwareVersion(softwareVersion)
+ .build()
+ )
+
+ var messageIndex = 0
+ for (waypoint in waypoints) {
+ dataRecords.add(
+ FitLocation.Builder()
+ .setTimestamp(timestamp)
+ .setName(waypoint.name)
+ .setPositionLat(waypoint.latitude)
+ .setPositionLong(waypoint.longitude)
+ .setMessageIndex(messageIndex++)
+ .setAltitude(waypoint.elevation?.toFloat())
+ .build()
+ )
+ }
+
+ return FitFile(dataRecords)
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_add_location_alt_24px.xml b/app/src/main/res/drawable/ic_add_location_alt_24px.xml
new file mode 100644
index 0000000000..7fb6561a79
--- /dev/null
+++ b/app/src/main/res/drawable/ic_add_location_alt_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_altitude_24px.xml b/app/src/main/res/drawable/ic_altitude_24px.xml
new file mode 100644
index 0000000000..d40b89ed51
--- /dev/null
+++ b/app/src/main/res/drawable/ic_altitude_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_content_paste_go_24px.xml b/app/src/main/res/drawable/ic_content_paste_go_24px.xml
new file mode 100644
index 0000000000..92dd40b9f4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_content_paste_go_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_label_24px.xml b/app/src/main/res/drawable/ic_label_24px.xml
new file mode 100644
index 0000000000..c952255e2c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_label_24px.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_not_listed_location.xml b/app/src/main/res/drawable/ic_not_listed_location.xml
new file mode 100644
index 0000000000..58376def30
--- /dev/null
+++ b/app/src/main/res/drawable/ic_not_listed_location.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_send_waypoint.xml b/app/src/main/res/layout/activity_send_waypoint.xml
new file mode 100644
index 0000000000..300175c78d
--- /dev/null
+++ b/app/src/main/res/layout/activity_send_waypoint.xml
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6d6a4ed01b..e243d372ff 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4725,6 +4725,33 @@
Search for app updates
Automatically search for watch app and watchface updates when opening the app manager
SOS contact
+
+ Device
+ Elevation
+ Elevation (%s)
+ elevation must be empty or a number
+ elevation must be between -1\'600 ft and +38\'713 ft
+ elevation must be between -500m and +11\'800m
+ feet
+ latitude is empty
+ latitude must be a number
+ latitude must be between -90° and +90°
+ longitude is empty
+ longitude must be a number
+ longitude must be between -180° and +180°
+ meter
+ Name
+
+ - name is %d character too long
+ - name is %d characters too long
+
+ name is empty
+ Paste
+ Send Waypoint
+ No Location
+ No location information found in the received data.
+ Send
+
Health Connect
Enable Health Connect Export
diff --git a/app/src/main/res/xml/devicesettings_garmin_send_waypoint.xml b/app/src/main/res/xml/devicesettings_garmin_send_waypoint.xml
new file mode 100644
index 0000000000..084018add2
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_garmin_send_waypoint.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java
index 849750f3f6..e52e7ba37e 100644
--- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java
@@ -48,10 +48,6 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDef
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.exception.FitParseException;
-import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionLocationSymbol;
-import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileCreator;
-import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileId;
-import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLocation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FitDataMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FitDefinitionMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
@@ -408,65 +404,6 @@ public class GarminSupportTest extends TestBase {
getAllFitFieldValues(fitFile);
}
- /// encode->decode three locations / waypoints in a Lctns.fit compliant format
- @Test
- public void TestFitLocationEncoding() throws FitParseException, IOException {
- final List records = new ArrayList<>();
-
- FitFileId.Builder fileId = new FitFileId.Builder();
- fileId.setType(FileType.FILETYPE.LOCATION);
- fileId.setManufacturer(5759);
- fileId.setProduct(65534);
- fileId.setTimeCreated(null);
- fileId.setNumber(65533);
- fileId.setProductName("Gadgetbridge");
- records.add(fileId.build());
-
- FitFileCreator.Builder fileCreator = new FitFileCreator.Builder();
- fileCreator.setSoftwareVersion(2318);
- fileCreator.setHardwareVersion(254);
- records.add(fileCreator.build());
-
- FitLocation.Builder bregenz = new FitLocation.Builder();
- bregenz.setName("Bregenz");
- bregenz.setPositionLat(47.505);
- bregenz.setPositionLong(9.740);
- bregenz.setSymbol(FieldDefinitionLocationSymbol.LocationSymbol.Airport);
- bregenz.setAltitude(-495.0f);
- bregenz.setMessageIndex(0);
- records.add(bregenz.build());
-
- FitLocation.Builder friedrichshafen = new FitLocation.Builder();
- friedrichshafen.setName("腓特烈港");
- friedrichshafen.setPositionLat(47.656);
- friedrichshafen.setPositionLong(9.473);
- friedrichshafen.setSymbol(FieldDefinitionLocationSymbol.LocationSymbol.Anchor);
- friedrichshafen.setAltitude(0.0f);
- friedrichshafen.setMessageIndex(1);
- records.add(friedrichshafen.build());
-
- FitLocation.Builder kreuzlingen = new FitLocation.Builder();
- kreuzlingen.setName("קרויצלינגן");
- kreuzlingen.setPositionLat(47.633);
- kreuzlingen.setPositionLong(9.167);
- kreuzlingen.setSymbol(FieldDefinitionLocationSymbol.LocationSymbol.Water_Source);
- kreuzlingen.setAltitude(8850.0f);
- kreuzlingen.setMessageIndex(2);
- records.add(kreuzlingen.build());
-
- FitFile fitEncoded = new FitFile(records);
- byte[] fit = fitEncoded.getOutgoingMessage();
-
- String expectedText = readTextResource("/TestFitLocationEncoding.txt");
- FitFile fitDecoded = FitFile.parseIncoming(fit);
- String actual = fitDecoded.toString().replace("}, Fit", "},\nFit").replace("}, RecordData{", "},\nRecordData{");
- Assert.assertEquals(expectedText, actual);
- getAllFitFieldValues(fitDecoded);
-
- byte[] expectedFit = readBinaryResource("/TestFitLocationEncoding.fit");
- Assert.assertArrayEquals(expectedFit, fit);
- }
-
// try to retrieve the value of each message's fields
private static void getAllFitFieldValues(FitFile fitFile) {
List records = fitFile.getRecords();
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/WaypointHelperTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/WaypointHelperTest.java
new file mode 100644
index 0000000000..ac31470762
--- /dev/null
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/WaypointHelperTest.java
@@ -0,0 +1,323 @@
+/* Copyright (C) 2026 Thomas Kuehne
+
+ 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;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupportTest.readBinaryResource;
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupportTest.readTextResource;
+
+import android.net.Uri;
+
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.TimeZone;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
+import nodomain.freeyourgadget.gadgetbridge.test.TestBase;
+
+public class WaypointHelperTest extends TestBase {
+ private static final WaypointHelper ACONCAGUA = new WaypointHelper(
+ "Aconcagua",
+ -(32.0 + 39.0 / 60.0 + 11.0 / 3600.0),
+ -(70.0 + 00.0 / 60.0 + 42.0 / 3600.0),
+ 6967.15
+ );
+
+ private static final WaypointHelper DEAD_SEA = new WaypointHelper(
+ "ים המלח",
+ 31.0 + 30.0 / 60.0,
+ 35.0 + 30.0 / 60.0,
+ -439.78
+ );
+ private static final WaypointHelper EVEREST = new WaypointHelper(
+ "सगरमाथा",
+ 27.0 + 59.0 / 60.0 + 18.0 / 3600.0,
+ 86.0 + 55.0 / 60.0 + 31.0 / 3600.0,
+ 8848.86
+ );
+ private static final WaypointHelper SANTA_CLAUS = new WaypointHelper(
+ "Wihnächtsmann",
+ 89.0 + 59.0 / 60.0 + 59.0 / 3600.0,
+ -(179.0 + 59.0 / 60.0 + 59.0 / 3600.0),
+ null
+ );
+
+ private static final WaypointHelper ROLAS = new WaypointHelper(
+ "Ilhéu das Rolas",
+ -(0.0 + 0.0 / 60.0 + 9.8 / 3600.0),
+ (6.0 + 31.0 / 60.0 + 0.2 / 3600.0),
+ 0.0
+ );
+
+ @BeforeClass
+ public static void forceUtc() {
+ // FIXME this is hacky, but we need the timestamps to match in the toString comparisons below
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
+ }
+
+ private static void ToStringRoundTrip(WaypointHelper input, String expected) {
+ String actual = input.toString();
+ Assert.assertEquals(expected, actual);
+ WaypointHelper reprocessed = WaypointHelper.Companion.fromText(actual);
+ Assert.assertNotNull(reprocessed);
+ Assert.assertEquals(expected, reprocessed.toString());
+ }
+
+ @Test
+ public void TestFromGeoUri() {
+ String[][] vectors = {
+ // https://datatracker.ietf.org/doc/html/rfc5870
+ {"geo:13.4125,103.8667", "geo:13.412500,103.866700?q=13.412500,103.866700"},
+ {"geo:48.2010,16.3695,-18.3", "geo:48.201000,16.369500,-18.300000?q=48.201000,16.369500"},
+ {"geo:-48.198634,16.371648;crs=wgs84;u=40", "geo:-48.198634,16.371648?q=-48.198634,16.371648"},
+ {"geo:90,-22.43;crs=WGS84", "geo:90.000000,-22.430000?q=90.000000,-22.430000"},
+ {"geo:90,46", "geo:90.000000,46.000000?q=90.000000,46.000000"},
+ {"geo:22.300,-118.44", "geo:22.300000,-118.440000?q=22.300000,-118.440000"},
+ {"geo:22.3,-118.4400", "geo:22.300000,-118.440000?q=22.300000,-118.440000"},
+ {"geo:66,30;u=6.500;FOo=this%2dthat", "geo:66.000000,30.000000?q=66.000000,30.000000"},
+ // https://developer.android.com/guide/components/intents-common
+ {"geo:47.6,-122.3", "geo:47.600000,-122.300000?q=47.600000,-122.300000"},
+ {"geo:47.6,-122.3?z=11", "geo:47.600000,-122.300000?q=47.600000,-122.300000"},
+ {"geo:0,0?q=34.99,-106.61(Treasure)", "geo:34.990000,-106.610000?q=34.990000,-106.610000(Treasure)"},
+ {"geo:0,0?q=1600+Amphitheatre+Parkway%2C+CA", "geo:0,0?q=1600%20Amphitheatre%20Parkway%2C%20CA"},
+ // https://developer.android.com/guide/components/google-maps-intents
+ {"geo:0,0?q=restaurants", "geo:0,0?q=restaurants"},
+ {"geo:37.7749,-122.4194?z=10&q=restaurants", "geo:37.774900,-122.419400?q=37.774900,-122.419400(restaurants)"},
+ // https://en.wikipedia.org/wiki/Geo_URI_scheme
+ {"geo:37.78918,-122.40335?z=14&(Wikimedia+Foundation)", "geo:37.789180,-122.403350?q=37.789180,-122.403350(Wikimedia%2BFoundation)"},
+ {"geo:37.78918,-122.40335(Wikimedia+Foundation)", "geo:37.789180,-122.403350?q=37.789180,-122.403350(Wikimedia%2BFoundation)"},
+ {"geo:0,0?q=37.78918,-122.40335(Wikimedia+Foundation)", "geo:37.789180,-122.403350?q=37.789180,-122.403350(Wikimedia%20Foundation)"},
+ {"geo:0,0?q=37.78918,-122.40335", "geo:37.789180,-122.403350?q=37.789180,-122.403350"},
+ {"geo:37.78918,-122.40335", "geo:37.789180,-122.403350?q=37.789180,-122.403350"}
+ };
+
+ for (String[] vector : vectors) {
+ final String input = vector[0];
+ final String expected = vector[1];
+ final Uri uri = Uri.parse(input);
+
+ final WaypointHelper helper = WaypointHelper.Companion.fromGeoUri(uri);
+ Assert.assertNotNull(input, helper);
+
+ final String actual = helper.toString();
+ Assert.assertEquals(expected, actual);
+ }
+ }
+
+ @Test
+ public void TestFromText() {
+ final String BUENOS_AIRES = "geo:-34.603889,-58.381389?q=-34.603889,-58.381389";
+ final String Q1 = "geo:0.002500,0.005000?q=0.002500,0.005000";
+ final String Q2 = "geo:-0.002500,0.005000?q=-0.002500,0.005000";
+ final String Q3 = "geo:-0.002500,-0.005000?q=-0.002500,-0.005000";
+ final String Q4 = "geo:0.002500,-0.005000?q=0.002500,-0.005000";
+
+ final String[][] vectors = {
+ // URI round trips:
+ {BUENOS_AIRES, BUENOS_AIRES},
+ {Q1, Q1},
+ {Q2, Q2},
+ {Q3, Q3},
+ {Q4, Q4},
+ // basic DMS handling:
+ {"0°00’09″N 0°00’18″E", Q1},
+ {"0°00’09″S 0°00’18″E", Q2},
+ {"0°00’09″S 0°00’18″W", Q3},
+ {"0°00’09″N 0°00’18″W", Q4},
+ {"N0°00’09″ E0°00’18″", Q1},
+ {"S0°00’09″ E0°00’18″", Q2},
+ {"S0°00’09″ W0°00’18″", Q3},
+ {"N0°00’09″ W0°00’18″", Q4},
+ {"+0°00’09″ +0°00’18″", Q1},
+ {"-0°00’09″ +0°00’18″", Q2},
+ {"-0°00’09″ -0°00’18″", Q3},
+ {"+0°00’09″ -0°00’18″", Q4},
+ // basic DM handling:
+ {"0°00.15’N 0°00.30’E", Q1},
+ {"0°00.15’S 0°00.30’E", Q2},
+ {"0°00.15’S 0°00.30’W", Q3},
+ {"0°00.15’N 0°00.30’W", Q4},
+ {"N0°00.15’ E0°00.30’", Q1},
+ {"S0°00.15’ E0°00.30’", Q2},
+ {"S0°00.15’ W0°00.30’", Q3},
+ {"N0°00.15’ W0°00.30’", Q4},
+ {"+0°00.15’ +0°00.30’", Q1},
+ {"-0°00.15’ +0°00.30’", Q2},
+ {"-0°00.15’ -0°00.30’", Q3},
+ {"+0°00.15’ -0°00.30’", Q4},
+ // basic D handling:
+ {"0.0025°N 0.0050°E", Q1},
+ {"0.0025°S 0.0050°E", Q2},
+ {"0.0025°S 0.0050°W", Q3},
+ {"0.0025°N 0.0050°W", Q4},
+ {"N0.0025° E0.0050°", Q1},
+ {"S0.0025° E0.0050°", Q2},
+ {"S0.0025° W0.0050°", Q3},
+ {"N0.0025° W0.0050°", Q4},
+ {"+0.0025° +0.0050°", Q1},
+ {"-0.0025° +0.0050°", Q2},
+ {"-0.0025° -0.0050°", Q3},
+ {"+0.0025° -0.0050°", Q4},
+ // Everybody uses a different format:
+ {"-34 36 14, -58 22 53", BUENOS_AIRES},
+ {"-34°36’14\" -58°22’53\"", BUENOS_AIRES},
+ {"-34°36’14″ -58°22’53″", BUENOS_AIRES},
+ {"34:36:14S 58:22:53W", BUENOS_AIRES},
+ {"34d 36’ 14\" S 58d 22’ 53\" W", BUENOS_AIRES},
+ {"34° 36' 14.0\" S 58° 22' 53.0\" W", BUENOS_AIRES},
+ {"34° 36′ 14.000″ S, 58° 22′ 53.000″ W", BUENOS_AIRES},
+ {"34° 36′ 14.00″ S, 58° 22′ 53.00″ W", BUENOS_AIRES},
+ {"34° 36′ 14.0″ S, 58° 22′ 53.0″ W", BUENOS_AIRES},
+ {"34° 36′ 14″ S 58° 22′ 53″ W", BUENOS_AIRES},
+ {"34° 36′ 14″ S ; 58° 22′ 53″ W", BUENOS_AIRES},
+ {"34° 36′ 14″ S, 58° 22′ 53″ W", BUENOS_AIRES},
+ {"34°36'14.0\"S 58°22'53.0\"W", BUENOS_AIRES},
+ {"34°36’14\"S 58°22’53\"W", BUENOS_AIRES},
+ {"34°36’14\"S, 58°22’53\"W", BUENOS_AIRES},
+ {"34°36′14″S 58°22′53″W", BUENOS_AIRES},
+ {"34°36′14″S,58°22′53″W", BUENOS_AIRES},
+ {"34°36′14″S;58°22′53″W", BUENOS_AIRES},
+ {"-34° 36.23333, -58° 22.88333", BUENOS_AIRES},
+ {"-34° 36.23333’; -58° 22.88333’", BUENOS_AIRES},
+ {"-34° 36.23333' -58° 22.88333'", BUENOS_AIRES},
+ {"34° 36.23333S , 58° 22.883333W", BUENOS_AIRES},
+ {"34° 36.23333’S , 58° 22.883333’W", BUENOS_AIRES},
+ {"34° 36.23333'S , 58° 22.883333'W", BUENOS_AIRES},
+ {"34° 36.23333S , 58° 22.883333 W", BUENOS_AIRES},
+ {"34° 36.23333’ S 58° 22.883333’ W", BUENOS_AIRES},
+ {"34° 36.23333' S 58° 22.883333' W", BUENOS_AIRES},
+ {"-34.603889, -58.381389", BUENOS_AIRES},
+ {"-34.603889 , -58.381389", BUENOS_AIRES},
+ {"-34.603889 -58.381389", BUENOS_AIRES},
+ {"-34.603889; -58.381389", BUENOS_AIRES},
+ {"34.6038890° S 58.3813890° W", BUENOS_AIRES},
+ {"-34.603889°,-58.381389°", BUENOS_AIRES},
+ {"34.6038890S58.3813890W", BUENOS_AIRES},
+ {"34.6038890S 58.3813890W", BUENOS_AIRES},
+ {"34/36/14.0S 58/22/53.0W", BUENOS_AIRES},
+ {"34/36.23333S 58/22.883333W", BUENOS_AIRES},
+ {"34.603889S 58.3813890W", BUENOS_AIRES},
+ {"34.6038890S/58.3813890W", BUENOS_AIRES},
+ {"-34.6038890/-58.3813890", BUENOS_AIRES},
+ {"34.6038890ºS , 58.3813890ºW", BUENOS_AIRES},
+ {"34.6038890°S ; 58.3813890°W", BUENOS_AIRES},
+ {"34.6038890°S / 58.3813890°W", BUENOS_AIRES},
+
+ // pasted geo:
+ {"Y geo:-34.603889,-58.381389?z=16 Z", BUENOS_AIRES},
+ {"X\nY geo:-34.603889,-58.381389?z=16 Z", BUENOS_AIRES},
+ {"X\nY geo:-34.603889,-58.381389?z=16\nZ", BUENOS_AIRES},
+ {"X\r\nY geo:-34.603889,-58.381389?z=16\r\nZ", BUENOS_AIRES},
+
+ // some URLs
+ {"https://osmand.net/map?pin=-34.603889,-58.381389#16/-34.603889/-58.381389", BUENOS_AIRES},
+ {"https://osmand.net/map/#16/-34.603889/-58.381389", BUENOS_AIRES},
+ {"https://www.google.com/maps/@-34.603889,-58.381389,15z?", BUENOS_AIRES},
+ {"https://www.google.com/maps/place/XYZ/@-34.603889,-58.381389,15z/data=", BUENOS_AIRES},
+ {"https://www.google.com/maps/search/?api=1&query=-34.603889%2C-58.381389&a=1", BUENOS_AIRES},
+ {"https://www.google.com/maps/search/?api=1&query=-34.603889,-58.381389#a", BUENOS_AIRES},
+ {"https://www.google.com/maps/@?api=1&map_action=map¢er=-34.603889%2C-58.381389&zoom=12&basemap=terrain", BUENOS_AIRES},
+ {"https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=-34.603889%2C-58.381389&heading=-45&pitch=38&fov=80", BUENOS_AIRES},
+ {"https://maps.google.com/?q=@-34.603889,-58.381389", BUENOS_AIRES}
+ };
+
+ for (String[] vector : vectors) {
+ final String input = vector[0];
+ final String expected = vector[1];
+
+ final WaypointHelper helper = WaypointHelper.Companion.fromText(input);
+ Assert.assertNotNull(input, helper);
+ final String actual = helper.toString();
+ Assert.assertEquals(expected, actual);
+ }
+ }
+
+ @Test
+ public void TestToString() {
+ ToStringRoundTrip(ACONCAGUA, "geo:-32.653056,-70.011667,6967.150000?q=-32.653056,-70.011667(Aconcagua)");
+ ToStringRoundTrip(DEAD_SEA, "geo:31.500000,35.500000,-439.780000?q=31.500000,35.500000(%D7%99%D7%9D%20%D7%94%D7%9E%D7%9C%D7%97)");
+ ToStringRoundTrip(EVEREST, "geo:27.988333,86.925278,8848.860000?q=27.988333,86.925278(%E0%A4%B8%E0%A4%97%E0%A4%B0%E0%A4%AE%E0%A4%BE%E0%A4%A5%E0%A4%BE)");
+ ToStringRoundTrip(SANTA_CLAUS, "geo:89.999722,-179.999722?q=89.999722,-179.999722(Wihn%C3%A4chtsmann)");
+ ToStringRoundTrip(ROLAS, "geo:-0.002722,6.516722,0.000000?q=-0.002722,6.516722(Ilh%C3%A9u%20das%20Rolas)");
+ }
+
+ /// encode->decode waypoints in a Lctns.fit compliant format
+ @Test
+ public void TestFitLocationEncoding() throws Exception {
+ // waypoints -> fit
+ WaypointHelper[] waypoints = {ACONCAGUA, DEAD_SEA, EVEREST, SANTA_CLAUS, ROLAS};
+ FitFile generatedFit = WaypointHelper.Companion.generateFitLocationFile(waypoints, 1774688353L, 246);
+ byte[] fitGenerated = generatedFit.getOutgoingMessage();
+ byte[] fitExpected = readBinaryResource("/TestFitLocationEncoding.fit");
+ Assert.assertArrayEquals(fitExpected, fitGenerated);
+
+ // fit -> text
+ FitFile decodedFit = FitFile.parseIncoming(fitGenerated);
+ String decodedActual = decodedFit.toString().replace("}, Fit", "},\nFit").replace("}, RecordData{", "},\nRecordData{");
+ String decodedExpected = readTextResource("/TestFitLocationEncoding.txt");
+ Assert.assertEquals(decodedExpected, decodedActual);
+ }
+
+ @Test
+ public void TestFormatPosition() {
+ String[] aconcaguaActual = WaypointHelper.Companion.formatPosition(ACONCAGUA.getLatitude(), ACONCAGUA.getLongitude());
+ String[] aconcaguaExpected = {
+ "32° 39′ 11.0″ S, 70° 00′ 42.0″ W",
+ "32° 39.183′ S, 70° 00.700′ W",
+ "32.653056°S 70.011667°W",
+ "-32.653056; -70.011667",
+ };
+ Assert.assertArrayEquals(aconcaguaExpected, aconcaguaActual);
+
+ String[] deadSeaActual = WaypointHelper.Companion.formatPosition(DEAD_SEA.getLatitude(), DEAD_SEA.getLongitude());
+ String[] deadSeaExpected = {
+ "31° 30′ 00.0″ N, 35° 30′ 00.0″ E",
+ "31° 30.000′ N, 35° 30.000′ E",
+ "31.500000°N 35.500000°E",
+ "31.500000; 35.500000",
+ };
+ Assert.assertArrayEquals(deadSeaExpected, deadSeaActual);
+
+ String[] everestActual = WaypointHelper.Companion.formatPosition(EVEREST.getLatitude(), EVEREST.getLongitude());
+ String[] everestExpected = {
+ "27° 59′ 18.0″ N, 86° 55′ 31.0″ E",
+ "27° 59.300′ N, 86° 55.517′ E",
+ "27.988333°N 86.925278°E",
+ "27.988333; 86.925278",
+ };
+ Assert.assertArrayEquals(everestExpected, everestActual);
+
+ String[] santaClausActual = WaypointHelper.Companion.formatPosition(SANTA_CLAUS.getLatitude(), SANTA_CLAUS.getLongitude());
+ String[] santaClausExpected = {
+ "89° 59′ 59.0″ N, 179° 59′ 59.0″ W",
+ "89° 59.983′ N, 179° 59.983′ W",
+ "89.999722°N 179.999722°W",
+ "89.999722; -179.999722",
+ };
+ Assert.assertArrayEquals(santaClausExpected, santaClausActual);
+
+ String[] rolasActual = WaypointHelper.Companion.formatPosition(ROLAS.getLatitude(), ROLAS.getLongitude());
+ String[] rolasExpected = {
+ "0° 00′ 09.8″ S, 6° 31′ 00.2″ E",
+ "0° 00.163′ S, 6° 31.003′ E",
+ "0.002722°S 6.516722°E",
+ "-0.002722; 6.516722",
+ };
+ Assert.assertArrayEquals(rolasExpected, rolasActual);
+ }
+}
diff --git a/app/src/test/resources/TestFitLocationEncoding.fit b/app/src/test/resources/TestFitLocationEncoding.fit
index 338253ca05..c152e7ec09 100644
Binary files a/app/src/test/resources/TestFitLocationEncoding.fit and b/app/src/test/resources/TestFitLocationEncoding.fit differ
diff --git a/app/src/test/resources/TestFitLocationEncoding.txt b/app/src/test/resources/TestFitLocationEncoding.txt
index 697305a799..c5e0e48704 100644
--- a/app/src/test/resources/TestFitLocationEncoding.txt
+++ b/app/src/test/resources/TestFitLocationEncoding.txt
@@ -1,5 +1,7 @@
-[FitFileId{type=LOCATION, manufacturer=5759, product=65534, number=65533, product_name=Gadgetbridge},
-FitFileCreator{software_version=2318, hardware_version=254},
-FitLocation{name=Bregenz, position_lat=47.50499999150634, position_long=9.73999997600913, symbol=Airport, altitude=-495.0, message_index=0},
-FitLocation{name=腓特烈港, position_lat=47.65599997714162, position_long=9.472999982535839, symbol=Anchor, altitude=0.0, message_index=1},
-FitLocation{name=קרויצלינגן, position_lat=47.633000034838915, position_long=9.166999999433756, symbol=Water Source, altitude=8850.0, message_index=2}]
\ No newline at end of file
+[FitFileId{type=LOCATION, manufacturer=1, product=65534, serial_number=1, time_created=1774688353, number=1, product_name=Gadgetbridge},
+FitFileCreator{software_version=246},
+FitLocation{2026-03-28 08:59:13.000, name=Aconcagua, position_lat=-32.65305555425584, position_long=-70.01166670583189, altitude=6967.0, timestamp=1774688353, message_index=0},
+FitLocation{2026-03-28 08:59:13.000, name=ים המלח, position_lat=31.499999966472387, position_long=35.4999999795109, altitude=-439.0, timestamp=1774688353, message_index=1},
+FitLocation{2026-03-28 08:59:13.000, name=सगरमाथा, position_lat=27.988333320245147, position_long=86.92527777515352, altitude=8848.0, timestamp=1774688353, message_index=2},
+FitLocation{2026-03-28 08:59:13.000, name=Wihnächtsmann, position_lat=89.9997222237289, position_long=-179.9997222237289, timestamp=1774688353, message_index=3},
+FitLocation{2026-03-28 08:59:13.000, name=Ilhéu das Rolas, position_lat=-0.0027221906930208206, position_long=6.516722263768315, altitude=0.0, timestamp=1774688353, message_index=4}]
\ No newline at end of file