Overview
The offline mode feature enables users to access previously loaded articles even without an internet connection. It uses Room database to cache pagination state and provides a foundation for caching article data.
Architecture
The app uses Room as the local persistence layer to store data that can be accessed offline.
Room Database Components:
Database class that extends RoomDatabase
DAOs (Data Access Objects) for database operations
Entities representing database tables
Database Configuration
The SpaceFlightNewsDb class defines the Room database structure.
SpaceFlightNewsDb.kt:16-20
@Database (entities = [PaginationEntity:: class ], version = 1 )
abstract class SpaceFlightNewsDb : RoomDatabase () {
abstract fun paginationDao (): PaginationDao
}
The database currently stores pagination state. The architecture can be extended to cache article content for full offline support.
Dependency Injection
The database is configured through Dagger Hilt for dependency injection.
@Module
@InstallIn (SingletonComponent:: class )
object RoomModule {
@Provides
@Singleton
fun provideDatabase ( @ApplicationContext context: Context ): SpaceFlightNewsDb {
return Room. databaseBuilder (
context,
SpaceFlightNewsDb:: class .java,
"space_flight_news_db"
). build ()
}
@Provides
fun providePaginationDao (database: SpaceFlightNewsDb ): PaginationDao {
return database. paginationDao ()
}
}
The app caches pagination state to remember the user’s scroll position.
This entity stores the current pagination state in the database.
@Entity (tableName = "pagination" )
data class PaginationEntity (
@PrimaryKey (autoGenerate = true )
val id: Int = 0 ,
val count: Int ,
val offset: Int ?
)
The DAO provides methods to interact with pagination data.
@Dao
interface PaginationDao {
@Query ( "SELECT * FROM pagination LIMIT 1" )
suspend fun getPagination (): PaginationEntity ?
@Insert (onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPagination (pagination: PaginationEntity )
@Query ( "DELETE FROM pagination" )
suspend fun deletePagination ()
}
The PaginationManager coordinates between the API and database.
Reading Cached Offset
PaginationManager.kt:17-19
suspend fun getCurrentOffset (): Int ? {
return paginationDao. getPagination ()?.offset
}
When the app starts offline, it can retrieve the last known offset from the database.
PaginationManager.kt:24-37
suspend fun updatePagination (paginationDto: PaginationDto ) {
val offset = extractOffset (paginationDto.next)
paginationDao. deletePagination ()
if (paginationDto.articles. isNotEmpty ()) {
paginationDao. insertPagination (
PaginationEntity (
count = paginationDto.count,
offset = offset
)
)
}
}
Repository Pattern
The repository abstracts data sources and can be extended for offline support.
Current Implementation
ArticleRepositoryImpl.kt:39-58
override suspend fun getArticles (
query: String ?
): Resource < List < Article >> {
return withContext (Dispatchers.IO) {
val response: ApiResponse < PaginationDto > =
apiHelper. safeApiCall {
api. getArticles (query, paginationManager. getCurrentOffset ())
}
when (response) {
is ApiSuccessResponse -> {
val pagination = response.body
paginationManager. updatePagination (pagination)
Resource. Success (pagination.articles. map { it. toArticleDomain () })
}
is ApiErrorResponse -> Resource. Error (response.code, response.msg, response.error)
}
}
}
Extending for Full Offline Support
To implement full offline caching, the repository can be enhanced:
Check Network
Determine if device is online or offline
Try Network First
If online, fetch from API and cache results
Fallback to Cache
If offline or network fails, retrieve from database
Return Data
Provide data regardless of network state
Enhanced Repository Pattern
override suspend fun getArticles (query: String ?): Resource < List < Article >> {
return withContext (Dispatchers.IO) {
// Try network first
if (networkHelper. isNetworkConnected ()) {
val response = apiHelper. safeApiCall {
api. getArticles (query, paginationManager. getCurrentOffset ())
}
when (response) {
is ApiSuccessResponse -> {
val articles = response.body.articles
// Cache articles in database
articleDao. insertArticles (articles. map { it. toEntity () })
paginationManager. updatePagination (response.body)
return @withContext Resource. Success (
articles. map { it. toArticleDomain () }
)
}
is ApiErrorResponse -> {
// Network error, fallback to cache
}
}
}
// Fallback to cached data
val cachedArticles = articleDao. getArticles ()
if (cachedArticles. isNotEmpty ()) {
Resource. Success (cachedArticles. map { it. toDomain () })
} else {
Resource. Error (message = "No cached data available" )
}
}
}
Network Status Detection
The app includes a NetworkHelper to detect connectivity.
class NetworkHelper @Inject constructor (
@ApplicationContext private val context: Context
) {
fun isNetworkConnected (): Boolean {
val connectivityManager = context. getSystemService (
Context.CONNECTIVITY_SERVICE
) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager. getNetworkCapabilities (network)
return capabilities?. hasCapability (
NetworkCapabilities.NET_CAPABILITY_INTERNET
) == true
}
}
Database Operations
Room database operations run on background threads using coroutines.
Suspend Functions All DAO methods use suspend for coroutine support
IO Dispatcher Database operations run on Dispatchers.IO
Type Safety Room provides compile-time SQL query verification
LiveData/Flow Support for reactive data streams
Extending Offline Capabilities
To add full article caching, create additional entities and DAOs:
Article Entity
@Entity (tableName = "articles" )
data class ArticleEntity (
@PrimaryKey
val id: Long ,
val title: String ,
val imageUrl: String ,
val newsSite: String ,
val publishedAt: String ,
val cachedAt: Long = System. currentTimeMillis ()
)
Article DAO
@Dao
interface ArticleDao {
@Query ( "SELECT * FROM articles ORDER BY publishedAt DESC" )
suspend fun getArticles (): List < ArticleEntity >
@Query ( "SELECT * FROM articles WHERE id = :articleId" )
suspend fun getArticleById (articleId: Long ): ArticleEntity ?
@Insert (onConflict = OnConflictStrategy.REPLACE)
suspend fun insertArticles (articles: List < ArticleEntity >)
@Query ( "DELETE FROM articles WHERE cachedAt < :expirationTime" )
suspend fun deleteExpiredArticles (expirationTime: Long )
}
ArticleDetail Entity
@Entity (tableName = "article_details" )
data class ArticleDetailEntity (
@PrimaryKey
val id: Long ,
val title: String ,
val url: String ,
val newsSite: String ,
val imageUrl: String ,
val summary: String ,
val publishedAt: String ,
val updatedAt: String
)
Cache Invalidation Strategy
Implement cache expiration to keep data fresh:
class CacheManager @Inject constructor (
private val articleDao: ArticleDao
) {
suspend fun clearExpiredCache () {
val oneDayAgo = System. currentTimeMillis () - ( 24 * 60 * 60 * 1000 )
articleDao. deleteExpiredArticles (oneDayAgo)
}
}
Regularly clearing expired cache prevents the database from growing too large and ensures users see relatively fresh content.
Benefits
Persistent State Pagination position survives app restarts
Offline Access View previously loaded content without internet
Better UX Instant loading of cached content
Reduced Data Usage Less API calls for repeated content
Current Limitations
The current implementation stores only pagination state. Future enhancements could include:
Cache the list of articles for offline browsing
Store full article details including content and images
Use Glide’s disk cache for offline image display
Sync cached data when connection is restored
Pagination Pagination state is persisted offline
Article List Can be enhanced with article caching
Article Details Can be enhanced with detail caching