← GeoStream

12 — SDK Flutter

ID: GS-SDK-001 · Versión: 0.3.0 · Decisiones: ADR-0012, ADR-0014 (ampliada), ADR-0022, ADR-0025, ADR-0030, ADR-0031 Requisitos: FR-SDK-001..016, FR-MAP-, FR-SMP-, FR-CORE-, FR-BND-, NFR-MOB-*, NFR-COMPAT-001/003

1. Objetivo

Que un integrador comparta y muestre ubicación en tiempo real con pocas líneas, sin acoplarse a un proveedor de mapas, con estados y errores explícitos y con comportamiento correcto ante permisos, segundo plano, pérdida de red y relojes desfasados.

Ampliación de la pista E. Además de compartir y mostrar, el SDK decide cuándo publicar (§11.1), sobrevive a la desconexión con cola cifrada (§13.2), suaviza el dibujo localmente (§13.3) y expone la estimación del servidor como dato derivado (estimate). El objetivo es que la lógica que decide sea la misma en todos los SDK y no una reimplementación por lenguaje (26 §1).

2. Paquetes

Paquete Fase Contenido Dependencias de mapas
geostream F5 Núcleo: cliente REST, stream, credenciales, ubicación, compartición, vistas de sesión Ninguna (FR-SDK-016)
geostream_core E3 Bindings FFI de gs-client-core + gs-client-runtime (ADR-0031) Ninguna
geostream_maps F9 Interfaces de renderer, geocodificación, rutas y ProviderStack Ninguna
geostream_maps_maplibre F9 Adaptador MapLibre maplibre_gl
geostream_maps_google F9 Adaptador Google Maps google_maps_flutter
example/ F5 App de ejemplo (lista en F5, mapa en F9) Las de los adaptadores

Ubicación: sdks/flutter/. Los nombres de paquete en pub.dev se verifican en F5 (T-F5-01) antes de publicar.

3. Arquitectura interna

flowchart TB
  subgraph Fachada
    C[GeoStreamClient]
    SA[SessionsApi] --- IA[InvitationsApi] --- SH[SharingApi] --- LA[LocationsApi] --- PA[PermissionsApi]
  end
  subgraph Núcleo
    REST[RestClient]
    CRED[CredentialManager]
    CONN[StreamConnection<br/>máquina de estados + reconexión]
    CLK[ClockSync]
    SEQ[SequenceAllocator]
    PUB[PublishPipeline]
    VIEW[SessionViewStore]
    VAL["SampleValidator<br/>(vectores de contracts/)"]
    CORE["geostream_core (FFI)<br/>muestreo · suavizado<br/>cola · predicción"]
  end
  subgraph Plataforma["Puertos inyectables"]
    LOC[LocationSource]
    KV[KeyValueStore]
    NET[ConnectivityMonitor]
    TR[StreamTransportFactory]
    LOG[GeoStreamLogger]
    CLOCK[Clock]
  end
  C --> SA & IA & SH & LA & PA
  SA --> REST & VIEW
  SH --> REST & PUB & LOC
  LA --> VIEW
  PUB --> SEQ & CLK & CONN & VAL & CORE
  VIEW --> CONN & CORE
  CONN --> TR & CRED & CLK & NET
  REST --> CRED
  SEQ --> KV
  CORE --> KV

Reglas: la fachada no expone tipos de transporte ni Protobuf; los modelos públicos son clases Dart inmutables propias. Todos los puertos de plataforma son inyectables (FR-SDK-015).

El núcleo es opcional en tiempo de ejecución. Si geostream_core no está disponible (build web, plataforma sin FFI), el SDK cae a una implementación Dart equivalente con la misma semántica y los mismos vectores SMP-*/PRED-*. La equivalencia se verifica en CI (26 §4); no es una degradación silenciosa de la calidad, es una segunda implementación probada.

4. API pública (normativa)

class GeoStreamClient {
  GeoStreamClient(GeoStreamConfig config);

  /// Carga installation_id y secuencia, prepara puertos. Idempotente.
  Future<void> initialize();

  Stream<ClientState> get states;
  ClientState get state;

  SessionsApi get sessions;
  InvitationsApi get invitations;
  SharingApi get sharing;
  LocationsApi get locations;
  PermissionsApi get permissions;

  /// installation_id persistido (no secreto). Lo usa el CredentialProvider.
  String get installationId;

  /// Detiene compartición local, cierra conexión y libera recursos. Idempotente.
  Future<void> dispose();
}

class GeoStreamConfig {
  const GeoStreamConfig({
    required this.baseUrl,
    required this.credentialProvider,
    this.locationSource,             // por defecto: GeolocatorLocationSource
    this.store,                      // por defecto: SharedPreferencesStore
    this.logger,                     // por defecto: sin logs
    this.httpClient,                 // por defecto: http.Client()
    this.transportFactory,           // por defecto: WebSocketChannel
    this.connectivity,               // por defecto: ConnectivityPlusMonitor
    this.clock,                      // por defecto: SystemClock
    this.subprotocols = const [StreamSubprotocol.proto],
    this.reconnectPolicy = const ReconnectPolicy(),
    this.appVersion = '',
    this.backgroundDisconnectDelay = const Duration(seconds: 30),
    this.pauseAutoStopAfter = const Duration(minutes: 10),
  });
  // campos finales homónimos…
}

/// Implementado por el integrador: llama a SU backend, que llama a POST /v1/tokens.
abstract interface class CredentialProvider {
  Future<AccessToken> getAccessToken({
    required String installationId,
    required bool forceRefresh,
  });
}

final class AccessToken {
  const AccessToken({required this.value, required this.expiresAt});
  final String value;
  final DateTime expiresAt; // UTC
}

abstract interface class SessionsApi {
  Future<Session> create(CreateSessionOptions options);
  Future<Session> get(String sessionId);
  Future<Page<Session>> list({int limit = 50, String? cursor});
  Future<Session> update(String sessionId, UpdateSessionOptions options, {int? ifMatchVersion});
  Future<Session> close(String sessionId);
  Future<void> leave(String sessionId);
  Future<Page<Member>> members(String sessionId, {int limit = 50, String? cursor});
  Future<Member> me(String sessionId);
  /// Suscribe al stream mientras haya oyentes; reconstruye el estado tras reconexión.
  Stream<SessionView> watch(String sessionId);
}

abstract interface class InvitationsApi {
  Future<CreatedInvitation> create(String sessionId, {Capabilities? grants, int maxUses = 1, Duration? expiresIn});
  Future<RedeemedInvitation> redeem(String code);
  Future<void> revoke(String sessionId, String invitationId);
}

abstract interface class SharingApi {
  Future<void> start(
    String sessionId, {
    SamplingProfile profile = SamplingProfile.balanced,
    BackgroundMode background = BackgroundMode.foregroundOnly,
  });
  Future<void> stop(String sessionId);
  Stream<SharingState> states(String sessionId);
  SharingState state(String sessionId);
}

abstract interface class LocationsApi {
  /// LocationUpdate crudas (ya filtradas por secuencia por miembro).
  Stream<LocationUpdate> watch(String sessionId);
  Future<SessionSnapshot> snapshot(String sessionId);
}

abstract interface class PermissionsApi {
  Future<LocationPermissionStatus> check();
  /// Muestra el diálogo del sistema. El SDK NUNCA lo muestra por iniciativa propia.
  Future<LocationPermissionStatus> request(LocationPermissionLevel level);
  Future<bool> openAppSettings();
  Future<bool> openLocationSettings();
}

enum SamplingProfile { navigation, balanced, fleet, lowPower }
enum BackgroundMode { foregroundOnly, continueInBackground, always }
enum LocationPermissionLevel { whileInUse, always }
enum LocationPermissionStatus { granted, grantedWhileInUseOnly, approximateOnly, denied, deniedForever, restricted, servicesDisabled }
enum StreamSubprotocol { proto, json }

Modelos públicos (inmutables, con ==/hashCode, sin dependencias de Protobuf): Session, Member, Capabilities, SessionMode, SessionStatus, MemberRole, Connectivity, Position, LocationQuality, LocationSample, LocationUpdate { sessionId, memberId, sample, receivedAt, delivery }, SessionView, MemberView, SessionSnapshot, CreatedInvitation, RedeemedInvitation, Page<T> { items, nextCursor }, CreateSessionOptions, UpdateSessionOptions, ReconnectPolicy.

final class SessionView {
  final Session session;
  final Map<String, MemberView> members;   // por memberId, solo miembros ACTIVE
  final bool live;                         // false mientras se reconecta
}

final class MemberView {
  final Member member;                     // incluye sharing, connectivity, role, capabilities
  final LocationUpdate? lastLocation;      // null si no comparte o sin datos
  final LocationFreshness freshness;       // live | stale | none (calculado con hora del servidor)
}
enum LocationFreshness { live, stale, none }

Ejemplo de uso (ILUSTRATIVO):

final geo = GeoStreamClient(GeoStreamConfig(
  baseUrl: Uri.parse('https://geo.example.com'),
  credentialProvider: MyBackendCredentialProvider(),
));
await geo.initialize();

final perm = await geo.permissions.request(LocationPermissionLevel.whileInUse);
if (perm != LocationPermissionStatus.granted) { /* UX del integrador */ }

final invite = await geo.invitations.redeem('ABCD-EFGH-JKMN-PQRS');
await geo.sharing.start(invite.session.id, background: BackgroundMode.continueInBackground);

final sub = geo.sessions.watch(invite.session.id).listen((view) {
  for (final m in view.members.values) { /* pintar m.lastLocation */ }
});
// …
await geo.sharing.stop(invite.session.id);   // dejar de compartir sin salir
await sub.cancel();
await geo.dispose();

5. Estados

sealed class ClientState {}
final class Uninitialized extends ClientState {}
final class Idle extends ClientState {}                       // inicializado, sin conexión necesaria
final class Connecting extends ClientState { final int attempt; }
final class Connected extends ClientState { final String connectionId; }
final class Reconnecting extends ClientState { final int attempt; final DateTime retryAt; final GeoStreamException? lastError; }
final class Offline extends ClientState {}                    // sin red según ConnectivityMonitor
final class Superseded extends ClientState {}                 // cierre 4409
final class AuthFailed extends ClientState { final GeoStreamException error; }
final class Disposed extends ClientState {}

sealed class SharingState {}
final class NotSharing extends SharingState {}
final class SharingStarting extends SharingState {}
final class Sharing extends SharingState { final SamplingProfile profile; final bool approximate; final DateTime? lastPublishedAt; }
final class SharingPaused extends SharingState { final PauseReason reason; }
final class SharingStopping extends SharingState {}
final class SharingFailed extends SharingState { final GeoStreamException error; }

enum PauseReason { permissionRevoked, locationServicesDisabled, backgroundRestricted, offline, serverUnavailable }

Transiciones de compartición:

6. Errores

sealed class GeoStreamException implements Exception {
  String get message;
  ErrorCode? get code;      // del catálogo (09 §6), si vino del servidor
  String? get requestId;
}
Excepción Códigos del servidor / causa local
PermissionException(status) Permiso de ubicación insuficiente (local)
LocationServiceDisabledException Servicio de ubicación apagado (local)
ConfigurationException Configuración inválida, ProviderStack prohibido (local)
AuthenticationException UNAUTHENTICATED, TOKEN_EXPIRED; fallo del CredentialProvider
AuthorizationException PERMISSION_DENIED, INSUFFICIENT_SCOPE, ACCOUNT_DISABLED, MEMBER_REVOKED, NOT_PUBLISHER
NotFoundException NOT_FOUND, INVITATION_INVALID
SessionStateException SESSION_CLOSED, SESSION_EXPIRED, SESSION_FULL, NOT_SHARING
ConflictException CONFLICT, PRECONDITION_FAILED, IDEMPOTENCY_KEY_REUSED
ValidationException VALIDATION_FAILED, INVALID_LOCATION, PAYLOAD_TOO_LARGE
RateLimitException(retryAfter) RATE_LIMITED
ProtocolException PROTOCOL_ERROR, PROTOCOL_VERSION_UNSUPPORTED, TOO_MANY_SUBSCRIPTIONS; cierres 4000/4001
ServerException INTERNAL, TEMPORARILY_UNAVAILABLE
NetworkException Errores de transporte, timeouts (local)
StorageException Fallo del KeyValueStore (local)
ProviderException Fallo de un adaptador de mapas/geocodificación/rutas (F9)

Una prueba recorre todos los valores de ErrorCode y verifica que cada uno mapea a exactamente una excepción (FR-SDK-009). Un código desconocido (servidor más nuevo) mapea a ServerException.

7. Permisos y servicios de ubicación

BackgroundMode Android iOS
foregroundOnly ACCESS_FINE_LOCATION o ACCESS_COARSE_LOCATION When In Use
continueInBackground Igual + foreground service de tipo location iniciado con la app visible When In Use + allowsBackgroundLocationUpdates (indicador azul) iniciado en primer plano
always + ACCESS_BACKGROUND_LOCATION (necesario solo para empezar a compartir estando en segundo plano) Always

8. Conexión y reconexión

9. Installation ID y secuencia (ADR-0008)

10. Desfase de reloj

11. Muestreo y conversión

Perfiles (valores iniciales, a validar en campo — NFR-MOB-002):

Perfil Precisión solicitada Filtro de distancia Intervalo deseado Intervalo mínimo de publicación Fix de mantenimiento
navigation máxima 5 m 1 s máx(1 s, min_publish_interval_ms) 15 s
balanced alta 10 m 3 s máx(3 s, servidor) 60 s
fleet media 25 m 10 s máx(10 s, servidor) 120 s
lowPower baja (ahorro) 100 m 60 s máx(60 s, servidor) 300 s

11.1 Muestreo adaptativo y publicación por cambio (E3)

Los perfiles de §11 son el punto de partida. En la pista E la decisión de cuándo publicar pasa a ser adaptativa, y la lógica vive en gs-client-core (Rust), no en Dart, para que sea la misma en todos los SDK (26 §1, ADR-0022).

12. Segundo plano

12.1 Android

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Solo con BackgroundMode.always -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>

12.2 iOS (F7)

12.3 Cuando Flutter no basta

Si el plugin elegido no expone una capacidad necesaria de forma fiable, se implementa un plugin propio mínimo en Kotlin/Swift dentro de sdks/flutter/geostream/ mediante platform channels, con ADR que lo justifique.

13. Política offline (ADR-0014, ampliada por ADR-0025)

13.1 latestOnly — política de la línea base

13.2 encryptedQueue — política de la pista E (E3)

ADR-0025 sustituye a ADR-0014 solo para el caso en que el integrador lo elija explícitamente; latestOnly sigue siendo el valor por defecto y no se elimina (NFR-COMPAT-003).

13.3 Predicción visual

MemberView puede dibujar una posición interpolada o dead reckoning (22 §3.6) para suavizar el movimiento. Esa posición es local, efímera y no persistente: nunca se publica, nunca entra en la cola, nunca se registra (08 §7.6, ADR-0030).

14. Semántica de SessionView

15. Privacidad en el SDK

16. Pruebas

17. Compatibilidad y publicación