From 4df07a40b3a2ffcdf2cb0129c4534c04518ee2b6 Mon Sep 17 00:00:00 2001 From: Christophe Beyls Date: Mon, 28 Jan 2019 13:30:07 +0100 Subject: [PATCH] Refactor all database code to use Room, LiveData and the pagination library (#42) * Bump minSDK version to 16 because SQLite < 3.7.11 doesn't support syntax 'CREATE TABLE IF NOT EXISTS' for FTS tables * Reimplement search results screen using pagination, share a ViewModel between the Activity and the Fragment to allow updating the same fragment instance * Preserve scroll position 0 in live fragments to ensure the insert/remove animation will be visible * Simplify MultiChoiceHelper to always dispatch selection changes to the adapter using a payload, which prevents item crossfading on selection state change * Use withLayer() for better performance of progress bar fade out animation --- app/build.gradle | 9 +- .../digitalia/fosdem/FosdemApplication.java | 3 - .../fosdem/activities/MainActivity.java | 42 +- .../activities/RoomImageDialogActivity.java | 2 +- .../activities/SearchResultActivity.java | 41 +- .../TrackScheduleEventActivity.java | 75 +- .../fosdem/adapters/BookmarksAdapter.java | 387 +++++--- .../fosdem/adapters/EventsAdapter.java | 233 +++-- .../adapters/RecyclerViewCursorAdapter.java | 80 -- .../fosdem/adapters/SimpleItemCallback.java | 15 + .../fosdem/adapters/TrackScheduleAdapter.java | 170 ++-- .../fosdem/alarms/FosdemAlarmManager.java | 80 +- .../be/digitalia/fosdem/api/FosdemApi.java | 19 +- .../be/digitalia/fosdem/db/AppDatabase.java | 99 ++ .../be/digitalia/fosdem/db/BookmarksDao.java | 86 ++ .../digitalia/fosdem/db/DatabaseHelper.java | 65 -- .../digitalia/fosdem/db/DatabaseManager.java | 872 ------------------ .../fosdem/db/LocalBroadcastCursor.java | 86 -- .../be/digitalia/fosdem/db/ScheduleDao.java | 441 +++++++++ .../db/converters/GlobalTypeConverters.java | 39 + .../converters/NonNullDateTypeConverters.java | 18 + .../NullableDateTypeConverters.java | 17 + .../fosdem/db/entities/Bookmark.java | 23 + .../fosdem/db/entities/EventEntity.java | 95 ++ .../fosdem/db/entities/EventTitles.java | 43 + .../fosdem/db/entities/EventToPerson.java | 30 + .../fragments/BaseLiveListFragment.java | 52 +- .../fragments/BookmarksListFragment.java | 154 ++-- .../fragments/EventDetailsFragment.java | 90 +- .../fragments/NextLiveListFragment.java | 30 +- .../fosdem/fragments/NowLiveListFragment.java | 28 +- .../fragments/PersonInfoListFragment.java | 76 +- .../fosdem/fragments/PersonsListFragment.java | 107 +-- .../fragments/SearchResultListFragment.java | 66 +- .../fragments/TrackScheduleListFragment.java | 78 +- .../fosdem/fragments/TracksFragment.java | 4 +- .../fosdem/fragments/TracksListFragment.java | 104 +-- .../fosdem/livedata/AsyncTaskLiveData.java | 100 -- .../fosdem/livedata/ExtraTransformations.java | 35 + .../fosdem/livedata/LiveDataFactory.java | 58 ++ .../fosdem/loaders/BaseLiveLoader.java | 42 - .../fosdem/loaders/SimpleCursorLoader.java | 105 --- .../fosdem/loaders/TrackScheduleLoader.java | 25 - .../be/digitalia/fosdem/model/AlarmInfo.java | 29 + .../java/be/digitalia/fosdem/model/Day.java | 26 +- .../digitalia/fosdem/model/DetailedEvent.java | 39 + .../java/be/digitalia/fosdem/model/Event.java | 57 +- .../digitalia/fosdem/model/EventDetails.java | 28 + .../java/be/digitalia/fosdem/model/Link.java | 37 +- .../be/digitalia/fosdem/model/Person.java | 13 +- .../digitalia/fosdem/model/StatusEvent.java | 39 + .../java/be/digitalia/fosdem/model/Track.java | 38 +- .../fosdem/parsers/EventsParser.java | 9 +- .../providers/BookmarksExportProvider.java | 55 +- .../providers/SearchSuggestionProvider.java | 7 +- .../fosdem/services/AlarmIntentService.java | 69 +- .../be/digitalia/fosdem/utils/ArrayUtils.java | 13 - .../fosdem/viewmodels/BookmarksViewModel.java | 73 ++ .../viewmodels/EventDetailsViewModel.java | 136 +-- .../fosdem/viewmodels/EventViewModel.java | 49 +- .../fosdem/viewmodels/LiveViewModel.java | 54 ++ .../viewmodels/PersonInfoViewModel.java | 42 + .../fosdem/viewmodels/PersonsViewModel.java | 27 + .../fosdem/viewmodels/SearchViewModel.java | 58 ++ .../viewmodels/TrackScheduleViewModel.java | 44 + .../fosdem/viewmodels/TracksViewModel.java | 42 + .../fosdem/widgets/MultiChoiceHelper.java | 57 +- 67 files changed, 2640 insertions(+), 2625 deletions(-) delete mode 100644 app/src/main/java/be/digitalia/fosdem/adapters/RecyclerViewCursorAdapter.java create mode 100644 app/src/main/java/be/digitalia/fosdem/adapters/SimpleItemCallback.java create mode 100644 app/src/main/java/be/digitalia/fosdem/db/AppDatabase.java create mode 100644 app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.java delete mode 100644 app/src/main/java/be/digitalia/fosdem/db/DatabaseHelper.java delete mode 100644 app/src/main/java/be/digitalia/fosdem/db/DatabaseManager.java delete mode 100644 app/src/main/java/be/digitalia/fosdem/db/LocalBroadcastCursor.java create mode 100644 app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.java create mode 100644 app/src/main/java/be/digitalia/fosdem/db/converters/GlobalTypeConverters.java create mode 100644 app/src/main/java/be/digitalia/fosdem/db/converters/NonNullDateTypeConverters.java create mode 100644 app/src/main/java/be/digitalia/fosdem/db/converters/NullableDateTypeConverters.java create mode 100644 app/src/main/java/be/digitalia/fosdem/db/entities/Bookmark.java create mode 100644 app/src/main/java/be/digitalia/fosdem/db/entities/EventEntity.java create mode 100644 app/src/main/java/be/digitalia/fosdem/db/entities/EventTitles.java create mode 100644 app/src/main/java/be/digitalia/fosdem/db/entities/EventToPerson.java delete mode 100644 app/src/main/java/be/digitalia/fosdem/livedata/AsyncTaskLiveData.java create mode 100644 app/src/main/java/be/digitalia/fosdem/livedata/ExtraTransformations.java create mode 100644 app/src/main/java/be/digitalia/fosdem/livedata/LiveDataFactory.java delete mode 100644 app/src/main/java/be/digitalia/fosdem/loaders/BaseLiveLoader.java delete mode 100644 app/src/main/java/be/digitalia/fosdem/loaders/SimpleCursorLoader.java delete mode 100644 app/src/main/java/be/digitalia/fosdem/loaders/TrackScheduleLoader.java create mode 100644 app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.java create mode 100644 app/src/main/java/be/digitalia/fosdem/model/DetailedEvent.java create mode 100644 app/src/main/java/be/digitalia/fosdem/model/EventDetails.java create mode 100644 app/src/main/java/be/digitalia/fosdem/model/StatusEvent.java delete mode 100644 app/src/main/java/be/digitalia/fosdem/utils/ArrayUtils.java create mode 100644 app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.java create mode 100644 app/src/main/java/be/digitalia/fosdem/viewmodels/LiveViewModel.java create mode 100644 app/src/main/java/be/digitalia/fosdem/viewmodels/PersonInfoViewModel.java create mode 100644 app/src/main/java/be/digitalia/fosdem/viewmodels/PersonsViewModel.java create mode 100644 app/src/main/java/be/digitalia/fosdem/viewmodels/SearchViewModel.java create mode 100644 app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleViewModel.java create mode 100644 app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.java diff --git a/app/build.gradle b/app/build.gradle index 3473ce7..6777b1a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,9 +6,9 @@ android { defaultConfig { applicationId "be.digitalia.fosdem" - minSdkVersion 15 + minSdkVersion 16 targetSdkVersion 28 - versionCode 1500160 + versionCode 1600160 versionName "1.6.0" // Supported languages resConfigs "en" @@ -25,6 +25,8 @@ android { } dependencies { + def room_version = "2.1.0-alpha03" + implementation 'androidx.appcompat:appcompat:1.0.2' implementation 'com.google.android.material:material:1.0.0' implementation 'androidx.recyclerview:recyclerview:1.0.0' @@ -36,5 +38,8 @@ dependencies { } implementation 'androidx.browser:browser:1.0.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0' + implementation 'androidx.paging:paging-runtime:2.1.0-rc01' + implementation "androidx.room:room-runtime:$room_version" + annotationProcessor "androidx.room:room-compiler:$room_version" implementation 'com.github.chrisbanes:PhotoView:2.3.0' } diff --git a/app/src/main/java/be/digitalia/fosdem/FosdemApplication.java b/app/src/main/java/be/digitalia/fosdem/FosdemApplication.java index 1bbe5e0..584a99f 100644 --- a/app/src/main/java/be/digitalia/fosdem/FosdemApplication.java +++ b/app/src/main/java/be/digitalia/fosdem/FosdemApplication.java @@ -1,10 +1,8 @@ package be.digitalia.fosdem; import android.app.Application; - import androidx.preference.PreferenceManager; import be.digitalia.fosdem.alarms.FosdemAlarmManager; -import be.digitalia.fosdem.db.DatabaseManager; public class FosdemApplication extends Application { @@ -12,7 +10,6 @@ public class FosdemApplication extends Application { public void onCreate() { super.onCreate(); - DatabaseManager.init(this); // Initialize settings PreferenceManager.setDefaultValues(this, R.xml.settings, false); // Alarms (requires settings) diff --git a/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.java index c990269..a9d104c 100644 --- a/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.java +++ b/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.java @@ -36,12 +36,11 @@ import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.Observer; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; import be.digitalia.fosdem.BuildConfig; import be.digitalia.fosdem.R; import be.digitalia.fosdem.api.FosdemApi; import be.digitalia.fosdem.api.FosdemUrls; -import be.digitalia.fosdem.db.DatabaseManager; +import be.digitalia.fosdem.db.AppDatabase; import be.digitalia.fosdem.fragments.*; import be.digitalia.fosdem.livedata.SingleEvent; import be.digitalia.fosdem.model.DownloadScheduleResult; @@ -150,14 +149,6 @@ public class MainActivity extends AppCompatActivity { } }; - private final BroadcastReceiver scheduleRefreshedReceiver = new BroadcastReceiver() { - - @Override - public void onReceive(Context context, Intent intent) { - updateLastUpdateTime(); - } - }; - public static class DownloadScheduleReminderDialogFragment extends DialogFragment { @NonNull @@ -213,6 +204,7 @@ public class MainActivity extends AppCompatActivity { progressBar.setProgress(100); progressBar.animate() .alpha(0f) + .withLayer() .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { @@ -283,11 +275,10 @@ public class MainActivity extends AppCompatActivity { } }); - LocalBroadcastManager.getInstance(this).registerReceiver(scheduleRefreshedReceiver, new IntentFilter(DatabaseManager.ACTION_SCHEDULE_REFRESHED)); - // Last update date, below the list lastUpdateTextView = mainMenu.findViewById(R.id.last_update); - updateLastUpdateTime(); + AppDatabase.getInstance(this).getScheduleDao().getLastUpdateTime() + .observe(this, lastUpdateTimeObserver); if (savedInstanceState == null) { // Select initial section @@ -319,13 +310,15 @@ public class MainActivity extends AppCompatActivity { } } - void updateLastUpdateTime() { - long lastUpdateTime = DatabaseManager.getInstance().getLastUpdateTime(); - lastUpdateTextView.setText(getString(R.string.last_update, - (lastUpdateTime == -1L) - ? getString(R.string.never) - : android.text.format.DateFormat.format(LAST_UPDATE_DATE_FORMAT, lastUpdateTime))); - } + private final Observer lastUpdateTimeObserver = new Observer() { + @Override + public void onChanged(Long lastUpdateTime) { + lastUpdateTextView.setText(getString(R.string.last_update, + (lastUpdateTime == -1L) + ? getString(R.string.never) + : android.text.format.DateFormat.format(LAST_UPDATE_DATE_FORMAT, lastUpdateTime))); + } + }; @Override protected void onPostCreate(Bundle savedInstanceState) { @@ -371,7 +364,8 @@ public class MainActivity extends AppCompatActivity { // Download reminder long now = System.currentTimeMillis(); - long time = DatabaseManager.getInstance().getLastUpdateTime(); + Long timeValue = AppDatabase.getInstance(this).getScheduleDao().getLastUpdateTime().getValue(); + long time = (timeValue == null) ? -1L : timeValue; if ((time == -1L) || (time < (now - DATABASE_VALIDITY_DURATION))) { SharedPreferences prefs = getPreferences(Context.MODE_PRIVATE); time = prefs.getLong(PREF_LAST_DOWNLOAD_REMINDER_TIME, -1L); @@ -397,12 +391,6 @@ public class MainActivity extends AppCompatActivity { super.onStop(); } - @Override - protected void onDestroy() { - super.onDestroy(); - LocalBroadcastManager.getInstance(this).unregisterReceiver(scheduleRefreshedReceiver); - } - @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.main, menu); diff --git a/app/src/main/java/be/digitalia/fosdem/activities/RoomImageDialogActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/RoomImageDialogActivity.java index d4b001a..330cf24 100644 --- a/app/src/main/java/be/digitalia/fosdem/activities/RoomImageDialogActivity.java +++ b/app/src/main/java/be/digitalia/fosdem/activities/RoomImageDialogActivity.java @@ -77,7 +77,7 @@ public class RoomImageDialogActivity extends AppCompatActivity { }); // Display the room status as subtitle - FosdemApi.getRoomStatuses().observe(owner, new Observer>() { + FosdemApi.getRoomStatuses(toolbar.getContext()).observe(owner, new Observer>() { @Override public void onChanged(Map roomStatuses) { RoomStatus roomStatus = roomStatuses.get(roomName); diff --git a/app/src/main/java/be/digitalia/fosdem/activities/SearchResultActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/SearchResultActivity.java index fbc68a8..9f6cfa3 100644 --- a/app/src/main/java/be/digitalia/fosdem/activities/SearchResultActivity.java +++ b/app/src/main/java/be/digitalia/fosdem/activities/SearchResultActivity.java @@ -13,22 +13,19 @@ import android.view.MenuItem; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.SearchView; import androidx.core.content.ContextCompat; -import androidx.core.util.ObjectsCompat; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; import be.digitalia.fosdem.R; import be.digitalia.fosdem.fragments.SearchResultListFragment; +import be.digitalia.fosdem.viewmodels.SearchViewModel; import com.google.android.material.snackbar.Snackbar; public class SearchResultActivity extends AppCompatActivity { - public static final int MIN_SEARCH_LENGTH = 3; - private static final String STATE_CURRENT_QUERY = "current_query"; // Search Intent sent by Google Now private static final String GMS_ACTION_SEARCH = "com.google.android.gms.actions.SEARCH_ACTION"; - private String currentQuery; + private SearchViewModel viewModel; private SearchView searchView; @Override @@ -38,17 +35,22 @@ public class SearchResultActivity extends AppCompatActivity { getSupportActionBar().setDisplayHomeAsUpEnabled(true); + viewModel = ViewModelProviders.of(this).get(SearchViewModel.class); + if (savedInstanceState == null) { + SearchResultListFragment f = SearchResultListFragment.newInstance(); + getSupportFragmentManager().beginTransaction().replace(R.id.content, f).commit(); + handleIntent(getIntent(), false); } else { - currentQuery = savedInstanceState.getString(STATE_CURRENT_QUERY); + viewModel.setQuery(savedInstanceState.getString(STATE_CURRENT_QUERY, "")); } } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); - outState.putString(STATE_CURRENT_QUERY, currentQuery); + outState.putString(STATE_CURRENT_QUERY, viewModel.getQuery()); } @Override @@ -62,29 +64,18 @@ public class SearchResultActivity extends AppCompatActivity { if (Intent.ACTION_SEARCH.equals(intentAction) || GMS_ACTION_SEARCH.equals(intentAction)) { // Normal search, results are displayed here String query = intent.getStringExtra(SearchManager.QUERY); - if (query != null) { + if (query == null) { + query = ""; + } else { query = query.trim(); } if (searchView != null) { setSearchViewQuery(query); } - final boolean isQueryTooShort = (query == null) || (query.length() < MIN_SEARCH_LENGTH); - if (!ObjectsCompat.equals(currentQuery, query)) { - currentQuery = query; - FragmentManager fm = getSupportFragmentManager(); - if (isQueryTooShort) { - Fragment f = fm.findFragmentById(R.id.content); - if (f != null) { - fm.beginTransaction().remove(f).commitAllowingStateLoss(); - } - } else { - SearchResultListFragment f = SearchResultListFragment.newInstance(query); - fm.beginTransaction().replace(R.id.content, f).commitAllowingStateLoss(); - } - } + viewModel.setQuery(query); - if (isQueryTooShort) { + if (SearchViewModel.isQueryTooShort(query)) { SpannableString errorMessage = new SpannableString(getString(R.string.search_length_error)); int textColor = ContextCompat.getColor(this, R.color.error_material); errorMessage.setSpan(new ForegroundColorSpan(textColor), 0, errorMessage.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -113,7 +104,7 @@ public class SearchResultActivity extends AppCompatActivity { searchView = (SearchView) searchMenuItem.getActionView(); searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName())); searchView.setIconifiedByDefault(false); // Always show the search view - setSearchViewQuery(currentQuery); + setSearchViewQuery(viewModel.getQuery()); return true; } diff --git a/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleEventActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleEventActivity.java index 60ce37c..5d09a8b 100644 --- a/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleEventActivity.java +++ b/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleEventActivity.java @@ -1,47 +1,42 @@ package be.digitalia.fosdem.activities; -import android.database.Cursor; import android.os.Bundle; -import android.provider.BaseColumns; import android.view.View; -import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentStatePagerAdapter; -import androidx.loader.app.LoaderManager; -import androidx.loader.app.LoaderManager.LoaderCallbacks; -import androidx.loader.content.Loader; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; import androidx.viewpager.widget.ViewPager; import be.digitalia.fosdem.R; -import be.digitalia.fosdem.db.DatabaseManager; import be.digitalia.fosdem.fragments.EventDetailsFragment; -import be.digitalia.fosdem.loaders.TrackScheduleLoader; import be.digitalia.fosdem.model.Day; +import be.digitalia.fosdem.model.Event; +import be.digitalia.fosdem.model.StatusEvent; import be.digitalia.fosdem.model.Track; import be.digitalia.fosdem.utils.NfcUtils; import be.digitalia.fosdem.utils.NfcUtils.CreateNfcAppDataCallback; import be.digitalia.fosdem.utils.ThemeUtils; +import be.digitalia.fosdem.viewmodels.TrackScheduleViewModel; import be.digitalia.fosdem.widgets.ContentLoadingProgressBar; import com.viewpagerindicator.UnderlinePageIndicator; +import java.util.List; + /** * Event view of the track schedule; allows to slide between events of the same track using a ViewPager. * * @author Christophe Beyls */ -public class TrackScheduleEventActivity extends AppCompatActivity implements LoaderCallbacks, CreateNfcAppDataCallback { +public class TrackScheduleEventActivity extends AppCompatActivity implements Observer>, CreateNfcAppDataCallback { public static final String EXTRA_DAY = "day"; public static final String EXTRA_TRACK = "track"; public static final String EXTRA_POSITION = "position"; - private static final int EVENTS_LOADER_ID = 1; - - private Day day; - private Track track; private int initialPosition = -1; private ContentLoadingProgressBar progress; private ViewPager pager; @@ -55,8 +50,8 @@ public class TrackScheduleEventActivity extends AppCompatActivity implements Loa setContentView(R.layout.track_schedule_event); Bundle extras = getIntent().getExtras(); - day = extras.getParcelable(EXTRA_DAY); - track = extras.getParcelable(EXTRA_TRACK); + final Day day = extras.getParcelable(EXTRA_DAY); + final Track track = extras.getParcelable(EXTRA_TRACK); progress = findViewById(R.id.progress); pager = findViewById(R.id.pager); @@ -78,7 +73,9 @@ public class TrackScheduleEventActivity extends AppCompatActivity implements Loa NfcUtils.setAppDataPushMessageCallbackIfAvailable(this, this); setCustomProgressVisibility(true); - LoaderManager.getInstance(this).initLoader(EVENTS_LOADER_ID, null, this); + final TrackScheduleViewModel viewModel = ViewModelProviders.of(this).get(TrackScheduleViewModel.class); + viewModel.setTrack(day, track); + viewModel.getSchedule().observe(this, this); } private void setCustomProgressVisibility(boolean isVisible) { @@ -94,11 +91,11 @@ public class TrackScheduleEventActivity extends AppCompatActivity implements Loa if (adapter.getCount() == 0) { return null; } - long eventId = adapter.getItemId(pager.getCurrentItem()); - if (eventId == -1L) { + Event event = adapter.getEvent(pager.getCurrentItem()); + if (event == null) { return null; } - return String.valueOf(eventId).getBytes(); + return String.valueOf(event.getId()).getBytes(); } @Override @@ -107,19 +104,13 @@ public class TrackScheduleEventActivity extends AppCompatActivity implements Loa return true; } - @NonNull @Override - public Loader onCreateLoader(int id, Bundle args) { - return new TrackScheduleLoader(this, day, track); - } - - @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { + public void onChanged(List schedule) { setCustomProgressVisibility(false); - if (data != null) { + if (schedule != null) { pager.setVisibility(View.VISIBLE); - adapter.setCursor(data); + adapter.setSchedule(schedule); // Delay setting the adapter // to ensure the current position is restored properly @@ -135,44 +126,34 @@ public class TrackScheduleEventActivity extends AppCompatActivity implements Loa } } - @Override - public void onLoaderReset(@NonNull Loader loader) { - adapter.setCursor(null); - } - public static class TrackScheduleEventAdapter extends FragmentStatePagerAdapter { - private Cursor cursor; + private List events = null; public TrackScheduleEventAdapter(FragmentManager fm) { super(fm); } - public Cursor getCursor() { - return cursor; - } - - public void setCursor(Cursor cursor) { - this.cursor = cursor; + public void setSchedule(List schedule) { + this.events = schedule; notifyDataSetChanged(); } @Override public int getCount() { - return (cursor == null) ? 0 : cursor.getCount(); + return (events == null) ? 0 : events.size(); } @Override public Fragment getItem(int position) { - cursor.moveToPosition(position); - return EventDetailsFragment.newInstance(DatabaseManager.toEvent(cursor)); + return EventDetailsFragment.newInstance(events.get(position).getEvent()); } - public long getItemId(int position) { - if (!cursor.moveToPosition(position)) { - return -1L; + public Event getEvent(int position) { + if (position < 0 || position >= getCount()) { + return null; } - return cursor.getLong(cursor.getColumnIndexOrThrow(BaseColumns._ID)); + return events.get(position).getEvent(); } } } diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/BookmarksAdapter.java b/app/src/main/java/be/digitalia/fosdem/adapters/BookmarksAdapter.java index 0dee71d..28bb52e 100644 --- a/app/src/main/java/be/digitalia/fosdem/adapters/BookmarksAdapter.java +++ b/app/src/main/java/be/digitalia/fosdem/adapters/BookmarksAdapter.java @@ -1,187 +1,314 @@ package be.digitalia.fosdem.adapters; import android.content.Context; -import android.database.Cursor; +import android.content.Intent; import android.graphics.Typeface; -import android.os.AsyncTask; -import android.os.Parcelable; import android.text.SpannableString; import android.text.Spanned; import android.text.TextUtils; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; -import android.view.Menu; -import android.view.MenuItem; +import android.view.LayoutInflater; import android.view.View; - -import java.util.Date; - +import android.view.ViewGroup; +import android.widget.TextView; import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.view.ActionMode; +import androidx.collection.SimpleArrayMap; import androidx.core.content.ContextCompat; +import androidx.core.util.ObjectsCompat; import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.Observer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; import be.digitalia.fosdem.R; -import be.digitalia.fosdem.db.DatabaseManager; +import be.digitalia.fosdem.activities.EventDetailsActivity; +import be.digitalia.fosdem.api.FosdemApi; import be.digitalia.fosdem.model.Event; import be.digitalia.fosdem.model.RoomStatus; import be.digitalia.fosdem.model.Track; +import be.digitalia.fosdem.utils.DateUtils; import be.digitalia.fosdem.widgets.MultiChoiceHelper; -public class BookmarksAdapter extends EventsAdapter { +import java.text.DateFormat; +import java.util.Date; +import java.util.List; +import java.util.Map; +public class BookmarksAdapter extends ListAdapter + implements Observer> { + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new SimpleItemCallback() { + @Override + public boolean areContentsTheSame(@NonNull Event oldEvent, @NonNull Event newEvent) { + return ObjectsCompat.equals(oldEvent.getTitle(), newEvent.getTitle()) + && ObjectsCompat.equals(oldEvent.getPersonsSummary(), newEvent.getPersonsSummary()) + && ObjectsCompat.equals(oldEvent.getTrack(), newEvent.getTrack()) + && ObjectsCompat.equals(oldEvent.getDay(), newEvent.getDay()) + && ObjectsCompat.equals(oldEvent.getStartTime(), newEvent.getStartTime()) + && ObjectsCompat.equals(oldEvent.getEndTime(), newEvent.getEndTime()) + && ObjectsCompat.equals(oldEvent.getRoomName(), newEvent.getRoomName()); + } + }; + static final Object DETAILS_PAYLOAD = new Object(); + + private final DateFormat timeDateFormat; @ColorInt private final int errorColor; + private final SimpleArrayMap observers = new SimpleArrayMap<>(); final MultiChoiceHelper multiChoiceHelper; + private Map roomStatuses; - public BookmarksAdapter(AppCompatActivity activity, LifecycleOwner owner) { - super(activity, owner); + public BookmarksAdapter(@NonNull AppCompatActivity activity, @NonNull LifecycleOwner owner, + @NonNull MultiChoiceHelper.MultiChoiceModeListener multiChoiceModeListener) { + super(DIFF_CALLBACK); + setHasStableIds(true); + timeDateFormat = DateUtils.getTimeDateFormat(activity); errorColor = ContextCompat.getColor(activity, R.color.error_material); + multiChoiceHelper = new MultiChoiceHelper(activity, this); - multiChoiceHelper.setMultiChoiceModeListener(new MultiChoiceHelper.MultiChoiceModeListener() { + multiChoiceHelper.setMultiChoiceModeListener(multiChoiceModeListener); - @Override - public boolean onCreateActionMode(ActionMode mode, Menu menu) { - mode.getMenuInflater().inflate(R.menu.action_mode_bookmarks, menu); - return true; - } - - private void updateSelectedCountDisplay(ActionMode mode) { - int count = multiChoiceHelper.getCheckedItemCount(); - mode.setTitle(multiChoiceHelper.getContext().getResources().getQuantityString(R.plurals.selected, count, count)); - } - - @Override - public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - updateSelectedCountDisplay(mode); - return true; - } - - @Override - public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - switch (item.getItemId()) { - case R.id.delete: - // Remove multiple bookmarks at once - new RemoveBookmarksAsyncTask().execute(multiChoiceHelper.getCheckedItemIds()); - mode.finish(); - return true; - } - return false; - } - - @Override - public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) { - updateSelectedCountDisplay(mode); - } - - @Override - public void onDestroyActionMode(ActionMode mode) { - } - }); + FosdemApi.getRoomStatuses(activity).observe(owner, this); } - public Parcelable onSaveInstanceState() { - return multiChoiceHelper.onSaveInstanceState(); - } - - public void onRestoreInstanceState(Parcelable state) { - multiChoiceHelper.onRestoreInstanceState(state); - } - - public void onDestroyView() { - multiChoiceHelper.clearChoices(); + @NonNull + public MultiChoiceHelper getMultiChoiceHelper() { + return multiChoiceHelper; } @Override - public void onBindViewHolder(ViewHolder holder, Cursor cursor) { - final int position = cursor.getPosition(); - Context context = holder.itemView.getContext(); - Event event = DatabaseManager.toEvent(cursor, holder.event); - holder.event = event; - holder.isOverlapping = isOverlapping(cursor, event.getStartTime(), event.getEndTime()); - - holder.title.setText(event.getTitle()); - String personsSummary = event.getPersonsSummary(); - holder.persons.setText(personsSummary); - holder.persons.setVisibility(TextUtils.isEmpty(personsSummary) ? View.GONE : View.VISIBLE); - Track track = event.getTrack(); - holder.trackName.setText(track.getName()); - holder.trackName.setTextColor(ContextCompat.getColor(context, track.getType().getColorResId())); - holder.trackName.setContentDescription(context.getString(R.string.track_content_description, track.getName())); - - bindDetails(holder, event); - - // Enable MultiChoice selection and update checked state - holder.bind(multiChoiceHelper, position); + public void onChanged(@Nullable Map roomStatuses) { + this.roomStatuses = roomStatuses; + notifyItemRangeChanged(0, getItemCount(), DETAILS_PAYLOAD); } @Override - protected void bindDetails(ViewHolder holder, Event event) { - Context context = holder.details.getContext(); - Date startTime = event.getStartTime(); - Date endTime = event.getEndTime(); - String startTimeString = (startTime != null) ? timeDateFormat.format(startTime) : "?"; - String endTimeString = (endTime != null) ? timeDateFormat.format(endTime) : "?"; - String roomName = event.getRoomName(); - String details = String.format("%1$s, %2$s ― %3$s | %4$s", event.getDay().getShortName(), startTimeString, endTimeString, roomName); - SpannableString detailsSpannable = new SpannableString(details); - String detailsContentDescription = details; + public long getItemId(int position) { + return getItem(position).getId(); + } - // Highlight the date and time with error color in case of conflicting schedules - if (holder.isOverlapping) { - int endPosition = details.indexOf(" | "); - detailsSpannable.setSpan(new ForegroundColorSpan(errorColor), 0, endPosition, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - detailsSpannable.setSpan(new StyleSpan(Typeface.BOLD), 0, endPosition, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - holder.details.setText(detailsSpannable); - detailsContentDescription = context.getString(R.string.bookmark_conflict_content_description, detailsContentDescription); + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_event, parent, false); + return new ViewHolder(view, multiChoiceHelper, timeDateFormat, errorColor); + } + + private RoomStatus getRoomStatus(Event event) { + return (roomStatuses == null) ? null : roomStatuses.get(event.getRoomName()); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + final Event event = getItem(position); + holder.bind(event); + final Event previous = position > 0 ? getItem(position - 1) : null; + final Event next = position + 1 < getItemCount() ? getItem(position + 1) : null; + holder.bindDetails(event, previous, next, getRoomStatus(event)); + holder.bindSelection(); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List payloads) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position); + } else { + final Event event = getItem(position); + if (payloads.contains(DETAILS_PAYLOAD)) { + final Event previous = position > 0 ? getItem(position - 1) : null; + final Event next = position + 1 < getItemCount() ? getItem(position + 1) : null; + holder.bindDetails(event, previous, next, getRoomStatus(event)); + } + if (payloads.contains(MultiChoiceHelper.SELECTION_PAYLOAD)) { + holder.bindSelection(); + } } - if (roomStatuses != null) { - RoomStatus roomStatus = roomStatuses.get(roomName); + } + + @Override + public void registerAdapterDataObserver(@NonNull RecyclerView.AdapterDataObserver observer) { + if (!observers.containsKey(observer)) { + final BookmarksDataObserverWrapper wrapper = new BookmarksDataObserverWrapper(observer, this); + observers.put(observer, wrapper); + super.registerAdapterDataObserver(wrapper); + } + } + + @Override + public void unregisterAdapterDataObserver(@NonNull RecyclerView.AdapterDataObserver observer) { + final BookmarksDataObserverWrapper wrapper = observers.remove(observer); + if (wrapper != null) { + super.unregisterAdapterDataObserver(wrapper); + } + } + + static class ViewHolder extends MultiChoiceHelper.ViewHolder implements View.OnClickListener { + final TextView title; + final TextView persons; + final TextView trackName; + final TextView details; + + private final DateFormat timeDateFormat; + @ColorInt + private final int errorColor; + + Event event; + + public ViewHolder(@NonNull View itemView, @NonNull MultiChoiceHelper helper, + @NonNull DateFormat timeDateFormat, @ColorInt int errorColor) { + super(itemView, helper); + + title = itemView.findViewById(R.id.title); + persons = itemView.findViewById(R.id.persons); + trackName = itemView.findViewById(R.id.track_name); + details = itemView.findViewById(R.id.details); + setOnClickListener(this); + + this.timeDateFormat = timeDateFormat; + this.errorColor = errorColor; + } + + void bind(@NonNull Event event) { + Context context = itemView.getContext(); + this.event = event; + + title.setText(event.getTitle()); + String personsSummary = event.getPersonsSummary(); + persons.setText(personsSummary); + persons.setVisibility(TextUtils.isEmpty(personsSummary) ? View.GONE : View.VISIBLE); + Track track = event.getTrack(); + trackName.setText(track.getName()); + trackName.setTextColor(ContextCompat.getColor(context, track.getType().getColorResId())); + trackName.setContentDescription(context.getString(R.string.track_content_description, track.getName())); + } + + void bindDetails(@NonNull Event event, @Nullable Event previous, @Nullable Event next, @Nullable RoomStatus roomStatus) { + Context context = details.getContext(); + Date startTime = event.getStartTime(); + Date endTime = event.getEndTime(); + String startTimeString = (startTime != null) ? timeDateFormat.format(startTime) : "?"; + String endTimeString = (endTime != null) ? timeDateFormat.format(endTime) : "?"; + String roomName = event.getRoomName(); + String detailsText = String.format("%1$s, %2$s ― %3$s | %4$s", event.getDay().getShortName(), startTimeString, endTimeString, roomName); + SpannableString detailsSpannable = new SpannableString(detailsText); + CharSequence detailsDescription = detailsText; + + // Highlight the date and time with error color in case of conflicting schedules + if (isOverlapping(event, previous, next)) { + int endPosition = detailsText.indexOf(" | "); + detailsSpannable.setSpan(new ForegroundColorSpan(errorColor), 0, endPosition, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + detailsSpannable.setSpan(new StyleSpan(Typeface.BOLD), 0, endPosition, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + details.setText(detailsSpannable); + detailsDescription = context.getString(R.string.bookmark_conflict_content_description, detailsDescription); + } if (roomStatus != null) { int color = ContextCompat.getColor(context, roomStatus.getColorResId()); detailsSpannable.setSpan(new ForegroundColorSpan(color), - details.length() - roomName.length(), - details.length(), + detailsText.length() - roomName.length(), + detailsText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } + details.setText(detailsSpannable); + details.setContentDescription(context.getString(R.string.details_content_description, detailsDescription)); } - holder.details.setText(detailsSpannable); - holder.details.setContentDescription(context.getString(R.string.details_content_description, detailsContentDescription)); - } - /** - * Checks if the current event is overlapping with the previous or next one. - * Warning: this methods will update the cursor's position. - */ - private static boolean isOverlapping(Cursor cursor, Date startTime, Date endTime) { - final int position = cursor.getPosition(); - - if ((startTime != null) && (position > 0) && cursor.moveToPosition(position - 1)) { - long previousEndTime = DatabaseManager.toEventEndTimeMillis(cursor); - if ((previousEndTime != -1L) && (previousEndTime > startTime.getTime())) { + /** + * Checks if the current event is overlapping with the previous or next one. + */ + private static boolean isOverlapping(@NonNull Event event, @Nullable Event previous, @Nullable Event next) { + final Date startTime = event.getStartTime(); + final Date previousEndTime = (previous == null) ? null : previous.getEndTime(); + if (startTime != null && previousEndTime != null && previousEndTime.getTime() > startTime.getTime()) { // The event overlaps with the previous one return true; } + + final Date endTime = event.getEndTime(); + final Date nextStartTime = (next == null) ? null : next.getStartTime(); + // The event overlaps with the next one + return endTime != null && nextStartTime != null && nextStartTime.getTime() < endTime.getTime(); } - if ((endTime != null) && (position < (cursor.getCount() - 1)) && cursor.moveToPosition(position + 1)) { - long nextStartTime = DatabaseManager.toEventStartTimeMillis(cursor); - if ((nextStartTime != -1L) && (nextStartTime < endTime.getTime())) { - // The event overlaps with the next one - return true; + @Override + public void onClick(View view) { + if (event != null) { + Context context = view.getContext(); + Intent intent = new Intent(context, EventDetailsActivity.class) + .putExtra(EventDetailsActivity.EXTRA_EVENT, event); + context.startActivity(intent); + } + } + } + + /** + * An observer dispatching updates to the source observer while additionally notifying changes + * of the immediately previous and next items in order to properly update their overlapping status display. + */ + static class BookmarksDataObserverWrapper extends RecyclerView.AdapterDataObserver { + private final RecyclerView.AdapterDataObserver observer; + private final RecyclerView.Adapter adapter; + + public BookmarksDataObserverWrapper(RecyclerView.AdapterDataObserver observer, RecyclerView.Adapter adapter) { + this.observer = observer; + this.adapter = adapter; + } + + private void updatePrevious(int position) { + if (position >= 0) { + observer.onItemRangeChanged(position, 1, DETAILS_PAYLOAD); } } - return false; - } - - static class RemoveBookmarksAsyncTask extends AsyncTask { - - @Override - protected Void doInBackground(long[]... params) { - DatabaseManager.getInstance().removeBookmarks(params[0]); - return null; + private void updateNext(int position) { + if (position < adapter.getItemCount()) { + observer.onItemRangeChanged(position, 1, DETAILS_PAYLOAD); + } } + @Override + public void onChanged() { + observer.onChanged(); + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount) { + observer.onItemRangeChanged(positionStart, itemCount); + updatePrevious(positionStart - 1); + updateNext(positionStart + itemCount); + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) { + observer.onItemRangeChanged(positionStart, itemCount, payload); + updatePrevious(positionStart - 1); + updateNext(positionStart + itemCount); + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + observer.onItemRangeInserted(positionStart, itemCount); + updatePrevious(positionStart - 1); + updateNext(positionStart + itemCount); + } + + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + observer.onItemRangeRemoved(positionStart, itemCount); + updatePrevious(positionStart - 1); + updateNext(positionStart); + } + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + updatePrevious(fromPosition - 1); + updateNext(fromPosition + itemCount); + observer.onItemRangeMoved(fromPosition, toPosition, itemCount); + updatePrevious(toPosition - 1); + updateNext(toPosition + itemCount); + } } } diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.java b/app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.java index 678147f..1db145e 100644 --- a/app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.java +++ b/app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.java @@ -2,7 +2,6 @@ package be.digitalia.fosdem.adapters; import android.content.Context; import android.content.Intent; -import android.database.Cursor; import android.graphics.drawable.Drawable; import android.text.SpannableString; import android.text.Spanned; @@ -12,49 +11,65 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.content.ContextCompat; +import androidx.core.util.ObjectsCompat; +import androidx.core.widget.TextViewCompat; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.Observer; +import androidx.paging.PagedListAdapter; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; +import be.digitalia.fosdem.R; +import be.digitalia.fosdem.activities.EventDetailsActivity; +import be.digitalia.fosdem.api.FosdemApi; +import be.digitalia.fosdem.model.Event; +import be.digitalia.fosdem.model.RoomStatus; +import be.digitalia.fosdem.model.StatusEvent; +import be.digitalia.fosdem.model.Track; +import be.digitalia.fosdem.utils.DateUtils; import java.text.DateFormat; import java.util.Date; import java.util.List; import java.util.Map; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.content.ContextCompat; -import androidx.core.widget.TextViewCompat; -import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.Observer; -import be.digitalia.fosdem.R; -import be.digitalia.fosdem.activities.EventDetailsActivity; -import be.digitalia.fosdem.api.FosdemApi; -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.model.Event; -import be.digitalia.fosdem.model.RoomStatus; -import be.digitalia.fosdem.model.Track; -import be.digitalia.fosdem.utils.DateUtils; -import be.digitalia.fosdem.widgets.MultiChoiceHelper; - -public class EventsAdapter extends RecyclerViewCursorAdapter +public class EventsAdapter extends PagedListAdapter implements Observer> { + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new SimpleItemCallback() { + @Override + public boolean areContentsTheSame(@NonNull StatusEvent oldItem, @NonNull StatusEvent newItem) { + final Event oldEvent = oldItem.getEvent(); + final Event newEvent = newItem.getEvent(); + return ObjectsCompat.equals(oldEvent.getTitle(), newEvent.getTitle()) + && ObjectsCompat.equals(oldEvent.getPersonsSummary(), newEvent.getPersonsSummary()) + && ObjectsCompat.equals(oldEvent.getTrack(), newEvent.getTrack()) + && ObjectsCompat.equals(oldEvent.getDay(), newEvent.getDay()) + && ObjectsCompat.equals(oldEvent.getStartTime(), newEvent.getStartTime()) + && ObjectsCompat.equals(oldEvent.getEndTime(), newEvent.getEndTime()) + && ObjectsCompat.equals(oldEvent.getRoomName(), newEvent.getRoomName()) + && oldItem.isBookmarked() == newItem.isBookmarked(); + } + }; private static final Object DETAILS_PAYLOAD = new Object(); - protected final LayoutInflater inflater; - protected final DateFormat timeDateFormat; + private final DateFormat timeDateFormat; private final boolean showDay; - protected Map roomStatuses; + private Map roomStatuses; public EventsAdapter(Context context, LifecycleOwner owner) { this(context, owner, true); } public EventsAdapter(Context context, LifecycleOwner owner, boolean showDay) { - inflater = LayoutInflater.from(context); + super(DIFF_CALLBACK); timeDateFormat = DateUtils.getTimeDateFormat(context); this.showDay = showDay; - FosdemApi.getRoomStatuses().observe(owner, this); + FosdemApi.getRoomStatuses(context).observe(owner, this); } @Override @@ -63,11 +78,6 @@ public class EventsAdapter extends RecyclerViewCursorAdapter extends RecyclerView.Adapter { - - private Cursor cursor; - private int rowIDColumn = -1; - - public RecyclerViewCursorAdapter() { - setHasStableIds(true); - } - - /** - * Swap in a new Cursor, returning the old Cursor. - * The old cursor is not closed. - * - * @return The previously set Cursor, if any. - * If the given new Cursor is the same instance as the previously set - * Cursor, null is also returned. - */ - public Cursor swapCursor(Cursor newCursor) { - if (newCursor == cursor) { - return null; - } - Cursor oldCursor = cursor; - cursor = newCursor; - rowIDColumn = (newCursor == null) ? -1 : newCursor.getColumnIndexOrThrow("_id"); - notifyDataSetChanged(); - return oldCursor; - } - - public Cursor getCursor() { - return cursor; - } - - @Override - public int getItemCount() { - return (cursor == null) ? 0 : cursor.getCount(); - } - - /** - * @return The cursor initialized to the specified position. - */ - public Object getItem(int position) { - if (cursor != null) { - cursor.moveToPosition(position); - } - return cursor; - } - - @Override - public long getItemId(int position) { - if ((cursor != null) && cursor.moveToPosition(position)) { - return cursor.getLong(rowIDColumn); - } - return RecyclerView.NO_ID; - } - - @Override - public void onBindViewHolder(@NonNull VH holder, int position) { - if (cursor == null) { - throw new IllegalStateException("this should only be called when the cursor is not null"); - } - if (!cursor.moveToPosition(position)) { - throw new IllegalStateException("couldn't move cursor to position " + position); - } - onBindViewHolder(holder, cursor); - } - - public abstract void onBindViewHolder(VH holder, Cursor cursor); -} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/SimpleItemCallback.java b/app/src/main/java/be/digitalia/fosdem/adapters/SimpleItemCallback.java new file mode 100644 index 0000000..e0499b7 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/adapters/SimpleItemCallback.java @@ -0,0 +1,15 @@ +package be.digitalia.fosdem.adapters; + +import androidx.annotation.NonNull; +import androidx.core.util.ObjectsCompat; +import androidx.recyclerview.widget.DiffUtil; + +/** + * Implementation of DiffUtil.ItemCallback which uses Object.equals() to determine if items are the same. + */ +public abstract class SimpleItemCallback extends DiffUtil.ItemCallback { + @Override + public boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem) { + return ObjectsCompat.equals(oldItem, newItem); + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/TrackScheduleAdapter.java b/app/src/main/java/be/digitalia/fosdem/adapters/TrackScheduleAdapter.java index f1af3ee..046d06d 100644 --- a/app/src/main/java/be/digitalia/fosdem/adapters/TrackScheduleAdapter.java +++ b/app/src/main/java/be/digitalia/fosdem/adapters/TrackScheduleAdapter.java @@ -2,7 +2,6 @@ package be.digitalia.fosdem.adapters; import android.content.Context; import android.content.res.TypedArray; -import android.database.Cursor; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.text.TextUtils; @@ -10,46 +9,60 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; - -import java.text.DateFormat; -import java.util.List; - import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.content.ContextCompat; +import androidx.core.util.ObjectsCompat; import androidx.core.view.ViewCompat; import androidx.core.widget.TextViewCompat; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import be.digitalia.fosdem.R; -import be.digitalia.fosdem.db.DatabaseManager; import be.digitalia.fosdem.model.Event; +import be.digitalia.fosdem.model.StatusEvent; import be.digitalia.fosdem.utils.DateUtils; -public class TrackScheduleAdapter extends RecyclerViewCursorAdapter { +import java.text.DateFormat; +import java.util.List; + +public class TrackScheduleAdapter extends ListAdapter { public interface EventClickListener { void onEventClick(int position, Event event); } + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new SimpleItemCallback() { + @Override + public boolean areContentsTheSame(@NonNull StatusEvent oldItem, @NonNull StatusEvent newItem) { + final Event oldEvent = oldItem.getEvent(); + final Event newEvent = newItem.getEvent(); + return ObjectsCompat.equals(oldEvent.getTitle(), newEvent.getTitle()) + && ObjectsCompat.equals(oldEvent.getPersonsSummary(), newEvent.getPersonsSummary()) + && ObjectsCompat.equals(oldEvent.getRoomName(), newEvent.getRoomName()) + && ObjectsCompat.equals(oldEvent.getStartTime(), newEvent.getStartTime()) + && oldItem.isBookmarked() == newItem.isBookmarked(); + } + }; private static final Object TIME_COLORS_PAYLOAD = new Object(); private static final Object SELECTION_PAYLOAD = new Object(); - private final LayoutInflater inflater; - private final DateFormat timeDateFormat; - private final int timeBackgroundColor; - private final int timeForegroundColor; - private final int timeRunningBackgroundColor; - private final int timeRunningForegroundColor; + final DateFormat timeDateFormat; + final int timeBackgroundColor; + final int timeForegroundColor; + final int timeRunningBackgroundColor; + final int timeRunningForegroundColor; @Nullable - private final EventClickListener listener; + final EventClickListener listener; private long currentTime = -1L; private long selectedId = -1L; public TrackScheduleAdapter(Context context, @Nullable EventClickListener listener) { - inflater = LayoutInflater.from(context); + super(DIFF_CALLBACK); + setHasStableIds(true); timeDateFormat = DateUtils.getTimeDateFormat(context); timeBackgroundColor = ContextCompat.getColor(context, R.color.schedule_time_background); timeRunningBackgroundColor = ContextCompat.getColor(context, R.color.schedule_time_running_background); @@ -99,63 +112,24 @@ public class TrackScheduleAdapter extends RecyclerViewCursorAdapter events = new EventsParser().parse(httpResult.inputStream); - int count = dbManager.storeSchedule(events, httpResult.lastModified); + Iterable events = new EventsParser().parse(httpResult.inputStream); + int count = scheduleDao.storeSchedule(events, httpResult.lastModified); res = DownloadScheduleResult.success(count); } finally { try { @@ -106,12 +107,12 @@ public class FosdemApi { } @MainThread - public static LiveData> getRoomStatuses() { + public static LiveData> getRoomStatuses(Context context) { if (roomStatuses == null) { // The room statuses will only be loaded when the event is live. // RoomStatusesLiveData uses the days from the database to determine it. - roomStatuses = new RoomStatusesLiveData(DatabaseManager.getInstance().getDays()); - // Implementors: replace the above live with the next one to disable room status support + roomStatuses = new RoomStatusesLiveData(AppDatabase.getInstance(context).getScheduleDao().getDays()); + // Implementors: replace the above line with the next one to disable room status support // roomStatuses = new MutableLiveData<>(); } return roomStatuses; diff --git a/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.java b/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.java new file mode 100644 index 0000000..6cd3909 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.java @@ -0,0 +1,99 @@ +package be.digitalia.fosdem.db; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.room.Database; +import androidx.room.Room; +import androidx.room.RoomDatabase; +import androidx.room.TypeConverters; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; +import be.digitalia.fosdem.db.converters.GlobalTypeConverters; +import be.digitalia.fosdem.db.entities.Bookmark; +import be.digitalia.fosdem.db.entities.EventEntity; +import be.digitalia.fosdem.db.entities.EventTitles; +import be.digitalia.fosdem.db.entities.EventToPerson; +import be.digitalia.fosdem.model.Day; +import be.digitalia.fosdem.model.Link; +import be.digitalia.fosdem.model.Person; +import be.digitalia.fosdem.model.Track; + +@Database(entities = {EventEntity.class, EventTitles.class, Person.class, EventToPerson.class, Link.class, Track.class, Day.class, Bookmark.class}, version = 2, exportSchema = false) +@TypeConverters({GlobalTypeConverters.class}) +public abstract class AppDatabase extends RoomDatabase { + + private static final String DB_PREFS_FILE = "database"; + private static volatile AppDatabase INSTANCE; + + static final Migration MIGRATION_1_2 = new Migration(1, 2) { + @Override + public void migrate(SupportSQLiteDatabase database) { + // Events: make primary key and track_id not null + database.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);"); + database.execSQL("INSERT INTO tmp_" + EventEntity.TABLE_NAME + " SELECT * FROM " + EventEntity.TABLE_NAME); + database.execSQL("DROP TABLE " + EventEntity.TABLE_NAME); + database.execSQL("ALTER TABLE tmp_" + EventEntity.TABLE_NAME + " RENAME TO " + EventEntity.TABLE_NAME); + database.execSQL("CREATE INDEX event_day_index_idx ON " + EventEntity.TABLE_NAME + " (day_index)"); + database.execSQL("CREATE INDEX event_start_time_idx ON " + EventEntity.TABLE_NAME + " (start_time)"); + database.execSQL("CREATE INDEX event_end_time_idx ON " + EventEntity.TABLE_NAME + " (end_time)"); + database.execSQL("CREATE INDEX event_track_id_idx ON " + EventEntity.TABLE_NAME + " (track_id)"); + + // Links: add explicit primary key + database.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);"); + database.execSQL("INSERT INTO tmp_" + Link.TABLE_NAME + " SELECT `rowid` AS id, event_id, url, description FROM " + Link.TABLE_NAME); + database.execSQL("DROP TABLE " + Link.TABLE_NAME); + database.execSQL("ALTER TABLE tmp_" + Link.TABLE_NAME + " RENAME TO " + Link.TABLE_NAME); + database.execSQL("CREATE INDEX link_event_id_idx ON " + Link.TABLE_NAME + " (event_id)"); + + // Tracks: make primary key not null + database.execSQL("CREATE TABLE tmp_" + Track.TABLE_NAME + " (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL);"); + database.execSQL("INSERT INTO tmp_" + Track.TABLE_NAME + " SELECT * FROM " + Track.TABLE_NAME); + database.execSQL("DROP TABLE " + Track.TABLE_NAME); + database.execSQL("ALTER TABLE tmp_" + Track.TABLE_NAME + " RENAME TO " + Track.TABLE_NAME); + database.execSQL("CREATE UNIQUE INDEX track_main_idx ON " + Track.TABLE_NAME + " (name, type)"); + + // Days: make primary key not null and rename _index to index + database.execSQL("CREATE TABLE tmp_" + Day.TABLE_NAME + " (`index` INTEGER PRIMARY KEY NOT NULL, date INTEGER NOT NULL);"); + database.execSQL("INSERT INTO tmp_" + Day.TABLE_NAME + " SELECT _index as `index`, date FROM " + Day.TABLE_NAME); + database.execSQL("DROP TABLE " + Day.TABLE_NAME); + database.execSQL("ALTER TABLE tmp_" + Day.TABLE_NAME + " RENAME TO " + Day.TABLE_NAME); + + // Bookmarks: make primary key not null + database.execSQL("CREATE TABLE tmp_" + Bookmark.TABLE_NAME + " (event_id INTEGER PRIMARY KEY NOT NULL);"); + database.execSQL("INSERT INTO tmp_" + Bookmark.TABLE_NAME + " SELECT * FROM " + Bookmark.TABLE_NAME); + database.execSQL("DROP TABLE " + Bookmark.TABLE_NAME); + database.execSQL("ALTER TABLE tmp_" + Bookmark.TABLE_NAME + " RENAME TO " + Bookmark.TABLE_NAME); + } + }; + + private SharedPreferences sharedPreferences; + + public SharedPreferences getSharedPreferences() { + return sharedPreferences; + } + + public static AppDatabase getInstance(Context context) { + AppDatabase res = INSTANCE; + if (res == null) { + synchronized (AppDatabase.class) { + res = INSTANCE; + if (res == null) { + res = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "fosdem.sqlite") + .addMigrations(MIGRATION_1_2) + .setJournalMode(JournalMode.TRUNCATE) + .build(); + res.sharedPreferences = context.getApplicationContext().getSharedPreferences(DB_PREFS_FILE, Context.MODE_PRIVATE); + INSTANCE = res; + } + } + } + return res; + } + + public abstract ScheduleDao getScheduleDao(); + + public abstract BookmarksDao getBookmarksDao(); +} diff --git a/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.java b/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.java new file mode 100644 index 0000000..773c626 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.java @@ -0,0 +1,86 @@ +package be.digitalia.fosdem.db; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.room.Dao; +import androidx.room.Delete; +import androidx.room.Insert; +import androidx.room.Query; +import be.digitalia.fosdem.alarms.FosdemAlarmManager; +import be.digitalia.fosdem.db.entities.Bookmark; +import be.digitalia.fosdem.model.AlarmInfo; +import be.digitalia.fosdem.model.Event; + +import java.util.List; + +@Dao +public abstract class BookmarksDao { + + /** + * Returns the bookmarks. + * + * @param minStartTime When greater than 0, only return the events starting after this time. + */ + @Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description" + + ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type" + + " FROM bookmarks b" + + " JOIN events e ON b.event_id = e.id" + + " JOIN events_titles et ON e.id = et.`rowid`" + + " JOIN days d ON e.day_index = d.`index`" + + " JOIN tracks t ON e.track_id = t.id" + + " LEFT JOIN events_persons ep ON e.id = ep.event_id" + + " LEFT JOIN persons p ON ep.person_id = p.`rowid`" + + " WHERE e.start_time > :minStartTime" + + " GROUP BY e.id" + + " ORDER BY e.start_time ASC") + public abstract LiveData> getBookmarks(long minStartTime); + + @Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description" + + ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type" + + " FROM bookmarks b" + + " JOIN events e ON b.event_id = e.id" + + " JOIN events_titles et ON e.id = et.`rowid`" + + " JOIN days d ON e.day_index = d.`index`" + + " JOIN tracks t ON e.track_id = t.id" + + " LEFT JOIN events_persons ep ON e.id = ep.event_id" + + " LEFT JOIN persons p ON ep.person_id = p.`rowid`" + + " GROUP BY e.id" + + " ORDER BY e.start_time ASC") + @WorkerThread + public abstract Event[] getBookmarks(); + + @Query("SELECT b.event_id, e.start_time" + + " FROM bookmarks b" + + " JOIN events e ON b.event_id = e.id" + + " WHERE e.start_time > :minStartTime" + + " ORDER BY e.start_time ASC") + @WorkerThread + public abstract AlarmInfo[] getBookmarksAlarmInfo(long minStartTime); + + @Query("SELECT COUNT(*) FROM bookmarks WHERE event_id = :event") + public abstract LiveData getBookmarkStatus(Event event); + + public void addBookmark(@NonNull Event event) { + if (addBookmarkInternal(new Bookmark(event.getId())) != -1L) { + FosdemAlarmManager.getInstance().onBookmarkAdded(event); + } + } + + @Insert + protected abstract long addBookmarkInternal(Bookmark bookmark); + + @Delete + public void removeBookmark(@NonNull Event event) { + removeBookmarks(event.getId()); + } + + public void removeBookmarks(@NonNull long... eventIds) { + if (removeBookmarksInternal(eventIds) > 0) { + FosdemAlarmManager.getInstance().onBookmarksRemoved(eventIds); + } + } + + @Query("DELETE FROM bookmarks WHERE event_id IN (:eventIds)") + protected abstract int removeBookmarksInternal(long[] eventIds); +} diff --git a/app/src/main/java/be/digitalia/fosdem/db/DatabaseHelper.java b/app/src/main/java/be/digitalia/fosdem/db/DatabaseHelper.java deleted file mode 100644 index 843ed74..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/DatabaseHelper.java +++ /dev/null @@ -1,65 +0,0 @@ -package be.digitalia.fosdem.db; - -import android.content.Context; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; - -public class DatabaseHelper extends SQLiteOpenHelper { - - private static final String DATABASE_NAME = "fosdem.sqlite"; - private static final int DATABASE_VERSION = 1; - - public static final String EVENTS_TABLE_NAME = "events"; - public static final String EVENTS_TITLES_TABLE_NAME = "events_titles"; - public static final String PERSONS_TABLE_NAME = "persons"; - public static final String EVENTS_PERSONS_TABLE_NAME = "events_persons"; - public static final String LINKS_TABLE_NAME = "links"; - public static final String TRACKS_TABLE_NAME = "tracks"; - public static final String DAYS_TABLE_NAME = "days"; - public static final String BOOKMARKS_TABLE_NAME = "bookmarks"; - - public DatabaseHelper(Context context) { - super(context, DATABASE_NAME, null, DATABASE_VERSION); - } - - @Override - public void onCreate(SQLiteDatabase database) { - // Events - database.execSQL("CREATE TABLE " - + EVENTS_TABLE_NAME - + " (id INTEGER PRIMARY KEY, day_index INTEGER NOT NULL, start_time INTEGER, end_time INTEGER, room_name TEXT, slug TEXT, track_id INTEGER, abstract TEXT, description TEXT);"); - database.execSQL("CREATE INDEX event_day_index_idx ON " + EVENTS_TABLE_NAME + " (day_index)"); - database.execSQL("CREATE INDEX event_start_time_idx ON " + EVENTS_TABLE_NAME + " (start_time)"); - database.execSQL("CREATE INDEX event_end_time_idx ON " + EVENTS_TABLE_NAME + " (end_time)"); - database.execSQL("CREATE INDEX event_track_id_idx ON " + EVENTS_TABLE_NAME + " (track_id)"); - // Secondary table with fulltext index on the titles - database.execSQL("CREATE VIRTUAL TABLE " + EVENTS_TITLES_TABLE_NAME + " USING fts3(title TEXT, subtitle TEXT);"); - - // Persons - database.execSQL("CREATE VIRTUAL TABLE " + PERSONS_TABLE_NAME + " USING fts3(name TEXT);"); - - // Events-to-Persons - database.execSQL("CREATE TABLE " + EVENTS_PERSONS_TABLE_NAME - + " (event_id INTEGER NOT NULL, person_id INTEGER NOT NULL, PRIMARY KEY(event_id, person_id));"); - database.execSQL("CREATE INDEX event_person_person_id_idx ON " + EVENTS_PERSONS_TABLE_NAME + " (person_id)"); - - // Links - database.execSQL("CREATE TABLE " + LINKS_TABLE_NAME + " (event_id INTEGER NOT NULL, url TEXT NOT NULL, description TEXT);"); - database.execSQL("CREATE INDEX link_event_id_idx ON " + LINKS_TABLE_NAME + " (event_id)"); - - // Tracks - database.execSQL("CREATE TABLE " + TRACKS_TABLE_NAME + " (id INTEGER PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL);"); - database.execSQL("CREATE UNIQUE INDEX track_main_idx ON " + TRACKS_TABLE_NAME + " (name, type)"); - - // Days - database.execSQL("CREATE TABLE " + DAYS_TABLE_NAME + " (_index INTEGER PRIMARY KEY, date INTEGER NOT NULL);"); - - // Bookmarks - database.execSQL("CREATE TABLE " + BOOKMARKS_TABLE_NAME + " (event_id INTEGER PRIMARY KEY);"); - } - - @Override - public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) { - // Nothing to upgrade yet - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/DatabaseManager.java b/app/src/main/java/be/digitalia/fosdem/db/DatabaseManager.java deleted file mode 100644 index 35735df..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/DatabaseManager.java +++ /dev/null @@ -1,872 +0,0 @@ -package be.digitalia.fosdem.db; - -import android.app.SearchManager; -import android.content.ContentValues; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.database.Cursor; -import android.database.DatabaseUtils; -import android.database.sqlite.SQLiteConstraintException; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteStatement; -import android.provider.BaseColumns; -import android.text.TextUtils; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; -import androidx.lifecycle.LiveData; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import be.digitalia.fosdem.BuildConfig; -import be.digitalia.fosdem.livedata.AsyncTaskLiveData; -import be.digitalia.fosdem.model.Day; -import be.digitalia.fosdem.model.Event; -import be.digitalia.fosdem.model.Link; -import be.digitalia.fosdem.model.Person; -import be.digitalia.fosdem.model.Track; -import be.digitalia.fosdem.utils.DateUtils; - -/** - * Here comes the badass SQL. - * - * @author Christophe Beyls - */ -public class DatabaseManager { - - public static final String ACTION_SCHEDULE_REFRESHED = BuildConfig.APPLICATION_ID + ".action.SCHEDULE_REFRESHED"; - public static final String ACTION_ADD_BOOKMARK = BuildConfig.APPLICATION_ID + ".action.ADD_BOOKMARK"; - public static final String EXTRA_EVENT_ID = "event_id"; - public static final String EXTRA_EVENT_START_TIME = "event_start"; - public static final String ACTION_REMOVE_BOOKMARKS = BuildConfig.APPLICATION_ID + ".action.REMOVE_BOOKMARKS"; - public static final String EXTRA_EVENT_IDS = "event_ids"; - - private static final String DB_PREFS_FILE = "database"; - private static final String LAST_UPDATE_TIME_PREF = "last_update_time"; - private static final String LAST_MODIFIED_TAG_PREF = "last_modified_tag"; - - private static DatabaseManager instance; - - private final Context context; - private final DatabaseHelper helper; - - public static void init(Context context) { - if (instance == null) { - instance = new DatabaseManager(context); - } - } - - public static DatabaseManager getInstance() { - return instance; - } - - private DatabaseManager(Context context) { - this.context = context; - helper = new DatabaseHelper(context); - } - - private static final String TRACK_INSERT_STATEMENT = "INSERT INTO " + DatabaseHelper.TRACKS_TABLE_NAME + " (id, name, type) VALUES (?, ?, ?);"; - private static final String EVENT_INSERT_STATEMENT = "INSERT INTO " + DatabaseHelper.EVENTS_TABLE_NAME - + " (id, day_index, start_time, end_time, room_name, slug, track_id, abstract, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);"; - private static final String EVENT_TITLES_INSERT_STATEMENT = "INSERT INTO " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME - + " (rowid, title, subtitle) VALUES (?, ?, ?);"; - private static final String EVENT_PERSON_INSERT_STATEMENT = "INSERT INTO " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME - + " (event_id, person_id) VALUES (?, ?);"; - // Ignore conflicts in case of existing person - private static final String PERSON_INSERT_STATEMENT = "INSERT OR IGNORE INTO " + DatabaseHelper.PERSONS_TABLE_NAME + " (rowid, name) VALUES (?, ?);"; - private static final String LINK_INSERT_STATEMENT = "INSERT INTO " + DatabaseHelper.LINKS_TABLE_NAME + " (event_id, url, description) VALUES (?, ?, ?);"; - - private static void bindString(SQLiteStatement statement, int index, String value) { - if (value == null) { - statement.bindNull(index); - } else { - statement.bindString(index, value); - } - } - - private SharedPreferences getSharedPreferences() { - return context.getSharedPreferences(DB_PREFS_FILE, Context.MODE_PRIVATE); - } - - /** - * @return The last update time in milliseconds since EPOCH, or -1 if not available. - */ - public long getLastUpdateTime() { - return getSharedPreferences().getLong(LAST_UPDATE_TIME_PREF, -1L); - } - - /** - * @return The time identifier of the current version of the database. - */ - public String getLastModifiedTag() { - return getSharedPreferences().getString(LAST_MODIFIED_TAG_PREF, null); - } - - /** - * Stores the schedule to the database. - * - * @param events - * @return The number of events processed. - */ - @WorkerThread - public int storeSchedule(Iterable events, String lastModifiedTag) { - boolean isComplete = false; - List daysList = null; - - SQLiteDatabase db = helper.getWritableDatabase(); - db.beginTransaction(); - try { - // 1: Delete the previous schedule - clearSchedule(db); - - // Compile the insert statements for the big tables - final SQLiteStatement trackInsertStatement = db.compileStatement(TRACK_INSERT_STATEMENT); - final SQLiteStatement eventInsertStatement = db.compileStatement(EVENT_INSERT_STATEMENT); - final SQLiteStatement eventTitlesInsertStatement = db.compileStatement(EVENT_TITLES_INSERT_STATEMENT); - final SQLiteStatement eventPersonInsertStatement = db.compileStatement(EVENT_PERSON_INSERT_STATEMENT); - final SQLiteStatement personInsertStatement = db.compileStatement(PERSON_INSERT_STATEMENT); - final SQLiteStatement linkInsertStatement = db.compileStatement(LINK_INSERT_STATEMENT); - - // 2: Insert the events - int totalEvents = 0; - Map tracks = new HashMap<>(); - long nextTrackId = 0L; - long minEventId = Long.MAX_VALUE; - Set days = new HashSet<>(2); - - for (Event event : events) { - // 2a: Retrieve or insert Track - Track track = event.getTrack(); - Long trackId = tracks.get(track); - if (trackId == null) { - // New track - nextTrackId++; - trackId = nextTrackId; - trackInsertStatement.clearBindings(); - trackInsertStatement.bindLong(1, nextTrackId); - bindString(trackInsertStatement, 2, track.getName()); - bindString(trackInsertStatement, 3, track.getType().name()); - if (trackInsertStatement.executeInsert() != -1L) { - tracks.put(track, trackId); - } - } - - // 2b: Insert main event - eventInsertStatement.clearBindings(); - long eventId = event.getId(); - if (eventId < minEventId) { - minEventId = eventId; - } - eventInsertStatement.bindLong(1, eventId); - Day day = event.getDay(); - days.add(day); - eventInsertStatement.bindLong(2, day.getIndex()); - Date time = event.getStartTime(); - if (time == null) { - eventInsertStatement.bindNull(3); - } else { - eventInsertStatement.bindLong(3, time.getTime()); - } - time = event.getEndTime(); - if (time == null) { - eventInsertStatement.bindNull(4); - } else { - eventInsertStatement.bindLong(4, time.getTime()); - } - bindString(eventInsertStatement, 5, event.getRoomName()); - bindString(eventInsertStatement, 6, event.getSlug()); - eventInsertStatement.bindLong(7, trackId); - bindString(eventInsertStatement, 8, event.getAbstractText()); - bindString(eventInsertStatement, 9, event.getDescription()); - - if (eventInsertStatement.executeInsert() != -1L) { - // 2c: Insert fulltext fields - eventTitlesInsertStatement.clearBindings(); - eventTitlesInsertStatement.bindLong(1, eventId); - bindString(eventTitlesInsertStatement, 2, event.getTitle()); - bindString(eventTitlesInsertStatement, 3, event.getSubTitle()); - eventTitlesInsertStatement.executeInsert(); - - // 2d: Insert persons - for (Person person : event.getPersons()) { - eventPersonInsertStatement.clearBindings(); - eventPersonInsertStatement.bindLong(1, eventId); - long personId = person.getId(); - eventPersonInsertStatement.bindLong(2, personId); - eventPersonInsertStatement.executeInsert(); - - personInsertStatement.clearBindings(); - personInsertStatement.bindLong(1, personId); - bindString(personInsertStatement, 2, person.getName()); - try { - personInsertStatement.executeInsert(); - } catch (SQLiteConstraintException e) { - // Older Android versions may not ignore an existing person - } - } - - // 2e: Insert links - for (Link link : event.getLinks()) { - linkInsertStatement.clearBindings(); - linkInsertStatement.bindLong(1, eventId); - bindString(linkInsertStatement, 2, link.getUrl()); - bindString(linkInsertStatement, 3, link.getDescription()); - linkInsertStatement.executeInsert(); - } - } - - totalEvents++; - } - - // 3: Insert collected days - ContentValues values = new ContentValues(); - for (Day day : days) { - values.clear(); - values.put("_index", day.getIndex()); - Date date = day.getDate(); - values.put("date", (date == null) ? 0L : date.getTime()); - db.insert(DatabaseHelper.DAYS_TABLE_NAME, null, values); - } - daysList = new ArrayList<>(days); - Collections.sort(daysList); - - // 4: Purge outdated bookmarks - if (minEventId < Long.MAX_VALUE) { - String[] whereArgs = new String[]{String.valueOf(minEventId)}; - db.delete(DatabaseHelper.BOOKMARKS_TABLE_NAME, "event_id < ?", whereArgs); - } - - if (totalEvents > 0) { - db.setTransactionSuccessful(); - isComplete = true; - } - - return totalEvents; - } finally { - db.endTransaction(); - - if (isComplete) { - // Update/clear cache - daysLiveData.postValue(daysList); - // Set last update time and server's last modified tag - getSharedPreferences().edit() - .putLong(LAST_UPDATE_TIME_PREF, System.currentTimeMillis()) - .putString(LAST_MODIFIED_TAG_PREF, lastModifiedTag) - .apply(); - - LocalBroadcastManager.getInstance(context).sendBroadcast(new Intent(ACTION_SCHEDULE_REFRESHED)); - } - } - } - - @WorkerThread - public void clearSchedule() { - SQLiteDatabase db = helper.getWritableDatabase(); - db.beginTransaction(); - try { - clearSchedule(db); - - db.setTransactionSuccessful(); - - daysLiveData.postValue(Collections.emptyList()); - getSharedPreferences().edit() - .remove(LAST_UPDATE_TIME_PREF) - .apply(); - } finally { - db.endTransaction(); - - LocalBroadcastManager.getInstance(context).sendBroadcast(new Intent(ACTION_SCHEDULE_REFRESHED)); - } - } - - private static void clearSchedule(SQLiteDatabase db) { - db.delete(DatabaseHelper.EVENTS_TABLE_NAME, null, null); - db.delete(DatabaseHelper.EVENTS_TITLES_TABLE_NAME, null, null); - db.delete(DatabaseHelper.PERSONS_TABLE_NAME, null, null); - db.delete(DatabaseHelper.EVENTS_PERSONS_TABLE_NAME, null, null); - db.delete(DatabaseHelper.LINKS_TABLE_NAME, null, null); - db.delete(DatabaseHelper.TRACKS_TABLE_NAME, null, null); - db.delete(DatabaseHelper.DAYS_TABLE_NAME, null, null); - } - - private final AsyncTaskLiveData> daysLiveData = new AsyncTaskLiveData>() { - - { - onContentChanged(); - } - - @Override - protected List loadInBackground() throws Exception { - Cursor cursor = helper.getReadableDatabase().query(DatabaseHelper.DAYS_TABLE_NAME, - new String[]{"_index", "date"}, null, null, null, null, "_index ASC"); - try { - List result = new ArrayList<>(cursor.getCount()); - while (cursor.moveToNext()) { - Day day = new Day(); - day.setIndex(cursor.getInt(0)); - day.setDate(new Date(cursor.getLong(1))); - result.add(day); - } - return result; - } finally { - cursor.close(); - } - } - }; - - /** - * @return The Days the events span to. - */ - public LiveData> getDays() { - return daysLiveData; - } - - @WorkerThread - public int getYear() { - // Use the current year by default - long date = System.currentTimeMillis(); - - // Compute from cached days if available - List days = daysLiveData.getValue(); - if (days != null) { - if (days.size() > 0) { - date = days.get(0).getDate().getTime(); - } - } else { - // Perform a quick DB query to retrieve the time of the first day - try { - date = DatabaseUtils.longForQuery(helper.getReadableDatabase(), - "SELECT date FROM " + DatabaseHelper.DAYS_TABLE_NAME + " ORDER BY _index ASC LIMIT 1", null); - } catch (Exception ignore) { - } - } - - return DateUtils.getYear(date); - } - - @WorkerThread - public Cursor getTracks(Day day) { - String[] selectionArgs = new String[]{String.valueOf(day.getIndex())}; - Cursor cursor = helper.getReadableDatabase().rawQuery( - "SELECT t.id AS _id, t.name, t.type" + " FROM " + DatabaseHelper.TRACKS_TABLE_NAME + " t" - + " JOIN " + DatabaseHelper.EVENTS_TABLE_NAME + " e ON t.id = e.track_id" - + " WHERE e.day_index = ?" - + " GROUP BY t.id" - + " ORDER BY t.name ASC", selectionArgs); - return new LocalBroadcastCursor(cursor, context, new IntentFilter(ACTION_SCHEDULE_REFRESHED)); - } - - public static Track toTrack(Cursor cursor, Track track) { - if (track == null) { - track = new Track(); - } - track.setName(cursor.getString(1)); - track.setType(Enum.valueOf(Track.Type.class, cursor.getString(2))); - - return track; - } - - public static Track toTrack(Cursor cursor) { - return toTrack(cursor, null); - } - - @WorkerThread - public long getEventsCount() { - return DatabaseUtils.queryNumEntries(helper.getReadableDatabase(), DatabaseHelper.EVENTS_TABLE_NAME, null, null); - } - - /** - * Returns the event with the specified id, or null if not found. - */ - @WorkerThread - @Nullable - public Event getEvent(long id) { - String[] selectionArgs = new String[]{String.valueOf(id)}; - Cursor cursor = helper.getReadableDatabase().rawQuery( - "SELECT e.id AS _id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description, GROUP_CONCAT(p.name, ', '), e.day_index, d.date, t.name, t.type" - + " FROM " + DatabaseHelper.EVENTS_TABLE_NAME + " e" - + " JOIN " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME + " et ON e.id = et.rowid" - + " JOIN " + DatabaseHelper.DAYS_TABLE_NAME + " d ON e.day_index = d._index" - + " JOIN " + DatabaseHelper.TRACKS_TABLE_NAME + " t ON e.track_id = t.id" - + " LEFT JOIN " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep ON e.id = ep.event_id" - + " LEFT JOIN " + DatabaseHelper.PERSONS_TABLE_NAME + " p ON ep.person_id = p.rowid" - + " WHERE e.id = ?" - + " GROUP BY e.id", selectionArgs); - try { - if (cursor.moveToFirst()) { - return toEvent(cursor); - } else { - return null; - } - } finally { - cursor.close(); - } - } - - private Cursor toEventCursor(Cursor wrappedCursor) { - IntentFilter intentFilter = new IntentFilter(ACTION_SCHEDULE_REFRESHED); - intentFilter.addAction(ACTION_ADD_BOOKMARK); - intentFilter.addAction(ACTION_REMOVE_BOOKMARKS); - return new LocalBroadcastCursor(wrappedCursor, context, intentFilter); - } - - /** - * Returns the events for a specified track. - * - * @param day - * @param track - * @return A cursor to Events - */ - @WorkerThread - public Cursor getEvents(Day day, Track track) { - String[] selectionArgs = new String[]{String.valueOf(day.getIndex()), track.getName(), track.getType().name()}; - Cursor cursor = helper.getReadableDatabase().rawQuery( - "SELECT e.id AS _id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description, GROUP_CONCAT(p.name, ', '), e.day_index, d.date, t.name, t.type, b.event_id" - + " FROM " + DatabaseHelper.EVENTS_TABLE_NAME + " e" - + " JOIN " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME + " et ON e.id = et.rowid" - + " JOIN " + DatabaseHelper.DAYS_TABLE_NAME + " d ON e.day_index = d._index" - + " JOIN " + DatabaseHelper.TRACKS_TABLE_NAME + " t ON e.track_id = t.id" - + " LEFT JOIN " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep ON e.id = ep.event_id" - + " LEFT JOIN " + DatabaseHelper.PERSONS_TABLE_NAME + " p ON ep.person_id = p.rowid" - + " LEFT JOIN " + DatabaseHelper.BOOKMARKS_TABLE_NAME + " b ON e.id = b.event_id" - + " WHERE e.day_index = ? AND t.name = ? AND t.type = ?" - + " GROUP BY e.id" - + " ORDER BY e.start_time ASC", selectionArgs); - return toEventCursor(cursor); - } - - /** - * Returns the events in the specified time window, ordered by start time. All parameters are optional but at least one must be provided. - * - * @param minStartTime Minimum start time, or -1 - * @param maxStartTime Maximum start time, or -1 - * @param minEndTime Minimum end time, or -1 - * @param ascending If true, order results from start time ascending, else order from start time descending - * @return - */ - @WorkerThread - public Cursor getEvents(long minStartTime, long maxStartTime, long minEndTime, boolean ascending) { - ArrayList selectionArgs = new ArrayList<>(3); - StringBuilder whereCondition = new StringBuilder(); - - if (minStartTime > 0L) { - whereCondition.append("e.start_time > ?"); - selectionArgs.add(String.valueOf(minStartTime)); - } - if (maxStartTime > 0L) { - if (whereCondition.length() > 0) { - whereCondition.append(" AND "); - } - whereCondition.append("e.start_time < ?"); - selectionArgs.add(String.valueOf(maxStartTime)); - } - if (minEndTime > 0L) { - if (whereCondition.length() > 0) { - whereCondition.append(" AND "); - } - whereCondition.append("e.end_time > ?"); - selectionArgs.add(String.valueOf(minEndTime)); - } - if (whereCondition.length() == 0) { - throw new IllegalArgumentException("At least one filter must be provided"); - } - String ascendingString = ascending ? "ASC" : "DESC"; - - Cursor cursor = helper.getReadableDatabase().rawQuery( - "SELECT e.id AS _id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description, GROUP_CONCAT(p.name, ', '), e.day_index, d.date, t.name, t.type, b.event_id" - + " FROM " + DatabaseHelper.EVENTS_TABLE_NAME + " e" - + " JOIN " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME + " et ON e.id = et.rowid" - + " JOIN " + DatabaseHelper.DAYS_TABLE_NAME + " d ON e.day_index = d._index" - + " JOIN " + DatabaseHelper.TRACKS_TABLE_NAME + " t ON e.track_id = t.id" - + " LEFT JOIN " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep ON e.id = ep.event_id" - + " LEFT JOIN " + DatabaseHelper.PERSONS_TABLE_NAME + " p ON ep.person_id = p.rowid" - + " LEFT JOIN " + DatabaseHelper.BOOKMARKS_TABLE_NAME + " b ON e.id = b.event_id" - + " WHERE " + whereCondition.toString() - + " GROUP BY e.id" - + " ORDER BY e.start_time " + ascendingString, - selectionArgs.toArray(new String[selectionArgs.size()])); - return toEventCursor(cursor); - } - - /** - * Returns the events presented by the specified person. - * - * @param person - * @return A cursor to Events - */ - @WorkerThread - public Cursor getEvents(Person person) { - String[] selectionArgs = new String[]{String.valueOf(person.getId())}; - Cursor cursor = helper.getReadableDatabase().rawQuery( - "SELECT e.id AS _id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description, GROUP_CONCAT(p.name, ', '), e.day_index, d.date, t.name, t.type, b.event_id" - + " FROM " + DatabaseHelper.EVENTS_TABLE_NAME + " e" - + " JOIN " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME + " et ON e.id = et.rowid" - + " JOIN " + DatabaseHelper.DAYS_TABLE_NAME + " d ON e.day_index = d._index" - + " JOIN " + DatabaseHelper.TRACKS_TABLE_NAME + " t ON e.track_id = t.id" - + " LEFT JOIN " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep ON e.id = ep.event_id" - + " LEFT JOIN " + DatabaseHelper.PERSONS_TABLE_NAME + " p ON ep.person_id = p.rowid" - + " LEFT JOIN " + DatabaseHelper.BOOKMARKS_TABLE_NAME + " b ON e.id = b.event_id" - + " JOIN " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep2 ON e.id = ep2.event_id" - + " WHERE ep2.person_id = ?" - + " GROUP BY e.id" - + " ORDER BY e.start_time ASC", selectionArgs); - return toEventCursor(cursor); - } - - /** - * Returns the bookmarks. - * - * @param minStartTime When positive, only return the events starting after this time. - * @return A cursor to Events - */ - @WorkerThread - public Cursor getBookmarks(long minStartTime) { - String whereCondition; - String[] selectionArgs; - if (minStartTime > 0L) { - whereCondition = " WHERE e.start_time > ?"; - selectionArgs = new String[]{String.valueOf(minStartTime)}; - } else { - whereCondition = ""; - selectionArgs = null; - } - - Cursor cursor = helper.getReadableDatabase().rawQuery( - "SELECT e.id AS _id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description, GROUP_CONCAT(p.name, ', '), e.day_index, d.date, t.name, t.type, 1" - + " FROM " + DatabaseHelper.BOOKMARKS_TABLE_NAME + " b" - + " JOIN " + DatabaseHelper.EVENTS_TABLE_NAME + " e ON b.event_id = e.id" - + " JOIN " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME + " et ON e.id = et.rowid" - + " JOIN " + DatabaseHelper.DAYS_TABLE_NAME + " d ON e.day_index = d._index" - + " JOIN " + DatabaseHelper.TRACKS_TABLE_NAME + " t ON e.track_id = t.id" - + " LEFT JOIN " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep ON e.id = ep.event_id" - + " LEFT JOIN " + DatabaseHelper.PERSONS_TABLE_NAME + " p ON ep.person_id = p.rowid" - + whereCondition - + " GROUP BY e.id" - + " ORDER BY e.start_time ASC", selectionArgs); - return toEventCursor(cursor); - } - - /** - * Search through matching titles, subtitles, track names, person names. We need to use an union of 3 sub-queries because a "match" condition can not be - * accompanied by other conditions in a "where" statement. - * - * @param query - * @return A cursor to Events - */ - @WorkerThread - public Cursor getSearchResults(String query) { - final String matchQuery = query + "*"; - String[] selectionArgs = new String[]{matchQuery, "%" + query + "%", matchQuery}; - Cursor cursor = helper.getReadableDatabase().rawQuery( - "SELECT e.id AS _id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description, GROUP_CONCAT(p.name, ', '), e.day_index, d.date, t.name, t.type, b.event_id" - + " FROM " + DatabaseHelper.EVENTS_TABLE_NAME + " e" - + " JOIN " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME + " et ON e.id = et.rowid" - + " JOIN " + DatabaseHelper.DAYS_TABLE_NAME + " d ON e.day_index = d._index" - + " JOIN " + DatabaseHelper.TRACKS_TABLE_NAME + " t ON e.track_id = t.id" - + " LEFT JOIN " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep ON e.id = ep.event_id" - + " LEFT JOIN " + DatabaseHelper.PERSONS_TABLE_NAME + " p ON ep.person_id = p.rowid" - + " LEFT JOIN " + DatabaseHelper.BOOKMARKS_TABLE_NAME + " b ON e.id = b.event_id" - + " WHERE e.id IN ( " - + "SELECT rowid" - + " FROM " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME - + " WHERE " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME + " MATCH ?" - + " UNION " - + "SELECT e.id" - + " FROM " + DatabaseHelper.EVENTS_TABLE_NAME + " e" - + " JOIN " + DatabaseHelper.TRACKS_TABLE_NAME + " t ON e.track_id = t.id" - + " WHERE t.name LIKE ?" - + " UNION " - + "SELECT ep.event_id" - + " FROM " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep" - + " JOIN " + DatabaseHelper.PERSONS_TABLE_NAME + " p ON ep.person_id = p.rowid" - + " WHERE p.name MATCH ?" - + " )" - + " GROUP BY e.id" - + " ORDER BY e.start_time ASC", selectionArgs); - return toEventCursor(cursor); - } - - /** - * Method called by SearchSuggestionProvider to return search results in the format expected by the search framework. - */ - @WorkerThread - public Cursor getSearchSuggestionResults(String query, int limit) { - final String matchQuery = query + "*"; - String[] selectionArgs = new String[]{matchQuery, "%" + query + "%", matchQuery, String.valueOf(limit)}; - // Query is similar to getSearchResults but returns different columns, does not join the Day table or the Bookmark table and limits the result set. - return helper.getReadableDatabase().rawQuery( - "SELECT e.id AS " + BaseColumns._ID - + ", et.title AS " + SearchManager.SUGGEST_COLUMN_TEXT_1 - + ", IFNULL(GROUP_CONCAT(p.name, ', '), '') || ' - ' || t.name AS " + SearchManager.SUGGEST_COLUMN_TEXT_2 - + ", e.id AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA - + " FROM " + DatabaseHelper.EVENTS_TABLE_NAME + " e" - + " JOIN " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME + " et ON e.id = et.rowid" - + " JOIN " + DatabaseHelper.TRACKS_TABLE_NAME + " t ON e.track_id = t.id" - + " LEFT JOIN " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep ON e.id = ep.event_id" - + " LEFT JOIN " + DatabaseHelper.PERSONS_TABLE_NAME + " p ON ep.person_id = p.rowid" - + " WHERE e.id IN ( " - + "SELECT rowid" - + " FROM " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME - + " WHERE " + DatabaseHelper.EVENTS_TITLES_TABLE_NAME + " MATCH ?" - + " UNION " - + "SELECT e.id" - + " FROM " + DatabaseHelper.EVENTS_TABLE_NAME + " e" - + " JOIN " + DatabaseHelper.TRACKS_TABLE_NAME + " t ON e.track_id = t.id" - + " WHERE t.name LIKE ?" - + " UNION " - + "SELECT ep.event_id" - + " FROM " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep" - + " JOIN " + DatabaseHelper.PERSONS_TABLE_NAME + " p ON ep.person_id = p.rowid" - + " WHERE p.name MATCH ?" - + " )" - + " GROUP BY e.id" - + " ORDER BY e.start_time ASC LIMIT ?", selectionArgs); - } - - public static Event toEvent(Cursor cursor, Event event) { - Day day; - Track track; - Date startTime; - Date endTime; - if (event == null) { - event = new Event(); - day = new Day(); - event.setDay(day); - track = new Track(); - event.setTrack(track); - - startTime = null; - endTime = null; - - day.setDate(new Date(cursor.getLong(11))); - } else { - day = event.getDay(); - track = event.getTrack(); - - startTime = event.getStartTime(); - endTime = event.getEndTime(); - - day.getDate().setTime(cursor.getLong(11)); - } - event.setId(cursor.getLong(0)); - if (cursor.isNull(1)) { - event.setStartTime(null); - } else { - if (startTime == null) { - event.setStartTime(new Date(cursor.getLong(1))); - } else { - startTime.setTime(cursor.getLong(1)); - } - } - if (cursor.isNull(2)) { - event.setEndTime(null); - } else { - if (endTime == null) { - event.setEndTime(new Date(cursor.getLong(2))); - } else { - endTime.setTime(cursor.getLong(2)); - } - } - - event.setRoomName(cursor.getString(3)); - event.setSlug(cursor.getString(4)); - event.setTitle(cursor.getString(5)); - event.setSubTitle(cursor.getString(6)); - event.setAbstractText(cursor.getString(7)); - event.setDescription(cursor.getString(8)); - event.setPersonsSummary(cursor.getString(9)); - - day.setIndex(cursor.getInt(10)); - - track.setName(cursor.getString(12)); - track.setType(Enum.valueOf(Track.Type.class, cursor.getString(13))); - - return event; - } - - public static Event toEvent(Cursor cursor) { - return toEvent(cursor, null); - } - - public static long toEventId(Cursor cursor) { - return cursor.getLong(0); - } - - public static long toEventStartTimeMillis(Cursor cursor) { - return cursor.isNull(1) ? -1L : cursor.getLong(1); - } - - public static long toEventEndTimeMillis(Cursor cursor) { - return cursor.isNull(2) ? -1L : cursor.getLong(2); - } - - public static boolean toBookmarkStatus(Cursor cursor) { - return !cursor.isNull(14); - } - - /** - * Returns all persons in alphabetical order. - */ - @WorkerThread - public Cursor getPersons() { - Cursor cursor = helper.getReadableDatabase().rawQuery( - "SELECT rowid AS _id, name" - + " FROM " + DatabaseHelper.PERSONS_TABLE_NAME - + " ORDER BY name COLLATE NOCASE", null); - return new LocalBroadcastCursor(cursor, context, new IntentFilter(ACTION_SCHEDULE_REFRESHED)); - } - - public static final int PERSON_NAME_COLUMN_INDEX = 1; - - /** - * Returns persons presenting the specified event. - */ - @WorkerThread - public List getPersons(Event event) { - String[] selectionArgs = new String[]{String.valueOf(event.getId())}; - Cursor cursor = helper.getReadableDatabase().rawQuery( - "SELECT p.rowid AS _id, p.name" - + " FROM " + DatabaseHelper.PERSONS_TABLE_NAME + " p" - + " JOIN " + DatabaseHelper.EVENTS_PERSONS_TABLE_NAME + " ep ON p.rowid = ep.person_id" - + " WHERE ep.event_id = ?", selectionArgs); - try { - List result = new ArrayList<>(cursor.getCount()); - while (cursor.moveToNext()) { - result.add(toPerson(cursor)); - } - return result; - } finally { - cursor.close(); - } - } - - public static Person toPerson(Cursor cursor, Person person) { - if (person == null) { - person = new Person(); - } - person.setId(cursor.getLong(0)); - person.setName(cursor.getString(1)); - - return person; - } - - public static Person toPerson(Cursor cursor) { - return toPerson(cursor, null); - } - - @WorkerThread - public List getLinks(Event event) { - String[] selectionArgs = new String[]{String.valueOf(event.getId())}; - Cursor cursor = helper.getReadableDatabase().rawQuery( - "SELECT url, description" - + " FROM " + DatabaseHelper.LINKS_TABLE_NAME - + " WHERE event_id = ?" - + " ORDER BY rowid ASC", selectionArgs); - try { - List result = new ArrayList<>(cursor.getCount()); - while (cursor.moveToNext()) { - Link link = new Link(); - link.setUrl(cursor.getString(0)); - link.setDescription(cursor.getString(1)); - result.add(link); - } - return result; - } finally { - cursor.close(); - } - } - - @WorkerThread - public boolean isBookmarked(Event event) { - String[] selectionArgs = new String[]{String.valueOf(event.getId())}; - return DatabaseUtils.queryNumEntries(helper.getReadableDatabase(), DatabaseHelper.BOOKMARKS_TABLE_NAME, "event_id = ?", selectionArgs) > 0L; - } - - @WorkerThread - public boolean addBookmark(Event event) { - boolean complete = false; - - SQLiteDatabase db = helper.getWritableDatabase(); - db.beginTransaction(); - try { - ContentValues values = new ContentValues(); - values.put("event_id", event.getId()); - long result = db.insert(DatabaseHelper.BOOKMARKS_TABLE_NAME, null, values); - - // If the bookmark is already present - if (result == -1L) { - return false; - } - - db.setTransactionSuccessful(); - complete = true; - return true; - } finally { - db.endTransaction(); - - if (complete) { - Intent intent = new Intent(ACTION_ADD_BOOKMARK).putExtra(EXTRA_EVENT_ID, event.getId()); - Date startTime = event.getStartTime(); - if (startTime != null) { - intent.putExtra(EXTRA_EVENT_START_TIME, startTime.getTime()); - } - LocalBroadcastManager.getInstance(context).sendBroadcast(intent); - } - } - } - - @WorkerThread - public boolean removeBookmark(Event event) { - return removeBookmarks(new long[]{event.getId()}); - } - - @WorkerThread - public boolean removeBookmark(long eventId) { - return removeBookmarks(new long[]{eventId}); - } - - @WorkerThread - public boolean removeBookmarks(long[] eventIds) { - int length = eventIds.length; - if (length == 0) { - throw new IllegalArgumentException("At least one bookmark id to remove must be passed"); - } - String[] stringEventIds = new String[length]; - for (int i = 0; i < length; ++i) { - stringEventIds[i] = String.valueOf(eventIds[i]); - } - - boolean isComplete = false; - - SQLiteDatabase db = helper.getWritableDatabase(); - db.beginTransaction(); - try { - String whereClause = "event_id IN (" + TextUtils.join(",", stringEventIds) + ")"; - int count = db.delete(DatabaseHelper.BOOKMARKS_TABLE_NAME, whereClause, null); - - if (count == 0) { - return false; - } - - db.setTransactionSuccessful(); - isComplete = true; - return true; - } finally { - db.endTransaction(); - - if (isComplete) { - Intent intent = new Intent(ACTION_REMOVE_BOOKMARKS).putExtra(EXTRA_EVENT_IDS, eventIds); - LocalBroadcastManager.getInstance(context).sendBroadcast(intent); - } - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/LocalBroadcastCursor.java b/app/src/main/java/be/digitalia/fosdem/db/LocalBroadcastCursor.java deleted file mode 100644 index 4bcd73f..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/LocalBroadcastCursor.java +++ /dev/null @@ -1,86 +0,0 @@ -package be.digitalia.fosdem.db; - -import android.content.BroadcastReceiver; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.database.ContentObservable; -import android.database.ContentObserver; -import android.database.Cursor; -import android.database.CursorWrapper; -import android.net.Uri; -import android.os.Build; - -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -/** - * A cursor notifying its observers when a local broadcast matches the provided IntentFilter. - * This is more efficient and more customizable than using the ContentResolver to notify changes. - * - * @author Christophe Beyls - */ -public class LocalBroadcastCursor extends CursorWrapper { - - final ContentObservable contentObservable = new ContentObservable(); - - private final LocalBroadcastManager localBroadcastManager; - private final BroadcastReceiver receiver = new BroadcastReceiver() { - @Override - @SuppressWarnings("deprecation") - public void onReceive(Context context, Intent intent) { - if (matchIntent(context, intent)) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - contentObservable.dispatchChange(false, null); - } else { - contentObservable.dispatchChange(false); - } - } - } - }; - - public LocalBroadcastCursor(Cursor wrappedCursor, Context context, IntentFilter intentFilter) { - super(wrappedCursor); - localBroadcastManager = LocalBroadcastManager.getInstance(context); - localBroadcastManager.registerReceiver(receiver, intentFilter); - } - - @Override - public void registerContentObserver(ContentObserver observer) { - contentObservable.registerObserver(observer); - } - - @Override - public void unregisterContentObserver(ContentObserver observer) { - // cursor will unregister all observers when it closes - if (!isClosed()) { - contentObservable.unregisterObserver(observer); - } - } - - @Override - public void setNotificationUri(ContentResolver cr, Uri uri) { - throw new UnsupportedOperationException(); - } - - @Override - public Uri getNotificationUri() { - return null; - } - - @Override - public void close() { - super.close(); - contentObservable.unregisterAll(); - localBroadcastManager.unregisterReceiver(receiver); - } - - /** - * Override this method to implement custom Intent matching in addition to the IntentFilter. - * - * @return True if the Intent matches and observers should be notified. Default is true. - */ - protected boolean matchIntent(Context context, Intent intent) { - return true; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.java b/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.java new file mode 100644 index 0000000..cbeaea0 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.java @@ -0,0 +1,441 @@ +package be.digitalia.fosdem.db; + +import android.app.SearchManager; +import android.database.Cursor; +import android.os.AsyncTask; +import android.provider.BaseColumns; +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.paging.DataSource; +import androidx.room.*; +import be.digitalia.fosdem.alarms.FosdemAlarmManager; +import be.digitalia.fosdem.db.entities.EventEntity; +import be.digitalia.fosdem.db.entities.EventTitles; +import be.digitalia.fosdem.db.entities.EventToPerson; +import be.digitalia.fosdem.model.*; +import be.digitalia.fosdem.utils.DateUtils; + +import java.util.*; + +@Dao +public abstract class ScheduleDao { + + private static final String LAST_UPDATE_TIME_PREF = "last_update_time"; + private static final String LAST_MODIFIED_TAG_PREF = "last_modified_tag"; + + private final AppDatabase appDatabase; + private final MutableLiveData lastUpdateTime = new MutableLiveData<>(); + + public ScheduleDao(AppDatabase appDatabase) { + this.appDatabase = appDatabase; + } + + /** + * @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. + */ + @MainThread + public LiveData getLastUpdateTime() { + if (lastUpdateTime.getValue() == null) { + lastUpdateTime.setValue(appDatabase.getSharedPreferences().getLong(LAST_UPDATE_TIME_PREF, -1L)); + } + return lastUpdateTime; + } + + /** + * @return The time identifier of the current version of the database. + */ + public String getLastModifiedTag() { + return appDatabase.getSharedPreferences().getString(LAST_MODIFIED_TAG_PREF, null); + } + + private static class EmptyScheduleException extends RuntimeException { + } + + /** + * Stores the schedule in the database. + * + * @param events The events stream. + * @return The number of events processed. + */ + @WorkerThread + public int storeSchedule(Iterable events, String lastModifiedTag) { + int totalEvents; + try { + totalEvents = storeScheduleInternal(events, lastModifiedTag); + } catch (EmptyScheduleException ese) { + totalEvents = 0; + } + if (totalEvents > 0) { + // Set last update time and server's last modified tag + final long now = System.currentTimeMillis(); + appDatabase.getSharedPreferences().edit() + .putLong(LAST_UPDATE_TIME_PREF, now) + .putString(LAST_MODIFIED_TAG_PREF, lastModifiedTag) + .apply(); + lastUpdateTime.postValue(now); + + FosdemAlarmManager.getInstance().onScheduleRefreshed(); + } + return totalEvents; + } + + @Transaction + protected int storeScheduleInternal(Iterable events, String lastModifiedTag) { + // 1: Delete the previous schedule + clearSchedule(); + + // 2: Insert the events + int totalEvents = 0; + final Map tracks = new HashMap<>(); + long nextTrackId = 0L; + long minEventId = Long.MAX_VALUE; + final Set days = new HashSet<>(2); + + for (DetailedEvent event : events) { + // Retrieve or insert Track + final Track track = event.getTrack(); + Long trackId = tracks.get(track); + if (trackId == null) { + // New track + nextTrackId++; + trackId = nextTrackId; + track.setId(nextTrackId); + insertTrack(track); + tracks.put(track, trackId); + } else { + track.setId(trackId); + } + + final long eventId = event.getId(); + try { + // Insert main event and fulltext fields + insertEvent(new EventEntity(event), new EventTitles(event)); + } catch (Exception e) { + // Duplicate event: skip + continue; + } + + days.add(event.getDay()); + if (eventId < minEventId) { + minEventId = eventId; + } + + final List persons = event.getPersons(); + insertPersons(persons); + final int personsCount = persons.size(); + final EventToPerson[] eventsToPersons = new EventToPerson[personsCount]; + for (int i = 0; i < personsCount; ++i) { + eventsToPersons[i] = new EventToPerson(eventId, persons.get(i).getId()); + } + insertEventsToPersons(eventsToPersons); + + insertLinks(event.getLinks()); + + totalEvents++; + } + + if (totalEvents == 0) { + // Rollback the transaction + throw new EmptyScheduleException(); + } + + // 3: Insert collected days + insertDays(days); + + // 4: Purge outdated bookmarks + purgeOutdatedBookmarks(minEventId); + + return totalEvents; + } + + @Insert + protected abstract void insertTrack(Track track); + + @Insert + protected abstract void insertEvent(EventEntity eventEntity, EventTitles eventTitles); + + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract void insertPersons(List persons); + + @Insert + protected abstract void insertEventsToPersons(EventToPerson[] eventsToPersons); + + @Insert + protected abstract void insertLinks(List links); + + @Insert + protected abstract void insertDays(Set days); + + @Query("DELETE FROM bookmarks WHERE event_id < :minEventId") + protected abstract void purgeOutdatedBookmarks(long minEventId); + + @WorkerThread + @Transaction + public void clearSchedule() { + clearEvents(); + clearEventTitles(); + clearPersons(); + clearEventToPersons(); + clearLinks(); + clearTracks(); + clearDays(); + } + + @Query("DELETE FROM events") + protected abstract void clearEvents(); + + @Query("DELETE FROM events_titles") + protected abstract void clearEventTitles(); + + @Query("DELETE FROM persons") + protected abstract void clearPersons(); + + @Query("DELETE FROM events_persons") + protected abstract void clearEventToPersons(); + + @Query("DELETE FROM links") + protected abstract void clearLinks(); + + @Query("DELETE FROM tracks") + protected abstract void clearTracks(); + + @Query("DELETE FROM days") + protected abstract void clearDays(); + + + // Cache days + private volatile LiveData> daysLiveData; + + public LiveData> getDays() { + if (daysLiveData != null) { + return daysLiveData; + } + synchronized (this) { + daysLiveData = getDaysInternal(); + return daysLiveData; + } + } + + @Query("SELECT `index`, date FROM days ORDER BY `index` ASC") + protected abstract LiveData> getDaysInternal(); + + @WorkerThread + public int getYear() { + long date = 0L; + + // Compute from cached days if available + final LiveData> cache = daysLiveData; + List days = (cache == null) ? null : cache.getValue(); + if (days != null) { + if (days.size() > 0) { + date = days.get(0).getDate().getTime(); + } + } else { + date = getConferenceStartDate(); + } + + // Use the current year by default + if (date == 0L) { + date = System.currentTimeMillis(); + } + + return DateUtils.getYear(date); + } + + @Query("SELECT date FROM days ORDER BY `index` ASC LIMIT 1") + protected abstract long getConferenceStartDate(); + + @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" + + " GROUP BY t.id" + + " ORDER BY t.name ASC") + public abstract LiveData> getTracks(Day day); + + /** + * Returns the event with the specified id, or null if not found. + */ + @Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description" + + ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type" + + " FROM events e" + + " JOIN events_titles et ON e.id = et.`rowid`" + + " JOIN days d ON e.day_index = d.`index`" + + " JOIN tracks t ON e.track_id = t.id" + + " LEFT JOIN events_persons ep ON e.id = ep.event_id" + + " LEFT JOIN persons p ON ep.person_id = p.`rowid`" + + " WHERE e.id = :id" + + " GROUP BY e.id") + @Nullable + @WorkerThread + public abstract Event getEvent(long id); + + /** + * Returns the events for a specified track. + */ + @Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description" + + ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type" + + ", b.event_id IS NOT NULL AS is_bookmarked" + + " FROM events e" + + " JOIN events_titles et ON e.id = et.`rowid`" + + " JOIN days d ON e.day_index = d.`index`" + + " JOIN tracks t ON e.track_id = t.id" + + " LEFT JOIN events_persons ep ON e.id = ep.event_id" + + " LEFT JOIN persons p ON ep.person_id = p.`rowid`" + + " LEFT JOIN bookmarks b ON e.id = b.event_id" + + " WHERE e.day_index = :day AND e.track_id = :track" + + " GROUP BY e.id" + + " ORDER BY e.start_time ASC") + public abstract LiveData> getEvents(Day day, Track track); + + /** + * Returns events starting in the specified interval, ordered by ascending start time. + */ + @Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description" + + ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type" + + ", b.event_id IS NOT NULL AS is_bookmarked" + + " FROM events e" + + " JOIN events_titles et ON e.id = et.`rowid`" + + " JOIN days d ON e.day_index = d.`index`" + + " JOIN tracks t ON e.track_id = t.id" + + " LEFT JOIN events_persons ep ON e.id = ep.event_id" + + " LEFT JOIN persons p ON ep.person_id = p.`rowid`" + + " LEFT JOIN bookmarks b ON e.id = b.event_id" + + " WHERE e.start_time BETWEEN :minStartTime AND :maxStartTime" + + " GROUP BY e.id" + + " ORDER BY e.start_time ASC") + public abstract DataSource.Factory getEventsWithStartTime(long minStartTime, long maxStartTime); + + /** + * Returns events in progress at the specified time, ordered by descending start time. + */ + @Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description" + + ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type" + + ", b.event_id IS NOT NULL AS is_bookmarked" + + " FROM events e" + + " JOIN events_titles et ON e.id = et.`rowid`" + + " JOIN days d ON e.day_index = d.`index`" + + " JOIN tracks t ON e.track_id = t.id" + + " LEFT JOIN events_persons ep ON e.id = ep.event_id" + + " LEFT JOIN persons p ON ep.person_id = p.`rowid`" + + " LEFT JOIN bookmarks b ON e.id = b.event_id" + + " WHERE e.start_time <= :time AND :time < e.end_time" + + " GROUP BY e.id" + + " ORDER BY e.start_time DESC") + public abstract DataSource.Factory getEventsInProgress(long time); + + /** + * Returns the events presented by the specified person. + */ + @Query("SELECT e.id , e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description" + + ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type" + + ", b.event_id IS NOT NULL AS is_bookmarked" + + " FROM events e" + + " JOIN events_titles et ON e.id = et.`rowid`" + + " JOIN days d ON e.day_index = d.`index`" + + " JOIN tracks t ON e.track_id = t.id" + + " LEFT JOIN events_persons ep ON e.id = ep.event_id" + + " LEFT JOIN persons p ON ep.person_id = p.`rowid`" + + " LEFT JOIN bookmarks b ON e.id = b.event_id" + + " JOIN events_persons ep2 ON e.id = ep2.event_id" + + " WHERE ep2.person_id = :person" + + " GROUP BY e.id" + + " ORDER BY e.start_time ASC") + public abstract DataSource.Factory getEvents(Person person); + + /** + * Search through matching titles, subtitles, track names, person names. We need to use an union of 3 sub-queries because a "match" condition can not be + * accompanied by other conditions in a "where" statement. + */ + @Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description" + + ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type" + + ", b.event_id IS NOT NULL AS is_bookmarked" + + " FROM events e" + + " JOIN events_titles et ON e.id = et.`rowid`" + + " JOIN days d ON e.day_index = d.`index`" + + " JOIN tracks t ON e.track_id = t.id" + + " LEFT JOIN events_persons ep ON e.id = ep.event_id" + + " LEFT JOIN persons p ON ep.person_id = p.`rowid`" + + " LEFT JOIN bookmarks b ON e.id = b.event_id" + + " WHERE e.id IN ( " + + "SELECT `rowid`" + + " FROM events_titles" + + " WHERE events_titles MATCH :query || '*'" + + " UNION " + + "SELECT e.id" + + " FROM events e" + + " JOIN tracks t ON e.track_id = t.id" + + " WHERE t.name LIKE '%' || :query || '%'" + + " UNION " + + "SELECT ep.event_id" + + " FROM events_persons ep" + + " JOIN persons p ON ep.person_id = p.`rowid`" + + " WHERE p.name MATCH :query || '*'" + + " )" + + " GROUP BY e.id" + + " ORDER BY e.start_time ASC") + public abstract DataSource.Factory getSearchResults(String query); + + /** + * Method called by SearchSuggestionProvider to return search results in the format expected by the search framework. + */ + @Query("SELECT e.id AS " + BaseColumns._ID + + ", et.title AS " + SearchManager.SUGGEST_COLUMN_TEXT_1 + + ", IFNULL(GROUP_CONCAT(p.name, ', '), '') || ' - ' || t.name AS " + SearchManager.SUGGEST_COLUMN_TEXT_2 + + ", e.id AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA + + " FROM events e" + + " JOIN events_titles et ON e.id = et.`rowid`" + + " JOIN tracks t ON e.track_id = t.id" + + " LEFT JOIN events_persons ep ON e.id = ep.event_id" + + " LEFT JOIN persons p ON ep.person_id = p.`rowid`" + + " WHERE e.id IN ( " + + "SELECT `rowid`" + + " FROM events_titles" + + " WHERE events_titles MATCH :query || '*'" + + " UNION " + + "SELECT e.id" + + " FROM events e" + + " JOIN tracks t ON e.track_id = t.id" + + " WHERE t.name LIKE '%' || :query || '%'" + + " UNION " + + "SELECT ep.event_id" + + " FROM events_persons ep" + + " JOIN persons p ON ep.person_id = p.`rowid`" + + " WHERE p.name MATCH :query || '*'" + + " )" + + " GROUP BY e.id" + + " ORDER BY e.start_time ASC LIMIT :limit") + @WorkerThread + public abstract Cursor getSearchSuggestionResults(String query, int limit); + + /** + * Returns all persons in alphabetical order. + */ + @Query("SELECT `rowid`, name" + + " FROM persons" + + " ORDER BY name COLLATE NOCASE") + public abstract DataSource.Factory getPersons(); + + public LiveData getEventDetails(final Event event) { + final MutableLiveData result = new MutableLiveData<>(); + AsyncTask.THREAD_POOL_EXECUTOR.execute(new Runnable() { + @Override + public void run() { + result.postValue(new EventDetails(getPersons(event), getLinks(event))); + } + }); + return result; + } + + @Query("SELECT p.`rowid`, p.name" + + " FROM persons p" + + " JOIN events_persons ep ON p.`rowid` = ep.person_id" + + " WHERE ep.event_id = :event") + protected abstract List getPersons(Event event); + + @Query("SELECT * FROM links WHERE event_id = :event ORDER BY id ASC") + protected abstract List getLinks(Event event); +} diff --git a/app/src/main/java/be/digitalia/fosdem/db/converters/GlobalTypeConverters.java b/app/src/main/java/be/digitalia/fosdem/db/converters/GlobalTypeConverters.java new file mode 100644 index 0000000..ab46623 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/converters/GlobalTypeConverters.java @@ -0,0 +1,39 @@ +package be.digitalia.fosdem.db.converters; + +import androidx.room.TypeConverter; +import be.digitalia.fosdem.model.Day; +import be.digitalia.fosdem.model.Event; +import be.digitalia.fosdem.model.Person; +import be.digitalia.fosdem.model.Track; + +public class GlobalTypeConverters { + @TypeConverter + public static Track.Type toTrackType(String value) { + return Track.Type.valueOf(value); + } + + @TypeConverter + public static String fromTrackType(Track.Type value) { + return value.name(); + } + + @TypeConverter + public static long fromDay(Day day) { + return day.getIndex(); + } + + @TypeConverter + public static long fromTrack(Track track) { + return track.getId(); + } + + @TypeConverter + public static long fromPerson(Person person) { + return person.getId(); + } + + @TypeConverter + public static long fromEvent(Event event) { + return event.getId(); + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/db/converters/NonNullDateTypeConverters.java b/app/src/main/java/be/digitalia/fosdem/db/converters/NonNullDateTypeConverters.java new file mode 100644 index 0000000..4949e63 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/converters/NonNullDateTypeConverters.java @@ -0,0 +1,18 @@ +package be.digitalia.fosdem.db.converters; + +import java.util.Date; + +import androidx.annotation.NonNull; +import androidx.room.TypeConverter; + +public class NonNullDateTypeConverters { + @TypeConverter + public static Date toDate(long value) { + return new Date(value); + } + + @TypeConverter + public static long fromDate(@NonNull Date value) { + return value.getTime(); + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/db/converters/NullableDateTypeConverters.java b/app/src/main/java/be/digitalia/fosdem/db/converters/NullableDateTypeConverters.java new file mode 100644 index 0000000..e460461 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/converters/NullableDateTypeConverters.java @@ -0,0 +1,17 @@ +package be.digitalia.fosdem.db.converters; + +import java.util.Date; + +import androidx.room.TypeConverter; + +public class NullableDateTypeConverters { + @TypeConverter + public static Date toDate(Long value) { + return value == null ? null : new Date(value); + } + + @TypeConverter + public static Long fromDate(Date value) { + return (value == null) ? null : value.getTime(); + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/Bookmark.java b/app/src/main/java/be/digitalia/fosdem/db/entities/Bookmark.java new file mode 100644 index 0000000..e60f07a --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/entities/Bookmark.java @@ -0,0 +1,23 @@ +package be.digitalia.fosdem.db.entities; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +@Entity(tableName = Bookmark.TABLE_NAME) +public class Bookmark { + + public static final String TABLE_NAME = "bookmarks"; + + @PrimaryKey + @ColumnInfo(name = "event_id") + private final long eventId; + + public Bookmark(long eventId) { + this.eventId = eventId; + } + + public long getEventId() { + return eventId; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/EventEntity.java b/app/src/main/java/be/digitalia/fosdem/db/entities/EventEntity.java new file mode 100644 index 0000000..b7d0f52 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/entities/EventEntity.java @@ -0,0 +1,95 @@ +package be.digitalia.fosdem.db.entities; + +import java.util.Date; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Index; +import androidx.room.PrimaryKey; +import androidx.room.TypeConverters; +import be.digitalia.fosdem.db.converters.NullableDateTypeConverters; +import be.digitalia.fosdem.model.Event; + +@Entity(tableName = EventEntity.TABLE_NAME, indices = { + @Index(value = {"day_index"}, name = "event_day_index_idx"), + @Index(value = {"start_time"}, name = "event_start_time_idx"), + @Index(value = {"end_time"}, name = "event_end_time_idx"), + @Index(value = {"track_id"}, name = "event_track_id_idx") +}) +public class EventEntity { + + public static final String TABLE_NAME = "events"; + + @PrimaryKey + private final long id; + @ColumnInfo(name = "day_index") + private final int dayIndex; + @ColumnInfo(name = "start_time") + @TypeConverters({NullableDateTypeConverters.class}) + private final Date startTime; + @ColumnInfo(name = "end_time") + @TypeConverters({NullableDateTypeConverters.class}) + private final Date endTime; + @ColumnInfo(name = "room_name") + private final String roomName; + private final String slug; + @ColumnInfo(name = "track_id") + private final long trackId; + @ColumnInfo(name = "abstract") + private final String abstractText; + private final String description; + + public EventEntity(Event event) { + this(event.getId(), event.getDay().getIndex(), event.getStartTime(), event.getEndTime(), event.getRoomName(), + event.getSlug(), event.getTrack().getId(), event.getAbstractText(), event.getDescription()); + } + + public EventEntity(long id, int dayIndex, Date startTime, Date endTime, String roomName, + String slug, long trackId, String abstractText, String description) { + this.id = id; + this.dayIndex = dayIndex; + this.startTime = startTime; + this.endTime = endTime; + this.roomName = roomName; + this.slug = slug; + this.trackId = trackId; + this.abstractText = abstractText; + this.description = description; + } + + public long getId() { + return id; + } + + public int getDayIndex() { + return dayIndex; + } + + public Date getStartTime() { + return startTime; + } + + public Date getEndTime() { + return endTime; + } + + public String getRoomName() { + return roomName; + } + + public String getSlug() { + return slug; + } + + public long getTrackId() { + return trackId; + } + + public String getAbstractText() { + return abstractText; + } + + public String getDescription() { + return description; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/EventTitles.java b/app/src/main/java/be/digitalia/fosdem/db/entities/EventTitles.java new file mode 100644 index 0000000..e7cc770 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/entities/EventTitles.java @@ -0,0 +1,43 @@ +package be.digitalia.fosdem.db.entities; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Fts3; +import androidx.room.PrimaryKey; +import be.digitalia.fosdem.model.Event; + +@Fts3 +@Entity(tableName = EventTitles.TABLE_NAME) +public class EventTitles { + + public static final String TABLE_NAME = "events_titles"; + + @PrimaryKey + @ColumnInfo(name = "rowid") + private final long id; + private final String title; + @ColumnInfo(name = "subtitle") + private final String subTitle; + + public EventTitles(Event event) { + this(event.getId(), event.getTitle(), event.getSubTitle()); + } + + public EventTitles(long id, String title, String subTitle) { + this.id = id; + this.title = title; + this.subTitle = subTitle; + } + + public long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getSubTitle() { + return subTitle; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/EventToPerson.java b/app/src/main/java/be/digitalia/fosdem/db/entities/EventToPerson.java new file mode 100644 index 0000000..278612f --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/entities/EventToPerson.java @@ -0,0 +1,30 @@ +package be.digitalia.fosdem.db.entities; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Index; + +@Entity(tableName = EventToPerson.TABLE_NAME, primaryKeys = {"event_id", "person_id"}, + indices = {@Index(value = {"person_id"}, name = "event_person_person_id_idx")}) +public class EventToPerson { + + public static final String TABLE_NAME = "events_persons"; + + @ColumnInfo(name = "event_id") + private final long eventId; + @ColumnInfo(name = "person_id") + private final long personId; + + public EventToPerson(long eventId, long personId) { + this.eventId = eventId; + this.personId = personId; + } + + public long getEventId() { + return eventId; + } + + public long getPersonId() { + return personId; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/BaseLiveListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/BaseLiveListFragment.java index 0993cbc..a7a0428 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/BaseLiveListFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/BaseLiveListFragment.java @@ -1,28 +1,27 @@ package be.digitalia.fosdem.fragments; -import android.database.Cursor; import android.os.Bundle; - import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; -import androidx.loader.app.LoaderManager; -import androidx.loader.app.LoaderManager.LoaderCallbacks; -import androidx.loader.content.Loader; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; +import androidx.paging.PagedList; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import be.digitalia.fosdem.adapters.EventsAdapter; +import be.digitalia.fosdem.model.StatusEvent; +import be.digitalia.fosdem.viewmodels.LiveViewModel; -public abstract class BaseLiveListFragment extends RecyclerViewFragment implements LoaderCallbacks { - - private static final int EVENTS_LOADER_ID = 1; +public abstract class BaseLiveListFragment extends RecyclerViewFragment implements Observer> { private EventsAdapter adapter; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - adapter = new EventsAdapter(getActivity(), this, false); + adapter = new EventsAdapter(getContext(), this, false); } @Override @@ -44,22 +43,29 @@ public abstract class BaseLiveListFragment extends RecyclerViewFragment implemen setEmptyText(getEmptyText()); setProgressBarVisible(true); - LoaderManager.getInstance(this).initLoader(EVENTS_LOADER_ID, null, this); + final LiveViewModel viewModel = ViewModelProviders.of(getParentFragment()).get(LiveViewModel.class); + getDataSource(viewModel).observe(getViewLifecycleOwner(), this); + } + + private final Runnable preserveScrollPositionRunnable = new Runnable() { + @Override + public void run() { + // Ensure we stay at scroll position 0 so we can see the insertion animation + final RecyclerView recyclerView = getRecyclerView(); + if (recyclerView.getScrollY() == 0) { + recyclerView.scrollToPosition(0); + } + } + }; + + @Override + public void onChanged(PagedList events) { + adapter.submitList(events, preserveScrollPositionRunnable); + setProgressBarVisible(false); } protected abstract String getEmptyText(); - @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { - if (data != null) { - adapter.swapCursor(data); - } - - setProgressBarVisible(false); - } - - @Override - public void onLoaderReset(@NonNull Loader loader) { - adapter.swapCursor(null); - } + @NonNull + protected abstract LiveData> getDataSource(@NonNull LiveViewModel viewModel); } diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.java index 8d72315..095674e 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.java @@ -2,41 +2,39 @@ package be.digitalia.fosdem.fragments; import android.content.Context; import android.content.Intent; -import android.database.Cursor; import android.os.Bundle; -import android.os.Handler; -import android.text.format.DateUtils; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; - import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; -import androidx.loader.app.LoaderManager; -import androidx.loader.app.LoaderManager.LoaderCallbacks; -import androidx.loader.content.Loader; +import androidx.appcompat.view.ActionMode; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import be.digitalia.fosdem.R; import be.digitalia.fosdem.adapters.BookmarksAdapter; -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.loaders.SimpleCursorLoader; +import be.digitalia.fosdem.model.Event; import be.digitalia.fosdem.providers.BookmarksExportProvider; +import be.digitalia.fosdem.viewmodels.BookmarksViewModel; +import be.digitalia.fosdem.widgets.MultiChoiceHelper; + +import java.util.List; /** * Bookmarks list, optionally filterable. * * @author Christophe Beyls */ -public class BookmarksListFragment extends RecyclerViewFragment implements LoaderCallbacks { +public class BookmarksListFragment extends RecyclerViewFragment implements Observer> { - private static final int BOOKMARKS_LOADER_ID = 1; private static final String PREF_UPCOMING_ONLY = "bookmarks_upcoming_only"; private static final String STATE_ADAPTER = "adapter"; + private BookmarksViewModel viewModel; private BookmarksAdapter adapter; - private boolean upcomingOnly; private MenuItem filterMenuItem; private MenuItem upcomingOnlyMenuItem; @@ -45,11 +43,53 @@ public class BookmarksListFragment extends RecyclerViewFragment implements Loade public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - adapter = new BookmarksAdapter((AppCompatActivity) getActivity(), this); + viewModel = ViewModelProviders.of(this).get(BookmarksViewModel.class); + final MultiChoiceHelper.MultiChoiceModeListener multiChoiceModeListener = new MultiChoiceHelper.MultiChoiceModeListener() { + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + mode.getMenuInflater().inflate(R.menu.action_mode_bookmarks, menu); + return true; + } + + private void updateSelectedCountDisplay(ActionMode mode) { + int count = adapter.getMultiChoiceHelper().getCheckedItemCount(); + mode.setTitle(getResources().getQuantityString(R.plurals.selected, count, count)); + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + updateSelectedCountDisplay(mode); + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + switch (item.getItemId()) { + case R.id.delete: + // Remove multiple bookmarks at once + viewModel.removeBookmarks(adapter.getMultiChoiceHelper().getCheckedItemIds()); + mode.finish(); + return true; + } + return false; + } + + @Override + public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) { + updateSelectedCountDisplay(mode); + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + } + }; + adapter = new BookmarksAdapter((AppCompatActivity) getActivity(), this, multiChoiceModeListener); if (savedInstanceState != null) { - adapter.onRestoreInstanceState(savedInstanceState.getParcelable(STATE_ADAPTER)); + adapter.getMultiChoiceHelper().onRestoreInstanceState(savedInstanceState.getParcelable(STATE_ADAPTER)); } - upcomingOnly = getActivity().getPreferences(Context.MODE_PRIVATE).getBoolean(PREF_UPCOMING_ONLY, false); + boolean upcomingOnly = getActivity().getPreferences(Context.MODE_PRIVATE).getBoolean(PREF_UPCOMING_ONLY, false); + viewModel.setUpcomingOnly(upcomingOnly); setHasOptionsMenu(true); } @@ -68,18 +108,18 @@ public class BookmarksListFragment extends RecyclerViewFragment implements Loade setEmptyText(getString(R.string.no_bookmark)); setProgressBarVisible(true); - LoaderManager.getInstance(this).initLoader(BOOKMARKS_LOADER_ID, null, this); + viewModel.getBookmarks().observe(getViewLifecycleOwner(), this); } @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); - outState.putParcelable(STATE_ADAPTER, adapter.onSaveInstanceState()); + outState.putParcelable(STATE_ADAPTER, adapter.getMultiChoiceHelper().onSaveInstanceState()); } @Override public void onDestroyView() { - adapter.onDestroyView(); + adapter.getMultiChoiceHelper().clearChoices(); super.onDestroyView(); } @@ -93,6 +133,7 @@ public class BookmarksListFragment extends RecyclerViewFragment implements Loade private void updateFilterMenuItem() { if (filterMenuItem != null) { + final boolean upcomingOnly = viewModel.getUpcomingOnly(); filterMenuItem.setIcon(upcomingOnly ? R.drawable.ic_filter_list_selected_white_24dp : R.drawable.ic_filter_list_white_24dp); @@ -111,12 +152,12 @@ public class BookmarksListFragment extends RecyclerViewFragment implements Loade public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.upcoming_only: - upcomingOnly = !upcomingOnly; + final boolean upcomingOnly = !viewModel.getUpcomingOnly(); + viewModel.setUpcomingOnly(upcomingOnly); updateFilterMenuItem(); getActivity().getPreferences(Context.MODE_PRIVATE).edit() .putBoolean(PREF_UPCOMING_ONLY, upcomingOnly) .apply(); - LoaderManager.getInstance(this).restartLoader(BOOKMARKS_LOADER_ID, null, this); return true; case R.id.export_bookmarks: Intent exportIntent = BookmarksExportProvider.getIntent(getActivity()); @@ -126,78 +167,9 @@ public class BookmarksListFragment extends RecyclerViewFragment implements Loade return false; } - private static class BookmarksLoader extends SimpleCursorLoader { - - // Events that just started are still shown for 5 minutes - private static final long TIME_OFFSET = 5L * DateUtils.MINUTE_IN_MILLIS; - - private final boolean upcomingOnly; - private final Handler handler; - private final Runnable timeoutRunnable = new Runnable() { - - @Override - public void run() { - onContentChanged(); - } - }; - - public BookmarksLoader(Context context, boolean upcomingOnly) { - super(context); - this.upcomingOnly = upcomingOnly; - this.handler = new Handler(); - } - - @Override - public void deliverResult(Cursor cursor) { - if (upcomingOnly && !isReset()) { - handler.removeCallbacks(timeoutRunnable); - // The loader will be refreshed when the start time of the first bookmark in the list is reached - if ((cursor != null) && cursor.moveToFirst()) { - long startTime = DatabaseManager.toEventStartTimeMillis(cursor); - if (startTime != -1L) { - long delay = startTime - (System.currentTimeMillis() - TIME_OFFSET); - if (delay > 0L) { - handler.postDelayed(timeoutRunnable, delay); - } else { - onContentChanged(); - } - } - } - } - super.deliverResult(cursor); - } - - @Override - protected void onReset() { - super.onReset(); - if (upcomingOnly) { - handler.removeCallbacks(timeoutRunnable); - } - } - - @Override - protected Cursor getCursor() { - return DatabaseManager.getInstance().getBookmarks(upcomingOnly ? System.currentTimeMillis() - TIME_OFFSET : 0L); - } - } - - @NonNull @Override - public Loader onCreateLoader(int id, Bundle args) { - return new BookmarksLoader(getActivity(), upcomingOnly); - } - - @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { - if (data != null) { - adapter.swapCursor(data); - } - + public void onChanged(List bookmarks) { + adapter.submitList(bookmarks); setProgressBarVisible(false); } - - @Override - public void onLoaderReset(@NonNull Loader loader) { - adapter.swapCursor(null); - } } diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/EventDetailsFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/EventDetailsFragment.java index ed71823..8e08975 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/EventDetailsFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/EventDetailsFragment.java @@ -9,28 +9,12 @@ import android.graphics.drawable.Animatable; import android.net.Uri; import android.os.Bundle; import android.provider.CalendarContract; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.SpannableStringBuilder; -import android.text.TextPaint; -import android.text.TextUtils; +import android.text.*; import android.text.method.LinkMovementMethod; import android.text.style.ClickableSpan; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; +import android.view.*; import android.widget.ImageView; import android.widget.TextView; - -import com.google.android.material.snackbar.Snackbar; - -import java.text.DateFormat; -import java.util.Date; -import java.util.Map; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.browser.customtabs.CustomTabsIntent; @@ -42,15 +26,17 @@ import androidx.lifecycle.ViewModelProviders; import be.digitalia.fosdem.R; import be.digitalia.fosdem.activities.PersonInfoActivity; import be.digitalia.fosdem.api.FosdemApi; -import be.digitalia.fosdem.model.Building; -import be.digitalia.fosdem.model.Event; -import be.digitalia.fosdem.model.Link; -import be.digitalia.fosdem.model.Person; -import be.digitalia.fosdem.model.RoomStatus; +import be.digitalia.fosdem.model.*; import be.digitalia.fosdem.utils.ClickableArrowKeyMovementMethod; import be.digitalia.fosdem.utils.DateUtils; import be.digitalia.fosdem.utils.StringUtils; import be.digitalia.fosdem.viewmodels.EventDetailsViewModel; +import com.google.android.material.snackbar.Snackbar; + +import java.text.DateFormat; +import java.util.Date; +import java.util.List; +import java.util.Map; public class EventDetailsFragment extends Fragment { @@ -73,7 +59,6 @@ public class EventDetailsFragment extends Fragment { private static final String ARG_EVENT = "event"; Event event; - int personsCount = 1; ViewHolder holder; EventDetailsViewModel viewModel; @@ -152,12 +137,12 @@ public class EventDetailsFragment extends Fragment { if (roomImageResId != 0) { roomText.setSpan(new ClickableSpan() { @Override - public void onClick(View view) { + public void onClick(@NonNull View view) { RoomImageDialogFragment.newInstance(roomName, roomImageResId).show(getFragmentManager()); } @Override - public void updateDrawState(TextPaint ds) { + public void updateDrawState(@NonNull TextPaint ds) { super.updateDrawState(ds); ds.setUnderlineText(false); } @@ -213,15 +198,17 @@ public class EventDetailsFragment extends Fragment { updateBookmarkMenuItem(isBookmarked, true); } }); - viewModel.getEventDetails().observe(getViewLifecycleOwner(), new Observer() { + viewModel.getEventDetails().observe(getViewLifecycleOwner(), new Observer() { @Override - public void onChanged(@Nullable EventDetailsViewModel.EventDetails eventDetails) { - setEventDetails(eventDetails); + public void onChanged(EventDetails eventDetails) { + if (eventDetails != null) { + setEventDetails(eventDetails); + } } }); // Live room status - FosdemApi.getRoomStatuses().observe(getViewLifecycleOwner(), new Observer>() { + FosdemApi.getRoomStatuses(getContext()).observe(getViewLifecycleOwner(), new Observer>() { @Override public void onChanged(Map roomStatuses) { RoomStatus roomStatus = roomStatuses.get(event.getRoomName()); @@ -354,6 +341,8 @@ public class EventDetailsFragment extends Fragment { } description = StringUtils.stripHtml(description); // Add speaker info if available + EventDetails details = viewModel.getEventDetails().getValue(); + final int personsCount = (details == null) ? 0 : details.getPersons().size(); if (personsCount > 0) { description = String.format("%1$s: %2$s\n\n%3$s", getResources().getQuantityString(R.plurals.speakers, personsCount), event.getPersonsSummary(), description); @@ -374,34 +363,33 @@ public class EventDetailsFragment extends Fragment { } } - void setEventDetails(@NonNull EventDetailsViewModel.EventDetails data) { + void setEventDetails(@NonNull EventDetails eventDetails) { // 1. Persons - if (data.persons != null) { - personsCount = data.persons.size(); - if (personsCount > 0) { - // Build a list of clickable persons - SpannableStringBuilder sb = new SpannableStringBuilder(); - int length = 0; - for (Person person : data.persons) { - if (length != 0) { - sb.append(", "); - } - String name = person.getName(); - sb.append(name); - length = sb.length(); - sb.setSpan(new PersonClickableSpan(person), length - name.length(), length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + final List persons = eventDetails.getPersons(); + if (persons.size() > 0) { + // Build a list of clickable persons + SpannableStringBuilder sb = new SpannableStringBuilder(); + int length = 0; + for (Person person : persons) { + if (length != 0) { + sb.append(", "); } - holder.personsTextView.setText(sb); - holder.personsTextView.setVisibility(View.VISIBLE); + String name = person.getName(); + sb.append(name); + length = sb.length(); + sb.setSpan(new PersonClickableSpan(person), length - name.length(), length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } + holder.personsTextView.setText(sb); + holder.personsTextView.setVisibility(View.VISIBLE); } // 2. Links + final List links = eventDetails.getLinks(); holder.linksContainer.removeAllViews(); - if ((data.links != null) && (data.links.size() > 0)) { + if (links.size() > 0) { holder.linksHeader.setVisibility(View.VISIBLE); holder.linksContainer.setVisibility(View.VISIBLE); - for (Link link : data.links) { + for (Link link : links) { View view = holder.inflater.inflate(R.layout.item_link, holder.linksContainer, false); TextView tv = view.findViewById(R.id.description); tv.setText(link.getDescription()); @@ -423,14 +411,14 @@ public class EventDetailsFragment extends Fragment { } @Override - public void onClick(View v) { + public void onClick(@NonNull View v) { Context context = v.getContext(); Intent intent = new Intent(context, PersonInfoActivity.class).putExtra(PersonInfoActivity.EXTRA_PERSON, person); context.startActivity(intent); } @Override - public void updateDrawState(TextPaint ds) { + public void updateDrawState(@NonNull TextPaint ds) { super.updateDrawState(ds); ds.setUnderlineText(false); } diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/NextLiveListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/NextLiveListFragment.java index fed56ef..b14fb44 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/NextLiveListFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/NextLiveListFragment.java @@ -1,14 +1,11 @@ package be.digitalia.fosdem.fragments; -import android.content.Context; -import android.database.Cursor; -import android.os.Bundle; - import androidx.annotation.NonNull; -import androidx.loader.content.Loader; +import androidx.lifecycle.LiveData; +import androidx.paging.PagedList; import be.digitalia.fosdem.R; -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.loaders.BaseLiveLoader; +import be.digitalia.fosdem.model.StatusEvent; +import be.digitalia.fosdem.viewmodels.LiveViewModel; public class NextLiveListFragment extends BaseLiveListFragment { @@ -19,22 +16,7 @@ public class NextLiveListFragment extends BaseLiveListFragment { @NonNull @Override - public Loader onCreateLoader(int id, Bundle args) { - return new NextLiveLoader(getActivity()); - } - - private static class NextLiveLoader extends BaseLiveLoader { - - private static final long INTERVAL = 30L * 60L * 1000L; // 30 minutes - - public NextLiveLoader(Context context) { - super(context); - } - - @Override - protected Cursor getCursor() { - long now = System.currentTimeMillis(); - return DatabaseManager.getInstance().getEvents(now, now + INTERVAL, -1L, true); - } + protected LiveData> getDataSource(@NonNull LiveViewModel viewModel) { + return viewModel.getNextEvents(); } } diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/NowLiveListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/NowLiveListFragment.java index 05abfda..18cc70d 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/NowLiveListFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/NowLiveListFragment.java @@ -1,14 +1,11 @@ package be.digitalia.fosdem.fragments; -import android.content.Context; -import android.database.Cursor; -import android.os.Bundle; - import androidx.annotation.NonNull; -import androidx.loader.content.Loader; +import androidx.lifecycle.LiveData; +import androidx.paging.PagedList; import be.digitalia.fosdem.R; -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.loaders.BaseLiveLoader; +import be.digitalia.fosdem.model.StatusEvent; +import be.digitalia.fosdem.viewmodels.LiveViewModel; public class NowLiveListFragment extends BaseLiveListFragment { @@ -19,20 +16,7 @@ public class NowLiveListFragment extends BaseLiveListFragment { @NonNull @Override - public Loader onCreateLoader(int id, Bundle args) { - return new NowLiveLoader(getActivity()); - } - - private static class NowLiveLoader extends BaseLiveLoader { - - public NowLiveLoader(Context context) { - super(context); - } - - @Override - protected Cursor getCursor() { - long now = System.currentTimeMillis(); - return DatabaseManager.getInstance().getEvents(-1L, now, now, false); - } + protected LiveData> getDataSource(@NonNull LiveViewModel viewModel) { + return viewModel.getEventsInProgress(); } } diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/PersonInfoListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/PersonInfoListFragment.java index 5605296..3d1eb05 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/PersonInfoListFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/PersonInfoListFragment.java @@ -2,36 +2,27 @@ package be.digitalia.fosdem.fragments; import android.app.Activity; import android.content.ActivityNotFoundException; -import android.content.Context; -import android.database.Cursor; import android.net.Uri; import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - +import android.view.*; import androidx.annotation.NonNull; import androidx.browser.customtabs.CustomTabsIntent; import androidx.core.content.ContextCompat; -import androidx.loader.app.LoaderManager; -import androidx.loader.app.LoaderManager.LoaderCallbacks; -import androidx.loader.content.Loader; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; +import androidx.paging.PagedList; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import be.digitalia.fosdem.R; import be.digitalia.fosdem.adapters.ConcatAdapter; import be.digitalia.fosdem.adapters.EventsAdapter; -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.loaders.SimpleCursorLoader; import be.digitalia.fosdem.model.Person; +import be.digitalia.fosdem.model.StatusEvent; import be.digitalia.fosdem.utils.DateUtils; +import be.digitalia.fosdem.viewmodels.PersonInfoViewModel; -public class PersonInfoListFragment extends RecyclerViewFragment implements LoaderCallbacks { +public class PersonInfoListFragment extends RecyclerViewFragment implements Observer> { - private static final int PERSON_EVENTS_LOADER_ID = 1; private static final String ARG_PERSON = "person"; private Person person; @@ -49,7 +40,7 @@ public class PersonInfoListFragment extends RecyclerViewFragment implements Load public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - adapter = new EventsAdapter(getActivity(), this); + adapter = new EventsAdapter(getContext(), this); person = getArguments().getParcelable(ARG_PERSON); setHasOptionsMenu(true); } @@ -63,8 +54,18 @@ public class PersonInfoListFragment extends RecyclerViewFragment implements Load public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.more_info: - if (adapter.getItemCount() > 0) { - final int year = DateUtils.getYear(adapter.getItem(0).getDay().getDate().getTime()); + // Look for the first non-placeholder event in the paged list + final PagedList list = adapter.getCurrentList(); + final int size = (list == null) ? 0 : list.size(); + StatusEvent statusEvent = null; + for (int i = 0; i < size; ++i) { + statusEvent = list.get(i); + if (statusEvent != null) { + break; + } + } + if (statusEvent != null) { + final int year = DateUtils.getYear(statusEvent.getEvent().getDay().getDate().getTime()); String url = person.getUrl(year); if (url != null) { try { @@ -102,44 +103,17 @@ public class PersonInfoListFragment extends RecyclerViewFragment implements Load setEmptyText(getString(R.string.no_data)); setProgressBarVisible(true); - LoaderManager.getInstance(this).initLoader(PERSON_EVENTS_LOADER_ID, null, this); - } - - private static class PersonEventsLoader extends SimpleCursorLoader { - - private final Person person; - - public PersonEventsLoader(Context context, Person person) { - super(context); - this.person = person; - } - - @Override - protected Cursor getCursor() { - return DatabaseManager.getInstance().getEvents(person); - } - } - - @NonNull - @Override - public Loader onCreateLoader(int id, Bundle args) { - return new PersonEventsLoader(getActivity(), person); + final PersonInfoViewModel viewModel = ViewModelProviders.of(this).get(PersonInfoViewModel.class); + viewModel.setPerson(person); + viewModel.getEvents().observe(getViewLifecycleOwner(), this); } @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { - if (data != null) { - adapter.swapCursor(data); - } - + public void onChanged(PagedList events) { + adapter.submitList(events); setProgressBarVisible(false); } - @Override - public void onLoaderReset(@NonNull Loader loader) { - adapter.swapCursor(null); - } - static class HeaderAdapter extends RecyclerView.Adapter { @Override diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/PersonsListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/PersonsListFragment.java index cc01af2..7e326f8 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/PersonsListFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/PersonsListFragment.java @@ -2,38 +2,36 @@ package be.digitalia.fosdem.fragments; import android.content.Context; import android.content.Intent; -import android.database.Cursor; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.loader.app.LoaderManager; -import androidx.loader.app.LoaderManager.LoaderCallbacks; -import androidx.loader.content.Loader; +import androidx.core.util.ObjectsCompat; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; +import androidx.paging.PagedList; +import androidx.paging.PagedListAdapter; +import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import be.digitalia.fosdem.R; import be.digitalia.fosdem.activities.PersonInfoActivity; -import be.digitalia.fosdem.adapters.RecyclerViewCursorAdapter; -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.loaders.SimpleCursorLoader; +import be.digitalia.fosdem.adapters.SimpleItemCallback; import be.digitalia.fosdem.model.Person; +import be.digitalia.fosdem.viewmodels.PersonsViewModel; -public class PersonsListFragment extends RecyclerViewFragment implements LoaderCallbacks { - - private static final int PERSONS_LOADER_ID = 1; +public class PersonsListFragment extends RecyclerViewFragment implements Observer> { private PersonsAdapter adapter; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - adapter = new PersonsAdapter(getActivity()); + adapter = new PersonsAdapter(); } @NonNull @@ -56,70 +54,49 @@ public class PersonsListFragment extends RecyclerViewFragment implements LoaderC setEmptyText(getString(R.string.no_data)); setProgressBarVisible(true); - LoaderManager.getInstance(this).initLoader(PERSONS_LOADER_ID, null, this); - } - - private static class PersonsLoader extends SimpleCursorLoader { - - PersonsLoader(Context context) { - super(context); - } - - @Override - protected Cursor getCursor() { - return DatabaseManager.getInstance().getPersons(); - } - } - - @NonNull - @Override - public Loader onCreateLoader(int id, Bundle args) { - return new PersonsLoader(getActivity()); + final PersonsViewModel viewModel = ViewModelProviders.of(this).get(PersonsViewModel.class); + viewModel.getPersons().observe(getViewLifecycleOwner(), this); } @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { - if (data != null) { - adapter.swapCursor(data); - } - + public void onChanged(PagedList persons) { + adapter.submitList(persons); setProgressBarVisible(false); } - @Override - public void onLoaderReset(@NonNull Loader loader) { - adapter.swapCursor(null); - } + private static class PersonsAdapter extends PagedListAdapter { - private static class PersonsAdapter extends RecyclerViewCursorAdapter { + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new SimpleItemCallback() { + @Override + public boolean areContentsTheSame(@NonNull Person oldItem, @NonNull Person newItem) { + return ObjectsCompat.equals(oldItem.getName(), newItem.getName()); + } + }; - private final LayoutInflater inflater; - - PersonsAdapter(Context context) { - inflater = LayoutInflater.from(context); - } - - @Override - public Person getItem(int position) { - return DatabaseManager.toPerson((Cursor) super.getItem(position)); + PersonsAdapter() { + super(DIFF_CALLBACK); } @NonNull @Override public PersonViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = inflater.inflate(R.layout.simple_list_item_1_material, parent, false); + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.simple_list_item_1_material, parent, false); return new PersonViewHolder(view); } @Override - public void onBindViewHolder(PersonViewHolder holder, Cursor cursor) { - holder.person = DatabaseManager.toPerson(cursor, holder.person); - holder.textView.setText(holder.person.getName()); + public void onBindViewHolder(@NonNull PersonViewHolder holder, int position) { + final Person person = getItem(position); + if (person == null) { + holder.clear(); + } else { + holder.bind(person); + } } } static class PersonViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { - TextView textView; + final TextView textView; Person person; @@ -129,12 +106,24 @@ public class PersonsListFragment extends RecyclerViewFragment implements LoaderC itemView.setOnClickListener(this); } + void clear() { + this.person = null; + textView.setText(null); + } + + void bind(@NonNull Person person) { + this.person = person; + textView.setText(person.getName()); + } + @Override public void onClick(View view) { - final Context context = view.getContext(); - Intent intent = new Intent(context, PersonInfoActivity.class) - .putExtra(PersonInfoActivity.EXTRA_PERSON, person); - context.startActivity(intent); + if (person != null) { + final Context context = view.getContext(); + Intent intent = new Intent(context, PersonInfoActivity.class) + .putExtra(PersonInfoActivity.EXTRA_PERSON, person); + context.startActivity(intent); + } } } } diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/SearchResultListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/SearchResultListFragment.java index cc72201..47ad97a 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/SearchResultListFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/SearchResultListFragment.java @@ -1,40 +1,29 @@ package be.digitalia.fosdem.fragments; -import android.content.Context; -import android.database.Cursor; import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.loader.app.LoaderManager; -import androidx.loader.app.LoaderManager.LoaderCallbacks; -import androidx.loader.content.Loader; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; +import androidx.paging.PagedList; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import be.digitalia.fosdem.R; import be.digitalia.fosdem.adapters.EventsAdapter; -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.loaders.SimpleCursorLoader; +import be.digitalia.fosdem.model.StatusEvent; +import be.digitalia.fosdem.viewmodels.SearchViewModel; -public class SearchResultListFragment extends RecyclerViewFragment implements LoaderCallbacks { - - private static final int EVENTS_LOADER_ID = 1; - private static final String ARG_QUERY = "query"; +public class SearchResultListFragment extends RecyclerViewFragment implements Observer> { private EventsAdapter adapter; - public static SearchResultListFragment newInstance(String query) { - SearchResultListFragment f = new SearchResultListFragment(); - Bundle args = new Bundle(); - args.putString(ARG_QUERY, query); - f.setArguments(args); - return f; + public static SearchResultListFragment newInstance() { + return new SearchResultListFragment(); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - adapter = new EventsAdapter(getActivity(), this); + adapter = new EventsAdapter(getContext(), this); } @Override @@ -51,42 +40,13 @@ public class SearchResultListFragment extends RecyclerViewFragment implements Lo setEmptyText(getString(R.string.no_search_result)); setProgressBarVisible(true); - LoaderManager.getInstance(this).initLoader(EVENTS_LOADER_ID, null, this); - } - - private static class TextSearchLoader extends SimpleCursorLoader { - - private final String query; - - public TextSearchLoader(Context context, String query) { - super(context); - this.query = query; - } - - @Override - protected Cursor getCursor() { - return DatabaseManager.getInstance().getSearchResults(query); - } - } - - @NonNull - @Override - public Loader onCreateLoader(int id, Bundle args) { - String query = getArguments().getString(ARG_QUERY); - return new TextSearchLoader(getActivity(), query); + final SearchViewModel viewModel = ViewModelProviders.of(getActivity()).get(SearchViewModel.class); + viewModel.getResults().observe(getViewLifecycleOwner(), this); } @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { - if (data != null) { - adapter.swapCursor(data); - } - + public void onChanged(PagedList results) { + adapter.submitList(results); setProgressBarVisible(false); } - - @Override - public void onLoaderReset(@NonNull Loader loader) { - adapter.swapCursor(null); - } } diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TrackScheduleListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/TrackScheduleListFragment.java index 4384a29..79d3207 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/TrackScheduleListFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/TrackScheduleListFragment.java @@ -1,29 +1,29 @@ package be.digitalia.fosdem.fragments; import android.content.Context; -import android.database.Cursor; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.text.format.DateUtils; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.loader.app.LoaderManager; -import androidx.loader.app.LoaderManager.LoaderCallbacks; -import androidx.loader.content.Loader; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import be.digitalia.fosdem.R; import be.digitalia.fosdem.adapters.TrackScheduleAdapter; -import be.digitalia.fosdem.loaders.TrackScheduleLoader; import be.digitalia.fosdem.model.Day; import be.digitalia.fosdem.model.Event; +import be.digitalia.fosdem.model.StatusEvent; import be.digitalia.fosdem.model.Track; +import be.digitalia.fosdem.viewmodels.TrackScheduleViewModel; + +import java.util.List; public class TrackScheduleListFragment extends RecyclerViewFragment - implements TrackScheduleAdapter.EventClickListener, Handler.Callback, LoaderCallbacks { + implements TrackScheduleAdapter.EventClickListener, Handler.Callback, Observer> { /** * Interface implemented by container activities @@ -32,7 +32,6 @@ public class TrackScheduleListFragment extends RecyclerViewFragment void onEventSelected(int position, Event event); } - private static final int EVENTS_LOADER_ID = 1; private static final int REFRESH_TIME_WHAT = 1; private static final long REFRESH_TIME_INTERVAL = DateUtils.MINUTE_IN_MILLIS; @@ -137,7 +136,10 @@ public class TrackScheduleListFragment extends RecyclerViewFragment setEmptyText(getString(R.string.no_data)); setProgressBarVisible(true); - LoaderManager.getInstance(this).initLoader(EVENTS_LOADER_ID, null, this); + Track track = getArguments().getParcelable(ARG_TRACK); + final TrackScheduleViewModel viewModel = ViewModelProviders.of(this).get(TrackScheduleViewModel.class); + viewModel.setTrack(day, track); + viewModel.getSchedule().observe(getViewLifecycleOwner(), this); } @Override @@ -184,47 +186,33 @@ public class TrackScheduleListFragment extends RecyclerViewFragment return false; } - @NonNull @Override - public Loader onCreateLoader(int id, Bundle args) { - Track track = getArguments().getParcelable(ARG_TRACK); - return new TrackScheduleLoader(getActivity(), day, track); - } + public void onChanged(List schedule) { + adapter.submitList(schedule); - @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { - if (data != null) { - adapter.swapCursor(data); - - if (selectionEnabled) { - int selectedPosition = adapter.getPositionForId(selectedId); - if (selectedPosition == RecyclerView.NO_POSITION && adapter.getItemCount() > 0) { - // There is no current valid selection, reset to use the first item - setSelectedId(adapter.getItemId(0)); - selectedPosition = 0; - } - - // Ensure the current selection is visible - if (selectedPosition != RecyclerView.NO_POSITION) { - getRecyclerView().scrollToPosition(selectedPosition); - } - // Notify the parent of the current selection to synchronize its state - notifyEventSelected(selectedPosition, (selectedPosition == RecyclerView.NO_POSITION) ? null : adapter.getItem(selectedPosition)); - - } else if (!isListAlreadyShown) { - final int position = adapter.getPositionForId(selectedId); - if (position != RecyclerView.NO_POSITION) { - getRecyclerView().scrollToPosition(position); - } + if (selectionEnabled) { + int selectedPosition = adapter.getPositionForId(selectedId); + if (selectedPosition == RecyclerView.NO_POSITION && adapter.getItemCount() > 0) { + // There is no current valid selection, reset to use the first item + setSelectedId(adapter.getItemId(0)); + selectedPosition = 0; + } + + // Ensure the current selection is visible + if (selectedPosition != RecyclerView.NO_POSITION) { + getRecyclerView().scrollToPosition(selectedPosition); + } + // Notify the parent of the current selection to synchronize its state + notifyEventSelected(selectedPosition, (selectedPosition == RecyclerView.NO_POSITION) ? null : schedule.get(selectedPosition).getEvent()); + + } else if (!isListAlreadyShown) { + final int position = adapter.getPositionForId(selectedId); + if (position != RecyclerView.NO_POSITION) { + getRecyclerView().scrollToPosition(position); } - isListAlreadyShown = true; } + isListAlreadyShown = true; setProgressBarVisible(false); } - - @Override - public void onLoaderReset(@NonNull Loader loader) { - adapter.swapCursor(null); - } } diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.java index 2a7b70a..5d9d520 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.java @@ -20,7 +20,7 @@ import androidx.lifecycle.Observer; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; import be.digitalia.fosdem.R; -import be.digitalia.fosdem.db.DatabaseManager; +import be.digitalia.fosdem.db.AppDatabase; import be.digitalia.fosdem.model.Day; public class TracksFragment extends Fragment implements RecycledViewPoolProvider, Observer> { @@ -74,7 +74,7 @@ public class TracksFragment extends Fragment implements RecycledViewPoolProvider public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - DatabaseManager.getInstance().getDays() + AppDatabase.getInstance(getContext()).getScheduleDao().getDays() .observe(getViewLifecycleOwner(), this); } diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.java index 12f7b86..73628b9 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.java +++ b/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.java @@ -2,36 +2,31 @@ package be.digitalia.fosdem.fragments; import android.content.Context; import android.content.Intent; -import android.database.Cursor; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; - import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; -import androidx.loader.app.LoaderManager; -import androidx.loader.app.LoaderManager.LoaderCallbacks; -import androidx.loader.content.Loader; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.*; import be.digitalia.fosdem.R; import be.digitalia.fosdem.activities.TrackScheduleActivity; -import be.digitalia.fosdem.adapters.RecyclerViewCursorAdapter; -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.loaders.SimpleCursorLoader; +import be.digitalia.fosdem.adapters.SimpleItemCallback; import be.digitalia.fosdem.model.Day; import be.digitalia.fosdem.model.Track; +import be.digitalia.fosdem.viewmodels.TracksViewModel; -public class TracksListFragment extends RecyclerViewFragment implements LoaderCallbacks { +import java.util.List; + +public class TracksListFragment extends RecyclerViewFragment implements Observer> { - private static final int TRACKS_LOADER_ID = 1; private static final String ARG_DAY = "day"; - Day day; + private Day day; private TracksAdapter adapter; public static TracksListFragment newInstance(Day day) { @@ -45,8 +40,8 @@ public class TracksListFragment extends RecyclerViewFragment implements LoaderCa @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - adapter = new TracksAdapter(); day = getArguments().getParcelable(ARG_DAY); + adapter = new TracksAdapter(day); } @Override @@ -68,77 +63,50 @@ public class TracksListFragment extends RecyclerViewFragment implements LoaderCa setEmptyText(getString(R.string.no_data)); setProgressBarVisible(true); - LoaderManager.getInstance(this).initLoader(TRACKS_LOADER_ID, null, this); - } - - private static class TracksLoader extends SimpleCursorLoader { - - private final Day day; - - public TracksLoader(Context context, Day day) { - super(context); - this.day = day; - } - - @Override - protected Cursor getCursor() { - return DatabaseManager.getInstance().getTracks(day); - } - } - - @NonNull - @Override - public Loader onCreateLoader(int id, Bundle args) { - return new TracksLoader(getActivity(), day); + final TracksViewModel viewModel = ViewModelProviders.of(this).get(TracksViewModel.class); + viewModel.setDay(day); + viewModel.getTracks().observe(getViewLifecycleOwner(), this); } @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { - if (data != null) { - adapter.swapCursor(data); - } - + public void onChanged(List tracks) { + adapter.submitList(tracks); setProgressBarVisible(false); } - @Override - public void onLoaderReset(@NonNull Loader loader) { - adapter.swapCursor(null); - } + private static class TracksAdapter extends ListAdapter { - private class TracksAdapter extends RecyclerViewCursorAdapter { + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new SimpleItemCallback() { + @Override + public boolean areContentsTheSame(@NonNull Track oldItem, @NonNull Track newItem) { + return oldItem.getName().equals(newItem.getName()) + && oldItem.getType() == newItem.getType(); + } + }; - private final LayoutInflater inflater; + private final Day day; - public TracksAdapter() { - inflater = LayoutInflater.from(getContext()); - } - - @Override - public Track getItem(int position) { - return DatabaseManager.toTrack((Cursor) super.getItem(position)); + TracksAdapter(Day day) { + super(DIFF_CALLBACK); + this.day = day; } @NonNull @Override public TrackViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = inflater.inflate(R.layout.simple_list_item_2_material, parent, false); + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.simple_list_item_2_material, parent, false); return new TrackViewHolder(view); } @Override - public void onBindViewHolder(TrackViewHolder holder, Cursor cursor) { - holder.day = day; - holder.track = DatabaseManager.toTrack(cursor, holder.track); - holder.name.setText(holder.track.getName()); - holder.type.setText(holder.track.getType().getNameResId()); - holder.type.setTextColor(ContextCompat.getColor(holder.type.getContext(), holder.track.getType().getColorResId())); + public void onBindViewHolder(@NonNull TrackViewHolder holder, int position) { + holder.bind(day, getItem(position)); } } static class TrackViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { - TextView name; - TextView type; + final TextView name; + final TextView type; Day day; Track track; @@ -150,6 +118,14 @@ public class TracksListFragment extends RecyclerViewFragment implements LoaderCa itemView.setOnClickListener(this); } + void bind(@NonNull Day day, @NonNull Track track) { + this.day = day; + this.track = track; + name.setText(track.getName()); + type.setText(track.getType().getNameResId()); + type.setTextColor(ContextCompat.getColor(type.getContext(), track.getType().getColorResId())); + } + @Override public void onClick(View view) { Context context = view.getContext(); diff --git a/app/src/main/java/be/digitalia/fosdem/livedata/AsyncTaskLiveData.java b/app/src/main/java/be/digitalia/fosdem/livedata/AsyncTaskLiveData.java deleted file mode 100644 index 5f57101..0000000 --- a/app/src/main/java/be/digitalia/fosdem/livedata/AsyncTaskLiveData.java +++ /dev/null @@ -1,100 +0,0 @@ -package be.digitalia.fosdem.livedata; - -import android.annotation.SuppressLint; -import android.os.AsyncTask; - -import androidx.annotation.MainThread; -import androidx.annotation.WorkerThread; -import androidx.lifecycle.MutableLiveData; - -/** - * A LiveData implementation with the same basic functionality as AsyncTaskLoader. - */ -public abstract class AsyncTaskLiveData extends MutableLiveData { - - private boolean contentChanged = false; - AsyncTask task = null; - - @MainThread - public void onContentChanged() { - if (hasActiveObservers()) { - forceLoad(); - } else { - contentChanged = true; - } - } - - @MainThread - public boolean cancelLoad() { - if (task != null) { - boolean result = task.cancel(false); - task = null; - return result; - } - return false; - } - - @Override - protected void onActive() { - if (contentChanged) { - contentChanged = false; - forceLoad(); - } - } - - @Override - public void setValue(T value) { - // Setting a value will cancel any pending AsyncTask - contentChanged = false; - cancelLoad(); - super.setValue(value); - } - - @MainThread - @SuppressLint("StaticFieldLeak") - public void forceLoad() { - cancelLoad(); - task = new AsyncTask() { - - private Throwable error; - - @Override - protected T doInBackground(Void... voids) { - try { - return loadInBackground(); - } catch (Throwable e) { - error = e; - return null; - } - } - - @Override - protected void onPostExecute(T result) { - task = null; - if (error == null) { - onSuccess(result); - } else { - onError(error); - } - } - }; - task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - - @WorkerThread - protected abstract T loadInBackground() throws Exception; - - @MainThread - protected void onSuccess(T result) { - setValue(result); - } - - /** - * Override this method for custom error handling. - */ - @MainThread - protected void onError(Throwable error) { - error.printStackTrace(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/livedata/ExtraTransformations.java b/app/src/main/java/be/digitalia/fosdem/livedata/ExtraTransformations.java new file mode 100644 index 0000000..8dfbbe2 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/livedata/ExtraTransformations.java @@ -0,0 +1,35 @@ +package be.digitalia.fosdem.livedata; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; +import androidx.lifecycle.Observer; + +public class ExtraTransformations { + + private ExtraTransformations() { + } + + @MainThread + @NonNull + public static LiveData distinctUntilChanged(@NonNull LiveData source) { + final MediatorLiveData outputLiveData = new MediatorLiveData<>(); + outputLiveData.addSource(source, new Observer() { + + boolean mFirstTime = true; + + @Override + public void onChanged(X currentValue) { + final X previousValue = outputLiveData.getValue(); + if (mFirstTime + || (previousValue == null && currentValue != null) + || (previousValue != null && !previousValue.equals(currentValue))) { + mFirstTime = false; + outputLiveData.setValue(currentValue); + } + } + }); + return outputLiveData; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/livedata/LiveDataFactory.java b/app/src/main/java/be/digitalia/fosdem/livedata/LiveDataFactory.java new file mode 100644 index 0000000..778b48a --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/livedata/LiveDataFactory.java @@ -0,0 +1,58 @@ +package be.digitalia.fosdem.livedata; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; + +import java.util.concurrent.TimeUnit; + +public class LiveDataFactory { + + static final Handler handler = new Handler(Looper.getMainLooper()); + + private LiveDataFactory() { + } + + private static class IntervalLiveData extends LiveData implements Runnable { + + private final long periodInMillis; + private long updateTime = 0L; + private long version = 0L; + + IntervalLiveData(long periodInMillis) { + this.periodInMillis = periodInMillis; + } + + @Override + protected void onActive() { + final long now = SystemClock.elapsedRealtime(); + if (now >= updateTime) { + update(now); + } else { + handler.postDelayed(this, updateTime - now); + } + } + + @Override + protected void onInactive() { + handler.removeCallbacks(this); + } + + private void update(long now) { + setValue(version++); + updateTime = now + periodInMillis; + handler.postDelayed(this, periodInMillis); + } + + @Override + public void run() { + update(SystemClock.elapsedRealtime()); + } + } + + public static LiveData interval(long period, @NonNull TimeUnit unit) { + return new IntervalLiveData(unit.toMillis(period)); + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/loaders/BaseLiveLoader.java b/app/src/main/java/be/digitalia/fosdem/loaders/BaseLiveLoader.java deleted file mode 100644 index 4ce5181..0000000 --- a/app/src/main/java/be/digitalia/fosdem/loaders/BaseLiveLoader.java +++ /dev/null @@ -1,42 +0,0 @@ -package be.digitalia.fosdem.loaders; - -import android.content.Context; -import android.os.Handler; - -/** - * A cursor loader which also automatically refreshes its data at a specified interval. - * - * @author Christophe Beyls - * - */ -public abstract class BaseLiveLoader extends SimpleCursorLoader { - - private static final long REFRESH_INTERVAL = 60L * 1000L; // 1 minute - - private final Handler handler; - private final Runnable timeoutRunnable = new Runnable() { - - @Override - public void run() { - onContentChanged(); - } - }; - - public BaseLiveLoader(Context context) { - super(context); - this.handler = new Handler(); - } - - @Override - protected void onForceLoad() { - super.onForceLoad(); - handler.removeCallbacks(timeoutRunnable); - handler.postDelayed(timeoutRunnable, REFRESH_INTERVAL); - } - - @Override - protected void onReset() { - super.onReset(); - handler.removeCallbacks(timeoutRunnable); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/loaders/SimpleCursorLoader.java b/app/src/main/java/be/digitalia/fosdem/loaders/SimpleCursorLoader.java deleted file mode 100644 index 9de020f..0000000 --- a/app/src/main/java/be/digitalia/fosdem/loaders/SimpleCursorLoader.java +++ /dev/null @@ -1,105 +0,0 @@ -package be.digitalia.fosdem.loaders; - -import android.content.Context; -import android.database.Cursor; - -import androidx.loader.content.AsyncTaskLoader; - -/** - * A CursorLoader that doesn't need a ContentProvider. - * - * @author Christophe Beyls - */ -public abstract class SimpleCursorLoader extends AsyncTaskLoader { - private final ForceLoadContentObserver mObserver; - - private Cursor mCursor; - - /* Runs on a worker thread */ - @Override - public Cursor loadInBackground() { - Cursor cursor = getCursor(); - if (cursor != null) { - // Ensure the cursor window is filled - cursor.getCount(); - cursor.registerContentObserver(mObserver); - } - return cursor; - } - - /* Runs on the UI thread */ - @Override - public void deliverResult(Cursor cursor) { - if (isReset()) { - // An async query came in while the loader is stopped - if (cursor != null) { - cursor.close(); - } - return; - } - Cursor oldCursor = mCursor; - mCursor = cursor; - - if (isStarted()) { - super.deliverResult(cursor); - } - - if (oldCursor != null && oldCursor != cursor && !oldCursor.isClosed()) { - oldCursor.close(); - } - } - - public SimpleCursorLoader(Context context) { - super(context); - mObserver = new ForceLoadContentObserver(); - } - - /** - * Starts an asynchronous load of the data. When the result is ready the callbacks will be called on the UI thread. If a previous load has been completed - * and is still valid the result may be passed to the callbacks immediately. - * - * Must be called from the UI thread - */ - @Override - protected void onStartLoading() { - if (mCursor != null) { - deliverResult(mCursor); - } - if (takeContentChanged() || mCursor == null) { - forceLoad(); - } - } - - /** - * Must be called from the UI thread - */ - @Override - protected void onStopLoading() { - // Attempt to cancel the current load task if possible. - cancelLoad(); - } - - @Override - public void onCanceled(Cursor cursor) { - if (cursor != null && !cursor.isClosed()) { - cursor.close(); - } - // Retry a refresh the next time the loader is started - onContentChanged(); - } - - @Override - protected void onReset() { - super.onReset(); - - // Ensure the loader is stopped - onStopLoading(); - - if (mCursor != null && !mCursor.isClosed()) { - mCursor.close(); - } - mCursor = null; - } - - protected abstract Cursor getCursor(); -} diff --git a/app/src/main/java/be/digitalia/fosdem/loaders/TrackScheduleLoader.java b/app/src/main/java/be/digitalia/fosdem/loaders/TrackScheduleLoader.java deleted file mode 100644 index 0c5eb4c..0000000 --- a/app/src/main/java/be/digitalia/fosdem/loaders/TrackScheduleLoader.java +++ /dev/null @@ -1,25 +0,0 @@ -package be.digitalia.fosdem.loaders; - -import android.content.Context; -import android.database.Cursor; - -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.model.Day; -import be.digitalia.fosdem.model.Track; - -public class TrackScheduleLoader extends SimpleCursorLoader { - - private final Day day; - private final Track track; - - public TrackScheduleLoader(Context context, Day day, Track track) { - super(context); - this.day = day; - this.track = track; - } - - @Override - protected Cursor getCursor() { - return DatabaseManager.getInstance().getEvents(day, track); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.java b/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.java new file mode 100644 index 0000000..c400221 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.java @@ -0,0 +1,29 @@ +package be.digitalia.fosdem.model; + +import androidx.room.ColumnInfo; +import androidx.room.TypeConverters; +import be.digitalia.fosdem.db.converters.NullableDateTypeConverters; + +import java.util.Date; + +public class AlarmInfo { + + @ColumnInfo(name = "event_id") + private long eventId; + @ColumnInfo(name = "start_time") + @TypeConverters({NullableDateTypeConverters.class}) + private Date startTime; + + public AlarmInfo(long eventId, Date startTime) { + this.eventId = eventId; + this.startTime = startTime; + } + + public long getEventId() { + return eventId; + } + + public Date getStartTime() { + return startTime; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/model/Day.java b/app/src/main/java/be/digitalia/fosdem/model/Day.java index 88a7d4c..a87b23a 100644 --- a/app/src/main/java/be/digitalia/fosdem/model/Day.java +++ b/app/src/main/java/be/digitalia/fosdem/model/Day.java @@ -2,20 +2,29 @@ package be.digitalia.fosdem.model; import android.os.Parcel; import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.room.Entity; +import androidx.room.PrimaryKey; +import androidx.room.TypeConverters; +import be.digitalia.fosdem.db.converters.NonNullDateTypeConverters; +import be.digitalia.fosdem.utils.DateUtils; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; -import androidx.annotation.NonNull; -import be.digitalia.fosdem.utils.DateUtils; - +@Entity(tableName = Day.TABLE_NAME) public class Day implements Comparable, Parcelable { + public static final String TABLE_NAME = "days"; + private static final DateFormat DAY_DATE_FORMAT = DateUtils.withBelgiumTimeZone(new SimpleDateFormat("EEEE", Locale.US)); + @PrimaryKey private int index; + @TypeConverters({NonNullDateTypeConverters.class}) + @NonNull private Date date; public Day() { @@ -29,11 +38,12 @@ public class Day implements Comparable, Parcelable { this.index = index; } + @NonNull public Date getDate() { return date; } - public void setDate(Date date) { + public void setDate(@NonNull Date date) { this.date = date; } @@ -45,6 +55,7 @@ public class Day implements Comparable, Parcelable { return DAY_DATE_FORMAT.format(date); } + @NonNull @Override public String toString() { return getName(); @@ -78,7 +89,7 @@ public class Day implements Comparable, Parcelable { @Override public void writeToParcel(Parcel out, int flags) { out.writeInt(index); - out.writeLong((date == null) ? 0L : date.getTime()); + out.writeLong(date.getTime()); } public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { @@ -93,9 +104,6 @@ public class Day implements Comparable, Parcelable { Day(Parcel in) { index = in.readInt(); - long time = in.readLong(); - if (time != 0L) { - date = new Date(time); - } + date = new Date(in.readLong()); } } diff --git a/app/src/main/java/be/digitalia/fosdem/model/DetailedEvent.java b/app/src/main/java/be/digitalia/fosdem/model/DetailedEvent.java new file mode 100644 index 0000000..2712bc6 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/DetailedEvent.java @@ -0,0 +1,39 @@ +package be.digitalia.fosdem.model; + +import android.text.TextUtils; + +import java.util.List; + +import androidx.annotation.NonNull; + +public class DetailedEvent extends Event { + + @NonNull + private List persons; + @NonNull + private List links; + + @Override + @NonNull + public String getPersonsSummary() { + return TextUtils.join(", ", persons); + } + + @NonNull + public List getPersons() { + return persons; + } + + public void setPersons(@NonNull List persons) { + this.persons = persons; + } + + @NonNull + public List getLinks() { + return links; + } + + public void setLinks(@NonNull List links) { + this.links = links; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/model/Event.java b/app/src/main/java/be/digitalia/fosdem/model/Event.java index fb50629..344be8a 100644 --- a/app/src/main/java/be/digitalia/fosdem/model/Event.java +++ b/app/src/main/java/be/digitalia/fosdem/model/Event.java @@ -2,30 +2,42 @@ package be.digitalia.fosdem.model; import android.os.Parcel; import android.os.Parcelable; -import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Embedded; +import androidx.room.TypeConverters; +import be.digitalia.fosdem.api.FosdemUrls; +import be.digitalia.fosdem.db.converters.NullableDateTypeConverters; +import be.digitalia.fosdem.utils.DateUtils; import java.util.Date; -import java.util.List; - -import be.digitalia.fosdem.api.FosdemUrls; -import be.digitalia.fosdem.utils.DateUtils; public class Event implements Parcelable { private long id; + @Embedded(prefix = "day_") + @NonNull private Day day; + @ColumnInfo(name = "start_time") + @TypeConverters({NullableDateTypeConverters.class}) private Date startTime; + @ColumnInfo(name = "end_time") + @TypeConverters({NullableDateTypeConverters.class}) private Date endTime; + @ColumnInfo(name = "room_name") private String roomName; private String slug; private String title; + @ColumnInfo(name = "subtitle") private String subTitle; + @Embedded(prefix = "track_") + @NonNull private Track track; + @ColumnInfo(name = "abstract") private String abstractText; private String description; + @ColumnInfo(name = "persons") private String personsSummary; - private List persons; // Optional - private List links; // Optional public Event() { } @@ -38,11 +50,12 @@ public class Event implements Parcelable { this.id = id; } + @NonNull public Day getDay() { return day; } - public void setDay(Day day) { + public void setDay(@NonNull Day day) { this.day = day; } @@ -112,11 +125,12 @@ public class Event implements Parcelable { this.subTitle = subTitle; } + @NonNull public Track getTrack() { return track; } - public void setTrack(Track track) { + public void setTrack(@NonNull Track track) { this.track = track; } @@ -136,13 +150,11 @@ public class Event implements Parcelable { this.description = description; } + @NonNull public String getPersonsSummary() { if (personsSummary != null) { return personsSummary; } - if (persons != null) { - return TextUtils.join(", ", persons); - } return ""; } @@ -150,22 +162,7 @@ public class Event implements Parcelable { this.personsSummary = personsSummary; } - public List getPersons() { - return persons; - } - - public void setPersons(List persons) { - this.persons = persons; - } - - public List getLinks() { - return links; - } - - public void setLinks(List links) { - this.links = links; - } - + @NonNull @Override public String toString() { return title; @@ -205,8 +202,6 @@ public class Event implements Parcelable { out.writeString(abstractText); out.writeString(description); out.writeString(personsSummary); - out.writeTypedList(persons); - out.writeTypedList(links); } public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { @@ -238,7 +233,5 @@ public class Event implements Parcelable { abstractText = in.readString(); description = in.readString(); personsSummary = in.readString(); - persons = in.createTypedArrayList(Person.CREATOR); - links = in.createTypedArrayList(Link.CREATOR); } } diff --git a/app/src/main/java/be/digitalia/fosdem/model/EventDetails.java b/app/src/main/java/be/digitalia/fosdem/model/EventDetails.java new file mode 100644 index 0000000..35a43c7 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/EventDetails.java @@ -0,0 +1,28 @@ +package be.digitalia.fosdem.model; + +import androidx.annotation.NonNull; + +import java.util.List; + +public class EventDetails { + + @NonNull + private final List persons; + @NonNull + private final List links; + + public EventDetails(@NonNull List persons, @NonNull List links) { + this.persons = persons; + this.links = links; + } + + @NonNull + public List getPersons() { + return persons; + } + + @NonNull + public List getLinks() { + return links; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/model/Link.java b/app/src/main/java/be/digitalia/fosdem/model/Link.java index fb57f9e..194cadf 100644 --- a/app/src/main/java/be/digitalia/fosdem/model/Link.java +++ b/app/src/main/java/be/digitalia/fosdem/model/Link.java @@ -2,20 +2,50 @@ package be.digitalia.fosdem.model; import android.os.Parcel; import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Index; +import androidx.room.PrimaryKey; +@Entity(tableName = "links", indices = {@Index(value = {"event_id"}, name = "link_event_id_idx")}) public class Link implements Parcelable { + public static final String TABLE_NAME = "links"; + + @PrimaryKey(autoGenerate = true) + private long id; + @ColumnInfo(name = "event_id") + private long eventId; + @NonNull private String url; private String description; public Link() { } + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public long getEventId() { + return eventId; + } + + public void setEventId(long eventId) { + this.eventId = eventId; + } + + @NonNull public String getUrl() { return url; } - public void setUrl(String url) { + public void setUrl(@NonNull String url) { this.url = url; } @@ -27,6 +57,7 @@ public class Link implements Parcelable { this.description = description; } + @NonNull @Override public String toString() { return description; @@ -54,6 +85,8 @@ public class Link implements Parcelable { @Override public void writeToParcel(Parcel out, int flags) { + out.writeLong(id); + out.writeLong(eventId); out.writeString(url); out.writeString(description); } @@ -69,6 +102,8 @@ public class Link implements Parcelable { }; Link(Parcel in) { + id = in.readLong(); + eventId = in.readLong(); url = in.readString(); description = in.readString(); } diff --git a/app/src/main/java/be/digitalia/fosdem/model/Person.java b/app/src/main/java/be/digitalia/fosdem/model/Person.java index 86d7cdf..6fcb82c 100644 --- a/app/src/main/java/be/digitalia/fosdem/model/Person.java +++ b/app/src/main/java/be/digitalia/fosdem/model/Person.java @@ -2,12 +2,22 @@ package be.digitalia.fosdem.model; import android.os.Parcel; import android.os.Parcelable; - +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Fts3; +import androidx.room.PrimaryKey; import be.digitalia.fosdem.api.FosdemUrls; import be.digitalia.fosdem.utils.StringUtils; +@Fts3 +@Entity(tableName = Person.TABLE_NAME) public class Person implements Parcelable { + public static final String TABLE_NAME = "persons"; + + @PrimaryKey + @ColumnInfo(name = "rowid") private long id; private String name; @@ -34,6 +44,7 @@ public class Person implements Parcelable { return FosdemUrls.getPerson(StringUtils.toSlug(name), year); } + @NonNull @Override public String toString() { return name; diff --git a/app/src/main/java/be/digitalia/fosdem/model/StatusEvent.java b/app/src/main/java/be/digitalia/fosdem/model/StatusEvent.java new file mode 100644 index 0000000..7af56d7 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/StatusEvent.java @@ -0,0 +1,39 @@ +package be.digitalia.fosdem.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.ColumnInfo; +import androidx.room.Embedded; + +public class StatusEvent { + + @Embedded + @NonNull + private Event event; + @ColumnInfo(name = "is_bookmarked") + private boolean isBookmarked; + + public StatusEvent(@NonNull Event event, boolean isBookmarked) { + this.event = event; + this.isBookmarked = isBookmarked; + } + + @NonNull + public Event getEvent() { + return event; + } + + public boolean isBookmarked() { + return isBookmarked; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + StatusEvent other = (StatusEvent) obj; + return event.equals(other.event); + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/model/Track.java b/app/src/main/java/be/digitalia/fosdem/model/Track.java index 48bd2db..b12f82b 100644 --- a/app/src/main/java/be/digitalia/fosdem/model/Track.java +++ b/app/src/main/java/be/digitalia/fosdem/model/Track.java @@ -2,13 +2,19 @@ package be.digitalia.fosdem.model; import android.os.Parcel; import android.os.Parcelable; - import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; import androidx.annotation.StringRes; +import androidx.room.Entity; +import androidx.room.Index; +import androidx.room.PrimaryKey; import be.digitalia.fosdem.R; +@Entity(tableName = Track.TABLE_NAME, indices = {@Index(value = {"name", "type"}, name = "track_main_idx", unique = true)}) public class Track implements Parcelable { + public static final String TABLE_NAME = "tracks"; + public enum Type { other(R.string.other, R.color.track_type_other, R.color.track_type_other_dark), keynote(R.string.keynote, R.color.track_type_keynote, R.color.track_type_keynote_dark), @@ -43,33 +49,37 @@ public class Track implements Parcelable { } } + @PrimaryKey + private long id; + @NonNull private String name; + @NonNull private Type type; - public Track() { - } - - public Track(String name, Type type) { + public Track(@NonNull String name, @NonNull Type type) { this.name = name; this.type = type; } + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + @NonNull public String getName() { return name; } - public void setName(String name) { - this.name = name; - } - + @NonNull public Type getType() { return type; } - public void setType(Type type) { - this.type = type; - } - + @NonNull @Override public String toString() { return name; @@ -101,6 +111,7 @@ public class Track implements Parcelable { @Override public void writeToParcel(Parcel out, int flags) { + out.writeLong(id); out.writeString(name); out.writeInt(type.ordinal()); } @@ -116,6 +127,7 @@ public class Track implements Parcelable { }; Track(Parcel in) { + id = in.readLong(); name = in.readString(); type = Type.values()[in.readInt()]; } diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/EventsParser.java b/app/src/main/java/be/digitalia/fosdem/parsers/EventsParser.java index 13517ba..9132ac1 100644 --- a/app/src/main/java/be/digitalia/fosdem/parsers/EventsParser.java +++ b/app/src/main/java/be/digitalia/fosdem/parsers/EventsParser.java @@ -12,7 +12,7 @@ import java.util.List; import java.util.Locale; import be.digitalia.fosdem.model.Day; -import be.digitalia.fosdem.model.Event; +import be.digitalia.fosdem.model.DetailedEvent; import be.digitalia.fosdem.model.Link; import be.digitalia.fosdem.model.Person; import be.digitalia.fosdem.model.Track; @@ -23,7 +23,7 @@ import be.digitalia.fosdem.utils.DateUtils; * * @author Christophe Beyls */ -public class EventsParser extends IterableAbstractPullParser { +public class EventsParser extends IterableAbstractPullParser { private final DateFormat DATE_FORMAT = DateUtils.withBelgiumTimeZone(new SimpleDateFormat("yyyy-MM-dd", Locale.US)); @@ -67,7 +67,7 @@ public class EventsParser extends IterableAbstractPullParser { } @Override - protected Event parseNext(XmlPullParser parser) throws Exception { + protected DetailedEvent parseNext(XmlPullParser parser) throws Exception { while (!isNextEndTag("schedule")) { if (isStartTag()) { @@ -81,7 +81,7 @@ public class EventsParser extends IterableAbstractPullParser { currentRoom = parser.getAttributeValue(null, "name"); break; case "event": - Event event = new Event(); + DetailedEvent event = new DetailedEvent(); event.setId(Long.parseLong(parser.getAttributeValue(null, "id"))); event.setDay(currentDay); event.setRoomName(currentRoom); @@ -151,6 +151,7 @@ public class EventsParser extends IterableAbstractPullParser { while (!isNextEndTag("links")) { if (isStartTag("link")) { Link link = new Link(); + link.setEventId(event.getId()); link.setUrl(parser.getAttributeValue(null, "href")); link.setDescription(parser.nextText()); diff --git a/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.java b/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.java index 8004e78..608ed0b 100644 --- a/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.java +++ b/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.java @@ -11,30 +11,25 @@ import android.os.Build; import android.os.ParcelFileDescriptor; import android.provider.OpenableColumns; import android.text.TextUtils; - -import java.io.BufferedWriter; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.Locale; -import java.util.TimeZone; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.ShareCompat; import be.digitalia.fosdem.BuildConfig; import be.digitalia.fosdem.R; import be.digitalia.fosdem.api.FosdemUrls; -import be.digitalia.fosdem.db.DatabaseManager; +import be.digitalia.fosdem.db.AppDatabase; import be.digitalia.fosdem.model.Event; import be.digitalia.fosdem.utils.DateUtils; import be.digitalia.fosdem.utils.ICalendarWriter; import be.digitalia.fosdem.utils.StringUtils; +import java.io.*; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Locale; +import java.util.TimeZone; + /** * Content Provider generating the current bookmarks list in iCalendar format. */ @@ -105,7 +100,7 @@ public class BookmarksExportProvider extends ContentProvider { for (String col : projection) { if (OpenableColumns.DISPLAY_NAME.equals(col)) { cols[i] = OpenableColumns.DISPLAY_NAME; - values[i++] = getContext().getString(R.string.export_bookmarks_file_name, DatabaseManager.getInstance().getYear()); + values[i++] = getContext().getString(R.string.export_bookmarks_file_name, AppDatabase.getInstance(getContext()).getScheduleDao().getYear()); } else if (OpenableColumns.SIZE.equals(col)) { cols[i] = OpenableColumns.SIZE; // Unknown size, content will be generated on-the-fly @@ -126,7 +121,10 @@ public class BookmarksExportProvider extends ContentProvider { public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { try { ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); - new DownloadThread(new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])).start(); + new DownloadThread( + new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]), + AppDatabase.getInstance(getContext()) + ).start(); return pipe[0]; } catch (IOException e) { throw new FileNotFoundException("Could not open pipe"); @@ -149,15 +147,17 @@ public class BookmarksExportProvider extends ContentProvider { static class DownloadThread extends Thread { private final ICalendarWriter writer; + private final AppDatabase appDatabase; private final Calendar calendar = Calendar.getInstance(DateUtils.getBelgiumTimeZone(), Locale.US); private final DateFormat dateFormat; private final String dtStamp; private final TextUtils.StringSplitter personsSplitter = new StringUtils.SimpleStringSplitter(", "); - DownloadThread(OutputStream out) { + DownloadThread(OutputStream out, AppDatabase appDatabase) { this.writer = new ICalendarWriter(new BufferedWriter(new OutputStreamWriter(out))); // Format all times in GMT + this.appDatabase = appDatabase; this.dateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US); this.dateFormat.setTimeZone(TimeZone.getTimeZone("GMT+0")); this.dtStamp = dateFormat.format(System.currentTimeMillis()); @@ -166,23 +166,16 @@ public class BookmarksExportProvider extends ContentProvider { @Override public void run() { try { - final Cursor cursor = DatabaseManager.getInstance().getBookmarks(0L); - try { - writer.write("BEGIN", "VCALENDAR"); - writer.write("VERSION", "2.0"); - writer.write("PRODID", "-//" + BuildConfig.APPLICATION_ID + "//NONSGML " + BuildConfig.VERSION_NAME + "//EN"); + final Event[] bookmarks = appDatabase.getBookmarksDao().getBookmarks(); + writer.write("BEGIN", "VCALENDAR"); + writer.write("VERSION", "2.0"); + writer.write("PRODID", "-//" + BuildConfig.APPLICATION_ID + "//NONSGML " + BuildConfig.VERSION_NAME + "//EN"); - Event event = null; - while (cursor.moveToNext()) { - event = DatabaseManager.toEvent(cursor, event); - writeEvent(event); - } - - writer.write("END", "VCALENDAR"); - - } finally { - cursor.close(); + for (Event event : bookmarks) { + writeEvent(event); } + + writer.write("END", "VCALENDAR"); } catch (Exception ignore) { } finally { try { diff --git a/app/src/main/java/be/digitalia/fosdem/providers/SearchSuggestionProvider.java b/app/src/main/java/be/digitalia/fosdem/providers/SearchSuggestionProvider.java index a7f0d11..a8d1be0 100644 --- a/app/src/main/java/be/digitalia/fosdem/providers/SearchSuggestionProvider.java +++ b/app/src/main/java/be/digitalia/fosdem/providers/SearchSuggestionProvider.java @@ -6,13 +6,12 @@ import android.content.ContentValues; import android.database.Cursor; import android.net.Uri; import android.text.TextUtils; - import androidx.annotation.NonNull; -import be.digitalia.fosdem.db.DatabaseManager; +import be.digitalia.fosdem.db.AppDatabase; /** * Simple content provider responsible for search suggestions. - * + * * @author Christophe Beyls */ public class SearchSuggestionProvider extends ContentProvider { @@ -60,6 +59,6 @@ public class SearchSuggestionProvider extends ContentProvider { String limitParam = uri.getQueryParameter("limit"); int limit = TextUtils.isEmpty(limitParam) ? DEFAULT_MAX_RESULTS : Integer.parseInt(limitParam); - return DatabaseManager.getInstance().getSearchSuggestionResults(query, limit); + return AppDatabase.getInstance(getContext()).getScheduleDao().getSearchSuggestionResults(query, limit); } } diff --git a/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.java b/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.java index d46e7a2..49dc313 100644 --- a/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.java +++ b/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.java @@ -1,16 +1,11 @@ package be.digitalia.fosdem.services; -import android.app.AlarmManager; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; +import android.app.*; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; -import android.database.Cursor; import android.graphics.Typeface; import android.net.Uri; import android.os.Build; @@ -20,13 +15,9 @@ import android.text.SpannableString; import android.text.TextUtils; import android.text.format.DateUtils; import android.text.style.StyleSpan; - import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; -import androidx.core.app.AlarmManagerCompat; -import androidx.core.app.JobIntentService; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; +import androidx.core.app.*; import androidx.core.app.TaskStackBuilder; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; @@ -35,8 +26,9 @@ import be.digitalia.fosdem.R; import be.digitalia.fosdem.activities.EventDetailsActivity; import be.digitalia.fosdem.activities.MainActivity; import be.digitalia.fosdem.activities.RoomImageDialogActivity; -import be.digitalia.fosdem.db.DatabaseManager; +import be.digitalia.fosdem.db.AppDatabase; import be.digitalia.fosdem.fragments.SettingsFragment; +import be.digitalia.fosdem.model.AlarmInfo; import be.digitalia.fosdem.model.Event; import be.digitalia.fosdem.receivers.AlarmReceiver; import be.digitalia.fosdem.utils.StringUtils; @@ -56,6 +48,12 @@ public class AlarmIntentService extends JobIntentService { public static final String ACTION_UPDATE_ALARMS = BuildConfig.APPLICATION_ID + ".action.UPDATE_ALARMS"; public static final String ACTION_DISABLE_ALARMS = BuildConfig.APPLICATION_ID + ".action.DISABLE_ALARMS"; + public static final String ACTION_ADD_BOOKMARK = BuildConfig.APPLICATION_ID + ".action.ADD_BOOKMARK"; + public static final String EXTRA_EVENT_ID = "event_id"; + public static final String EXTRA_EVENT_START_TIME = "event_start"; + public static final String ACTION_REMOVE_BOOKMARKS = BuildConfig.APPLICATION_ID + ".action.REMOVE_BOOKMARKS"; + public static final String EXTRA_EVENT_IDS = "event_ids"; + private AlarmManager alarmManager; @@ -89,23 +87,16 @@ public class AlarmIntentService extends JobIntentService { final long delay = getDelay(); final long now = System.currentTimeMillis(); boolean hasAlarms = false; - Cursor cursor = DatabaseManager.getInstance().getBookmarks(0L); - try { - while (cursor.moveToNext()) { - long eventId = DatabaseManager.toEventId(cursor); - long notificationTime = DatabaseManager.toEventStartTimeMillis(cursor) - delay; - PendingIntent pi = getAlarmPendingIntent(eventId); - if (notificationTime < now) { - // Cancel pending alarms that are now scheduled in the past, if any - alarmManager.cancel(pi); - } else { - AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC_WAKEUP, notificationTime, pi); - hasAlarms = true; - } + for (AlarmInfo info : AppDatabase.getInstance(this).getBookmarksDao().getBookmarksAlarmInfo(0L)) { + final long notificationTime = info.getStartTime() == null ? -1L : info.getStartTime().getTime() - delay; + PendingIntent pi = getAlarmPendingIntent(info.getEventId()); + if (notificationTime < now) { + // Cancel pending alarms that are now scheduled in the past, if any + alarmManager.cancel(pi); + } else { + AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC_WAKEUP, notificationTime, pi); + hasAlarms = true; } - - } finally { - cursor.close(); } setAlarmReceiverEnabled(hasAlarms); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasAlarms) { @@ -117,24 +108,18 @@ public class AlarmIntentService extends JobIntentService { case ACTION_DISABLE_ALARMS: { // Cancel alarms of every bookmark in the future - Cursor cursor = DatabaseManager.getInstance().getBookmarks(System.currentTimeMillis()); - try { - while (cursor.moveToNext()) { - long eventId = DatabaseManager.toEventId(cursor); - alarmManager.cancel(getAlarmPendingIntent(eventId)); - } - } finally { - cursor.close(); + for (AlarmInfo info : AppDatabase.getInstance(this).getBookmarksDao().getBookmarksAlarmInfo(System.currentTimeMillis())) { + alarmManager.cancel(getAlarmPendingIntent(info.getEventId())); } setAlarmReceiverEnabled(false); break; } - case DatabaseManager.ACTION_ADD_BOOKMARK: { + case ACTION_ADD_BOOKMARK: { long delay = getDelay(); - long eventId = intent.getLongExtra(DatabaseManager.EXTRA_EVENT_ID, -1L); - long startTime = intent.getLongExtra(DatabaseManager.EXTRA_EVENT_START_TIME, -1L); + long eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1L); + long startTime = intent.getLongExtra(EXTRA_EVENT_START_TIME, -1L); // Only schedule future events. If they start before the delay, the alarm will go off immediately if ((startTime == -1L) || (startTime < System.currentTimeMillis())) { break; @@ -147,10 +132,10 @@ public class AlarmIntentService extends JobIntentService { break; } - case DatabaseManager.ACTION_REMOVE_BOOKMARKS: { + case ACTION_REMOVE_BOOKMARKS: { // Cancel matching alarms, might they exist or not - long[] eventIds = intent.getLongArrayExtra(DatabaseManager.EXTRA_EVENT_IDS); + long[] eventIds = intent.getLongArrayExtra(EXTRA_EVENT_IDS); for (long eventId : eventIds) { alarmManager.cancel(getAlarmPendingIntent(eventId)); } @@ -160,7 +145,7 @@ public class AlarmIntentService extends JobIntentService { case AlarmReceiver.ACTION_NOTIFY_EVENT: { long eventId = Long.parseLong(intent.getDataString()); - Event event = DatabaseManager.getInstance().getEvent(eventId); + Event event = AppDatabase.getInstance(this).getScheduleDao().getEvent(eventId); if (event != null) { NotificationManagerCompat.from(this).notify((int) eventId, buildNotification(event)); } diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ArrayUtils.java b/app/src/main/java/be/digitalia/fosdem/utils/ArrayUtils.java deleted file mode 100644 index b944374..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/ArrayUtils.java +++ /dev/null @@ -1,13 +0,0 @@ -package be.digitalia.fosdem.utils; - -public class ArrayUtils { - - public static int indexOf(long[] array, long value) { - for (int i = 0; i < array.length; ++i) { - if (array[i] == value) { - return i; - } - } - return -1; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.java new file mode 100644 index 0000000..f190838 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.java @@ -0,0 +1,73 @@ +package be.digitalia.fosdem.viewmodels; + +import android.app.Application; +import android.os.AsyncTask; +import android.text.format.DateUtils; +import androidx.annotation.NonNull; +import androidx.arch.core.util.Function; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import be.digitalia.fosdem.db.AppDatabase; +import be.digitalia.fosdem.livedata.LiveDataFactory; +import be.digitalia.fosdem.model.Event; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class BookmarksViewModel extends AndroidViewModel { + + // In upcomingOnly mode, events that just started are still shown for 5 minutes + static final long TIME_OFFSET = 5L * DateUtils.MINUTE_IN_MILLIS; + + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); + private final MutableLiveData upcomingOnly = new MutableLiveData<>(); + private final LiveData> bookmarks = Transformations.switchMap(upcomingOnly, + new Function>>() { + @Override + public LiveData> apply(Boolean upcomingOnly) { + if (upcomingOnly == Boolean.TRUE) { + // Refresh upcoming bookmarks every 2 minutes + final LiveData heartbeat = LiveDataFactory.interval(2L, TimeUnit.MINUTES); + return Transformations.switchMap(heartbeat, + new Function>>() { + @Override + public LiveData> apply(Long version) { + return appDatabase.getBookmarksDao().getBookmarks(System.currentTimeMillis() - TIME_OFFSET); + } + }); + } + + return appDatabase.getBookmarksDao().getBookmarks(-1L); + } + }); + + public BookmarksViewModel(@NonNull Application application) { + super(application); + } + + public void setUpcomingOnly(boolean upcomingOnly) { + final Boolean boxedUpcomingOnly = upcomingOnly; + if (!boxedUpcomingOnly.equals(this.upcomingOnly.getValue())) { + this.upcomingOnly.setValue(boxedUpcomingOnly); + } + } + + public boolean getUpcomingOnly() { + return Boolean.TRUE.equals(this.upcomingOnly.getValue()); + } + + public LiveData> getBookmarks() { + return bookmarks; + } + + public void removeBookmarks(final long[] eventIds) { + AsyncTask.SERIAL_EXECUTOR.execute(new Runnable() { + @Override + public void run() { + appDatabase.getBookmarksDao().removeBookmarks(eventIds); + } + }); + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/EventDetailsViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/EventDetailsViewModel.java index 55583f5..05cca13 100644 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/EventDetailsViewModel.java +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/EventDetailsViewModel.java @@ -1,87 +1,47 @@ package be.digitalia.fosdem.viewmodels; import android.app.Application; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; import android.os.AsyncTask; - -import java.util.List; - import androidx.annotation.NonNull; +import androidx.arch.core.util.Function; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.livedata.AsyncTaskLiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import be.digitalia.fosdem.db.AppDatabase; +import be.digitalia.fosdem.livedata.ExtraTransformations; import be.digitalia.fosdem.model.Event; -import be.digitalia.fosdem.model.Link; -import be.digitalia.fosdem.model.Person; -import be.digitalia.fosdem.utils.ArrayUtils; +import be.digitalia.fosdem.model.EventDetails; public class EventDetailsViewModel extends AndroidViewModel { - public static class EventDetails { - public List persons; - public List links; - } - - private Event event = null; - - private final AsyncTaskLiveData bookmarkStatus = new AsyncTaskLiveData() { - - @Override - protected Boolean loadInBackground() throws Exception { - return DatabaseManager.getInstance().isBookmarked(event); - } - }; - private final AsyncTaskLiveData eventDetails = new AsyncTaskLiveData() { - - @Override - protected EventDetails loadInBackground() throws Exception { - EventDetails result = new EventDetails(); - DatabaseManager dbm = DatabaseManager.getInstance(); - result.persons = dbm.getPersons(event); - result.links = dbm.getLinks(event); - return result; - } - }; - - private final BroadcastReceiver addBookmarkReceiver = new BroadcastReceiver() { - - @Override - public void onReceive(Context context, Intent intent) { - if (event.getId() == intent.getLongExtra(DatabaseManager.EXTRA_EVENT_ID, -1L)) { - bookmarkStatus.setValue(true); - } - } - }; - private final BroadcastReceiver removeBookmarksReceiver = new BroadcastReceiver() { - - @Override - public void onReceive(Context context, Intent intent) { - long[] eventIds = intent.getLongArrayExtra(DatabaseManager.EXTRA_EVENT_IDS); - if (ArrayUtils.indexOf(eventIds, event.getId()) != -1) { - bookmarkStatus.setValue(false); - } - } - }; + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); + private final MutableLiveData event = new MutableLiveData<>(); + private final LiveData bookmarkStatus = Transformations.switchMap(event, + new Function>() { + @Override + public LiveData apply(Event event) { + // Prevent animating the UI when a bookmark is added back or removed back + return ExtraTransformations.distinctUntilChanged( + appDatabase.getBookmarksDao().getBookmarkStatus(event) + ); + } + }); + private final LiveData eventDetails = Transformations.switchMap(event, + new Function>() { + @Override + public LiveData apply(Event event) { + return appDatabase.getScheduleDao().getEventDetails(event); + } + }); public EventDetailsViewModel(@NonNull Application application) { super(application); } public void setEvent(@NonNull Event event) { - if (this.event == null) { - this.event = event; - - bookmarkStatus.forceLoad(); - LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(getApplication()); - lbm.registerReceiver(addBookmarkReceiver, new IntentFilter(DatabaseManager.ACTION_ADD_BOOKMARK)); - lbm.registerReceiver(removeBookmarksReceiver, new IntentFilter(DatabaseManager.ACTION_REMOVE_BOOKMARKS)); - - eventDetails.forceLoad(); + if (!event.equals(this.event.getValue())) { + this.event.setValue(event); } } @@ -90,39 +50,23 @@ public class EventDetailsViewModel extends AndroidViewModel { } public void toggleBookmarkStatus() { - Boolean isBookmarked = bookmarkStatus.getValue(); - if (isBookmarked != null) { - new ToggleBookmarkAsyncTask(event).execute(isBookmarked); - } - } - - private static class ToggleBookmarkAsyncTask extends AsyncTask { - - private final Event event; - - public ToggleBookmarkAsyncTask(Event event) { - this.event = event; - } - - @Override - protected Void doInBackground(Boolean... remove) { - if (remove[0]) { - DatabaseManager.getInstance().removeBookmark(event); - } else { - DatabaseManager.getInstance().addBookmark(event); - } - return null; + final Event event = this.event.getValue(); + final Boolean isBookmarked = bookmarkStatus.getValue(); + if (event != null && isBookmarked != null) { + AsyncTask.SERIAL_EXECUTOR.execute(new Runnable() { + @Override + public void run() { + if (isBookmarked) { + appDatabase.getBookmarksDao().removeBookmark(event); + } else { + appDatabase.getBookmarksDao().addBookmark(event); + } + } + }); } } public LiveData getEventDetails() { return eventDetails; } - - @Override - protected void onCleared() { - LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(getApplication()); - lbm.unregisterReceiver(addBookmarkReceiver); - lbm.unregisterReceiver(removeBookmarksReceiver); - } } diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/EventViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/EventViewModel.java index 9c0a15c..a61b233 100644 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/EventViewModel.java +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/EventViewModel.java @@ -1,30 +1,49 @@ package be.digitalia.fosdem.viewmodels; +import android.app.Application; +import android.os.AsyncTask; + +import androidx.annotation.NonNull; +import androidx.arch.core.util.Function; +import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; -import androidx.lifecycle.ViewModel; -import be.digitalia.fosdem.db.DatabaseManager; -import be.digitalia.fosdem.livedata.AsyncTaskLiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import be.digitalia.fosdem.db.AppDatabase; import be.digitalia.fosdem.model.Event; -public class EventViewModel extends ViewModel { +public class EventViewModel extends AndroidViewModel { - private long eventId = -1L; + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); + private final MutableLiveData eventId = new MutableLiveData<>(); + private final LiveData event = Transformations.switchMap(eventId, + new Function>() { + @Override + public LiveData apply(final Long id) { + final MutableLiveData resultLiveData = new MutableLiveData<>(); + AsyncTask.THREAD_POOL_EXECUTOR.execute(new Runnable() { + @Override + public void run() { + final Event result = appDatabase.getScheduleDao().getEvent(id); + resultLiveData.postValue(result); + } + }); + return resultLiveData; + } + }); - private final AsyncTaskLiveData event = new AsyncTaskLiveData() { - @Override - protected Event loadInBackground() throws Exception { - return DatabaseManager.getInstance().getEvent(eventId); - } - }; + public EventViewModel(@NonNull Application application) { + super(application); + } public boolean hasEventId() { - return this.eventId != -1L; + return this.eventId.getValue() != null; } public void setEventId(long eventId) { - if (this.eventId != eventId) { - this.eventId = eventId; - event.forceLoad(); + Long newEventId = eventId; + if (!newEventId.equals(this.eventId.getValue())) { + this.eventId.setValue(newEventId); } } diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/LiveViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/LiveViewModel.java new file mode 100644 index 0000000..bcd148e --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/LiveViewModel.java @@ -0,0 +1,54 @@ +package be.digitalia.fosdem.viewmodels; + +import android.app.Application; +import android.text.format.DateUtils; +import androidx.annotation.NonNull; +import androidx.arch.core.util.Function; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.paging.LivePagedListBuilder; +import androidx.paging.PagedList; +import be.digitalia.fosdem.db.AppDatabase; +import be.digitalia.fosdem.livedata.LiveDataFactory; +import be.digitalia.fosdem.model.StatusEvent; + +import java.util.concurrent.TimeUnit; + +public class LiveViewModel extends AndroidViewModel { + + static final long NEXT_EVENTS_INTERVAL = 30L * DateUtils.MINUTE_IN_MILLIS; + + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); + private final LiveData heartbeat = LiveDataFactory.interval(1L, TimeUnit.MINUTES); + private final LiveData> nextEvents = Transformations.switchMap(heartbeat, + new Function>>() { + @Override + public LiveData> apply(Long version) { + final long now = System.currentTimeMillis(); + return new LivePagedListBuilder<>(appDatabase.getScheduleDao().getEventsWithStartTime(now, now + NEXT_EVENTS_INTERVAL), 20) + .build(); + } + }); + private final LiveData> eventsInProgress = Transformations.switchMap(heartbeat, + new Function>>() { + @Override + public LiveData> apply(Long version) { + final long now = System.currentTimeMillis(); + return new LivePagedListBuilder<>(appDatabase.getScheduleDao().getEventsInProgress(now), 20) + .build(); + } + }); + + public LiveViewModel(@NonNull Application application) { + super(application); + } + + public LiveData> getNextEvents() { + return nextEvents; + } + + public LiveData> getEventsInProgress() { + return eventsInProgress; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonInfoViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonInfoViewModel.java new file mode 100644 index 0000000..c7c7e6f --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonInfoViewModel.java @@ -0,0 +1,42 @@ +package be.digitalia.fosdem.viewmodels; + +import android.app.Application; +import androidx.annotation.NonNull; +import androidx.arch.core.util.Function; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.paging.LivePagedListBuilder; +import androidx.paging.PagedList; +import be.digitalia.fosdem.db.AppDatabase; +import be.digitalia.fosdem.model.Person; +import be.digitalia.fosdem.model.StatusEvent; + +public class PersonInfoViewModel extends AndroidViewModel { + + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); + private final MutableLiveData person = new MutableLiveData<>(); + private final LiveData> events = Transformations.switchMap(person, + new Function>>() { + @Override + public LiveData> apply(Person person) { + return new LivePagedListBuilder<>(appDatabase.getScheduleDao().getEvents(person), 20) + .build(); + } + }); + + public PersonInfoViewModel(@NonNull Application application) { + super(application); + } + + public void setPerson(@NonNull Person person) { + if (!person.equals(this.person.getValue())) { + this.person.setValue(person); + } + } + + public LiveData> getEvents() { + return events; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonsViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonsViewModel.java new file mode 100644 index 0000000..f217cf7 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonsViewModel.java @@ -0,0 +1,27 @@ +package be.digitalia.fosdem.viewmodels; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.paging.LivePagedListBuilder; +import androidx.paging.PagedList; +import be.digitalia.fosdem.db.AppDatabase; +import be.digitalia.fosdem.model.Person; + +public class PersonsViewModel extends AndroidViewModel { + + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); + private final LiveData> persons + = new LivePagedListBuilder<>(appDatabase.getScheduleDao().getPersons(), 100) + .build(); + + public PersonsViewModel(@NonNull Application application) { + super(application); + } + + public LiveData> getPersons() { + return persons; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/SearchViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/SearchViewModel.java new file mode 100644 index 0000000..fa2aaf5 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/SearchViewModel.java @@ -0,0 +1,58 @@ +package be.digitalia.fosdem.viewmodels; + +import android.app.Application; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.arch.core.util.Function; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.paging.LivePagedListBuilder; +import androidx.paging.PagedList; +import be.digitalia.fosdem.db.AppDatabase; +import be.digitalia.fosdem.model.StatusEvent; + +public class SearchViewModel extends AndroidViewModel { + + private static final int MIN_SEARCH_LENGTH = 3; + + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); + private final MutableLiveData query = new MutableLiveData<>(); + private final LiveData> results = Transformations.switchMap(query, + new Function>>() { + @Override + public LiveData> apply(String query) { + if (isQueryTooShort(query)) { + MutableLiveData> emptyResult = new MutableLiveData<>(); + emptyResult.setValue(null); + return emptyResult; + } + return new LivePagedListBuilder<>(appDatabase.getScheduleDao().getSearchResults(query), 20) + .build(); + } + }); + + public SearchViewModel(@NonNull Application application) { + super(application); + } + + public void setQuery(@NonNull String query) { + if (!query.equals(this.query.getValue())) { + this.query.setValue(query); + } + } + + @Nullable + public String getQuery() { + return query.getValue(); + } + + public static boolean isQueryTooShort(String value) { + return (value == null) || (value.length() < MIN_SEARCH_LENGTH); + } + + public LiveData> getResults() { + return results; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleViewModel.java new file mode 100644 index 0000000..e50738a --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleViewModel.java @@ -0,0 +1,44 @@ +package be.digitalia.fosdem.viewmodels; + +import android.app.Application; +import androidx.annotation.NonNull; +import androidx.arch.core.util.Function; +import androidx.core.util.Pair; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import be.digitalia.fosdem.db.AppDatabase; +import be.digitalia.fosdem.model.Day; +import be.digitalia.fosdem.model.StatusEvent; +import be.digitalia.fosdem.model.Track; + +import java.util.List; + +public class TrackScheduleViewModel extends AndroidViewModel { + + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); + private final MutableLiveData> dayTrack = new MutableLiveData<>(); + private final LiveData> schedule = Transformations.switchMap(dayTrack, + new Function, LiveData>>() { + @Override + public LiveData> apply(Pair dayTrack) { + return appDatabase.getScheduleDao().getEvents(dayTrack.first, dayTrack.second); + } + }); + + public TrackScheduleViewModel(@NonNull Application application) { + super(application); + } + + public void setTrack(@NonNull Day day, @NonNull Track track) { + Pair dayTrack = Pair.create(day, track); + if (!dayTrack.equals(this.dayTrack.getValue())) { + this.dayTrack.setValue(dayTrack); + } + } + + public LiveData> getSchedule() { + return schedule; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.java new file mode 100644 index 0000000..705ae0d --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.java @@ -0,0 +1,42 @@ +package be.digitalia.fosdem.viewmodels; + +import android.app.Application; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.arch.core.util.Function; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import be.digitalia.fosdem.db.AppDatabase; +import be.digitalia.fosdem.model.Day; +import be.digitalia.fosdem.model.Track; + +public class TracksViewModel extends AndroidViewModel { + + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); + private final MutableLiveData day = new MutableLiveData<>(); + private final LiveData> tracks = Transformations.switchMap(day, + new Function>>() { + @Override + public LiveData> apply(Day day) { + return appDatabase.getScheduleDao().getTracks(day); + } + }); + + public TracksViewModel(@NonNull Application application) { + super(application); + } + + public void setDay(@NonNull Day day) { + if (!day.equals(this.day.getValue())) { + this.day.setValue(day); + } + } + + public LiveData> getTracks() { + return tracks; + } +} diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/MultiChoiceHelper.java b/app/src/main/java/be/digitalia/fosdem/widgets/MultiChoiceHelper.java index 86d3232..35d4acb 100644 --- a/app/src/main/java/be/digitalia/fosdem/widgets/MultiChoiceHelper.java +++ b/app/src/main/java/be/digitalia/fosdem/widgets/MultiChoiceHelper.java @@ -1,6 +1,5 @@ package be.digitalia.fosdem.widgets; -import android.content.Context; import android.os.Parcel; import android.os.Parcelable; import android.util.SparseBooleanArray; @@ -8,7 +7,6 @@ import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.Checkable; - import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; @@ -31,18 +29,18 @@ public class MultiChoiceHelper { public static abstract class ViewHolder extends RecyclerView.ViewHolder { View.OnClickListener clickListener; - MultiChoiceHelper multiChoiceHelper; + final MultiChoiceHelper multiChoiceHelper; - public ViewHolder(View itemView) { + public ViewHolder(@NonNull View itemView, @NonNull MultiChoiceHelper helper) { super(itemView); + multiChoiceHelper = helper; itemView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { if (isMultiChoiceActive()) { int position = getAdapterPosition(); if (position != RecyclerView.NO_POSITION) { - multiChoiceHelper.toggleItemChecked(position, false); - updateCheckedState(position); + multiChoiceHelper.toggleItemChecked(position); } } else { if (clickListener != null) { @@ -54,41 +52,36 @@ public class MultiChoiceHelper { itemView.setOnLongClickListener(new View.OnLongClickListener() { @Override public boolean onLongClick(View view) { - if ((multiChoiceHelper == null) || isMultiChoiceActive()) { + if (isMultiChoiceActive()) { return false; } int position = getAdapterPosition(); if (position != RecyclerView.NO_POSITION) { - multiChoiceHelper.setItemChecked(position, true, false); - updateCheckedState(position); + multiChoiceHelper.setItemChecked(position, true); } return true; } }); } - void updateCheckedState(int position) { - final boolean isChecked = multiChoiceHelper.isItemChecked(position); - if (itemView instanceof Checkable) { - ((Checkable) itemView).setChecked(isChecked); - } else { - itemView.setActivated(isChecked); - } - } - public void setOnClickListener(View.OnClickListener clickListener) { this.clickListener = clickListener; } - public void bind(MultiChoiceHelper multiChoiceHelper, int position) { - this.multiChoiceHelper = multiChoiceHelper; - if (multiChoiceHelper != null) { - updateCheckedState(position); + public void bindSelection() { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + final boolean isChecked = multiChoiceHelper.isItemChecked(position); + if (itemView instanceof Checkable) { + ((Checkable) itemView).setChecked(isChecked); + } else { + itemView.setActivated(isChecked); + } } } public boolean isMultiChoiceActive() { - return (multiChoiceHelper != null) && (multiChoiceHelper.getCheckedItemCount() > 0); + return multiChoiceHelper.getCheckedItemCount() > 0; } } @@ -105,6 +98,8 @@ public class MultiChoiceHelper { void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked); } + public static final Object SELECTION_PAYLOAD = new Object(); + private static final int CHECK_POSITION_SEARCH_DISTANCE = 20; private final AppCompatActivity activity; @@ -129,10 +124,6 @@ public class MultiChoiceHelper { } } - public Context getContext() { - return activity; - } - public void setMultiChoiceModeListener(MultiChoiceModeListener listener) { if (listener == null) { multiChoiceModeCallback = null; @@ -182,7 +173,7 @@ public class MultiChoiceHelper { } checkedItemCount = 0; - adapter.notifyItemRangeChanged(start, end - start + 1); + adapter.notifyItemRangeChanged(start, end - start + 1, SELECTION_PAYLOAD); if (choiceActionMode != null) { choiceActionMode.finish(); @@ -190,7 +181,7 @@ public class MultiChoiceHelper { } } - public void setItemChecked(int position, boolean value, boolean notifyChanged) { + public void setItemChecked(int position, boolean value) { // Start selection mode if needed. We don't need to if we're unchecking something. if (value) { startSupportActionModeIfNeeded(); @@ -216,9 +207,7 @@ public class MultiChoiceHelper { checkedItemCount--; } - if (notifyChanged) { - adapter.notifyItemChanged(position); - } + adapter.notifyItemChanged(position, SELECTION_PAYLOAD); if (choiceActionMode != null) { multiChoiceModeCallback.onItemCheckedStateChanged(choiceActionMode, position, id, value); @@ -229,8 +218,8 @@ public class MultiChoiceHelper { } } - public void toggleItemChecked(int position, boolean notifyChanged) { - setItemChecked(position, !isItemChecked(position), notifyChanged); + public void toggleItemChecked(int position) { + setItemChecked(position, !isItemChecked(position)); } public Parcelable onSaveInstanceState() {