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 }) + ) } } } 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 +) 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 } diff --git a/app/src/main/java/com/harshit/crossdevicetracker/SessionSyncService.kt b/app/src/main/java/com/harshit/crossdevicetracker/SessionSyncService.kt index 9478f64..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 @@ -23,40 +21,41 @@ 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 + 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 errMsg = "API error: $code" + for (session in pendingSessions) { + if (session.id in acceptedIdSet) { + sessionRepository.markSessionSent(session.id) + } + } + sessionRepository.setLastSuccessfulSync(Instant.now().toEpochMilli()) + hasSuccessfulBatch = true + null + } + } else { + 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) } @@ -77,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 } } 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, 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(),