Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e7ed007
Merge pull request #3 from vmlcode/develop
vmlcode Jun 28, 2026
e459815
Merge pull request #5 from vmlcode/develop
vmlcode Jun 28, 2026
97485f8
Merge pull request #6 from vmlcode/develop
davgonsan Jun 29, 2026
c500d13
feat(android): add Firebase App Distribution via Gradle plugin
ielijose Jun 29, 2026
fee19c2
fix(workflow): include assembleDebug in APK distribution step
ielijose Jun 29, 2026
1e4b10b
fix(workflow): update release notes file path for Firebase App Distri…
ielijose Jun 29, 2026
894d107
fix(workflow): add cache option for JDK setup in Android distribution…
ielijose Jun 29, 2026
3730367
feat(crashlytics): integrate Firebase Crashlytics for error reporting
ielijose Jun 29, 2026
26de965
test: add unit tests for FloodRouter functionality
ielijose Jun 29, 2026
85dd135
fix(security): AdbCommandReceiver solo en builds de debug
ielijose Jun 29, 2026
464d2f7
test: add unit tests for FloodRouter functionality
ielijose Jun 29, 2026
3cd9928
fix(security): AdbCommandReceiver solo en builds de debug
ielijose Jun 29, 2026
4199d96
chore: agrega template de PRs en español
ielijose Jun 29, 2026
d01daec
fix(aware): verificar permiso antes de attach y propagar errores en p…
ielijose Jun 29, 2026
c1bf3e3
fix(ui): mostrar banner cuando el permiso de ubicación está denegado
ielijose Jun 29, 2026
f4595cc
Merge pull request #7 from vmlcode/tests/flood-router-cascade
vmlcode Jun 30, 2026
38ccc8c
Merge pull request #8 from vmlcode/fix/adb-receiver-debug-only
vmlcode Jun 30, 2026
9ec1895
Merge pull request #9 from vmlcode/chore/pr-template
vmlcode Jun 30, 2026
fd796a7
Merge pull request #10 from vmlcode/fix/nan-messenger-permission-and-…
vmlcode Jun 30, 2026
748271b
Merge pull request #12 from vmlcode/fix/location-permission-banner-v2
vmlcode Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## ¿Qué hace este PR?

<!-- Una o dos oraciones. Qué cambia y en qué parte del proyecto. -->

## ¿Por qué?

<!-- Motivación: bug, mejora de seguridad, nueva feature, deuda técnica, etc. -->

## ¿Cómo probarlo?

<!-- Pasos concretos para verificar que funciona. -->

- [ ]
- [ ]

## 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
36 changes: 36 additions & 0 deletions .github/workflows/android-distribute.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -38,6 +41,7 @@ android {
buildTypes {
release {
isMinifyEnabled = false
manifestPlaceholders["firebase_crashlytics_collection_enabled"] = "true"
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
Expand All @@ -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"
}
}
}

Expand Down Expand Up @@ -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)
}
29 changes: 29 additions & 0 deletions android/app/google-services.json
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions android/app/release-notes.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Development build — see git log for details.
33 changes: 33 additions & 0 deletions android/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only components. This manifest is merged only into debug builds.
Nothing here ships in release APKs.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<!--
adb entry point for automation / demo scripts. Exported so adb shell
am broadcast can reach it without root. Lives here (debug-only) so
release builds have zero attack surface — no app can send commands to
a receiver that doesn't exist.

Usage:
adb shell am broadcast -a net.guacamaya.action.OBSERVE_ON \
-n net.guacamaya/.adb.AdbCommandReceiver
-->
<receiver
android:name=".adb.AdbCommandReceiver"
android:exported="true">
<intent-filter>
<action android:name="net.guacamaya.action.OBSERVE_ON" />
<action android:name="net.guacamaya.action.OBSERVE_OFF" />
<action android:name="net.guacamaya.action.START" />
<action android:name="net.guacamaya.action.STOP" />
<action android:name="net.guacamaya.action.HEARTBEAT_ON" />
<action android:name="net.guacamaya.action.HEARTBEAT_OFF" />
</intent-filter>
</receiver>
</application>

</manifest>
17 changes: 4 additions & 13 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
android:theme="@style/Theme.Guacamaya"
android:hardwareAccelerated="true">

<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="${firebase_crashlytics_collection_enabled}" />

<activity
android:name=".ui.MainActivity"
android:exported="true"
Expand Down Expand Up @@ -74,18 +78,5 @@
android:exported="false"
android:foregroundServiceType="connectedDevice" />

<!-- adb: am broadcast -a net.guacamaya.action.OBSERVE_ON -n net.guacamaya/.adb.AdbCommandReceiver -->
<receiver
android:name=".adb.AdbCommandReceiver"
android:exported="true">
<intent-filter>
<action android:name="net.guacamaya.action.OBSERVE_ON" />
<action android:name="net.guacamaya.action.OBSERVE_OFF" />
<action android:name="net.guacamaya.action.START" />
<action android:name="net.guacamaya.action.STOP" />
<action android:name="net.guacamaya.action.HEARTBEAT_ON" />
<action android:name="net.guacamaya.action.HEARTBEAT_OFF" />
</intent-filter>
</receiver>
</application>
</manifest>
28 changes: 24 additions & 4 deletions android/app/src/main/kotlin/net/guacamaya/aware/NanMessenger.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

/**
Expand All @@ -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())
Expand All @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
}
65 changes: 65 additions & 0 deletions android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -333,6 +336,7 @@ private fun Screen(vm: MapViewModel = viewModel()) {
onPower = { onPower() },
onSelectMode = { onSelectMode(it) },
broadcastSupported = broadcastSupported,
locationGranted = locationGranted,
onOpenRadar = { showRadar = true },
)
}
Expand Down Expand Up @@ -371,6 +375,7 @@ private fun HomeScreen(
onPower: () -> Unit,
onSelectMode: (MeshMode) -> Unit,
broadcastSupported: Boolean,
locationGranted: Boolean,
onOpenRadar: () -> Unit,
) {
val lastNode = latestNodes.firstOrNull()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading