diff --git a/project/app/build.gradle.kts b/project/app/build.gradle.kts
index ed231ec527..e87e024e92 100644
--- a/project/app/build.gradle.kts
+++ b/project/app/build.gradle.kts
@@ -234,6 +234,9 @@ dependencies {
implementation(libs.paho.mqttclient)
implementation(libs.okhttp)
+ // External USB GNSS (u-blox F9P et al.)
+ implementation("com.github.mik3y:usb-serial-for-android:3.5.1")
+
// Utility libraries
implementation(libs.bundles.hilt)
implementation(libs.square.tape2)
diff --git a/project/app/src/gms/java/org/owntracks/android/di/LocationProviderClientModule.kt b/project/app/src/gms/java/org/owntracks/android/di/LocationProviderClientModule.kt
index 0d7bc469bc..64c6619d42 100644
--- a/project/app/src/gms/java/org/owntracks/android/di/LocationProviderClientModule.kt
+++ b/project/app/src/gms/java/org/owntracks/android/di/LocationProviderClientModule.kt
@@ -8,7 +8,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.owntracks.android.gms.location.GMSLocationProviderClient
+import org.owntracks.android.location.ExternalGnssLocationProviderClient
import org.owntracks.android.location.LocationProviderClient
+import org.owntracks.android.location.external.ExternalGnssController
@InstallIn(SingletonComponent::class)
@Module
@@ -16,6 +18,11 @@ class LocationProviderClientModule {
@Provides
@Singleton
fun getLocationProviderClient(
- @ApplicationContext applicationContext: Context
- ): LocationProviderClient = GMSLocationProviderClient.create(applicationContext)
+ @ApplicationContext applicationContext: Context,
+ externalGnssController: ExternalGnssController,
+ ): LocationProviderClient =
+ ExternalGnssLocationProviderClient(
+ delegate = GMSLocationProviderClient.create(applicationContext),
+ controller = externalGnssController,
+ )
}
diff --git a/project/app/src/main/AndroidManifest.xml b/project/app/src/main/AndroidManifest.xml
index e7ef771ec6..d655a48ac1 100644
--- a/project/app/src/main/AndroidManifest.xml
+++ b/project/app/src/main/AndroidManifest.xml
@@ -19,6 +19,10 @@
android:name="android.hardware.wifi"
android:required="false" />
+
+
@@ -243,6 +247,12 @@
+
+
+
+
diff --git a/project/app/src/main/java/org/owntracks/android/location/ExternalGnssLocationProviderClient.kt b/project/app/src/main/java/org/owntracks/android/location/ExternalGnssLocationProviderClient.kt
new file mode 100644
index 0000000000..50c40cb56d
--- /dev/null
+++ b/project/app/src/main/java/org/owntracks/android/location/ExternalGnssLocationProviderClient.kt
@@ -0,0 +1,54 @@
+package org.owntracks.android.location
+
+import android.location.Location
+import android.os.Looper
+import androidx.annotation.RequiresPermission
+import org.owntracks.android.location.external.ExternalGnssController
+
+/**
+ * [LocationProviderClient] that wraps an existing provider (AOSP or GMS) and augments it with fixes
+ * coming from an external USB GNSS receiver managed by [ExternalGnssController].
+ *
+ * All location requests are transparently forwarded to the [delegate]; in addition, every
+ * [LocationCallback] is registered with the controller so it also receives synthetic
+ * [android.location.Location] objects built from external NMEA fixes.
+ */
+class ExternalGnssLocationProviderClient(
+ private val delegate: LocationProviderClient,
+ private val controller: ExternalGnssController,
+) : LocationProviderClient() {
+
+ @RequiresPermission(
+ anyOf =
+ ["android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION"])
+ override fun singleHighAccuracyLocation(clientCallBack: LocationCallback, looper: Looper) {
+ delegate.singleHighAccuracyLocation(clientCallBack, looper)
+ }
+
+ @RequiresPermission(
+ anyOf =
+ ["android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION"])
+ override fun actuallyRequestLocationUpdates(
+ locationRequest: LocationRequest,
+ clientCallBack: LocationCallback,
+ looper: Looper
+ ) {
+ // Forward to the real provider (uses the public entry point because the "actually" method is
+ // protected on the delegate).
+ delegate.requestLocationUpdates(locationRequest, clientCallBack, looper)
+ controller.registerCallback(clientCallBack)
+ }
+
+ override fun removeLocationUpdates(clientCallBack: LocationCallback) {
+ delegate.removeLocationUpdates(clientCallBack)
+ controller.unregisterCallback(clientCallBack)
+ }
+
+ override fun flushLocations() {
+ delegate.flushLocations()
+ }
+
+ override fun getLastLocation(): Location? {
+ return controller.getLastExternalLocation() ?: delegate.getLastLocation()
+ }
+}
diff --git a/project/app/src/main/java/org/owntracks/android/location/external/ExternalGnssController.kt b/project/app/src/main/java/org/owntracks/android/location/external/ExternalGnssController.kt
new file mode 100644
index 0000000000..08eed123a7
--- /dev/null
+++ b/project/app/src/main/java/org/owntracks/android/location/external/ExternalGnssController.kt
@@ -0,0 +1,295 @@
+package org.owntracks.android.location.external
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbManager
+import android.location.Location
+import android.os.Build
+import android.os.SystemClock
+import androidx.core.content.ContextCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.concurrent.CopyOnWriteArraySet
+import javax.inject.Inject
+import javax.inject.Singleton
+import org.owntracks.android.location.LocationCallback
+import org.owntracks.android.location.LocationResult
+import timber.log.Timber
+
+/**
+ * Application-scoped coordinator for the external USB GNSS feature.
+ *
+ * - Owns a single [ExternalGnssManager] instance.
+ * - Handles USB attach/detach and runtime permission flow.
+ * - Receives GNSS fixes from the manager and fans them out as synthetic [Location] updates to every
+ * [LocationCallback] that has been registered via the [ExternalGnssLocationProviderClient]
+ * wrapper, so that OwnTracks publishes them through its standard pipeline.
+ * - Reacts to changes in [NtripConfig] persisted in [SharedPreferences].
+ */
+@Singleton
+class ExternalGnssController
+@Inject
+constructor(@ApplicationContext private val appContext: Context) {
+
+ private val usbManager =
+ appContext.getSystemService(Context.USB_SERVICE) as UsbManager
+
+ private val prefs: SharedPreferences =
+ appContext.getSharedPreferences(NtripConfig.PREFS_NAME, Context.MODE_PRIVATE)
+
+ private val callbacks = CopyOnWriteArraySet()
+
+ private val startupExecutor =
+ java.util.concurrent.Executors.newSingleThreadExecutor { r ->
+ Thread(r, "external-gnss-startup").apply { isDaemon = true }
+ }
+
+ @Volatile private var lastLocation: Location? = null
+
+ private val manager: ExternalGnssManager =
+ ExternalGnssManager(
+ context = appContext,
+ onFix = { lat, lon, alt, accM -> dispatchFix(lat, lon, alt, accM) },
+ onStatus = { Timber.i("[ExternalGnss] %s", it) },
+ onActiveChanged = { Timber.i("[ExternalGnss] active=%s", it) },
+ )
+
+ private val permissionReceiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ACTION_USB_PERMISSION) return
+ val device: UsbDevice? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
+ } else {
+ @Suppress("DEPRECATION") intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
+ }
+ val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
+ if (granted && device != null) {
+ startupExecutor.execute {
+ runCatching { startWithDevice(device) }
+ .onFailure { Timber.e(it, "[ExternalGnss] startWithDevice failed") }
+ }
+ } else {
+ Timber.w("USB permission denied for %s", device?.deviceName)
+ }
+ }
+ }
+
+ private val detachReceiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED && manager.isRunning) {
+ Timber.i("USB device detached; stopping external GNSS")
+ startupExecutor.execute { runCatching { manager.stop() } }
+ }
+ }
+ }
+
+ private val prefsListener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
+ startupExecutor.execute {
+ runCatching { onConfigChanged() }
+ .onFailure { Timber.e(it, "[ExternalGnss] onConfigChanged failed") }
+ }
+ }
+
+ init {
+ registerReceivers()
+ prefs.registerOnSharedPreferenceChangeListener(prefsListener)
+ runCatching { manager.updateNtripConfig(NtripConfig.load(appContext)) }
+ .onFailure { Timber.w(it, "[ExternalGnss] updateNtripConfig at init failed") }
+ // Auto-start if the feature is already enabled and a supported device is attached.
+ // Run off the injection thread: USB open + UBX writes are blocking I/O and must never
+ // run on the thread that Hilt uses to satisfy @Inject; otherwise BackgroundService.onCreate
+ // can ANR/crash.
+ if (isExternalGnssEnabled) {
+ Timber.i("[ExternalGnss] Feature enabled at boot; scheduling auto-start")
+ startupExecutor.execute {
+ runCatching { tryStartFromAttachedDevices() }
+ .onFailure { Timber.e(it, "[ExternalGnss] auto-start failed") }
+ }
+ } else {
+ Timber.d("[ExternalGnss] Feature disabled at boot; not starting")
+ }
+ }
+
+ private fun registerReceivers() {
+ val permissionFilter = IntentFilter(ACTION_USB_PERMISSION)
+ val detachFilter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ContextCompat.registerReceiver(
+ appContext, permissionReceiver, permissionFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
+ ContextCompat.registerReceiver(
+ appContext, detachReceiver, detachFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
+ } else {
+ appContext.registerReceiver(permissionReceiver, permissionFilter)
+ appContext.registerReceiver(detachReceiver, detachFilter)
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Public API called from LocationProviderClient wrapper
+ // ---------------------------------------------------------------------------
+
+ fun registerCallback(cb: LocationCallback) {
+ callbacks.add(cb)
+ }
+
+ fun unregisterCallback(cb: LocationCallback) {
+ callbacks.remove(cb)
+ }
+
+ fun getLastExternalLocation(): Location? = lastLocation
+
+ val isExternalGnssEnabled: Boolean
+ get() = prefs.getBoolean(NtripConfig.KEY_EXTERNAL_ENABLED, false)
+
+ // ---------------------------------------------------------------------------
+ // Called from MapActivity when the USB_DEVICE_ATTACHED intent fires
+ // ---------------------------------------------------------------------------
+
+ fun handleUsbAttachIntent(intent: Intent?) {
+ if (intent == null) return
+ val device: UsbDevice? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
+ } else {
+ @Suppress("DEPRECATION") intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
+ }
+ if (device != null) {
+ startupExecutor.execute {
+ runCatching { requestPermissionAndStart(device) }
+ .onFailure { Timber.e(it, "[ExternalGnss] handleUsbAttachIntent failed") }
+ }
+ }
+ }
+
+ /**
+ * Called from preferences/UI when the user toggles the feature or taps "connect". Scans attached
+ * USB devices and asks permission for the first supported one.
+ */
+ fun tryStartFromAttachedDevices() {
+ if (!isExternalGnssEnabled) {
+ startupExecutor.execute { runCatching { manager.stop() } }
+ return
+ }
+ startupExecutor.execute {
+ runCatching {
+ val candidate =
+ usbManager.deviceList.values.firstOrNull {
+ it.vendorId in SUPPORTED_VENDOR_IDS
+ } ?: usbManager.deviceList.values.firstOrNull()
+ if (candidate != null) {
+ requestPermissionAndStart(candidate)
+ } else {
+ Timber.i("No USB devices attached")
+ }
+ }
+ .onFailure { Timber.e(it, "[ExternalGnss] tryStartFromAttachedDevices failed") }
+ }
+ }
+
+ fun stop() {
+ startupExecutor.execute { runCatching { manager.stop() } }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Internal
+ // ---------------------------------------------------------------------------
+
+ @SuppressLint("InlinedApi")
+ private fun requestPermissionAndStart(device: UsbDevice) {
+ if (!isExternalGnssEnabled) return
+ if (usbManager.hasPermission(device)) {
+ startWithDevice(device)
+ return
+ }
+ val flags =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ }
+ val intent = Intent(ACTION_USB_PERMISSION).setPackage(appContext.packageName)
+ val pi = PendingIntent.getBroadcast(appContext, 0, intent, flags)
+ usbManager.requestPermission(device, pi)
+ }
+
+ private fun startWithDevice(device: UsbDevice) {
+ if (!isExternalGnssEnabled) return
+ if (manager.isRunning) {
+ // Update NTRIP config anyway in case it changed.
+ manager.updateNtripConfig(NtripConfig.load(appContext))
+ return
+ }
+ manager.updateNtripConfig(NtripConfig.load(appContext))
+ manager.start(device)
+ }
+
+ private fun onConfigChanged() {
+ val cfg = NtripConfig.load(appContext)
+ val enabled = isExternalGnssEnabled
+ if (!enabled) {
+ manager.stop()
+ } else {
+ manager.updateNtripConfig(cfg)
+ if (!manager.isRunning) {
+ tryStartFromAttachedDevices()
+ }
+ }
+ }
+
+ private fun dispatchFix(lat: Double, lon: Double, alt: Double, accM: Double) {
+ if (!isExternalGnssEnabled) return
+ if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) !=
+ PackageManager.PERMISSION_GRANTED) {
+ // OwnTracks publishes Location objects freely once permissions have been granted by the
+ // user; without ACCESS_FINE_LOCATION the standard provider wouldn't be reporting either,
+ // so we mirror that behaviour.
+ return
+ }
+ val location =
+ Location(PROVIDER_NAME).apply {
+ latitude = lat
+ longitude = lon
+ altitude = alt
+ if (accM > 0.0) {
+ accuracy = accM.toFloat()
+ }
+ time = System.currentTimeMillis()
+ elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
+ }
+ lastLocation = location
+ val result = LocationResult(location)
+ callbacks.forEach { cb ->
+ try {
+ cb.onLocationResult(result)
+ } catch (t: Throwable) {
+ Timber.w(t, "Callback %s threw on external fix", cb)
+ }
+ }
+ }
+
+ companion object {
+ const val PROVIDER_NAME = "external_gnss"
+ private const val ACTION_USB_PERMISSION = "org.owntracks.android.USB_PERMISSION"
+
+ /** Vendor IDs of the USB-serial chips we support. Mirrors usb_device_filter.xml. */
+ private val SUPPORTED_VENDOR_IDS =
+ setOf(
+ 0x1546, // u-blox
+ 0x0403, // FTDI
+ 0x10C4, // Silicon Labs CP210x
+ 0x1A86, // QinHeng CH34x
+ 0x067B, // Prolific PL2303
+ )
+ }
+}
diff --git a/project/app/src/main/java/org/owntracks/android/location/external/ExternalGnssManager.kt b/project/app/src/main/java/org/owntracks/android/location/external/ExternalGnssManager.kt
new file mode 100644
index 0000000000..23b5cab340
--- /dev/null
+++ b/project/app/src/main/java/org/owntracks/android/location/external/ExternalGnssManager.kt
@@ -0,0 +1,308 @@
+package org.owntracks.android.location.external
+
+import android.content.Context
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbDeviceConnection
+import android.hardware.usb.UsbManager
+import com.hoho.android.usbserial.driver.UsbSerialDriver
+import com.hoho.android.usbserial.driver.UsbSerialPort
+import com.hoho.android.usbserial.driver.UsbSerialProber
+import com.hoho.android.usbserial.util.SerialInputOutputManager
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.atomic.AtomicBoolean
+import timber.log.Timber
+
+/**
+ * Manages an external USB GNSS receiver (typically a u-blox F9P).
+ *
+ * - Opens the USB-serial port.
+ * - Sends UBX-CFG-RATE to push it to 10 Hz.
+ * - Parses NMEA GGA and forwards fixes via [onFix].
+ * - Optionally connects to an NTRIP caster and injects RTCM corrections.
+ */
+class ExternalGnssManager(
+ private val context: Context,
+ private val onFix: (lat: Double, lon: Double, alt: Double, accM: Double) -> Unit,
+ private val onStatus: (String) -> Unit = {},
+ private val onActiveChanged: (Boolean) -> Unit = {},
+ @Volatile private var ntripConfig: NtripConfig? = null,
+) {
+ private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
+
+ private var deviceConnection: UsbDeviceConnection? = null
+ private var serialPort: UsbSerialPort? = null
+ private var ioManager: SerialInputOutputManager? = null
+ private var ioFuture: Future<*>? = null
+
+ private val executor = Executors.newSingleThreadExecutor { r ->
+ Thread(r, "external-gnss").apply { isDaemon = true }
+ }
+ private val started = AtomicBoolean(false)
+
+ private var ntripClient: NtripClient? = null
+ private val serialWriteLock = Any()
+ private val nmeaBuffer = StringBuilder()
+
+ val isRunning: Boolean
+ get() = started.get()
+
+ val isNtripConnected: Boolean
+ get() = ntripClient?.isConnected == true
+
+ @Volatile var lastHdop: Double = -1.0
+ private set
+
+ val rtcmBytesReceived: Long
+ get() = ntripClient?.totalBytesReceived?.get() ?: 0L
+
+ fun start(preferredDevice: UsbDevice? = null) {
+ if (!started.compareAndSet(false, true)) {
+ Timber.w("External GNSS already started")
+ return
+ }
+ try {
+ val driver = findPreferredDriver(preferredDevice)
+ if (driver == null) {
+ onStatus("No USB-serial device found")
+ started.set(false)
+ onActiveChanged(false)
+ return
+ }
+ val device = driver.device
+ if (!usbManager.hasPermission(device)) {
+ onStatus("No USB permission for ${device.deviceName}")
+ started.set(false)
+ onActiveChanged(false)
+ return
+ }
+ val connection = usbManager.openDevice(device)
+ if (connection == null) {
+ onStatus("Cannot open USB device")
+ started.set(false)
+ onActiveChanged(false)
+ return
+ }
+ val port = driver.ports.firstOrNull()
+ if (port == null) {
+ connection.close()
+ onStatus("USB driver exposes no ports")
+ started.set(false)
+ onActiveChanged(false)
+ return
+ }
+
+ val baud = ntripConfig?.serialBaud ?: 115200
+ port.open(connection)
+ port.setParameters(
+ baud,
+ UsbSerialPort.DATABITS_8,
+ UsbSerialPort.STOPBITS_1,
+ UsbSerialPort.PARITY_NONE,
+ )
+ runCatching { port.dtr = true }
+ runCatching { port.rts = true }
+
+ deviceConnection = connection
+ serialPort = port
+
+ runCatching { UbxConfigSender.configureF9PFor10Hz(port) }
+
+ val listener =
+ object : SerialInputOutputManager.Listener {
+ override fun onNewData(data: ByteArray) {
+ handleIncoming(data)
+ }
+
+ override fun onRunError(e: Exception) {
+ Timber.w(e, "External GNSS IO error")
+ onStatus("External GNSS IO error: ${e.message}")
+ stop()
+ }
+ }
+ val manager = SerialInputOutputManager(port, listener)
+ ioManager = manager
+ ioFuture = executor.submit(manager)
+
+ onStatus("External GNSS started on ${device.deviceName}")
+ onActiveChanged(true)
+
+ ntripConfig?.let { startNtripIfConfigured(port, it) }
+ } catch (t: Throwable) {
+ Timber.e(t, "Unexpected error starting external GNSS")
+ onStatus("External GNSS error: ${t.message}")
+ started.set(false)
+ safeCloseAll()
+ onActiveChanged(false)
+ }
+ }
+
+ fun stop() {
+ if (!started.compareAndSet(true, false)) return
+ onActiveChanged(false)
+ onStatus("External GNSS stopped")
+ stopNtrip()
+ runCatching { ioManager?.stop() }
+ ioManager = null
+ runCatching { ioFuture?.cancel(true) }
+ ioFuture = null
+ safeCloseAll()
+ }
+
+ fun updateNtripConfig(config: NtripConfig?) {
+ ntripConfig = config
+ stopNtrip()
+ val port = serialPort
+ if (config != null && config.enabled && config.isReady && port != null && started.get()) {
+ startNtripWithConfig(config, port)
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // NMEA reader
+ // ---------------------------------------------------------------------------
+
+ @Synchronized
+ private fun handleIncoming(data: ByteArray) {
+ for (b in data) {
+ val c = (b.toInt() and 0xFF).toChar()
+ if (c == '\n') {
+ val raw = nmeaBuffer.toString().trimEnd('\r')
+ nmeaBuffer.setLength(0)
+ if (raw.isNotEmpty()) handleNmeaLine(raw)
+ } else if (c != '\u0000') {
+ nmeaBuffer.append(c)
+ if (nmeaBuffer.length > 512) nmeaBuffer.setLength(0)
+ }
+ }
+ }
+
+ private fun handleNmeaLine(line: String) {
+ if (!line.startsWith('$')) return
+ if (!NmeaChecksum.ok(line)) return
+ val starIdx = line.indexOf('*')
+ val noChecksum = if (starIdx > 0) line.substring(0, starIdx) else line
+ val parts = noChecksum.split(',')
+ if (parts.isEmpty()) return
+ if (parts[0].endsWith("GGA", ignoreCase = true)) {
+ parseGga(parts)
+ ntripClient?.sendGga(line)
+ }
+ }
+
+ private fun parseGga(fields: List) {
+ if (fields.size < 10) return
+ val latStr = fields[2]
+ val ns = fields[3]
+ val lonStr = fields[4]
+ val ew = fields[5]
+ val fixQuality = fields[6]
+ val hdopStr = fields[8]
+ val altMslStr = fields[9]
+ val geoidSepStr = fields.getOrNull(11)
+
+ if (fixQuality.isEmpty() || fixQuality == "0") return
+ if (latStr.isBlank() || lonStr.isBlank()) return
+ val lat = nmeaCoordToDeg(latStr, ns)
+ val lon = nmeaCoordToDeg(lonStr, ew)
+ if (!lat.isFinite() || !lon.isFinite()) return
+ val altMsl = altMslStr.toDoubleOrNull() ?: 0.0
+ val geoidSep = geoidSepStr?.toDoubleOrNull() ?: 0.0
+ val alt = altMsl + geoidSep
+ val hdop = hdopStr.toDoubleOrNull() ?: -1.0
+ val accM = if (hdop > 0.0) hdop * 1.5 else -1.0
+ if (hdop > 0.0) lastHdop = hdop
+ onFix(lat, lon, alt, accM)
+ }
+
+ private fun nmeaCoordToDeg(coord: String, hemi: String): Double {
+ if (coord.length < 4) return Double.NaN
+ val dot = coord.indexOf('.')
+ val degLen = if (dot > 0) dot - 2 else coord.length - 2
+ if (degLen <= 0) return Double.NaN
+ val deg = coord.substring(0, degLen).toDoubleOrNull() ?: return Double.NaN
+ val min = coord.substring(degLen).toDoubleOrNull() ?: return Double.NaN
+ var valDeg = deg + (min / 60.0)
+ when (hemi.uppercase()) {
+ "S", "W" -> valDeg = -valDeg
+ }
+ return valDeg
+ }
+
+ // ---------------------------------------------------------------------------
+ // USB helpers
+ // ---------------------------------------------------------------------------
+
+ private fun findPreferredDriver(preferredDevice: UsbDevice?): UsbSerialDriver? {
+ val prober = UsbSerialProber.getDefaultProber()
+ if (preferredDevice != null) {
+ val drv = prober.probeDevice(preferredDevice)
+ if (drv != null) return drv
+ }
+ val drivers = prober.findAllDrivers(usbManager)
+ if (drivers.isEmpty()) return null
+ val ublox =
+ drivers.firstOrNull { d ->
+ val p = (d.device.productName ?: "").lowercase()
+ val m = runCatching { d.device.manufacturerName ?: "" }.getOrDefault("").lowercase()
+ val s = "$p $m"
+ (d.device.vendorId == UBLOX_VENDOR_ID) ||
+ "u-blox" in s ||
+ "ublox" in s ||
+ "f9p" in s ||
+ "zed" in s
+ }
+ return ublox ?: drivers.first()
+ }
+
+ private fun safeCloseAll() {
+ runCatching { serialPort?.close() }
+ serialPort = null
+ runCatching { deviceConnection?.close() }
+ deviceConnection = null
+ }
+
+ // ---------------------------------------------------------------------------
+ // NTRIP
+ // ---------------------------------------------------------------------------
+
+ private fun startNtripIfConfigured(port: UsbSerialPort, cfg: NtripConfig) {
+ if (!cfg.enabled || !cfg.isReady) {
+ Timber.i("NTRIP not configured; skipping")
+ return
+ }
+ startNtripWithConfig(cfg, port)
+ }
+
+ private fun startNtripWithConfig(cfg: NtripConfig, port: UsbSerialPort) {
+ val client =
+ NtripClient(
+ host = cfg.host,
+ port = cfg.port,
+ mountpoint = cfg.mountpoint,
+ user = cfg.user,
+ password = cfg.password,
+ )
+ client.onRtcmData = { data, len ->
+ try {
+ val chunk = if (len == data.size) data else data.copyOfRange(0, len)
+ synchronized(serialWriteLock) { port.write(chunk, 200) }
+ } catch (e: Exception) {
+ Timber.w(e, "Error writing RTCM to serial")
+ }
+ }
+ client.onStatus = { msg -> onStatus("NTRIP: $msg") }
+ ntripClient = client
+ client.start()
+ onStatus("NTRIP client starting for ${cfg.host}:${cfg.port}/${cfg.mountpoint}")
+ }
+
+ private fun stopNtrip() {
+ ntripClient?.stop()
+ ntripClient = null
+ }
+
+ private companion object {
+ const val UBLOX_VENDOR_ID = 0x1546
+ }
+}
diff --git a/project/app/src/main/java/org/owntracks/android/location/external/NmeaChecksum.kt b/project/app/src/main/java/org/owntracks/android/location/external/NmeaChecksum.kt
new file mode 100644
index 0000000000..125a9f32cb
--- /dev/null
+++ b/project/app/src/main/java/org/owntracks/android/location/external/NmeaChecksum.kt
@@ -0,0 +1,20 @@
+package org.owntracks.android.location.external
+
+/** Simple NMEA 0183 checksum validator (sentences like "$GPGGA,...*5C"). */
+internal object NmeaChecksum {
+ fun ok(sentence: String): Boolean {
+ val asterisk = sentence.indexOf('*')
+ if (asterisk < 0 || asterisk + 3 > sentence.length) return false
+ val data = sentence.substring(1, asterisk)
+ var checksum = 0
+ for (c in data) {
+ checksum = checksum xor c.code
+ }
+ val expected = sentence.substring(asterisk + 1, asterisk + 3)
+ return try {
+ checksum == expected.toInt(16)
+ } catch (_: NumberFormatException) {
+ false
+ }
+ }
+}
diff --git a/project/app/src/main/java/org/owntracks/android/location/external/NtripClient.kt b/project/app/src/main/java/org/owntracks/android/location/external/NtripClient.kt
new file mode 100644
index 0000000000..32b575e3ba
--- /dev/null
+++ b/project/app/src/main/java/org/owntracks/android/location/external/NtripClient.kt
@@ -0,0 +1,171 @@
+package org.owntracks.android.location.external
+
+import android.util.Base64
+import java.io.BufferedInputStream
+import java.io.OutputStream
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
+import timber.log.Timber
+
+/**
+ * NTRIP v1 client. Connects to a caster, authenticates with Basic auth, then streams RTCM3
+ * corrections to [onRtcmData]. Send the rover's latest GGA via [sendGga] (required for VRS).
+ */
+internal class NtripClient(
+ private val host: String,
+ private val port: Int,
+ private val mountpoint: String,
+ private val user: String,
+ private val password: String,
+) {
+ private companion object {
+ const val CONNECT_TIMEOUT_MS = 8_000
+ const val READ_TIMEOUT_MS = 15_000
+ const val RECONNECT_BASE_MS = 2_000L
+ const val RECONNECT_MAX_MS = 30_000L
+ const val READ_BUF_SIZE = 4096
+ const val GGA_INTERVAL_MS = 10_000L
+ }
+
+ var onRtcmData: ((ByteArray, Int) -> Unit)? = null
+ var onStatus: ((String) -> Unit)? = null
+
+ val totalBytesReceived = AtomicLong(0L)
+
+ private val running = AtomicBoolean(false)
+ private var workerThread: Thread? = null
+ private var currentSocket: Socket? = null
+
+ @Volatile private var lastGga: String? = null
+
+ val isConnected: Boolean
+ get() = running.get() && currentSocket?.isConnected == true
+
+ fun sendGga(gga: String) {
+ lastGga = gga
+ }
+
+ fun start() {
+ if (!running.compareAndSet(false, true)) return
+ workerThread =
+ Thread(::connectLoop, "ntrip-client").apply {
+ isDaemon = true
+ start()
+ }
+ }
+
+ fun stop() {
+ if (!running.compareAndSet(true, false)) return
+ closeSocket()
+ workerThread?.interrupt()
+ workerThread = null
+ onStatus?.invoke("NTRIP stopped")
+ }
+
+ private fun connectLoop() {
+ var backoff = RECONNECT_BASE_MS
+ while (running.get()) {
+ try {
+ onStatus?.invoke("Connecting to $host:$port/$mountpoint")
+ doSession()
+ backoff = RECONNECT_BASE_MS
+ } catch (_: InterruptedException) {
+ return
+ } catch (e: Exception) {
+ if (!running.get()) return
+ Timber.w(e, "NTRIP session error")
+ onStatus?.invoke("NTRIP error: ${e.message}")
+ }
+ if (!running.get()) return
+ onStatus?.invoke("Reconnecting in ${backoff / 1000}s")
+ try {
+ Thread.sleep(backoff)
+ } catch (_: InterruptedException) {
+ return
+ }
+ backoff = (backoff * 2).coerceAtMost(RECONNECT_MAX_MS)
+ }
+ }
+
+ private fun doSession() {
+ val socket = Socket()
+ currentSocket = socket
+ try {
+ socket.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS)
+ socket.soTimeout = READ_TIMEOUT_MS
+
+ val out = socket.getOutputStream()
+ val auth = Base64.encodeToString("$user:$password".toByteArray(), Base64.NO_WRAP)
+ val request = buildString {
+ append("GET /$mountpoint HTTP/1.0\r\n")
+ append("User-Agent: NTRIP OwnTracks-ExternalGNSS/1.0\r\n")
+ append("Authorization: Basic $auth\r\n")
+ append("Accept: */*\r\n")
+ append("\r\n")
+ }
+ out.write(request.toByteArray(Charsets.US_ASCII))
+ out.flush()
+
+ val inp = BufferedInputStream(socket.getInputStream(), READ_BUF_SIZE)
+ val header = readHttpHeader(inp)
+ if (!header.startsWith("ICY 200 OK") && !header.contains("200 OK")) {
+ val firstLine = header.lineSequence().firstOrNull() ?: header
+ onStatus?.invoke("NTRIP rejected: $firstLine")
+ return
+ }
+ onStatus?.invoke("NTRIP connected")
+
+ lastGga?.let { sendGgaToServer(out, it) }
+
+ val buf = ByteArray(READ_BUF_SIZE)
+ var lastGgaSentMs = System.currentTimeMillis()
+ while (running.get()) {
+ val n = inp.read(buf)
+ if (n < 0) {
+ onStatus?.invoke("NTRIP stream ended")
+ break
+ }
+ if (n > 0) {
+ totalBytesReceived.addAndGet(n.toLong())
+ onRtcmData?.invoke(buf, n)
+ }
+ val now = System.currentTimeMillis()
+ if (now - lastGgaSentMs >= GGA_INTERVAL_MS) {
+ lastGga?.let { sendGgaToServer(out, it) }
+ lastGgaSentMs = now
+ }
+ }
+ } finally {
+ closeSocket()
+ }
+ }
+
+ private fun sendGgaToServer(out: OutputStream, gga: String) {
+ try {
+ val line = if (gga.endsWith("\r\n")) gga else "$gga\r\n"
+ out.write(line.toByteArray(Charsets.US_ASCII))
+ out.flush()
+ } catch (e: Exception) {
+ Timber.v(e, "sendGga failed")
+ }
+ }
+
+ private fun readHttpHeader(inp: BufferedInputStream): String {
+ val sb = StringBuilder()
+ val maxHeaderBytes = 4096
+ while (sb.length < maxHeaderBytes) {
+ val b = inp.read()
+ if (b < 0) break
+ sb.append(b.toChar())
+ if (sb.length >= 4 && sb.substring(sb.length - 4) == "\r\n\r\n") break
+ }
+ return sb.toString()
+ }
+
+ private fun closeSocket() {
+ runCatching { currentSocket?.close() }
+ currentSocket = null
+ }
+}
diff --git a/project/app/src/main/java/org/owntracks/android/location/external/NtripConfig.kt b/project/app/src/main/java/org/owntracks/android/location/external/NtripConfig.kt
new file mode 100644
index 0000000000..f4436b5d19
--- /dev/null
+++ b/project/app/src/main/java/org/owntracks/android/location/external/NtripConfig.kt
@@ -0,0 +1,61 @@
+package org.owntracks.android.location.external
+
+import android.content.Context
+import android.content.SharedPreferences
+
+/** NTRIP caster parameters persisted via [SharedPreferences]. */
+data class NtripConfig(
+ val enabled: Boolean = false,
+ val host: String = "",
+ val port: Int = 2101,
+ val mountpoint: String = "",
+ val user: String = "",
+ val password: String = "",
+ val serialBaud: Int = 115200,
+) {
+ val isReady: Boolean
+ get() = host.isNotBlank() && mountpoint.isNotBlank()
+
+ companion object {
+ const val PREFS_NAME = "external_gnss_prefs"
+ const val KEY_EXTERNAL_ENABLED = "external_gnss_enabled"
+ const val KEY_NTRIP_ENABLED = "ntrip_enabled"
+ const val KEY_HOST = "ntrip_host"
+ const val KEY_PORT = "ntrip_port"
+ const val KEY_MOUNTPOINT = "ntrip_mountpoint"
+ const val KEY_USER = "ntrip_user"
+ const val KEY_PASSWORD = "ntrip_password"
+ const val KEY_SERIAL_BAUD = "serial_baud"
+
+ fun load(context: Context): NtripConfig {
+ val p = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ return NtripConfig(
+ enabled = p.getBoolean(KEY_NTRIP_ENABLED, false),
+ host = p.getString(KEY_HOST, "") ?: "",
+ port = p.readIntCompat(KEY_PORT, 2101),
+ mountpoint = p.getString(KEY_MOUNTPOINT, "") ?: "",
+ user = p.getString(KEY_USER, "") ?: "",
+ password = p.getString(KEY_PASSWORD, "") ?: "",
+ serialBaud = p.readIntCompat(KEY_SERIAL_BAUD, 115200),
+ )
+ }
+
+ /**
+ * EditTextPreference always stores values as [String], so reading them with
+ * [SharedPreferences.getInt] throws [ClassCastException]. This helper tolerates either type.
+ */
+ private fun SharedPreferences.readIntCompat(key: String, defaultValue: Int): Int {
+ if (!contains(key)) return defaultValue
+ return try {
+ getInt(key, defaultValue)
+ } catch (_: ClassCastException) {
+ getString(key, null)?.trim()?.toIntOrNull() ?: defaultValue
+ }
+ }
+
+ fun isExternalGnssEnabled(context: Context): Boolean =
+ context
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .getBoolean(KEY_EXTERNAL_ENABLED, false)
+ }
+}
diff --git a/project/app/src/main/java/org/owntracks/android/location/external/UbxConfigSender.kt b/project/app/src/main/java/org/owntracks/android/location/external/UbxConfigSender.kt
new file mode 100644
index 0000000000..60c413fa5d
--- /dev/null
+++ b/project/app/src/main/java/org/owntracks/android/location/external/UbxConfigSender.kt
@@ -0,0 +1,83 @@
+package org.owntracks.android.location.external
+
+import com.hoho.android.usbserial.driver.UsbSerialPort
+import timber.log.Timber
+
+/**
+ * Minimal u-blox UBX frame builder used to put an F9P (or similar) into 10 Hz mode and make sure
+ * NMEA GGA is enabled on every port.
+ */
+internal object UbxConfigSender {
+ private const val CLASS_CFG = 0x06
+ private const val ID_CFG_RATE = 0x08
+ private const val ID_CFG_MSG = 0x01
+
+ fun configureF9PFor10Hz(port: UsbSerialPort) {
+ try {
+ runCatching { port.purgeHwBuffers(true, true) }
+
+ // CFG-RATE: measRate=100 ms (10 Hz), navRate=1, timeRef=UTC
+ val measRateMs = 100
+ val navRate = 1
+ val timeRef = 0
+ val payloadRate =
+ byteArrayOf(
+ (measRateMs and 0xFF).toByte(),
+ ((measRateMs shr 8) and 0xFF).toByte(),
+ (navRate and 0xFF).toByte(),
+ ((navRate shr 8) and 0xFF).toByte(),
+ (timeRef and 0xFF).toByte(),
+ ((timeRef shr 8) and 0xFF).toByte(),
+ )
+ val frameRate = buildUbxFrame(CLASS_CFG, ID_CFG_RATE, payloadRate)
+ port.write(frameRate, 500)
+ drain(port, "CFG-RATE")
+
+ // CFG-MSG for NMEA GGA (class 0xF0, id 0x00) on every port at rate 1
+ val payloadMsg =
+ byteArrayOf(
+ 0xF0.toByte(),
+ 0x00.toByte(),
+ 1.toByte(),
+ 1.toByte(),
+ 1.toByte(),
+ 1.toByte(),
+ 1.toByte(),
+ 1.toByte(),
+ )
+ val frameMsg = buildUbxFrame(CLASS_CFG, ID_CFG_MSG, payloadMsg)
+ port.write(frameMsg, 500)
+ drain(port, "CFG-MSG GGA")
+ } catch (e: Exception) {
+ Timber.w(e, "Error sending UBX config to F9P")
+ throw e
+ }
+ }
+
+ private fun buildUbxFrame(classId: Int, msgId: Int, payload: ByteArray): ByteArray {
+ val len = payload.size
+ val frame = ByteArray(6 + len + 2)
+ frame[0] = 0xB5.toByte()
+ frame[1] = 0x62.toByte()
+ frame[2] = classId.toByte()
+ frame[3] = msgId.toByte()
+ frame[4] = (len and 0xFF).toByte()
+ frame[5] = ((len shr 8) and 0xFF).toByte()
+ System.arraycopy(payload, 0, frame, 6, len)
+ var ckA = 0
+ var ckB = 0
+ for (i in 2 until 6 + len) {
+ val b = frame[i].toInt() and 0xFF
+ ckA = (ckA + b) and 0xFF
+ ckB = (ckB + ckA) and 0xFF
+ }
+ frame[6 + len] = ckA.toByte()
+ frame[7 + len] = ckB.toByte()
+ return frame
+ }
+
+ private fun drain(port: UsbSerialPort, label: String) {
+ val buf = ByteArray(128)
+ runCatching { port.read(buf, 200) }.onFailure { Timber.v(it, "drain %s failed", label) }
+ }
+}
diff --git a/project/app/src/main/java/org/owntracks/android/services/LocationProcessor.kt b/project/app/src/main/java/org/owntracks/android/services/LocationProcessor.kt
index c0b4584fbf..fb7fd023ec 100644
--- a/project/app/src/main/java/org/owntracks/android/services/LocationProcessor.kt
+++ b/project/app/src/main/java/org/owntracks/android/services/LocationProcessor.kt
@@ -64,7 +64,7 @@ constructor(
suspend fun publishLocationMessage(trigger: MessageLocation.ReportType) =
locationRepo.currentPublishedLocation.value?.run { publishLocationMessage(trigger, this) }
- private val highAccuracyProviders = setOf("gps", "fused")
+ private val highAccuracyProviders = setOf("gps", "fused", "external_gnss")
private suspend fun publishLocationMessage(
trigger: MessageLocation.ReportType,
diff --git a/project/app/src/main/java/org/owntracks/android/ui/map/MapActivity.kt b/project/app/src/main/java/org/owntracks/android/ui/map/MapActivity.kt
index 769a2f98fb..09f8598560 100644
--- a/project/app/src/main/java/org/owntracks/android/ui/map/MapActivity.kt
+++ b/project/app/src/main/java/org/owntracks/android/ui/map/MapActivity.kt
@@ -140,6 +140,10 @@ class MapActivity :
@Inject lateinit var drawerProvider: DrawerProvider
+ @Inject
+ lateinit var externalGnssController:
+ org.owntracks.android.location.external.ExternalGnssController
+
private val serviceConnection =
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
@@ -710,6 +714,11 @@ class MapActivity :
if (checkAndRequestLocationServicesEnabled(false)) {
viewModel.requestLocationUpdatesForBlueDot()
}
+ if (intent?.action == android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED) {
+ externalGnssController.handleUsbAttachIntent(intent)
+ } else if (externalGnssController.isExternalGnssEnabled) {
+ externalGnssController.tryStartFromAttachedDevices()
+ }
}
private fun handleIntentExtras(intent: Intent) {
@@ -728,6 +737,9 @@ class MapActivity :
super.onNewIntent(intent)
service?.clearEventStackNotification()
handleIntentExtras(intent)
+ if (intent.action == android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED) {
+ externalGnssController.handleUsbAttachIntent(intent)
+ }
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
diff --git a/project/app/src/main/java/org/owntracks/android/ui/preferences/ExternalGnssFragment.kt b/project/app/src/main/java/org/owntracks/android/ui/preferences/ExternalGnssFragment.kt
new file mode 100644
index 0000000000..3df79bd4c2
--- /dev/null
+++ b/project/app/src/main/java/org/owntracks/android/ui/preferences/ExternalGnssFragment.kt
@@ -0,0 +1,38 @@
+package org.owntracks.android.ui.preferences
+
+import android.os.Bundle
+import androidx.preference.EditTextPreference
+import androidx.preference.Preference
+import androidx.preference.PreferenceFragmentCompat
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+import org.owntracks.android.R
+import org.owntracks.android.location.external.ExternalGnssController
+import org.owntracks.android.location.external.NtripConfig
+
+/**
+ * Dedicated preference screen to enable the external USB GNSS receiver and configure an NTRIP
+ * caster. Uses its own SharedPreferences file so it does not collide with OwnTracks' own keyed
+ * preference store.
+ */
+@AndroidEntryPoint
+class ExternalGnssFragment : PreferenceFragmentCompat() {
+
+ @Inject lateinit var externalGnssController: ExternalGnssController
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ preferenceManager.sharedPreferencesName = NtripConfig.PREFS_NAME
+ setPreferencesFromResource(R.xml.preferences_external_gnss, rootKey)
+
+ findPreference(NtripConfig.KEY_PORT)?.setOnPreferenceChangeListener {
+ _,
+ newValue ->
+ (newValue as? String)?.toIntOrNull() != null || newValue == ""
+ }
+
+ findPreference("external_gnss_connect")?.setOnPreferenceClickListener {
+ externalGnssController.tryStartFromAttachedDevices()
+ true
+ }
+ }
+}
diff --git a/project/app/src/main/res/values/strings.xml b/project/app/src/main/res/values/strings.xml
index 556353a971..defcdae557 100644
--- a/project/app/src/main/res/values/strings.xml
+++ b/project/app/src/main/res/values/strings.xml
@@ -140,6 +140,21 @@ To allow this, please enable Location in the device settings."
"Google Play Services are now available"
"Google Play Services update is required"
"Advanced"
+ "External GNSS"
+ "USB receiver"
+ "Use external GNSS"
+ "Publish positions coming from a USB GNSS receiver (e.g. u-blox F9P) instead of the phone\'s built-in GPS."
+ "Connect to attached device"
+ "Request USB permission and open the currently attached receiver."
+ "NTRIP caster (RTK)"
+ "Enable NTRIP"
+ "Forward RTCM corrections from an NTRIP caster to the external GNSS receiver."
+ "Caster host"
+ "Caster port"
+ "Mountpoint"
+ "Username"
+ "Password"
+ "Password for the NTRIP mountpoint."
"This version of Android restricts OwnTracks from receiving locations when automatically started on device boot until the app is opened."
OpenCage Privacy Policy
To use the OpenCage geocoder, OwnTracks will send every location it processes to OpenCage over a TLS-encrypted connection.\n\nThe OpenCage privacy policy can be found on their website.
diff --git a/project/app/src/main/res/xml/preferences_external_gnss.xml b/project/app/src/main/res/xml/preferences_external_gnss.xml
new file mode 100644
index 0000000000..d77274c07d
--- /dev/null
+++ b/project/app/src/main/res/xml/preferences_external_gnss.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/project/app/src/main/res/xml/preferences_root.xml b/project/app/src/main/res/xml/preferences_root.xml
index 902e2d5fdb..e2617d8a63 100644
--- a/project/app/src/main/res/xml/preferences_root.xml
+++ b/project/app/src/main/res/xml/preferences_root.xml
@@ -26,6 +26,11 @@
android:title="@string/preferencesReporting"
app:fragment="org.owntracks.android.ui.preferences.ReportingFragment"
app:icon="@drawable/ic_baseline_send_24" />
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/project/app/src/oss/java/org/owntracks/android/di/LocationProviderClientModule.kt b/project/app/src/oss/java/org/owntracks/android/di/LocationProviderClientModule.kt
index 94e392c5e9..358811d479 100644
--- a/project/app/src/oss/java/org/owntracks/android/di/LocationProviderClientModule.kt
+++ b/project/app/src/oss/java/org/owntracks/android/di/LocationProviderClientModule.kt
@@ -8,7 +8,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.owntracks.android.location.AospLocationProviderClient
+import org.owntracks.android.location.ExternalGnssLocationProviderClient
import org.owntracks.android.location.LocationProviderClient
+import org.owntracks.android.location.external.ExternalGnssController
@InstallIn(SingletonComponent::class)
@Module
@@ -16,6 +18,11 @@ class LocationProviderClientModule {
@Provides
@Singleton
fun getLocationProviderClient(
- @ApplicationContext applicationContext: Context
- ): LocationProviderClient = AospLocationProviderClient(applicationContext)
+ @ApplicationContext applicationContext: Context,
+ externalGnssController: ExternalGnssController,
+ ): LocationProviderClient =
+ ExternalGnssLocationProviderClient(
+ delegate = AospLocationProviderClient(applicationContext),
+ controller = externalGnssController,
+ )
}