1
0
Fork 0
mirror of https://github.com/MatomoCamp/matomocamp-companion-android.git synced 2024-09-19 16:13:46 +02:00

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
This commit is contained in:
Christophe Beyls 2019-01-28 13:30:07 +01:00 committed by GitHub
parent f943edf747
commit 4df07a40b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2640 additions and 2625 deletions

View file

@ -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'
}

View file

@ -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)

View file

@ -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<Long> lastUpdateTimeObserver = new Observer<Long>() {
@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);

View file

@ -77,7 +77,7 @@ public class RoomImageDialogActivity extends AppCompatActivity {
});
// Display the room status as subtitle
FosdemApi.getRoomStatuses().observe(owner, new Observer<Map<String, RoomStatus>>() {
FosdemApi.getRoomStatuses(toolbar.getContext()).observe(owner, new Observer<Map<String, RoomStatus>>() {
@Override
public void onChanged(Map<String, RoomStatus> roomStatuses) {
RoomStatus roomStatus = roomStatuses.get(roomName);

View file

@ -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;
}

View file

@ -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<Cursor>, CreateNfcAppDataCallback {
public class TrackScheduleEventActivity extends AppCompatActivity implements Observer<List<StatusEvent>>, 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<Cursor> onCreateLoader(int id, Bundle args) {
return new TrackScheduleLoader(this, day, track);
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
public void onChanged(List<StatusEvent> 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<Cursor> loader) {
adapter.setCursor(null);
}
public static class TrackScheduleEventAdapter extends FragmentStatePagerAdapter {
private Cursor cursor;
private List<StatusEvent> 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<StatusEvent> 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();
}
}
}

View file

@ -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<Event, BookmarksAdapter.ViewHolder>
implements Observer<Map<String, RoomStatus>> {
private static final DiffUtil.ItemCallback<Event> DIFF_CALLBACK = new SimpleItemCallback<Event>() {
@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<RecyclerView.AdapterDataObserver, BookmarksDataObserverWrapper> observers = new SimpleArrayMap<>();
final MultiChoiceHelper multiChoiceHelper;
private Map<String, RoomStatus> 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<String, RoomStatus> 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<Object> 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<long[], Void, Void> {
@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);
}
}
}

View file

@ -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<EventsAdapter.ViewHolder>
public class EventsAdapter extends PagedListAdapter<StatusEvent, EventsAdapter.ViewHolder>
implements Observer<Map<String, RoomStatus>> {
private static final DiffUtil.ItemCallback<StatusEvent> DIFF_CALLBACK = new SimpleItemCallback<StatusEvent>() {
@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<String, RoomStatus> roomStatuses;
private Map<String, RoomStatus> 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<EventsAdapter.ViewH
notifyItemRangeChanged(0, getItemCount(), DETAILS_PAYLOAD);
}
@Override
public Event getItem(int position) {
return DatabaseManager.toEvent((Cursor) super.getItem(position));
}
@Override
public int getItemViewType(int position) {
return R.layout.item_event;
@ -76,67 +86,24 @@ public class EventsAdapter extends RecyclerViewCursorAdapter<EventsAdapter.ViewH
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = inflater.inflate(R.layout.item_event, parent, false);
return new ViewHolder(view);
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_event, parent, false);
return new ViewHolder(view, timeDateFormat);
}
private RoomStatus getRoomStatus(Event event) {
return (roomStatuses == null) ? null : roomStatuses.get(event.getRoomName());
}
@Override
public void onBindViewHolder(ViewHolder holder, Cursor cursor) {
Context context = holder.itemView.getContext();
Event event = DatabaseManager.toEvent(cursor, holder.event);
holder.event = event;
holder.title.setText(event.getTitle());
boolean isBookmarked = DatabaseManager.toBookmarkStatus(cursor);
Drawable bookmarkDrawable = isBookmarked
? AppCompatResources.getDrawable(context, R.drawable.ic_bookmark_grey600_24dp)
: null;
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(holder.title, null, null, bookmarkDrawable, null);
holder.title.setContentDescription(isBookmarked
? context.getString(R.string.in_bookmarks_content_description, event.getTitle())
: null
);
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);
}
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();
CharSequence details;
if (showDay) {
details = String.format("%1$s, %2$s ― %3$s | %4$s", event.getDay().getShortName(), startTimeString, endTimeString, roomName);
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
final StatusEvent statusEvent = getItem(position);
if (statusEvent == null) {
holder.clear();
} else {
details = String.format("%1$s ― %2$s | %3$s", startTimeString, endTimeString, roomName);
final Event event = statusEvent.getEvent();
holder.bind(event, statusEvent.isBookmarked());
holder.bindDetails(event, showDay, getRoomStatus(event));
}
CharSequence detailsDescription = details;
if (roomStatuses != null) {
RoomStatus roomStatus = roomStatuses.get(roomName);
if (roomStatus != null) {
SpannableString detailsSpannable = new SpannableString(details);
int color = ContextCompat.getColor(context, roomStatus.getColorResId());
detailsSpannable.setSpan(new ForegroundColorSpan(color),
details.length() - roomName.length(),
details.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
details = detailsSpannable;
detailsDescription = String.format("%1$s (%2$s)", detailsDescription, context.getString(roomStatus.getNameResId()));
}
}
holder.details.setText(details);
holder.details.setContentDescription(context.getString(R.string.details_content_description, detailsDescription));
}
@Override
@ -144,36 +111,104 @@ public class EventsAdapter extends RecyclerViewCursorAdapter<EventsAdapter.ViewH
if (payloads.isEmpty()) {
onBindViewHolder(holder, position);
} else {
if (payloads.contains(DETAILS_PAYLOAD) && (holder.event != null)) {
bindDetails(holder, holder.event);
final StatusEvent statusEvent = getItem(position);
if (statusEvent != null) {
if (payloads.contains(DETAILS_PAYLOAD)) {
final Event event = statusEvent.getEvent();
holder.bindDetails(event, showDay, getRoomStatus(event));
}
}
}
}
static class ViewHolder extends MultiChoiceHelper.ViewHolder implements View.OnClickListener {
TextView title;
TextView persons;
TextView trackName;
TextView details;
static class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
final TextView title;
final TextView persons;
final TextView trackName;
final TextView details;
private final DateFormat timeDateFormat;
Event event;
boolean isOverlapping;
public ViewHolder(View itemView) {
ViewHolder(View itemView, @NonNull DateFormat timeDateFormat) {
super(itemView);
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);
itemView.setOnClickListener(this);
this.timeDateFormat = timeDateFormat;
}
void clear() {
this.event = null;
title.setText(null);
persons.setText(null);
trackName.setText(null);
details.setText(null);
}
void bind(@NonNull Event event, boolean isBookmarked) {
Context context = itemView.getContext();
this.event = event;
title.setText(event.getTitle());
Drawable bookmarkDrawable = isBookmarked
? AppCompatResources.getDrawable(context, R.drawable.ic_bookmark_grey600_24dp)
: null;
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(title, null, null, bookmarkDrawable, null);
title.setContentDescription(isBookmarked
? context.getString(R.string.in_bookmarks_content_description, event.getTitle())
: null
);
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, boolean showDay, @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();
CharSequence detailsText;
if (showDay) {
detailsText = String.format("%1$s, %2$s ― %3$s | %4$s", event.getDay().getShortName(), startTimeString, endTimeString, roomName);
} else {
detailsText = String.format("%1$s ― %2$s | %3$s", startTimeString, endTimeString, roomName);
}
CharSequence detailsDescription = detailsText;
if (roomStatus != null) {
SpannableString detailsSpannable = new SpannableString(detailsText);
int color = ContextCompat.getColor(context, roomStatus.getColorResId());
detailsSpannable.setSpan(new ForegroundColorSpan(color),
detailsText.length() - roomName.length(),
detailsText.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
detailsText = detailsSpannable;
detailsDescription = String.format("%1$s (%2$s)", detailsDescription, context.getString(roomStatus.getNameResId()));
}
details.setText(detailsText);
details.setContentDescription(context.getString(R.string.details_content_description, detailsDescription));
}
@Override
public void onClick(View view) {
Context context = view.getContext();
Intent intent = new Intent(context, EventDetailsActivity.class)
.putExtra(EventDetailsActivity.EXTRA_EVENT, event);
context.startActivity(intent);
if (event != null) {
Context context = view.getContext();
Intent intent = new Intent(context, EventDetailsActivity.class)
.putExtra(EventDetailsActivity.EXTRA_EVENT, event);
context.startActivity(intent);
}
}
}
}

View file

@ -1,80 +0,0 @@
package be.digitalia.fosdem.adapters;
import android.database.Cursor;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
/**
* Simplified CursorAdapter designed for RecyclerView.
*
* @author Christophe Beyls
*/
public abstract class RecyclerViewCursorAdapter<VH extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<VH> {
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);
}

View file

@ -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<T> extends DiffUtil.ItemCallback<T> {
@Override
public boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem) {
return ObjectsCompat.equals(oldItem, newItem);
}
}

View file

@ -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<TrackScheduleAdapter.ViewHolder> {
import java.text.DateFormat;
import java.util.List;
public class TrackScheduleAdapter extends ListAdapter<StatusEvent, TrackScheduleAdapter.ViewHolder> {
public interface EventClickListener {
void onEventClick(int position, Event event);
}
private static final DiffUtil.ItemCallback<StatusEvent> DIFF_CALLBACK = new SimpleItemCallback<StatusEvent>() {
@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<TrackSchedul
}
@Override
public Event getItem(int position) {
return DatabaseManager.toEvent((Cursor) super.getItem(position));
public long getItemId(int position) {
return getItem(position).getEvent().getId();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = inflater.inflate(R.layout.item_schedule_event, parent, false);
return new ViewHolder(view, R.drawable.activated_background, listener);
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_schedule_event, parent, false);
return new ViewHolder(view, R.drawable.activated_background);
}
@Override
public void onBindViewHolder(ViewHolder holder, Cursor cursor) {
Context context = holder.itemView.getContext();
Event event = DatabaseManager.toEvent(cursor, holder.event);
holder.event = event;
holder.time.setText(timeDateFormat.format(event.getStartTime()));
bindTimeColors(holder, event);
holder.title.setText(event.getTitle());
boolean isBookmarked = DatabaseManager.toBookmarkStatus(cursor);
Drawable bookmarkDrawable = isBookmarked
? AppCompatResources.getDrawable(context, R.drawable.ic_bookmark_grey600_24dp)
: null;
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(holder.title, null, null, bookmarkDrawable, null);
holder.title.setContentDescription(isBookmarked
? context.getString(R.string.in_bookmarks_content_description, event.getTitle())
: null
);
String personsSummary = event.getPersonsSummary();
holder.persons.setText(personsSummary);
holder.persons.setVisibility(TextUtils.isEmpty(personsSummary) ? View.GONE : View.VISIBLE);
holder.room.setText(event.getRoomName());
holder.room.setContentDescription(context.getString(R.string.room_content_description, event.getRoomName()));
bindSelection(holder, event);
}
private void bindTimeColors(ViewHolder holder, Event event) {
final TextView timeTextView = holder.time;
if ((currentTime != -1L) && event.isRunningAtTime(currentTime)) {
// Contrast colors for running event
timeTextView.setBackgroundColor(timeRunningBackgroundColor);
timeTextView.setTextColor(timeRunningForegroundColor);
timeTextView.setContentDescription(timeTextView.getContext().getString(R.string.in_progress_content_description, timeTextView.getText()));
} else {
// Normal colors
timeTextView.setBackgroundColor(timeBackgroundColor);
timeTextView.setTextColor(timeForegroundColor);
// Use text as content description
timeTextView.setContentDescription(null);
}
}
private void bindSelection(ViewHolder holder, Event event) {
holder.itemView.setActivated(event.getId() == selectedId);
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
final StatusEvent statusEvent = getItem(position);
final Event event = statusEvent.getEvent();
holder.bind(event, statusEvent.isBookmarked());
holder.bindTimeColors(event, currentTime);
holder.bindSelection(event.getId() == selectedId);
}
@Override
@ -163,28 +137,25 @@ public class TrackScheduleAdapter extends RecyclerViewCursorAdapter<TrackSchedul
if (payloads.isEmpty()) {
onBindViewHolder(holder, position);
} else {
if (holder.event != null) {
if (payloads.contains(TIME_COLORS_PAYLOAD)) {
bindTimeColors(holder, holder.event);
}
if (payloads.contains(SELECTION_PAYLOAD)) {
bindSelection(holder, holder.event);
}
final StatusEvent statusEvent = getItem(position);
if (payloads.contains(TIME_COLORS_PAYLOAD)) {
holder.bindTimeColors(statusEvent.getEvent(), currentTime);
}
if (payloads.contains(SELECTION_PAYLOAD)) {
holder.bindSelection(statusEvent.getEvent().getId() == selectedId);
}
}
}
static class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
TextView time;
TextView title;
TextView persons;
TextView room;
@Nullable
EventClickListener listener;
class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
final TextView time;
final TextView title;
final TextView persons;
final TextView room;
Event event;
public ViewHolder(@NonNull View itemView, @DrawableRes int activatedBackgroundResId, @Nullable EventClickListener listener) {
ViewHolder(@NonNull View itemView, @DrawableRes int activatedBackgroundResId) {
super(itemView);
time = itemView.findViewById(R.id.time);
title = itemView.findViewById(R.id.title);
@ -206,7 +177,46 @@ public class TrackScheduleAdapter extends RecyclerViewCursorAdapter<TrackSchedul
}
ViewCompat.setBackground(itemView, newBackground);
}
this.listener = listener;
}
void bind(@NonNull Event event, boolean isBookmarked) {
Context context = itemView.getContext();
this.event = event;
time.setText(timeDateFormat.format(event.getStartTime()));
title.setText(event.getTitle());
Drawable bookmarkDrawable = isBookmarked
? AppCompatResources.getDrawable(context, R.drawable.ic_bookmark_grey600_24dp)
: null;
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(title, null, null, bookmarkDrawable, null);
title.setContentDescription(isBookmarked
? context.getString(R.string.in_bookmarks_content_description, event.getTitle())
: null
);
String personsSummary = event.getPersonsSummary();
persons.setText(personsSummary);
persons.setVisibility(TextUtils.isEmpty(personsSummary) ? View.GONE : View.VISIBLE);
room.setText(event.getRoomName());
room.setContentDescription(context.getString(R.string.room_content_description, event.getRoomName()));
}
void bindTimeColors(@NonNull Event event, long currentTime) {
if ((currentTime != -1L) && event.isRunningAtTime(currentTime)) {
// Contrast colors for running event
time.setBackgroundColor(timeRunningBackgroundColor);
time.setTextColor(timeRunningForegroundColor);
time.setContentDescription(time.getContext().getString(R.string.in_progress_content_description, time.getText()));
} else {
// Normal colors
time.setBackgroundColor(timeBackgroundColor);
time.setTextColor(timeForegroundColor);
// Use text as content description
time.setContentDescription(null);
}
}
void bindSelection(boolean isSelected) {
itemView.setActivated(isSelected);
}
@Override

View file

@ -1,18 +1,16 @@
package be.digitalia.fosdem.alarms;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.preference.PreferenceManager;
import be.digitalia.fosdem.db.DatabaseManager;
import be.digitalia.fosdem.fragments.SettingsFragment;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.services.AlarmIntentService;
import java.util.Date;
/**
* This class monitors bookmarks and preferences changes to dispatch alarm update work to AlarmIntentService.
*
@ -23,26 +21,7 @@ public class FosdemAlarmManager implements OnSharedPreferenceChangeListener {
private static FosdemAlarmManager instance;
private final Context context;
private boolean isEnabled;
private final BroadcastReceiver scheduleRefreshedReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
// When the schedule DB is updated, update the alarms too
startUpdateAlarms();
}
};
private final BroadcastReceiver bookmarksReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
// Dispatch the Bookmark broadcasts to the service
Intent serviceIntent = new Intent(intent);
AlarmIntentService.enqueueWork(context, serviceIntent);
}
};
private volatile boolean isEnabled;
public static void init(Context context) {
if (instance == null) {
@ -58,9 +37,6 @@ public class FosdemAlarmManager implements OnSharedPreferenceChangeListener {
this.context = context;
SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
isEnabled = sharedPreferences.getBoolean(SettingsFragment.KEY_PREF_NOTIFICATIONS_ENABLED, false);
if (isEnabled) {
registerReceivers();
}
sharedPreferences.registerOnSharedPreferenceChangeListener(this);
}
@ -68,15 +44,40 @@ public class FosdemAlarmManager implements OnSharedPreferenceChangeListener {
return isEnabled;
}
public void onScheduleRefreshed() {
if (isEnabled) {
startUpdateAlarms();
}
}
public void onBookmarkAdded(Event event) {
if (isEnabled) {
Intent serviceIntent = new Intent(AlarmIntentService.ACTION_ADD_BOOKMARK)
.putExtra(AlarmIntentService.EXTRA_EVENT_ID, event.getId());
final Date startTime = event.getStartTime();
if (startTime != null) {
serviceIntent.putExtra(AlarmIntentService.EXTRA_EVENT_START_TIME, startTime.getTime());
}
AlarmIntentService.enqueueWork(context, serviceIntent);
}
}
public void onBookmarksRemoved(long[] eventIds) {
if (isEnabled) {
Intent serviceIntent = new Intent(AlarmIntentService.ACTION_REMOVE_BOOKMARKS)
.putExtra(AlarmIntentService.EXTRA_EVENT_IDS, eventIds);
AlarmIntentService.enqueueWork(context, serviceIntent);
}
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (SettingsFragment.KEY_PREF_NOTIFICATIONS_ENABLED.equals(key)) {
isEnabled = sharedPreferences.getBoolean(SettingsFragment.KEY_PREF_NOTIFICATIONS_ENABLED, false);
final boolean isEnabled = sharedPreferences.getBoolean(SettingsFragment.KEY_PREF_NOTIFICATIONS_ENABLED, false);
this.isEnabled = isEnabled;
if (isEnabled) {
registerReceivers();
startUpdateAlarms();
} else {
unregisterReceivers();
startDisableAlarms();
}
} else if (SettingsFragment.KEY_PREF_NOTIFICATIONS_DELAY.equals(key)) {
@ -84,22 +85,7 @@ public class FosdemAlarmManager implements OnSharedPreferenceChangeListener {
}
}
private void registerReceivers() {
LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context);
lbm.registerReceiver(scheduleRefreshedReceiver, new IntentFilter(DatabaseManager.ACTION_SCHEDULE_REFRESHED));
IntentFilter filter = new IntentFilter();
filter.addAction(DatabaseManager.ACTION_ADD_BOOKMARK);
filter.addAction(DatabaseManager.ACTION_REMOVE_BOOKMARKS);
lbm.registerReceiver(bookmarksReceiver, filter);
}
private void unregisterReceivers() {
LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context);
lbm.unregisterReceiver(scheduleRefreshedReceiver);
lbm.unregisterReceiver(bookmarksReceiver);
}
void startUpdateAlarms() {
private void startUpdateAlarms() {
Intent serviceIntent = new Intent(AlarmIntentService.ACTION_UPDATE_ALARMS);
AlarmIntentService.enqueueWork(context, serviceIntent);
}

View file

@ -6,10 +6,11 @@ import androidx.annotation.MainThread;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import be.digitalia.fosdem.db.DatabaseManager;
import be.digitalia.fosdem.db.AppDatabase;
import be.digitalia.fosdem.db.ScheduleDao;
import be.digitalia.fosdem.livedata.SingleEvent;
import be.digitalia.fosdem.model.DetailedEvent;
import be.digitalia.fosdem.model.DownloadScheduleResult;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.model.RoomStatus;
import be.digitalia.fosdem.parsers.EventsParser;
import be.digitalia.fosdem.utils.HttpUtils;
@ -55,10 +56,10 @@ public class FosdemApi {
progress.postValue(-1);
DownloadScheduleResult res = DownloadScheduleResult.error();
try {
DatabaseManager dbManager = DatabaseManager.getInstance();
ScheduleDao scheduleDao = AppDatabase.getInstance(context).getScheduleDao();
HttpUtils.HttpResult httpResult = HttpUtils.get(
FosdemUrls.getSchedule(),
dbManager.getLastModifiedTag(),
scheduleDao.getLastModifiedTag(),
new HttpUtils.ProgressUpdateListener() {
@Override
public void onProgressUpdate(int percent) {
@ -72,8 +73,8 @@ public class FosdemApi {
}
try {
Iterable<Event> events = new EventsParser().parse(httpResult.inputStream);
int count = dbManager.storeSchedule(events, httpResult.lastModified);
Iterable<DetailedEvent> 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<Map<String, RoomStatus>> getRoomStatuses() {
public static LiveData<Map<String, RoomStatus>> 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;

View file

@ -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();
}

View file

@ -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<List<Event>> 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<Boolean> 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);
}

View file

@ -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
}
}

View file

@ -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<Event> events, String lastModifiedTag) {
boolean isComplete = false;
List<Day> 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<Track, Long> tracks = new HashMap<>();
long nextTrackId = 0L;
long minEventId = Long.MAX_VALUE;
Set<Day> 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.<Day>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<List<Day>> daysLiveData = new AsyncTaskLiveData<List<Day>>() {
{
onContentChanged();
}
@Override
protected List<Day> loadInBackground() throws Exception {
Cursor cursor = helper.getReadableDatabase().query(DatabaseHelper.DAYS_TABLE_NAME,
new String[]{"_index", "date"}, null, null, null, null, "_index ASC");
try {
List<Day> 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<List<Day>> getDays() {
return daysLiveData;
}
@WorkerThread
public int getYear() {
// Use the current year by default
long date = System.currentTimeMillis();
// Compute from cached days if available
List<Day> 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<String> 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<Person> 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<Person> 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<Link> 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<Link> 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);
}
}
}
}

View file

@ -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;
}
}

View file

@ -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<Long> 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<Long> 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<DetailedEvent> 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<DetailedEvent> events, String lastModifiedTag) {
// 1: Delete the previous schedule
clearSchedule();
// 2: Insert the events
int totalEvents = 0;
final Map<Track, Long> tracks = new HashMap<>();
long nextTrackId = 0L;
long minEventId = Long.MAX_VALUE;
final Set<Day> 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<Person> 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<Person> persons);
@Insert
protected abstract void insertEventsToPersons(EventToPerson[] eventsToPersons);
@Insert
protected abstract void insertLinks(List<Link> links);
@Insert
protected abstract void insertDays(Set<Day> 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<List<Day>> daysLiveData;
public LiveData<List<Day>> 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<List<Day>> getDaysInternal();
@WorkerThread
public int getYear() {
long date = 0L;
// Compute from cached days if available
final LiveData<List<Day>> cache = daysLiveData;
List<Day> 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<List<Track>> 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<List<StatusEvent>> 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<Integer, StatusEvent> 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<Integer, StatusEvent> 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<Integer, StatusEvent> 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<Integer, StatusEvent> 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<Integer, Person> getPersons();
public LiveData<EventDetails> getEventDetails(final Event event) {
final MutableLiveData<EventDetails> 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<Person> getPersons(Event event);
@Query("SELECT * FROM links WHERE event_id = :event ORDER BY id ASC")
protected abstract List<Link> getLinks(Event event);
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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<Cursor> {
private static final int EVENTS_LOADER_ID = 1;
public abstract class BaseLiveListFragment extends RecyclerViewFragment implements Observer<PagedList<StatusEvent>> {
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<StatusEvent> events) {
adapter.submitList(events, preserveScrollPositionRunnable);
setProgressBarVisible(false);
}
protected abstract String getEmptyText();
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
if (data != null) {
adapter.swapCursor(data);
}
setProgressBarVisible(false);
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
adapter.swapCursor(null);
}
@NonNull
protected abstract LiveData<PagedList<StatusEvent>> getDataSource(@NonNull LiveViewModel viewModel);
}

View file

@ -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<Cursor> {
public class BookmarksListFragment extends RecyclerViewFragment implements Observer<List<Event>> {
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<Cursor> onCreateLoader(int id, Bundle args) {
return new BookmarksLoader(getActivity(), upcomingOnly);
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
if (data != null) {
adapter.swapCursor(data);
}
public void onChanged(List<Event> bookmarks) {
adapter.submitList(bookmarks);
setProgressBarVisible(false);
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
adapter.swapCursor(null);
}
}

View file

@ -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<EventDetailsViewModel.EventDetails>() {
viewModel.getEventDetails().observe(getViewLifecycleOwner(), new Observer<EventDetails>() {
@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<Map<String, RoomStatus>>() {
FosdemApi.getRoomStatuses(getContext()).observe(getViewLifecycleOwner(), new Observer<Map<String, RoomStatus>>() {
@Override
public void onChanged(Map<String, RoomStatus> 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<Person> 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<Link> 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);
}

View file

@ -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<Cursor> 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<PagedList<StatusEvent>> getDataSource(@NonNull LiveViewModel viewModel) {
return viewModel.getNextEvents();
}
}

View file

@ -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<Cursor> 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<PagedList<StatusEvent>> getDataSource(@NonNull LiveViewModel viewModel) {
return viewModel.getEventsInProgress();
}
}

View file

@ -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<Cursor> {
public class PersonInfoListFragment extends RecyclerViewFragment implements Observer<PagedList<StatusEvent>> {
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<StatusEvent> 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<Cursor> 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<Cursor> loader, Cursor data) {
if (data != null) {
adapter.swapCursor(data);
}
public void onChanged(PagedList<StatusEvent> events) {
adapter.submitList(events);
setProgressBarVisible(false);
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
adapter.swapCursor(null);
}
static class HeaderAdapter extends RecyclerView.Adapter<HeaderAdapter.ViewHolder> {
@Override

View file

@ -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<Cursor> {
private static final int PERSONS_LOADER_ID = 1;
public class PersonsListFragment extends RecyclerViewFragment implements Observer<PagedList<Person>> {
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<Cursor> 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<Cursor> loader, Cursor data) {
if (data != null) {
adapter.swapCursor(data);
}
public void onChanged(PagedList<Person> persons) {
adapter.submitList(persons);
setProgressBarVisible(false);
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
adapter.swapCursor(null);
}
private static class PersonsAdapter extends PagedListAdapter<Person, PersonViewHolder> {
private static class PersonsAdapter extends RecyclerViewCursorAdapter<PersonViewHolder> {
private static final DiffUtil.ItemCallback<Person> DIFF_CALLBACK = new SimpleItemCallback<Person>() {
@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);
}
}
}
}

View file

@ -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<Cursor> {
private static final int EVENTS_LOADER_ID = 1;
private static final String ARG_QUERY = "query";
public class SearchResultListFragment extends RecyclerViewFragment implements Observer<PagedList<StatusEvent>> {
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<Cursor> 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<Cursor> loader, Cursor data) {
if (data != null) {
adapter.swapCursor(data);
}
public void onChanged(PagedList<StatusEvent> results) {
adapter.submitList(results);
setProgressBarVisible(false);
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
adapter.swapCursor(null);
}
}

View file

@ -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<Cursor> {
implements TrackScheduleAdapter.EventClickListener, Handler.Callback, Observer<List<StatusEvent>> {
/**
* 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<Cursor> onCreateLoader(int id, Bundle args) {
Track track = getArguments().getParcelable(ARG_TRACK);
return new TrackScheduleLoader(getActivity(), day, track);
}
public void onChanged(List<StatusEvent> schedule) {
adapter.submitList(schedule);
@Override
public void onLoadFinished(@NonNull Loader<Cursor> 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<Cursor> loader) {
adapter.swapCursor(null);
}
}

View file

@ -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<List<Day>> {
@ -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);
}

View file

@ -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<Cursor> {
import java.util.List;
public class TracksListFragment extends RecyclerViewFragment implements Observer<List<Track>> {
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<Cursor> 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<Cursor> loader, Cursor data) {
if (data != null) {
adapter.swapCursor(data);
}
public void onChanged(List<Track> tracks) {
adapter.submitList(tracks);
setProgressBarVisible(false);
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
adapter.swapCursor(null);
}
private static class TracksAdapter extends ListAdapter<Track, TrackViewHolder> {
private class TracksAdapter extends RecyclerViewCursorAdapter<TrackViewHolder> {
private static final DiffUtil.ItemCallback<Track> DIFF_CALLBACK = new SimpleItemCallback<Track>() {
@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();

View file

@ -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<T> extends MutableLiveData<T> {
private boolean contentChanged = false;
AsyncTask<Void, Void, T> 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<Void, Void, T>() {
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();
}
}

View file

@ -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 <X> LiveData<X> distinctUntilChanged(@NonNull LiveData<X> source) {
final MediatorLiveData<X> outputLiveData = new MediatorLiveData<>();
outputLiveData.addSource(source, new Observer<X>() {
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;
}
}

View file

@ -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<Long> 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<Long> interval(long period, @NonNull TimeUnit unit) {
return new IntervalLiveData(unit.toMillis(period));
}
}

View file

@ -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);
}
}

View file

@ -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<Cursor> {
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();
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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<Day>, 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<Day>, 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<Day>, Parcelable {
return DAY_DATE_FORMAT.format(date);
}
@NonNull
@Override
public String toString() {
return getName();
@ -78,7 +89,7 @@ public class Day implements Comparable<Day>, 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<Day> CREATOR = new Parcelable.Creator<Day>() {
@ -93,9 +104,6 @@ public class Day implements Comparable<Day>, Parcelable {
Day(Parcel in) {
index = in.readInt();
long time = in.readLong();
if (time != 0L) {
date = new Date(time);
}
date = new Date(in.readLong());
}
}

View file

@ -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<Person> persons;
@NonNull
private List<Link> links;
@Override
@NonNull
public String getPersonsSummary() {
return TextUtils.join(", ", persons);
}
@NonNull
public List<Person> getPersons() {
return persons;
}
public void setPersons(@NonNull List<Person> persons) {
this.persons = persons;
}
@NonNull
public List<Link> getLinks() {
return links;
}
public void setLinks(@NonNull List<Link> links) {
this.links = links;
}
}

View file

@ -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<Person> persons; // Optional
private List<Link> 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<Person> getPersons() {
return persons;
}
public void setPersons(List<Person> persons) {
this.persons = persons;
}
public List<Link> getLinks() {
return links;
}
public void setLinks(List<Link> 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<Event> CREATOR = new Parcelable.Creator<Event>() {
@ -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);
}
}

View file

@ -0,0 +1,28 @@
package be.digitalia.fosdem.model;
import androidx.annotation.NonNull;
import java.util.List;
public class EventDetails {
@NonNull
private final List<Person> persons;
@NonNull
private final List<Link> links;
public EventDetails(@NonNull List<Person> persons, @NonNull List<Link> links) {
this.persons = persons;
this.links = links;
}
@NonNull
public List<Person> getPersons() {
return persons;
}
@NonNull
public List<Link> getLinks() {
return links;
}
}

View file

@ -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();
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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()];
}

View file

@ -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<Event> {
public class EventsParser extends IterableAbstractPullParser<DetailedEvent> {
private final DateFormat DATE_FORMAT = DateUtils.withBelgiumTimeZone(new SimpleDateFormat("yyyy-MM-dd", Locale.US));
@ -67,7 +67,7 @@ public class EventsParser extends IterableAbstractPullParser<Event> {
}
@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<Event> {
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<Event> {
while (!isNextEndTag("links")) {
if (isStartTag("link")) {
Link link = new Link();
link.setEventId(event.getId());
link.setUrl(parser.getAttributeValue(null, "href"));
link.setDescription(parser.nextText());

View file

@ -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 {

View file

@ -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);
}
}

View file

@ -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));
}

View file

@ -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;
}
}

View file

@ -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<Boolean> upcomingOnly = new MutableLiveData<>();
private final LiveData<List<Event>> bookmarks = Transformations.switchMap(upcomingOnly,
new Function<Boolean, LiveData<List<Event>>>() {
@Override
public LiveData<List<Event>> apply(Boolean upcomingOnly) {
if (upcomingOnly == Boolean.TRUE) {
// Refresh upcoming bookmarks every 2 minutes
final LiveData<Long> heartbeat = LiveDataFactory.interval(2L, TimeUnit.MINUTES);
return Transformations.switchMap(heartbeat,
new Function<Long, LiveData<List<Event>>>() {
@Override
public LiveData<List<Event>> 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<List<Event>> getBookmarks() {
return bookmarks;
}
public void removeBookmarks(final long[] eventIds) {
AsyncTask.SERIAL_EXECUTOR.execute(new Runnable() {
@Override
public void run() {
appDatabase.getBookmarksDao().removeBookmarks(eventIds);
}
});
}
}

View file

@ -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<Person> persons;
public List<Link> links;
}
private Event event = null;
private final AsyncTaskLiveData<Boolean> bookmarkStatus = new AsyncTaskLiveData<Boolean>() {
@Override
protected Boolean loadInBackground() throws Exception {
return DatabaseManager.getInstance().isBookmarked(event);
}
};
private final AsyncTaskLiveData<EventDetails> eventDetails = new AsyncTaskLiveData<EventDetails>() {
@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> event = new MutableLiveData<>();
private final LiveData<Boolean> bookmarkStatus = Transformations.switchMap(event,
new Function<Event, LiveData<Boolean>>() {
@Override
public LiveData<Boolean> 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> eventDetails = Transformations.switchMap(event,
new Function<Event, LiveData<EventDetails>>() {
@Override
public LiveData<EventDetails> 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<Boolean, Void, Void> {
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<EventDetails> getEventDetails() {
return eventDetails;
}
@Override
protected void onCleared() {
LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(getApplication());
lbm.unregisterReceiver(addBookmarkReceiver);
lbm.unregisterReceiver(removeBookmarksReceiver);
}
}

View file

@ -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<Long> eventId = new MutableLiveData<>();
private final LiveData<Event> event = Transformations.switchMap(eventId,
new Function<Long, LiveData<Event>>() {
@Override
public LiveData<Event> apply(final Long id) {
final MutableLiveData<Event> 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> event = new AsyncTaskLiveData<Event>() {
@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);
}
}

View file

@ -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<Long> heartbeat = LiveDataFactory.interval(1L, TimeUnit.MINUTES);
private final LiveData<PagedList<StatusEvent>> nextEvents = Transformations.switchMap(heartbeat,
new Function<Long, LiveData<PagedList<StatusEvent>>>() {
@Override
public LiveData<PagedList<StatusEvent>> apply(Long version) {
final long now = System.currentTimeMillis();
return new LivePagedListBuilder<>(appDatabase.getScheduleDao().getEventsWithStartTime(now, now + NEXT_EVENTS_INTERVAL), 20)
.build();
}
});
private final LiveData<PagedList<StatusEvent>> eventsInProgress = Transformations.switchMap(heartbeat,
new Function<Long, LiveData<PagedList<StatusEvent>>>() {
@Override
public LiveData<PagedList<StatusEvent>> 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<PagedList<StatusEvent>> getNextEvents() {
return nextEvents;
}
public LiveData<PagedList<StatusEvent>> getEventsInProgress() {
return eventsInProgress;
}
}

View file

@ -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> person = new MutableLiveData<>();
private final LiveData<PagedList<StatusEvent>> events = Transformations.switchMap(person,
new Function<Person, LiveData<PagedList<StatusEvent>>>() {
@Override
public LiveData<PagedList<StatusEvent>> 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<PagedList<StatusEvent>> getEvents() {
return events;
}
}

View file

@ -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<PagedList<Person>> persons
= new LivePagedListBuilder<>(appDatabase.getScheduleDao().getPersons(), 100)
.build();
public PersonsViewModel(@NonNull Application application) {
super(application);
}
public LiveData<PagedList<Person>> getPersons() {
return persons;
}
}

View file

@ -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<String> query = new MutableLiveData<>();
private final LiveData<PagedList<StatusEvent>> results = Transformations.switchMap(query,
new Function<String, LiveData<PagedList<StatusEvent>>>() {
@Override
public LiveData<PagedList<StatusEvent>> apply(String query) {
if (isQueryTooShort(query)) {
MutableLiveData<PagedList<StatusEvent>> 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<PagedList<StatusEvent>> getResults() {
return results;
}
}

View file

@ -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<Pair<Day, Track>> dayTrack = new MutableLiveData<>();
private final LiveData<List<StatusEvent>> schedule = Transformations.switchMap(dayTrack,
new Function<Pair<Day, Track>, LiveData<List<StatusEvent>>>() {
@Override
public LiveData<List<StatusEvent>> apply(Pair<Day, Track> 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<Day, Track> dayTrack = Pair.create(day, track);
if (!dayTrack.equals(this.dayTrack.getValue())) {
this.dayTrack.setValue(dayTrack);
}
}
public LiveData<List<StatusEvent>> getSchedule() {
return schedule;
}
}

View file

@ -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> day = new MutableLiveData<>();
private final LiveData<List<Track>> tracks = Transformations.switchMap(day,
new Function<Day, LiveData<List<Track>>>() {
@Override
public LiveData<List<Track>> 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<List<Track>> getTracks() {
return tracks;
}
}

View file

@ -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() {