Skip to content

Commit

Permalink
Sessions offline first capabilities and app sync at startup time (dro…
Browse files Browse the repository at this point in the history
…idconKE#160)

* Migrate state in speakers screen and speakerdetails screen to viewmodel

* Update tests

* Requested changes

* Sessions offline first features

* Fix linting errors

* Fix failing tests

* Made requested changes

* Fix error due to merge conflict

* Removed comments and fixed failing test
  • Loading branch information
chege4179 authored Aug 23, 2023
1 parent 5aff0ba commit cf0a3d7
Show file tree
Hide file tree
Showing 26 changed files with 558 additions and 285 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@
package com.android254.droidconKE2023.app

import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkManager
import com.android254.data.network.util.RemoteFeatureToggle
import com.android254.data.work.WorkConstants
import com.android254.data.work.WorkInitializer
import com.android254.droidconKE2023.crashlytics.CrashlyticsTree
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
Expand All @@ -37,6 +43,8 @@ class DroidconKE2023App : Application(), Configuration.Provider {
super.onCreate()
remoteFeatureToggle.sync()
initTimber()
setUpWorkerManagerNotificationChannel()
WorkInitializer.initialize(context = this)
}

override fun getWorkManagerConfiguration(): Configuration =
Expand All @@ -58,4 +66,16 @@ class DroidconKE2023App : Application(), Configuration.Provider {
Timber.plant(CrashlyticsTree())
}
}
private fun setUpWorkerManagerNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
WorkConstants.NOTIFICATION_CHANNEL,
WorkConstants.syncDataWorkerName,
NotificationManager.IMPORTANCE_HIGH
)
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
WorkManager.initialize(this, Configuration.Builder().setWorkerFactory(workerFactory).build())
}
}
3 changes: 2 additions & 1 deletion data/src/main/java/com/android254/data/dao/BookmarkDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ package com.android254.data.dao
import androidx.room.Dao
import androidx.room.Query
import com.android254.data.db.model.BookmarkEntity
import kotlinx.coroutines.flow.Flow

@Dao
interface BookmarkDao : BaseDao<BookmarkEntity> {
@Query("SELECT * FROM bookmarks")
fun getBookmarkIds(): List<BookmarkEntity>
fun getBookmarkIds(): Flow<List<BookmarkEntity>>
}
6 changes: 3 additions & 3 deletions data/src/main/java/com/android254/data/dao/SessionDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ interface SessionDao : BaseDao<SessionEntity> {
suspend fun clearSessions()

@Query("SELECT * FROM sessions WHERE id = :id")
suspend fun getSessionById(id: String): SessionEntity?
fun getSessionById(id: String): Flow<SessionEntity?>

@RawQuery
suspend fun fetchSessionsWithFilters(query: SupportSQLiteQuery): List<SessionEntity>
@RawQuery(observedEntities = arrayOf(SessionEntity::class))
fun fetchSessionsWithFilters(query: SupportSQLiteQuery): Flow<List<SessionEntity>>

@Query("UPDATE sessions SET isBookmarked = :isBookmarked WHERE remote_id = :id")
suspend fun updateBookmarkedStatus(id: String, isBookmarked: Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,4 @@ object RemoteConfigModule {
@Provides
@Singleton
fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig = RemoteConfigConfig.setup()

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ object RemoteConfigConfig {
}
setConfigSettingsAsync(configSettings)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ class RemoteFeatureToggle(
}

fun getString(key: String): String = remoteConfig.getString(key)
}
}
127 changes: 49 additions & 78 deletions data/src/main/java/com/android254/data/repos/SessionsManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,20 @@
*/
package com.android254.data.repos

import android.os.Build
import androidx.annotation.RequiresApi
import androidx.sqlite.db.SimpleSQLiteQuery
import com.android254.data.dao.BookmarkDao
import com.android254.data.dao.SessionDao
import com.android254.data.db.model.BookmarkEntity
import com.android254.data.di.IoDispatcher
import com.android254.data.network.apis.SessionsApi
import com.android254.data.network.util.NetworkError
import com.android254.data.repos.mappers.toDomainModel
import com.android254.data.repos.mappers.toEntity
import com.android254.domain.models.ResourceResult
import com.android254.domain.models.Session
import com.android254.domain.repos.SessionsRepo
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject

Expand All @@ -40,87 +38,60 @@ class SessionsManager @Inject constructor(
private val bookmarkDao: BookmarkDao,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : SessionsRepo {
@RequiresApi(Build.VERSION_CODES.O)
override suspend fun fetchAndSaveSessions(
fetchFromRemote: Boolean,
query: String?
): ResourceResult<List<Session>> = withContext(ioDispatcher) {
val sessions = if (query == null) {
dao.fetchSessions().first()
} else {
dao.fetchSessionsWithFilters(SimpleSQLiteQuery(query))
}

val isDbEmpty = sessions.isEmpty()
val hasAQuery = query != null
val shouldLoadFromCache = (!isDbEmpty && !fetchFromRemote) || hasAQuery
if (shouldLoadFromCache) {
return@withContext try {
val response = api.fetchSessions()
val remoteSessions = response.data.flatMap { (_, value) -> value }
if (remoteSessions.isEmpty()) {
ResourceResult.Empty("No sessions just yet")
} else {
remoteSessions.let {
dao.clearSessions()
val bookmarkIds = bookmarkDao.getBookmarkIds().map { sessionEntity ->
sessionEntity.sessionId
}
val sessionEntities = it.map { session ->
val newSession = session.toEntity().copy(
isBookmarked = bookmarkIds.contains(session.id)
)
newSession
}
dao.insert(sessionEntities)
ResourceResult.Success(
data = sessionEntities.map { sessionEntity -> sessionEntity.toDomainModel() }
)
}
}
} catch (e: Exception) {
when (e) {
is NetworkError -> ResourceResult.Error("Network error")
else -> ResourceResult.Error("Error fetching sessions")
override fun fetchSessions(): Flow<List<Session>> {
val bookmarksFlow = bookmarkDao.getBookmarkIds()
val sessionsFlow = dao.fetchSessions()
return combine(sessionsFlow, bookmarksFlow) { sessions, bookmarks ->
sessions.map { it.toDomainModel() }
.map { session ->
session.copy(isBookmarked = bookmarks.map { it.sessionId }.contains(session.id))
}
}
} else {
return@withContext ResourceResult.Success(
data = sessions.map {
it.toDomainModel()
}.flowOn(ioDispatcher)
}
override fun fetchBookmarkedSessions(): Flow<List<Session>> {
val bookmarksFlow = bookmarkDao.getBookmarkIds()
val sessionsFlow = dao.fetchSessions()
return combine(sessionsFlow, bookmarksFlow) { sessions, bookmarks ->
sessions.map { it.toDomainModel() }
.map { session ->
session.copy(isBookmarked = bookmarks.map { it.sessionId }.contains(session.id))
}
)
.filter { session -> session.isBookmarked }
}.flowOn(ioDispatcher)
}

override fun fetchFilteredSessions(query: String): Flow<List<Session>> {
val filteredSessions = dao.fetchSessionsWithFilters(SimpleSQLiteQuery(query))
val bookmarksFlow = bookmarkDao.getBookmarkIds()
return combine(filteredSessions, bookmarksFlow) { sessions, bookmarks ->
sessions.map { session ->
session.copy(
isBookmarked = bookmarks.map { it.sessionId }.contains(session.id.toString())
)
}.map { it.toDomainModel() }
}.flowOn(ioDispatcher)
}

override fun fetchSessionById(sessionId: String): Flow<Session?> {
val bookmarksFlow = bookmarkDao.getBookmarkIds()
val sessionFlow = dao.getSessionById(sessionId).map {
it?.toDomainModel()
}
return combine(sessionFlow, bookmarksFlow) { session, bookmarks ->
session?.copy(isBookmarked = bookmarks.map { it.sessionId }.contains(session.id))
}.flowOn(ioDispatcher)
}

override suspend fun fetchSessionById(id: String): ResourceResult<Session> =
override suspend fun bookmarkSession(id: String) {
withContext(ioDispatcher) {
val session = dao.getSessionById(id)
?: return@withContext ResourceResult.Error(message = "requested event no longer available")
return@withContext ResourceResult.Success(data = session.toDomainModel())
bookmarkDao.insert(BookmarkEntity(id))
}
}

override suspend fun toggleBookmarkStatus(
id: String,
isCurrentlyStarred: Boolean
): ResourceResult<Boolean> = withContext(ioDispatcher) {
try {
dao.updateBookmarkedStatus(id, !isCurrentlyStarred)
if (isCurrentlyStarred) {
bookmarkDao.delete(BookmarkEntity(id))
} else {
bookmarkDao.insert(BookmarkEntity(id))
}
} catch (e: Exception) {
when (e) {
is NetworkError -> {
return@withContext ResourceResult.Error("Network error")
}
else -> {
return@withContext ResourceResult.Error("Error fetching sessions")
}
}
override suspend fun unBookmarkSession(id: String) {
withContext(ioDispatcher) {
bookmarkDao.delete(BookmarkEntity(id))
}
return@withContext ResourceResult.Success(data = dao.getBookmarkStatus(id))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ interface LocalSessionsDataSource {

suspend fun deleteCachedSessions()

suspend fun getCachedSessionById(id: String): SessionEntity?
fun getCachedSessionById(id: String): Flow<SessionEntity?>

suspend fun fetchSessionWithFilters(query: SupportSQLiteQuery): List<Session>
fun fetchSessionWithFilters(query: SupportSQLiteQuery): Flow<List<Session>>

suspend fun updateBookmarkedStatus(id: String, isBookmarked: Boolean)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,12 @@ class LocalSessionsDataSourceImpl @Inject constructor(
}
}

override suspend fun getCachedSessionById(id: String): SessionEntity? {
return withContext(ioDispatcher) {
sessionDao.getSessionById(id = id)
}
}
override fun getCachedSessionById(id: String): Flow<SessionEntity?> =
sessionDao.getSessionById(id = id).flowOn(ioDispatcher)

override suspend fun fetchSessionWithFilters(query: SupportSQLiteQuery): List<Session> {
return withContext(ioDispatcher) {
sessionDao.fetchSessionsWithFilters(query = query).map { it.toDomainModel() }
}
}
override fun fetchSessionWithFilters(query: SupportSQLiteQuery): Flow<List<Session>> =
sessionDao.fetchSessionsWithFilters(query = query).map { it.map { it.toDomainModel() } }
.flowOn(ioDispatcher)

override suspend fun updateBookmarkedStatus(id: String, isBookmarked: Boolean) {
withContext(ioDispatcher) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.android254.data.work.WorkConstants.syncDataWorkerName
import com.android254.domain.work.SyncDataWorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -53,10 +54,6 @@ class SyncDataWorkManagerImpl @Inject constructor(
workManager.beginUniqueWork(syncDataWorkerName, ExistingWorkPolicy.KEEP, syncDataRequest)
.enqueue()
}

companion object {
const val syncDataWorkerName = "sync_data"
}
}

val List<WorkInfo>.anyRunning get() = any { it.state == WorkInfo.State.RUNNING }
15 changes: 15 additions & 0 deletions data/src/main/java/com/android254/data/work/SyncDataWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
package com.android254.data.work

import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.android254.data.di.IoDispatcher
import com.android254.data.repos.local.LocalSessionsDataSource
Expand All @@ -29,10 +31,12 @@ import com.android254.data.repos.remote.RemoteSponsorsDataSource
import com.android254.domain.models.ResourceResult
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import ke.droidcon.kotlin.data.R
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.random.Random

@HiltWorker
class SyncDataWorker @AssistedInject constructor(
Expand All @@ -49,6 +53,17 @@ class SyncDataWorker @AssistedInject constructor(

) : CoroutineWorker(appContext, workerParameters) {

override suspend fun getForegroundInfo(): ForegroundInfo {
return ForegroundInfo(
Random.nextInt(),
NotificationCompat.Builder(appContext, WorkConstants.NOTIFICATION_CHANNEL)
.setSmallIcon(androidx.core.R.drawable.notification_bg_low)
.setContentTitle(R.string.sync_notification_message.toString())
.build()

)
}

override suspend fun doWork(): Result {
withContext(ioDispatcher) {
val sessionSyncDefferred = async { syncSessions() }
Expand Down
22 changes: 22 additions & 0 deletions data/src/main/java/com/android254/data/work/WorkConstants.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright 2023 DroidconKE
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android254.data.work

object WorkConstants {

const val NOTIFICATION_CHANNEL = "notification_channel"
const val syncDataWorkerName = "sync_data"
}
Loading

0 comments on commit cf0a3d7

Please sign in to comment.