diff --git a/app/build.gradle b/app/build.gradle index 4463339..7afeb64 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -124,6 +124,9 @@ dependencies { // Dexter implementation "com.karumi:dexter:$dexter_version" + // WorkManager + implementation "androidx.work:work-runtime-ktx:2.9.0" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.activity:activity:1.9.1' diff --git a/app/src/androidTest/java/com/turingheights/wally/commons/data/local/daos/CachedPhotoDaoTest.kt b/app/src/androidTest/java/com/turingheights/wally/commons/data/local/daos/CachedPhotoDaoTest.kt new file mode 100644 index 0000000..8c3eb2d --- /dev/null +++ b/app/src/androidTest/java/com/turingheights/wally/commons/data/local/daos/CachedPhotoDaoTest.kt @@ -0,0 +1,101 @@ +package com.turingheights.wally.commons.data.local.daos + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.turingheights.wally.commons.data.local.PhotoDatabase +import com.turingheights.wally.commons.data.local.entities.CacheMetadataEntity +import com.turingheights.wally.commons.models.Photo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class CachedPhotoDaoTest { + + private lateinit var database: PhotoDatabase + private lateinit var dao: CachedPhotoDao + + private val mockPhoto = Photo( + id = 1, + previewURL = "preview", + fullHDURL = "full", + imageURL = "image", + previewHeight = 100, + previewWidth = 100, + largeImageURL = "large", + webformatURL = "web", + webformatWidth = 100, + webformatHeight = 100 + ) + + @Before + fun setup() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + PhotoDatabase::class.java + ).allowMainThreadQueries().build() + dao = database.cachedPhotoDao() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun saveAndGetCachedPhotos() = runTest { + val metadata = CacheMetadataEntity( + query = "nature", + category = "all", + safeSearch = true, + orientation = "all", + imageType = "all", + order = "popular" + ) + + dao.saveCache(metadata, listOf(mockPhoto)) + + val cachedPhotos = dao.getCachedPhotos( + query = "nature", + category = "all", + safeSearch = true, + orientation = "all", + imageType = "all", + order = "popular" + ) + + assertThat(cachedPhotos).hasSize(1) + assertThat(cachedPhotos[0].id).isEqualTo(mockPhoto.id) + } + + @Test + fun getCachedPhotos_returnsEmpty_whenNoMatch() = runTest { + val metadata = CacheMetadataEntity( + query = "nature", + category = "all", + safeSearch = true, + orientation = "all", + imageType = "all", + order = "popular" + ) + + dao.saveCache(metadata, listOf(mockPhoto)) + + val cachedPhotos = dao.getCachedPhotos( + query = "different", + category = "all", + safeSearch = true, + orientation = "all", + imageType = "all", + order = "popular" + ) + + assertThat(cachedPhotos).isEmpty() + } +} diff --git a/app/src/main/java/com/turingheights/wally/MainActivity.kt b/app/src/main/java/com/turingheights/wally/MainActivity.kt index 85eb658..3886c01 100644 --- a/app/src/main/java/com/turingheights/wally/MainActivity.kt +++ b/app/src/main/java/com/turingheights/wally/MainActivity.kt @@ -5,8 +5,13 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager import dagger.hilt.android.AndroidEntryPoint +import com.turingheights.wally.commons.utils.CacheCleanupWorker import com.turingheights.wally.databinding.ActivityMainBinding +import java.util.concurrent.TimeUnit @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -23,5 +28,18 @@ class MainActivity : AppCompatActivity() { navController = (supportFragmentManager.findFragmentById(viewBinding.navHost.id) as NavHostFragment).navController + + scheduleCacheCleanup() + } + + private fun scheduleCacheCleanup() { + val cleanupRequest = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .build() + + WorkManager.getInstance(applicationContext).enqueueUniquePeriodicWork( + "CacheCleanup", + ExistingPeriodicWorkPolicy.KEEP, + cleanupRequest + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/turingheights/wally/commons/data/local/PhotoDatabase.kt b/app/src/main/java/com/turingheights/wally/commons/data/local/PhotoDatabase.kt index 6353f88..e99f76e 100644 --- a/app/src/main/java/com/turingheights/wally/commons/data/local/PhotoDatabase.kt +++ b/app/src/main/java/com/turingheights/wally/commons/data/local/PhotoDatabase.kt @@ -4,23 +4,36 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import com.turingheights.wally.commons.data.local.daos.CachedPhotoDao import com.turingheights.wally.commons.data.local.daos.PhotoDao +import com.turingheights.wally.commons.data.local.entities.CacheMetadataEntity +import com.turingheights.wally.commons.data.local.entities.CachedPhotoEntity import com.turingheights.wally.commons.data.local.entities.PhotoEntity -@Database(entities = [PhotoEntity::class], version = 1) +@Database( + entities = [ + PhotoEntity::class, + CachedPhotoEntity::class, + CacheMetadataEntity::class + ], + version = 2 +) abstract class PhotoDatabase: RoomDatabase() { abstract fun photoDao(): PhotoDao + abstract fun cachedPhotoDao(): CachedPhotoDao companion object { private var INSTANCE: PhotoDatabase? = null fun getInstance(context: Context): PhotoDatabase { if (INSTANCE == null) { - INSTANCE = Room.databaseBuilder(context, PhotoDatabase::class.java, "photo.db").build() + INSTANCE = Room.databaseBuilder(context, PhotoDatabase::class.java, "photo.db") + .fallbackToDestructiveMigration() + .build() } return INSTANCE!! } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/turingheights/wally/commons/data/local/daos/CachedPhotoDao.kt b/app/src/main/java/com/turingheights/wally/commons/data/local/daos/CachedPhotoDao.kt new file mode 100644 index 0000000..1c91acf --- /dev/null +++ b/app/src/main/java/com/turingheights/wally/commons/data/local/daos/CachedPhotoDao.kt @@ -0,0 +1,80 @@ +package com.turingheights.wally.commons.data.local.daos + +import androidx.room.* +import com.turingheights.wally.commons.data.local.entities.CacheMetadataEntity +import com.turingheights.wally.commons.data.local.entities.CachedPhotoEntity +import com.turingheights.wally.commons.models.Photo + +@Dao +interface CachedPhotoDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMetadata(metadata: CacheMetadataEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCachedPhotos(photos: List) + + @Query(""" + SELECT * FROM cache_metadata + WHERE `query` IS :query + AND category IS :category + AND safeSearch = :safeSearch + AND orientation = :orientation + AND imageType = :imageType + AND `order` = :order + ORDER BY timestamp DESC LIMIT 1 + """) + suspend fun getLatestMetadata( + query: String?, + category: String?, + safeSearch: Boolean, + orientation: String, + imageType: String, + order: String + ): CacheMetadataEntity? + + @Query("SELECT * FROM cached_photos WHERE cacheMetadataId = :metadataId") + suspend fun getPhotosForMetadata(metadataId: Long): List + + @Transaction + suspend fun saveCache(metadata: CacheMetadataEntity, photos: List) { + // Optional: clear old cache for these params first + deleteOldCache(metadata.query, metadata.category, metadata.safeSearch, metadata.orientation, metadata.imageType, metadata.order) + + val id = insertMetadata(metadata) + val entities = photos.map { CachedPhotoEntity.fromPhoto(it, id) } + insertCachedPhotos(entities) + } + + @Query(""" + DELETE FROM cache_metadata + WHERE `query` IS :query + AND category IS :category + AND safeSearch = :safeSearch + AND orientation = :orientation + AND imageType = :imageType + AND `order` = :order + """) + suspend fun deleteOldCache( + query: String?, + category: String?, + safeSearch: Boolean, + orientation: String, + imageType: String, + order: String + ) + + // Helper to get photos directly if cache exists + @Transaction + suspend fun getCachedPhotos( + query: String?, + category: String?, + safeSearch: Boolean, + orientation: String, + imageType: String, + order: String + ): List { + val metadata = getLatestMetadata(query, category, safeSearch, orientation, imageType, order) + return metadata?.let { getPhotosForMetadata(it.id) } ?: emptyList() + } +} diff --git a/app/src/main/java/com/turingheights/wally/commons/data/local/entities/CacheMetadataEntity.kt b/app/src/main/java/com/turingheights/wally/commons/data/local/entities/CacheMetadataEntity.kt new file mode 100644 index 0000000..40f333b --- /dev/null +++ b/app/src/main/java/com/turingheights/wally/commons/data/local/entities/CacheMetadataEntity.kt @@ -0,0 +1,17 @@ +package com.turingheights.wally.commons.data.local.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "cache_metadata") +data class CacheMetadataEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val query: String?, + val category: String?, + val safeSearch: Boolean, + val orientation: String, + val imageType: String, + val order: String, + val timestamp: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/turingheights/wally/commons/data/local/entities/CachedPhotoEntity.kt b/app/src/main/java/com/turingheights/wally/commons/data/local/entities/CachedPhotoEntity.kt new file mode 100644 index 0000000..70c5297 --- /dev/null +++ b/app/src/main/java/com/turingheights/wally/commons/data/local/entities/CachedPhotoEntity.kt @@ -0,0 +1,64 @@ +package com.turingheights.wally.commons.data.local.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.turingheights.wally.commons.models.Photo + +@Entity( + tableName = "cached_photos", + foreignKeys = [ + ForeignKey( + entity = CacheMetadataEntity::class, + parentColumns = ["id"], + childColumns = ["cacheMetadataId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index(value = ["cacheMetadataId"])] +) +data class CachedPhotoEntity( + @PrimaryKey(autoGenerate = true) + val cacheEntryId: Long = 0, + val cacheMetadataId: Long, + val id: Int, // The original photo ID from API + val previewURL: String, + val fullHDURL: String?, + val imageURL: String?, + val previewHeight: Int, + val previewWidth: Int, + val largeImageURL: String, + val webformatURL: String, + val webformatWidth: Int, + val webformatHeight: Int +) { + companion object { + fun fromPhoto(photo: Photo, cacheMetadataId: Long) = CachedPhotoEntity( + cacheMetadataId = cacheMetadataId, + id = photo.id, + previewURL = photo.previewURL, + fullHDURL = photo.fullHDURL, + imageURL = photo.imageURL, + previewHeight = photo.previewHeight, + previewWidth = photo.previewWidth, + largeImageURL = photo.largeImageURL, + webformatURL = photo.webformatURL, + webformatWidth = photo.webformatWidth, + webformatHeight = photo.webformatHeight, + ) + + fun toPhoto(entity: CachedPhotoEntity) = Photo( + id = entity.id, + previewURL = entity.previewURL, + fullHDURL = entity.fullHDURL, + imageURL = entity.imageURL, + previewHeight = entity.previewHeight, + previewWidth = entity.previewWidth, + largeImageURL = entity.largeImageURL, + webformatURL = entity.webformatURL, + webformatWidth = entity.webformatWidth, + webformatHeight = entity.webformatHeight, + ) + } +} diff --git a/app/src/main/java/com/turingheights/wally/commons/di/modules/DataModule.kt b/app/src/main/java/com/turingheights/wally/commons/di/modules/DataModule.kt index 16c1530..b1fcb47 100644 --- a/app/src/main/java/com/turingheights/wally/commons/di/modules/DataModule.kt +++ b/app/src/main/java/com/turingheights/wally/commons/di/modules/DataModule.kt @@ -19,4 +19,7 @@ class DataModule { return PhotoDatabase.getInstance(context) } + @Provides + fun providesCachedPhotoDao(database: PhotoDatabase) = database.cachedPhotoDao() + } \ No newline at end of file diff --git a/app/src/main/java/com/turingheights/wally/commons/utils/CacheCleanupWorker.kt b/app/src/main/java/com/turingheights/wally/commons/utils/CacheCleanupWorker.kt new file mode 100644 index 0000000..4e2b073 --- /dev/null +++ b/app/src/main/java/com/turingheights/wally/commons/utils/CacheCleanupWorker.kt @@ -0,0 +1,39 @@ +package com.turingheights.wally.commons.utils + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import androidx.work.ListenableWorker +import timber.log.Timber +import java.io.File + +class CacheCleanupWorker( + context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): ListenableWorker.Result { + return try { + val cacheDir = File(applicationContext.cacheDir, "image_manager_disk_cache") + if (cacheDir.exists() && cacheDir.isDirectory) { + val currentTime = System.currentTimeMillis() + val expiryTime = 30L * 24 * 60 * 60 * 1000 // 30 days in ms + + val files = cacheDir.listFiles() + var deletedCount = 0 + files?.forEach { file -> + if (currentTime - file.lastModified() > expiryTime) { + if (file.delete()) { + deletedCount++ + } + } + } + Timber.d("Cache cleanup completed. Deleted $deletedCount files.") + } + ListenableWorker.Result.success() + } catch (e: Exception) { + Timber.e(e, "Error during cache cleanup") + ListenableWorker.Result.failure() + } + } +} diff --git a/app/src/main/java/com/turingheights/wally/commons/utils/WallyGlideModule.kt b/app/src/main/java/com/turingheights/wally/commons/utils/WallyGlideModule.kt new file mode 100644 index 0000000..00532c5 --- /dev/null +++ b/app/src/main/java/com/turingheights/wally/commons/utils/WallyGlideModule.kt @@ -0,0 +1,41 @@ +package com.turingheights.wally.commons.utils + +import android.content.Context +import android.os.Environment +import android.os.StatFs +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory +import com.bumptech.glide.load.engine.cache.LruResourceCache +import com.bumptech.glide.module.AppGlideModule +import timber.log.Timber + +@GlideModule +class WallyGlideModule : AppGlideModule() { + + override fun applyOptions(context: Context, builder: GlideBuilder) { + val minStorageRequired = 50 * 1024 * 1024 // 50MB + if (!isStorageAvailable(context, minStorageRequired)) { + Timber.w("Low storage detected. Disabling disk cache.") + // Effectively disable disk cache by setting size to 0 + builder.setDiskCache(InternalCacheDiskCacheFactory(context, 0)) + } else { + // Default behavior or custom size + builder.setDiskCache(InternalCacheDiskCacheFactory(context, 250 * 1024 * 1024)) // 250MB + } + + // Increase memory cache slightly for better performance + builder.setMemoryCache(LruResourceCache(20 * 1024 * 1024)) // 20MB + } + + private fun isStorageAvailable(context: Context, requiredBytes: Int): Boolean { + return try { + val path = context.cacheDir + val stat = StatFs(path.path) + val availableBytes = stat.availableBlocksLong * stat.blockSizeLong + availableBytes > requiredBytes + } catch (e: Exception) { + false + } + } +} diff --git a/app/src/main/java/com/turingheights/wally/commons/views/FavouritesFragment.kt b/app/src/main/java/com/turingheights/wally/commons/views/FavouritesFragment.kt index 81e3ede..36edfa3 100644 --- a/app/src/main/java/com/turingheights/wally/commons/views/FavouritesFragment.kt +++ b/app/src/main/java/com/turingheights/wally/commons/views/FavouritesFragment.kt @@ -113,7 +113,7 @@ class FavouritesFragment : Fragment(R.layout.fragment_favourites) { Glide.with(viewBinding.root.context) .load(photo.webformatURL) .thumbnail(Glide.with(viewBinding.root.context).load(photo.previewURL)) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .diskCacheStrategy(DiskCacheStrategy.ALL) .transition(DrawableTransitionOptions.withCrossFade()) .listener(object : RequestListener { override fun onLoadFailed( diff --git a/app/src/main/java/com/turingheights/wally/commons/views/FullImageFragment.kt b/app/src/main/java/com/turingheights/wally/commons/views/FullImageFragment.kt index ec1b51f..193fb73 100644 --- a/app/src/main/java/com/turingheights/wally/commons/views/FullImageFragment.kt +++ b/app/src/main/java/com/turingheights/wally/commons/views/FullImageFragment.kt @@ -14,8 +14,11 @@ import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import com.turingheights.wally.R @@ -60,21 +63,52 @@ class FullImageFragment : Fragment(R.layout.fragment_full_image) { findNavController().navigateUp() } - Glide.with(requireContext()).asBitmap().load(arg.photo.largeImageURL) - .into(object : CustomTarget() { - override fun onResourceReady( - resource: Bitmap, - transition: Transition? - ) { - - binding.photoView.setImageBitmap(resource) + Glide.with(requireContext()) + .load(arg.photo.largeImageURL) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .thumbnail( + Glide.with(requireContext()) + .load(arg.photo.webformatURL) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .thumbnail( + Glide.with(requireContext()) + .load(arg.photo.previewURL) + .diskCacheStrategy(DiskCacheStrategy.ALL) + ) + ) + .error( + Glide.with(requireContext()) + .load(arg.photo.webformatURL) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .error( + Glide.with(requireContext()) + .load(arg.photo.previewURL) + .diskCacheStrategy(DiskCacheStrategy.ALL) + ) + ) + .listener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { binding.cropImageProgressBar.isVisible = false + return false } - override fun onLoadCleared(placeholder: Drawable?) { + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { binding.cropImageProgressBar.isVisible = false + return false } }) + .into(binding.photoView) binding.setWallpaper.setOnClickListener { selectWallpaperTargetDialog.bindListener(object : diff --git a/app/src/main/java/com/turingheights/wally/home/repository/HomeWallpaperPagingSource.kt b/app/src/main/java/com/turingheights/wally/home/repository/HomeWallpaperPagingSource.kt index 617f3fe..239a6c6 100644 --- a/app/src/main/java/com/turingheights/wally/home/repository/HomeWallpaperPagingSource.kt +++ b/app/src/main/java/com/turingheights/wally/home/repository/HomeWallpaperPagingSource.kt @@ -2,6 +2,9 @@ package com.turingheights.wally.home.repository import androidx.paging.PagingSource import androidx.paging.PagingState +import com.turingheights.wally.commons.data.local.daos.CachedPhotoDao +import com.turingheights.wally.commons.data.local.entities.CacheMetadataEntity +import com.turingheights.wally.commons.data.local.entities.CachedPhotoEntity import kotlinx.coroutines.flow.MutableStateFlow import com.turingheights.wally.commons.models.Photo import com.turingheights.wally.commons.models.WallpaperDataNetworkState @@ -11,6 +14,7 @@ import timber.log.Timber class HomeWallpaperPagingSource constructor( private val homeScreenWallpaperService: HomeScreenWallpaperService, + private val cachedPhotoDao: CachedPhotoDao, private val safeSearch: Boolean, private val orientation: String, private val imageType: String, @@ -20,6 +24,8 @@ class HomeWallpaperPagingSource constructor( private val networkStateFlow: MutableStateFlow ): PagingSource(){ + private var hasLoadedFromCache = false + override fun getRefreshKey(state: PagingState): Int? { return state.anchorPosition.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition ?: return null) @@ -29,6 +35,30 @@ class HomeWallpaperPagingSource constructor( override suspend fun load(params: LoadParams): LoadResult { val nextPageNumber: Int = params.key ?: 1 + + // 1. Try to load from cache first if it's the very first request + if (nextPageNumber == 1 && !hasLoadedFromCache) { + try { + val cachedEntities = cachedPhotoDao.getCachedPhotos( + searchTerm, category, safeSearch, orientation, imageType, order + ) + if (cachedEntities.isNotEmpty()) { + Timber.d("Loading from cache: ${cachedEntities.size} items") + hasLoadedFromCache = true + val photos = cachedEntities.map { CachedPhotoEntity.toPhoto(it) } + // Update network state to success since we have data to show + networkStateFlow.value = WallpaperDataNetworkState.Success + // Return cache, but next key should be 1 to trigger fresh network fetch of page 1? + // Actually, the prompt says "subsequent calls... gotten from the network". + // If we want to replace cache with network immediately, we'd need a different approach. + // But if we just want to show cache THEN network on scroll, we set nextKey = 2. + return LoadResult.Page(photos, null, 2) + } + } catch (e: Exception) { + Timber.e(e, "Error loading from cache") + } + } + return try { if (nextPageNumber == 1) { Timber.d("Starting initial retrofit request") @@ -43,9 +73,25 @@ class HomeWallpaperPagingSource constructor( imagetype = imageType, order = order ) + + // 2. Save to cache if it's page 1 if (nextPageNumber == 1) { networkStateFlow.value = WallpaperDataNetworkState.Success + try { + val metadata = CacheMetadataEntity( + query = searchTerm, + category = category, + safeSearch = safeSearch, + orientation = orientation, + imageType = imageType, + order = order + ) + cachedPhotoDao.saveCache(metadata, response.hits) + } catch (e: Exception) { + Timber.e(e, "Error saving to cache") + } } + Timber.d(response.hits.toString()) LoadResult.Page(response.hits, null, nextPageNumber + 1) } catch (e: Exception) { @@ -60,4 +106,4 @@ class HomeWallpaperPagingSource constructor( } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/turingheights/wally/home/viewmodels/HomeViewModel.kt b/app/src/main/java/com/turingheights/wally/home/viewmodels/HomeViewModel.kt index 25f14b7..7700db5 100644 --- a/app/src/main/java/com/turingheights/wally/home/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/turingheights/wally/home/viewmodels/HomeViewModel.kt @@ -22,6 +22,7 @@ import com.turingheights.wally.commons.utils.POPULAR import com.turingheights.wally.commons.utils.WallyDownloader import com.turingheights.wally.home.data.remote.HomeScreenWallpaperService import com.turingheights.wally.home.repository.HomeWallpaperPagingSource +import com.turingheights.wally.commons.data.local.daos.CachedPhotoDao import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -29,7 +30,8 @@ import javax.inject.Inject class HomeViewModel @Inject constructor( private val homeScreenWallpaperService: HomeScreenWallpaperService, private val wallyDownloader: WallyDownloader, - private val favouritesService: FavouritePhotosRepository + private val favouritesService: FavouritePhotosRepository, + private val cachedPhotoDao: CachedPhotoDao ): ViewModel() { data class SearchParams( @@ -49,6 +51,7 @@ class HomeViewModel @Inject constructor( Pager(PagingConfig(HOME_WALLPAPER_PAGE_SIZE)) { HomeWallpaperPagingSource( homeScreenWallpaperService, + cachedPhotoDao, params.safeSearch, params.orientation, params.imageType, @@ -86,4 +89,4 @@ class HomeViewModel @Inject constructor( } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/turingheights/wally/home/views/adapters/HomePagedWallpaperAdapter.kt b/app/src/main/java/com/turingheights/wally/home/views/adapters/HomePagedWallpaperAdapter.kt index baf5dbb..b548516 100644 --- a/app/src/main/java/com/turingheights/wally/home/views/adapters/HomePagedWallpaperAdapter.kt +++ b/app/src/main/java/com/turingheights/wally/home/views/adapters/HomePagedWallpaperAdapter.kt @@ -41,7 +41,7 @@ class HomePagedWallpaperAdapter(private val actionMoreListener: (Photo, Int, Vie .load(photo.webformatURL) .thumbnail(Glide.with(viewBinding.root.context).load(photo.previewURL)) .centerCrop() - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .diskCacheStrategy(DiskCacheStrategy.ALL) .transition(DrawableTransitionOptions.withCrossFade()) .listener(object : RequestListener { override fun onLoadFailed( diff --git a/app/src/test/java/com/turingheights/wally/home/repository/HomeWallpaperPagingSourceTest.kt b/app/src/test/java/com/turingheights/wally/home/repository/HomeWallpaperPagingSourceTest.kt index 288346f..49b4d37 100644 --- a/app/src/test/java/com/turingheights/wally/home/repository/HomeWallpaperPagingSourceTest.kt +++ b/app/src/test/java/com/turingheights/wally/home/repository/HomeWallpaperPagingSourceTest.kt @@ -2,11 +2,14 @@ package com.turingheights.wally.home.repository import androidx.paging.PagingSource import com.google.common.truth.Truth.assertThat +import com.turingheights.wally.commons.data.local.daos.CachedPhotoDao +import com.turingheights.wally.commons.data.local.entities.CachedPhotoEntity import com.turingheights.wally.commons.models.Photo import com.turingheights.wally.commons.models.PhotoSearchResult import com.turingheights.wally.commons.models.WallpaperDataNetworkState import com.turingheights.wally.home.data.remote.HomeScreenWallpaperService import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -18,6 +21,7 @@ import org.junit.Test class HomeWallpaperPagingSourceTest { private val service: HomeScreenWallpaperService = mockk() + private val cachedPhotoDao: CachedPhotoDao = mockk(relaxed = true) private val networkStateFlow = MutableStateFlow(WallpaperDataNetworkState.Loading) private lateinit var pagingSource: HomeWallpaperPagingSource @@ -39,6 +43,7 @@ class HomeWallpaperPagingSourceTest { fun setup() { pagingSource = HomeWallpaperPagingSource( homeScreenWallpaperService = service, + cachedPhotoDao = cachedPhotoDao, safeSearch = true, orientation = "all", imageType = "all", @@ -48,7 +53,37 @@ class HomeWallpaperPagingSourceTest { } @Test - fun `load returns success when service returns data`() = runTest { + fun `load returns data from cache on initial load if available`() = runTest { + val cachedEntity = CachedPhotoEntity.fromPhoto(mockPhoto, 1L) + coEvery { + cachedPhotoDao.getCachedPhotos(any(), any(), any(), any(), any(), any()) + } returns listOf(cachedEntity) + + val result = pagingSource.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 1, + placeholdersEnabled = false + ) + ) + + val expectedResult = PagingSource.LoadResult.Page( + data = listOf(mockPhoto), + prevKey = null, + nextKey = 2 + ) + + assertThat(result).isEqualTo(expectedResult) + assertThat(networkStateFlow.value).isEqualTo(WallpaperDataNetworkState.Success) + coVerify(exactly = 0) { service.getHomeScreenWallpaper(any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `load returns success from network when cache is empty`() = runTest { + coEvery { + cachedPhotoDao.getCachedPhotos(any(), any(), any(), any(), any(), any()) + } returns emptyList() + val expectedResponse = PhotoSearchResult(1, 1, listOf(mockPhoto)) coEvery { service.getHomeScreenWallpaper(any(), any(), any(), any(), any(), any(), any()) @@ -70,10 +105,15 @@ class HomeWallpaperPagingSourceTest { assertThat(result).isEqualTo(expectedResult) assertThat(networkStateFlow.value).isEqualTo(WallpaperDataNetworkState.Success) + coVerify { cachedPhotoDao.saveCache(any(), any()) } } @Test - fun `load returns error when service throws exception`() = runTest { + fun `load returns error when service throws exception and cache is empty`() = runTest { + coEvery { + cachedPhotoDao.getCachedPhotos(any(), any(), any(), any(), any(), any()) + } returns emptyList() + val exception = RuntimeException("Network Error") coEvery { service.getHomeScreenWallpaper(any(), any(), any(), any(), any(), any(), any()) diff --git a/app/src/test/java/com/turingheights/wally/home/viewmodels/HomeViewModelTest.kt b/app/src/test/java/com/turingheights/wally/home/viewmodels/HomeViewModelTest.kt index a01c712..a3a0f91 100644 --- a/app/src/test/java/com/turingheights/wally/home/viewmodels/HomeViewModelTest.kt +++ b/app/src/test/java/com/turingheights/wally/home/viewmodels/HomeViewModelTest.kt @@ -1,6 +1,7 @@ package com.turingheights.wally.home.viewmodels import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.turingheights.wally.commons.data.local.daos.CachedPhotoDao import com.turingheights.wally.commons.repositories.FavouritePhotosRepository import com.turingheights.wally.commons.utils.WallyDownloader import com.turingheights.wally.home.data.remote.HomeScreenWallpaperService @@ -25,6 +26,7 @@ class HomeViewModelTest { private val service: HomeScreenWallpaperService = mockk(relaxed = true) private val downloader: WallyDownloader = mockk(relaxed = true) private val repository: FavouritePhotosRepository = mockk(relaxed = true) + private val cachedPhotoDao: CachedPhotoDao = mockk(relaxed = true) private lateinit var viewModel: HomeViewModel private val testDispatcher = UnconfinedTestDispatcher() @@ -32,7 +34,7 @@ class HomeViewModelTest { @Before fun setup() { Dispatchers.setMain(testDispatcher) - viewModel = HomeViewModel(service, downloader, repository) + viewModel = HomeViewModel(service, downloader, repository, cachedPhotoDao) } @After @@ -50,7 +52,6 @@ class HomeViewModelTest { @Test fun `updateSearchParams updates state`() { viewModel.updateSearchParams(query = "nature") - // Verify no crash and params updated (params is private, so we observe side effects if any) - // In this case, we can check if it triggers a new flow but that's complex with Paging. + // Verify no crash and params updated } }