HOCO B2B
Mobile (Flutter)

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.

IDRequirementReleasePrioritySourceApps
SYN-01Back-office → apps flows: accounts, roles, zones, tours, objectives, tasks, catalog, prices, stock, promotions, order statuses, invoices, notifications, support repliesV1MUST§7.1API
SYN-02Apps → back-office flows: sign-ups, profiles, orders, addresses, payments, claims, positions, visits, photos, checklists, reports, installs, conversionsV1MUST§7.2API
SYN-03Critical data sent immediately when a connection is availableV1MUST§7.3DA · RA
SYN-04Delegate app keeps the tour, visits, notes and photos locally under weak networkV1MUST§7.3DA
SYN-05Sync queue handling retries, duplicates (idempotency), errors and conflictsV1MUST§7.3DA · API
SYN-06Visible sync state: synced, pending, errorV1MUST§7.3DA
SYN-07Real-time events: to the back-office (orders, visits, tours, positions) and to the reseller apps (their own order status changes)V1MUST§5.3, §6.4API · BO
MethodPathOperationAccessAppsRelease
POST/sync/batchsync.batch
Replay up to 50 queued offline mutations in order; per-item results with idempotent replays
delegateDAV1
GET/sync/deltasync.delta
Changes since a cursor for the delegate's offline scopes (tours, visits, resellers, tasks, reference lists)
delegateDAV1
POST/delegate/location-pingssync.locationPings
Batch of position samples during an active tour (policy-driven frequency)
delegate
Idempotency-Key
DAV1

The write path

Every user action in the delegate app follows the same path, online or not:

  1. 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.
  2. 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.
  3. 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.

TableHoldsFilled byKept
toursThe delegate's tours of the sync windowdelegate.tours.today, sync.deltaToday, the next planned days, the last 7 days
visitsStops of those tours with their local stateSame, plus local actionsWith their tour
resellersOne card per point of sale or prospect in scope (ResellerCard), plus full sheets of today's stopssync.delta, today snapshot, local creationsWhile in scope
tasksOpen follow-ups (relances)sync.delta, local actionsOpen, and done for 7 days
reference_listsLists (VISIT_NOT_DONE_REASON, VISIT_ISSUE_TAG, TOUR_SUSPEND_REASON, BUSINESS_TYPE, PERSON_ROLE), wilayas and communes of the zone, checklist templates, visit typesreference.*, sync.deltaReplaced per list
outboxQueued mutationsLocal actionsUntil applied, then 7 days for the sync centre history
uploadsPhotos waiting to be uploadedCameraUntil attached, file deleted afterwards
sync_stateCursors, last pull and push, clock offset, settings snapshot, engine leaseEngineAlways
location_pingsSamples taken during an IN_PROGRESS tourLocation serviceUntil sent

Columns (types are SQLite/drift types; JSON columns use a converter):

TableColumns
toursid 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
visitsid 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
resellersid 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
tasksid TEXT PK · resellerId TEXT · type TEXT (TaskType) · status TEXT (TaskStatus) · dueAt DATETIME · note TEXT NULL · version INT · dirty BOOL
reference_listslistKey TEXT · code TEXT · parentCode TEXT NULL (commune → wilaya) · labelFr, labelAr TEXT · sortOrder INT · active BOOL · etag TEXT NULL — PK (listKey, code)
outboxsee the Dart definition below
uploadsid 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_statekey 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_pingsid 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

FieldRule
idLocal UUIDv7 of the row (sync centre, logs). Not sent: the server identifies items by idempotencyKey
idempotencyKeyGenerated 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, bodyExactly the request the operation would send online; ids of offline-created records are client UUIDv7, so paths never need rewriting
occurredAtTime 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
dependsOnIdempotency 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)
coalesceKeySaves 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, nextAttemptAtBackoff bookkeeping (triggers)
statusState machine below
lastErrorLast 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:

ChainRule
Prospect created offline → anything about itdelegate.resellers.create first; notes, photos, an unplanned visit (delegate.tours.addVisit), an update and delegate.invitations.markShared list it in dependsOn
Visit lifecyclearrive → start → checklist / note / addPhoto / report → end; or notDone. Each step depends on the previous one of the same visit
One visit at a timeThe 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 lifecycledelegate.tours.start → suspend / resume pairs → visits; every visit item depends on the tour's start
Photo attachdelegate.visits.addPhoto / delegate.resellers.addPhoto depend on their upload reaching COMPLETED (the fileId is written into the body then)
Reorderdelegate.tours.reorder carries the tour's baseVersion; it depends on the last applied tour change
Optimistic locksdelegate.resellers.update and delegate.tasks.update carry the version read locally; a concurrent back-office edit returns CONFLICT
Closingdelegate.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).

ValueUI label (fr)Meaning
APPLIEDSynchronisé
REPLAYEDDéjà synchroniséSame Idempotency-Key seen before; original result returned.
CONFLICTConflitState changed on the server; the client must show it in the sync centre.
REJECTEDRejetéValidation or permission error; never retried automatically.
RETRYÀ réessayerTransient server error; retried with backoff.
ResultOutboxLocal dataUser sees
APPLIEDAPPLIED, result storedServer fields merged into the row (number, distanceMeters, gpsCheck, status); dirty cleared when no other item is pending for the entitySynchronisé
REPLAYEDSame as APPLIED — the first response was lost, the server returns the originalSameSynchronisé
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 deltaSync 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 »
REJECTEDREJECTED, never retried automatically; dependents BLOCKEDLocal change kept and flaggedSync 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 onceUnchangedEn attente
Whole batch fails (network, timeout, 5xx)Items back to PENDING; the same batch is resent with the same keyUnchangedEn attente, retry countdown
401 on the batchItems back to PENDING, engine pausedUnchangedSign-in prompt; replay resumes after sign-in by the same user
426 on the batchEngine pausedUnchangedForced-update screen; the outbox survives the update and replays after it

Triggers and backoff

TriggerAction
An item is enqueuedPush 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 resumedPush, then pull if the last pull is older than 5 minutes
Every 60 s while items are pending and the app is in the foregroundPush
Tour IN_PROGRESSThe location foreground service keeps the process alive, so the foreground triggers keep working with the screen off (location)
App in background with pending itemsWorkManager 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 centrePush 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.

SituationRule
Record not dirty (no pending item for it)Upsert the server version; delete on a tombstone
Record dirtyKeep 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 recordDelete 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 recordKeep 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 snapshotReplaces today's tour and visits that are not dirty; dirty visits keep their local state
Reference listsReplaced 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: falseHidden 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:

  1. files.createUpload with the stored createKey → fileId, upload URL (status CREATED).
  2. PUT to the URL with the returned headers (status UPLOADED). A 403 means the 10-minute URL expired: call createUpload again with the same key — the server returns the same fileId with a fresh URL (files).
  3. files.completeUpload with completeKey (status COMPLETED), then the fileId is written into the body of the waiting addPhoto outbox item, which becomes ready.
  4. 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

FeatureWhyOffline behaviour
Sign-in, password resetCredentials and OTP are server-sideFirst 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 aggregatesLocal 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 liveOffline banner; the draft is server-side
Duplicate phone checkNeeds the whole CRMThe 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, messagesServer readsLast 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 401 pauses 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

DataBudget or rule
DatabaseTarget under 50 MB; checked in the diagnostics screen
Tours and visitsToday, planned days of the sync window, last 7 days
Applied and discarded outbox items7 days
PhotosDeleted once attached; warning when pending photos exceed 200 MB or free storage is under 200 MB
Location pingsDeleted 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):

ScenarioStepsExpected
Airplane-mode visit dayDownload 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 backEverything 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-syncQueue 30 items; start a sync; kill the app during the batch; reopenIN_FLIGHT items return to PENDING; resend gives REPLAYED for the ones already applied; no duplicate
Duplicate replayReplay an applied batch manually (same keys)Every item REPLAYED, server state unchanged
More than 50 itemsQueue 120 items offlineThree batches, order preserved
Dependency rejectedCreate a prospect with a phone that already exists, then a note and a photo for itCreation CONFLICT (PHONE_ALREADY_EXISTS), note and photo BLOCKED; « Ouvrir la fiche existante » works when in scope
Planner change meanwhileOffline: end a visit; meanwhile the planner cancels the tourCONFLICT with Tournée annulée; local work kept; user discards or keeps
Token expired offlineRevoke the session on the server; act offline; reconnectEngine pauses on 401; sign-in prompt; replay resumes; nothing lost
Clock skewSet the phone clock 10 minutes ahead; act offline; reconnectWarning shown from serverTime; affected items REJECTED with OCCURRED_AT_OUT_OF_RANGE and the explanation
72-hour wallKeep 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 expiryCreate an upload, wait more than 10 minutes, then PUT403, same-key createUpload, same fileId, success
Weak networkThrottle to 2G/EDGE; sync 10 photosTimeouts retried; at most two uploads at a time; UI stays responsive
Forced update with pending itemsRaise minVersion on staging with items queuedUpdate screen; after updating, migrations run and the outbox replays
Concurrent enginesTrigger WorkManager while the app is openOne engine holds the lease; no item sent twice
Storage fullFill the device to under 200 MB freeCamera warns; existing queue keeps working

On this page