From 9b61e1884e55b30be330c5fd1c9dbfcb9e5b27a9 Mon Sep 17 00:00:00 2001 From: Harshit Yadav Date: Fri, 11 Sep 2026 11:05:33 +0530 Subject: [PATCH 1/7] Send deterministic id as client session id --- .../java/com/harshit/crossdevicetracker/SessionUploadDto.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/harshit/crossdevicetracker/SessionUploadDto.kt b/app/src/main/java/com/harshit/crossdevicetracker/SessionUploadDto.kt index e1144c3..8c2810c 100644 --- a/app/src/main/java/com/harshit/crossdevicetracker/SessionUploadDto.kt +++ b/app/src/main/java/com/harshit/crossdevicetracker/SessionUploadDto.kt @@ -1,7 +1,7 @@ package com.harshit.crossdevicetracker data class SessionUploadDto( - val id: String, + val clientSessionId: String, val packageName: String, val appName: String?, val startTimeUtc: String, From 0bfa29a676c7802d7e5cd64a630fac0166eedda6 Mon Sep 17 00:00:00 2001 From: Harshit Yadav Date: Fri, 11 Sep 2026 11:05:38 +0530 Subject: [PATCH 2/7] Map local session id to client session id --- .../java/com/harshit/crossdevicetracker/SessionUploadMapper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/harshit/crossdevicetracker/SessionUploadMapper.kt b/app/src/main/java/com/harshit/crossdevicetracker/SessionUploadMapper.kt index eb4b8ad..c0bb0cd 100644 --- a/app/src/main/java/com/harshit/crossdevicetracker/SessionUploadMapper.kt +++ b/app/src/main/java/com/harshit/crossdevicetracker/SessionUploadMapper.kt @@ -3,7 +3,7 @@ package com.harshit.crossdevicetracker object SessionUploadMapper { fun toDto(session: Session): SessionUploadDto { return SessionUploadDto( - id = session.id, + clientSessionId = session.id, packageName = session.packageName, appName = session.appName, startTimeUtc = session.startTimeUtc.toString(), From 545b2781fdbac99d9e001ea24c1091d051eab980 Mon Sep 17 00:00:00 2001 From: Harshit Yadav Date: Fri, 11 Sep 2026 11:05:42 +0530 Subject: [PATCH 3/7] Add accepted session ids response model --- .../harshit/crossdevicetracker/AcceptedTimeLogsResponse.kt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 app/src/main/java/com/harshit/crossdevicetracker/AcceptedTimeLogsResponse.kt diff --git a/app/src/main/java/com/harshit/crossdevicetracker/AcceptedTimeLogsResponse.kt b/app/src/main/java/com/harshit/crossdevicetracker/AcceptedTimeLogsResponse.kt new file mode 100644 index 0000000..dc550bf --- /dev/null +++ b/app/src/main/java/com/harshit/crossdevicetracker/AcceptedTimeLogsResponse.kt @@ -0,0 +1,5 @@ +package com.harshit.crossdevicetracker + +data class AcceptedTimeLogsResponse( + val acceptedClientSessionIds: List +) From 5c69ac9d4fd14c2b4968e9d05c1d8e6d71b9b8e7 Mon Sep 17 00:00:00 2001 From: Harshit Yadav Date: Fri, 11 Sep 2026 11:05:54 +0530 Subject: [PATCH 4/7] Read accepted session ids from batch response --- app/src/main/java/com/harshit/crossdevicetracker/SessionApi.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/harshit/crossdevicetracker/SessionApi.kt b/app/src/main/java/com/harshit/crossdevicetracker/SessionApi.kt index 81c54c5..126c98e 100644 --- a/app/src/main/java/com/harshit/crossdevicetracker/SessionApi.kt +++ b/app/src/main/java/com/harshit/crossdevicetracker/SessionApi.kt @@ -10,5 +10,5 @@ interface SessionApi { suspend fun uploadSessions( @Header("Authorization") authToken: String, @Body sessions: List - ): Response + ): Response } From b02befff159df63885e356830ccd23b8dfff3ddc Mon Sep 17 00:00:00 2001 From: Harshit Yadav Date: Fri, 11 Sep 2026 11:06:06 +0530 Subject: [PATCH 5/7] Mark only server-accepted sessions as sent --- .../crossdevicetracker/SessionSyncService.kt | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/harshit/crossdevicetracker/SessionSyncService.kt b/app/src/main/java/com/harshit/crossdevicetracker/SessionSyncService.kt index 9478f64..86f0d2a 100644 --- a/app/src/main/java/com/harshit/crossdevicetracker/SessionSyncService.kt +++ b/app/src/main/java/com/harshit/crossdevicetracker/SessionSyncService.kt @@ -23,40 +23,42 @@ class SessionSyncService( while (true) { val pendingSessions = sessionRepository.getPendingSessions(limit = 50) Log.d("SessionSync", "Pending sessions: ${pendingSessions.size}") - if (pendingSessions.isEmpty()) { - break - } + if (pendingSessions.isEmpty()) break val dtos = pendingSessions.map(SessionUploadMapper::toDto) Log.d("SessionSync", "Uploading batch of ${pendingSessions.size} sessions") + val result = try { val response = sessionApi.uploadSessions(authHeader, dtos) - Log.d("SessionSync", "Upload API returned") Log.d("SessionSync", "HTTP: ${response.code()} success=${response.isSuccessful}") - if (!response.isSuccessful) { - Log.d("SessionSync", "Body: ${response.errorBody()?.string()}") - } - Log.d("API_RESPONSE", response.toString()) + if (response.isSuccessful) { - for (session in pendingSessions) { - sessionRepository.markSessionSent(session.id) - } - sessionRepository.setLastSuccessfulSync(Instant.now().toEpochMilli()) - hasSuccessfulBatch = true - null - } else { - val code = response.code() - if (code == 401) { - SessionSyncResult.AUTH_ERROR - } else if (code >= 500) { - val errMsg = "Server error: $code" + val acceptedIds = response.body()?.acceptedClientSessionIds + if (acceptedIds == null) { + val errMsg = "Successful sync response did not contain accepted session ids" for (session in pendingSessions) { sessionRepository.markSessionFailed(session.id, errMsg) } hasFailedBatch = true SessionSyncResult.SERVER_ERROR } else { - val errMsg = "API error: $code" + val acceptedIdSet = acceptedIds.toHashSet() + for (session in pendingSessions) { + if (session.id in acceptedIdSet) { + sessionRepository.markSessionSent(session.id) + } + } + sessionRepository.setLastSuccessfulSync(Instant.now().toEpochMilli()) + hasSuccessfulBatch = true + null + } + } else { + Log.d("SessionSync", "Body: ${response.errorBody()?.string()}") + val code = response.code() + if (code == 401) { + SessionSyncResult.AUTH_ERROR + } else { + val errMsg = if (code >= 500) "Server error: $code" else "API error: $code" for (session in pendingSessions) { sessionRepository.markSessionFailed(session.id, errMsg) } From 2cc193ce6730c9a4206515355017b122daec18ef Mon Sep 17 00:00:00 2001 From: Harshit Yadav Date: Fri, 11 Sep 2026 11:06:42 +0530 Subject: [PATCH 6/7] Update sync tests for accepted session ids --- .../SessionSyncServiceTest.kt | 48 +++++-------------- 1 file changed, 13 insertions(+), 35 deletions(-) diff --git a/app/src/androidTest/java/com/harshit/crossdevicetracker/SessionSyncServiceTest.kt b/app/src/androidTest/java/com/harshit/crossdevicetracker/SessionSyncServiceTest.kt index 378665e..a6be61c 100644 --- a/app/src/androidTest/java/com/harshit/crossdevicetracker/SessionSyncServiceTest.kt +++ b/app/src/androidTest/java/com/harshit/crossdevicetracker/SessionSyncServiceTest.kt @@ -52,7 +52,7 @@ class SessionSyncServiceTest { } @Test - fun sync_withPendingSessions_uploadsAndMarksSent() = runBlocking { + fun sync_withPendingSessions_uploadsAndMarksAcceptedSessionsSent() = runBlocking { val sessions = listOf( createSession("com.app.one", 1000L, 2000L), createSession("com.app.two", 2000L, 3000L) @@ -65,8 +65,8 @@ class SessionSyncServiceTest { assertEquals(1, fakeApi.uploadedBatches.size) assertEquals(2, fakeApi.uploadedBatches[0].size) assertEquals("Bearer fake-jwt-token", fakeApi.lastAuthHeader) + assertEquals(sessions.map { it.id }, fakeApi.uploadedBatches[0].map { it.clientSessionId }) - // Verify database states val pending = repository.getPendingSessions(10) assertTrue(pending.isEmpty()) @@ -74,7 +74,6 @@ class SessionSyncServiceTest { assertEquals(2, dbSessions.size) assertTrue(dbSessions.all { it.syncStatus == "SENT" }) - // Verify last successful sync metadata val lastSync = repository.getLastSuccessfulSync() assertNotNull(lastSync) } @@ -94,62 +93,42 @@ class SessionSyncServiceTest { assertEquals(50, fakeApi.uploadedBatches[0].size) assertEquals(50, fakeApi.uploadedBatches[1].size) assertEquals(20, fakeApi.uploadedBatches[2].size) - - val pending = repository.getPendingSessions(200) - assertTrue(pending.isEmpty()) + assertTrue(repository.getPendingSessions(200).isEmpty()) } @Test fun sync_authError_stopsSyncAndReturnsAuthError() = runBlocking { - val sessions = listOf( - createSession("com.app.one", 1000L, 2000L) - ) - repository.saveSessions(sessions, 2000L) + repository.saveSessions(listOf(createSession("com.app.one", 1000L, 2000L)), 2000L) fakeApi.shouldFailWithCode = 401 val result = syncService.syncPendingSessions() assertEquals(SessionSyncResult.AUTH_ERROR, result) - // Sessions remain PENDING - val pending = repository.getPendingSessions(10) - assertEquals(1, pending.size) - assertEquals(SyncStatus.PENDING, pending[0].syncStatus) + assertEquals(1, repository.getPendingSessions(10).size) } @Test fun sync_serverError_marksFailedAndReturnsServerError() = runBlocking { - val sessions = listOf( - createSession("com.app.one", 1000L, 2000L) - ) - repository.saveSessions(sessions, 2000L) + repository.saveSessions(listOf(createSession("com.app.one", 1000L, 2000L)), 2000L) fakeApi.shouldFailWithCode = 500 val result = syncService.syncPendingSessions() assertEquals(SessionSyncResult.SERVER_ERROR, result) - - // Session should be marked FAILED with error message val dbSessions = database.sessionDao().getSessionsBetween(0L, 10000L) - assertEquals(1, dbSessions.size) assertEquals("FAILED", dbSessions[0].syncStatus) assertEquals("Server error: 500", dbSessions[0].errorMessage) } @Test fun sync_networkError_keepsPendingAndReturnsNetworkError() = runBlocking { - val sessions = listOf( - createSession("com.app.one", 1000L, 2000L) - ) - repository.saveSessions(sessions, 2000L) + repository.saveSessions(listOf(createSession("com.app.one", 1000L, 2000L)), 2000L) fakeApi.shouldThrowNetworkError = true val result = syncService.syncPendingSessions() assertEquals(SessionSyncResult.NETWORK_ERROR, result) - - // Session should remain PENDING val dbSessions = database.sessionDao().getSessionsBetween(0L, 10000L) - assertEquals(1, dbSessions.size) assertEquals("PENDING", dbSessions[0].syncStatus) } @@ -167,12 +146,9 @@ class SessionSyncServiceTest { val result = syncService.syncPendingSessions() assertEquals(SessionSyncResult.PARTIAL_SUCCESS, result) - val dbSessions = database.sessionDao().getSessionsBetween(0L, 200000L) - val sentCount = dbSessions.count { it.syncStatus == "SENT" } - val failedCount = dbSessions.count { it.syncStatus == "FAILED" } - assertEquals(50, sentCount) - assertEquals(25, failedCount) + assertEquals(50, dbSessions.count { it.syncStatus == "SENT" }) + assertEquals(25, dbSessions.count { it.syncStatus == "FAILED" }) } private fun createSession(packageName: String, startTime: Long, endTime: Long): Session { @@ -201,7 +177,7 @@ class SessionSyncServiceTest { override suspend fun uploadSessions( authToken: String, sessions: List - ): Response { + ): Response { lastAuthHeader = authToken val currentCall = callCount callCount++ @@ -216,7 +192,9 @@ class SessionSyncServiceTest { } uploadedBatches.add(sessions) - return Response.success(Unit) + return Response.success( + AcceptedTimeLogsResponse(sessions.map { it.clientSessionId }) + ) } } } From 93f574049c6d24e64bdb9bb188618670a0048a0f Mon Sep 17 00:00:00 2001 From: Harshit Yadav Date: Fri, 11 Sep 2026 11:06:49 +0530 Subject: [PATCH 7/7] Handle empty accepted-id responses safely --- .../crossdevicetracker/SessionSyncService.kt | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/harshit/crossdevicetracker/SessionSyncService.kt b/app/src/main/java/com/harshit/crossdevicetracker/SessionSyncService.kt index 86f0d2a..51bf9cc 100644 --- a/app/src/main/java/com/harshit/crossdevicetracker/SessionSyncService.kt +++ b/app/src/main/java/com/harshit/crossdevicetracker/SessionSyncService.kt @@ -11,9 +11,7 @@ class SessionSyncService( ) { suspend fun syncPendingSessions(): SessionSyncResult { val deviceToken = deviceTokenStore.getDeviceToken() - if (deviceToken.isNullOrBlank()) { - return SessionSyncResult.AUTH_ERROR - } + if (deviceToken.isNullOrBlank()) return SessionSyncResult.AUTH_ERROR val authHeader = "Bearer $deviceToken" var hasSuccessfulBatch = false @@ -26,23 +24,23 @@ class SessionSyncService( if (pendingSessions.isEmpty()) break val dtos = pendingSessions.map(SessionUploadMapper::toDto) - Log.d("SessionSync", "Uploading batch of ${pendingSessions.size} sessions") - val result = try { val response = sessionApi.uploadSessions(authHeader, dtos) Log.d("SessionSync", "HTTP: ${response.code()} success=${response.isSuccessful}") if (response.isSuccessful) { val acceptedIds = response.body()?.acceptedClientSessionIds - if (acceptedIds == null) { - val errMsg = "Successful sync response did not contain accepted session ids" + val acceptedIdSet = acceptedIds?.toHashSet() + val acceptedPendingCount = acceptedIdSet?.count { id -> pendingSessions.any { it.id == id } } ?: 0 + + if (acceptedIdSet == null || acceptedPendingCount == 0) { + val errMsg = "Successful sync response did not accept any pending session ids" for (session in pendingSessions) { sessionRepository.markSessionFailed(session.id, errMsg) } hasFailedBatch = true SessionSyncResult.SERVER_ERROR } else { - val acceptedIdSet = acceptedIds.toHashSet() for (session in pendingSessions) { if (session.id in acceptedIdSet) { sessionRepository.markSessionSent(session.id) @@ -53,7 +51,6 @@ class SessionSyncService( null } } else { - Log.d("SessionSync", "Body: ${response.errorBody()?.string()}") val code = response.code() if (code == 401) { SessionSyncResult.AUTH_ERROR @@ -79,9 +76,7 @@ class SessionSyncService( if (result != null) { lastErrorResult = result - if (result == SessionSyncResult.AUTH_ERROR || result == SessionSyncResult.NETWORK_ERROR) { - break - } + if (result == SessionSyncResult.AUTH_ERROR || result == SessionSyncResult.NETWORK_ERROR) break } }