From d45bcd4db32828a980c29131992c24fcbb1510d0 Mon Sep 17 00:00:00 2001 From: Thomas Kuehne Date: Sun, 3 May 2026 08:39:09 +0000 Subject: [PATCH] Garmin: add basic Send Waypoint activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/src/main/AndroidManifest.xml | 21 + .../devices/garmin/GarminCoordinator.java | 10 + .../devices/garmin/GarminPreferences.java | 1 + .../garmin/GarminSettingsCustomizer.java | 7 + .../actions/GarminSendWaypointActivity.kt | 378 +++++++++++++++ .../gadgetbridge/model/DistanceUnit.java | 12 +- .../gadgetbridge/util/WaypointHelper.kt | 439 ++++++++++++++++++ .../res/drawable/ic_add_location_alt_24px.xml | 10 + .../main/res/drawable/ic_altitude_24px.xml | 10 + .../res/drawable/ic_content_paste_go_24px.xml | 10 + app/src/main/res/drawable/ic_label_24px.xml | 11 + .../res/drawable/ic_not_listed_location.xml | 11 + .../res/layout/activity_send_waypoint.xml | 147 ++++++ app/src/main/res/values/strings.xml | 27 ++ .../devicesettings_garmin_send_waypoint.xml | 7 + .../devices/garmin/GarminSupportTest.java | 63 --- .../gadgetbridge/util/WaypointHelperTest.java | 323 +++++++++++++ .../resources/TestFitLocationEncoding.fit | Bin 253 -> 360 bytes .../resources/TestFitLocationEncoding.txt | 12 +- 19 files changed, 1430 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/actions/GarminSendWaypointActivity.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WaypointHelper.kt create mode 100644 app/src/main/res/drawable/ic_add_location_alt_24px.xml create mode 100644 app/src/main/res/drawable/ic_altitude_24px.xml create mode 100644 app/src/main/res/drawable/ic_content_paste_go_24px.xml create mode 100644 app/src/main/res/drawable/ic_label_24px.xml create mode 100644 app/src/main/res/drawable/ic_not_listed_location.xml create mode 100644 app/src/main/res/layout/activity_send_waypoint.xml create mode 100644 app/src/main/res/xml/devicesettings_garmin_send_waypoint.xml create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/WaypointHelperTest.java 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 @@ + + + + + + + + + + + + + + + + + + + + +