From c500d13077f175759ed374c205ac50d541dc1f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 07:23:00 -0600 Subject: [PATCH 01/12] feat(android): add Firebase App Distribution via Gradle plugin --- .github/workflows/android-distribute.yml | 34 ++++++++++++++++++++++++ android/app/build.gradle.kts | 6 +++++ android/app/google-services.json | 29 ++++++++++++++++++++ android/app/release-notes.txt | 1 + android/build.gradle.kts | 2 ++ android/gradle/libs.versions.toml | 4 +++ 6 files changed, 76 insertions(+) create mode 100644 .github/workflows/android-distribute.yml create mode 100644 android/app/google-services.json create mode 100644 android/app/release-notes.txt diff --git a/.github/workflows/android-distribute.yml b/.github/workflows/android-distribute.yml new file mode 100644 index 0000000..3dbab7c --- /dev/null +++ b/.github/workflows/android-distribute.yml @@ -0,0 +1,34 @@ +name: Android — Distribute + +on: + push: + branches: [main] + paths: + - 'android/**' + 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 + + - 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 appDistributionUploadDebug diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5f26ab3..9654632 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -2,6 +2,8 @@ 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) } // Backend base URL (uplink /ingest + downlink /channels, /pubkey, /health). @@ -50,6 +52,10 @@ 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\"") + firebaseAppDistribution { + groups = "internal-testers" + releaseNotesFile = "release-notes.txt" + } } } 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/build.gradle.kts b/android/build.gradle.kts index 43e7a0c..58725e8 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -2,4 +2,6 @@ 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 } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 63da8c5..77f2bba 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -12,6 +12,8 @@ osmdroid = "6.1.18" playServicesLocation = "21.3.0" workManager = "2.9.1" junit = "4.13.2" +googleServices = "4.4.2" +firebaseAppdistribution = "5.0.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -37,3 +39,5 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } 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" } From fee19c2e44fa22d8599b1ed0f53bad707e3ef773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 07:25:47 -0600 Subject: [PATCH 02/12] fix(workflow): include assembleDebug in APK distribution step --- .github/workflows/android-distribute.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android-distribute.yml b/.github/workflows/android-distribute.yml index 3dbab7c..5fbabac 100644 --- a/.github/workflows/android-distribute.yml +++ b/.github/workflows/android-distribute.yml @@ -31,4 +31,4 @@ jobs: working-directory: android env: GOOGLE_APPLICATION_CREDENTIALS: /tmp/sa.json - run: ./gradlew appDistributionUploadDebug + run: ./gradlew assembleDebug appDistributionUploadDebug From 1e4b10bb8f51af3bf1e37d7322cd6a9b38e15858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 07:35:18 -0600 Subject: [PATCH 03/12] fix(workflow): update release notes file path for Firebase App Distribution --- .github/workflows/android-distribute.yml | 1 + android/app/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android-distribute.yml b/.github/workflows/android-distribute.yml index 5fbabac..f35a17d 100644 --- a/.github/workflows/android-distribute.yml +++ b/.github/workflows/android-distribute.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'android/**' + - '.github/workflows/android-distribute.yml' workflow_dispatch: jobs: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9654632..facb67b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -54,7 +54,7 @@ android { buildConfigField("String", "BACKEND_BASE_URL", "\"$backendDebugUrl\"") firebaseAppDistribution { groups = "internal-testers" - releaseNotesFile = "release-notes.txt" + releaseNotesFile = "app/release-notes.txt" } } } From 894d1078b46a9f186a22fb1947a4237bf7655899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 07:41:40 -0600 Subject: [PATCH 04/12] fix(workflow): add cache option for JDK setup in Android distribution workflow --- .github/workflows/android-distribute.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/android-distribute.yml b/.github/workflows/android-distribute.yml index f35a17d..60ff423 100644 --- a/.github/workflows/android-distribute.yml +++ b/.github/workflows/android-distribute.yml @@ -19,6 +19,7 @@ jobs: 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 From 3730367ad0d0410e5863aa40ad612270a7ce784f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 07:54:42 -0600 Subject: [PATCH 05/12] feat(crashlytics): integrate Firebase Crashlytics for error reporting --- android/app/build.gradle.kts | 5 +++++ android/app/src/main/AndroidManifest.xml | 4 ++++ android/build.gradle.kts | 1 + android/gradle/libs.versions.toml | 4 ++++ 4 files changed, 14 insertions(+) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index facb67b..7c9ed34 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { 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). @@ -40,6 +41,7 @@ android { buildTypes { release { isMinifyEnabled = false + manifestPlaceholders["firebase_crashlytics_collection_enabled"] = "true" proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -52,6 +54,7 @@ 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" @@ -114,6 +117,8 @@ 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) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 69edf32..2390c20 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"> + + Date: Mon, 29 Jun 2026 10:15:30 -0600 Subject: [PATCH 06/12] test: add unit tests for FloodRouter functionality --- android/app/build.gradle.kts | 1 + .../net/guacamaya/mesh/FloodRouterTest.kt | 267 ++++++++++++++++++ android/gradle/libs.versions.toml | 2 + 3 files changed, 270 insertions(+) create mode 100644 android/app/src/test/kotlin/net/guacamaya/mesh/FloodRouterTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7c9ed34..43cb486 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -123,4 +123,5 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } 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/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 8264bc8..f7ea6c2 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -12,6 +12,7 @@ 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" @@ -37,6 +38,7 @@ play-services-location = { group = "com.google.android.gms", name = "play-servic 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" } From 85dd13564931d621243b3c5d30831a03474649f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 10:18:24 -0600 Subject: [PATCH 07/12] fix(security): AdbCommandReceiver solo en builds de debug --- android/app/src/debug/AndroidManifest.xml | 33 +++++++++++++++++++++++ android/app/src/main/AndroidManifest.xml | 13 --------- 2 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 android/app/src/debug/AndroidManifest.xml 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 2390c20..a5c9329 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -78,18 +78,5 @@ android:exported="false" android:foregroundServiceType="connectedDevice" /> - - - - - - - - - - - From 464d2f7777518ac780e17d6f1a0d3189eca942ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 10:15:30 -0600 Subject: [PATCH 08/12] test: add unit tests for FloodRouter functionality --- android/app/build.gradle.kts | 1 + .../net/guacamaya/mesh/FloodRouterTest.kt | 267 ++++++++++++++++++ android/gradle/libs.versions.toml | 2 + 3 files changed, 270 insertions(+) create mode 100644 android/app/src/test/kotlin/net/guacamaya/mesh/FloodRouterTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7c9ed34..43cb486 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -123,4 +123,5 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } 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/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 8264bc8..f7ea6c2 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -12,6 +12,7 @@ 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" @@ -37,6 +38,7 @@ play-services-location = { group = "com.google.android.gms", name = "play-servic 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" } From 3cd9928a9680c2c5412d10b17b6496f4933d888d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 10:18:24 -0600 Subject: [PATCH 09/12] fix(security): AdbCommandReceiver solo en builds de debug --- android/app/src/debug/AndroidManifest.xml | 33 +++++++++++++++++++++++ android/app/src/main/AndroidManifest.xml | 13 --------- 2 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 android/app/src/debug/AndroidManifest.xml 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 2390c20..a5c9329 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -78,18 +78,5 @@ android:exported="false" android:foregroundServiceType="connectedDevice" /> - - - - - - - - - - - From 4199d9616ed839b533e7eea8e20fb2a8c96b6785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 10:21:10 -0600 Subject: [PATCH 10/12] =?UTF-8?q?chore:=20agrega=20template=20de=20PRs=20e?= =?UTF-8?q?n=20espa=C3=B1ol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md 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 From d01daec4cb9843ab12690b50a62a690b5411367d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 10:25:27 -0600 Subject: [PATCH 11/12] fix(aware): verificar permiso antes de attach y propagar errores en publish/subscribe --- .../net/guacamaya/aware/NanMessenger.kt | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) 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) } } } From c1bf3e34d5019fe52dbead24f01460d1c00b3154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eli=20Jos=C3=A9=20Carrasquero?= Date: Mon, 29 Jun 2026 10:41:38 -0600 Subject: [PATCH 12/12] =?UTF-8?q?fix(ui):=20mostrar=20banner=20cuando=20el?= =?UTF-8?q?=20permiso=20de=20ubicaci=C3=B3n=20est=C3=A1=20denegado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuando el permiso de ubicación está denegado, el SOS se transmitía silenciosamente con coordenadas (0, 0). Ahora se muestra un banner rojo en los modos SOS y Ambos que avisa al usuario y ofrece un botón para activar el permiso directamente desde ajustes de la app. --- .../kotlin/net/guacamaya/ui/MainActivity.kt | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) 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() {