Garmin: add basic Send Waypoint activity

- available in device specific settings for supported gadgets
- supports pasting common text formats (e.g. 27°59′18″N 86°55′31″E)
- supports geo: URIs (e.g. geo:27.988333,86.925278,8848)
This commit is contained in:
Thomas Kuehne
2026-05-03 16:11:55 +00:00
parent c51a0535c9
commit d45bcd4db3
19 changed files with 1430 additions and 69 deletions
+21
View File
@@ -1099,6 +1099,27 @@
android:launchMode="singleInstance"
android:exported="true" />
<activity
android:name=".devices.garmin.actions.GarminSendWaypointActivity"
android:exported="true"
android:label="@string/activity_send_waypoint_titel">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geo" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!-- For supported versions through Android 13, create an activity to show the rationale
of Health Connect permissions once users click the privacy policy link. -->
<activity
@@ -263,6 +263,10 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
);
}
if (supportsSendWaypoint(device)) {
deviceSpecificSettings.addRootScreen(R.xml.devicesettings_garmin_send_waypoint);
}
final List<Integer> 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());
@@ -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));
@@ -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) {
@@ -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 <https://www.gnu.org/licenses/>. */
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
}
}
}
@@ -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;
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>? {
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<WaypointHelper>, timestamp: Long, softwareVersion: Int
): FitFile {
val dataRecords: MutableList<RecordData?> = 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)
}
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q319,743 239.5,625.5Q160,508 160,408Q160,258 256.5,169Q353,80 480,80Q490,80 500,80Q510,80 520,82L520,163Q510,161 500.5,160.5Q491,160 480,160Q379,160 309.5,229.5Q240,299 240,408Q240,479 299,570.5Q358,662 480,774Q602,662 661,570.5Q720,479 720,408Q720,406 720,404Q720,402 720,400L800,400Q800,402 800,404Q800,406 800,408Q800,508 720.5,625.5Q641,743 480,880ZM536.5,456.5Q560,433 560,400Q560,367 536.5,343.5Q513,320 480,320Q447,320 423.5,343.5Q400,367 400,400Q400,433 423.5,456.5Q447,480 480,480Q513,480 536.5,456.5ZM480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400L480,400L480,400L480,400L480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400ZM720,320L800,320L800,200L920,200L920,120L800,120L800,0L720,0L720,120L600,120L600,200L720,200L720,320Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M720,480L720,312L656,376L600,320L760,160L920,320L864,377L800,313L800,480L720,480ZM40,880L280,560L460,800L760,800L560,534L460,666L410,600L560,400L920,880L40,880ZM460,800L460,800L460,800L460,800L460,800Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M720,840L664,783L727,720L480,720L480,640L727,640L664,576L720,520L880,680L720,840ZM840,440L760,440L760,200Q760,200 760,200Q760,200 760,200L680,200L680,320L280,320L280,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L400,760L400,840L200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L367,120Q378,85 410,62.5Q442,40 480,40Q520,40 551.5,62.5Q583,85 594,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,440ZM508.5,188.5Q520,177 520,160Q520,143 508.5,131.5Q497,120 480,120Q463,120 451.5,131.5Q440,143 440,160Q440,177 451.5,188.5Q463,200 480,200Q497,200 508.5,188.5Z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L600,160Q619,160 636,168.5Q653,177 664,192L880,480L664,768Q653,783 636,791.5Q619,800 600,800L160,800ZM160,720L600,720Q600,720 600,720Q600,720 600,720L780,480L600,240Q600,240 600,240Q600,240 600,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM380,480Q380,480 380,480Q380,480 380,480L380,480Q380,480 380,480Q380,480 380,480L380,480Q380,480 380,480Q380,480 380,480L380,480L380,480Q380,480 380,480Q380,480 380,480Z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M509.5,627.5Q522,615 522,598Q522,581 509.5,568.5Q497,556 480,556Q463,556 450.5,568.5Q438,581 438,598Q438,615 450.5,627.5Q463,640 480,640Q497,640 509.5,627.5ZM450,516L510,516Q510,497 511.5,486Q513,475 516,468Q520,460 527.5,451.5Q535,443 552,426Q573,405 583.5,384Q594,363 594,342Q594,295 563,267.5Q532,240 480,240Q439,240 408,263Q377,286 366,324L420,346Q427,323 443,310.5Q459,298 480,298Q504,298 519,311Q534,324 534,344Q534,361 526.5,373.5Q519,386 500,402Q483,416 473,427.5Q463,439 458,450Q453,460 451.5,474.5Q450,489 450,516ZM480,774Q602,662 661,570.5Q720,479 720,408Q720,299 650.5,229.5Q581,160 480,160Q379,160 309.5,229.5Q240,299 240,408Q240,479 299,570.5Q358,662 480,774ZM480,880Q319,743 239.5,625.5Q160,508 160,408Q160,258 256.5,169Q353,80 480,80Q607,80 703.5,169Q800,258 800,408Q800,508 720.5,625.5Q641,743 480,880ZM480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Z"/>
</vector>
@@ -0,0 +1,147 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/activity_horizontal_margin"
android:paddingVertical="@dimen/activity_vertical_margin"
android:orientation="vertical"
android:scrollbars="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/activity_send_waypoint_name" />
<EditText
android:id="@+id/waypointName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawableStart="@drawable/ic_label_24px"
android:drawablePadding="@dimen/cpv_item_horizontal_padding"
android:hint="@string/activity_send_waypoint_name"
android:inputType="text"
android:minHeight="48dp"
android:singleLine="true" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:labelFor="@id/waypointLatitude"
android:text="@string/pref_title_location_latitude" />
<EditText
android:id="@+id/waypointLatitude"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawableStart="@drawable/ic_swap_vert"
android:drawablePadding="@dimen/cpv_item_horizontal_padding"
android:hint="@string/pref_title_location_latitude"
android:inputType="number|numberDecimal|numberSigned"
android:minHeight="48dp"
android:singleLine="true" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:labelFor="@id/waypointLongitude"
android:text="@string/pref_title_location_longitude" />
<EditText
android:id="@+id/waypointLongitude"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawableStart="@drawable/ic_swap_horiz"
android:drawablePadding="@dimen/cpv_item_horizontal_padding"
android:hint="@string/pref_title_location_longitude"
android:inputType="number|numberDecimal|numberSigned"
android:minHeight="48dp"
android:singleLine="true" />
<TextView
android:id="@+id/waypointElevationLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:labelFor="@id/waypointElevation"
android:text="@string/activity_send_waypoint_elevation" />
<EditText
android:id="@+id/waypointElevation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawableStart="@drawable/ic_altitude_24px"
android:drawablePadding="@dimen/cpv_item_horizontal_padding"
android:hint="@string/activity_send_waypoint_elevation"
android:inputType="number|numberDecimal|numberSigned"
android:minHeight="48dp"
android:singleLine="true" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="@dimen/activity_vertical_margin">
<Button
android:id="@+id/waypointPaste"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/about_margin"
android:layout_weight="1"
android:text="@string/activity_send_waypoint_paste"
app:icon="@drawable/ic_content_paste_go_24px" />
<Button
android:id="@+id/waypointSend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginStart="@dimen/about_margin"
android:layout_weight="1"
android:enabled="false"
android:text="@string/activity_send_waypoint_to_device"
app:icon="@drawable/ic_add_location_alt_24px" />
</LinearLayout>
<TextView
android:id="@+id/waypoint_info_0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:clickable="true"
android:focusable="true"
android:textAlignment="center"
android:textIsSelectable="false" />
<TextView
android:id="@+id/waypoint_info_1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:clickable="true"
android:focusable="true"
android:textAlignment="center"
android:textIsSelectable="false" />
<TextView
android:id="@+id/waypoint_info_2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:clickable="true"
android:focusable="true"
android:textAlignment="center"
android:textIsSelectable="false" />
<TextView
android:id="@+id/waypoint_info_3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:clickable="true"
android:focusable="true"
android:textAlignment="center"
android:textIsSelectable="false" />
</LinearLayout>
+27
View File
@@ -4725,6 +4725,33 @@
<string name="pref_title_pebble_search_app_updates">Search for app updates</string>
<string name="pref_summary_pebble_search_app_updates">Automatically search for watch app and watchface updates when opening the app manager</string>
<string name="pref_sos_contact_title">SOS contact</string>
<string name="activity_send_waypoint_device">Device</string>
<string name="activity_send_waypoint_elevation">Elevation</string>
<string name="activity_send_waypoint_elevation_label">Elevation (%s)</string>
<string name="activity_send_waypoint_elevation_number">elevation must be empty or a number</string>
<string name="activity_send_waypoint_elevation_range_imperial">elevation must be between -1\'600 ft and +38\'713 ft</string>
<string name="activity_send_waypoint_elevation_range_metric">elevation must be between -500m and +11\'800m</string>
<string name="activity_send_waypoint_feet">feet</string>
<string name="activity_send_waypoint_latitude_missing">latitude is empty</string>
<string name="activity_send_waypoint_latitude_number">latitude must be a number</string>
<string name="activity_send_waypoint_latitude_range">latitude must be between -90° and +90°</string>
<string name="activity_send_waypoint_longitude_missing">longitude is empty</string>
<string name="activity_send_waypoint_longitude_number">longitude must be a number</string>
<string name="activity_send_waypoint_longitude_range">longitude must be between -180° and +180°</string>
<string name="activity_send_waypoint_meter">meter</string>
<string name="activity_send_waypoint_name">Name</string>
<plurals name="activity_send_waypoint_name_length">
<item quantity="one">name is %d character too long</item>
<item quantity="other">name is %d characters too long</item>
</plurals>
<string name="activity_send_waypoint_name_missing">name is empty</string>
<string name="activity_send_waypoint_paste">Paste</string>
<string name="activity_send_waypoint_titel">Send Waypoint</string>
<string name="activity_send_waypoint_no_location">No Location</string>
<string name="activity_send_waypoint_no_location_details">No location information found in the received data.</string>
<string name="activity_send_waypoint_to_device">Send</string>
<!-- Export Health Connect preferences -->
<string name="healthconnect_settings">Health Connect</string>
<string name="pref_health_connect_enabled">Enable Health Connect Export</string>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:icon="@drawable/ic_add_location_alt_24px"
android:key="garmin_send_waypoint"
android:title="@string/activity_send_waypoint_titel" />
</androidx.preference.PreferenceScreen>
@@ -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<RecordData> 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<RecordData> records = fitFile.getRecords();
@@ -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 <https://www.gnu.org/licenses/>. */
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°0009″N 0°0018″E", Q1},
{"0°0009″S 0°0018″E", Q2},
{"0°0009″S 0°0018″W", Q3},
{"0°0009″N 0°0018″W", Q4},
{"N0°0009″ E0°0018″", Q1},
{"S0°0009″ E0°0018″", Q2},
{"S0°0009″ W0°0018″", Q3},
{"N0°0009″ W0°0018″", Q4},
{"+0°0009″ +0°0018″", Q1},
{"-0°0009″ +0°0018″", Q2},
{"-0°0009″ -0°0018″", Q3},
{"+0°0009″ -0°0018″", Q4},
// basic DM handling:
{"0°00.15N 0°00.30E", Q1},
{"0°00.15S 0°00.30E", Q2},
{"0°00.15S 0°00.30W", Q3},
{"0°00.15N 0°00.30W", 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°3614\" -58°2253\"", BUENOS_AIRES},
{"-34°3614″ -58°2253″", 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°3614\"S 58°2253\"W", BUENOS_AIRES},
{"34°3614\"S, 58°2253\"W", BUENOS_AIRES},
{"34°3614″S 58°2253″W", BUENOS_AIRES},
{"34°3614″S,58°2253″W", BUENOS_AIRES},
{"34°3614″S;58°2253″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.23333S , 58° 22.883333W", 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&center=-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);
}
}
Binary file not shown.
@@ -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}]
[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}]