From d285c89a3854e3a34706a9895e1f0f72f5704d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 18:11:17 -0600 Subject: [PATCH] feat(location): integrate LocationProvider for improved location access and fallback feat(nan): implement Wi-Fi Aware integration in GuacamayaForegroundService test(aware): add AwareConfigTest to validate frame packing functionality docs: update project status and documentation for Wi-Fi Aware and location features --- .../kotlin/net/guacamaya/aware/AwareConfig.kt | 23 ++- .../net/guacamaya/aware/NanMessenger.kt | 110 +++++++----- .../net/guacamaya/ble/BleMeshRuntime.kt | 23 ++- .../guacamaya/location/LocationProvider.kt | 168 ++++++++++++++++++ .../service/GuacamayaForegroundService.kt | 97 ++++++---- .../kotlin/net/guacamaya/ui/MainActivity.kt | 39 +++- .../net/guacamaya/aware/AwareConfigTest.kt | 25 +++ docs/GuacaMallaProject/Estado y Pendientes.md | 9 +- .../GuacaMallaProject/GuacaMalla (Android).md | 8 +- 9 files changed, 400 insertions(+), 102 deletions(-) create mode 100644 android/app/src/main/kotlin/net/guacamaya/location/LocationProvider.kt create mode 100644 android/app/src/test/kotlin/net/guacamaya/aware/AwareConfigTest.kt diff --git a/android/app/src/main/kotlin/net/guacamaya/aware/AwareConfig.kt b/android/app/src/main/kotlin/net/guacamaya/aware/AwareConfig.kt index 4dc9ca8..cf30ab2 100644 --- a/android/app/src/main/kotlin/net/guacamaya/aware/AwareConfig.kt +++ b/android/app/src/main/kotlin/net/guacamaya/aware/AwareConfig.kt @@ -1,21 +1,38 @@ package net.guacamaya.aware +import net.guacamaya.ble.BleConfig + /** * Constants for Wi-Fi Aware (NAN). Service name is the wire prefix subscribers * match on; GuacaMalla uses a stable prefix so any nearby node can subscribe to * the whole family via "guacamalla::*" semantics (Android matches on exact prefix). * * SSI (Service Specific Info) carries up to 255 B at the MAC layer — no IP, no - * auth, no user-facing pairing. See docs/protocol-flows.md Flow 4. + * auth, no user-facing pairing. GuacaMalla publishes the same 119 B frame used + * by BLE service-data, including the mutable hop-TTL byte, so the normal + * FloodRouter can verify/persist/relay frames from either radio plane. + * See docs/protocol-flows.md Flow 4. */ object AwareConfig { /** NAN service name prefix. */ const val SERVICE_NAME = "net.guacamaya.v1" - /** Service Specific Info payload — same 118 B layout as the BLE frame. */ - const val SSI_SIZE = 22 + 32 + 64 + /** Service Specific Info payload — same 119 B layout as BLE service-data. */ + const val SSI_SIZE = BleConfig.SERVICE_DATA_SIZE /** Max bytes the NAN SSI field can carry per IEEE 802.11v. */ const val SSI_MAX = 255 + + fun packFrame(ttl: Int, payload22: ByteArray, pub32: ByteArray, sig64: ByteArray): ByteArray { + require(payload22.size == 22) { "payload must be 22 B" } + require(pub32.size == 32) { "pubkey must be 32 B" } + require(sig64.size == 64) { "signature must be 64 B" } + return ByteArray(SSI_SIZE).also { + it[BleConfig.TTL_OFFSET] = ttl.coerceIn(0, 255).toByte() + payload22.copyInto(it, BleConfig.PAYLOAD_OFFSET) + pub32.copyInto(it, BleConfig.PUBKEY_OFFSET) + sig64.copyInto(it, BleConfig.SIG_OFFSET) + } + } } diff --git a/android/app/src/main/kotlin/net/guacamaya/aware/NanMessenger.kt b/android/app/src/main/kotlin/net/guacamaya/aware/NanMessenger.kt index 52ec7f2..c2f6149 100644 --- a/android/app/src/main/kotlin/net/guacamaya/aware/NanMessenger.kt +++ b/android/app/src/main/kotlin/net/guacamaya/aware/NanMessenger.kt @@ -14,9 +14,10 @@ import android.os.Handler import android.os.Looper import android.util.Log import java.util.concurrent.atomic.AtomicReference +import net.guacamaya.ble.BleConfig /** - * Wi-Fi Aware messenger for payloads up to 255 B (BLE frame layout, 118 B). + * Wi-Fi Aware messenger for payloads up to 255 B (BLE frame layout, 119 B). * * Two roles: * - [publish] — radiate the SSI as part of the NAN service discovery frame. @@ -39,7 +40,7 @@ class NanMessenger private constructor( private val subscribeRef = AtomicReference(null) fun interface Listener { - fun onFrame(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, peer: PeerHandle) + fun onFrame(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, ttl: Int, peer: PeerHandle) } private var listener: Listener? = null @@ -59,18 +60,26 @@ class NanMessenger private constructor( onFailed(-1) return } - manager.attach(object : AttachCallback() { - override fun onAttached(session: WifiAwareSession?) { - sessionRef.set(session) - Log.i(tag, "Aware attached") - onAttached() - } - - override fun onAttachFailed() { - Log.e(tag, "Aware attach failed") - onFailed(-2) - } - }, handler) + try { + manager.attach(object : AttachCallback() { + override fun onAttached(session: WifiAwareSession?) { + sessionRef.set(session) + Log.i(tag, "Aware attached") + onAttached() + } + + override fun onAttachFailed() { + Log.e(tag, "Aware attach failed") + onFailed(-2) + } + }, handler) + } catch (se: SecurityException) { + Log.w(tag, "Aware attach denied — NEARBY_WIFI_DEVICES? ${se.message}") + onFailed(-3) + } catch (t: Throwable) { + Log.w(tag, "Aware attach unavailable: ${t.message}") + onFailed(-4) + } } /** @@ -101,16 +110,22 @@ class NanMessenger private constructor( return } - session.publish(config, object : DiscoverySessionCallback() { - override fun onPublishStarted(session: PublishDiscoverySession) { - publishRef.set(session) - Log.i(tag, "publish started") - } + try { + session.publish(config, object : DiscoverySessionCallback() { + override fun onPublishStarted(session: PublishDiscoverySession) { + publishRef.set(session) + Log.i(tag, "publish started") + } - override fun onMessageReceived(peerHandle: PeerHandle, message: ByteArray) { - Log.d(tag, "msg from peer=${peerHandle.hashCode()} size=${message.size}") - } - }, handler) + override fun onMessageReceived(peerHandle: PeerHandle, message: ByteArray) { + Log.d(tag, "msg from peer=${peerHandle.hashCode()} size=${message.size}") + } + }, handler) + } catch (se: SecurityException) { + Log.w(tag, "publish denied — NEARBY_WIFI_DEVICES? ${se.message}") + } catch (t: Throwable) { + Log.w(tag, "publish failed: ${t.message}") + } } fun stopPublish() { @@ -129,28 +144,35 @@ class NanMessenger private constructor( val config = SubscribeConfig.Builder() .setServiceName(AwareConfig.SERVICE_NAME) .build() - session.subscribe(config, object : DiscoverySessionCallback() { - override fun onSubscribeStarted(session: SubscribeDiscoverySession) { - subscribeRef.set(session) - Log.i(tag, "subscribe started") - } - - override fun onServiceDiscovered( - peerHandle: PeerHandle, - serviceSpecificInfo: ByteArray, - matchFilter: MutableList, - ) { - val ssi = serviceSpecificInfo - if (ssi.size != AwareConfig.SSI_SIZE) { - Log.w(tag, "discovered but malformed ssi size=${ssi.size}") - return + try { + session.subscribe(config, object : DiscoverySessionCallback() { + override fun onSubscribeStarted(session: SubscribeDiscoverySession) { + subscribeRef.set(session) + Log.i(tag, "subscribe started") } - val p22 = ssi.copyOfRange(0, 22) - val pub32 = ssi.copyOfRange(22, 22 + 32) - val sig64 = ssi.copyOfRange(22 + 32, 22 + 32 + 64) - listener?.onFrame(p22, pub32, sig64, peerHandle) - } - }, handler) + + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: MutableList, + ) { + val ssi = serviceSpecificInfo + if (ssi.size != AwareConfig.SSI_SIZE) { + Log.w(tag, "discovered but malformed ssi size=${ssi.size}") + return + } + val ttl = ssi[BleConfig.TTL_OFFSET].toInt() and 0xFF + val p22 = ssi.copyOfRange(BleConfig.PAYLOAD_OFFSET, BleConfig.PUBKEY_OFFSET) + val pub32 = ssi.copyOfRange(BleConfig.PUBKEY_OFFSET, BleConfig.SIG_OFFSET) + val sig64 = ssi.copyOfRange(BleConfig.SIG_OFFSET, AwareConfig.SSI_SIZE) + listener?.onFrame(p22, pub32, sig64, ttl, peerHandle) + } + }, handler) + } catch (se: SecurityException) { + Log.w(tag, "subscribe denied — NEARBY_WIFI_DEVICES? ${se.message}") + } catch (t: Throwable) { + Log.w(tag, "subscribe failed: ${t.message}") + } } fun stopSubscribe() { diff --git a/android/app/src/main/kotlin/net/guacamaya/ble/BleMeshRuntime.kt b/android/app/src/main/kotlin/net/guacamaya/ble/BleMeshRuntime.kt index 9ce6ae6..16b9b0e 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ble/BleMeshRuntime.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ble/BleMeshRuntime.kt @@ -21,20 +21,17 @@ object BleMeshRuntime { private var router: FloodRouter? = null fun ensureObserving(ctx: Context): Boolean { + ensureRouter(ctx.applicationContext) val app = ctx.applicationContext - if (observer == null || router == null) { + if (observer == null) { val obs = Observer.create(app) ?: run { Log.i(PROBE, "observe fail Observer.create=null") return false } - val dao = GuacamayaDatabase.get(app).messageDao() - val bcast = Broadcaster.create(app) - val r = FloodRouter(dao = dao, broadcaster = bcast, scope = scope) obs.setListener { p22, pub32, sig64, ttl, rssi -> - r.onFrame(p22, pub32, sig64, ttl, rssi) + router?.onFrame(p22, pub32, sig64, ttl, rssi) } observer = obs - router = r } val obs = observer ?: return false if (obs.isScanning) { @@ -46,6 +43,20 @@ object BleMeshRuntime { return obs.isScanning } + fun routeFrame(ctx: Context, payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, ttl: Int, rssi: Int) { + ensureRouter(ctx.applicationContext) + router?.onFrame(payload22, pub32, sig64, ttl, rssi) + } + + private fun ensureRouter(ctx: Context) { + val app = ctx.applicationContext + if (router == null) { + val dao = GuacamayaDatabase.get(app).messageDao() + val bcast = Broadcaster.create(app) + router = FloodRouter(dao = dao, broadcaster = bcast, scope = scope) + } + } + fun stopObserving() { observer?.stop() Log.i(PROBE, "BleMeshRuntime stopped") diff --git a/android/app/src/main/kotlin/net/guacamaya/location/LocationProvider.kt b/android/app/src/main/kotlin/net/guacamaya/location/LocationProvider.kt new file mode 100644 index 0000000..d43174b --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/location/LocationProvider.kt @@ -0,0 +1,168 @@ +package net.guacamaya.location + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import android.os.Looper +import android.util.Log +import androidx.core.content.ContextCompat +import com.google.android.gms.location.LocationServices +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Best-effort location access for rescue payload stamping and radar. + * + * Prefer Google Play Services' fused provider when it exists, but always keep a + * platform LocationManager path so low-end / de-Googled devices can still stamp + * SOS frames with their last known coordinates. + */ +object LocationProvider { + private const val FUSED_TIMEOUT_MS = 1_500L + + fun hasPermission(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + suspend fun lastKnown(context: Context, logTag: String): Location? { + if (!hasPermission(context)) { + Log.w(logTag, "location permission not granted; broadcasting without coordinates") + return null + } + fusedLastKnown(context, logTag)?.let { return it } + return platformLastKnown(context, logTag) + } + + fun toE7(location: Location): Pair { + val latE7 = (location.latitude * 1e7).toInt().coerceIn(-900_000_000, 900_000_000) + val lonE7 = (location.longitude * 1e7).toInt().coerceIn(-1_800_000_000, 1_800_000_000) + return latE7 to lonE7 + } + + @SuppressLint("MissingPermission") + private suspend fun fusedLastKnown(context: Context, logTag: String): Location? = + try { + withTimeoutOrNull(FUSED_TIMEOUT_MS) { + val client = LocationServices.getFusedLocationProviderClient(context) + suspendCancellableCoroutine { cont -> + client.lastLocation + .addOnSuccessListener { cont.resume(it) } + .addOnFailureListener { + Log.w(logTag, "fused lastLocation failed: ${it.message}") + cont.resume(null) + } + } + } + } catch (t: Throwable) { + // Includes missing/old Google Play Services on AOSP-style devices. + Log.w(logTag, "fused location unavailable: ${t.message}") + null + } + + @SuppressLint("MissingPermission") + fun platformLastKnown(context: Context, logTag: String): Location? { + if (!hasPermission(context)) return null + val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager + ?: return null + val providers = preferredProviders(highAccuracy = true).filter { provider -> + try { + manager.isProviderEnabled(provider) + } catch (_: Exception) { + false + } + }.ifEmpty { + try { + manager.getProviders(true) + } catch (_: Exception) { + emptyList() + } + } + val best = providers.mapNotNull { provider -> + try { + manager.getLastKnownLocation(provider) + } catch (se: SecurityException) { + Log.w(logTag, "platform lastKnown denied: ${se.message}") + null + } catch (t: Throwable) { + Log.w(logTag, "platform lastKnown($provider) failed: ${t.message}") + null + } + }.maxWithOrNull(compareBy { it.time }.thenBy { it.accuracy }) + if (best == null) Log.w(logTag, "no platform last known location available") + return best + } + + @SuppressLint("MissingPermission") + fun listenPlatform( + context: Context, + highAccuracy: Boolean, + looper: Looper, + logTag: String, + onFix: (Location) -> Unit, + ): PlatformSubscription? { + if (!hasPermission(context)) return null + val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager + ?: return null + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) = onFix(location) + override fun onProviderEnabled(provider: String) = Unit + override fun onProviderDisabled(provider: String) = Unit + @Deprecated("Deprecated in Android framework") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit + } + val providers = preferredProviders(highAccuracy).filter { provider -> + try { + manager.isProviderEnabled(provider) + } catch (_: Exception) { + false + } + } + if (providers.isEmpty()) { + Log.w(logTag, "no enabled platform location providers") + return null + } + val minTimeMs = if (highAccuracy) 500L else 2_000L + val minDistanceM = if (highAccuracy) 0f else 1f + val registered = providers.mapNotNull { provider -> + try { + manager.requestLocationUpdates(provider, minTimeMs, minDistanceM, listener, looper) + provider + } catch (se: SecurityException) { + Log.w(logTag, "platform updates denied: ${se.message}") + null + } catch (t: Throwable) { + Log.w(logTag, "platform updates($provider) failed: ${t.message}") + null + } + } + return if (registered.isEmpty()) { + null + } else { + PlatformSubscription(manager, listener) + } + } + + private fun preferredProviders(highAccuracy: Boolean): List = + if (highAccuracy) { + listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.PASSIVE_PROVIDER) + } else { + listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.PASSIVE_PROVIDER) + } + + class PlatformSubscription internal constructor( + private val manager: LocationManager, + private val listener: LocationListener, + ) { + fun close() { + manager.removeUpdates(listener) + } + } +} diff --git a/android/app/src/main/kotlin/net/guacamaya/service/GuacamayaForegroundService.kt b/android/app/src/main/kotlin/net/guacamaya/service/GuacamayaForegroundService.kt index e4c3b48..af24c31 100644 --- a/android/app/src/main/kotlin/net/guacamaya/service/GuacamayaForegroundService.kt +++ b/android/app/src/main/kotlin/net/guacamaya/service/GuacamayaForegroundService.kt @@ -1,7 +1,5 @@ package net.guacamaya.service -import android.Manifest -import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -9,27 +7,25 @@ import android.app.Service import android.bluetooth.BluetoothManager import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.content.pm.ServiceInfo -import android.location.Location import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat -import com.google.android.gms.location.LocationServices -import kotlin.coroutines.resume import kotlinx.coroutines.Job -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import net.guacamaya.R +import net.guacamaya.aware.AwareConfig +import net.guacamaya.aware.NanMessenger import net.guacamaya.ble.BleMeshRuntime import net.guacamaya.ble.Broadcaster import net.guacamaya.crypto.Identity import net.guacamaya.ingest.IngestUploadWorker import kotlinx.coroutines.flow.first +import net.guacamaya.location.LocationProvider import net.guacamaya.mesh.GuacamayaDatabase import net.guacamaya.mesh.MessageEntity import net.guacamaya.proto.Flags @@ -64,6 +60,7 @@ class GuacamayaForegroundService : Service() { private var observeHealthJob: Job? = null private var broadcaster: Broadcaster? = null + private var nanMessenger: NanMessenger? = null private var identity: Identity? = null private var broadcastJob: Job? = null @@ -137,6 +134,25 @@ class GuacamayaForegroundService : Service() { if (broadcaster == null) broadcaster = Broadcaster.create(this) } + private fun ensureNanMessenger(): NanMessenger? { + val existing = nanMessenger + if (existing != null) return existing + return NanMessenger.create(this)?.also { nan -> + nan.setListener { p22, pub32, sig64, ttl, peer -> + Log.d(tag, "aware frame peer=${peer.hashCode()} ttl=$ttl") + BleMeshRuntime.routeFrame( + this@GuacamayaForegroundService, + p22, + pub32, + sig64, + ttl, + AWARE_RSSI_SENTINEL, + ) + } + nanMessenger = nan + } + } + private fun startObserving() { restoreWantObserving() if (!wantObserving) { @@ -152,6 +168,14 @@ class GuacamayaForegroundService : Service() { ensureObserverAndRouter() if (!BleMeshRuntime.ensureObserving(this)) scheduleObserveRetry() else ensureObserveHealthLoop() + startAwareSubscribe() + } + + private fun startAwareSubscribe() { + ensureNanMessenger()?.attach( + onAttached = { nanMessenger?.subscribe() }, + onFailed = { code -> Log.w(tag, "aware subscribe unavailable code=$code") }, + ) } private fun ensureObserveHealthLoop() { @@ -202,13 +226,14 @@ class GuacamayaForegroundService : Service() { broadcastJob = scope.launch { val id = identity ?: Identity.loadOrCreate(this@GuacamayaForegroundService).also { identity = it } val bcast = broadcaster ?: Broadcaster.create(this@GuacamayaForegroundService)?.also { broadcaster = it } - if (bcast == null) { - Log.e(tag, "Broadcaster.create failed — extended BLE ADV not supported") + val nan = ensureNanMessenger() + if (bcast == null && nan == null) { + Log.e(tag, "no radio TX available — BLE extended ADV and Wi-Fi Aware unavailable") // Toast needs a Looper thread; this coroutine runs on Dispatchers.Default. withContext(Dispatchers.Main) { android.widget.Toast.makeText( this@GuacamayaForegroundService, - "Este equipo no puede transmitir SOS por BLE", + "Este equipo no puede transmitir SOS por BLE/NAN", android.widget.Toast.LENGTH_LONG, ).show() } @@ -218,7 +243,9 @@ class GuacamayaForegroundService : Service() { // Bootstrap own ADV via start() so the compat→coded PHY negotiation runs once. signedPayload(id, ownType, ownCritical, ownTtl).let { p -> - bcast.start(p, id.publicKey, id.sign(p)) + val sig = id.sign(p) + bcast?.start(p, id.publicKey, sig) + publishAware(p, id.publicKey, sig, ownTtl) } var held: List = emptyList() @@ -228,7 +255,9 @@ class GuacamayaForegroundService : Service() { delay(FORWARD_DWELL_MS) if (ownTurn) { val p = signedPayload(id, ownType, ownCritical, ownTtl) - bcast.swap(p, id.publicKey, id.sign(p), ownTtl) + val sig = id.sign(p) + bcast?.swap(p, id.publicKey, sig, ownTtl) + publishAware(p, id.publicKey, sig, ownTtl) // Refresh the held set each time we return to our own frame. held = dao.latestHelpFramesPerNode(REBROADCAST_NODES) .filterNot { it.nodeId.contentEquals(id.nodeId) } @@ -236,22 +265,31 @@ class GuacamayaForegroundService : Service() { } else if (held.isNotEmpty()) { val e = held[idx % held.size] idx++ - bcast.swap(e.payloadRaw, e.pubkey, e.sig, REBROADCAST_TTL) + bcast?.swap(e.payloadRaw, e.pubkey, e.sig, REBROADCAST_TTL) + publishAware(e.payloadRaw, e.pubkey, e.sig, REBROADCAST_TTL) } ownTurn = !ownTurn } } } + private fun publishAware(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, ttl: Int) { + val nan = nanMessenger ?: return + val ssi = AwareConfig.packFrame(ttl, payload22, pub32, sig64) + nan.publish(ssi) + } + private fun stopBroadcasting() { broadcastJob?.cancel() broadcastJob = null broadcaster?.stop() + nanMessenger?.stopPublish() } private fun stopPresenceHeartbeat() { broadcastJob?.cancel() broadcastJob = null + nanMessenger?.stopPublish() } private suspend fun signedPayload( @@ -274,38 +312,18 @@ class GuacamayaForegroundService : Service() { /** * Fetch the device's current position as (latE7, lonE7), or null if location is - * unavailable (permission denied, services off, or no Google Play Services). + * unavailable (permission denied or services off). * - * Uses FusedLocationProviderClient.lastLocation — a cached one-shot read, which is - * cheap on battery (no continuous updates) and fine for stamping an SOS. On devices - * without Google Play Services this resolves to null and the caller falls back. + * Uses fused lastLocation when Google Play Services exists, then falls back to + * Android platform LocationManager on low-end / de-Googled devices. */ - @SuppressLint("MissingPermission") private suspend fun currentLatLonE7(): Pair? { - val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED || - ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!granted) { - Log.w(tag, "location permission not granted; broadcasting without coordinates") - return null - } - val client = LocationServices.getFusedLocationProviderClient(this) - val loc: Location? = suspendCancellableCoroutine { cont -> - client.lastLocation - .addOnSuccessListener { cont.resume(it) } - .addOnFailureListener { - Log.w(tag, "lastLocation failed: ${it.message}") - cont.resume(null) - } - } + val loc = LocationProvider.lastKnown(this, tag) if (loc == null) { Log.w(tag, "no last known location available") return null } - val latE7 = (loc.latitude * 1e7).toInt().coerceIn(-900_000_000, 900_000_000) - val lonE7 = (loc.longitude * 1e7).toInt().coerceIn(-1_800_000_000, 1_800_000_000) - return latE7 to lonE7 + return LocationProvider.toE7(loc) } private fun scheduleObserveRetry() { @@ -322,6 +340,7 @@ class GuacamayaForegroundService : Service() { observeHealthJob?.cancel() observeHealthJob = null BleMeshRuntime.stopObserving() + nanMessenger?.stopSubscribe() } override fun onDestroy() { @@ -329,6 +348,7 @@ class GuacamayaForegroundService : Service() { if (instance === this) instance = null BleMeshRuntime.stopObserving() broadcaster?.stop() + nanMessenger?.detach() stopPresenceHeartbeat() scope.cancel() } @@ -380,6 +400,7 @@ class GuacamayaForegroundService : Service() { private const val PREFS = "guacamaya_service" private const val KEY_WANT_OBSERVE = "want_observe" private const val OBSERVE_HEALTH_MS = 8_000L + private const val AWARE_RSSI_SENTINEL = -127 } private fun probeLog(msg: String) = Log.i(probeTag, msg) diff --git a/android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt b/android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt index e9c7db3..7e4dd92 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt @@ -86,6 +86,7 @@ import com.google.android.gms.location.LocationServices import net.guacamaya.mesh.MessageEntity import net.guacamaya.mesh.NodeCatalog import net.guacamaya.backend.OfficialAlert +import net.guacamaya.location.LocationProvider import net.guacamaya.service.GuacamayaForegroundService import org.json.JSONObject import android.util.Log @@ -1007,23 +1008,51 @@ private fun rememberLiveLocation(ctx: Context, highAccuracy: Boolean): Location? if (!fine && !coarse) { onDispose { } } else { - val client = LocationServices.getFusedLocationProviderClient(ctx) var current = location - val callback = LocationTracker.listen( - client, + val platform = LocationProvider.listenPlatform( + ctx, highAccuracy, ctx.mainLooper, + "guacamaya.location", ) { raw -> current = LocationTracker.smoothFix(current, raw) location = current } - client.lastLocation.addOnSuccessListener { fix -> + LocationProvider.platformLastKnown(ctx, "guacamaya.location")?.let { fix -> + current = LocationTracker.smoothFix(current, fix) + location = current + } + val client = try { + LocationServices.getFusedLocationProviderClient(ctx) + } catch (_: Throwable) { + null + } + val callback = client?.let { + try { + LocationTracker.listen( + it, + highAccuracy, + ctx.mainLooper, + ) { raw -> + current = LocationTracker.smoothFix(current, raw) + location = current + } + } catch (_: Throwable) { + null + } + } + client?.lastLocation?.addOnSuccessListener { fix -> if (fix != null) { current = LocationTracker.smoothFix(current, fix) location = current } + }?.addOnFailureListener { + Log.w("guacamaya.location", "fused lastLocation failed: ${it.message}") + } + onDispose { + if (callback != null) client?.removeLocationUpdates(callback) + platform?.close() } - onDispose { client.removeLocationUpdates(callback) } } } return location diff --git a/android/app/src/test/kotlin/net/guacamaya/aware/AwareConfigTest.kt b/android/app/src/test/kotlin/net/guacamaya/aware/AwareConfigTest.kt new file mode 100644 index 0000000..db246eb --- /dev/null +++ b/android/app/src/test/kotlin/net/guacamaya/aware/AwareConfigTest.kt @@ -0,0 +1,25 @@ +package net.guacamaya.aware + +import net.guacamaya.ble.BleConfig +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class AwareConfigTest { + + @Test + fun packFrame_matchesBleServiceDataLayout() { + val payload = ByteArray(22) { (it + 1).toByte() } + val pub = ByteArray(32) { (it + 40).toByte() } + val sig = ByteArray(64) { (it + 90).toByte() } + + val frame = AwareConfig.packFrame(ttl = 7, payload22 = payload, pub32 = pub, sig64 = sig) + + assertEquals(BleConfig.SERVICE_DATA_SIZE, AwareConfig.SSI_SIZE) + assertEquals(119, frame.size) + assertEquals(7, frame[BleConfig.TTL_OFFSET].toInt()) + assertArrayEquals(payload, frame.copyOfRange(BleConfig.PAYLOAD_OFFSET, BleConfig.PUBKEY_OFFSET)) + assertArrayEquals(pub, frame.copyOfRange(BleConfig.PUBKEY_OFFSET, BleConfig.SIG_OFFSET)) + assertArrayEquals(sig, frame.copyOfRange(BleConfig.SIG_OFFSET, AwareConfig.SSI_SIZE)) + } +} diff --git a/docs/GuacaMallaProject/Estado y Pendientes.md b/docs/GuacaMallaProject/Estado y Pendientes.md index 0ab7700..b363bf9 100644 --- a/docs/GuacaMallaProject/Estado y Pendientes.md +++ b/docs/GuacaMallaProject/Estado y Pendientes.md @@ -29,7 +29,9 @@ Foto del estado de [[GuacaMallaProject]] al **2026-06-28** (rama `develop`, mono ## Parcial / stub - 🟡 **Brújula MIUI ("sweet")**: reporta `magnet=bad`, bloqueada en calibración manual (figura-8). Realme calibra bien. Principal pendiente de **campo**. -- 🟡 **Wi-Fi Aware no integrado**: `NanMessenger` escrito pero el `GuacamayaForegroundService` no lo arranca. Hoy solo corre BLE. +- 🟡 **Wi-Fi Aware integrado al servicio, pendiente campo**: `GuacamayaForegroundService` ya arranca + publish/subscribe de `NanMessenger` y enruta el SSI al mismo `FloodRouter`; falta validar en dos + equipos con hardware Wi-Fi Aware real. - 🔴 **NAN Data Path** (`NanDataPath`): stub, payloads pesados devuelven error. - 🟡 **Resolve sin clientes**: backend listo, pero faltan la app del buscador y la consola del coordinador (todo el flujo de campo). Ver [[Resolve y Confirmacion de Rescate]]. @@ -48,9 +50,10 @@ están en código y verificados *headless*. Lo que falta, en orden: ## Trabajo abierto (backlog) -- [ ] **Fallback de ubicación sin Google Play Services**: el fix GPS aún usa `FusedLocationProviderClient` (GMS). Alternativa robusta para gama baja sin GMS: `LocationManager` de plataforma. +- [x] **Fallback de ubicación sin Google Play Services**: `LocationProvider` usa Fused si existe y + cae a `LocationManager` de plataforma para last-known/live fixes. - [ ] **Calibrar/robustecer brújula MIUI** en campo (`functional-compass` con Δheading ≈ 0° en paralelo). -- [ ] **Integrar Wi-Fi Aware** al servicio (publish/subscribe del `NanMessenger`). +- [x] **Integrar Wi-Fi Aware** al servicio (publish/subscribe del `NanMessenger`) — falta prueba de campo. - [ ] **Clientes de Resolve**: app del buscador + consola del coordinador (ver nota dedicada). - [ ] **Endurecer `/ingest`**: rate-limit por origen además del global; moderación de reportes de comunidad. - [ ] **UUID de servicio BLE** en `BleConfig` sigue siendo placeholder — cambiar antes de uso productivo. diff --git a/docs/GuacaMallaProject/GuacaMalla (Android).md b/docs/GuacaMallaProject/GuacaMalla (Android).md index 0ca7766..618a30c 100644 --- a/docs/GuacaMallaProject/GuacaMalla (Android).md +++ b/docs/GuacaMallaProject/GuacaMalla (Android).md @@ -14,10 +14,11 @@ formato binario está en [[Protocolo y Frame]]. | Plano | Radio | Carga | Estado | |---|---|---|---| | Control / descubrimiento | BLE 5 Extended Advertising (`Broadcaster` / `Observer`) | el frame de 119 B como service data | ✅ funcionando end-to-end, **verificado en dos teléfonos físicos** | -| Datos ligeros (≤255 B) | Wi-Fi Aware NAN (service discovery, `NanMessenger`) | mismo frame como SSI | 🟡 escrito, no integrado al servicio | +| Datos ligeros (≤255 B) | Wi-Fi Aware NAN (service discovery, `NanMessenger`) | mismo frame 119 B como SSI | 🟡 integrado al servicio; falta prueba en hardware real | | Datos pesados (>255 B) | Wi-Fi Aware NAN Data Path (`NanDataPath`) | payloads grandes | 🔴 stub | -> En runtime hoy solo corre el plano BLE; el `GuacamayaForegroundService` aún no arranca Wi-Fi Aware. +> En runtime el `GuacamayaForegroundService` arranca BLE y, si el hardware lo expone, Wi-Fi Aware. +> El emulador no tiene `WifiAwareManager`; la validación real requiere dos teléfonos compatibles. ## Mapa de módulos (`app/src/main/kotlin/net/guacamaya/`) @@ -48,7 +49,8 @@ decisión en [[Arquitectura y Decisiones]] §7. - `ui/GeoProximity.kt` — distancia entre nodos con suavizado EMA del GPS + posición por nodo; dentro de la incertidumbre del fix muestra **«junto»** en vez de saltar 1–4 m; sub-10 m en cm. Cuando el GPS dice «junto», usa el RSSI BLE suavizado como hint (`tocando`, `~1 m`, …). -- `ui/LocationTracker.kt` — fix de GPS (hoy vía `FusedLocationProviderClient`). +- `location/LocationProvider.kt` + `ui/LocationTracker.kt` — fix de GPS: Fused/GMS cuando existe, + fallback nativo con `LocationManager` para equipos sin Google Play Services. - `ui/FunctionalProbe.kt` — sonda de diagnóstico que se vuelca por logcat (`nodes`, `frames`, `bearing`, `rel=`, `co_loc`, `magnet=…`) para las pruebas adb dual-device. - `ui/MapViewModel.kt`, `ui/MainActivity.kt` (~934 líneas), `ui/Theme.kt` — UI Compose.