diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca49467..cf492bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -140,6 +140,9 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.hilt.navigation.compose) implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.hilt.work) + ksp(libs.hilt.work.compiler) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e8dbfc1..fc63dcd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + + + diff --git a/app/src/main/java/com/example/catalogapp/CatalogApplication.kt b/app/src/main/java/com/example/catalogapp/CatalogApplication.kt index 89275aa..7a0be34 100644 --- a/app/src/main/java/com/example/catalogapp/CatalogApplication.kt +++ b/app/src/main/java/com/example/catalogapp/CatalogApplication.kt @@ -1,7 +1,28 @@ package com.example.catalogapp import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import com.example.catalogapp.data.product.sync.SyncScheduler import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject @HiltAndroidApp -class CatalogApplication : Application() +class CatalogApplication : Application(), Configuration.Provider { + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + @Inject + lateinit var syncScheduler: SyncScheduler + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + + override fun onCreate() { + super.onCreate() + syncScheduler.schedulePeriodicSync() + } +} diff --git a/data/product/build.gradle.kts b/data/product/build.gradle.kts index 90c1440..a40fbde 100644 --- a/data/product/build.gradle.kts +++ b/data/product/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { // Room — local cache implementation(libs.room.runtime) implementation(libs.room.ktx) + implementation(libs.core.ktx) ksp(libs.room.compiler) // Hilt @@ -73,12 +74,18 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.retrofit.core) + implementation(libs.gson) + + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.hilt.work) + ksp(libs.hilt.work.compiler) testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.retrofit.core) testImplementation(libs.okhttp.core) + testImplementation(libs.androidx.work.testing) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/di/WorkManagerModule.kt b/data/product/src/main/java/com/example/catalogapp/data/product/di/WorkManagerModule.kt new file mode 100644 index 0000000..3b58e65 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/di/WorkManagerModule.kt @@ -0,0 +1,19 @@ +package com.example.catalogapp.data.product.di + +import android.content.Context +import androidx.work.WorkManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object WorkManagerModule { + @Provides + @Singleton + fun provideWorkManager(@ApplicationContext context: Context): WorkManager = + WorkManager.getInstance(context) +} diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/repository/ProductRepositoryImpl.kt b/data/product/src/main/java/com/example/catalogapp/data/product/repository/ProductRepositoryImpl.kt index f088a43..15afc25 100644 --- a/data/product/src/main/java/com/example/catalogapp/data/product/repository/ProductRepositoryImpl.kt +++ b/data/product/src/main/java/com/example/catalogapp/data/product/repository/ProductRepositoryImpl.kt @@ -9,6 +9,8 @@ import com.example.catalogapp.data.product.mapper.toDomainList import com.example.catalogapp.data.product.mapper.toEntityList import com.example.catalogapp.domain.product.Product import com.example.catalogapp.domain.product.ProductRepository +import com.example.catalogapp.domain.product.SyncError +import com.example.catalogapp.domain.product.SyncResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -49,6 +51,7 @@ class ProductRepositoryImpl @Inject constructor( ?: fetchProductByIdFromNetwork(id) } + override suspend fun syncProducts(): SyncResult = refreshProducts() // ─── Private helpers ────────────────────────────────────────────────────── private suspend fun shouldRefreshCache(): Boolean { @@ -56,19 +59,25 @@ class ProductRepositoryImpl @Inject constructor( return System.currentTimeMillis() - oldestTimestamp > CACHE_THRESHOLD_MS } - private suspend fun refreshProducts() { - if (!connectivityChecker.isConnected()) return - - try { + // refreshProducts() becomes the single source of truth for both callers + private suspend fun refreshProducts(): SyncResult { + if (!connectivityChecker.isConnected()) { + return SyncResult.Error(SyncError.NoInternet) + } + return try { val products = apiService.getProducts() productDao.clearAll() productDao.insertProducts(products.toEntityList()) + SyncResult.Success } catch (e: SocketTimeoutException) { logError(NetworkError.Timeout, e) + SyncResult.Error(SyncError.Timeout) } catch (e: IOException) { logError(NetworkError.NoInternet, e) + SyncResult.Error(SyncError.NoInternet) } catch (e: HttpException) { logError(NetworkError.ServerError(e.code(), e.message()), e) + SyncResult.Error(SyncError.ServerError(e.code(), e.message())) } } diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/sync/SyncScheduler.kt b/data/product/src/main/java/com/example/catalogapp/data/product/sync/SyncScheduler.kt new file mode 100644 index 0000000..e388186 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/sync/SyncScheduler.kt @@ -0,0 +1,46 @@ +package com.example.catalogapp.data.product.sync + +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.example.catalogapp.data.product.worker.ProductSyncWorker +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class SyncScheduler @Inject constructor( + private val workManager: WorkManager +) { + fun schedulePeriodicSync() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(true) + .build() + + val syncRequest = PeriodicWorkRequestBuilder( + repeatInterval = SYNC_INTERVAL_HOURS, + repeatIntervalTimeUnit = TimeUnit.HOURS + ) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_DELAY_MINUTES, TimeUnit.MINUTES) + .addTag(ProductSyncWorker.WORK_NAME) + .build() + + workManager.enqueueUniquePeriodicWork( + ProductSyncWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + syncRequest + ) + } + + fun cancelSync() { + workManager.cancelUniqueWork(ProductSyncWorker.WORK_NAME) + } + + companion object { + private const val SYNC_INTERVAL_HOURS = 6L + private const val BACKOFF_DELAY_MINUTES = 15L + } +} diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/worker/ProductSyncWorker.kt b/data/product/src/main/java/com/example/catalogapp/data/product/worker/ProductSyncWorker.kt new file mode 100644 index 0000000..898b820 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/worker/ProductSyncWorker.kt @@ -0,0 +1,27 @@ +package com.example.catalogapp.data.product.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.example.catalogapp.domain.product.ProductRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +@HiltWorker +class ProductSyncWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + private val productRepository: ProductRepository +) : CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result = + productRepository.syncProducts().toWorkerResult(runAttemptCount) + + companion object { + const val WORK_NAME = "ProductSyncWorker" + } +} + + + diff --git a/data/product/src/main/java/com/example/catalogapp/data/product/worker/SyncResultMapper.kt b/data/product/src/main/java/com/example/catalogapp/data/product/worker/SyncResultMapper.kt new file mode 100644 index 0000000..30f19d5 --- /dev/null +++ b/data/product/src/main/java/com/example/catalogapp/data/product/worker/SyncResultMapper.kt @@ -0,0 +1,28 @@ +package com.example.catalogapp.data.product.worker + +import androidx.work.ListenableWorker.Result +import com.example.catalogapp.domain.product.SyncError +import com.example.catalogapp.domain.product.SyncResult + +/** + * Pure mapping from domain SyncResult to WorkManager's Result. + * No coroutines, no Android framework, no WorkManager instantiation — + * testable with plain JUnit in milliseconds. + */ +internal fun SyncResult.toWorkerResult(runAttemptCount: Int): Result = when (this) { + is SyncResult.Success -> Result.success() + is SyncResult.Error -> when (val error = reason) { + is SyncError.Timeout, + is SyncError.NoInternet -> retryOrFail(runAttemptCount) + + is SyncError.ServerError -> + if (error.code in RETRYABLE_HTTP_CODES) retryOrFail(runAttemptCount) else Result.failure() + } +} + +private fun retryOrFail(runAttemptCount: Int): Result = + if (runAttemptCount < MAX_RETRY_ATTEMPTS) Result.retry() else Result.failure() + +private const val MAX_RETRY_ATTEMPTS = 3 +private val RETRYABLE_HTTP_CODES = 500..599 + diff --git a/data/product/src/test/java/com/example/catalogapp/data/product/repository/ProductRepositoryImplTest.kt b/data/product/src/test/java/com/example/catalogapp/data/product/repository/ProductRepositoryImplTest.kt index d9361d9..fa27f22 100644 --- a/data/product/src/test/java/com/example/catalogapp/data/product/repository/ProductRepositoryImplTest.kt +++ b/data/product/src/test/java/com/example/catalogapp/data/product/repository/ProductRepositoryImplTest.kt @@ -6,12 +6,15 @@ import com.example.catalogapp.core.network.dto.ProductDto import com.example.catalogapp.core.network.dto.RatingDto import com.example.catalogapp.data.product.local.dao.ProductDao import com.example.catalogapp.data.product.local.entity.ProductEntity +import com.example.catalogapp.domain.product.SyncError +import com.example.catalogapp.domain.product.SyncResult import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -151,22 +154,23 @@ class ProductRepositoryImplTest { } @Test - fun `refreshProducts handles SocketTimeoutException silently and serves stale cache`() = runTest { - // Arrange — stale cache, connected, but network times out - val staleTimestamp = System.currentTimeMillis() - (31 * 60 * 1000L) - every { productDao.getAllProducts() } returns flowOf(listOf(fakeEntity)) - coEvery { productDao.getOldestCacheTimestamp() } returns staleTimestamp - every { connectivityChecker.isConnected() } returns true - coEvery { apiService.getProducts() } throws SocketTimeoutException() - coEvery { productDao.clearAll() } returns Unit - - // Act — should not throw, stale cache served silently - val result = repository.getProducts().first() - - // Assert — user still sees cached data, no crash - assertEquals(1, result.size) - assertEquals("Gold Ring", result[0].title) - } + fun `refreshProducts handles SocketTimeoutException silently and serves stale cache`() = + runTest { + // Arrange — stale cache, connected, but network times out + val staleTimestamp = System.currentTimeMillis() - (31 * 60 * 1000L) + every { productDao.getAllProducts() } returns flowOf(listOf(fakeEntity)) + coEvery { productDao.getOldestCacheTimestamp() } returns staleTimestamp + every { connectivityChecker.isConnected() } returns true + coEvery { apiService.getProducts() } throws SocketTimeoutException() + coEvery { productDao.clearAll() } returns Unit + + // Act — should not throw, stale cache served silently + val result = repository.getProducts().first() + + // Assert — user still sees cached data, no crash + assertEquals(1, result.size) + assertEquals("Gold Ring", result[0].title) + } @Test fun `refreshProducts handles HttpException silently and serves stale cache`() = runTest { @@ -213,4 +217,30 @@ class ProductRepositoryImplTest { assertEquals(0f, result?.rating) assertEquals("Test Product", result?.title) } + + @Test + fun `syncProducts returns Success when sync completes`() = runTest { + every { connectivityChecker.isConnected() } returns true + coEvery { apiService.getProducts() } returns listOf(fakeDto) + coEvery { productDao.clearAll() } returns Unit + coEvery { productDao.insertProducts(any()) } returns Unit + + val result = repository.syncProducts() + + assertTrue(result is SyncResult.Success) + coVerify { apiService.getProducts() } + coVerify { productDao.clearAll() } + coVerify { productDao.insertProducts(any()) } + } + + @Test + fun `syncProducts returns Error NoInternet when disconnected`() = runTest { + every { connectivityChecker.isConnected() } returns false + + val result = repository.syncProducts() + + assertTrue(result is SyncResult.Error) + assertTrue((result as SyncResult.Error).reason is SyncError.NoInternet) + coVerify(exactly = 0) { apiService.getProducts() } + } } diff --git a/data/product/src/test/java/com/example/catalogapp/data/product/worker/SyncResultMapperTest.kt b/data/product/src/test/java/com/example/catalogapp/data/product/worker/SyncResultMapperTest.kt new file mode 100644 index 0000000..c3d6795 --- /dev/null +++ b/data/product/src/test/java/com/example/catalogapp/data/product/worker/SyncResultMapperTest.kt @@ -0,0 +1,48 @@ +package com.example.catalogapp.data.product.worker + +import androidx.work.ListenableWorker.Result +import com.example.catalogapp.domain.product.SyncError +import com.example.catalogapp.domain.product.SyncResult +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncResultMapperTest { + + @Test + fun `Success maps to Result Success`() { + val result = SyncResult.Success.toWorkerResult(runAttemptCount = 0) + assertTrue(result is Result.Success) + } + + @Test + fun `Timeout retries under max attempts`() { + val result = SyncResult.Error(SyncError.Timeout).toWorkerResult(runAttemptCount = 1) + assertTrue(result is Result.Retry) + } + + @Test + fun `Timeout fails after max attempts exhausted`() { + val result = SyncResult.Error(SyncError.Timeout).toWorkerResult(runAttemptCount = 3) + assertTrue(result is Result.Failure) + } + + @Test + fun `NoInternet retries under max attempts`() { + val result = SyncResult.Error(SyncError.NoInternet).toWorkerResult(runAttemptCount = 0) + assertTrue(result is Result.Retry) + } + + @Test + fun `5xx server error retries`() { + val result = SyncResult.Error(SyncError.ServerError(503, "Unavailable")) + .toWorkerResult(runAttemptCount = 0) + assertTrue(result is Result.Retry) + } + + @Test + fun `4xx server error fails fast without retry`() { + val result = SyncResult.Error(SyncError.ServerError(400, "Bad Request")) + .toWorkerResult(runAttemptCount = 0) + assertTrue(result is Result.Failure) + } +} \ No newline at end of file diff --git a/domain/product/src/main/java/com/example/catalogapp/domain/product/ProductRepository.kt b/domain/product/src/main/java/com/example/catalogapp/domain/product/ProductRepository.kt index 01c7f1b..df5147f 100644 --- a/domain/product/src/main/java/com/example/catalogapp/domain/product/ProductRepository.kt +++ b/domain/product/src/main/java/com/example/catalogapp/domain/product/ProductRepository.kt @@ -5,4 +5,6 @@ import kotlinx.coroutines.flow.Flow interface ProductRepository { fun getProducts(): Flow> suspend fun getProductById(id: Int): Product? + suspend fun syncProducts(): SyncResult } + diff --git a/domain/product/src/main/java/com/example/catalogapp/domain/product/SyncResult.kt b/domain/product/src/main/java/com/example/catalogapp/domain/product/SyncResult.kt new file mode 100644 index 0000000..117e139 --- /dev/null +++ b/domain/product/src/main/java/com/example/catalogapp/domain/product/SyncResult.kt @@ -0,0 +1,12 @@ +package com.example.catalogapp.domain.product + +sealed class SyncResult { + object Success : SyncResult() + data class Error(val reason: SyncError) : SyncResult() +} + +sealed class SyncError { + object Timeout : SyncError() + object NoInternet : SyncError() + data class ServerError(val code: Int, val message: String?) : SyncError() +} diff --git a/domain/product/src/test/java/com/example/catalogapp/domain/product/SyncResultTest.kt b/domain/product/src/test/java/com/example/catalogapp/domain/product/SyncResultTest.kt new file mode 100644 index 0000000..25fe0a5 --- /dev/null +++ b/domain/product/src/test/java/com/example/catalogapp/domain/product/SyncResultTest.kt @@ -0,0 +1,18 @@ +package com.example.catalogapp.domain.product + +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncResultTest { + @Test + fun `Success is a SyncResult`() { + assertTrue(true) + } + + @Test + fun `Error wraps SyncError correctly`() { + val result = SyncResult.Error(SyncError.ServerError(500, "Internal error")) + assertTrue(true) + assertTrue(result.reason is SyncError.ServerError) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 559d10b..48ddfd6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ composeBom = "2024.09.00" coroutines = "1.9.0" retrofit = "2.11.0" okhttp = "4.12.0" +robolectric = "4.14.1" room = "2.8.4" lifecycle = "2.8.7" navigation = "2.8.5" @@ -24,9 +25,15 @@ detekt = "1.23.7" kotlin-serialization = "2.2.10" foundation = "1.11.4" uiText = "1.11.4" +workmanager = "2.9.1" +hilt-work = "1.2.0" +gson = "2.11.0" +workTesting = "2.11.2" +coreKtxVersion = "1.7.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "workTesting" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -53,6 +60,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version material = { group = "com.google.android.material", name = "material", version.ref = "material" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } @@ -65,6 +73,11 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material" kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.7.3" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } androidx-compose-ui-text = { group = "androidx.compose.ui", name = "ui-text", version.ref = "uiText" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" } +hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hilt-work" } +hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hilt-work" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "coreKtxVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }