Sync & background work
Every change a field worker makes is saved on the phone first, then pushed to the server by a two-level queue that retries almost forever and never checks for conflicts.
Path shorthands: A: = app/src/main/java/org/platformcommons/urvi/, D: = data/src/main/java/org/mindtree/app/agricultor/data/.
The idea in one paragraph
Field workers often have no network. So the app never sends a change straight to the server. It saves the change in the local database and writes a "to do" note. Later, when there is a network, background jobs turn each note into an HTTP request, send it, and copy the server's new ids back into the local database. Some notes must wait for others (a crop plan cannot be sent before its farmer exists on the server), so the app tracks those dependencies too.
End-to-end flow
- User saves something (new farmer, land, crop plan, photo, endorsement...).
data/…/data/repo/DataRepository.ktwrites the entity to Room and callsSyncLedgerUtils.addInSyncLedger[WithDependencies]. - Level 1, the ledger. A row goes into Room table
sync_ledger(entity_name,entity_id= app UUID,sync_method,sync_op_id,sync_status = SYNC_NOT_INITIALIZED). If it must wait for another row, async_dependencyrow (parent_id->child_id) is added. SyncWorkruns (WorkManager, every 15 minutes, uniqueperiodic_sync; also kicked from Home). It picks ledger rows that have no pending parents and nosync_op_idyet.- An entity worker builds the request. For example
PortfolioLineItemWorkerlooks up server ids that now exist in Room (the asset id from the asset code), and builds a CoreAPIOperation: URL, method, body, file params, response-handler class name, and the ledger row asmetaJson. - Level 2, the offline-core queue.
CoreAPI.dooWithRetry(op)stamps the session id and a retry policy and stores the operation in SQLiteSYNC_QUEUE_DB, tableOPERATION(plusINTEGRATORfor library-level dependencies). The ledger row becomesIN_SYNC_QUEUEwithsync_op_idset. - The Android SyncAdapter drains the queue. It runs in a separate
:syncprocess (enabled inapp/…/urvi/view/home/HomeActivity.kt:706-731).SyncQueueProcessor.processSyncQueue()loads pending rows oldest first and sends each one. - A response handler applies the result (
data/…/data/sync/rh/*, base classBaseResponseHandler.kt).- Success (2xx): write server ids into Room, mark the ledger row
SYNC_DONE, delete itssync_dependencyrows. Children are now free and go out on the nextSyncWorkrun. - Failure: log it, and usually answer
LATER(try again after a delay). A few cases answerNEVER(drop).
- Success (2xx): write server ids into Room, mark the ledger row
- Clean-up. Garbage collection deletes finished queue rows after 30 seconds.
SYNC_DONEledger rows are deleted bySyncLedgerDao.
User save
-> Room entity + sync_ledger (+ sync_dependency)
-> SyncWork (15 min) -> <Entity>Worker -> CoreAPI Operation
-> SYNC_QUEUE_DB.OPERATION
-> SyncAdapter (:sync process) -> HTTP
-> <X>RH.onSuccess: server ids -> Room, ledger SYNC_DONE, children unblocked
<X>RH.onError : ErrorLogEntity, LATER (retry) or NEVER (drop)
Ordering and dependencies
Ordering is enforced before an operation exists: a child is not even built until its parent is done, so it can be built with the parent's real server id. There are no temporary ids on the server. The client keeps its own appId (UUID); for farmers the aliasId is that UUID.
| Child (entity, method) | Waits for |
|---|---|
IE_FLAT POST | IE POST (same farmer) |
IE PATCH | IE_FLAT POST if the farmer has no server id; an earlier IE PATCH still queued (a not-yet-started PATCH is merged: the new edit is skipped) |
IE PATCH_CATEGORY | IE PATCH, IE_FLAT POST |
IE_PORTFOLIO POST | IE_FLAT POST (if no farmer id) and FARMER_ASSETS POST (if the land has no id); also auto-queues the crop geofence from the land KML |
IE_ROLE_MAP POST / DELETE | IE_FLAT POST (created if missing) |
FARMER_ASSETS POST / PATCH / DELETE / GEOFENCING | farmer, then earlier asset ops |
IE_ATM POST / IMAGE / GEOFENCING | farmer, then ATM POST |
INVENTORY_PRODUCT POST / PATCH | farmer |
IE_PORTFOLIO_LINE_ITEM PUT / DELETE / image / GEOFENCING | portfolio POST, earlier line-item ops |
COMPACT_BLOCK POST / PUT / DELETE, COMPACT_BLOCK_MEMBER DELETE | earlier compact-block ops |
ENDORSEMENT_ATTACHMENT | ENDORSEMENT POST |
ENDORSEMENT, ENDORSEMENT_COMMENT, HARVEST, IE_ICONPIC | nothing, or the farmer |
Some writes skip the ledger and go straight into the level-2 queue: annual action plans (2025-26 POST/PUT, 2024-25 PATCH), ATM harvest, and assessment (AIA) uploads.
Retries
- Retry limit comes from ref-data
KEY_SYNC_RETRY_THRESHOLD_LIMIT(default 1176 attempts). Delay comes fromKEY_RETRY_DELAY_SECONDS(default 180 s), constant, with no backoff. - After the limit the operation is expired and its dependents are expired too (the library passes the two arguments in the wrong order,
SyncQueueProcessor.java:276). - A 401 stops the whole run. The library retries once on 401 or 503 inside a single send.
- An exception inside a response handler becomes
NEVER: the change is silently dropped. - A DELETE answered with "already deactivated" is retried anyway, because the success branch is commented out, so it loops.
Conflicts
There is no conflict detection. PUT and PATCH send whole objects, so the last write wins. A 409 is only treated as "server already saw this request". The one special case is the compact-block lock: if the server says a line item "is a part of compactBlock. Hence, Editing is not allowed.", the app refetches the item and overwrites the local copy.
Errors and the error log
- Every failure is saved in Room table
ErrorLogEntity(URL, body, headers, raw response) byBaseResponseHandler.logErrorInDb. - Every 10th attempt, the operation is copied to Firestore
SYNC_LOG. - A 403 after more than 2 attempts on a farmer that already has a server id becomes
NEVER, with a Firestore log. - Some handlers fix the stored request in place (
updateOperation), for example defaulttotalQuantity=0on an "Integrity" error, or fixowningEntityIdfrom the alias. - Asset-mismatch errors schedule
FetchFarmerAssetDataWorkerto refetch the asset and mark a sync error in the UI. - The Pending Sync screen (
app/…/urvi/view/pendingsync/*) lists queued operations with labels and per-item retry. A failure broadcast (SYNC_FAIL) is also sent, with no receiver. - Home repairs ledger rows that say
IN_SYNC_QUEUEbut whose operation has vanished (HomeViewModel.repairSyncLedger).
Image and file uploads
- Uploads are multipart operations with a
fileparam: crop photo, crop / land / ATM KML geofence, ATM photo, farmer photo, endorsement abstract. - Files live in the app cache and files folders (commit
14abc4ad). - Each upload needs the parent's server id, so it waits for the parent create.
- If the image path is an http URL (a server copy), the worker downloads it and uploads it again.
- Firebase fallback: after N failed crop-photo uploads (ref-data
FIREBASE_ATTACHMENT_FALLBACK_UPLOAD_FLAG), the photo goes to Firebase Storage (userId/farmerId/pliId/pliId.ext), its metadata to FirestorePLI_IMAGE, and the ledger row is marked done. The backend never sees that photo.
Live response handlers
All in data/…/data/sync/rh/. "Depends on" is the ledger dependency (see above).
| Handler | Entity / method | Endpoint | Depends on |
|---|---|---|---|
IEPostRH | IE POST | POST /ptld/api/customworknode/v1/worknodeietag/urvi/ie | none |
IEPatchRH | IE PATCH, PATCH_CATEGORY | PATCH /ptld/api/customworknode/v1/worknodeietag/urvi/ie | IE_FLAT POST, earlier IE PATCH |
IEFlatPostRH | IE_FLAT POST | POST /ptld/api/customworknode/v1/worknodeietag/urvi | IE POST |
IEIconPicRH | IE_ICONPIC PUT | PUT /ctld/api/ie/v1/iconpic | farmer |
IERoleMapPostRH | IE_ROLE_MAP POST | POST /ctld/api/ierolemap/v1 | IE_FLAT POST |
IERoleMapDeleteRH | IE_ROLE_MAP DELETE | DELETE /ctld/api/ierolemap/v1?iERoleMapId= | IE_FLAT POST |
InventoryProductPostRH | INVENTORY_PRODUCT POST | POST /ctld/api/inventory/inventoryproduct/custominventoryproduct/v1 | farmer |
InventoryProductPatchRH | INVENTORY_PRODUCT PATCH | PATCH /ctld/api/inventory/inventoryproduct/v1 | farmer |
FarmerAssetsPostRH | FARMER_ASSETS POST | POST /gateway/asset-service/api/v1/assets/ | farmer |
FarmerAssetsPatchRH | FARMER_ASSETS PATCH | PATCH /gateway/asset-service/api/v1/assets/ | farmer, earlier asset ops |
AssetDeleteRH | FARMER_ASSETS DELETE | DELETE /gateway/asset-service/api/v1/assets/{id} | earlier asset ops |
IEAssetGeofencingPostRH | FARMER_ASSETS GEOFENCING | POST /gateway/asset-service/api/v1/asset/attachment | asset POST |
IEPortfolioPostRH | IE_PORTFOLIO POST | POST /gateway/portfolio-service/api/v1/portfolios/line-items/v2 | IE_FLAT POST, FARMER_ASSETS POST |
IEPortfolioPatchRH | IE_PORTFOLIO PATCH | PATCH /gateway/portfolio-service/api/v1/portfolios/ | portfolio POST |
PortfolioLineItemPutWithCropChangeRH | IE_PORTFOLIO_LINE_ITEM PUT | PUT .../portfolios/{pid}/portfolioLineItem/v2/crop-activity | portfolio POST, earlier line-item ops |
PortfolioLineItemDeleteRH | IE_PORTFOLIO_LINE_ITEM DELETE | DELETE .../portfolios/portfolioLineItem/ | portfolio POST |
PortfolioLineItemPicRH | IE_PORTFOLIO_LINE_ITEM POST (photo) | POST .../portfolios/line-items/attachment/item/custom | portfolio POST |
PortfolioLineItemGeofencingPostRH | IE_PORTFOLIO_LINE_ITEM GEOFENCING | same, with saveFileContent=true | portfolio POST |
FarmingModelPostRH | IE_ATM POST | POST /gateway/portfolio-service/api/v1/farming-model | farmer |
ATMPicRH | IE_ATM POST_IMAGE | POST .../farming-model/attachment/custom | ATM POST |
FarmingModelGeofencingPostRH | IE_ATM GEOFENCING | same, with saveFileContent | ATM POST |
FarmingModelHarvestPostRH | ATM harvest (no ledger) | POST .../farming-model/harvest/{entityId}/{entityType} | none |
HarvestPostRH | HARVEST POST | POST .../portfolios/harvest/{id}/ENTITY_TYPE.IE | none |
CompactBlockPostRH | COMPACT_BLOCK POST | POST /gateway/portfolio-service/api/v1/compact-block | earlier CB ops |
CompactBlockPatchRH | COMPACT_BLOCK PUT | PUT .../compact-block | earlier CB ops |
CompactBlockDeleteRH | COMPACT_BLOCK DELETE | DELETE .../compact-block?compactBlockId&reason | earlier CB ops |
CompactBlockMemberDeleteRH | COMPACT_BLOCK_MEMBER DELETE | DELETE .../compact-block/{cbId}/compact-block-member | earlier CB ops |
EndorsementPostRH | ENDORSEMENT POST | POST /gateway/asset-service/api/v1/endorsement/bulk/save-all | none |
EndorsementAttachmentRH | ENDORSEMENT_ATTACHMENT | POST .../endorsements/attachment | ENDORSEMENT POST |
EndorsementCommentPostRH | ENDORSEMENT_COMMENT | POST .../endorsementComment | none |
AnnualActionPlan2526PostRH | AAP 2025-26 POST (no ledger) | POST .../annual-action-plan-extended | none |
AnnualActionPlan2526PutRH | AAP 2025-26 PUT (no ledger) | PUT .../annual-action-plan-extended | none |
AnnualActionPlanPatchRH | AAP 2024-25 PATCH (no ledger) | PATCH .../annual-action-plans | none |
Live background workers
| Worker | Schedule / constraints | What it does |
|---|---|---|
SyncWork (data/…/data/sync/work/SyncWork.kt) | Periodic 15 min, unique periodic_sync, REPLACE; also started from Home | Turns ready ledger rows into queued operations |
SyncAdapter SyncService (offline-android) | Android auto-sync, :sync process | Sends queued operations, runs response handlers |
FetchRefDataWorker | Periodic 1 h, network required | Reloads ref-data CLASS.PORTFOLIO_APP_CONFIG |
FetchCommentsNotificationWorker | Periodic 1 h, network required | Polls endorsementComment/postedby, shows local notifications |
FetchEndorsementDataWorker | One-time first run, then periodic 15 min (KEEP) | Pulls endorsement/me. Its unique name is "FetchAnalyticsDataWorker" (:84), so it and the analytics worker replace or skip each other |
FetchAnalyticsDataWorker | Periodic 12 h (KEEP) and on demand; network; foreground | Cadre / Unit In-charge analytics datasets. Skipped for unit login |
FetchNotificationsDataWorker | Periodic 24 h, no network | Local "N items pending to sync" notification |
FetchDisabledLandDataWorker | One-time from Home when the interval has passed and network is up | DS:DisabledAssetCodeForCadre, marks lands disabled |
FetchIEDataWorker | One-time KEEP, network, foreground; after data setup | Inventory products, AAP 2025-26, gap start date, assessments, image preload |
FetchUserDataWorker | One-time KEEP from the navigation drawer | The cadre's own roles, assets, portfolio, attachments, inventory |
SendSyncDetailsDataWorker | Periodic 6 h, network | POSTs the whole queue to sync-service/so |
SendUserAnalyticsWorker | Periodic 12 h, network | POSTs clickstream to sync-service/clickstream |
FetchFarmerAssetDataWorker (data/…/data/utils/LandDataUpdateWorker.kt; a same-named copy in app/…/urvi/utility/FarmerDataUpdateWorker.kt) | One-time REPLACE, from handler errors | Refetches one asset, overwrites local, flags a sync error |
PortfolioLineItemUpdateWorker | One-time, network, from the line-item PUT handler | Refetches line items, overwrites local, fixes the ledger |
UpdateLastSyncTimeWorker | One-time on every successful sync | Writes a "last synced" timestamp. One job per synced change is overkill |
Workers that exist but never run are listed on the stale page.
Push messages (FCM)
- The receiver is
app/…/urvi/fcm/FirebaseMessageReceiver.kt. The device token is only logged, never sent to a server, so nobody can push to a specific user. - The app subscribes to topics
urvi_endorsement_status,urvi_endorsement_commentandNOTIFICATION. - Endorsement-status and comment messages are shown only if that farmer is in the phone's local DB and the sender is someone else. They are also saved to the
Notificationstable. Anything else shows itstitleandbody. - The sender was the app itself: after an endorsement or comment syncs, the handler calls the legacy FCM API with a server key. That API was shut down in 2024 and the key is never loaded, so endorsement push is dead. Polling (15 min endorsements, hourly comments) is the only thing that works.
- Remote Config supplies
show_force_sync,show_analytics_tabandcurrent_season_value.
How sync is triggered
| Trigger | Where |
|---|---|
SyncWork started on Home open and refresh | app/…/urvi/view/home/HomeActivity.kt:176, :547 |
| SyncAdapter auto-sync | HomeActivity.enableSyncSettings |
Force sync from Settings / Pending Sync: checks network and session, starts SyncWork, drains the queue in the app process, then garbage-collects | data/…/data/sync/utils/SyncUtils.kt:45-115 |
| Per-item retry on the Pending Sync screen | app/…/urvi/view/pendingsync/* |
Before clear app data / logout / remote CLEAR_APP_DATA: send the queue to sync-service/so, zip the DBs to Firebase or the remote-action PATCH, then wipe | app/…/urvi/view/home/HomeActivity.kt:957-959 |
Problems
- Two queues for one job. The Room ledger and the library's SQLite queue both track state, and drift between them needs a repair step on Home.
- Two processes can drain the queue at once (the
:syncadapter and in-app force sync), with no lock. - No conflict detection: last write wins, silently.
- Retries are effectively infinite (1176 × 180 s, about 2.5 days of constant retries) with no backoff, then a silent expiry.
- Silent drops: an exception in a handler becomes
NEVER. - Client-side id resolution: about 40 handlers back-fill server ids; one failure leaves partial server state.
- The queue stores class names and Java-serialized files, so renaming or obfuscating a handler breaks queued data, and a new app cannot read the old queue.
- Uploads wait for parents, re-upload server images, and can bypass the backend via Firebase.
- PII leaves the device in queue snapshots (every 6 h) and Firestore logs.
- Push is dead, and the original design put a server key on the device.
- Worker name collision drops either analytics or endorsement refresh.
What the new outbox should do
- One Room table
outbox: id (UUID, also theIdempotency-Key), entity type, op, payload JSON,dependsOn, status, attempts,nextAttemptAt,lastError,createdAt. No reflection, no class names in the DB. - One WorkManager
CoroutineWorker: unique work, network required, exponential backoff, expedited for "Sync now". Drop the SyncAdapter,StubContentProviderand the:syncprocess. - Send batches to
POST /v2/sync/batch, keyed by clientappId. The App Service resolves parent references and returns{appId, serverId, status, error, serverVersion}per item. The client stops back-filling ids. - Upload files first to
POST /v2/filesand reference thefileId, so uploads never wait for a parent's server id. - Detect conflicts: send a version or
updatedAtprecondition; the server returns a typed conflict error (the compact-block lock becomes one of these). - Bounded retries, never silent: after the max attempts, mark the row
failed, show it on the Pending Sync screen, and allow manual retry. - Expose state as a Flow (pending count, failures) instead of broadcasts.
- Server-side push by user and VO, using device tokens registered with
PUT /v2/devices/{id}/push-token. - Migration: ship a final old-app release that forces a full drain of
SYNC_QUEUE_DBand shows the pending count before cutover, because the new app cannot read the old queue.