From a9184e62aae83e78351dc96f34104b5700c8d65f Mon Sep 17 00:00:00 2001 From: Christophe Beyls Date: Mon, 22 Nov 2021 19:44:02 +0100 Subject: [PATCH] Refactor UI State SharedPreferences and add Datastore to the database (#73) - change database to use destructive migration and properly clear the extra data on each migration - store database extra data using the datastore-preferences library instead of SharedPreferences - move ScheduleDao access from TracksFragment to TracksViewModel and rename previous TracksViewModel to TracksListViewModel - centralize all UI State preferences in the same SharedPreferences file and inject it. It is loaded asynchronously on application startup to minimize blocking the main thread - change days cache in ScheduleDao from LiveData to a hot Flow. --- app/build.gradle | 1 + .../be/digitalia/fosdem/FosdemApplication.kt | 8 ++ .../fosdem/activities/MainActivity.kt | 47 ++++++---- .../java/be/digitalia/fosdem/api/FosdemApi.kt | 8 +- .../be/digitalia/fosdem/db/AppDatabase.kt | 30 +------ .../be/digitalia/fosdem/db/ScheduleDao.kt | 89 +++++++++---------- .../fosdem/fragments/BookmarksListFragment.kt | 15 ++-- .../fosdem/fragments/TracksFragment.kt | 24 ++--- .../fosdem/fragments/TracksListFragment.kt | 4 +- .../digitalia/fosdem/inject/DatabaseModule.kt | 82 +++++++---------- .../digitalia/fosdem/inject/UIStateModule.kt | 24 +++++ .../providers/BookmarksExportProvider.kt | 4 +- .../fosdem/viewmodels/TracksListViewModel.kt | 27 ++++++ .../fosdem/viewmodels/TracksViewModel.kt | 20 ++--- 14 files changed, 208 insertions(+), 175 deletions(-) create mode 100644 app/src/main/java/be/digitalia/fosdem/inject/UIStateModule.kt create mode 100644 app/src/main/java/be/digitalia/fosdem/viewmodels/TracksListViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index 66b89dc..726e6c2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -99,6 +99,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" implementation 'androidx.paging:paging-runtime-ktx:2.1.2' implementation "androidx.room:room-ktx:$room_version" + implementation "androidx.datastore:datastore-preferences:1.0.0" kapt "androidx.room:room-compiler:$room_version" implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation ("com.squareup.okhttp3:okhttp-tls:$okhttp_version") { diff --git a/app/src/main/java/be/digitalia/fosdem/FosdemApplication.kt b/app/src/main/java/be/digitalia/fosdem/FosdemApplication.kt index fccfde7..08b3b55 100644 --- a/app/src/main/java/be/digitalia/fosdem/FosdemApplication.kt +++ b/app/src/main/java/be/digitalia/fosdem/FosdemApplication.kt @@ -2,22 +2,30 @@ package be.digitalia.fosdem import android.app.Application import android.content.Context +import android.content.SharedPreferences import androidx.multidex.MultiDex import be.digitalia.fosdem.alarms.AppAlarmManager import be.digitalia.fosdem.utils.ThemeManager import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject +import javax.inject.Named @HiltAndroidApp class FosdemApplication : Application() { // Injected for automatic initialization on app startup + @Inject lateinit var themeManager: ThemeManager @Inject lateinit var alarmManager: AppAlarmManager + // Preload UI State SharedPreferences for faster initial access + @Inject + @Named("UIState") + lateinit var preferences: SharedPreferences + override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) if (BuildConfig.DEBUG) { diff --git a/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.kt index ad7b5a7..8751afd 100644 --- a/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.kt +++ b/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.kt @@ -2,8 +2,8 @@ package be.digitalia.fosdem.activities import android.annotation.SuppressLint import android.content.ActivityNotFoundException -import android.content.Context import android.content.Intent +import android.content.SharedPreferences import android.graphics.drawable.Animatable import android.net.Uri import android.nfc.NdefRecord @@ -47,7 +47,11 @@ import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import javax.inject.Inject +import javax.inject.Named /** * Main entry point of the application. Allows to switch between section fragments and update the database. @@ -78,8 +82,13 @@ class MainActivity : AppCompatActivity(R.layout.main), CreateNfcAppDataCallback val drawerLayout: DrawerLayout, val navigationView: NavigationView) + @Inject + @Named("UIState") + lateinit var preferences: SharedPreferences + @Inject lateinit var api: FosdemApi + @Inject lateinit var scheduleDao: ScheduleDao @@ -177,12 +186,13 @@ class MainActivity : AppCompatActivity(R.layout.main), CreateNfcAppDataCallback // Latest update date, below the list val latestUpdateTextView: TextView = navigationView.findViewById(R.id.latest_update) - scheduleDao.latestUpdateTime - .observe(this) { time -> - val timeString = if (time == -1L) getString(R.string.never) - else DateFormat.format(LATEST_UPDATE_DATE_FORMAT, time) - latestUpdateTextView.text = getString(R.string.last_update, timeString) - } + lifecycleScope.launch { + scheduleDao.latestUpdateTime.collect { time -> + val timeString = time?.let { DateFormat.format(LATEST_UPDATE_DATE_FORMAT, it) } + ?: getString(R.string.never) + latestUpdateTextView.text = getString(R.string.last_update, timeString) + } + } holder = ViewHolder(contentView, drawerLayout, navigationView) @@ -240,17 +250,18 @@ class MainActivity : AppCompatActivity(R.layout.main), CreateNfcAppDataCallback super.onStart() // Scheduled database update - val now = System.currentTimeMillis() - val latestUpdateTime = scheduleDao.latestUpdateTime.value - if (latestUpdateTime == null || latestUpdateTime < now - DATABASE_VALIDITY_DURATION) { - val prefs = getPreferences(Context.MODE_PRIVATE) - val latestAttemptTime = prefs.getLong(PREF_LATEST_AUTO_UPDATE_ATTEMPT_TIME, -1L) - if (latestAttemptTime == -1L || latestAttemptTime < now - AUTO_UPDATE_SNOOZE_DURATION) { - prefs.edit { - putLong(PREF_LATEST_AUTO_UPDATE_ATTEMPT_TIME, now) + lifecycleScope.launch { + val now = System.currentTimeMillis() + val latestUpdateTime = scheduleDao.latestUpdateTime.first() + if (latestUpdateTime == null || latestUpdateTime.time < now - DATABASE_VALIDITY_DURATION) { + val latestAttemptTime = preferences.getLong(LATEST_UPDATE_ATTEMPT_TIME_PREF_KEY, -1L) + if (latestAttemptTime == -1L || latestAttemptTime < now - AUTO_UPDATE_SNOOZE_DURATION) { + preferences.edit { + putLong(LATEST_UPDATE_ATTEMPT_TIME_PREF_KEY, now) + } + // Try to update immediately. If it fails, the user gets a message and a retry button. + api.downloadSchedule() } - // Try to update immediately. If it fails, the user gets a message and a retry button. - api.downloadSchedule() } } } @@ -349,7 +360,7 @@ class MainActivity : AppCompatActivity(R.layout.main), CreateNfcAppDataCallback private const val ERROR_MESSAGE_DISPLAY_DURATION = 5000 private const val DATABASE_VALIDITY_DURATION = DateUtils.DAY_IN_MILLIS private const val AUTO_UPDATE_SNOOZE_DURATION = DateUtils.DAY_IN_MILLIS - private const val PREF_LATEST_AUTO_UPDATE_ATTEMPT_TIME = "last_download_reminder_time" + private const val LATEST_UPDATE_ATTEMPT_TIME_PREF_KEY = "latest_update_attempt_time" private const val LATEST_UPDATE_DATE_FORMAT = "d MMM yyyy kk:mm:ss" } } \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/api/FosdemApi.kt b/app/src/main/java/be/digitalia/fosdem/api/FosdemApi.kt index 6984daa..1442e5a 100644 --- a/app/src/main/java/be/digitalia/fosdem/api/FosdemApi.kt +++ b/app/src/main/java/be/digitalia/fosdem/api/FosdemApi.kt @@ -5,6 +5,8 @@ import android.text.format.DateUtils import androidx.annotation.MainThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asLiveData +import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.liveData import androidx.lifecycle.switchMap import be.digitalia.fosdem.alarms.AppAlarmManager @@ -22,6 +24,7 @@ import be.digitalia.fosdem.utils.network.HttpClient import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import okio.buffer import javax.inject.Inject @@ -58,11 +61,10 @@ class FosdemApi @Inject constructor( } } - @MainThread private suspend fun downloadScheduleInternal() { _downloadScheduleState.value = LoadingState.Loading() val res = try { - val response = httpClient.get(FosdemUrls.schedule, scheduleDao.lastModifiedTag) { body, headers -> + val response = httpClient.get(FosdemUrls.schedule, scheduleDao.lastModifiedTag.first()) { body, headers -> val length = body.contentLength() val source = if (length > 0L) { // Broadcast the progression in percents, with a precision of 1/10 of the total file size @@ -97,7 +99,7 @@ class FosdemApi @Inject constructor( val roomStatuses: LiveData> by lazy(LazyThreadSafetyMode.NONE) { // The room statuses will only be loaded when the event is live. // Use the days from the database to determine it. - val scheduler = scheduleDao.days.switchMap { days -> + val scheduler = scheduleDao.days.asLiveData().distinctUntilChanged().switchMap { days -> val startEndTimestamps = LongArray(days.size * 2) var index = 0 for (day in days) { diff --git a/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.kt b/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.kt index ec73998..24a8073 100644 --- a/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.kt +++ b/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.kt @@ -1,8 +1,8 @@ package be.digitalia.fosdem.db -import android.content.SharedPreferences +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.room.Database -import androidx.room.DatabaseConfiguration import androidx.room.RoomDatabase import androidx.room.TypeConverters import be.digitalia.fosdem.alarms.AppAlarmManager @@ -15,11 +15,6 @@ import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.model.Link import be.digitalia.fosdem.model.Person import be.digitalia.fosdem.model.Track -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import javax.inject.Named @Database( entities = [EventEntity::class, EventTitles::class, Person::class, EventToPerson::class, @@ -31,24 +26,7 @@ abstract class AppDatabase : RoomDatabase() { abstract val scheduleDao: ScheduleDao abstract val bookmarksDao: BookmarksDao - lateinit var sharedPreferences: SharedPreferences - private set + // Manually injected fields, used by Daos + lateinit var dataStore: DataStore lateinit var alarmManager: AppAlarmManager - private set - - override fun init(configuration: DatabaseConfiguration) { - super.init(configuration) - // Manual dependency injection - val entryPoint = EntryPointAccessors.fromApplication(configuration.context, AppDatabaseEntryPoint::class.java) - sharedPreferences = entryPoint.sharedPreferences - alarmManager = entryPoint.alarmManager - } - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface AppDatabaseEntryPoint { - @get:Named("Database") - val sharedPreferences: SharedPreferences - val alarmManager: AppAlarmManager - } } \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.kt b/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.kt index aca38a1..ce62b71 100644 --- a/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.kt +++ b/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.kt @@ -1,10 +1,10 @@ package be.digitalia.fosdem.db -import androidx.annotation.MainThread import androidx.annotation.WorkerThread -import androidx.core.content.edit +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.liveData import androidx.paging.DataSource import androidx.room.Dao @@ -23,34 +23,35 @@ import be.digitalia.fosdem.model.Link import be.digitalia.fosdem.model.Person import be.digitalia.fosdem.model.StatusEvent import be.digitalia.fosdem.model.Track +import be.digitalia.fosdem.utils.BackgroundWorkScope import be.digitalia.fosdem.utils.DateUtils import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.runBlocking +import java.util.Date import java.util.HashSet @Dao abstract class ScheduleDao(private val appDatabase: AppDatabase) { - private val _latestUpdateTime = MutableLiveData() - /** - * @return The last update time in milliseconds since EPOCH, or -1 if not available. - * This LiveData is pre-initialized with the up-to-date value. + * @return The latest update time, or null if not available. */ - val latestUpdateTime: LiveData - @MainThread - get() { - if (_latestUpdateTime.value == null) { - _latestUpdateTime.value = appDatabase.sharedPreferences.getLong(LAST_UPDATE_TIME_PREF, -1L) - } - return _latestUpdateTime - } + val latestUpdateTime: Flow = appDatabase.dataStore.data.map { prefs -> + prefs[LATEST_UPDATE_TIME_PREF_KEY]?.let { Date(it) } + } /** * @return The time identifier of the current version of the database. */ - val lastModifiedTag: String? - get() = appDatabase.sharedPreferences.getString(LAST_MODIFIED_TAG_PREF, null) + val lastModifiedTag: Flow = appDatabase.dataStore.data.map { prefs -> + prefs[LAST_MODIFIED_TAG_PREF] + } private class EmptyScheduleException : Exception() @@ -69,11 +70,15 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) { } if (totalEvents > 0) { // Set last update time and server's last modified tag val now = System.currentTimeMillis() - appDatabase.sharedPreferences.edit { - putLong(LAST_UPDATE_TIME_PREF, now) - putString(LAST_MODIFIED_TAG_PREF, lastModifiedTag) + runBlocking { + appDatabase.dataStore.edit { prefs -> + prefs.clear() + prefs[LATEST_UPDATE_TIME_PREF_KEY] = now + if (lastModifiedTag != null) { + prefs[LAST_MODIFIED_TAG_PREF] = lastModifiedTag + } + } } - _latestUpdateTime.postValue(now) } return totalEvents } @@ -213,38 +218,30 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) { protected abstract fun clearDays() // Cache days - private val daysLiveDataDelegate = lazy { getDaysInternal() } - - val days: LiveData> by daysLiveDataDelegate + val days: Flow> by lazy { + getDaysInternal().shareIn( + scope = BackgroundWorkScope, + started = SharingStarted.Eagerly, + replay = 1 + ) + } @Query("SELECT `index`, date FROM days ORDER BY `index` ASC") - protected abstract fun getDaysInternal(): LiveData> + protected abstract fun getDaysInternal(): Flow> - @WorkerThread - fun getYear(): Int { - var date = 0L - - // Compute from cached days if available - val days = if (daysLiveDataDelegate.isInitialized()) days.value else null - if (days != null) { - if (days.isNotEmpty()) { - date = days[0].date.time - } + suspend fun getYear(): Int { + // Compute from days if available + val days = days.first() + val date = if (days.isNotEmpty()) { + days[0].date.time } else { - date = getConferenceStartDate() - } - - // Use the current year by default - if (date == 0L) { - date = System.currentTimeMillis() + // Use the current year by default + System.currentTimeMillis() } return DateUtils.getYear(date) } - @Query("SELECT date FROM days ORDER BY `index` ASC LIMIT 1") - protected abstract fun getConferenceStartDate(): Long - @Query("""SELECT t.id, t.name, t.type FROM tracks t JOIN events e ON t.id = e.track_id WHERE e.day_index = :day @@ -436,7 +433,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) { protected abstract suspend fun getLinks(event: Event?): List companion object { - private const val LAST_UPDATE_TIME_PREF = "last_update_time" - private const val LAST_MODIFIED_TAG_PREF = "last_modified_tag" + private val LATEST_UPDATE_TIME_PREF_KEY = longPreferencesKey("latest_update_time") + private val LAST_MODIFIED_TAG_PREF = stringPreferencesKey("last_modified_tag") } } \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt index 79e34aa..e5f46dc 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt +++ b/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt @@ -1,8 +1,8 @@ package be.digitalia.fosdem.fragments import android.app.Dialog -import android.content.Context import android.content.Intent +import android.content.SharedPreferences import android.net.Uri import android.nfc.NdefRecord import android.os.Bundle @@ -33,6 +33,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import java.util.concurrent.CancellationException import javax.inject.Inject +import javax.inject.Named /** * Bookmarks list, optionally filterable. @@ -42,8 +43,12 @@ import javax.inject.Inject @AndroidEntryPoint class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataCallback { + @Inject + @Named("UIState") + lateinit var preferences: SharedPreferences @Inject lateinit var api: FosdemApi + private val viewModel: BookmarksViewModel by viewModels() private val multiChoiceHelper: MultiChoiceHelper by lazy(LazyThreadSafetyMode.NONE) { MultiChoiceHelper(requireActivity() as AppCompatActivity, this, object : MultiChoiceHelper.MultiChoiceModeListener { @@ -91,7 +96,7 @@ class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataC override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val upcomingOnly = requireActivity().getPreferences(Context.MODE_PRIVATE).getBoolean(PREF_UPCOMING_ONLY, false) + val upcomingOnly = preferences.getBoolean(UPCOMING_ONLY_PREF_KEY, false) viewModel.upcomingOnly = upcomingOnly setHasOptionsMenu(true) @@ -145,8 +150,8 @@ class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataC val upcomingOnly = !viewModel.upcomingOnly viewModel.upcomingOnly = upcomingOnly updateMenuItems() - requireActivity().getPreferences(Context.MODE_PRIVATE).edit { - putBoolean(PREF_UPCOMING_ONLY, upcomingOnly) + preferences.edit { + putBoolean(UPCOMING_ONLY_PREF_KEY, upcomingOnly) } true } @@ -197,6 +202,6 @@ class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataC } companion object { - private const val PREF_UPCOMING_ONLY = "bookmarks_upcoming_only" + private const val UPCOMING_ONLY_PREF_KEY = "bookmarks_upcoming_only" } } \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.kt index ca025cb..e9ae0a3 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.kt +++ b/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.kt @@ -1,27 +1,29 @@ package be.digitalia.fosdem.fragments -import android.content.Context +import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.core.content.edit import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import be.digitalia.fosdem.R -import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.utils.enforceSingleScrollDirection import be.digitalia.fosdem.utils.instantiate import be.digitalia.fosdem.utils.recyclerView import be.digitalia.fosdem.utils.viewLifecycleLazy +import be.digitalia.fosdem.viewmodels.TracksViewModel import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import javax.inject.Named @AndroidEntryPoint class TracksFragment : Fragment(R.layout.fragment_tracks), RecycledViewPoolProvider { @@ -34,7 +36,10 @@ class TracksFragment : Fragment(R.layout.fragment_tracks), RecycledViewPoolProvi } @Inject - lateinit var scheduleDao: ScheduleDao + @Named("UIState") + lateinit var preferences: SharedPreferences + + private val viewModel: TracksViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -49,10 +54,10 @@ class TracksFragment : Fragment(R.layout.fragment_tracks), RecycledViewPoolProvi var savedCurrentPage = if (savedInstanceState == null) { // Restore the current page from preferences - requireActivity().getPreferences(Context.MODE_PRIVATE).getInt(PREF_CURRENT_PAGE, -1) + preferences.getInt(TRACKS_CURRENT_PAGE_PREF_KEY, -1) } else -1 - scheduleDao.days.observe(viewLifecycleOwner) { days -> + viewModel.days.observe(viewLifecycleOwner) { days -> holder.run { daysAdapter.days = days @@ -79,10 +84,9 @@ class TracksFragment : Fragment(R.layout.fragment_tracks), RecycledViewPoolProvi if (event == Lifecycle.Event.ON_STOP) { // Save the current page to preferences if it has changed val page = holder.pager.currentItem - val prefs = requireActivity().getPreferences(Context.MODE_PRIVATE) - if (prefs.getInt(PREF_CURRENT_PAGE, -1) != page) { - prefs.edit { - putInt(PREF_CURRENT_PAGE, page) + if (preferences.getInt(TRACKS_CURRENT_PAGE_PREF_KEY, -1) != page) { + preferences.edit { + putInt(TRACKS_CURRENT_PAGE_PREF_KEY, page) } } } @@ -121,6 +125,6 @@ class TracksFragment : Fragment(R.layout.fragment_tracks), RecycledViewPoolProvi } companion object { - private const val PREF_CURRENT_PAGE = "tracks_current_page" + private const val TRACKS_CURRENT_PAGE_PREF_KEY = "tracks_current_page" } } \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.kt index babd51f..547d81e 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.kt +++ b/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.kt @@ -18,13 +18,13 @@ import be.digitalia.fosdem.R import be.digitalia.fosdem.activities.TrackScheduleActivity import be.digitalia.fosdem.model.Day import be.digitalia.fosdem.model.Track -import be.digitalia.fosdem.viewmodels.TracksViewModel +import be.digitalia.fosdem.viewmodels.TracksListViewModel import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class TracksListFragment : Fragment(R.layout.recyclerview) { - private val viewModel: TracksViewModel by viewModels() + private val viewModel: TracksListViewModel by viewModels() private val day by lazy(LazyThreadSafetyMode.NONE) { requireArguments().getParcelable(ARG_DAY)!! } diff --git a/app/src/main/java/be/digitalia/fosdem/inject/DatabaseModule.kt b/app/src/main/java/be/digitalia/fosdem/inject/DatabaseModule.kt index da69e9c..3ca0465 100644 --- a/app/src/main/java/be/digitalia/fosdem/inject/DatabaseModule.kt +++ b/app/src/main/java/be/digitalia/fosdem/inject/DatabaseModule.kt @@ -1,24 +1,25 @@ package be.digitalia.fosdem.inject import android.content.Context -import android.content.SharedPreferences +import androidx.annotation.WorkerThread +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import androidx.room.RoomDatabase -import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import be.digitalia.fosdem.alarms.AppAlarmManager import be.digitalia.fosdem.db.AppDatabase import be.digitalia.fosdem.db.BookmarksDao import be.digitalia.fosdem.db.ScheduleDao -import be.digitalia.fosdem.db.entities.Bookmark -import be.digitalia.fosdem.db.entities.EventEntity -import be.digitalia.fosdem.model.Day -import be.digitalia.fosdem.model.Link -import be.digitalia.fosdem.model.Track import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.runBlocking import javax.inject.Named import javax.inject.Singleton @@ -26,57 +27,38 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object DatabaseModule { private const val DB_FILE = "fosdem.sqlite" - private const val DB_PREFS_FILE = "database" + private const val DB_DATASTORE_FILE = "database" @Provides @Named("Database") - fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences { - return context.applicationContext.getSharedPreferences(DB_PREFS_FILE, Context.MODE_PRIVATE) + fun provideDataStore(@ApplicationContext context: Context): DataStore { + return PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile(DB_DATASTORE_FILE) } + ) } @Provides @Singleton - fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { - val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(database: SupportSQLiteDatabase) = with(database) { - // Events: make primary key and track_id not null - execSQL("CREATE TABLE tmp_${EventEntity.TABLE_NAME} (id INTEGER PRIMARY KEY NOT NULL, day_index INTEGER NOT NULL, start_time INTEGER, end_time INTEGER, room_name TEXT, slug TEXT, track_id INTEGER NOT NULL, abstract TEXT, description TEXT)") - execSQL("INSERT INTO tmp_${EventEntity.TABLE_NAME} SELECT * FROM ${EventEntity.TABLE_NAME}") - execSQL("DROP TABLE ${EventEntity.TABLE_NAME}") - execSQL("ALTER TABLE tmp_${EventEntity.TABLE_NAME} RENAME TO ${EventEntity.TABLE_NAME}") - execSQL("CREATE INDEX event_day_index_idx ON ${EventEntity.TABLE_NAME} (day_index)") - execSQL("CREATE INDEX event_start_time_idx ON ${EventEntity.TABLE_NAME} (start_time)") - execSQL("CREATE INDEX event_end_time_idx ON ${EventEntity.TABLE_NAME} (end_time)") - execSQL("CREATE INDEX event_track_id_idx ON ${EventEntity.TABLE_NAME} (track_id)") - // Links: add explicit primary key - execSQL("CREATE TABLE tmp_${Link.TABLE_NAME} (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, event_id INTEGER NOT NULL, url TEXT NOT NULL, description TEXT)") - execSQL("INSERT INTO tmp_${Link.TABLE_NAME} SELECT `rowid` AS id, event_id, url, description FROM ${Link.TABLE_NAME}") - execSQL("DROP TABLE ${Link.TABLE_NAME}") - execSQL("ALTER TABLE tmp_${Link.TABLE_NAME} RENAME TO ${Link.TABLE_NAME}") - execSQL("CREATE INDEX link_event_id_idx ON ${Link.TABLE_NAME} (event_id)") - // Tracks: make primary key not null - execSQL("CREATE TABLE tmp_${Track.TABLE_NAME} (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL)") - execSQL("INSERT INTO tmp_${Track.TABLE_NAME} SELECT * FROM ${Track.TABLE_NAME}") - execSQL("DROP TABLE ${Track.TABLE_NAME}") - execSQL("ALTER TABLE tmp_${Track.TABLE_NAME} RENAME TO ${Track.TABLE_NAME}") - execSQL("CREATE UNIQUE INDEX track_main_idx ON ${Track.TABLE_NAME} (name, type)") - // Days: make primary key not null and rename _index to index - execSQL("CREATE TABLE tmp_${Day.TABLE_NAME} (`index` INTEGER PRIMARY KEY NOT NULL, date INTEGER NOT NULL)") - execSQL("INSERT INTO tmp_${Day.TABLE_NAME} SELECT _index as `index`, date FROM ${Day.TABLE_NAME}") - execSQL("DROP TABLE ${Day.TABLE_NAME}") - execSQL("ALTER TABLE tmp_${Day.TABLE_NAME} RENAME TO ${Day.TABLE_NAME}") - // Bookmarks: make primary key not null - execSQL("CREATE TABLE tmp_${Bookmark.TABLE_NAME} (event_id INTEGER PRIMARY KEY NOT NULL)") - execSQL("INSERT INTO tmp_${Bookmark.TABLE_NAME} SELECT * FROM ${Bookmark.TABLE_NAME}") - execSQL("DROP TABLE ${Bookmark.TABLE_NAME}") - execSQL("ALTER TABLE tmp_${Bookmark.TABLE_NAME} RENAME TO ${Bookmark.TABLE_NAME}") - } - } - + fun provideAppDatabase(@ApplicationContext context: Context, + @Named("Database") dataStore: DataStore, + alarmManager: AppAlarmManager): AppDatabase { return Room.databaseBuilder(context, AppDatabase::class.java, DB_FILE) - .addMigrations(MIGRATION_1_2) - .setJournalMode(RoomDatabase.JournalMode.TRUNCATE) - .build() + .setJournalMode(RoomDatabase.JournalMode.TRUNCATE) + .fallbackToDestructiveMigration() + .addCallback(object : RoomDatabase.Callback() { + @WorkerThread + override fun onDestructiveMigration(db: SupportSQLiteDatabase) { + runBlocking { + dataStore.edit { it.clear() } + } + } + }) + .build() + .also { + // Manual dependency injection + it.dataStore = dataStore + it.alarmManager = alarmManager + } } @Provides diff --git a/app/src/main/java/be/digitalia/fosdem/inject/UIStateModule.kt b/app/src/main/java/be/digitalia/fosdem/inject/UIStateModule.kt new file mode 100644 index 0000000..f9f6db1 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/inject/UIStateModule.kt @@ -0,0 +1,24 @@ +package be.digitalia.fosdem.inject + +import android.content.Context +import android.content.SharedPreferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object UIStateModule { + private const val SHARED_PREFERENCES_NAME = "ui_state" + + @Provides + @Named("UIState") + @Singleton + fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences { + return context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt b/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt index e498615..572b94d 100644 --- a/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt +++ b/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt @@ -25,6 +25,7 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.runBlocking import okio.buffer import okio.sink import java.io.FileNotFoundException @@ -73,7 +74,8 @@ class BookmarksExportProvider : ContentProvider() { when (col) { OpenableColumns.DISPLAY_NAME -> { cols[columnCount] = OpenableColumns.DISPLAY_NAME - values[columnCount++] = ctx.getString(R.string.export_bookmarks_file_name, scheduleDao.getYear()) + val year = runBlocking { scheduleDao.getYear() } + values[columnCount++] = ctx.getString(R.string.export_bookmarks_file_name, year) } OpenableColumns.SIZE -> { cols[columnCount] = OpenableColumns.SIZE diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksListViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksListViewModel.kt new file mode 100644 index 0000000..37c89f1 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksListViewModel.kt @@ -0,0 +1,27 @@ +package be.digitalia.fosdem.viewmodels + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.switchMap +import be.digitalia.fosdem.db.ScheduleDao +import be.digitalia.fosdem.model.Day +import be.digitalia.fosdem.model.Track +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class TracksListViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { + + private val dayLiveData = MutableLiveData() + + val tracks: LiveData> = dayLiveData.switchMap { day: Day -> + scheduleDao.getTracks(day) + } + + fun setDay(day: Day) { + if (day != dayLiveData.value) { + dayLiveData.value = day + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.kt index 03b33de..70ffe61 100644 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.kt +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.kt @@ -1,27 +1,19 @@ package be.digitalia.fosdem.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.switchMap +import androidx.lifecycle.asLiveData +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.viewModelScope import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.model.Day -import be.digitalia.fosdem.model.Track import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class TracksViewModel @Inject constructor(scheduleDao: ScheduleDao) : ViewModel() { - private val dayLiveData = MutableLiveData() - - val tracks: LiveData> = dayLiveData.switchMap { day: Day -> - scheduleDao.getTracks(day) - } - - fun setDay(day: Day) { - if (day != dayLiveData.value) { - dayLiveData.value = day - } - } + val days: LiveData> = scheduleDao.days + .asLiveData(viewModelScope.coroutineContext) + .distinctUntilChanged() } \ No newline at end of file