From 9a665f3c8acb9399d30531ede0ec75d7512ad95e Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 29 Sep 2022 17:59:53 +0200 Subject: [PATCH] cache for notifications --- .../client/entities/api/Notification.java | 6 + .../client/entities/app/StatusCache.java | 149 +++++++++++++++++- .../ui/drawer/NotificationAdapter.java | 32 +++- .../android/ui/drawer/StatusAdapter.java | 36 +---- .../FragmentMastodonNotification.java | 115 ++++++++++++-- .../timeline/FragmentMastodonTimeline.java | 82 +++++++--- .../viewmodel/mastodon/NotificationsVM.java | 101 +++++++++--- .../viewmodel/mastodon/TimelinesVM.java | 3 + 8 files changed, 424 insertions(+), 100 deletions(-) diff --git a/app/src/main/java/app/fedilab/android/client/entities/api/Notification.java b/app/src/main/java/app/fedilab/android/client/entities/api/Notification.java index 851c32264..733e28ef7 100644 --- a/app/src/main/java/app/fedilab/android/client/entities/api/Notification.java +++ b/app/src/main/java/app/fedilab/android/client/entities/api/Notification.java @@ -35,6 +35,12 @@ public class Notification { public Account account; @SerializedName("status") public Status status; + public PositionFetchMore positionFetchMore = PositionFetchMore.BOTTOM; + + public enum PositionFetchMore { + TOP, + BOTTOM + } public transient List relatedNotifications; public boolean isFetchMore; diff --git a/app/src/main/java/app/fedilab/android/client/entities/app/StatusCache.java b/app/src/main/java/app/fedilab/android/client/entities/app/StatusCache.java index 28a39c2b6..96c59221b 100644 --- a/app/src/main/java/app/fedilab/android/client/entities/app/StatusCache.java +++ b/app/src/main/java/app/fedilab/android/client/entities/app/StatusCache.java @@ -28,6 +28,8 @@ import java.util.Date; import java.util.List; import app.fedilab.android.activities.MainActivity; +import app.fedilab.android.client.entities.api.Notification; +import app.fedilab.android.client.entities.api.Notifications; import app.fedilab.android.client.entities.api.Pagination; import app.fedilab.android.client.entities.api.Status; import app.fedilab.android.client.entities.api.Statuses; @@ -53,6 +55,8 @@ public class StatusCache { public String status_id; @SerializedName("status") public Status status; + @SerializedName("notification") + public Notification notification; @SerializedName("created_at") public Date created_at; @SerializedName("updated_at") @@ -84,6 +88,21 @@ public class StatusCache { } } + /** + * Serialized a Notification class + * + * @param mastodon_notification {@link Notification} to serialize + * @return String serialized status + */ + public static String mastodonNotificationToStringStorage(Notification mastodon_notification) { + Gson gson = new Gson(); + try { + return gson.toJson(mastodon_notification); + } catch (Exception e) { + return null; + } + } + /** * Unserialized a Mastodon Status * @@ -100,6 +119,22 @@ public class StatusCache { } } + /** + * Unserialized a Mastodon Notification + * + * @param serializedNotification String serialized status + * @return {@link Notification} + */ + public static Notification restoreNotificationFromString(String serializedNotification) { + Gson gson = new Gson(); + try { + return gson.fromJson(serializedNotification, Notification.class); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + /** * Insert or update a status * @@ -197,7 +232,12 @@ public class StatusCache { values.put(Sqlite.COL_SLUG, slug); values.put(Sqlite.COL_STATUS_ID, statusCache.status_id); values.put(Sqlite.COL_TYPE, statusCache.type.getValue()); - values.put(Sqlite.COL_STATUS, mastodonStatusToStringStorage(statusCache.status)); + if (statusCache.status != null) { + values.put(Sqlite.COL_STATUS, mastodonStatusToStringStorage(statusCache.status)); + } + if (statusCache.notification != null) { + values.put(Sqlite.COL_STATUS, mastodonNotificationToStringStorage(statusCache.notification)); + } values.put(Sqlite.COL_CREATED_AT, Helper.dateToString(new Date())); //Inserts token try { @@ -222,7 +262,12 @@ public class StatusCache { ContentValues values = new ContentValues(); values.put(Sqlite.COL_USER_ID, statusCache.user_id); values.put(Sqlite.COL_STATUS_ID, statusCache.status_id); - values.put(Sqlite.COL_STATUS, mastodonStatusToStringStorage(statusCache.status)); + if (statusCache.status != null) { + values.put(Sqlite.COL_STATUS, mastodonStatusToStringStorage(statusCache.status)); + } + if (statusCache.notification != null) { + values.put(Sqlite.COL_STATUS, mastodonNotificationToStringStorage(statusCache.notification)); + } values.put(Sqlite.COL_UPDATED_AT, Helper.dateToString(new Date())); //Inserts token try { @@ -298,6 +343,48 @@ public class StatusCache { } + /** + * Get paginated notifications from db + * + * @param instance String - instance + * @param user_id String - us + * @param max_id String - status having max id + * @param min_id String - status having min id + * @return Statuses + * @throws DBException - throws a db exception + */ + public Notifications getNotifications(List exclude_type, String instance, String user_id, String max_id, String min_id, String since_id) throws DBException { + if (db == null) { + throw new DBException("db is null. Wrong initialization."); + } + String order = " DESC"; + String selection = Sqlite.COL_INSTANCE + "='" + instance + "' AND " + Sqlite.COL_USER_ID + "= '" + user_id + "' AND " + Sqlite.COL_SLUG + "= '" + Timeline.TimeLineEnum.NOTIFICATION.getValue() + "' "; + String limit = String.valueOf(MastodonHelper.statusesPerCall(context)); + if (min_id != null) { + selection += "AND " + Sqlite.COL_STATUS_ID + " > '" + min_id + "' "; + order = " ASC"; + } else if (max_id != null) { + selection += "AND " + Sqlite.COL_STATUS_ID + " < '" + max_id + "' "; + } else if (since_id != null) { + selection += "AND " + Sqlite.COL_STATUS_ID + " > '" + since_id + "' "; + limit = null; + } + if (exclude_type != null && exclude_type.size() > 0) { + StringBuilder exclude = new StringBuilder(); + for (String excluded : exclude_type) { + exclude.append("'").append(excluded).append("'").append(","); + } + exclude = new StringBuilder(exclude.substring(0, exclude.length() - 1)); + selection += "AND " + Sqlite.COL_SLUG + " NOT IN (" + exclude + ") "; + } + try { + Cursor c = db.query(Sqlite.TABLE_STATUS_CACHE, null, selection, null, null, null, Sqlite.COL_STATUS_ID + order, limit); + return createNotificationReply(cursorToListOfNotifications(c)); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } /** @@ -403,6 +490,52 @@ public class StatusCache { return statusList; } + /** + * Convert a cursor to list of notifications + * + * @param c Cursor + * @return List + */ + private List cursorToListOfNotifications(Cursor c) { + //No element found + if (c.getCount() == 0) { + c.close(); + return null; + } + List notificationList = new ArrayList<>(); + while (c.moveToNext()) { + Notification notification = convertCursorToNotification(c); + notificationList.add(notification); + } + //Close the cursor + c.close(); + return notificationList; + } + + + /** + * Create a reply from db in the same way than API call + * + * @param notificationList List + * @return Notifications (with pagination) + */ + private Notifications createNotificationReply(List notificationList) { + Notifications notifications = new Notifications(); + notifications.notifications = notificationList; + Pagination pagination = new Pagination(); + if (notificationList != null && notificationList.size() > 0) { + //Status list is inverted, it happens for min_id due to ASC ordering + if (notificationList.get(0).id.compareTo(notificationList.get(notificationList.size() - 1).id) < 0) { + Collections.reverse(notificationList); + notifications.notifications = notificationList; + } + pagination.max_id = notificationList.get(0).id; + pagination.min_id = notificationList.get(notificationList.size() - 1).id; + } + notifications.pagination = pagination; + return notifications; + } + /** * Create a reply from db in the same way than API call * @@ -437,6 +570,18 @@ public class StatusCache { return restoreStatusFromString(serializedStatus); } + /** + * Read cursor and hydrate without closing it + * + * @param c - Cursor + * @return Notification + */ + private Notification convertCursorToNotification(Cursor c) { + String serializedNotification = c.getString(c.getColumnIndexOrThrow(Sqlite.COL_STATUS)); + return restoreNotificationFromString(serializedNotification); + } + + public enum order { @SerializedName("ASC") ASC("ASC"), diff --git a/app/src/main/java/app/fedilab/android/ui/drawer/NotificationAdapter.java b/app/src/main/java/app/fedilab/android/ui/drawer/NotificationAdapter.java index 589a69e5f..0e499c551 100644 --- a/app/src/main/java/app/fedilab/android/ui/drawer/NotificationAdapter.java +++ b/app/src/main/java/app/fedilab/android/ui/drawer/NotificationAdapter.java @@ -62,7 +62,7 @@ public class NotificationAdapter extends RecyclerView.Adapter notificationList) { @@ -114,7 +114,6 @@ public class NotificationAdapter extends RecyclerView.Adapter { notification.isFetchMore = false; - notifyItemChanged(position); - fetchMoreCallBack.onClickMinId(notification.id); + if (holderFollow.getBindingAdapterPosition() < notificationList.size() - 1) { + String fromId; + if (notification.positionFetchMore == Notification.PositionFetchMore.TOP) { + fromId = notificationList.get(position + 1).id; + } else { + fromId = notification.id; + } + fetchMoreCallBack.onClickMinId(fromId, notification); + notifyItemChanged(position); + } + }); holderFollow.binding.layoutFetchMore.fetchMoreMax.setOnClickListener(v -> { //We hide the button notification.isFetchMore = false; + String fromId; + if (notification.positionFetchMore == Notification.PositionFetchMore.TOP) { + fromId = notificationList.get(position).id; + } else { + fromId = notificationList.get(position - 1).id; + } notifyItemChanged(position); - fetchMoreCallBack.onClickMaxId(notification.id); + fetchMoreCallBack.onClickMaxId(fromId, notification); }); } else { holderFollow.binding.layoutFetchMore.fetchMoreContainer.setVisibility(View.GONE); @@ -191,7 +205,7 @@ public class NotificationAdapter extends RecyclerView.Adapter } else { fromId = status.id; } - fetchMoreCallBack.onClickMinId(fromId); - if (!remote) { - new Thread(() -> { - StatusCache statusCache = new StatusCache(); - statusCache.instance = BaseMainActivity.currentInstance; - statusCache.user_id = BaseMainActivity.currentUserID; - statusCache.status = status; - statusCache.status_id = status.id; - try { - new StatusCache(context).updateIfExists(statusCache); - } catch (DBException e) { - e.printStackTrace(); - } - }).start(); - } + fetchMoreCallBack.onClickMinId(fromId, status); } }); holder.binding.layoutFetchMore.fetchMoreMax.setOnClickListener(v -> { @@ -1904,22 +1890,8 @@ public class StatusAdapter extends RecyclerView.Adapter } else { fromId = statusList.get(holder.getBindingAdapterPosition() - 1).id; } - fetchMoreCallBack.onClickMaxId(fromId); + fetchMoreCallBack.onClickMaxId(fromId, status); adapter.notifyItemChanged(holder.getBindingAdapterPosition()); - if (!remote) { - new Thread(() -> { - StatusCache statusCache = new StatusCache(); - statusCache.instance = BaseMainActivity.currentInstance; - statusCache.user_id = BaseMainActivity.currentUserID; - statusCache.status = status; - statusCache.status_id = status.id; - try { - new StatusCache(context).updateIfExists(statusCache); - } catch (DBException e) { - e.printStackTrace(); - } - }).start(); - } }); } else { holder.binding.layoutFetchMore.fetchMoreContainer.setVisibility(View.GONE); @@ -2073,9 +2045,9 @@ public class StatusAdapter extends RecyclerView.Adapter } public interface FetchMoreCallBack { - void onClickMinId(String min_id); + void onClickMinId(String min_id, Status statusToUpdate); - void onClickMaxId(String max_id); + void onClickMaxId(String max_id, Status statusToUpdate); } public static class StatusViewHolder extends RecyclerView.ViewHolder { diff --git a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonNotification.java b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonNotification.java index f13584c49..1984e5317 100644 --- a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonNotification.java +++ b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonNotification.java @@ -43,16 +43,17 @@ import app.fedilab.android.R; import app.fedilab.android.client.entities.api.Notification; import app.fedilab.android.client.entities.api.Notifications; import app.fedilab.android.client.entities.api.Status; +import app.fedilab.android.client.entities.app.Timeline; import app.fedilab.android.databinding.FragmentPaginationBinding; import app.fedilab.android.helper.Helper; import app.fedilab.android.helper.MastodonHelper; import app.fedilab.android.helper.ThemeHelper; import app.fedilab.android.ui.drawer.NotificationAdapter; -import app.fedilab.android.ui.drawer.StatusAdapter; import app.fedilab.android.viewmodel.mastodon.NotificationsVM; +import app.fedilab.android.viewmodel.mastodon.TimelinesVM; -public class FragmentMastodonNotification extends Fragment implements StatusAdapter.FetchMoreCallBack { +public class FragmentMastodonNotification extends Fragment implements NotificationAdapter.FetchMoreCallBack { private static final int NOTIFICATION_PRESENT = -1; @@ -270,26 +271,108 @@ public class FragmentMastodonNotification extends Fragment implements StatusAdap * @param direction - DIRECTION null if first call, then is set to TOP or BOTTOM depending of scroll */ private void route(FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing) { + route(direction, fetchingMissing, null); + } + + /** + * Router for timelines + * + * @param direction - DIRECTION null if first call, then is set to TOP or BOTTOM depending of scroll + */ + private void route(FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing, Notification notificationToUpdate) { if (binding == null || !isAdded() || getActivity() == null) { return; } if (!isAdded()) { return; } - if (direction == null) { - notificationsVM.getNotifications(BaseMainActivity.currentInstance, BaseMainActivity.currentToken, null, null, null, MastodonHelper.statusesPerCall(requireActivity()), excludeType, null) - .observe(getViewLifecycleOwner(), this::initializeNotificationView); + + SharedPreferences sharedpreferences = PreferenceManager.getDefaultSharedPreferences(requireActivity()); + boolean useCache = sharedpreferences.getBoolean(getString(R.string.SET_USE_CACHE), true); + + TimelinesVM.TimelineParams timelineParams = new TimelinesVM.TimelineParams(Timeline.TimeLineEnum.NOTIFICATION, direction, null); + timelineParams.limit = MastodonHelper.notificationsPerCall(requireActivity()); + if (direction == FragmentMastodonTimeline.DIRECTION.REFRESH || direction == FragmentMastodonTimeline.DIRECTION.SCROLL_TOP) { + timelineParams.maxId = null; + timelineParams.minId = null; } else if (direction == FragmentMastodonTimeline.DIRECTION.BOTTOM) { - notificationsVM.getNotifications(BaseMainActivity.currentInstance, BaseMainActivity.currentToken, fetchingMissing ? max_id_fetch_more : max_id, null, null, MastodonHelper.statusesPerCall(requireActivity()), excludeType, null) - .observe(getViewLifecycleOwner(), notificationsBottom -> dealWithPagination(notificationsBottom, FragmentMastodonTimeline.DIRECTION.BOTTOM, fetchingMissing)); + timelineParams.maxId = fetchingMissing ? max_id_fetch_more : max_id; + timelineParams.minId = null; } else if (direction == FragmentMastodonTimeline.DIRECTION.TOP) { - notificationsVM.getNotifications(BaseMainActivity.currentInstance, BaseMainActivity.currentToken, null, null, fetchingMissing ? min_id_fetch_more : min_id, MastodonHelper.statusesPerCall(requireActivity()), excludeType, null) - .observe(getViewLifecycleOwner(), notificationsTop -> dealWithPagination(notificationsTop, FragmentMastodonTimeline.DIRECTION.TOP, fetchingMissing)); + timelineParams.minId = fetchingMissing ? min_id_fetch_more : min_id; + timelineParams.maxId = null; + } else { + timelineParams.maxId = max_id; + } + timelineParams.excludeType = excludeType; + timelineParams.fetchingMissing = fetchingMissing; + + if (useCache) { + getCachedNotifications(direction, fetchingMissing, timelineParams); + } else { + getLiveNotifications(direction, fetchingMissing, timelineParams, notificationToUpdate); + } + + + } + + private void getCachedNotifications(FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing, TimelinesVM.TimelineParams timelineParams) { + + if (direction == null) { + notificationsVM.getNotificationCache(notificationList, timelineParams) + .observe(getViewLifecycleOwner(), notificationsCached -> { + if (notificationsCached == null || notificationsCached.notifications == null || notificationsCached.notifications.size() == 0) { + getLiveNotifications(null, fetchingMissing, timelineParams, null); + } else { + initializeNotificationView(notificationsCached); + } + }); + } else if (direction == FragmentMastodonTimeline.DIRECTION.BOTTOM) { + notificationsVM.getNotificationCache(notificationList, timelineParams) + .observe(getViewLifecycleOwner(), notificationsBottom -> { + if (notificationsBottom == null || notificationsBottom.notifications == null || notificationsBottom.notifications.size() == 0) { + getLiveNotifications(FragmentMastodonTimeline.DIRECTION.BOTTOM, fetchingMissing, timelineParams, null); + } else { + dealWithPagination(notificationsBottom, FragmentMastodonTimeline.DIRECTION.BOTTOM, fetchingMissing, null); + } + + }); + } else if (direction == FragmentMastodonTimeline.DIRECTION.TOP) { + notificationsVM.getNotificationCache(notificationList, timelineParams) + .observe(getViewLifecycleOwner(), notificationsTop -> { + if (notificationsTop == null || notificationsTop.notifications == null || notificationsTop.notifications.size() == 0) { + getLiveNotifications(FragmentMastodonTimeline.DIRECTION.BOTTOM, fetchingMissing, timelineParams, null); + } else { + dealWithPagination(notificationsTop, FragmentMastodonTimeline.DIRECTION.TOP, fetchingMissing, null); + } + }); } else if (direction == FragmentMastodonTimeline.DIRECTION.REFRESH) { - notificationsVM.getNotifications(BaseMainActivity.currentInstance, BaseMainActivity.currentToken, null, null, null, MastodonHelper.statusesPerCall(requireActivity()), excludeType, null) + notificationsVM.getNotifications(notificationList, timelineParams) .observe(getViewLifecycleOwner(), notificationsRefresh -> { if (notificationAdapter != null) { - dealWithPagination(notificationsRefresh, FragmentMastodonTimeline.DIRECTION.REFRESH, true); + dealWithPagination(notificationsRefresh, FragmentMastodonTimeline.DIRECTION.REFRESH, true, null); + } else { + initializeNotificationView(notificationsRefresh); + } + }); + } + } + + private void getLiveNotifications(FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing, TimelinesVM.TimelineParams timelineParams, Notification notificationToUpdate) { + if (direction == null) { + notificationsVM.getNotifications(notificationList, timelineParams) + .observe(getViewLifecycleOwner(), this::initializeNotificationView); + } else if (direction == FragmentMastodonTimeline.DIRECTION.BOTTOM) { + notificationsVM.getNotifications(notificationList, timelineParams) + .observe(getViewLifecycleOwner(), notificationsBottom -> dealWithPagination(notificationsBottom, FragmentMastodonTimeline.DIRECTION.BOTTOM, fetchingMissing, notificationToUpdate)); + } else if (direction == FragmentMastodonTimeline.DIRECTION.TOP) { + notificationsVM.getNotifications(notificationList, timelineParams) + .observe(getViewLifecycleOwner(), notificationsTop -> dealWithPagination(notificationsTop, FragmentMastodonTimeline.DIRECTION.TOP, fetchingMissing, notificationToUpdate)); + } else if (direction == FragmentMastodonTimeline.DIRECTION.REFRESH) { + notificationsVM.getNotifications(notificationList, timelineParams) + .observe(getViewLifecycleOwner(), notificationsRefresh -> { + if (notificationAdapter != null) { + dealWithPagination(notificationsRefresh, FragmentMastodonTimeline.DIRECTION.REFRESH, true, notificationToUpdate); } else { initializeNotificationView(notificationsRefresh); } @@ -333,7 +416,7 @@ public class FragmentMastodonNotification extends Fragment implements StatusAdap * * @param fetched_notifications Notifications */ - private synchronized void dealWithPagination(Notifications fetched_notifications, FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing) { + private synchronized void dealWithPagination(Notifications fetched_notifications, FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing, Notification notificationToUpdate) { if (binding == null || !isAdded() || getActivity() == null) { return; } @@ -456,17 +539,17 @@ public class FragmentMastodonNotification extends Fragment implements StatusAdap @Override - public void onClickMinId(String min_id) { + public void onClickMinId(String min_id, Notification notificationToUpdate) { //Fetch more has been pressed min_id_fetch_more = min_id; - route(FragmentMastodonTimeline.DIRECTION.TOP, true); + route(FragmentMastodonTimeline.DIRECTION.TOP, true, notificationToUpdate); } @Override - public void onClickMaxId(String max_id) { + public void onClickMaxId(String max_id, Notification notificationToUpdate) { //Fetch more has been pressed max_id_fetch_more = max_id; - route(FragmentMastodonTimeline.DIRECTION.BOTTOM, true); + route(FragmentMastodonTimeline.DIRECTION.BOTTOM, true, notificationToUpdate); } public enum NotificationTypeEnum { diff --git a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java index 9d9c71f2e..370643f13 100644 --- a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java +++ b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java @@ -49,9 +49,11 @@ import app.fedilab.android.client.entities.api.Status; import app.fedilab.android.client.entities.api.Statuses; import app.fedilab.android.client.entities.app.PinnedTimeline; import app.fedilab.android.client.entities.app.RemoteInstance; +import app.fedilab.android.client.entities.app.StatusCache; import app.fedilab.android.client.entities.app.TagTimeline; import app.fedilab.android.client.entities.app.Timeline; import app.fedilab.android.databinding.FragmentPaginationBinding; +import app.fedilab.android.exception.DBException; import app.fedilab.android.helper.Helper; import app.fedilab.android.helper.MastodonHelper; import app.fedilab.android.helper.ThemeHelper; @@ -378,7 +380,7 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. * * @param fetched_statuses Statuses */ - private synchronized void dealWithPagination(Statuses fetched_statuses, DIRECTION direction, boolean fetchingMissing) { + private synchronized void dealWithPagination(Statuses fetched_statuses, DIRECTION direction, boolean fetchingMissing, Status statusToUpdate) { if (binding == null || !isAdded() || getActivity() == null) { return; } @@ -386,6 +388,23 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. binding.loadingNextElements.setVisibility(View.GONE); flagLoading = false; if (timelineStatuses != null && fetched_statuses != null && fetched_statuses.statuses != null && fetched_statuses.statuses.size() > 0) { + try { + new Thread(() -> { + StatusCache statusCache = new StatusCache(); + statusCache.instance = BaseMainActivity.currentInstance; + statusCache.user_id = BaseMainActivity.currentUserID; + statusCache.status = statusToUpdate; + if (statusToUpdate != null) { + statusCache.status_id = statusToUpdate.id; + } + try { + new StatusCache(requireActivity()).updateIfExists(statusCache); + } catch (DBException e) { + e.printStackTrace(); + } + }).start(); + } catch (Exception ignored) { + } flagLoading = fetched_statuses.pagination.max_id == null; binding.noAction.setVisibility(View.GONE); if (timelineType == Timeline.TimeLineEnum.ART) { @@ -425,6 +444,15 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. } } + /** + * Update view and pagination when scrolling down + * + * @param fetched_statuses Statuses + */ + private synchronized void dealWithPagination(Statuses fetched_statuses, DIRECTION direction, boolean fetchingMissing) { + dealWithPagination(fetched_statuses, direction, fetchingMissing, null); + } + /** * Update the timeline with received statuses * @@ -521,7 +549,7 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. * * @param direction - DIRECTION null if first call, then is set to TOP or BOTTOM depending of scroll */ - private void routeCommon(DIRECTION direction, boolean fetchingMissing) { + private void routeCommon(DIRECTION direction, boolean fetchingMissing, Status status) { if (binding == null || getActivity() == null || !isAdded()) { return; } @@ -575,7 +603,7 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. if (useCache) { getCachedStatus(direction, fetchingMissing, timelineParams); } else { - getLiveStatus(direction, fetchingMissing, timelineParams); + getLiveStatus(direction, fetchingMissing, timelineParams, status); } } @@ -585,7 +613,7 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. timelinesVM.getTimelineCache(timelineStatuses, timelineParams) .observe(getViewLifecycleOwner(), statusesCached -> { if (statusesCached == null || statusesCached.statuses == null || statusesCached.statuses.size() == 0) { - getLiveStatus(null, fetchingMissing, timelineParams); + getLiveStatus(null, fetchingMissing, timelineParams, null); } else { initializeStatusesCommonView(statusesCached); } @@ -594,7 +622,7 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. timelinesVM.getTimelineCache(timelineStatuses, timelineParams) .observe(getViewLifecycleOwner(), statusesCachedBottom -> { if (statusesCachedBottom == null || statusesCachedBottom.statuses == null || statusesCachedBottom.statuses.size() == 0) { - getLiveStatus(DIRECTION.BOTTOM, fetchingMissing, timelineParams); + getLiveStatus(DIRECTION.BOTTOM, fetchingMissing, timelineParams, null); } else { dealWithPagination(statusesCachedBottom, DIRECTION.BOTTOM, fetchingMissing); } @@ -603,7 +631,7 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. timelinesVM.getTimelineCache(timelineStatuses, timelineParams) .observe(getViewLifecycleOwner(), statusesCachedTop -> { if (statusesCachedTop == null || statusesCachedTop.statuses == null || statusesCachedTop.statuses.size() == 0) { - getLiveStatus(DIRECTION.TOP, fetchingMissing, timelineParams); + getLiveStatus(DIRECTION.TOP, fetchingMissing, timelineParams, null); } else { dealWithPagination(statusesCachedTop, DIRECTION.TOP, fetchingMissing); } @@ -625,21 +653,22 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. } } - private void getLiveStatus(DIRECTION direction, boolean fetchingMissing, TimelinesVM.TimelineParams timelineParams) { + + private void getLiveStatus(DIRECTION direction, boolean fetchingMissing, TimelinesVM.TimelineParams timelineParams, Status status) { if (direction == null) { timelinesVM.getTimeline(timelineStatuses, timelineParams) .observe(getViewLifecycleOwner(), this::initializeStatusesCommonView); } else if (direction == DIRECTION.BOTTOM) { timelinesVM.getTimeline(timelineStatuses, timelineParams) - .observe(getViewLifecycleOwner(), statusesBottom -> dealWithPagination(statusesBottom, DIRECTION.BOTTOM, fetchingMissing)); + .observe(getViewLifecycleOwner(), statusesBottom -> dealWithPagination(statusesBottom, DIRECTION.BOTTOM, fetchingMissing, status)); } else if (direction == DIRECTION.TOP) { timelinesVM.getTimeline(timelineStatuses, timelineParams) - .observe(getViewLifecycleOwner(), statusesTop -> dealWithPagination(statusesTop, DIRECTION.TOP, fetchingMissing)); + .observe(getViewLifecycleOwner(), statusesTop -> dealWithPagination(statusesTop, DIRECTION.TOP, fetchingMissing, status)); } else if (direction == DIRECTION.REFRESH || direction == DIRECTION.SCROLL_TOP) { timelinesVM.getTimeline(timelineStatuses, timelineParams) .observe(getViewLifecycleOwner(), statusesRefresh -> { if (statusAdapter != null) { - dealWithPagination(statusesRefresh, direction, true); + dealWithPagination(statusesRefresh, direction, true, status); } else { initializeStatusesCommonView(statusesRefresh); } @@ -647,23 +676,33 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. } } + /** * Router for timelines * * @param direction - DIRECTION null if first call, then is set to TOP or BOTTOM depending of scroll */ private void route(DIRECTION direction, boolean fetchingMissing) { + route(direction, fetchingMissing, null); + } + + /** + * Router for timelines + * + * @param direction - DIRECTION null if first call, then is set to TOP or BOTTOM depending of scroll + */ + private void route(DIRECTION direction, boolean fetchingMissing, Status statusToUpdate) { if (binding == null || getActivity() == null || !isAdded()) { return; } // --- HOME TIMELINE --- if (timelineType == Timeline.TimeLineEnum.HOME) { //for more visibility it's done through loadHomeStrategy method - routeCommon(direction, fetchingMissing); + routeCommon(direction, fetchingMissing, statusToUpdate); } else if (timelineType == Timeline.TimeLineEnum.LOCAL) { //LOCAL TIMELINE - routeCommon(direction, fetchingMissing); + routeCommon(direction, fetchingMissing, statusToUpdate); } else if (timelineType == Timeline.TimeLineEnum.PUBLIC) { //PUBLIC TIMELINE - routeCommon(direction, fetchingMissing); + routeCommon(direction, fetchingMissing, statusToUpdate); } else if (timelineType == Timeline.TimeLineEnum.REMOTE) { //REMOTE TIMELINE //NITTER TIMELINES if (pinnedTimeline != null && pinnedTimeline.remoteInstance.type == RemoteInstance.InstanceType.NITTER) { @@ -729,12 +768,12 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. }); } } else { //Other remote timelines - routeCommon(direction, fetchingMissing); + routeCommon(direction, fetchingMissing, statusToUpdate); } } else if (timelineType == Timeline.TimeLineEnum.LIST) { //LIST TIMELINE - routeCommon(direction, fetchingMissing); + routeCommon(direction, fetchingMissing, statusToUpdate); } else if (timelineType == Timeline.TimeLineEnum.TAG || timelineType == Timeline.TimeLineEnum.ART) { //TAG TIMELINE - routeCommon(direction, fetchingMissing); + routeCommon(direction, fetchingMissing, statusToUpdate); } else if (timelineType == Timeline.TimeLineEnum.ACCOUNT_TIMELINE) { //PROFILE TIMELINES if (direction == null) { if (show_pinned) { @@ -821,10 +860,11 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. } } - } + + /** * Refresh status in list */ @@ -835,16 +875,16 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. } @Override - public void onClickMinId(String min_id) { + public void onClickMinId(String min_id, Status statusToUpdate) { //Fetch more has been pressed min_id_fetch_more = min_id; - route(DIRECTION.TOP, true); + route(DIRECTION.TOP, true, statusToUpdate); } @Override - public void onClickMaxId(String max_id) { + public void onClickMaxId(String max_id, Status statusToUpdate) { max_id_fetch_more = max_id; - route(DIRECTION.BOTTOM, true); + route(DIRECTION.BOTTOM, true, statusToUpdate); } public enum DIRECTION { diff --git a/app/src/main/java/app/fedilab/android/viewmodel/mastodon/NotificationsVM.java b/app/src/main/java/app/fedilab/android/viewmodel/mastodon/NotificationsVM.java index 30faf9f66..45b0d924b 100644 --- a/app/src/main/java/app/fedilab/android/viewmodel/mastodon/NotificationsVM.java +++ b/app/src/main/java/app/fedilab/android/viewmodel/mastodon/NotificationsVM.java @@ -23,19 +23,21 @@ import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; - import java.util.List; import java.util.concurrent.TimeUnit; import app.fedilab.android.client.endpoints.MastodonNotificationsService; import app.fedilab.android.client.entities.api.Notification; import app.fedilab.android.client.entities.api.Notifications; +import app.fedilab.android.client.entities.api.Pagination; import app.fedilab.android.client.entities.api.PushSubscription; +import app.fedilab.android.client.entities.app.StatusCache; +import app.fedilab.android.client.entities.app.Timeline; +import app.fedilab.android.exception.DBException; import app.fedilab.android.helper.Helper; import app.fedilab.android.helper.MastodonHelper; import app.fedilab.android.helper.TimelineHelper; +import app.fedilab.android.ui.fragment.timeline.FragmentMastodonTimeline; import okhttp3.OkHttpClient; import retrofit2.Call; import retrofit2.Response; @@ -62,7 +64,6 @@ public class NotificationsVM extends AndroidViewModel { } private MastodonNotificationsService init(@NonNull String instance) { - Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").create(); Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://" + instance + "/api/v1/") .addConverterFactory(GsonConverterFactory.create(Helper.getDateBuilder())) @@ -71,38 +72,66 @@ public class NotificationsVM extends AndroidViewModel { return retrofit.create(MastodonNotificationsService.class); } + private static void addFetchMoreNotifications(List notificationList, List timelineNotifications, TimelinesVM.TimelineParams timelineParams) throws DBException { + if (notificationList != null && notificationList.size() > 0 && timelineNotifications != null && timelineNotifications.size() > 0) { + if (timelineParams.direction == FragmentMastodonTimeline.DIRECTION.REFRESH || timelineParams.direction == FragmentMastodonTimeline.DIRECTION.SCROLL_TOP) { + //When refreshing/scrolling to TOP, if last statuses fetched has a greater id from newest in cache, there is potential hole + if (notificationList.get(notificationList.size() - 1).id.compareToIgnoreCase(timelineNotifications.get(0).id) > 0) { + notificationList.get(notificationList.size() - 1).isFetchMore = true; + notificationList.get(notificationList.size() - 1).positionFetchMore = Notification.PositionFetchMore.TOP; + } + } else if (timelineParams.direction == FragmentMastodonTimeline.DIRECTION.TOP && timelineParams.fetchingMissing) { + if (!timelineNotifications.contains(notificationList.get(0))) { + notificationList.get(0).isFetchMore = true; + notificationList.get(0).positionFetchMore = Notification.PositionFetchMore.BOTTOM; + } + } else if (timelineParams.direction == FragmentMastodonTimeline.DIRECTION.BOTTOM && timelineParams.fetchingMissing) { + if (!timelineNotifications.contains(notificationList.get(notificationList.size() - 1))) { + notificationList.get(notificationList.size() - 1).isFetchMore = true; + notificationList.get(notificationList.size() - 1).positionFetchMore = Notification.PositionFetchMore.TOP; + } + } + } + } + /** * Get notifications for the authenticated account * - * @param instance String - Instance for the api call - * @param token String - Token of the authenticated account - * @param maxId String - max id for pagination - * @param sinceId String - since id for pagination - * @param minId String - min id for pagination - * @param limit int - result fetched - * @param exlude_types List - type of notifications to exclude in reply - * @param account_id String - target notifications from an account * @return {@link LiveData} containing a {@link Notifications} */ - public LiveData getNotifications(@NonNull String instance, String token, - String maxId, - String sinceId, - String minId, - int limit, - List exlude_types, - String account_id) { + public LiveData getNotifications(List notificationList, TimelinesVM.TimelineParams timelineParams) { notificationsMutableLiveData = new MutableLiveData<>(); - MastodonNotificationsService mastodonNotificationsService = init(instance); + MastodonNotificationsService mastodonNotificationsService = init(timelineParams.instance); new Thread(() -> { Notifications notifications = new Notifications(); - Call> notificationsCall = mastodonNotificationsService.getNotifications(token, exlude_types, account_id, maxId, sinceId, minId, limit); + Call> notificationsCall = mastodonNotificationsService.getNotifications(timelineParams.token, timelineParams.excludeType, timelineParams.userId, timelineParams.maxId, timelineParams.sinceId, timelineParams.minId, timelineParams.limit); if (notificationsCall != null) { try { Response> notificationsResponse = notificationsCall.execute(); if (notificationsResponse.isSuccessful()) { - List notFilteredNotifications = notificationsResponse.body(); - notifications.notifications = TimelineHelper.filterNotification(getApplication().getApplicationContext(), notFilteredNotifications); + notifications.notifications = notificationsResponse.body(); + TimelineHelper.filterNotification(getApplication().getApplicationContext(), notifications.notifications); + addFetchMoreNotifications(notifications.notifications, notificationList, timelineParams); notifications.pagination = MastodonHelper.getPagination(notificationsResponse.headers()); + + if (notifications.notifications != null && notifications.notifications.size() > 0) { + for (Notification notification : notifications.notifications) { + StatusCache statusCacheDAO = new StatusCache(getApplication().getApplicationContext()); + StatusCache statusCache = new StatusCache(); + statusCache.instance = timelineParams.instance; + statusCache.user_id = timelineParams.userId; + statusCache.notification = notification; + statusCache.slug = notification.type; + statusCache.type = Timeline.TimeLineEnum.NOTIFICATION; + statusCache.status_id = notification.id; + try { + statusCacheDAO.insertOrUpdate(statusCache, timelineParams.slug); + } catch (DBException e) { + e.printStackTrace(); + } + } + addFetchMoreNotifications(notifications.notifications, notificationList, timelineParams); + } } } catch (Exception e) { e.printStackTrace(); @@ -116,6 +145,32 @@ public class NotificationsVM extends AndroidViewModel { return notificationsMutableLiveData; } + public LiveData getNotificationCache(List notificationList, TimelinesVM.TimelineParams timelineParams) { + notificationsMutableLiveData = new MutableLiveData<>(); + new Thread(() -> { + StatusCache statusCacheDAO = new StatusCache(getApplication().getApplicationContext()); + Notifications notifications = null; + try { + notifications = statusCacheDAO.getNotifications(timelineParams.excludeType, timelineParams.instance, timelineParams.userId, timelineParams.maxId, timelineParams.minId, timelineParams.sinceId); + if (notifications != null) { + if (notifications.notifications != null && notifications.notifications.size() > 0) { + TimelineHelper.filterNotification(getApplication().getApplicationContext(), notifications.notifications); + addFetchMoreNotifications(notifications.notifications, notificationList, timelineParams); + notifications.pagination = new Pagination(); + notifications.pagination.min_id = notifications.notifications.get(0).id; + notifications.pagination.max_id = notifications.notifications.get(notifications.notifications.size() - 1).id; + } + } + } catch (DBException e) { + e.printStackTrace(); + } + Handler mainHandler = new Handler(Looper.getMainLooper()); + Notifications finalNotifications = notifications; + Runnable myRunnable = () -> notificationsMutableLiveData.setValue(finalNotifications); + mainHandler.post(myRunnable); + }).start(); + return notificationsMutableLiveData; + } /** * Get a notification for the authenticated account by its id diff --git a/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java b/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java index 338f26615..c2c45270c 100644 --- a/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java +++ b/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java @@ -363,6 +363,7 @@ public class TimelinesVM extends AndroidViewModel { } } + public LiveData getTimeline(List timelineStatuses, TimelineParams timelineParams) { statusesMutableLiveData = new MutableLiveData<>(); @@ -451,6 +452,7 @@ public class TimelinesVM extends AndroidViewModel { return statusesMutableLiveData; } + /** * Get user drafts * @@ -843,6 +845,7 @@ public class TimelinesVM extends AndroidViewModel { public String minId; public int limit = 40; public Boolean local; + public List excludeType; public TimelineParams(@NonNull Timeline.TimeLineEnum timeLineEnum, @Nullable FragmentMastodonTimeline.DIRECTION timelineDirection, @Nullable String ident) { if (type != Timeline.TimeLineEnum.REMOTE) {