diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 469f2bbc..e8203dcb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,7 @@ android { manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = kakaoNativeAppKey buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"${kakaoNativeAppKey}\"") buildConfigField("String", "KAKAO_REST_API_KEY", "\"${kakaoRestApiKey}\"") + resValue("bool", "timer_sync_enabled", "true") ndkVersion = "28.0.10027231" @@ -93,6 +94,7 @@ android { } else { signingConfigs.getByName("debug") } + resValue("bool", "timer_sync_enabled", "false") resValue("string", "app_name", "운다방(E2E)") } } diff --git a/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt b/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt index eb0415d3..68e9206f 100644 --- a/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt +++ b/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt @@ -2,8 +2,10 @@ package com.project200.undabang.di import com.project200.common.utils.ChatRoomStateRepository import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.manager.TimerSyncScheduler import com.project200.undabang.fcm.ChatRoomStateRepositoryImpl import com.project200.undabang.fcm.FcmTokenSyncSchedulerImpl +import com.project200.undabang.timer.TimerSyncSchedulerImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -20,4 +22,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindFcmTokenSyncScheduler(impl: FcmTokenSyncSchedulerImpl): FcmTokenSyncScheduler + + @Binds + @Singleton + abstract fun bindTimerSyncScheduler(impl: TimerSyncSchedulerImpl): TimerSyncScheduler } diff --git a/app/src/main/java/com/project200/undabang/main/MainViewModel.kt b/app/src/main/java/com/project200/undabang/main/MainViewModel.kt index a48e671f..2c2ffb8a 100644 --- a/app/src/main/java/com/project200/undabang/main/MainViewModel.kt +++ b/app/src/main/java/com/project200/undabang/main/MainViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.common.utils.NetworkMonitor import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.manager.TimerSyncScheduler import com.project200.domain.model.SessionExitReason import com.project200.domain.model.UpdateCheckResult import com.project200.domain.usecase.CheckForUpdateUseCase @@ -30,6 +31,7 @@ class MainViewModel constructor( private val checkForUpdateUseCase: CheckForUpdateUseCase, private val fcmTokenSyncScheduler: FcmTokenSyncScheduler, + private val timerSyncScheduler: TimerSyncScheduler, private val networkMonitor: NetworkMonitor, private val authManager: AuthManager, private val authStateManager: AuthStateManager, @@ -117,6 +119,7 @@ class MainViewModel } // 등록은 워커에 맡김. 재시도와 백오프, 프로세스 종료 후 재개를 WorkManager가 처리 fcmTokenSyncScheduler.schedule() + timerSyncScheduler.schedule() _entryState.compareAndSet(EntryState.Loading, EntryState.Content) } diff --git a/app/src/main/java/com/project200/undabang/timer/TimerSyncPolicy.kt b/app/src/main/java/com/project200/undabang/timer/TimerSyncPolicy.kt new file mode 100644 index 00000000..f0ae1543 --- /dev/null +++ b/app/src/main/java/com/project200/undabang/timer/TimerSyncPolicy.kt @@ -0,0 +1,8 @@ +package com.project200.undabang.timer + +/** TimerSyncWorker의 재시도 횟수를 제한합니다 */ +object TimerSyncPolicy { + const val MAX_ATTEMPT_COUNT = 10 + + fun shouldRetry(runAttemptCount: Int): Boolean = runAttemptCount + 1 < MAX_ATTEMPT_COUNT +} diff --git a/app/src/main/java/com/project200/undabang/timer/TimerSyncSchedulerImpl.kt b/app/src/main/java/com/project200/undabang/timer/TimerSyncSchedulerImpl.kt new file mode 100644 index 00000000..01b11ff9 --- /dev/null +++ b/app/src/main/java/com/project200/undabang/timer/TimerSyncSchedulerImpl.kt @@ -0,0 +1,59 @@ +package com.project200.undabang.timer + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.project200.data.local.PreferenceManager +import com.project200.domain.manager.TimerSyncScheduler +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TimerSyncSchedulerImpl + @Inject + constructor( + @ApplicationContext private val context: Context, + private val preferenceManager: PreferenceManager, + ) : TimerSyncScheduler { + override fun schedule() { + // E2E는 로컬 타이머 목록 동작을 검증하므로 서버 동기화로 선택자가 바뀌지 않게 합니다 + if (!isTimerSyncEnabled()) return + val memberId = preferenceManager.getMemberId() ?: return + val input = Data.Builder().putString(TimerSyncWorker.KEY_MEMBER_ID, memberId).build() + val request = + OneTimeWorkRequestBuilder() + .setInputData(input) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_DELAY_SECONDS, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request) + } + + override fun cancel() { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + + private fun isTimerSyncEnabled(): Boolean { + val resourceId = + context.resources.getIdentifier( + TIMER_SYNC_ENABLED_RESOURCE, + "bool", + context.packageName, + ) + return resourceId == 0 || context.resources.getBoolean(resourceId) + } + + private companion object { + const val TIMER_SYNC_ENABLED_RESOURCE = "timer_sync_enabled" + const val WORK_NAME = TimerSyncWorker.WORK_NAME + const val BACKOFF_DELAY_SECONDS = 30L + } + } diff --git a/app/src/main/java/com/project200/undabang/timer/TimerSyncWorker.kt b/app/src/main/java/com/project200/undabang/timer/TimerSyncWorker.kt new file mode 100644 index 00000000..9ee04682 --- /dev/null +++ b/app/src/main/java/com/project200/undabang/timer/TimerSyncWorker.kt @@ -0,0 +1,45 @@ +package com.project200.undabang.timer + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.project200.domain.model.TimerSyncResult +import com.project200.domain.usecase.GetMemberIdUseCase +import com.project200.domain.usecase.SyncTimersUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import timber.log.Timber + +@HiltWorker +class TimerSyncWorker + @AssistedInject + constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val getMemberIdUseCase: GetMemberIdUseCase, + private val syncTimersUseCase: SyncTimersUseCase, + ) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val requestedMemberId = inputData.getString(KEY_MEMBER_ID) ?: return Result.success() + val currentMemberId = getMemberIdUseCase() ?: return Result.success() + if (currentMemberId != requestedMemberId) return Result.success() + + return when (syncTimersUseCase(requestedMemberId)) { + TimerSyncResult.Success, + TimerSyncResult.Skipped, + -> Result.success() + TimerSyncResult.Retry -> { + Timber.tag(TAG).w("타이머 동기화 재시도: 시도 ${runAttemptCount + 1}회") + if (TimerSyncPolicy.shouldRetry(runAttemptCount)) Result.retry() else Result.failure() + } + TimerSyncResult.Failure -> Result.failure() + } + } + + companion object { + const val WORK_NAME = "timer_sync" + const val KEY_MEMBER_ID = "member_id" + private const val TAG = "TimerSyncWorker" + } + } diff --git a/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt b/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt index 35e33a97..ed08df81 100644 --- a/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt +++ b/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt @@ -3,6 +3,7 @@ package com.project200.undabang.main import com.google.common.truth.Truth.assertThat import com.project200.common.utils.NetworkMonitor import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.manager.TimerSyncScheduler import com.project200.domain.model.SessionExitReason import com.project200.domain.model.UpdateCheckResult import com.project200.domain.usecase.CheckForUpdateUseCase @@ -45,6 +46,9 @@ class MainViewModelTest { @MockK private lateinit var mockFcmTokenSyncScheduler: FcmTokenSyncScheduler + @MockK + private lateinit var mockTimerSyncScheduler: TimerSyncScheduler + @MockK private lateinit var mockNetworkMonitor: NetworkMonitor @@ -73,8 +77,9 @@ class MainViewModelTest { forceLogoutFlow = MutableSharedFlow(extraBufferCapacity = 1) every { mockNetworkMonitor.networkState } returns networkStateFlow every { mockNetworkMonitor.isCurrentlyConnected() } returns true - every { mockAuthManager.forceLogoutFlow } returns forceLogoutFlow every { mockFcmTokenSyncScheduler.schedule() } just Runs + every { mockTimerSyncScheduler.schedule() } just Runs + every { mockAuthManager.forceLogoutFlow } returns forceLogoutFlow coEvery { mockClearSessionUseCase(SessionExitReason.FORCED) } just Runs } @@ -87,6 +92,7 @@ class MainViewModelTest { return MainViewModel( checkForUpdateUseCase = mockCheckForUpdateUseCase, fcmTokenSyncScheduler = mockFcmTokenSyncScheduler, + timerSyncScheduler = mockTimerSyncScheduler, networkMonitor = mockNetworkMonitor, authManager = mockAuthManager, authStateManager = mockAuthStateManager, diff --git a/app/src/test/java/com/project200/undabang/timer/TimerSyncPolicyTest.kt b/app/src/test/java/com/project200/undabang/timer/TimerSyncPolicyTest.kt new file mode 100644 index 00000000..ba2d0337 --- /dev/null +++ b/app/src/test/java/com/project200/undabang/timer/TimerSyncPolicyTest.kt @@ -0,0 +1,12 @@ +package com.project200.undabang.timer + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class TimerSyncPolicyTest { + @Test + fun `마지막 시도 전까지 재시도한다`() { + assertThat(TimerSyncPolicy.shouldRetry(TimerSyncPolicy.MAX_ATTEMPT_COUNT - 2)).isTrue() + assertThat(TimerSyncPolicy.shouldRetry(TimerSyncPolicy.MAX_ATTEMPT_COUNT - 1)).isFalse() + } +} diff --git a/data/schemas/com.project200.data.local.UndabangDatabase/4.json b/data/schemas/com.project200.data.local.UndabangDatabase/4.json new file mode 100644 index 00000000..e632c178 --- /dev/null +++ b/data/schemas/com.project200.data.local.UndabangDatabase/4.json @@ -0,0 +1,495 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "8e3e6b2a7fea8072a90eab8987010f11", + "entities": [ + { + "tableName": "exercise_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `date` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`memberId`, `date`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "date" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exercise_list_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `localId` TEXT NOT NULL, `serverId` INTEGER, `syncState` TEXT NOT NULL, `date` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `title` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `thumbnailUrls` TEXT, PRIMARY KEY(`memberId`, `localId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "personalType", + "columnName": "personalType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrls", + "columnName": "thumbnailUrls", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "localId" + ] + }, + "indices": [ + { + "name": "index_exercise_list_item_memberId_date", + "unique": false, + "columnNames": [ + "memberId", + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_list_item_memberId_date` ON `${TABLE_NAME}` (`memberId`, `date`)" + }, + { + "name": "index_exercise_list_item_memberId_serverId", + "unique": false, + "columnNames": [ + "memberId", + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_list_item_memberId_serverId` ON `${TABLE_NAME}` (`memberId`, `serverId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exercise_record_detail", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `localId` TEXT NOT NULL, `serverId` INTEGER, `syncState` TEXT NOT NULL, `title` TEXT NOT NULL, `detail` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `location` TEXT NOT NULL, `pictures` TEXT, PRIMARY KEY(`memberId`, `localId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "detail", + "columnName": "detail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "personalType", + "columnName": "personalType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pictures", + "columnName": "pictures", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "localId" + ] + }, + "indices": [ + { + "name": "index_exercise_record_detail_memberId_serverId", + "unique": false, + "columnNames": [ + "memberId", + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_record_detail_memberId_serverId` ON `${TABLE_NAME}` (`memberId`, `serverId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exercise_cache_query", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `queryType` TEXT NOT NULL, `date` TEXT NOT NULL, PRIMARY KEY(`memberId`, `queryType`, `date`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "queryType", + "columnName": "queryType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "queryType", + "date" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "simple_timer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `localId` TEXT NOT NULL, `serverId` INTEGER, `syncState` TEXT NOT NULL, `time` INTEGER NOT NULL, `createRequestId` TEXT NOT NULL, `pendingEditId` TEXT, `editedAt` INTEGER NOT NULL, `inFlightState` TEXT, `inFlightEditId` TEXT, `inFlightEditedAt` INTEGER, `inFlightTime` INTEGER, `lastSyncError` TEXT, PRIMARY KEY(`memberId`, `localId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createRequestId", + "columnName": "createRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pendingEditId", + "columnName": "pendingEditId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inFlightState", + "columnName": "inFlightState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inFlightEditId", + "columnName": "inFlightEditId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inFlightEditedAt", + "columnName": "inFlightEditedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inFlightTime", + "columnName": "inFlightTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncError", + "columnName": "lastSyncError", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "localId" + ] + }, + "indices": [ + { + "name": "index_simple_timer_memberId_serverId", + "unique": false, + "columnNames": [ + "memberId", + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_simple_timer_memberId_serverId` ON `${TABLE_NAME}` (`memberId`, `serverId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "custom_timer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `localId` TEXT NOT NULL, `serverId` INTEGER, `syncState` TEXT NOT NULL, `name` TEXT NOT NULL, `steps` TEXT NOT NULL, `createRequestId` TEXT NOT NULL, `pendingEditId` TEXT, `editedAt` INTEGER NOT NULL, `inFlightState` TEXT, `inFlightEditId` TEXT, `inFlightEditedAt` INTEGER, `inFlightName` TEXT, `inFlightSteps` TEXT, `lastSyncError` TEXT, PRIMARY KEY(`memberId`, `localId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createRequestId", + "columnName": "createRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pendingEditId", + "columnName": "pendingEditId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inFlightState", + "columnName": "inFlightState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inFlightEditId", + "columnName": "inFlightEditId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inFlightEditedAt", + "columnName": "inFlightEditedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inFlightName", + "columnName": "inFlightName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inFlightSteps", + "columnName": "inFlightSteps", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSyncError", + "columnName": "lastSyncError", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "localId" + ] + }, + "indices": [ + { + "name": "index_custom_timer_memberId_serverId", + "unique": false, + "columnNames": [ + "memberId", + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_custom_timer_memberId_serverId` ON `${TABLE_NAME}` (`memberId`, `serverId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8e3e6b2a7fea8072a90eab8987010f11')" + ] + } +} \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/api/ApiService.kt b/data/src/main/java/com/project200/data/api/ApiService.kt index 77294b29..302d4116 100644 --- a/data/src/main/java/com/project200/data/api/ApiService.kt +++ b/data/src/main/java/com/project200/data/api/ApiService.kt @@ -7,6 +7,7 @@ import com.project200.data.dto.CreateCommentResponseDTO import com.project200.data.dto.CreateFeedRequestDTO import com.project200.data.dto.CustomTimerIdDTO import com.project200.data.dto.DeletePreferredExerciseDTO +import com.project200.data.dto.DeleteTimerRequestDTO import com.project200.data.dto.EditExercisePlaceDTO import com.project200.data.dto.ExerciseIdDto import com.project200.data.dto.ExpectedScoreInfoDTO @@ -55,6 +56,7 @@ import com.project200.data.dto.PostSignUpRequest import com.project200.data.dto.PutProfileRequest import com.project200.data.dto.SimpleTimerIdDTO import com.project200.data.dto.SimpleTimerRequest +import com.project200.data.dto.TimerMutationResponseDTO import com.project200.data.dto.UpdateFeedRequestDTO import com.project200.data.utils.AccessTokenApi import com.project200.data.utils.AccessTokenWithFcmApi @@ -306,22 +308,23 @@ interface ApiService { @AccessTokenApi suspend fun patchSimpleTimer( @Path("simpleTimerId") simpleTimerId: Long, - @Body time: SimpleTimerRequest, - ): BaseResponse + @Body request: SimpleTimerRequest, + ): BaseResponse // 심플 타이머 추가 @POST("api/v1/simple-timers") @AccessTokenApi suspend fun postSimpleTimer( - @Body time: SimpleTimerRequest, + @Body request: SimpleTimerRequest, ): BaseResponse // 심플 타이머 삭제 - @DELETE("api/v1/simple-timers/{simpleTimerId}") + @HTTP(method = "DELETE", path = "api/v1/simple-timers/{simpleTimerId}", hasBody = true) @AccessTokenApi suspend fun deleteSimpleTimer( @Path("simpleTimerId") simpleTimerId: Long, - ): BaseResponse + @Body request: DeleteTimerRequestDTO, + ): BaseResponse // 커스텀 타이머 리스트 조회 @GET("api/v1/custom-timers") @@ -342,25 +345,28 @@ interface ApiService { @Body customTimer: PostCustomTimerRequest, ): BaseResponse - // 커스텀 타이머 삭제 - @DELETE("api/v1/custom-timers/{customTimerId}") + @HTTP(method = "DELETE", path = "api/v1/custom-timers/{customTimerId}", hasBody = true) + @AccessTokenApi suspend fun deleteCustomTimer( @Path("customTimerId") customTimerId: Long, - ): BaseResponse + @Body request: DeleteTimerRequestDTO, + ): BaseResponse // 커스텀 타이머 이름 수정 @PATCH("api/v1/custom-timers/{customTimerId}") + @AccessTokenApi suspend fun patchCustomTimerTitle( @Path("customTimerId") customTimerId: Long, - @Body title: PatchCustomTimerTitleRequest, - ): BaseResponse + @Body request: PatchCustomTimerTitleRequest, + ): BaseResponse // 커스텀 타이머 전체 수정 @PUT("api/v1/custom-timers/{customTimerId}") + @AccessTokenApi suspend fun putCustomTimer( @Path("customTimerId") customTimerId: Long, - @Body customTimer: PostCustomTimerRequest, - ): BaseResponse + @Body request: PostCustomTimerRequest, + ): BaseResponse /** 매칭 - 회원 */ // 매칭지도 회원들 조회 diff --git a/data/src/main/java/com/project200/data/datasource/TimerLocalDataSource.kt b/data/src/main/java/com/project200/data/datasource/TimerLocalDataSource.kt index 32570094..8cfb1258 100644 --- a/data/src/main/java/com/project200/data/datasource/TimerLocalDataSource.kt +++ b/data/src/main/java/com/project200/data/datasource/TimerLocalDataSource.kt @@ -91,7 +91,7 @@ class TimerLocalDataSource suspend fun deleteSimpleTimer(localId: String) { val memberId = currentMemberId() ?: return val current = timerDao.getSimpleTimer(memberId, localId) ?: return - if (current.serverId == null) { + if (current.serverId == null && current.inFlightState == null) { timerDao.deleteSimpleTimer(memberId, localId) return } @@ -157,7 +157,7 @@ class TimerLocalDataSource suspend fun deleteCustomTimer(localId: String) { val memberId = currentMemberId() ?: return val current = timerDao.getCustomTimer(memberId, localId) ?: return - if (current.serverId == null) { + if (current.serverId == null && current.inFlightState == null) { timerDao.deleteCustomTimer(memberId, localId) return } @@ -262,6 +262,220 @@ class TimerLocalDataSource } } + suspend fun claimSimpleTimer( + memberId: String, + localId: String, + ): TimerSyncSnapshot.Simple? = + database.withTransaction { + if (currentMemberId() != memberId) return@withTransaction null + val current = timerDao.getSimpleTimer(memberId, localId) ?: return@withTransaction null + if (current.syncState == SyncState.SYNCED) return@withTransaction null + val snapshot = + TimerSyncSnapshot.Simple( + localId = current.localId, + serverId = current.serverId, + state = current.inFlightState ?: current.syncState, + editId = current.inFlightEditId ?: current.pendingEditId, + editedAt = current.inFlightEditedAt ?: current.editedAt, + createRequestId = current.createRequestId, + time = current.inFlightTime ?: current.time, + ) + if (current.inFlightState == null) { + timerDao.upsertSimpleTimer( + current.copy( + inFlightState = snapshot.state, + inFlightEditId = snapshot.editId, + inFlightEditedAt = snapshot.editedAt, + inFlightTime = snapshot.time, + ), + ) + } + snapshot + } + + suspend fun claimCustomTimer( + memberId: String, + localId: String, + ): TimerSyncSnapshot.Custom? = + database.withTransaction { + if (currentMemberId() != memberId) return@withTransaction null + val current = timerDao.getCustomTimer(memberId, localId) ?: return@withTransaction null + if (current.syncState == SyncState.SYNCED) return@withTransaction null + val snapshot = + TimerSyncSnapshot.Custom( + localId = current.localId, + serverId = current.serverId, + state = current.inFlightState ?: current.syncState, + editId = current.inFlightEditId ?: current.pendingEditId, + editedAt = current.inFlightEditedAt ?: current.editedAt, + createRequestId = current.createRequestId, + name = current.inFlightName ?: current.name, + steps = current.inFlightSteps ?: current.steps, + ) + if (current.inFlightState == null) { + timerDao.upsertCustomTimer( + current.copy( + inFlightState = snapshot.state, + inFlightEditId = snapshot.editId, + inFlightEditedAt = snapshot.editedAt, + inFlightName = snapshot.name, + inFlightSteps = snapshot.steps, + ), + ) + } + snapshot + } + + suspend fun completeSimple( + snapshot: TimerSyncSnapshot.Simple, + serverId: Long, + ) { + val memberId = currentMemberId() ?: return + database.withTransaction { + val current = timerDao.getSimpleTimer(memberId, snapshot.localId) ?: return@withTransaction + val newerEdit = + current.inFlightState == snapshot.state && + (current.editedAt != snapshot.editedAt || current.pendingEditId != snapshot.editId) + if (snapshot.state == SyncState.DELETE_PENDING && !newerEdit) { + timerDao.deleteSimpleTimer(memberId, snapshot.localId) + } else { + timerDao.upsertSimpleTimer( + current.copy( + serverId = serverId, + syncState = + when { + newerEdit && current.syncState == SyncState.DELETE_PENDING -> SyncState.DELETE_PENDING + newerEdit && snapshot.state == SyncState.CREATE_PENDING -> SyncState.UPDATE_PENDING + newerEdit -> current.syncState + else -> SyncState.SYNCED + }, + inFlightState = null, + inFlightEditId = null, + inFlightEditedAt = null, + inFlightTime = null, + lastSyncError = null, + ), + ) + } + } + } + + suspend fun completeCustom( + snapshot: TimerSyncSnapshot.Custom, + serverId: Long, + ) { + val memberId = currentMemberId() ?: return + database.withTransaction { + val current = timerDao.getCustomTimer(memberId, snapshot.localId) ?: return@withTransaction + val newerEdit = + current.inFlightState == snapshot.state && + (current.editedAt != snapshot.editedAt || current.pendingEditId != snapshot.editId) + if (snapshot.state == SyncState.DELETE_PENDING && !newerEdit) { + timerDao.deleteCustomTimer(memberId, snapshot.localId) + } else { + timerDao.upsertCustomTimer( + current.copy( + serverId = serverId, + syncState = + when { + newerEdit && current.syncState == SyncState.DELETE_PENDING -> SyncState.DELETE_PENDING + newerEdit && snapshot.state == SyncState.CREATE_PENDING -> SyncState.UPDATE_PENDING + newerEdit -> current.syncState + else -> SyncState.SYNCED + }, + inFlightState = null, + inFlightEditId = null, + inFlightEditedAt = null, + inFlightName = null, + inFlightSteps = null, + lastSyncError = null, + ), + ) + } + } + } + + suspend fun resolveSimpleConflict( + snapshot: TimerSyncSnapshot.Simple, + server: ServerSimpleTimer?, + ) { + val memberId = currentMemberId() ?: return + database.withTransaction { + val current = timerDao.getSimpleTimer(memberId, snapshot.localId) ?: return@withTransaction + if (server == null) { + timerDao.deleteSimpleTimer(memberId, snapshot.localId) + } else { + timerDao.upsertSimpleTimer( + current.copy( + serverId = server.serverId, + syncState = SyncState.SYNCED, + time = server.time, + inFlightState = null, + inFlightEditId = null, + inFlightEditedAt = null, + inFlightTime = null, + lastSyncError = null, + ), + ) + } + } + } + + suspend fun resolveCustomConflict( + snapshot: TimerSyncSnapshot.Custom, + server: ServerCustomTimer?, + ) { + val memberId = currentMemberId() ?: return + database.withTransaction { + val current = timerDao.getCustomTimer(memberId, snapshot.localId) ?: return@withTransaction + if (server == null) { + timerDao.deleteCustomTimer(memberId, snapshot.localId) + } else { + timerDao.upsertCustomTimer( + current.copy( + serverId = server.serverId, + syncState = SyncState.SYNCED, + name = server.name, + steps = server.steps, + inFlightState = null, + inFlightEditId = null, + inFlightEditedAt = null, + inFlightName = null, + inFlightSteps = null, + lastSyncError = null, + ), + ) + } + } + } + + suspend fun recordSyncError( + snapshot: TimerSyncSnapshot, + errorCode: String?, + ) { + val memberId = currentMemberId() ?: return + database.withTransaction { + when (snapshot) { + is TimerSyncSnapshot.Simple -> { + timerDao.getSimpleTimer(memberId, snapshot.localId)?.let { current -> + timerDao.upsertSimpleTimer(current.copy(lastSyncError = errorCode)) + } + } + is TimerSyncSnapshot.Custom -> { + timerDao.getCustomTimer(memberId, snapshot.localId)?.let { current -> + timerDao.upsertCustomTimer(current.copy(lastSyncError = errorCode)) + } + } + } + } + } + + suspend fun clearSyncError(snapshot: TimerSyncSnapshot) { + recordSyncError(snapshot, null) + } + + fun isCurrentMember(memberId: String): Boolean = currentMemberId() == memberId + private fun nextStateOnEdit(current: SyncState): SyncState = if (current == SyncState.CREATE_PENDING) SyncState.CREATE_PENDING else SyncState.UPDATE_PENDING diff --git a/data/src/main/java/com/project200/data/datasource/TimerSyncSnapshot.kt b/data/src/main/java/com/project200/data/datasource/TimerSyncSnapshot.kt new file mode 100644 index 00000000..3fd2a562 --- /dev/null +++ b/data/src/main/java/com/project200/data/datasource/TimerSyncSnapshot.kt @@ -0,0 +1,33 @@ +package com.project200.data.datasource + +import com.project200.data.local.entity.CachedTimerStep +import com.project200.data.local.entity.SyncState + +sealed interface TimerSyncSnapshot { + val localId: String + val serverId: Long? + val state: SyncState + val editId: String? + val editedAt: Long + + data class Simple( + override val localId: String, + override val serverId: Long?, + override val state: SyncState, + override val editId: String?, + override val editedAt: Long, + val createRequestId: String, + val time: Int, + ) : TimerSyncSnapshot + + data class Custom( + override val localId: String, + override val serverId: Long?, + override val state: SyncState, + override val editId: String?, + override val editedAt: Long, + val createRequestId: String, + val name: String, + val steps: List, + ) : TimerSyncSnapshot +} diff --git a/data/src/main/java/com/project200/data/di/DatabaseModule.kt b/data/src/main/java/com/project200/data/di/DatabaseModule.kt index 4edb27f3..35f2347d 100644 --- a/data/src/main/java/com/project200/data/di/DatabaseModule.kt +++ b/data/src/main/java/com/project200/data/di/DatabaseModule.kt @@ -45,6 +45,23 @@ abstract class DatabaseModule { } } + val MIGRATION_3_4 = + object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE simple_timer ADD COLUMN inFlightState TEXT") + db.execSQL("ALTER TABLE simple_timer ADD COLUMN inFlightEditId TEXT") + db.execSQL("ALTER TABLE simple_timer ADD COLUMN inFlightEditedAt INTEGER") + db.execSQL("ALTER TABLE simple_timer ADD COLUMN inFlightTime INTEGER") + db.execSQL("ALTER TABLE simple_timer ADD COLUMN lastSyncError TEXT") + db.execSQL("ALTER TABLE custom_timer ADD COLUMN inFlightState TEXT") + db.execSQL("ALTER TABLE custom_timer ADD COLUMN inFlightEditId TEXT") + db.execSQL("ALTER TABLE custom_timer ADD COLUMN inFlightEditedAt INTEGER") + db.execSQL("ALTER TABLE custom_timer ADD COLUMN inFlightName TEXT") + db.execSQL("ALTER TABLE custom_timer ADD COLUMN inFlightSteps TEXT") + db.execSQL("ALTER TABLE custom_timer ADD COLUMN lastSyncError TEXT") + } + } + @Provides @Singleton fun provideUndabangTypeConverters(moshi: Moshi): UndabangTypeConverters { @@ -57,7 +74,6 @@ abstract class DatabaseModule { * 스키마를 바꿀 때는 version을 올립니다. version을 그대로 두고 스키마만 바꾸면 * identityHash가 어긋나 재생성 없이 크래시합니다 * - * 주의: 재생성은 전송 대기 행까지 지웁니다. 출시 뒤에는 이 설정을 걷고 * 마이그레이션을 씁니다 */ @Provides @@ -72,7 +88,7 @@ abstract class DatabaseModule { UndabangDatabase.DATABASE_NAME, ) .addTypeConverter(typeConverters) - .addMigrations(MIGRATION_2_3) + .addMigrations(MIGRATION_2_3, MIGRATION_3_4) .fallbackToDestructiveMigration() .build() } diff --git a/data/src/main/java/com/project200/data/dto/TimerDTO.kt b/data/src/main/java/com/project200/data/dto/TimerDTO.kt index 86865bd3..e8d0a850 100644 --- a/data/src/main/java/com/project200/data/dto/TimerDTO.kt +++ b/data/src/main/java/com/project200/data/dto/TimerDTO.kt @@ -1,5 +1,7 @@ package com.project200.data.dto +import java.time.LocalDateTime + data class GetSimpleTimersDTO( val simpleTimerCount: Int, val simpleTimers: List, @@ -8,10 +10,14 @@ data class GetSimpleTimersDTO( data class SimpleTimerDTO( val simpleTimerId: Long, val time: Int, + val updatedAt: LocalDateTime? = null, ) data class SimpleTimerRequest( val time: Int, + val createRequestId: String? = null, + val editId: String? = null, + val editedAt: Long? = null, ) data class GetCustomTimerListDTO( @@ -22,14 +28,15 @@ data class GetCustomTimerListDTO( data class CustomTimerSummaryDTO( val customTimerId: Long, val customTimerName: String, + val updatedAt: LocalDateTime? = null, ) -// 커스텀 타이머 상세 조회 DTO data class GetCustomTimerDetailDTO( val customTimerId: Long, val customTimerName: String, val customTimerStepCount: Int, val customTimerSteps: List, + val updatedAt: LocalDateTime? = null, ) data class CustomTimerDetailStepDTO( @@ -39,10 +46,12 @@ data class CustomTimerDetailStepDTO( val customTimerStepTime: Int, ) -// 커스텀 타이머 생성 요청 DTO data class PostCustomTimerRequest( val customTimerName: String, val customTimerSteps: List, + val createRequestId: String? = null, + val editedAt: Long? = null, + val editId: String? = null, ) data class PostCustomTimerStepDTO( @@ -53,12 +62,25 @@ data class PostCustomTimerStepDTO( data class CustomTimerIdDTO( val customTimerId: Long, + val updatedAt: LocalDateTime? = null, ) data class PatchCustomTimerTitleRequest( val customTimerName: String, + val editId: String? = null, + val editedAt: Long? = null, ) data class SimpleTimerIdDTO( val simpleTimerId: Long, + val updatedAt: LocalDateTime? = null, +) + +data class TimerMutationResponseDTO( + val updatedAt: LocalDateTime? = null, +) + +data class DeleteTimerRequestDTO( + val editId: String? = null, + val editedAt: Long? = null, ) diff --git a/data/src/main/java/com/project200/data/impl/TimerRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/TimerRepositoryImpl.kt index 48f18853..5c0dabc6 100644 --- a/data/src/main/java/com/project200/data/impl/TimerRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/TimerRepositoryImpl.kt @@ -3,19 +3,29 @@ package com.project200.data.impl import com.project200.common.di.IoDispatcher import com.project200.data.api.ApiService import com.project200.data.datasource.ServerCustomTimer +import com.project200.data.datasource.ServerSimpleTimer import com.project200.data.datasource.TimerLocalDataSource +import com.project200.data.datasource.TimerSyncSnapshot +import com.project200.data.dto.DeleteTimerRequestDTO import com.project200.data.dto.GetCustomTimerDetailDTO import com.project200.data.dto.GetCustomTimerListDTO import com.project200.data.dto.GetSimpleTimersDTO +import com.project200.data.dto.PostCustomTimerRequest +import com.project200.data.dto.PostCustomTimerStepDTO +import com.project200.data.dto.SimpleTimerRequest +import com.project200.data.local.entity.CachedTimerStep import com.project200.data.local.entity.CustomTimerEntity +import com.project200.data.local.entity.SyncState import com.project200.data.mapper.toCachedSteps import com.project200.data.mapper.toModel import com.project200.data.mapper.toServerModel import com.project200.data.utils.apiCallBuilder +import com.project200.domain.manager.TimerSyncScheduler import com.project200.domain.model.BaseResult import com.project200.domain.model.CustomTimer import com.project200.domain.model.SimpleTimer import com.project200.domain.model.Step +import com.project200.domain.model.TimerSyncResult import com.project200.domain.repository.TimerRepository import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Inject @@ -26,6 +36,7 @@ class TimerRepositoryImpl private val apiService: ApiService, private val localDataSource: TimerLocalDataSource, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val timerSyncScheduler: TimerSyncScheduler, ) : TimerRepository { override suspend fun getSimpleTimers(): BaseResult> { val listResult = @@ -53,6 +64,7 @@ class TimerRepositoryImpl override suspend fun addSimpleTimer(time: Int): BaseResult { val localId = localDataSource.createSimpleTimer(time) ?: return noMemberIdError() + timerSyncScheduler.schedule() return BaseResult.Success(localId) } @@ -61,11 +73,13 @@ class TimerRepositoryImpl time: Int, ): BaseResult { localDataSource.updateSimpleTimer(localId, time) + timerSyncScheduler.schedule() return BaseResult.Success(Unit) } override suspend fun deleteSimpleTimer(localId: String): BaseResult { localDataSource.deleteSimpleTimer(localId) + timerSyncScheduler.schedule() return BaseResult.Success(Unit) } @@ -136,6 +150,7 @@ class TimerRepositoryImpl ): BaseResult { val localId = localDataSource.createCustomTimer(title, steps.toCachedSteps()) ?: return noMemberIdError() + timerSyncScheduler.schedule() return BaseResult.Success(localId) } @@ -145,20 +160,335 @@ class TimerRepositoryImpl steps: List, ): BaseResult { localDataSource.updateCustomTimer(localId, title, steps.toCachedSteps()) + timerSyncScheduler.schedule() return BaseResult.Success(Unit) } override suspend fun deleteCustomTimer(localId: String): BaseResult { localDataSource.deleteCustomTimer(localId) + timerSyncScheduler.schedule() return BaseResult.Success(Unit) } + override suspend fun syncPendingTimers(memberId: String): TimerSyncResult { + if (!localDataSource.isCurrentMember(memberId)) return TimerSyncResult.Skipped + for (state in listOf(SyncState.CREATE_PENDING, SyncState.UPDATE_PENDING, SyncState.DELETE_PENDING)) { + val simpleRows = + localDataSource.getPendingSimpleTimers() + .filter { (it.inFlightState ?: it.syncState) == state } + for (row in simpleRows) { + val snapshot = localDataSource.claimSimpleTimer(memberId, row.localId) ?: continue + when (val result = syncSimple(memberId, snapshot)) { + TimerSyncResult.Success -> Unit + TimerSyncResult.Skipped -> return result + TimerSyncResult.Retry -> return result + TimerSyncResult.Failure -> return result + } + } + + val customRows = + localDataSource.getPendingCustomTimers() + .filter { (it.inFlightState ?: it.syncState) == state } + for (row in customRows) { + val snapshot = localDataSource.claimCustomTimer(memberId, row.localId) ?: continue + when (val result = syncCustom(memberId, snapshot)) { + TimerSyncResult.Success -> Unit + TimerSyncResult.Skipped -> return result + TimerSyncResult.Retry -> return result + TimerSyncResult.Failure -> return result + } + } + } + if (!localDataSource.isCurrentMember(memberId)) return TimerSyncResult.Skipped + return if ( + localDataSource.getPendingSimpleTimers().isNotEmpty() || + localDataSource.getPendingCustomTimers().isNotEmpty() + ) { + TimerSyncResult.Retry + } else { + TimerSyncResult.Success + } + } + + private suspend fun syncSimple( + memberId: String, + snapshot: TimerSyncSnapshot.Simple, + ): TimerSyncResult { + if (!localDataSource.isCurrentMember(memberId)) return TimerSyncResult.Skipped + if (snapshot.serverId == null && snapshot.state == SyncState.CREATE_PENDING) { + val result = + apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + apiService.postSimpleTimer( + SimpleTimerRequest( + time = snapshot.time, + createRequestId = snapshot.createRequestId, + editedAt = snapshot.editedAt, + ), + ) + }, + mapper = { it?.simpleTimerId ?: error("simple_timer_id_missing") }, + ) + return when (result) { + is BaseResult.Success -> { + localDataSource.completeSimple(snapshot, result.data) + TimerSyncResult.Success + } + is BaseResult.Error -> classify(snapshot, result) + } + } + + val serverId = snapshot.serverId ?: return TimerSyncResult.Failure + val result = + when (snapshot.state) { + SyncState.UPDATE_PENDING -> + apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + apiService.patchSimpleTimer( + serverId, + SimpleTimerRequest( + time = snapshot.time, + editId = snapshot.editId, + editedAt = snapshot.editedAt, + ), + ) + }, + mapper = { it?.updatedAt }, + ) + SyncState.DELETE_PENDING -> + apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + apiService.deleteSimpleTimer( + serverId, + DeleteTimerRequestDTO( + editId = snapshot.editId, + editedAt = snapshot.editedAt, + ), + ) + }, + mapper = { it?.updatedAt }, + ) + else -> return TimerSyncResult.Failure + } + return handleMutationResult(memberId, result, snapshot) + } + + private suspend fun syncCustom( + memberId: String, + snapshot: TimerSyncSnapshot.Custom, + ): TimerSyncResult { + if (!localDataSource.isCurrentMember(memberId)) return TimerSyncResult.Skipped + if (snapshot.serverId == null && snapshot.state == SyncState.CREATE_PENDING) { + val result = + apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + apiService.postCustomTimer( + PostCustomTimerRequest( + customTimerName = snapshot.name, + customTimerSteps = + snapshot.steps.map { + PostCustomTimerStepDTO(it.name, it.order, it.time) + }, + createRequestId = snapshot.createRequestId, + editedAt = snapshot.editedAt, + ), + ) + }, + mapper = { it?.customTimerId ?: error("custom_timer_id_missing") }, + ) + return when (result) { + is BaseResult.Success -> { + localDataSource.completeCustom(snapshot, result.data) + TimerSyncResult.Success + } + is BaseResult.Error -> classify(snapshot, result) + } + } + + val serverId = snapshot.serverId ?: return TimerSyncResult.Failure + val result = + when (snapshot.state) { + SyncState.UPDATE_PENDING -> + apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + apiService.putCustomTimer( + serverId, + PostCustomTimerRequest( + customTimerName = snapshot.name, + customTimerSteps = + snapshot.steps.map { + PostCustomTimerStepDTO(it.name, it.order, it.time) + }, + editId = snapshot.editId, + editedAt = snapshot.editedAt, + ), + ) + }, + mapper = { it?.updatedAt }, + ) + SyncState.DELETE_PENDING -> + apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + apiService.deleteCustomTimer( + serverId, + DeleteTimerRequestDTO( + editId = snapshot.editId, + editedAt = snapshot.editedAt, + ), + ) + }, + mapper = { it?.updatedAt }, + ) + else -> return TimerSyncResult.Failure + } + return handleMutationResult(memberId, result, snapshot) + } + + private suspend fun handleMutationResult( + memberId: String, + result: BaseResult<*>, + snapshot: TimerSyncSnapshot, + ): TimerSyncResult { + if (!localDataSource.isCurrentMember(memberId)) return TimerSyncResult.Skipped + return when (result) { + is BaseResult.Success -> { + when (snapshot) { + is TimerSyncSnapshot.Simple -> localDataSource.completeSimple(snapshot, snapshot.serverId!!) + is TimerSyncSnapshot.Custom -> localDataSource.completeCustom(snapshot, snapshot.serverId!!) + } + TimerSyncResult.Success + } + is BaseResult.Error -> { + when { + result.errorCode == TIMER_EDIT_CONFLICT_CODE -> resolveConflict(memberId, snapshot) + result.errorCode == TIMER_DELETED_CODE -> { + when (snapshot) { + is TimerSyncSnapshot.Simple -> localDataSource.resolveSimpleConflict(snapshot, null) + is TimerSyncSnapshot.Custom -> localDataSource.resolveCustomConflict(snapshot, null) + } + TimerSyncResult.Success + } + else -> classify(snapshot, result) + } + } + } + } + + private suspend fun resolveConflict( + memberId: String, + snapshot: TimerSyncSnapshot, + ): TimerSyncResult { + if (!localDataSource.isCurrentMember(memberId)) return TimerSyncResult.Skipped + return when (snapshot) { + is TimerSyncSnapshot.Simple -> { + when (val result = fetchSimpleServer(memberId, snapshot.serverId!!)) { + is BaseResult.Success -> { + localDataSource.resolveSimpleConflict(snapshot, result.data) + TimerSyncResult.Success + } + is BaseResult.Error -> classify(snapshot, result) + } + } + is TimerSyncSnapshot.Custom -> { + when (val result = fetchCustomServer(memberId, snapshot.serverId!!)) { + is BaseResult.Success -> { + localDataSource.resolveCustomConflict(snapshot, result.data) + TimerSyncResult.Success + } + is BaseResult.Error -> classify(snapshot, result) + } + } + } + } + + private suspend fun fetchSimpleServer( + memberId: String, + serverId: Long, + ): BaseResult { + if (!localDataSource.isCurrentMember(memberId)) return accountChangedError() + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.getSimpleTimers() }, + mapper = { dto: GetSimpleTimersDTO? -> + dto?.simpleTimers?.firstOrNull { it.simpleTimerId == serverId }?.let { + ServerSimpleTimer(it.simpleTimerId, it.time) + } + }, + ) + } + + private suspend fun fetchCustomServer( + memberId: String, + serverId: Long, + ): BaseResult { + if (!localDataSource.isCurrentMember(memberId)) return accountChangedError() + val list = + apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.getCustomTimerList() }, + mapper = { dto: GetCustomTimerListDTO? -> + dto?.customTimers?.firstOrNull { it.customTimerId == serverId } + }, + ) + if (list is BaseResult.Error) return list + if (!localDataSource.isCurrentMember(memberId)) return accountChangedError() + if ((list as BaseResult.Success).data == null) return BaseResult.Success(null) + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.getCustomTimer(serverId) }, + mapper = { dto: GetCustomTimerDetailDTO? -> + dto?.let { + ServerCustomTimer( + serverId = it.customTimerId, + name = it.customTimerName, + steps = + it.customTimerSteps.map { step -> + CachedTimerStep( + step.customTimerStepOrder, + step.customTimerStepName, + step.customTimerStepTime, + ) + }, + ) + } + }, + ) + } + + private suspend fun classify( + snapshot: TimerSyncSnapshot, + error: BaseResult.Error, + ): TimerSyncResult { + if (error.errorCode == ACCOUNT_CHANGED_CODE) return TimerSyncResult.Skipped + localDataSource.recordSyncError(snapshot, error.errorCode) + return classify(error) + } + + private fun accountChangedError(): BaseResult.Error = BaseResult.Error(errorCode = ACCOUNT_CHANGED_CODE, message = "회원 계정이 변경되었습니다") + + private fun classify(error: BaseResult.Error): TimerSyncResult = + when (error.errorCode) { + ACCOUNT_CHANGED_CODE -> TimerSyncResult.Skipped + NETWORK_ERROR_CODE, UNKNOWN_ERROR_CODE -> TimerSyncResult.Retry + else -> TimerSyncResult.Failure + } + private fun noMemberIdError(): BaseResult.Error = BaseResult.Error(errorCode = "NO_MEMBER_ID", message = "회원ID가 없어 저장할 수 없습니다") private fun notFoundError(localId: String): BaseResult.Error = BaseResult.Error(errorCode = "NOT_FOUND", message = "$localId 타이머를 찾을 수 없습니다") companion object { + private const val ACCOUNT_CHANGED_CODE = "ACCOUNT_CHANGED" private const val NETWORK_ERROR_CODE = "NETWORK_ERROR" + private const val UNKNOWN_ERROR_CODE = "UNKNOWN_ERROR" + private const val TIMER_DELETED_CODE = "TIMER_DELETED" + private const val TIMER_EDIT_CONFLICT_CODE = "TIMER_EDIT_CONFLICT" } } diff --git a/data/src/main/java/com/project200/data/local/UndabangDatabase.kt b/data/src/main/java/com/project200/data/local/UndabangDatabase.kt index 8f80e35b..0118184e 100644 --- a/data/src/main/java/com/project200/data/local/UndabangDatabase.kt +++ b/data/src/main/java/com/project200/data/local/UndabangDatabase.kt @@ -28,7 +28,7 @@ import com.project200.data.local.entity.SimpleTimerEntity SimpleTimerEntity::class, CustomTimerEntity::class, ], - version = 3, + version = 4, exportSchema = true, ) @TypeConverters(UndabangTypeConverters::class) diff --git a/data/src/main/java/com/project200/data/local/entity/CustomTimerEntity.kt b/data/src/main/java/com/project200/data/local/entity/CustomTimerEntity.kt index 44e51cd3..85562aef 100644 --- a/data/src/main/java/com/project200/data/local/entity/CustomTimerEntity.kt +++ b/data/src/main/java/com/project200/data/local/entity/CustomTimerEntity.kt @@ -27,4 +27,10 @@ data class CustomTimerEntity( val createRequestId: String, val pendingEditId: String?, val editedAt: Long, + val inFlightState: SyncState? = null, + val inFlightEditId: String? = null, + val inFlightEditedAt: Long? = null, + val inFlightName: String? = null, + val inFlightSteps: List? = null, + val lastSyncError: String? = null, ) diff --git a/data/src/main/java/com/project200/data/local/entity/SimpleTimerEntity.kt b/data/src/main/java/com/project200/data/local/entity/SimpleTimerEntity.kt index aa91e33c..84bbbafc 100644 --- a/data/src/main/java/com/project200/data/local/entity/SimpleTimerEntity.kt +++ b/data/src/main/java/com/project200/data/local/entity/SimpleTimerEntity.kt @@ -29,4 +29,9 @@ data class SimpleTimerEntity( val createRequestId: String, val pendingEditId: String?, val editedAt: Long, + val inFlightState: SyncState? = null, + val inFlightEditId: String? = null, + val inFlightEditedAt: Long? = null, + val inFlightTime: Int? = null, + val lastSyncError: String? = null, ) diff --git a/data/src/test/java/com/project200/data/datasource/TimerLocalDataSourceTest.kt b/data/src/test/java/com/project200/data/datasource/TimerLocalDataSourceTest.kt index a5f29f95..d6f066ab 100644 --- a/data/src/test/java/com/project200/data/datasource/TimerLocalDataSourceTest.kt +++ b/data/src/test/java/com/project200/data/datasource/TimerLocalDataSourceTest.kt @@ -214,6 +214,74 @@ class TimerLocalDataSourceTest { assertThat(dataSource.getPendingSimpleTimers().map { it.localId }).containsExactly(pending) } + @Test + fun `전송 중 편집 - 이전 snapshot으로 완료하고 새 편집은 대기로 남긴다`() = + runTest { + val localId = dataSource.createSimpleTimer(time = 60)!! + + val snapshot = dataSource.claimSimpleTimer(MEMBER_ID, localId)!! + dataSource.updateSimpleTimer(localId, time = 90) + + val retrySnapshot = dataSource.claimSimpleTimer(MEMBER_ID, localId)!! + dataSource.completeSimple(snapshot, serverId = 7L) + + val saved = database.timerDao().getSimpleTimer(MEMBER_ID, localId)!! + assertThat(retrySnapshot.time).isEqualTo(60) + assertThat(saved.serverId).isEqualTo(7L) + assertThat(saved.time).isEqualTo(90) + assertThat(saved.syncState).isEqualTo(SyncState.UPDATE_PENDING) + assertThat(saved.inFlightState).isNull() + } + + @Test + fun `생성 전송 중 삭제 - 서버 ID를 받은 뒤 삭제 대기로 이어간다`() = + runTest { + // Given + val localId = dataSource.createSimpleTimer(time = 60)!! + val snapshot = dataSource.claimSimpleTimer(MEMBER_ID, localId)!! + + // When + dataSource.deleteSimpleTimer(localId) + dataSource.completeSimple(snapshot, serverId = 7L) + + // Then + val saved = database.timerDao().getSimpleTimer(MEMBER_ID, localId)!! + assertThat(saved.serverId).isEqualTo(7L) + assertThat(saved.syncState).isEqualTo(SyncState.DELETE_PENDING) + assertThat(saved.inFlightState).isNull() + } + + @Test + fun `커스텀 생성 전송 중 삭제 - 서버 ID를 받은 뒤 삭제 대기로 이어간다`() = + runTest { + // Given + val localId = dataSource.createCustomTimer(name = "8세트", steps = steps)!! + val snapshot = dataSource.claimCustomTimer(MEMBER_ID, localId)!! + + // When + dataSource.deleteCustomTimer(localId) + dataSource.completeCustom(snapshot, serverId = 9L) + + // Then + val saved = database.timerDao().getCustomTimer(MEMBER_ID, localId)!! + assertThat(saved.serverId).isEqualTo(9L) + assertThat(saved.syncState).isEqualTo(SyncState.DELETE_PENDING) + assertThat(saved.inFlightState).isNull() + } + + @Test + fun `전송 실패 - 마지막 오류 코드를 기록하고 성공 시 지운다`() = + runTest { + val localId = dataSource.createSimpleTimer(time = 60)!! + val snapshot = dataSource.claimSimpleTimer(MEMBER_ID, localId)!! + + dataSource.recordSyncError(snapshot, "NETWORK_ERROR") + assertThat(database.timerDao().getSimpleTimer(MEMBER_ID, localId)!!.lastSyncError) + .isEqualTo("NETWORK_ERROR") + + dataSource.clearSyncError(snapshot) + assertThat(database.timerDao().getSimpleTimer(MEMBER_ID, localId)!!.lastSyncError).isNull() + } // ── 서버 목록 반영 ──────────────────────────────── @Test diff --git a/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt b/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt index f54f7093..aeea04cf 100644 --- a/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt +++ b/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt @@ -79,7 +79,7 @@ class SchemaRecreatePolicyTest { } @Test - fun `2에서 3 마이그레이션은 대기 운동과 타이머를 보존하고 조회 마커만 추가한다`() = + fun `3에서 4 마이그레이션은 전송 대기 행과 기존 캐시를 보존한다`() = runTest { var database = openDatabase() val pendingRecord = @@ -112,27 +112,41 @@ class SchemaRecreatePolicyTest { database.exerciseCountDao().upsertAll(listOf(ExerciseCountEntity("member-a", date, 2))) database.close() - // 2→3의 유일한 스키마 차이인 조회 마커 테이블을 제거해 실제 이전 파일을 재현합니다 val path = context.getDatabasePath(UndabangDatabase.DATABASE_NAME).absolutePath SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { raw -> - raw.execSQL("DROP TABLE exercise_cache_query") - raw.version = 2 + raw.execSQL( + "CREATE TABLE simple_timer_v3 (" + + "memberId TEXT NOT NULL, localId TEXT NOT NULL, serverId INTEGER, " + + "syncState TEXT NOT NULL, time INTEGER NOT NULL, createRequestId TEXT NOT NULL, " + + "pendingEditId TEXT, editedAt INTEGER NOT NULL, PRIMARY KEY(memberId, localId))", + ) + raw.execSQL( + "INSERT INTO simple_timer_v3 " + + "SELECT memberId, localId, serverId, syncState, time, createRequestId, pendingEditId, editedAt " + + "FROM simple_timer", + ) + raw.execSQL("DROP TABLE simple_timer") + raw.execSQL("ALTER TABLE simple_timer_v3 RENAME TO simple_timer") + raw.execSQL("CREATE INDEX index_simple_timer_memberId_serverId ON simple_timer(memberId, serverId)") + raw.execSQL( + "CREATE TABLE custom_timer_v3 (" + + "memberId TEXT NOT NULL, localId TEXT NOT NULL, serverId INTEGER, " + + "syncState TEXT NOT NULL, name TEXT NOT NULL, steps TEXT NOT NULL, " + + "createRequestId TEXT NOT NULL, pendingEditId TEXT, editedAt INTEGER NOT NULL, " + + "PRIMARY KEY(memberId, localId))", + ) + raw.execSQL("DROP TABLE custom_timer") + raw.execSQL("ALTER TABLE custom_timer_v3 RENAME TO custom_timer") + raw.execSQL("CREATE INDEX index_custom_timer_memberId_serverId ON custom_timer(memberId, serverId)") + raw.version = 3 } database = openDatabase() - assertThat(database.openHelper.readableDatabase.version).isEqualTo(3) + assertThat(database.openHelper.readableDatabase.version).isEqualTo(4) assertThat(database.exerciseRecordDao().getListByDate("member-a", date)).containsExactly(pendingRecord) assertThat(database.timerDao().getSimpleTimer("member-a", "pending-timer")).isEqualTo(pendingTimer) assertThat(database.exerciseCountDao().getRange("member-a", date, date)) .containsExactly(ExerciseCountEntity("member-a", date, 2)) - database.openHelper.writableDatabase.execSQL( - "INSERT INTO exercise_cache_query(memberId, queryType, date) VALUES (?, ?, ?)", - arrayOf("member-a", "list", date.toString()), - ) - database.openHelper.readableDatabase.query("SELECT queryType FROM exercise_cache_query").use { cursor -> - assertThat(cursor.moveToFirst()).isTrue() - assertThat(cursor.getString(0)).isEqualTo("list") - } database.close() } } diff --git a/data/src/test/java/com/project200/data/dto/TimerDTOTest.kt b/data/src/test/java/com/project200/data/dto/TimerDTOTest.kt new file mode 100644 index 00000000..0d132e46 --- /dev/null +++ b/data/src/test/java/com/project200/data/dto/TimerDTOTest.kt @@ -0,0 +1,29 @@ +package com.project200.data.dto + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import org.junit.Test + +class TimerDTOTest { + @Test + fun `타이머 요청 식별자는 UUID 문자열로 직렬화된다`() { + val request = + SimpleTimerRequest( + time = 60, + createRequestId = "00000000-0000-0000-0000-000000000001", + editId = "00000000-0000-0000-0000-000000000002", + editedAt = 123L, + ) + val adapter = + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + .adapter(SimpleTimerRequest::class.java) + + val json = adapter.toJson(request) + + assertThat(json).contains("\"createRequestId\":\"00000000-0000-0000-0000-000000000001\"") + assertThat(json).contains("\"editId\":\"00000000-0000-0000-0000-000000000002\"") + } +} diff --git a/data/src/test/java/com/project200/data/impl/TimerRepositoryImplTest.kt b/data/src/test/java/com/project200/data/impl/TimerRepositoryImplTest.kt index ec56fc0d..379a9a91 100644 --- a/data/src/test/java/com/project200/data/impl/TimerRepositoryImplTest.kt +++ b/data/src/test/java/com/project200/data/impl/TimerRepositoryImplTest.kt @@ -5,6 +5,7 @@ import com.project200.data.api.ApiService import com.project200.data.datasource.ServerCustomTimer import com.project200.data.datasource.ServerSimpleTimer import com.project200.data.datasource.TimerLocalDataSource +import com.project200.data.datasource.TimerSyncSnapshot import com.project200.data.dto.BaseResponse import com.project200.data.dto.CustomTimerDetailStepDTO import com.project200.data.dto.CustomTimerSummaryDTO @@ -12,17 +13,23 @@ import com.project200.data.dto.GetCustomTimerDetailDTO import com.project200.data.dto.GetCustomTimerListDTO import com.project200.data.dto.GetSimpleTimersDTO import com.project200.data.dto.SimpleTimerDTO +import com.project200.data.dto.SimpleTimerIdDTO import com.project200.data.local.entity.CachedTimerStep import com.project200.data.local.entity.CustomTimerEntity import com.project200.data.local.entity.SimpleTimerEntity import com.project200.data.local.entity.SyncState import com.project200.data.mapper.toModel +import com.project200.domain.manager.TimerSyncScheduler import com.project200.domain.model.BaseResult import com.project200.domain.model.Step +import com.project200.domain.model.TimerSyncResult +import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.junit4.MockKRule +import io.mockk.just import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -44,6 +51,9 @@ class TimerRepositoryImplTest { @MockK private lateinit var apiService: ApiService + @MockK + private lateinit var timerSyncScheduler: TimerSyncScheduler + @MockK private lateinit var localDataSource: TimerLocalDataSource @@ -51,7 +61,8 @@ class TimerRepositoryImplTest { @Before fun setUp() { - repository = TimerRepositoryImpl(apiService, localDataSource, UnconfinedTestDispatcher()) + every { timerSyncScheduler.schedule() } just Runs + repository = TimerRepositoryImpl(apiService, localDataSource, UnconfinedTestDispatcher(), timerSyncScheduler) } private fun simpleEntity( @@ -154,6 +165,41 @@ class TimerRepositoryImplTest { assertThat(result).isEqualTo(BaseResult.Success(localSnapshot.map { it.toModel() })) } + @Test + fun `동기화 성공 뒤 남은 pending은 성공으로 종료하지 않는다`() = + runTest { + // Given + val pending = simpleEntity("local-1", null, 60, SyncState.CREATE_PENDING) + val snapshot = + TimerSyncSnapshot.Simple( + localId = "local-1", + serverId = null, + state = SyncState.CREATE_PENDING, + editId = null, + editedAt = 0L, + createRequestId = pending.createRequestId, + time = 60, + ) + every { localDataSource.isCurrentMember(MEMBER_ID) } returns true + coEvery { localDataSource.getPendingSimpleTimers() } returnsMany + listOf(listOf(pending), emptyList(), emptyList(), listOf(pending)) + coEvery { localDataSource.getPendingCustomTimers() } returns emptyList() + coEvery { localDataSource.claimSimpleTimer(MEMBER_ID, "local-1") } returns snapshot + coEvery { apiService.postSimpleTimer(any()) } returns + BaseResponse( + succeed = true, + code = "200", + message = "", + data = SimpleTimerIdDTO(7L), + ) + coEvery { localDataSource.completeSimple(snapshot, 7L) } returns Unit + + // When + val result = repository.syncPendingTimers(MEMBER_ID) + + // Then + assertThat(result).isEqualTo(TimerSyncResult.Retry) + } // ── 심플 타이머 쓰기 ──────────────────────────────── @Test diff --git a/domain/src/main/java/com/project200/domain/manager/TimerSyncScheduler.kt b/domain/src/main/java/com/project200/domain/manager/TimerSyncScheduler.kt new file mode 100644 index 00000000..fdfe730d --- /dev/null +++ b/domain/src/main/java/com/project200/domain/manager/TimerSyncScheduler.kt @@ -0,0 +1,7 @@ +package com.project200.domain.manager + +interface TimerSyncScheduler { + fun schedule() + + fun cancel() +} diff --git a/domain/src/main/java/com/project200/domain/model/TimerSyncResult.kt b/domain/src/main/java/com/project200/domain/model/TimerSyncResult.kt new file mode 100644 index 00000000..710eb8c4 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/model/TimerSyncResult.kt @@ -0,0 +1,8 @@ +package com.project200.domain.model + +sealed interface TimerSyncResult { + data object Success : TimerSyncResult + data object Skipped : TimerSyncResult + data object Retry : TimerSyncResult + data object Failure : TimerSyncResult +} diff --git a/domain/src/main/java/com/project200/domain/repository/TimerRepository.kt b/domain/src/main/java/com/project200/domain/repository/TimerRepository.kt index 73b0cb6c..07a3be73 100644 --- a/domain/src/main/java/com/project200/domain/repository/TimerRepository.kt +++ b/domain/src/main/java/com/project200/domain/repository/TimerRepository.kt @@ -4,6 +4,7 @@ import com.project200.domain.model.BaseResult import com.project200.domain.model.CustomTimer import com.project200.domain.model.SimpleTimer import com.project200.domain.model.Step +import com.project200.domain.model.TimerSyncResult /** * 타이머는 기기가 원본입니다. 쓰기는 로컬에 먼저 반영되고 전송은 워커가 맡습니다. @@ -48,4 +49,5 @@ interface TimerRepository { ): BaseResult suspend fun deleteCustomTimer(localId: String): BaseResult + suspend fun syncPendingTimers(memberId: String): TimerSyncResult } diff --git a/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt index 95b7bee0..32b92d13 100644 --- a/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt @@ -2,6 +2,7 @@ package com.project200.domain.usecase import com.project200.domain.manager.FcmTokenSyncScheduler import com.project200.domain.manager.SessionDataCleaner +import com.project200.domain.manager.TimerSyncScheduler import com.project200.domain.model.SessionExitReason import com.project200.domain.repository.AuthRepository import javax.inject.Inject @@ -11,6 +12,7 @@ class ClearSessionUseCase constructor( private val authRepository: AuthRepository, private val fcmTokenSyncScheduler: FcmTokenSyncScheduler, + private val timerSyncScheduler: TimerSyncScheduler, private val sessionDataCleaner: SessionDataCleaner, ) { /** @@ -33,6 +35,7 @@ constructor( } } finally { fcmTokenSyncScheduler.cancel() + timerSyncScheduler.cancel() when (reason) { SessionExitReason.USER_INITIATED -> authRepository.clearSession() SessionExitReason.FORCED -> authRepository.clearTokens() diff --git a/domain/src/main/java/com/project200/domain/usecase/SyncTimersUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/SyncTimersUseCase.kt new file mode 100644 index 00000000..8995b08a --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/SyncTimersUseCase.kt @@ -0,0 +1,12 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.TimerSyncResult +import com.project200.domain.repository.TimerRepository +import javax.inject.Inject + +class SyncTimersUseCase @Inject constructor( + private val timerRepository: TimerRepository, +) { + suspend operator fun invoke(memberId: String): TimerSyncResult = + timerRepository.syncPendingTimers(memberId) +} diff --git a/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt index 15940c2a..fefe2000 100644 --- a/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt @@ -3,6 +3,7 @@ package com.project200.domain.usecase import com.google.common.truth.Truth.assertThat import com.project200.domain.manager.FcmTokenSyncScheduler import com.project200.domain.manager.SessionDataCleaner +import com.project200.domain.manager.TimerSyncScheduler import com.project200.domain.model.SessionExitReason import com.project200.domain.repository.AuthRepository import io.mockk.Runs @@ -32,6 +33,9 @@ class ClearSessionUseCaseTest { @MockK private lateinit var mockFcmTokenSyncScheduler: FcmTokenSyncScheduler + @MockK + private lateinit var mockTimerSyncScheduler: TimerSyncScheduler + @MockK private lateinit var mockSessionDataCleaner: SessionDataCleaner @@ -39,10 +43,11 @@ class ClearSessionUseCaseTest { @Before fun setUp() { - useCase = ClearSessionUseCase(mockRepository, mockFcmTokenSyncScheduler, mockSessionDataCleaner) + useCase = ClearSessionUseCase(mockRepository, mockFcmTokenSyncScheduler, mockTimerSyncScheduler, mockSessionDataCleaner) coEvery { mockRepository.clearSession() } just Runs coEvery { mockRepository.clearTokens() } just Runs every { mockFcmTokenSyncScheduler.cancel() } just Runs + every { mockTimerSyncScheduler.cancel() } just Runs coEvery { mockSessionDataCleaner.clearAll() } just Runs }