Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application
android:name=".CatalogApplication"
Expand All @@ -21,6 +22,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup.InitializationProvider"
tools:node="remove" />
</provider>
</application>

</manifest>
23 changes: 22 additions & 1 deletion app/src/main/java/com/example/catalogapp/CatalogApplication.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
7 changes: 7 additions & 0 deletions data/product/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,26 +51,33 @@ class ProductRepositoryImpl @Inject constructor(
?: fetchProductByIdFromNetwork(id)
}

override suspend fun syncProducts(): SyncResult = refreshProducts()
// ─── Private helpers ──────────────────────────────────────────────────────

private suspend fun shouldRefreshCache(): Boolean {
val oldestTimestamp = productDao.getOldestCacheTimestamp() ?: return true
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()))
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProductSyncWorker>(
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
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}



Original file line number Diff line number Diff line change
@@ -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

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() }
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading