diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..129fe8b
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,20 @@
+## ¿Qué hace este PR?
+
+
+
+## ¿Por qué?
+
+
+
+## ¿Cómo probarlo?
+
+
+
+- [ ]
+- [ ]
+
+## Checklist
+
+- [ ] Los tests existentes siguen pasando (`./gradlew testDebugUnitTest` / `bun test`)
+- [ ] Si toca el wire format o el protocolo de malla, se actualizó el lado complementario (Android ↔ backend)
+- [ ] Si toca `/ingest` o la firma Ed25519, se verificó el cascade de rechazo
diff --git a/.github/workflows/android-distribute.yml b/.github/workflows/android-distribute.yml
new file mode 100644
index 0000000..60ff423
--- /dev/null
+++ b/.github/workflows/android-distribute.yml
@@ -0,0 +1,36 @@
+name: Android — Distribute
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'android/**'
+ - '.github/workflows/android-distribute.yml'
+ workflow_dispatch:
+
+jobs:
+ distribute:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 17
+ cache: gradle
+
+ - name: Write release notes from last commit
+ run: git log -1 --pretty=format:"%s%n%n%b" > android/app/release-notes.txt
+
+ - name: Write Firebase service account
+ env:
+ FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
+ run: echo "$FIREBASE_SERVICE_ACCOUNT" > /tmp/sa.json
+
+ - name: Build & distribute debug APK
+ working-directory: android
+ env:
+ GOOGLE_APPLICATION_CREDENTIALS: /tmp/sa.json
+ run: ./gradlew assembleDebug appDistributionUploadDebug
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 5f26ab3..43cb486 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -2,6 +2,9 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
+ alias(libs.plugins.google.services)
+ alias(libs.plugins.firebase.appdistribution)
+ alias(libs.plugins.firebase.crashlytics)
}
// Backend base URL (uplink /ingest + downlink /channels, /pubkey, /health).
@@ -38,6 +41,7 @@ android {
buildTypes {
release {
isMinifyEnabled = false
+ manifestPlaceholders["firebase_crashlytics_collection_enabled"] = "true"
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -50,6 +54,11 @@ android {
// Emulator loopback by default; override with -PBACKEND_BASE_URL for a LAN backend.
// Cleartext allowed broadly by the debug network-security-config.
buildConfigField("String", "BACKEND_BASE_URL", "\"$backendDebugUrl\"")
+ manifestPlaceholders["firebase_crashlytics_collection_enabled"] = "true"
+ firebaseAppDistribution {
+ groups = "internal-testers"
+ releaseNotesFile = "app/release-notes.txt"
+ }
}
}
@@ -108,8 +117,11 @@ dependencies {
ksp(libs.androidx.room.compiler)
implementation(libs.play.services.location)
implementation(libs.androidx.work.runtime)
+ implementation(platform(libs.firebase.bom))
+ implementation("com.google.firebase:firebase-crashlytics-ktx")
debugImplementation(libs.androidx.ui.tooling)
testImplementation(libs.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/android/app/google-services.json b/android/app/google-services.json
new file mode 100644
index 0000000..088f3c3
--- /dev/null
+++ b/android/app/google-services.json
@@ -0,0 +1,29 @@
+{
+ "project_info": {
+ "project_number": "800641010595",
+ "project_id": "guacamalla-app",
+ "storage_bucket": "guacamalla-app.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:800641010595:android:cb83f00966ab0e87faa816",
+ "android_client_info": {
+ "package_name": "net.guacamaya"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyAjgHuFOT97JMnINSc1kbKazMxXjgaW05g"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/android/app/release-notes.txt b/android/app/release-notes.txt
new file mode 100644
index 0000000..2bda164
--- /dev/null
+++ b/android/app/release-notes.txt
@@ -0,0 +1 @@
+Development build — see git log for details.
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..0f47ea3
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 69edf32..a5c9329 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -47,6 +47,10 @@
android:theme="@style/Theme.Guacamaya"
android:hardwareAccelerated="true">
+
+
-
-
-
-
-
-
-
-
-
-
-
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..79f568f 100644
--- a/android/app/src/main/kotlin/net/guacamaya/aware/NanMessenger.kt
+++ b/android/app/src/main/kotlin/net/guacamaya/aware/NanMessenger.kt
@@ -1,6 +1,7 @@
package net.guacamaya.aware
import android.content.Context
+import android.content.pm.PackageManager
import android.net.wifi.aware.AttachCallback
import android.net.wifi.aware.DiscoverySessionCallback
import android.net.wifi.aware.PeerHandle
@@ -10,9 +11,11 @@ import android.net.wifi.aware.SubscribeConfig
import android.net.wifi.aware.SubscribeDiscoverySession
import android.net.wifi.aware.WifiAwareManager
import android.net.wifi.aware.WifiAwareSession
+import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
+import androidx.core.content.ContextCompat
import java.util.concurrent.atomic.AtomicReference
/**
@@ -30,6 +33,7 @@ import java.util.concurrent.atomic.AtomicReference
*/
class NanMessenger private constructor(
private val manager: WifiAwareManager,
+ private val context: Context,
) {
private val tag = "guacamaya.aware.NanMessenger"
private val handler = Handler(Looper.getMainLooper())
@@ -55,10 +59,20 @@ class NanMessenger private constructor(
return
}
if (!manager.isAvailable) {
- Log.w(tag, "WifiAware not available")
+ Log.w(tag, "attach skipped — WifiAware not available")
onFailed(-1)
return
}
+ val awarePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ android.Manifest.permission.NEARBY_WIFI_DEVICES
+ } else {
+ android.Manifest.permission.ACCESS_FINE_LOCATION
+ }
+ if (ContextCompat.checkSelfPermission(context, awarePermission) != PackageManager.PERMISSION_GRANTED) {
+ Log.w(tag, "attach skipped — $awarePermission not granted")
+ onFailed(-3)
+ return
+ }
manager.attach(object : AttachCallback() {
override fun onAttached(session: WifiAwareSession?) {
sessionRef.set(session)
@@ -85,7 +99,10 @@ class NanMessenger private constructor(
val session = sessionRef.get() ?: run {
Log.w(tag, "publish called before attach; attaching then publishing")
- attach(onAttached = { publish(ssi) })
+ attach(
+ onAttached = { publish(ssi) },
+ onFailed = { Log.e(tag, "publish: attach failed (code=$it), frame lost") },
+ )
return
}
@@ -123,7 +140,10 @@ class NanMessenger private constructor(
*/
fun subscribe() {
val session = sessionRef.get() ?: run {
- attach(onAttached = { subscribe() })
+ attach(
+ onAttached = { subscribe() },
+ onFailed = { Log.e(tag, "subscribe: attach failed (code=$it)") },
+ )
return
}
val config = SubscribeConfig.Builder()
@@ -170,7 +190,7 @@ class NanMessenger private constructor(
Log.w("guacamaya.aware.NanMessenger", "no WifiAwareManager")
return null
}
- return NanMessenger(mgr)
+ return NanMessenger(mgr, context.applicationContext)
}
}
}
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 6115f17..e9c7db3 100644
--- a/android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt
+++ b/android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt
@@ -6,10 +6,12 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
+import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
+import android.provider.Settings
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -255,6 +257,7 @@ private fun Screen(vm: MapViewModel = viewModel()) {
var showRadar by remember { mutableStateOf(false) }
val running = broadcasting || observing
+ val locationGranted = rememberLocationPermission(ctx)
FunctionalProbe(compass = probeCompass, location = probeLocation, nodes = latestNodes, totalFrames = totalFrames)
// Re-check BLE broadcast capability on resume (e.g. returning from BT/quick settings),
@@ -333,6 +336,7 @@ private fun Screen(vm: MapViewModel = viewModel()) {
onPower = { onPower() },
onSelectMode = { onSelectMode(it) },
broadcastSupported = broadcastSupported,
+ locationGranted = locationGranted,
onOpenRadar = { showRadar = true },
)
}
@@ -371,6 +375,7 @@ private fun HomeScreen(
onPower: () -> Unit,
onSelectMode: (MeshMode) -> Unit,
broadcastSupported: Boolean,
+ locationGranted: Boolean,
onOpenRadar: () -> Unit,
) {
val lastNode = latestNodes.firstOrNull()
@@ -407,6 +412,11 @@ private fun HomeScreen(
LiveSosIndicator(count = liveSosCount)
}
+ if (!locationGranted && mode != MeshMode.FIND) {
+ Spacer(Modifier.height(Space.sm))
+ LocationDeniedBanner(ctx)
+ }
+
if (showBatteryHint) {
Spacer(Modifier.height(Space.sm))
BatteryHintBanner(
@@ -457,6 +467,61 @@ private fun HomeScreen(
}
}
+@Composable
+private fun rememberLocationPermission(ctx: Context): Boolean {
+ val check = {
+ ContextCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
+ ContextCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ }
+ var granted by remember { mutableStateOf(check()) }
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) granted = check()
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+ return granted
+}
+
+/** Red banner shown when location permission is denied and the mode requires GPS (SOS / Ambos). */
+@Composable
+private fun LocationDeniedBanner(ctx: Context) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clip(MaterialTheme.shapes.medium)
+ .background(GuacamayaPalette.DangerSoft)
+ .border(1.dp, DangerC.copy(alpha = 0.55f), MaterialTheme.shapes.medium)
+ .padding(Space.sm),
+ ) {
+ Text(
+ "⚠ Sin ubicación — SOS sin coordenadas",
+ color = DangerC,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Spacer(Modifier.height(Space.xxs))
+ Text(
+ "El permiso de ubicación está denegado. Tu SOS se transmitirá sin posición GPS, dificultando el rescate.",
+ color = TextLo,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Spacer(Modifier.height(Space.xs))
+ Button(
+ onClick = {
+ ctx.startActivity(
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts("package", ctx.packageName, null)
+ }
+ )
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = DangerC, contentColor = GuacamayaPalette.OnSemantic),
+ shape = MaterialTheme.shapes.small,
+ ) { Text("Activar permiso", style = MaterialTheme.typography.labelLarge) }
+ }
+}
+
/** Warns (amber, icon + word) that this device can't transmit SOS over BLE — e.g. emulators. */
@Composable
private fun BroadcastUnsupportedBanner() {
diff --git a/android/app/src/test/kotlin/net/guacamaya/mesh/FloodRouterTest.kt b/android/app/src/test/kotlin/net/guacamaya/mesh/FloodRouterTest.kt
new file mode 100644
index 0000000..b79631c
--- /dev/null
+++ b/android/app/src/test/kotlin/net/guacamaya/mesh/FloodRouterTest.kt
@@ -0,0 +1,267 @@
+package net.guacamaya.mesh
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import net.guacamaya.crypto.Signer
+import net.guacamaya.proto.Crc16
+import net.guacamaya.proto.Flags
+import net.guacamaya.proto.Payload
+import net.guacamaya.proto.SosType
+import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
+import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
+import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.security.MessageDigest
+import java.security.SecureRandom
+
+/**
+ * Unit tests for the FloodRouter reject cascade:
+ * 1. pubkey/node_id binding
+ * 2. CRC check
+ * 3. Age policy (presence vs help-request windows)
+ * 4. Ed25519 signature verification
+ * → dedupe → persist → relay
+ *
+ * All tests run on the JVM (no Android runtime). Android stubs return default
+ * values via unitTests.isReturnDefaultValues = true (gradle.properties), so
+ * Log.w/d/i calls are no-ops. The broadcaster is injected as null — relay
+ * behavior requires instrumented tests with real BLE hardware.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class FloodRouterTest {
+
+ // Fixed clock: 1,800,000,000 unix seconds — well within uint32 range.
+ private val nowMs = 1_800_000_000_000L
+ private val nowSec = nowMs / 1000
+
+ private val rng = SecureRandom()
+ private lateinit var priv32: ByteArray
+ private lateinit var pub32: ByteArray
+ private lateinit var nodeId: ByteArray // SHA-256(pub32)[0..4]
+ private lateinit var dao: FakeMessageDao
+
+ @Before fun setUp() {
+ val gen = Ed25519KeyPairGenerator().apply {
+ init(Ed25519KeyGenerationParameters(rng))
+ }
+ val kp = gen.generateKeyPair()
+ priv32 = (kp.private as Ed25519PrivateKeyParameters).encoded
+ pub32 = (kp.public as Ed25519PublicKeyParameters).encoded
+ nodeId = MessageDigest.getInstance("SHA-256").digest(pub32).copyOfRange(0, 4)
+ dao = FakeMessageDao()
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ private fun buildPayload(
+ tsUnix: Long = nowSec,
+ critical: Boolean = true,
+ sosType: SosType = SosType.DISTRESS,
+ msgId: Int = 1,
+ hopTtl: Int = 5,
+ ) = Payload(
+ latE7 = 10_000_000, lonE7 = -74_000_000,
+ tsUnix = tsUnix, nodeId = nodeId,
+ flags = Flags(hasHeavy = false, critical = critical, batteryBucket = 2, hopTtl = hopTtl),
+ sosType = sosType, msgId = msgId,
+ )
+
+ private fun sign(payload: Payload): Triple {
+ val p22 = payload.encode()
+ return Triple(p22, pub32, Signer.sign(priv32, p22))
+ }
+
+ private fun generateKeyPair(): Pair {
+ val gen = Ed25519KeyPairGenerator().apply { init(Ed25519KeyGenerationParameters(rng)) }
+ val kp = gen.generateKeyPair()
+ return (kp.private as Ed25519PrivateKeyParameters).encoded to
+ (kp.public as Ed25519PublicKeyParameters).encoded
+ }
+
+ // ── happy path ────────────────────────────────────────────────────────────
+
+ @Test fun `valid frame is persisted`() = runTest {
+ val (p22, pub, sig) = sign(buildPayload())
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(p22, pub, sig, ttl = 5, rssi = -70)
+ advanceUntilIdle()
+ assertEquals(1, dao.inserted.size)
+ }
+
+ @Test fun `persisted entity carries correct fields`() = runTest {
+ val payload = buildPayload(msgId = 42)
+ val (p22, pub, sig) = sign(payload)
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(p22, pub, sig, ttl = 3, rssi = -80)
+ advanceUntilIdle()
+ val e = dao.inserted.single()
+ assertTrue(e.nodeId.contentEquals(nodeId))
+ assertEquals(42, e.msgId)
+ assertEquals(-80, e.rssi)
+ assertEquals(nowMs, e.receivedAt)
+ }
+
+ // ── cascade step 1: pubkey / node_id binding ──────────────────────────────
+
+ @Test fun `pubkey-nodeId mismatch is dropped`() = runTest {
+ // A different keypair whose SHA-256 doesn't match the payload's node_id.
+ val (otherPriv, otherPub) = generateKeyPair()
+ val p22 = buildPayload().encode()
+ val sig = Signer.sign(otherPriv, p22) // signed with other key to avoid masking
+
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(p22, otherPub, sig, ttl = 5, rssi = -70)
+ advanceUntilIdle()
+ assertEquals(0, dao.inserted.size)
+ }
+
+ // ── cascade step 2: CRC ───────────────────────────────────────────────────
+
+ @Test fun `bad CRC is dropped`() = runTest {
+ val (p22, pub, sig) = sign(buildPayload())
+ val corrupted = p22.copyOf().also { it[20] = (it[20].toInt() xor 0xFF).toByte() }
+
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(corrupted, pub, sig, ttl = 5, rssi = -70)
+ advanceUntilIdle()
+ assertEquals(0, dao.inserted.size)
+ }
+
+ // ── cascade step 3: age policy ─────────────────────────────────────────────
+
+ @Test fun `stale presence frame is dropped`() = runTest {
+ // Presence = non-critical OTHER (see Payload.isHelpRequest)
+ val staleTs = nowSec - AgePolicy.PRESENCE_MAX_AGE_SECONDS - 1
+ val (p22, pub, sig) = sign(buildPayload(tsUnix = staleTs, critical = false, sosType = SosType.OTHER))
+
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(p22, pub, sig, ttl = 5, rssi = -70)
+ advanceUntilIdle()
+ assertEquals(0, dao.inserted.size)
+ }
+
+ @Test fun `help request inside 24h window is accepted`() = runTest {
+ val oldTs = nowSec - 23 * 3_600L
+ val (p22, pub, sig) = sign(buildPayload(tsUnix = oldTs, critical = true))
+
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(p22, pub, sig, ttl = 5, rssi = -70)
+ advanceUntilIdle()
+ assertEquals(1, dao.inserted.size)
+ }
+
+ @Test fun `help request older than 24h is dropped`() = runTest {
+ val tooOld = nowSec - AgePolicy.HELP_MAX_AGE_SECONDS - 1
+ val (p22, pub, sig) = sign(buildPayload(tsUnix = tooOld, critical = true))
+
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(p22, pub, sig, ttl = 5, rssi = -70)
+ advanceUntilIdle()
+ assertEquals(0, dao.inserted.size)
+ }
+
+ @Test fun `far-future frame beyond skew tolerance is dropped`() = runTest {
+ val futureTs = nowSec + AgePolicy.FUTURE_SKEW_SECONDS + 1
+ val (p22, pub, sig) = sign(buildPayload(tsUnix = futureTs))
+
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(p22, pub, sig, ttl = 5, rssi = -70)
+ advanceUntilIdle()
+ assertEquals(0, dao.inserted.size)
+ }
+
+ // ── cascade step 4: Ed25519 signature ─────────────────────────────────────
+
+ @Test fun `payload tampered after signing is dropped`() = runTest {
+ val (p22, pub, sig) = sign(buildPayload())
+ // Flip a bit in the latE7 field (bytes 0..3, not node_id or CRC)
+ // and recompute the CRC so the frame passes step 2 but fails step 4.
+ val tampered = p22.copyOf().also { it[0] = (it[0].toInt() xor 0x01).toByte() }
+ val crc = Crc16.ccitt(tampered, 0, 20)
+ tampered[20] = (crc shr 8).toByte()
+ tampered[21] = (crc and 0xFF).toByte()
+
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(tampered, pub, sig, ttl = 5, rssi = -70)
+ advanceUntilIdle()
+ assertEquals(0, dao.inserted.size)
+ }
+
+ @Test fun `signature byte flip is dropped`() = runTest {
+ val (p22, pub, sig) = sign(buildPayload())
+ val badSig = sig.copyOf().also { it[0] = (it[0].toInt() xor 0x80).toByte() }
+
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(p22, pub, badSig, ttl = 5, rssi = -70)
+ advanceUntilIdle()
+ assertEquals(0, dao.inserted.size)
+ }
+
+ // ── dedupe ────────────────────────────────────────────────────────────────
+
+ @Test fun `duplicate frame within TTL window is persisted once`() = runTest {
+ val (p22, pub, sig) = sign(buildPayload())
+ val router = FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ router.onFrame(p22, pub, sig, ttl = 5, rssi = -70)
+ router.onFrame(p22, pub, sig, ttl = 5, rssi = -65)
+ advanceUntilIdle()
+ assertEquals(1, dao.inserted.size)
+ }
+
+ // ── hop TTL ───────────────────────────────────────────────────────────────
+
+ @Test fun `frame with TTL 1 is still persisted`() = runTest {
+ // nextTtl = 0 suppresses relay but must not suppress persistence.
+ val (p22, pub, sig) = sign(buildPayload(hopTtl = 1))
+ FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ .onFrame(p22, pub, sig, ttl = 1, rssi = -70)
+ advanceUntilIdle()
+ assertEquals(1, dao.inserted.size)
+ }
+
+ // ── multi-frame ───────────────────────────────────────────────────────────
+
+ @Test fun `distinct messages from same node are each persisted`() = runTest {
+ val router = FloodRouter(dao = dao, scope = this, broadcaster = null, now = { nowMs })
+ for (id in 1..5) {
+ val (p22, pub, sig) = sign(buildPayload(msgId = id))
+ router.onFrame(p22, pub, sig, ttl = 5, rssi = -70)
+ }
+ advanceUntilIdle()
+ assertEquals(5, dao.inserted.size)
+ }
+
+ // ── fake DAO ──────────────────────────────────────────────────────────────
+
+ private class FakeMessageDao : MessageDao {
+ val inserted = mutableListOf()
+ private val keys = mutableSetOf, Int>>()
+
+ override suspend fun insert(entity: MessageEntity): Long {
+ val key = entity.nodeId.toList() to entity.msgId
+ return if (!keys.add(key)) -1L
+ else { inserted.add(entity); inserted.size.toLong() }
+ }
+
+ override suspend fun pruneOldKeeping(keep: Int) {}
+ override suspend fun clear() { inserted.clear() }
+
+ // Unused by FloodRouter — fail loudly if unexpectedly called.
+ override fun observeRecent(limit: Int): Flow> = flowOf(emptyList())
+ override suspend fun count(): Int = throw NotImplementedError()
+ override fun observeCount(): Flow = throw NotImplementedError()
+ override fun observeNodeCount(): Flow = throw NotImplementedError()
+ override fun observeLatestPerNode(limit: Int): Flow> = throw NotImplementedError()
+ override suspend fun latestHelpFramesPerNode(limit: Int): List = throw NotImplementedError()
+ override suspend fun selectUploadable(limit: Int): List = throw NotImplementedError()
+ override suspend fun markUploaded(ids: List) = throw NotImplementedError()
+ override suspend fun countUploadable(): Int = throw NotImplementedError()
+ }
+}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 43e7a0c..33b8d60 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -2,4 +2,7 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.google.services) apply false
+ alias(libs.plugins.firebase.appdistribution) apply false
+ alias(libs.plugins.firebase.crashlytics) apply false
}
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 63da8c5..f7ea6c2 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -12,6 +12,11 @@ osmdroid = "6.1.18"
playServicesLocation = "21.3.0"
workManager = "2.9.1"
junit = "4.13.2"
+kotlinxCoroutinesTest = "1.7.3"
+googleServices = "4.4.2"
+firebaseAppdistribution = "5.0.0"
+firebaseCrashlyticsPlugin = "3.0.2"
+firebaseBom = "33.5.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -30,10 +35,15 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref =
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
+firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
+firebase-appdistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppdistribution" }
+firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" }