Sibling libraries
Four in-house "Bridge" libraries carry Urvi's login, HTTP and offline write queue; the rewrite should replace all four and move their platform logic into the App Service.
How they fit together
| Layer | Library | Role in Urvi |
|---|---|---|
| Request model | core-api | Operation + CoreAPI run every REST call, now or queued |
| Queue logic | offline-core | Dependency-aware retry processor and garbage collector |
| Queue storage + drain | offline-android | SQLite SYNC_QUEUE_DB, SyncAdapter in a :sync process |
| Auth | account-lib | AccountManager authenticator, login UI, session token |
The write path is:
- A screen saves an entity to Room with
syncStatus=SYNC_NOT_INITIALIZED. - A WorkManager worker (
data/.../sync/work/*Worker.kt) builds anOperationand callsCoreAPI.dooWithRetry(data/.../manager/CoreAPIManager.kt:44-58). CoreAPIOfflineImplwrites the operation to SQLite instead of sending it.SyncWorkStatusHelper.triggerSyncAdapterasks Android to run theSyncAdapter.- The adapter drains the queue through
SyncQueueProcessorand core-api. - About 40 response handlers (
data/.../sync/rh/*RH.kt) write server ids back into Room.
Online-only calls (setup data, VO filters, endorsements) skip the queue and use CoreAPI.dooNowOrThrow.
core-api
What it does. A small "operation" abstraction over REST. An Operation<T> holds URL, method, body DTO, multipart params, headers, a response handler and integrators. CoreAPI runs it now or queues it.
| Item | Value |
|---|---|
| Coordinates | com.mindtree.bridge.framework:CoreAPI:8.2.2 (versions.gradle:74), from private Nexus |
| Source repo | 8.2.1, one version behind the consumed artifact |
| Size | 15 Java files, about 1.2k LOC, Java 1.8 jar |
| HTTP | framework-android-rest-client:4.2.0, which wraps Apache HttpClient |
| Loose copy | CoreAPI-8.2.2.jar at the repo root is not on any classpath |
What Urvi uses.
Operationheavily: 12 imports in:app, 67 in:data.CoreAPI.dooNowOrThrowfor all online calls.CoreAPI.dooonly through thedooWithRetryextension.CoreAPIFactory.getCoreAPI(app/.../bridge/BridgeUtil.kt:32,CoreAPIManager.kt:24).ResponseHandler(about 40 subclasses ofBaseResponseHandler.kt) andIntegrator(IECreateAIACreateIntegrator,AttachmentIntegrator).TryAgainvaluesNOW,LATER,NEVER.RetryScheduler.Policy(1176, 0, retryDelay): up to 1,176 attempts at a constant delay.
Key internals.
- The factory loads
CoreAPIOfflineImplby reflection, else falls back toCoreAPIDefaultImpl. Each call builds a new instance. - URL = session base URL +
op.getUrl(). Body is Gson JSON.Content-Type: application/jsonalways. - Auth:
X-SESSIONID= AccountManager auth token, on every call. - On 401 it invalidates the token and retries. The next token fetch silently logs in again with the stored password.
- On 503 it retries.
dooNowOrThrowallows 1 retry,dooallows 2. - Dates go on the wire as epoch milliseconds.
Known bugs.
| Bug | Where |
|---|---|
dooNowOrThrow never throws; it swallows every exception, so callers must check getStatus() | impl/CoreAPIDefaultImpl.java |
Response headers are copied into op.params, not responseHeaders | CoreAPIDefaultImpl.processResponse |
Unused parts. doo(op, PriorityQueue), PriorityQueue, setDeviceID, deprecated TryAgain.IN*, URLUpdater, MultipartByteSeek, getNewRequestId.
offline-core
What it does. Platform-agnostic logic for a persistent outbound queue: entity model, Operation mapper, processor (dependencies, retries, expiry) and garbage collection. Storage comes from offline-android.
| Item | Value |
|---|---|
| Coordinates | com.mindtree.bridge.framework:OfflineCore:8.2.1 (versions.gradle:86), transitive = false |
| Size | 11 Java files, about 1.7k LOC |
| Repo state | Branch feature/app_version_header; the only local edit is whitespace |
What Urvi uses.
SyncQueueProcessor.processSyncQueue()directly indata/.../sync/utils/SyncUtils.kt:71-80(forceSync), and through theSyncAdapter.SyncQueueGarbageCollectorImpl.collectGarbage(dao, 30000)(SyncUtils.kt:78).OperationManagerImplfor the Pending Sync screen (app/.../pendingsync/PendingSyncViewModel.kt:20) and integrator repair (HomeViewModel.kt:63).OperationMapper.mapOperation(HomeViewModel.kt:70).- Raw
OperationEntityreads inapp/.../utility/DataUtil.kt:855andClearSyncQueueWorker.kt.
Key internals.
| Concern | Behaviour |
|---|---|
| Persistence | Body as JSON. params (may hold File) as Java-serialized, Base64 HashMap. Handlers and integrators stored as class names, rebuilt by reflection |
| Idempotency | POST/PUT/PATCH get X-REQUESTID: <UUID> at enqueue |
| Three-way ACK | Each send also carries X-ACK-REQUESTID (one earlier successful id) so the server can forget it |
| Ordering | Rows sorted CREATE_TIME ASC, QUEUE_PRIORITY ASC; priority only breaks ties |
| Dependencies | Each integrator names a source op. Source runs first (depth-first); then integrate(target, source) copies the server id into the target |
| Retries | LATER sets nextAttempt = now + delaySeed + attempts*gradient. Urvi uses gradient 0, so the delay is constant |
| 401 | Sets syncBlocked and stops the run |
| 409 | Treated as "already seen": old request id moves to the ACK set, a new UUID is issued. No payload merge |
| Expiry | After attemptThreshold (1,176) the op and every dependent op are cleared |
| GC | Deletes evaluated rows older than 30 s unless a pending target depends on them |
| Concurrency | None. Plain HashMaps, no lock |
Known bugs.
| Bug | Where |
|---|---|
| Queueing an op without a retry policy throws an NPE | OperationMapper.java:124 |
onSourceExpire arguments are swapped | SyncQueueProcessor.java:276 |
A response-handler exception becomes NEVER; the op is silently dropped | SyncQueueProcessor |
Two processes can drain the same SQLite file at once (:sync adapter and in-app forceSync); the isSyncActive check is racy | SyncUtils.kt, SyncAdapter |
| Renaming or obfuscating any handler, integrator or DTO class breaks rows already in the queue | OperationMapper |
Unused parts. OperationManager.addDeviceInPreference / getDeviceID.
offline-android
What it does. The Android side of the queue: SQLite DAO, CoreAPIOfflineImpl (persists instead of sending), SyncAdapter that drains the queue, and failure broadcasts.
| Item | Value |
|---|---|
| Coordinates | com.mindtree.bridge.framework:OfflineAndroid:8.2.3@aar (versions.gradle:75) |
| Size | 19 Java files, about 1.4k LOC |
| Build | compileSdk/targetSdk 27, minSdk 19, support library (pre-AndroidX, jetified) |
| Pulls in | OfflineCore 8.2.1, bridge-account-lib 2.7.1 (older than Urvi's 2.7.13) |
What Urvi uses.
CoreAPIOfflineImpl(via reflection):doowrites withqueuePriority = 1.OperationDaoSqLiteImplandSQLiteHelperdirectly, including raw cursor reads (DataUtil.kt:856).SyncService+SyncAdapter, enabled inHomeActivity.kt:708-728.StubContentProvider, declared inapp/src/main/AndroidManifest.xml:178.SyncCallback(SyncUtils.kt:66),SyncListener(AppSyncListener),SyncHelper.getQueueSize(8 call sites).
Key internals.
- DB
SYNC_QUEUE_DBv3. TableOPERATION(22 columns: id, URL, method, message, params, headers, handler class, DTO class, evaluated, user, attempts, next attempt, retry policy and more). TableINTEGRATOR(class name, target id, source id). - ACK ids live in SharedPreferences
CORE-API-SYNC-3WAY. SyncAdapter.onPerformSyncbuildsAndroidSessionManager(account), runs the processor, then GC, then calls theSyncListeneron raw threads. Exceptions are swallowed.SyncCallbacksends an implicit, unprotected broadcast (com.mindtree.bridge.coreapi.SYNC_FAIL) for every non-2xx, with status, error and account.
Known bugs.
| Bug | Where |
|---|---|
Queue priority ignored (sort is CREATE_TIME first) | OperationDaoSqLiteImpl.java:183 |
ACK code mutates the set returned by getStringSet; change may not persist, order is arbitrary | OperationDaoSqLiteImpl.java:297-339 |
Targets SDK 27; obsolete READ/WRITE_EXTERNAL_STORAGE | library manifest |
| Nexus read credentials hardcoded | offline-android/build.gradle:11-12,21-22,43-44,53-54 |
Unused parts. Sync (init/start/cancel), SyncHelper.triggerSyncNow / addPeriodicSync, SyncConstants.AUTHORITY (Urvi overrides it in app/src/main/res/xml/syncadapter.xml), SyncResponseBroadcastReceiver (no receiver), OperationSplitter / FileSplitter chunked upload (call commented out).
account-lib
What it does. An Android AccountManager authenticator for "Bridge" accounts: login UI, session token issue and refresh, base URL and tenant (federation) selection, and device-registration headers.
| Item | Value |
|---|---|
| Coordinates | com.mindtree.bridge.framework:bridge-account-lib:2.7.13-urvi (versions.gradle:73); :app takes it transitively, :data with transitive=false |
| Repo state | Branch dev-firoz, uncommitted edits in 43 files; the published aar may not match the tree |
| Size | 41 Java files, about 8k LOC; strings in en, hi, ta, te |
| Leaks deps | api exposes okhttp 5.2.1, retrofit 3.0.0, converter-gson 3.0.0, which clash with Urvi's retrofit 2.11.0 / okhttp 5.3.0 |
What Urvi uses.
AndroidSessionManager: 26 imports, 55 constructions.getSessionId()=AccountManager.blockingGetAuthToken(account, "Full access").getUserId()= account name.BridgeAccountHelper.triggerAccountSelection/getSelectedAccount(SplashActivity.kt:116,140,157,SettingsActivity.kt:381,394).BaseURLUtilget/set base URL (app/.../core/ApplicationClass.kt:66-85,RetrofitAPIClient.kt:34,RetrofitProvider.kt:49).SelectAccountActivity(splash pager + "Add account") andAuthenticatorActivity(the login form).Authenticatorimplicitly. Urvi also callsAccountManager.getAuthTokenitself on session expiry (SettingsActivity.kt:419-441,HomeActivity.kt:329-379).
Login flow Urvi depends on.
- Validate: online, non-empty username and password, lower-case and trim.
- Accepted login shapes: email,
login@@tenant, numeric IE id longer than 12 digits, or any username plus an org. - Account name becomes
login@@tenant(email@@worldwhen org is empty). - Tenant
ap.govswitches the base URL to the federation host. This is the only multi-tenant switch. PUT <base>//ctld/api/device/deviceid/v1registers the device (note the double slash).- Login headers:
X-APPID,X-APPVERSION,X-APPKEY,X-APPCODE,X-DEVICEID. BridgeSessionHelper.createSession(in closed-sourcebridge-security-api) returns the session id.addAccountExplicitly(account, password, null)stores the plaintext password;setAuthTokenstores the session.- On a later 401,
Authenticator.getAuthTokenrepeats steps 5-7 silently using the stored password.
Known bugs and risks.
| Issue | Where |
|---|---|
| Plaintext password in AccountManager, reused for silent re-login | AuthenticatorActivity.java:553, Authenticator.getAuthToken |
| Offline password compare in plaintext (multi-user mode, off in Urvi) | SelectAccountActivity.java:513 |
Session token written to Log.d | AndroidSessionManager.java:54 |
Static Activity reference leaks memory | BridgeAccountHelper.callingActivity |
getBaseURLWithTechl produces //ctld | utils/BaseURLUtil |
| Nexus credentials hardcoded | account-lib/build.gradle:11-12,21-22,44-45,54-55 |
Unused parts. Forgot / change password, self-registration (1.9k LOC) and SMS OTP, account activation, multi-instance server picker, DevUtil, DefaultAccountPreferences, GPSTracker, local User DB, rest/IEAPI, rest/UserAPI, RestClient classes, getHeaders(). All are switched off in app/src/main/res/values/configfile.xml.
Keep / replace
The App Service should own session creation, device registration, tenant routing, idempotency storage and cross-entity id resolution. The client then needs one auth flow, one HTTP client, one local DB and one sync worker.
| Concern | Compose (Kotlin) | Flutter | Move to App Service |
|---|---|---|---|
Request model (Operation) | Retrofit 2.11 or Ktor + kotlinx.serialization | dio + freezed / json_serializable | Base-URL prefixes (ctld, ptld), 206 paging, one error shape |
| Auth header, refresh on 401 | OkHttp Authenticator or Ktor Auth bearer | dio QueuedInterceptor | Issue access + refresh tokens |
| 503 retry | OkHttp interceptor / Ktor HttpRequestRetry | dio_smart_retry | Return Retry-After |
| Multipart uploads | Retrofit @Multipart, or presigned PUT | dio FormData, or presigned PUT | Issue presigned upload URLs |
| Persistent queue | Room outbox table with Flow for pending count | Drift outbox table with streams | n/a |
| Drain trigger | WorkManager CoroutineWorker, unique work, CONNECTED, exponential backoff, expedited "Sync now". Drop SyncAdapter, StubContentProvider, :sync | workmanager plugin + BGTaskScheduler on iOS, connectivity_plus | n/a |
| Dependencies (integrators) | Client UUIDs; dependsOn column only if needed | same | Accept client ids and resolve parent references |
| Idempotency | Idempotency-Key = outbox row id | same | Store keys with a TTL; ACK set no longer needed |
| Retry / expiry | attempts column, then a visible "failed" state with manual retry. Never drop silently | same | n/a |
| Conflicts | Send If-Match / updatedAt | same | Per-entity server-wins or merge policy |
| Response handlers (40 classes) | Sealed OutboxOp + when; no reflection, no class names in DB | enum + switch | n/a |
| Failure broadcast | StateFlow / Room Flow | Stream / Riverpod | n/a |
| Login | AppAuth-Android or Credential Manager (OIDC + PKCE) | flutter_appauth | POST /auth/login runs device registration and Bridge createSession server-side |
| Token storage | Refresh token in EncryptedDataStore + Keystore. Never store the password | flutter_secure_storage | n/a |
| Tenant / federation | Keep the org field and @@ parsing | same | GET /tenants/resolve?name= or remote config |
| Device headers | App id + version as headers; app-scoped UUID, not ANDROID_ID; drop APP_KEY | package_info_plus, UUID in secure storage | Play Integrity / App Attest check |
| Unused account flows | Do not port | Do not port | Add later only if product asks |
Net result. All four Bridge libraries, Apache HttpClient, the support library, the SyncAdapter and AccountManager go away. In Kotlin, roughly 150 lines of outbox code plus one worker replace the queue.
Migration risk: drain the queue before cutover
Field devices hold unsynced farmer data in SYNC_QUEUE_DB (OPERATION, INTEGRATOR). Those rows are Java-serialized and bound to class names. A Flutter app cannot read them, and a Kotlin app can only read them if every class name stays the same.
| Step | Action |
|---|---|
| 1 | Ship a final release of the current app that forces a full drain and shows the pending count |
| 2 | Block the upgrade (or warn hard) until the old queue is empty |
| 3 | On first launch, the new app checks for a non-empty legacy queue and refuses to wipe it |
| 4 | Keep the old 409 / X-REQUESTID handling server-side until all old clients are gone, so replays stay idempotent |
Do not rename or obfuscate handler, integrator or DTO classes in any interim release of the current app. That breaks rows already queued.