Offline sync (delegate app)
The client side of the delegate app's offline-first design: drift schema, outbox model, ordering and dependency rules, replay through sync.batch, result handling, backoff, delta pulls and merge rules, photo queue, conflicts UX, purge and the test matrix.
Field networks are weak; the delegate app must keep a tour, its visits, notes and photos locally, replay them later without duplicates and show the user whether everything is synchronisé, en attente or en erreur (cahier §7.3, ADR-010). This page is the client half, built in hoco_offline (MOB-09) and surfaced in the sync centre (MDA-11). The server half — idempotency records, batch semantics, delta cursors — is the offline sync design; the wire format is on the sync API. Where this page and those pages differ, they win; the examples below follow the sync API page.
| ID | Requirement | Release | Priority | Source | Apps |
|---|---|---|---|---|---|
SYN-01 | Back-office → apps flows: accounts, roles, zones, tours, objectives, tasks, catalog, prices, stock, promotions, order statuses, invoices, notifications, support replies | V1 | MUST | §7.1 | API |
SYN-02 | Apps → back-office flows: sign-ups, profiles, orders, addresses, payments, claims, positions, visits, photos, checklists, reports, installs, conversions | V1 | MUST | §7.2 | API |
SYN-03 | Critical data sent immediately when a connection is available | V1 | MUST | §7.3 | DA · RA |
SYN-04 | Delegate app keeps the tour, visits, notes and photos locally under weak network | V1 | MUST | §7.3 | DA |
SYN-05 | Sync queue handling retries, duplicates (idempotency), errors and conflicts | V1 | MUST | §7.3 | DA · API |
SYN-06 | Visible sync state: synced, pending, error | V1 | MUST | §7.3 | DA |
SYN-07 | Real-time events: to the back-office (orders, visits, tours, positions) and to the reseller apps (their own order status changes) | V1 | MUST | §5.3, §6.4 | API · BO |
| Method | Path | Operation | Access | Apps | Release |
|---|---|---|---|---|---|
| POST | /sync/batch | sync.batchReplay up to 50 queued offline mutations in order; per-item results with idempotent replays | delegate | DA | V1 |
| GET | /sync/delta | sync.deltaChanges since a cursor for the delegate's offline scopes (tours, visits, resellers, tasks, reference lists) | delegate | DA | V1 |
| POST | /delegate/location-pings | sync.locationPingsBatch of position samples during an active tour (policy-driven frequency) | delegateIdempotency-Key | DA | V1 |
The write path
Every user action in the delegate app follows the same path, online or not:
- The local change and its outbox item are written in the same transaction: either both exist or neither does. The screen reads the local tables only, so it shows the new state immediately.
- The engine sends pending items as soon as a connection exists (cahier §7.3: « Les données critiques sont envoyées immédiatement lorsque la connexion est disponible »), in order, by batches.
- Results update the local rows (server numbers such as
VIS-2026-001234, computed distances, statuses) and the outbox item status.
Which operations are offline-capable is stated per operation on the API pages (tours and visits, prospects); the online-only list below summarises the exceptions.
Local database
hoco_offline uses drift (SQLite, typed queries, streams, tested migrations). One database file per signed-in user (hoco_<userId>.sqlite), opened in a background isolate and shared with the WorkManager isolate through drift's isolate sharing — two isolates opening the same file independently end in database is locked.
| Table | Holds | Filled by | Kept |
|---|---|---|---|
tours | The delegate's tours of the sync window | delegate.tours.today, sync.delta | Today, the next planned days, the last 7 days |
visits | Stops of those tours with their local state | Same, plus local actions | With their tour |
resellers | One card per point of sale or prospect in scope (ResellerCard), plus full sheets of today's stops | sync.delta, today snapshot, local creations | While in scope |
tasks | Open follow-ups (relances) | sync.delta, local actions | Open, and done for 7 days |
reference_lists | Lists (VISIT_NOT_DONE_REASON, VISIT_ISSUE_TAG, TOUR_SUSPEND_REASON, BUSINESS_TYPE, PERSON_ROLE), wilayas and communes of the zone, checklist templates, visit types | reference.*, sync.delta | Replaced per list |
outbox | Queued mutations | Local actions | Until applied, then 7 days for the sync centre history |
uploads | Photos waiting to be uploaded | Camera | Until attached, file deleted afterwards |
sync_state | Cursors, last pull and push, clock offset, settings snapshot, engine lease | Engine | Always |
location_pings | Samples taken during an IN_PROGRESS tour | Location service | Until sent |
Columns (types are SQLite/drift types; JSON columns use a converter):
| Table | Columns |
|---|---|
tours | id TEXT PK · number TEXT · day TEXT (YYYY-MM-DD, Algiers) · status TEXT (TourStatus) · orderingMode TEXT (IMPOSED/RECOMMENDED) · startedAt, suspendedAt, completedAt DATETIME NULL · plannedCount, doneCount INT · version INT · serverJson TEXT NULL · dirty BOOL · updatedAt DATETIME |
visits | id TEXT PK (server or client UUIDv7) · number TEXT NULL (until the server assigns it) · tourId TEXT · resellerId TEXT · position INT · slotStart, slotEnd DATETIME NULL · priority TEXT (NORMAL/HIGH) · status TEXT (VisitStatus) · visitTypeId TEXT · photosRequired, reportRequired BOOL · checkedInAt DATETIME NULL · checkInLat, checkInLng, checkInAccuracy REAL NULL · distanceMeters INT NULL · gpsCheck TEXT NULL · startedAt, endedAt DATETIME NULL · notDoneReason TEXT NULL · checklistJson, reportJson TEXT NULL · note TEXT NULL (300 chars) · version INT · localOnly BOOL · dirty BOOL |
resellers | id TEXT PK · number TEXT NULL · storeName, ownerName TEXT · phone, whatsapp TEXT (E.164) · wilayaCode TEXT · communeId, communeName TEXT · addressLine TEXT · lat, lng, accuracy REAL NULL · businessType TEXT · potential TEXT · customerType TEXT NULL · status TEXT (ResellerStatus) · lastVisitAt DATETIME NULL · sheetJson TEXT NULL (full sheet for today's stops) · version INT · localOnly BOOL · dirty BOOL |
tasks | id TEXT PK · resellerId TEXT · type TEXT (TaskType) · status TEXT (TaskStatus) · dueAt DATETIME · note TEXT NULL · version INT · dirty BOOL |
reference_lists | listKey TEXT · code TEXT · parentCode TEXT NULL (commune → wilaya) · labelFr, labelAr TEXT · sortOrder INT · active BOOL · etag TEXT NULL — PK (listKey, code) |
outbox | see the Dart definition below |
uploads | id TEXT PK · purpose TEXT (FilePurpose) · localPath TEXT (app documents directory, never the cache) · mimeType TEXT · size INT · sha256 TEXT · entityType, entityId TEXT · capturedAt DATETIME · lat, lng, accuracy REAL NULL · createKey, completeKey TEXT · fileId TEXT NULL · status TEXT (QUEUED, CREATED, UPLOADED, COMPLETED, FAILED) · attempts INT · nextAttemptAt DATETIME NULL · lastError TEXT NULL |
sync_state | key TEXT PK · value TEXT · updatedAt DATETIME — keys delta.cursor, pull.lastAt, push.lastAt, clock.offsetMs, settings.snapshot (the field.* and sync.* settings of appConfig.get), engine.lease |
location_pings | id TEXT PK · tourId TEXT · lat, lng, accuracy REAL · speed, heading REAL NULL · isMock BOOL · capturedAt DATETIME · batchKey TEXT NULL · sentAt DATETIME NULL |
enum OutboxStatus { pending, inFlight, applied, conflict, rejected, blocked, discarded }
@DataClassName('OutboxItem')
class Outbox extends Table {
IntColumn get seq => integer().autoIncrement()(); // replay order (FIFO)
TextColumn get id => text().unique()(); // UUIDv7, also the batch item id
TextColumn get userId => text()(); // never replayed under another account
TextColumn get opId => text()(); // delegate.visits.arrive
TextColumn get method => text()(); // POST, PUT, PATCH, DELETE
TextColumn get path => text()(); // /delegate/visits/0193…/arrive
TextColumn get body => text().map(const JsonMapConverter())();
TextColumn get idempotencyKey => text()(); // UUIDv7, fixed at creation
DateTimeColumn get occurredAt => dateTime()(); // device time of the action
DateTimeColumn get createdAt => dateTime()();
TextColumn get entityType => text()(); // TOUR, VISIT, RESELLER, TASK, INVITATION
TextColumn get entityId => text()();
TextColumn get coalesceKey => text().nullable()(); // visit:0193…:checklist
TextColumn get dependsOn => text().map(const StringListConverter())(); // idempotency keys, or upload:<id> (local only)
IntColumn get attempts => integer().withDefault(const Constant(0))();
DateTimeColumn get nextAttemptAt => dateTime().nullable()();
TextColumn get status => textEnum<OutboxStatus>()();
TextColumn get lastError => text().map(const JsonMapConverter()).nullable()(); // status, code, message, requestId
TextColumn get result => text().map(const JsonMapConverter()).nullable()(); // response body once applied
TextColumn get label => text()(); // « Arrivée — Magasin El Wafa » for the sync centre
}Migrations. Every schema change bumps schemaVersion with a step-by-step migration and a migration test generated from schema snapshots (drift's make-migrations workflow). An app update must never lose the outbox: a lost outbox is lost field data (git workflow).
Encryption at rest. V1 relies on the app sandbox and Android file-based encryption. If HOCO's privacy review (D-11) asks for more, SQLCipher can be added to drift at the cost of a larger APK — decide before the pilot, not after.
The outbox
| Field | Rule |
|---|---|
id | Local UUIDv7 of the row (sync centre, logs). Not sent: the server identifies items by idempotencyKey |
idempotencyKey | Generated once when the item is created and never changed while the payload is the same — it is also the key the direct call would use. Replays of the same item are answered REPLAYED; results come back keyed by it |
method, path, body | Exactly the request the operation would send online; ids of offline-created records are client UUIDv7, so paths never need rewriting |
occurredAt | Time of the action: the device clock corrected by the offset derived from the last serverTime (clock). Also inside body when the operation defines it — the two must be equal or the item is REJECTED. The server refuses values more than 5 minutes ahead or older than sync.maxOfflineHours (72 h) with OCCURRED_AT_OUT_OF_RANGE |
dependsOn | Idempotency keys of earlier items that must succeed first — sent as is in the batch — plus upload:<id> markers for photos, resolved locally and never sent (ordering) |
coalesceKey | Saves that replace each other — checklist, quick note, report draft of one visit: enqueuing a new save replaces the pending one with the same key (new body, new idempotency key, same seq position) instead of stacking ten saves; items that depended on the old key are re-pointed to the new one in the same transaction. Never coalesce an item already inFlight |
attempts, nextAttemptAt | Backoff bookkeeping (triggers) |
status | State machine below |
lastError | Last error envelope (status, code, message, requestId) shown in the sync centre |
On start-up, every IN_FLIGHT item goes back to PENDING (the app may have been killed mid-batch); replaying it with the same key is safe — the server answers REPLAYED with the original result.
Ordering and dependencies
The engine sends items in seq order and the server applies a batch in order. On top of FIFO, explicit dependencies prevent a later item from overtaking an earlier one across batches or retries:
| Chain | Rule |
|---|---|
| Prospect created offline → anything about it | delegate.resellers.create first; notes, photos, an unplanned visit (delegate.tours.addVisit), an update and delegate.invitations.markShared list it in dependsOn |
| Visit lifecycle | arrive → start → checklist / note / addPhoto / report → end; or notDone. Each step depends on the previous one of the same visit |
| One visit at a time | The server refuses an arrive while another visit of the tour is CHECKED_IN or IN_PROGRESS (409 VISIT_IN_PROGRESS), so the next visit's arrive depends on the previous visit's end or notDone |
| Tour lifecycle | delegate.tours.start → suspend / resume pairs → visits; every visit item depends on the tour's start |
| Photo attach | delegate.visits.addPhoto / delegate.resellers.addPhoto depend on their upload reaching COMPLETED (the fileId is written into the body then) |
| Reorder | delegate.tours.reorder carries the tour's baseVersion; it depends on the last applied tour change |
| Optimistic locks | delegate.resellers.update and delegate.tasks.update carry the version read locally; a concurrent back-office edit returns CONFLICT |
| Closing | delegate.tours.close is not queued: it is sent online once no item of the tour is pending (online-only) |
An item is ready when it is PENDING, its nextAttemptAt has passed and all its dependencies are APPLIED. When a dependency becomes REJECTED or DISCARDED, its dependents become BLOCKED and the sync centre shows them under the failed item.
Replay through sync.batch
loop while ready items exist and the network answers:
take up to 50 ready items in seq order, total body at most 1 MB (a dependency must be earlier in the batch or already sent)
mark them IN_FLIGHT
POST /sync/batch with Idempotency-Key = batchKey(items)
apply each result, then continue with the next batch/// Same set of items → same key, so a retried batch is replayed by the server;
/// a different set (new item, coalesced save) → a different key.
String batchKey(List<OutboxItem> items) =>
const Uuid().v5(_hocoBatchNamespace, items.map((i) => i.idempotencyKey).join(','));Request and response, shaped as on the sync API (SyncItem, SyncItemOutcome; a full worked example is on that page):
{
"items": [
{
"idempotencyKey": "0193a40b-1c2d-7e3f-8a90-b1c2d3e4f502",
"method": "POST",
"path": "/delegate/visits/0193a2f0-5b6c-7e8d-9f01-23456789ab11/arrive",
"body": {
"occurredAt": "2026-11-03T08:12:00Z",
"location": { "lat": 35.71143, "lng": -0.57262, "accuracy": 12, "capturedAt": "2026-11-03T08:11:58Z" },
"isMockLocation": false
},
"occurredAt": "2026-11-03T08:12:00Z"
},
{
"idempotencyKey": "0193a40b-1c2d-7e3f-8a90-b1c2d3e4f504",
"method": "POST",
"path": "/delegate/visits/0193a2f0-5b6c-7e8d-9f01-23456789ab11/start",
"body": { "occurredAt": "2026-11-03T08:14:00Z" },
"occurredAt": "2026-11-03T08:14:00Z",
"dependsOn": ["0193a40b-1c2d-7e3f-8a90-b1c2d3e4f502"]
}
]
}{
"results": [
{ "idempotencyKey": "0193a40b-1c2d-7e3f-8a90-b1c2d3e4f502", "result": "APPLIED", "status": 200, "body": { "visitId": "0193a2f0-5b6c-7e8d-9f01-23456789ab11", "status": "CHECKED_IN", "distanceMeters": 32, "gpsCheck": "WITHIN_TOLERANCE" }, "receivedAt": "2026-11-03T10:02:09Z" },
{ "idempotencyKey": "0193a40b-1c2d-7e3f-8a90-b1c2d3e4f504", "result": "APPLIED", "status": 200, "body": { "visitId": "0193a2f0-5b6c-7e8d-9f01-23456789ab11", "status": "IN_PROGRESS" }, "receivedAt": "2026-11-03T10:02:10Z" }
],
"serverTime": "2026-11-03T10:02:11Z"
}The batch request is always 200 when well-formed; per-item outcomes carry the result. Whole-request errors: 413 BATCH_TOO_LARGE (split the batch), 409 IDEMPOTENCY_KEY_REUSED (a client bug in batchKey), 409 IDEMPOTENCY_IN_PROGRESS (the same batch is still running — wait and resend).
| Value | UI label (fr) | Meaning |
|---|---|---|
APPLIED | Synchronisé | |
REPLAYED | Déjà synchronisé | Same Idempotency-Key seen before; original result returned. |
CONFLICT | Conflit | State changed on the server; the client must show it in the sync centre. |
REJECTED | Rejeté | Validation or permission error; never retried automatically. |
RETRY | À réessayer | Transient server error; retried with backoff. |
| Result | Outbox | Local data | User sees |
|---|---|---|---|
APPLIED | APPLIED, result stored | Server fields merged into the row (number, distanceMeters, gpsCheck, status); dirty cleared when no other item is pending for the entity | Synchronisé |
REPLAYED | Same as APPLIED — the first response was lost, the server returns the original | Same | Synchronisé |
CONFLICT (also with replayed: true when an earlier attempt already got it) | CONFLICT; dependents wait (in the same batch the server answers them REJECTED with a dependency error) | The local row keeps the user's version and a serverJson copy of the server state from details or the next delta | Sync centre: what happened (« La visite a été annulée par le planificateur »), choices « Ignorer mon action », « Réessayer » (new payload, new key), « Voir la fiche » |
REJECTED | REJECTED, never retried automatically; dependents BLOCKED | Local change kept and flagged | Sync centre: the server message, « Modifier » when the form can fix it (e.g. a phone already used), « Ignorer » |
RETRY (server error, IDEMPOTENCY_IN_PROGRESS, dependency still pending, BATCH_TIME_BUDGET after 20 s of processing) | Back to PENDING, attempts + 1, backoff — BATCH_TIME_BUDGET items are resent at once | Unchanged | En attente |
Whole batch fails (network, timeout, 5xx) | Items back to PENDING; the same batch is resent with the same key | Unchanged | En attente, retry countdown |
401 on the batch | Items back to PENDING, engine paused | Unchanged | Sign-in prompt; replay resumes after sign-in by the same user |
426 on the batch | Engine paused | Unchanged | Forced-update screen; the outbox survives the update and replays after it |
Triggers and backoff
| Trigger | Action |
|---|---|
| An item is enqueued | Push after 2 s of quiet (debounce), so one visit's quick actions travel together |
Connectivity becomes available (connectivity_plus) | Push, then pull — connectivity is only a hint; the request itself is the real test |
| App resumed | Push, then pull if the last pull is older than 5 minutes |
| Every 60 s while items are pending and the app is in the foreground | Push |
Tour IN_PROGRESS | The location foreground service keeps the process alive, so the foreground triggers keep working with the screen off (location) |
| App in background with pending items | WorkManager one-off task with a network constraint, plus a periodic task every 15 minutes (workmanager), running the same engine in a background isolate |
Silent push {"type": "sync", "scope": "tours"} | Pull (notifications matrix › payload) |
| « Synchroniser maintenant » in the sync centre | Push and pull, ignoring backoff |
Backoff for RETRY items and failed batches: 2 s, 4 s, 8 s … capped at 5 minutes, ± 20 % jitter; reset when connectivity changes. After 8 failed attempts the item shows En erreur in the sync centre but keeps retrying at the cap — the user can force a retry.
One engine at a time. The UI isolate and the WorkManager isolate share the database; a lease row in sync_state (engine.lease: owner and expiry, renewed every 20 s, taken in a transaction) prevents two engines from sending the same items.
Clock. The sync operations, the today snapshot and appConfig.get all return serverTime; the app stores the offset (clock.offsetMs) and stamps occurredAt with the device clock corrected by it (sync API › conventions). If the device clock is more than 5 minutes off, the app also warns (« L'heure du téléphone est incorrecte — activez l'heure automatique », app config). Durations (visit timer) use a monotonic stopwatch, not wall-clock differences.
The 72-hour wall. Items older than 48 hours trigger a persistent banner and a local notification (« Des actions attendent depuis plus de 48 h — connectez-vous à un réseau »); past sync.maxOfflineHours the server rejects them, and the sync centre explains why.
Delta pulls and merge rules
The app downloads today's tour as a snapshot (delegate.tours.today) and keeps everything else fresh with sync.delta: changes and tombstones since a cursor for the scopes tours (today − 7 days to today + 30 days), visits, resellers (every point of sale in scope, as ResellerCard), tasks, referenceLists and announcements. The first cursor comes with the today snapshot (deltaCursor). Pull order: push first (so the server already has local actions), then pull pages (limit up to 1 000) while hasMore is true, storing nextCursor after each page in the same transaction as the page's rows.
| Situation | Rule |
|---|---|
| Record not dirty (no pending item for it) | Upsert the server version; delete on a tombstone |
| Record dirty | Keep the local fields; store the server copy in serverJson and its version. When the pending items are applied, their results (or the next delta) replace the row |
Tombstone (OUT_OF_SCOPE: reseller or tour reassigned, tour out of the window; DELETED) on a clean record | Delete locally (visits of a cancelled tour are kept read-only until the day ends, with the status pill Annulée) |
| Tombstone or out of scope on a dirty record | Keep the row, mark its pending items as conflicts with « Ce point de vente n'est plus dans votre secteur » or « Tournée annulée » |
| Today's snapshot | Replaces today's tour and visits that are not dirty; dirty visits keep their local state |
| Reference lists | Replaced per listKey when the version or ETag changes |
fullResyncRequired: true (cursor older than 7 days, zones changed, delta format changed) | Drop the cached scopes — never the outbox — reload the today snapshot and a delta without cursor, then re-apply pending local state on top |
Reference items with active: false | Hidden from pickers, kept to display past records |
Photo queue
Photos are taken with the camera only for visit proof (no gallery), saved as JPEG (quality 85, longest edge 2560 px) in the app's documents directory with their capture time and position, and recorded in uploads. The queue runs independently of sync.batch, because binaries never travel in a batch:
files.createUploadwith the storedcreateKey→fileId, upload URL (statusCREATED).PUTto the URL with the returned headers (statusUPLOADED). A403means the 10-minute URL expired: callcreateUploadagain with the same key — the server returns the samefileIdwith a fresh URL (files).files.completeUploadwithcompleteKey(statusCOMPLETED), then thefileIdis written into the body of the waitingaddPhotooutbox item, which becomes ready.- Once that item is
APPLIED, the local file is deleted.
At most two uploads run at a time; on a metered or weak network the queue waits between files. Pending files not attached within 24 hours are deleted by the server, so a photo that waited longer simply restarts at step 1 with new keys. If free storage drops under 200 MB the camera button warns before capturing.
Location pings
Samples recorded while a tour is IN_PROGRESS go to location_pings, not to the outbox, and are sent in batches to sync.locationPings — every 15 minutes or 20 samples, whichever comes first, in chunks of at most 200 (413 BATCH_TOO_LARGE above), with an idempotency key written on the rows (batchKey) before sending so a retry resends the same body; samples listed in dropped (outside the tour interval, too old) are deleted. Pings older than 72 hours are dropped. Sampling itself is on location and maps.
What stays online-only
| Feature | Why | Offline behaviour |
|---|---|---|
| Sign-in, password reset | Credentials and OTP are server-side | First sign-in needs a network; later launches open on the stored session |
| Tour closing (DA-20) | Closing needs every action of the day on the server (cahier §6.9) | The local summary is shown; « Terminer la tournée » is disabled with the number of pending items and a link to the sync centre |
| Closing preview figures (revenue, installs) | Server aggregates | Local counters only |
| Invitation creation (DA-16) | The code and the signed link are generated by the server | « Connexion requise pour générer l'invitation »; an invitation already created is cached and its QR shown offline; sharing is recorded offline (markShared) |
| Assisted order (DA-17) | Prices, availability and credit are checked live | Offline banner; the draft is server-side |
| Duplicate phone check | Needs the whole CRM | The app checks its local cards; the server repeats the check at replay (PHONE_ALREADY_EXISTS → conflict) |
| Orders of my resellers (DA-18), reports and performance (DA-21), objectives, calendar and history, messages | Server reads | Last cached copy with its date (« Données du 3 nov. à 08:10 ») |
Sync centre and conflicts
The sync centre (DA-27) is reachable from the account tab and from the status chip shown in the header of the home, tour and visit screens (Synchronisé · En attente (3) · En erreur (1)), SYN-06.
- Header: connection state, last successful push and pull, « Synchroniser maintenant ».
- Needs attention (conflicts, rejected, blocked) first, grouped by record (Magasin El Wafa — visite VIS-2026-001234), each with the server message, the
requestId(copyable for support) and the actions above. Discarding asks for confirmation and is logged (Sentry breadcrumb plus the diagnostics buffer). - Pending: label, time of the action, attempts, next retry.
- Synchronised today: the last items applied, for reassurance.
- Photos: per-file progress and size, total waiting.
- The tour-closing screen links here when it is blocked; sign-out is refused while items are pending (sign-out rule).
Sign-out, account switch and updates
- Sign-out is refused while the outbox or the upload queue holds anything; the user is sent to the sync centre. A forced sign-out (lost phone handled by the back-office revoking the session) keeps the database file; the next sign-in of the same user replays it.
- Another user signs in on the same phone: the previous user's database is kept, never replayed under the new session. If it still holds pending items, the app tells the new user and the back-office (diagnostic event) — the previous delegate must sign in again to flush it.
- Session expired offline: work continues locally; the first
401pauses the engine and asks for the password; after sign-in by the same user, replay resumes with the same keys. - App update: migrations run before the engine starts; the forced-update gate never deletes local data.
Storage limits and purge
| Data | Budget or rule |
|---|---|
| Database | Target under 50 MB; checked in the diagnostics screen |
| Tours and visits | Today, planned days of the sync window, last 7 days |
| Applied and discarded outbox items | 7 days |
| Photos | Deleted once attached; warning when pending photos exceed 200 MB or free storage is under 200 MB |
| Location pings | Deleted once sent; dropped after 72 hours |
| Cached reads (reports, orders, objectives) | Last result per screen, replaced on refresh |
The purge runs at start-up and after each successful pull, inside the engine lease.
Test matrix
Unit tests cover the outbox (coalescing, dependency resolution, batch building, key stability), result handling and merge rules with an in-memory drift database. These scenarios run as integration tests against a fake server, then by hand on a phone against staging, and again in the field test (MOB-13):
| Scenario | Steps | Expected |
|---|---|---|
| Airplane-mode visit day | Download today's tour; airplane mode; start tour; two full visits (arrive, start, checklist, note, 2 photos, report, end); one not-done; create a prospect with a façade photo and an unplanned visit to it; network back | Everything applies in order, no duplicate; numbers appear; photos uploaded and attached; prospect visible in the back-office CRM with source, delegate and position (cahier §12 Hors ligne, Prospect) |
| App killed mid-sync | Queue 30 items; start a sync; kill the app during the batch; reopen | IN_FLIGHT items return to PENDING; resend gives REPLAYED for the ones already applied; no duplicate |
| Duplicate replay | Replay an applied batch manually (same keys) | Every item REPLAYED, server state unchanged |
| More than 50 items | Queue 120 items offline | Three batches, order preserved |
| Dependency rejected | Create a prospect with a phone that already exists, then a note and a photo for it | Creation CONFLICT (PHONE_ALREADY_EXISTS), note and photo BLOCKED; « Ouvrir la fiche existante » works when in scope |
| Planner change meanwhile | Offline: end a visit; meanwhile the planner cancels the tour | CONFLICT with Tournée annulée; local work kept; user discards or keeps |
| Token expired offline | Revoke the session on the server; act offline; reconnect | Engine pauses on 401; sign-in prompt; replay resumes; nothing lost |
| Clock skew | Set the phone clock 10 minutes ahead; act offline; reconnect | Warning shown from serverTime; affected items REJECTED with OCCURRED_AT_OUT_OF_RANGE and the explanation |
| 72-hour wall | Keep an item offline past 48 h, then past 72 h (simulated clock in tests) | Banner and notification at 48 h; rejection explained after 72 h |
| Upload URL expiry | Create an upload, wait more than 10 minutes, then PUT | 403, same-key createUpload, same fileId, success |
| Weak network | Throttle to 2G/EDGE; sync 10 photos | Timeouts retried; at most two uploads at a time; UI stays responsive |
| Forced update with pending items | Raise minVersion on staging with items queued | Update screen; after updating, migrations run and the outbox replays |
| Concurrent engines | Trigger WorkManager while the app is open | One engine holds the lease; no item sent twice |
| Storage full | Fill the device to under 200 MB free | Camera warns; existing queue keeps working |
API integration
How the apps consume the contract: pinning the OpenAPI document, generating hoco_api, the Prism mock, the dio interceptors, the SSE client for live order status, pagination, money and date models, uploads, error UX and the contract-change procedure.
Location and maps
Consent and permissions, the location service, sampling only during tours through a foreground service, the GPS tolerance check at arrival, maps and external navigation in the delegate app, the regional WhatsApp lookup of the reseller app, and testing with mock locations.