Skip to content

fix: correct geofence window handling and protect user data on upgrade - #71

Merged
arrase merged 1 commit into
mainfrom
fix/geofence-window-and-data-integrity
Sep 30, 2026
Merged

arrase merged 1 commit into
mainfrom
fix/geofence-window-and-data-integrity

Conversation

@arrase

@arrase arrase commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Resumen

Corrección del manejo de la ventana deslizante de geofences y protección de los datos del usuario al actualizar la base de datos.

Todos los fallos de la sección "Correctness" se verificaron contra un dispositivo real con una base de datos en v3 con datos existentes, no solo por lectura de código.

Correctness

Pérdida de datos al actualizar

fallbackToDestructiveMigration estaba activo sin ninguna migración registrada, así que cada actualización de la app borraba todas las ubicaciones y recordatorios. Se declara la cadena completa 1→5, y cada paso se valida con MigrationTestHelper contra el schema exportado.

Verificado en dispositivo: BD v3 con 5 ubicaciones y 2 recordatorios → tras instalar, v5, integridad correcta, los 7 registros intactos.

Notificaciones nunca mostradas

NotificationHelper comprobaba POST_NOTIFICATIONS sin guard de versión de SDK. Ese permiso no existe por debajo de API 33, así que checkSelfPermission devolvía siempre DENIED y notify() nunca se alcanzaba en todo el rango soportado (minSdk 24).

Geofence principal inerte

Se registraba con setTransitionTypes(GEOFENCE_TRANSITION_EXIT) pidiendo setInitialTrigger(INITIAL_TRIGGER_ENTER). GMS ignora un trigger inicial cuyo tipo no está en la máscara de transiciones de la geofence.

POIs perdidos en el antimeridiano

El bounding box solo contemplaba el cruce cuando minLon > maxLon. Cerca de ±180 la ventana queda en 174..184, la rama normal se activa y se descartan las longitudes negativas: -179.5 nunca se consultaba. La ventana se prueba ahora desplazada ±360 grados.

Verificado con SQLite: antes devolvía a, c; ahora también devuelve b, d.

Worker de recálculo

  • ExistingWorkPolicy.REPLACE podía cancelar un worker en ejecución entre removeAllGeofences() y el registro, dejando cero geofences. Ahora expedited y debounced usan nombres y políticas separadas (KEEP para no interrumpir un en curso, REPLACE para colapsar rafagas de edición).
  • El estado derivado se persistía antes de que GMS aceptara la ventana, así que la UI podía afirmar que existían geofences inexistentes.
  • Result.retry() sin límite generaba cadenas de reintento eternas en dispositivos sin fix.

Errores y mensajes

  • catch (e: Exception) tragándose CancellationException en repositorios, ViewModels, worker y receiver.
  • MutableSharedFlow(replay = 0) descartaba los mensajes de error emitidos sin collectors, justo cuando importan. Sustituido por un Channel con búfer.
  • "Error: null" en RemindersViewModel cuando la excepción no tenía mensaje.
  • El "borrado" se notificaba antes de que la operación tuviera éxito.

Alias con espacios (bug presente en datos reales)

Los alias se guardaban sin trim mientras el índice único es COLLATE NOCASE, que ignora mayúsculas pero no espacios. La UI decía "alias disponible" y SQLite rechazaba el insert con UNIQUE constraint failed.

Las escrituras nuevas se normalizan, y la migración v4→v5 limpia las existentes. rows que colisionarían en el índice único o quedarían en blanco se conservan intactas en vez de romper la app.

Otros

  • SettingsManager trataba 0.0 como "sin valor", haciendo la coordenada válida (0.0, 0.0) irrepresentable, y escribía lat/lng en transacciones separadas que podían leerse descuadradas.
  • Las mutaciones vía App Functions no disparaban recálculo: los recordatorios creados por el asistente no surtían efecto hasta la siguiente apertura de la app.
  • Las notificaciones usaban el id del recordatorio como request code del PendingIntent, así que ids en colisión sobrescribían los extras del intent del otro.

Limpieza

Código muerto eliminado: dependencias navigation-compose y hilt-navigation-compose sin uso, 2 providers DI, 2 métodos de GeofenceManager, 1 query del DAO, un flow de ViewModel sin consumir y 14 recursos de string en los 21 idiomas.

Duplicación extraída a BaseViewModel, ReminderStatusChips y ThemeSetting.resolve. Los tres constructores de geofence estaban duplicados.

Compose: remember en el ColorScheme dinámico, cast as Activity reemplazado por uno seguro, iconos de la barra de navegación, when exhaustivo en pestañas, BackHandler, estado de formularios persistente al rotar, colores de overlays derivados del tema, y MapView.onDetach() al salir de composición.

onDetach() se都不用 antes porque el comentario que lo justificaba era falso: verificado en el bytecode de osmdroid 6.1.20 que SqlTileWriter.onDetach() es un no-op y que el executor de tiles sí se filtra por instancia de MapView.

Verificación

  • 142 tests unitarios, 0 fallos
  • 8 tests instrumentados en Pixel 7 (API 37), 0 fallos
  • 0 errores de lint
  • Build limpio desde cero

Los tests de migración cubren el SQL de recorte de alias porque un subquery correlacionado que olvide cualificar su referencia externa hace que la guarda sea siempre cierta y la migración recorte nada en silencio, mientras la app abre con normalidad.

Nota

androidx.room:room-testing requiere kotlinx-serialization 1.8.1 pero la consistent resolution de Gradle la fijaba a 1.7.3, provocando AbstractMethodError. Se fija con un constraint documentado.

Breaks that could reach users, all verified against a real device holding a
v3 database with existing data:

- Database: `fallbackToDestructiveMigration` was active with no registered
  migrations, so every app update wiped all locations and reminders. Adds the
  full 1->5 chain; each step is validated by MigrationTestHelper against the
  exported schema.
- Notifications: `POST_NOTIFICATIONS` was checked without an SDK guard. The
  permission does not exist below API 33, so the check always reported denied
  and no notification was ever posted across the whole supported range
  (minSdk 24).
- Master geofence: registered with only `GEOFENCE_TRANSITION_EXIT` while
  requesting `INITIAL_TRIGGER_ENTER`, a combination GMS silently ignores.
- Spatial search: the bounding-box query only handled the antimeridian when
  minLon > maxLon, so a window near +/-180 discarded every point on the far
  side. The window is now tested at +/-360 degrees.
- Worker: `ExistingWorkPolicy.REPLACE` could cancel a running recalculation
  between removing and re-registering geofences, leaving none registered.
  Expedited and debounced work now use separate names and policies.
- Worker: derived state was persisted before GMS accepted the new window, so
  the UI could claim geofences that did not exist.
- Errors: `catch (e: Exception)` swallowed `CancellationException` in the
  repositories, ViewModels, worker and broadcast receiver.
- Messages: `MutableSharedFlow(replay = 0)` discarded error events emitted
  while nothing was collecting, which is exactly when they matter. Replaced
  with a buffered channel.
- `SettingsManager` treated 0.0 as "unset" for the last recalculation centre,
  making the valid coordinate (0.0, 0.0) unrepresentable, and wrote the two
  coordinates in separate transactions that could be read torn.
- Aliases were stored untrimmed while the unique index is `COLLATE NOCASE`,
  so the UI reported "alias available" and SQLite then rejected the insert.
  New writes are trimmed; a v4->v5 migration fixes existing rows, skipping
  any that would collide on the unique index or become blank.
- App Functions mutations never triggered a recalculation, so reminders
  created by the assistant had no effect until the app was next launched.
- Notifications shared the reminder id as a `PendingIntent` request code, so
  colliding ids overwrote each other's intent extras.

Cleanups: remove the unused navigation and Hilt-navigation dependencies, two
dead DI providers, two dead `GeofenceManager` methods, a dead DAO query, an
uncollected ViewModel flow, and 14 unused string resources across all 21
locales. Extract `BaseViewModel`, `ReminderStatusChips` and
`ThemeSetting.resolve` to remove repeated ViewModel and Compose logic, and
deduplicate the three geofence builders.

Compose: remember the dynamic color scheme, replace the `as Activity` cast
with a safe one, restore the navigation-bar icon appearance, make the tab
switch exhaustive, add a back handler, keep form state across rotation, derive
map overlay colours from the theme, and call `MapView.onDetach()` when a map
leaves composition so its tile thread pool is not leaked.

Tests: 142 unit tests and 8 instrumented tests pass, lint is clean. Adds
MigrationTest coverage for the alias-trimming SQL, since a correlated
subquery that forgets to qualify its outer reference makes the guard
permanently true and the migration silently trims nothing.
@arrase
arrase merged commit 6dab343 into main Sep 30, 2026
1 check passed
@arrase
arrase deleted the fix/geofence-window-and-data-integrity branch September 30, 2026 00:19
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant