From 48dad79babb282f262d1b51f60e31e3611c9849a Mon Sep 17 00:00:00 2001 From: sictiru Date: Tue, 25 Oct 2022 21:09:45 -0700 Subject: [PATCH] New preference to load next feed on mark read (#15) New preference to load next feed on mark read. When the option is enabled, a session data source is created to calculate the next feed or folder to load. --- clients/android/NewsBlur/build.gradle | 5 + .../android/NewsBlur/res/values/strings.xml | 2 + .../NewsBlur/res/xml/activity_settings.xml | 5 + .../com/newsblur/activity/FeedItemsList.java | 51 +++++- .../newsblur/activity/FolderItemsList.java | 12 +- .../src/com/newsblur/activity/ItemsList.java | 56 ++++-- .../activity/SocialFeedItemsList.java | 11 ++ .../newsblur/database/FolderListAdapter.java | 28 ++- .../newsblur/database/StoryViewAdapter.java | 6 +- .../delegate/ItemListContextMenuDelegate.kt | 4 +- .../src/com/newsblur/di/Annotations.kt | 4 - .../src/com/newsblur/domain/Feed.java | 3 +- .../newsblur/fragment/FolderListFragment.java | 30 +++- .../ReadingActionConfirmationFragment.java | 32 ++-- .../newsblur/fragment/ReadingItemFragment.kt | 2 +- .../src/com/newsblur/util/AppConstants.java | 15 ++ .../src/com/newsblur/util/FeedSet.java | 4 +- .../src/com/newsblur/util/FeedUtils.kt | 29 ++- .../src/com/newsblur/util/PrefConstants.java | 1 + .../src/com/newsblur/util/PrefsUtils.java | 5 + .../com/newsblur/util/SessionDataSource.kt | 149 ++++++++++++++++ .../newsblur/viewModel/ItemListViewModel.kt | 16 ++ .../com/newsblur/SessionDataSourceTest.kt | 165 ++++++++++++++++++ 23 files changed, 557 insertions(+), 78 deletions(-) create mode 100644 clients/android/NewsBlur/src/com/newsblur/util/SessionDataSource.kt create mode 100644 clients/android/NewsBlur/src/com/newsblur/viewModel/ItemListViewModel.kt create mode 100644 clients/android/NewsBlur/test/com/newsblur/SessionDataSourceTest.kt diff --git a/clients/android/NewsBlur/build.gradle b/clients/android/NewsBlur/build.gradle index 5e0b0ba21..8d1cf6d55 100644 --- a/clients/android/NewsBlur/build.gradle +++ b/clients/android/NewsBlur/build.gradle @@ -44,6 +44,8 @@ dependencies { implementation 'androidx.core:core-splashscreen:1.0.0' implementation "com.google.dagger:hilt-android:2.43.2" kapt "com.google.dagger:hilt-compiler:2.43.2" + + testImplementation "junit:junit:4.13.2" } android { @@ -71,6 +73,9 @@ android { res.srcDirs = ['res'] assets.srcDirs = ['assets'] } + test { + java.srcDirs = ['test'] + } } buildTypes { diff --git a/clients/android/NewsBlur/res/values/strings.xml b/clients/android/NewsBlur/res/values/strings.xml index 7423875d3..99daea771 100644 --- a/clients/android/NewsBlur/res/values/strings.xml +++ b/clients/android/NewsBlur/res/values/strings.xml @@ -579,6 +579,8 @@ DOWN_NEXT OFF + Open next feed/folder after read + Load the next feed/folder after marked as read Confirm mark all read on… Neither diff --git a/clients/android/NewsBlur/res/xml/activity_settings.xml b/clients/android/NewsBlur/res/xml/activity_settings.xml index 8a22bce0a..8a4563953 100644 --- a/clients/android/NewsBlur/res/xml/activity_settings.xml +++ b/clients/android/NewsBlur/res/xml/activity_settings.xml @@ -148,6 +148,11 @@ android:entries="@array/volume_key_navigation_entries" android:entryValues="@array/volume_key_navigation_values" android:defaultValue="@string/default_volume_key_navigation_value" /> + + setupFolder(session.getFolderName())); } @Override String getSaveSearchFeedId() { return "river:" + folderName; } + + private void setupFolder(String folderName) { + this.folderName = folderName; + UIUtils.setupToolbar(this, R.drawable.ic_folder_closed, folderName, false); + } } diff --git a/clients/android/NewsBlur/src/com/newsblur/activity/ItemsList.java b/clients/android/NewsBlur/src/com/newsblur/activity/ItemsList.java index 08592b021..da62bb175 100644 --- a/clients/android/NewsBlur/src/com/newsblur/activity/ItemsList.java +++ b/clients/android/NewsBlur/src/com/newsblur/activity/ItemsList.java @@ -5,8 +5,12 @@ import static com.newsblur.service.NBSyncReceiver.UPDATE_STATUS; import static com.newsblur.service.NBSyncReceiver.UPDATE_STORY; import android.os.Bundle; + +import androidx.annotation.Nullable; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.ViewModelProvider; + import android.text.TextUtils; import android.view.KeyEvent; import android.view.Menu; @@ -19,23 +23,25 @@ import com.newsblur.database.BlurDatabaseHelper; import com.newsblur.databinding.ActivityItemslistBinding; import com.newsblur.delegate.ItemListContextMenuDelegate; import com.newsblur.delegate.ItemListContextMenuDelegateImpl; -import com.newsblur.di.IconLoader; import com.newsblur.fragment.ItemSetFragment; import com.newsblur.service.NBSyncService; import com.newsblur.util.AppConstants; import com.newsblur.util.FeedSet; import com.newsblur.util.FeedUtils; -import com.newsblur.util.ImageLoader; +import com.newsblur.util.ReadingActionListener; import com.newsblur.util.PrefsUtils; +import com.newsblur.util.Session; +import com.newsblur.util.SessionDataSource; import com.newsblur.util.StateFilter; import com.newsblur.util.UIUtils; +import com.newsblur.viewModel.ItemListViewModel; import javax.inject.Inject; import dagger.hilt.android.AndroidEntryPoint; @AndroidEntryPoint -public abstract class ItemsList extends NbActivity { +public abstract class ItemsList extends NbActivity implements ReadingActionListener { @Inject BlurDatabaseHelper dbHelper; @@ -43,21 +49,21 @@ public abstract class ItemsList extends NbActivity { @Inject FeedUtils feedUtils; - @Inject - @IconLoader - ImageLoader iconLoader; - public static final String EXTRA_FEED_SET = "feed_set"; public static final String EXTRA_STORY_HASH = "story_hash"; public static final String EXTRA_WIDGET_STORY = "widget_story"; public static final String EXTRA_VISIBLE_SEARCH = "visibleSearch"; + public static final String EXTRA_SESSION_DATA = "session_data"; private static final String BUNDLE_ACTIVE_SEARCH_QUERY = "activeSearchQuery"; - private ActivityItemslistBinding binding; - protected ItemListContextMenuDelegate contextMenuDelegate; - protected ItemSetFragment itemSetFragment; - protected StateFilter intelState; + protected ItemListViewModel viewModel; protected FeedSet fs; + + private ItemSetFragment itemSetFragment; + private ActivityItemslistBinding binding; + private ItemListContextMenuDelegate contextMenuDelegate; + @Nullable + private SessionDataSource sessionDataSource; @Override protected void onCreate(Bundle bundle) { @@ -66,8 +72,9 @@ public abstract class ItemsList extends NbActivity { overridePendingTransition(R.anim.slide_in_from_right, R.anim.slide_out_to_left); contextMenuDelegate = new ItemListContextMenuDelegateImpl(this, feedUtils); + viewModel = new ViewModelProvider(this).get(ItemListViewModel.class); fs = (FeedSet) getIntent().getSerializableExtra(EXTRA_FEED_SET); - intelState = PrefsUtils.getStateFilter(this); + sessionDataSource = (SessionDataSource) getIntent().getSerializableExtra(EXTRA_SESSION_DATA); // this is not strictly necessary, since our first refresh with the fs will swap in // the correct session, but that can be delayed by sync backup, so we try here to @@ -77,6 +84,7 @@ public abstract class ItemsList extends NbActivity { String hash = (String) getIntent().getSerializableExtra(EXTRA_STORY_HASH); UIUtils.startReadingActivity(fs, hash, this); } else if (PrefsUtils.isAutoOpenFirstUnread(this)) { + StateFilter intelState = PrefsUtils.getStateFilter(this); if (dbHelper.getUnreadCount(fs, intelState) > 0) { UIUtils.startReadingActivity(fs, Reading.FIND_FIRST_UNREAD, this); } @@ -168,7 +176,7 @@ public abstract class ItemsList extends NbActivity { return contextMenuDelegate.onPrepareMenuOptions(menu, fs, showSavedSearch); } - @Override + @Override public boolean onOptionsItemSelected(MenuItem item) { return contextMenuDelegate.onOptionsItemSelected(item, itemSetFragment, fs, binding.itemlistSearchQuery, getSaveSearchFeedId()); } @@ -188,6 +196,27 @@ public abstract class ItemsList extends NbActivity { } } + @Override + public void onReadingActionCompleted() { + if (sessionDataSource != null) { + Session session = sessionDataSource.getNextSession(); + if (session != null) { + // set the next session on the parent activity + fs = session.getFeedSet(); + feedUtils.prepareReadingSession(fs, false); + triggerSync(); + + // set the next session on the child activity + viewModel.updateSession(session); + + // update item set fragment + itemSetFragment.resetEmptyState(); + itemSetFragment.hasUpdated(); + itemSetFragment.scrollToTop(); + } else finish(); + } else finish(); + } + private void updateStatusIndicators() { if (binding.itemlistSyncStatus != null) { String syncStatus = NBSyncService.getSyncStatusMessage(this, true); @@ -266,4 +295,5 @@ public abstract class ItemsList extends NbActivity { } abstract String getSaveSearchFeedId(); + } diff --git a/clients/android/NewsBlur/src/com/newsblur/activity/SocialFeedItemsList.java b/clients/android/NewsBlur/src/com/newsblur/activity/SocialFeedItemsList.java index eae26d8a2..88fed9314 100644 --- a/clients/android/NewsBlur/src/com/newsblur/activity/SocialFeedItemsList.java +++ b/clients/android/NewsBlur/src/com/newsblur/activity/SocialFeedItemsList.java @@ -2,11 +2,22 @@ package com.newsblur.activity; import android.os.Bundle; +import com.newsblur.di.IconLoader; import com.newsblur.domain.SocialFeed; +import com.newsblur.util.ImageLoader; import com.newsblur.util.UIUtils; +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint public class SocialFeedItemsList extends ItemsList { + @Inject + @IconLoader + ImageLoader iconLoader; + public static final String EXTRA_SOCIAL_FEED = "social_feed"; private SocialFeed socialFeed; diff --git a/clients/android/NewsBlur/src/com/newsblur/database/FolderListAdapter.java b/clients/android/NewsBlur/src/com/newsblur/database/FolderListAdapter.java index e5b868e34..cbd8b3abf 100644 --- a/clients/android/NewsBlur/src/com/newsblur/database/FolderListAdapter.java +++ b/clients/android/NewsBlur/src/com/newsblur/database/FolderListAdapter.java @@ -1,5 +1,13 @@ package com.newsblur.database; +import static com.newsblur.util.AppConstants.ALL_SHARED_STORIES_GROUP_KEY; +import static com.newsblur.util.AppConstants.ALL_STORIES_GROUP_KEY; +import static com.newsblur.util.AppConstants.GLOBAL_SHARED_STORIES_GROUP_KEY; +import static com.newsblur.util.AppConstants.INFREQUENT_SITE_STORIES_GROUP_KEY; +import static com.newsblur.util.AppConstants.READ_STORIES_GROUP_KEY; +import static com.newsblur.util.AppConstants.SAVED_SEARCHES_GROUP_KEY; +import static com.newsblur.util.AppConstants.SAVED_STORIES_GROUP_KEY; + import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collection; @@ -35,8 +43,10 @@ import com.newsblur.domain.Folder; import com.newsblur.domain.SavedSearch; import com.newsblur.domain.StarredCount; import com.newsblur.domain.SocialFeed; +import com.newsblur.util.Session; import com.newsblur.util.AppConstants; import com.newsblur.util.FeedListOrder; +import com.newsblur.util.SessionDataSource; import com.newsblur.util.SpacingStyle; import com.newsblur.util.FeedSet; import com.newsblur.util.ImageLoader; @@ -52,21 +62,6 @@ public class FolderListAdapter extends BaseExpandableListAdapter { private enum GroupType { GLOBAL_SHARED_STORIES, ALL_SHARED_STORIES, INFREQUENT_STORIES, ALL_STORIES, FOLDER, READ_STORIES, SAVED_SEARCHES, SAVED_STORIES } private enum ChildType { SOCIAL_FEED, FEED, SAVED_BY_TAG, SAVED_SEARCH } - // The following keys are used to mark the position of the special meta-folders within - // the folders array. Since the ExpandableListView doesn't handle collapsing of views - // set to View.GONE, we have to totally remove any hidden groups from the group count - // and adjust all folder indicies accordingly. Fake folders are created with these - // very unlikely names and layout methods check against them before assuming a row is - // a normal folder. All the string comparison is a small price to pay to avoid the - // alternative of index-counting in a situation where some rows might be disabled. - private static final String GLOBAL_SHARED_STORIES_GROUP_KEY = "GLOBAL_SHARED_STORIES_GROUP_KEY"; - private static final String ALL_SHARED_STORIES_GROUP_KEY = "ALL_SHARED_STORIES_GROUP_KEY"; - private static final String ALL_STORIES_GROUP_KEY = "ALL_STORIES_GROUP_KEY"; - private static final String INFREQUENT_SITE_STORIES_GROUP_KEY = "INFREQUENT_SITE_STORIES_GROUP_KEY"; - private static final String READ_STORIES_GROUP_KEY = "READ_STORIES_GROUP_KEY"; - private static final String SAVED_STORIES_GROUP_KEY = "SAVED_STORIES_GROUP_KEY"; - private static final String SAVED_SEARCHES_GROUP_KEY = "SAVED_SEARCHES_GROUP_KEY"; - private final static float defaultTextSize_childName = 14; private final static float defaultTextSize_groupName = 13; private final static float defaultTextSize_count = 13; @@ -982,4 +977,7 @@ public class FolderListAdapter extends BaseExpandableListAdapter { this.spacingStyle = spacingStyle; } + public SessionDataSource buildSessionDataSource(Session activeSession) { + return new SessionDataSource(activeSession, activeFolderNames, activeFolderChildren); + } } diff --git a/clients/android/NewsBlur/src/com/newsblur/database/StoryViewAdapter.java b/clients/android/NewsBlur/src/com/newsblur/database/StoryViewAdapter.java index 537066789..be03993cf 100644 --- a/clients/android/NewsBlur/src/com/newsblur/database/StoryViewAdapter.java +++ b/clients/android/NewsBlur/src/com/newsblur/database/StoryViewAdapter.java @@ -423,11 +423,11 @@ public class StoryViewAdapter extends RecyclerView.Adapter, Serializable { @@ -153,7 +154,7 @@ public class Feed implements Comparable, Serializable { public boolean equals(Object o) { if (! (o instanceof Feed)) return false; Feed otherFeed = (Feed) o; - return (TextUtils.equals(feedId, otherFeed.feedId)); + return (FeedUtils.textUtilsEquals(feedId, otherFeed.feedId)); } @Override diff --git a/clients/android/NewsBlur/src/com/newsblur/fragment/FolderListFragment.java b/clients/android/NewsBlur/src/com/newsblur/fragment/FolderListFragment.java index 707092215..abd38e84f 100644 --- a/clients/android/NewsBlur/src/com/newsblur/fragment/FolderListFragment.java +++ b/clients/android/NewsBlur/src/com/newsblur/fragment/FolderListFragment.java @@ -51,8 +51,10 @@ import com.newsblur.domain.Feed; import com.newsblur.domain.Folder; import com.newsblur.domain.SavedSearch; import com.newsblur.domain.SocialFeed; +import com.newsblur.util.Session; import com.newsblur.util.AppConstants; import com.newsblur.util.FeedExt; +import com.newsblur.util.SessionDataSource; import com.newsblur.util.SpacingStyle; import com.newsblur.util.FeedSet; import com.newsblur.util.FeedUtils; @@ -392,7 +394,7 @@ public class FolderListFragment extends NbFragment implements OnCreateContextMen } private void markFeedsAsRead(FeedSet fs) { - feedUtils.markRead(((NbActivity) getActivity()), fs, null, null, R.array.mark_all_read_options, false); + feedUtils.markRead(((NbActivity) getActivity()), fs, null, null, R.array.mark_all_read_options); adapter.lastFeedViewedId = fs.getSingleFeed(); adapter.lastFolderViewed = fs.getFolderName(); } @@ -437,7 +439,13 @@ public class FolderListFragment extends NbFragment implements OnCreateContextMen @Override public boolean onGroupClick(ExpandableListView list, View group, int groupPosition, long id) { - Intent i = null; + if (adapter.isRowSavedSearches(groupPosition)) { + // group not clickable + return true; + } + + FeedSet fs = adapter.getGroup(groupPosition); + Intent i; if (adapter.isRowAllStories(groupPosition)) { if (currentState == StateFilter.SAVED) { // the existence of this row in saved mode is something of a framework artifact and may @@ -456,17 +464,15 @@ public class FolderListFragment extends NbFragment implements OnCreateContextMen i = new Intent(getActivity(), ReadStoriesItemsList.class); } else if (adapter.isRowSavedStories(groupPosition)) { i = new Intent(getActivity(), SavedStoriesItemsList.class); - } else if (adapter.isRowSavedSearches(groupPosition)) { - // group not clickable - return true; } else { i = new Intent(getActivity(), FolderItemsList.class); String canonicalFolderName = adapter.getGroupFolderName(groupPosition); + SessionDataSource sessionDataSource = getSessionData(fs, canonicalFolderName, null); i.putExtra(FolderItemsList.EXTRA_FOLDER_NAME, canonicalFolderName); + i.putExtra(ItemsList.EXTRA_SESSION_DATA, sessionDataSource); adapter.lastFeedViewedId = null; adapter.lastFolderViewed = canonicalFolderName; } - FeedSet fs = adapter.getGroup(groupPosition); i.putExtra(ItemsList.EXTRA_FEED_SET, fs); startActivity(i); @@ -541,7 +547,8 @@ public class FolderListFragment extends NbFragment implements OnCreateContextMen feedUtils.currentFolderName = folderName; } - FeedItemsList.startActivity(getActivity(), fs, feed, folderName); + SessionDataSource sessionDataSource = getSessionData(fs, folderName, feed); + FeedItemsList.startActivity(getActivity(), fs, feed, folderName, sessionDataSource); adapter.lastFeedViewedId = feed.feedId; adapter.lastFolderViewed = null; } @@ -619,4 +626,13 @@ public class FolderListFragment extends NbFragment implements OnCreateContextMen adapter.notifyDataSetChanged(); } } + + @Nullable + private SessionDataSource getSessionData(FeedSet fs, String folderName, @Nullable Feed feed) { + if (PrefsUtils.loadNextOnMarkRead(requireContext())) { + Session activeSession = new Session(fs, folderName, feed); + return adapter.buildSessionDataSource(activeSession); + } + return null; + } } diff --git a/clients/android/NewsBlur/src/com/newsblur/fragment/ReadingActionConfirmationFragment.java b/clients/android/NewsBlur/src/com/newsblur/fragment/ReadingActionConfirmationFragment.java index 766fa8d27..f51981166 100644 --- a/clients/android/NewsBlur/src/com/newsblur/fragment/ReadingActionConfirmationFragment.java +++ b/clients/android/NewsBlur/src/com/newsblur/fragment/ReadingActionConfirmationFragment.java @@ -1,15 +1,18 @@ package com.newsblur.fragment; -import com.newsblur.util.FeedUtils; -import com.newsblur.util.ReadingAction; - import android.app.Dialog; import android.content.DialogInterface; import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; +import com.newsblur.util.FeedUtils; +import com.newsblur.util.ReadingAction; +import com.newsblur.util.ReadingActionListener; + import javax.inject.Inject; import dagger.hilt.android.AndroidEntryPoint; @@ -24,39 +27,40 @@ public class ReadingActionConfirmationFragment extends DialogFragment { private static final String DIALOG_TITLE = "dialog_title"; private static final String DIALOG_MESSAGE = "dialog_message"; private static final String DIALOG_CHOICES_RID = "dialog_choices_rid"; - private static final String FINISH_AFTER = "finish_after"; - - public static ReadingActionConfirmationFragment newInstance(ReadingAction ra, CharSequence title, CharSequence message, int choicesId, boolean finishAfter) { + private static final String ACTION_CALLBACK = "action_callback"; + + public static ReadingActionConfirmationFragment newInstance(ReadingAction ra, CharSequence title, CharSequence message, int choicesId, @Nullable ReadingActionListener callback) { ReadingActionConfirmationFragment fragment = new ReadingActionConfirmationFragment(); Bundle args = new Bundle(); args.putSerializable(READING_ACTION, ra); args.putCharSequence(DIALOG_TITLE, title); args.putCharSequence(DIALOG_MESSAGE, message); args.putInt(DIALOG_CHOICES_RID, choicesId); - args.putBoolean(FINISH_AFTER, finishAfter); + args.putSerializable(ACTION_CALLBACK, callback); fragment.setArguments(args); return fragment; } + @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()); - final ReadingAction ra = (ReadingAction)getArguments().getSerializable(READING_ACTION); + final ReadingAction ra = (ReadingAction) getArguments().getSerializable(READING_ACTION); CharSequence title = getArguments().getCharSequence(DIALOG_TITLE); CharSequence message = getArguments().getCharSequence(DIALOG_MESSAGE); int choicesId = getArguments().getInt(DIALOG_CHOICES_RID); - final boolean finishAfter = getArguments().getBoolean(FINISH_AFTER); - + @Nullable ReadingActionListener callback = (ReadingActionListener) getArguments().getSerializable(ACTION_CALLBACK); + builder.setTitle(title); // NB: setting a message will override the display of the buttons, making the dialogue a no-op if (message != null) builder.setMessage(message); builder.setItems(choicesId, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int which) { if (which == 0) { - feedUtils.doAction(ra, getActivity()); - if (finishAfter) { - getActivity().finish(); + feedUtils.doAction(ra, requireContext()); + if (callback != null) { + callback.onReadingActionCompleted(); } } } diff --git a/clients/android/NewsBlur/src/com/newsblur/fragment/ReadingItemFragment.kt b/clients/android/NewsBlur/src/com/newsblur/fragment/ReadingItemFragment.kt index 1be4fac94..2f7cce366 100644 --- a/clients/android/NewsBlur/src/com/newsblur/fragment/ReadingItemFragment.kt +++ b/clients/android/NewsBlur/src/com/newsblur/fragment/ReadingItemFragment.kt @@ -419,7 +419,7 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener { true } R.id.menu_go_to_feed -> { - FeedItemsList.startActivity(context, fs, dbHelper.getFeed(story!!.feedId), null) + FeedItemsList.startActivity(context, fs, dbHelper.getFeed(story!!.feedId), null, null) true } else -> { diff --git a/clients/android/NewsBlur/src/com/newsblur/util/AppConstants.java b/clients/android/NewsBlur/src/com/newsblur/util/AppConstants.java index aa7dc7c27..d81e1990d 100644 --- a/clients/android/NewsBlur/src/com/newsblur/util/AppConstants.java +++ b/clients/android/NewsBlur/src/com/newsblur/util/AppConstants.java @@ -95,4 +95,19 @@ public class AppConstants { // Free standard account sites limit public final static int FREE_ACCOUNT_SITE_LIMIT = 64; + // The following keys are used to mark the position of the special meta-folders within + // the folders array. Since the ExpandableListView doesn't handle collapsing of views + // set to View.GONE, we have to totally remove any hidden groups from the group count + // and adjust all folder indicies accordingly. Fake folders are created with these + // very unlikely names and layout methods check against them before assuming a row is + // a normal folder. All the string comparison is a small price to pay to avoid the + // alternative of index-counting in a situation where some rows might be disabled. + public static final String GLOBAL_SHARED_STORIES_GROUP_KEY = "GLOBAL_SHARED_STORIES_GROUP_KEY"; + public static final String ALL_SHARED_STORIES_GROUP_KEY = "ALL_SHARED_STORIES_GROUP_KEY"; + public static final String ALL_STORIES_GROUP_KEY = "ALL_STORIES_GROUP_KEY"; + public static final String INFREQUENT_SITE_STORIES_GROUP_KEY = "INFREQUENT_SITE_STORIES_GROUP_KEY"; + public static final String READ_STORIES_GROUP_KEY = "READ_STORIES_GROUP_KEY"; + public static final String SAVED_STORIES_GROUP_KEY = "SAVED_STORIES_GROUP_KEY"; + public static final String SAVED_SEARCHES_GROUP_KEY = "SAVED_SEARCHES_GROUP_KEY"; + } diff --git a/clients/android/NewsBlur/src/com/newsblur/util/FeedSet.java b/clients/android/NewsBlur/src/com/newsblur/util/FeedSet.java index 6bf1ca34f..61bbef664 100644 --- a/clients/android/NewsBlur/src/com/newsblur/util/FeedSet.java +++ b/clients/android/NewsBlur/src/com/newsblur/util/FeedSet.java @@ -330,8 +330,8 @@ public class FeedSet implements Serializable { if (!( o instanceof FeedSet)) return false; FeedSet s = (FeedSet) o; - if ( !TextUtils.equals(searchQuery, s.searchQuery)) return false; - if ( !TextUtils.equals(folderName, s.folderName)) return false; + if ( !FeedUtils.textUtilsEquals(searchQuery, s.searchQuery)) return false; + if ( !FeedUtils.textUtilsEquals(folderName, s.folderName)) return false; if ( isFilterSaved != s.isFilterSaved ) return false; if ( (feeds != null) && (s.feeds != null) && s.feeds.equals(feeds) ) return true; if ( (socialFeeds != null) && (s.socialFeeds != null) && s.socialFeeds.equals(socialFeeds) ) return true; diff --git a/clients/android/NewsBlur/src/com/newsblur/util/FeedUtils.kt b/clients/android/NewsBlur/src/com/newsblur/util/FeedUtils.kt index 9d24e1b40..0a9dfb014 100644 --- a/clients/android/NewsBlur/src/com/newsblur/util/FeedUtils.kt +++ b/clients/android/NewsBlur/src/com/newsblur/util/FeedUtils.kt @@ -210,10 +210,13 @@ class FeedUtils( triggerSync(context) } + fun markRead(activity: NbActivity, fs: FeedSet, olderThan: Long?, newerThan: Long?, choicesRid: Int) = + markRead(activity, fs, olderThan, newerThan, choicesRid, null) + /** * Marks some or all of the stories in a FeedSet as read for an activity, handling confirmation dialogues as necessary. */ - fun markRead(activity: NbActivity, fs: FeedSet, olderThan: Long?, newerThan: Long?, choicesRid: Int, finishAfter: Boolean) { + fun markRead(activity: NbActivity, fs: FeedSet, olderThan: Long?, newerThan: Long?, choicesRid: Int, callback: ReadingActionListener?) { val ra: ReadingAction = if (fs.isAllNormal && (olderThan != null || newerThan != null)) { // the mark-all-read API doesn't support range bounding, so we need to pass each and every // feed ID to the API instead. @@ -252,9 +255,7 @@ class FeedUtils( } if (doImmediate) { doAction(ra, activity) - if (finishAfter) { - activity.finish() - } + callback?.onReadingActionCompleted() } else { val title: String? = when { fs.isAllNormal -> { @@ -270,7 +271,7 @@ class FeedUtils( dbHelper.getFeed(fs.singleFeed)?.title ?: "" } } - val dialog = ReadingActionConfirmationFragment.newInstance(ra, title, optionalOverrideMessage, choicesRid, finishAfter) + val dialog = ReadingActionConfirmationFragment.newInstance(ra, title, optionalOverrideMessage, choicesRid, callback) dialog.show(activity.supportFragmentManager, "dialog") } } @@ -508,5 +509,23 @@ class FeedUtils( val parts = TextUtils.split(storyHash, ":") return if (parts.size != 2) null else parts[0] } + + /** + * Copy of TextUtils.equals because of Java for unit tests + */ + @JvmStatic + fun textUtilsEquals(a: CharSequence?, b: CharSequence?): Boolean { + if (a === b) return true + return if (a != null && b != null && a.length == b.length) { + if (a is String && b is String) { + a == b + } else { + for (i in a.indices) { + if (a[i] != b[i]) return false + } + true + } + } else false + } } } diff --git a/clients/android/NewsBlur/src/com/newsblur/util/PrefConstants.java b/clients/android/NewsBlur/src/com/newsblur/util/PrefConstants.java index 21abd4e62..35a2adf97 100644 --- a/clients/android/NewsBlur/src/com/newsblur/util/PrefConstants.java +++ b/clients/android/NewsBlur/src/com/newsblur/util/PrefConstants.java @@ -123,4 +123,5 @@ public class PrefConstants { public static final String FEED_CHOOSER_FOLDER_VIEW = "feed_chooser_folder_view"; public static final String WIDGET_BACKGROUND = "widget_background"; public static final String IN_APP_REVIEW = "in_app_review"; + public static final String LOAD_NEXT_ON_MARK_READ = "load_next_on_mark_read"; } diff --git a/clients/android/NewsBlur/src/com/newsblur/util/PrefsUtils.java b/clients/android/NewsBlur/src/com/newsblur/util/PrefsUtils.java index 36a67bf5e..9e148ed3f 100644 --- a/clients/android/NewsBlur/src/com/newsblur/util/PrefsUtils.java +++ b/clients/android/NewsBlur/src/com/newsblur/util/PrefsUtils.java @@ -1064,4 +1064,9 @@ public class PrefsUtils { SharedPreferences preferences = context.getSharedPreferences(PrefConstants.PREFERENCES, 0); return MarkStoryReadBehavior.valueOf(preferences.getString(PrefConstants.STORY_MARK_READ_BEHAVIOR, MarkStoryReadBehavior.IMMEDIATELY.name())); } + + public static boolean loadNextOnMarkRead(Context context) { + SharedPreferences prefs = context.getSharedPreferences(PrefConstants.PREFERENCES, 0); + return prefs.getBoolean(PrefConstants.LOAD_NEXT_ON_MARK_READ, false); + } } diff --git a/clients/android/NewsBlur/src/com/newsblur/util/SessionDataSource.kt b/clients/android/NewsBlur/src/com/newsblur/util/SessionDataSource.kt new file mode 100644 index 000000000..cf6fb4132 --- /dev/null +++ b/clients/android/NewsBlur/src/com/newsblur/util/SessionDataSource.kt @@ -0,0 +1,149 @@ +package com.newsblur.util + +import com.newsblur.domain.Feed +import java.io.Serializable + +/** + * @return Set of folder keys that don't support + * mark all read action + */ +private val invalidMarkAllReadFolderKeys by lazy { + setOf( + AppConstants.GLOBAL_SHARED_STORIES_GROUP_KEY, + AppConstants.ALL_SHARED_STORIES_GROUP_KEY, + AppConstants.INFREQUENT_SITE_STORIES_GROUP_KEY, + AppConstants.READ_STORIES_GROUP_KEY, + AppConstants.SAVED_STORIES_GROUP_KEY, + AppConstants.SAVED_SEARCHES_GROUP_KEY, + ) +} + +/** + * As of writing this function, the zipping of the two sources + * is valid as the "activeFolderNames" and "activeFolderChildren" + * can be mapped by their folder name. + * @return Map of folder names to their feed list. + */ +private fun List.zipFolderFeed(foldersChildren: List>): Map> { + val first = this.iterator() + val second = foldersChildren.iterator() + return buildMap { + while (first.hasNext() && second.hasNext()) { + this[first.next()] = second.next() + } + } +} + +private fun Feed.toFeedSet() = FeedSet.singleFeed(this.feedId).apply { + isMuted = !this@toFeedSet.active +} + +/** + * Represents the user's current reading session data source + * as constructed and filtered by the home list adapter + * based on settings and preferences. + */ +class SessionDataSource private constructor( + private val folders: List, + private val foldersChildrenMap: Map> +) : Serializable { + + private lateinit var session: Session + + constructor( + activeSession: Session, + folders: List, + foldersChildren: List>, + ) : this( + folders = folders.filterNot { invalidMarkAllReadFolderKeys.contains(it) }, + foldersChildrenMap = folders.zipFolderFeed(foldersChildren) + .filterNot { invalidMarkAllReadFolderKeys.contains(it.key) }, + ) { + this.session = activeSession + } + + /** + * @return The next feed within a folder or null if the folder + * is showing the last feed. + */ + private fun getNextFolderFeed(feed: Feed, folderName: String): Feed? { + val cleanFolderName = + // ROOT FOLDER maps to ALL_STORIES_GROUP_KEY + if (folderName == AppConstants.ROOT_FOLDER) + AppConstants.ALL_STORIES_GROUP_KEY + else folderName + val folderFeeds = foldersChildrenMap[cleanFolderName] + return folderFeeds?.let { feeds -> + val feedIndex = feeds.indexOf(feed) + if (feedIndex == -1) return null // invalid feed + + val nextFeedIndex = when (feedIndex) { + feeds.size - 1 -> null // null feed if EOL + in feeds.indices -> feedIndex + 1 // next feed + else -> null // no valid feed found + } + + nextFeedIndex?.let { feeds[it] } + } + } + + /** + * @return The next non empty folder and its feeds based on the given folder name. + * If the next folder doesn't have feeds, it will call itself with the new folder name + * until it finds a non empty folder or it will get to the end of the folder list. + */ + private fun getNextNonEmptyFolder(folderName: String): Pair>? = with(folders.indexOf(folderName)) { + val nextIndex = if (this == folders.size - 1) { + 0 // first folder if EOL + } else if (this in folders.indices) { + this + 1 // next folder + } else this // no folder found + + val nextFolderName = if (nextIndex in folders.indices) { + folders[nextIndex] + } else null + + if (nextFolderName == null || nextFolderName == folderName) + return null + + val feeds = foldersChildrenMap[nextFolderName] + if (feeds == null || feeds.isEmpty()) + // try and get the next non empty folder name + getNextNonEmptyFolder(nextFolderName) + else nextFolderName to feeds + } + + fun getNextSession(): Session? = if (session.feedSet.isFolder) { + val folderName = session.feedSet.folderName + getNextNonEmptyFolder(folderName)?.let { (nextFolderName, nextFolderFeeds) -> + val nextFeedSet = FeedSet.folder(nextFolderName, nextFolderFeeds.map { it.feedId }.toSet()) + Session(feedSet = nextFeedSet, folderName = nextFolderName).also { nextSession -> + session = nextSession + } + } + } else if (session.feed != null && session.folderName != null) { + val nextFeed = getNextFolderFeed(feed = session.feed!!, folderName = session.folderName!!) + nextFeed?.let { + Session(feedSet = it.toFeedSet(), session.folderName, it).also { nextSession -> + session = nextSession + } + } + } else null +} + +/** + * Represents the user's current reading session. + * + * When reading a folder, [folderName] and [feed] will be null. + * + * When reading a feed, [folderName] and [feed] will be non null. + */ +data class Session( + val feedSet: FeedSet, + val folderName: String? = null, + val feed: Feed? = null, +) : Serializable + +interface ReadingActionListener : Serializable { + fun onReadingActionCompleted() +} \ No newline at end of file diff --git a/clients/android/NewsBlur/src/com/newsblur/viewModel/ItemListViewModel.kt b/clients/android/NewsBlur/src/com/newsblur/viewModel/ItemListViewModel.kt new file mode 100644 index 000000000..7a6ddbb4f --- /dev/null +++ b/clients/android/NewsBlur/src/com/newsblur/viewModel/ItemListViewModel.kt @@ -0,0 +1,16 @@ +package com.newsblur.viewModel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.newsblur.util.Session + +class ItemListViewModel : ViewModel() { + + private val _nextSession = MutableLiveData() + val nextSession: LiveData = _nextSession + + fun updateSession(session: Session) { + _nextSession.value = session + } +} \ No newline at end of file diff --git a/clients/android/NewsBlur/test/com/newsblur/SessionDataSourceTest.kt b/clients/android/NewsBlur/test/com/newsblur/SessionDataSourceTest.kt new file mode 100644 index 000000000..ba8334dc4 --- /dev/null +++ b/clients/android/NewsBlur/test/com/newsblur/SessionDataSourceTest.kt @@ -0,0 +1,165 @@ +package com.newsblur + +import com.newsblur.domain.Feed +import com.newsblur.util.FeedSet +import com.newsblur.util.Session +import com.newsblur.util.SessionDataSource +import org.junit.Assert +import org.junit.Test + +class SessionDataSourceTest { + + private val folders = listOf( + "F1", + "F2", + "F3", + "F4", + "F5", + ) + + private val folderChildren = listOf( + emptyList(), + listOf( + createFeed("20"), + createFeed("21"), + createFeed("22"), + ), + listOf( + createFeed("30"), + ), + emptyList(), + listOf( + createFeed("50"), + createFeed("51"), + ) + ) + + @Test + fun `session constructor test`() { + val feedSet = FeedSet.singleFeed("1") + val session = Session(feedSet) + Assert.assertEquals(feedSet, session.feedSet) + Assert.assertNull(session.feed) + Assert.assertNull(session.folderName) + } + + @Test + fun `session full constructor test`() { + val feedSet = FeedSet.singleFeed("10") + val feed = createFeed("10") + val session = Session(feedSet, "folderName", feed) + Assert.assertEquals(feedSet, session.feedSet) + Assert.assertEquals("folderName", session.folderName) + Assert.assertEquals(feed, session.feed) + } + + @Test + fun `next session for unknown feedId`() { + val session = Session(FeedSet.singleFeed("123")) + val sessionDs = SessionDataSource(session, folders, folderChildren) + Assert.assertNull(sessionDs.getNextSession()) + } + + @Test + fun `next session for empty folder`() { + val feedSet = FeedSet.singleFeed("123") + val feed = createFeed("123") + val session = Session(feedSet, "F1", feed) + val sessionDs = SessionDataSource(session, folders, folderChildren) + + Assert.assertNull(sessionDs.getNextSession()) + } + + /** + * Expected to return the next [Session] containing feed id 11 + * as the second feed in folder F2 after feed id 10 + */ + @Test + fun `next session for F2 feedSet`() { + val feedSet = FeedSet.singleFeed("20") + val feed = createFeed("20") + val session = Session(feedSet, "F2", feed) + val sessionDs = SessionDataSource(session, folders, folderChildren) + + sessionDs.getNextSession()?.let { + Assert.assertEquals("F2", it.folderName) + Assert.assertEquals("21", it.feed?.feedId) + with(it.feedSet) { + Assert.assertNotNull(this) + Assert.assertTrue(it.feedSet.flatFeedIds.size == 1) + Assert.assertEquals("21", it.feedSet.flatFeedIds.first()) + } + } ?: Assert.fail("Next session was null") + } + + /** + * Expected to return a null [Session] because feed id 12 + * is the last feed id in folder F2 + */ + @Test + fun `next session for end of F2 feedSet`() { + val feedSet = FeedSet.singleFeed("22") + val feed = createFeed("22") + val session = Session(feedSet, "F2", feed) + val sessionDs = SessionDataSource(session, folders, folderChildren) + + Assert.assertNull(sessionDs.getNextSession()) + } + + @Test + fun `next session for F2 feedSetFolder`() { + val feedSet = FeedSet.folder("F2", setOf("21")) + val feed = createFeed("21") + val session = Session(feedSet, "F2", feed) + val sessionDs = SessionDataSource(session, folders, folderChildren) + + sessionDs.getNextSession()?.let { + Assert.assertNull(it.feed) + Assert.assertEquals("F3", it.folderName) + Assert.assertEquals("F3", it.feedSet.folderName) + Assert.assertEquals("30", it.feedSet.flatFeedIds.firstOrNull()) + } ?: Assert.fail("Next session is null for F2 feedSetFolder") + } + + /** + * Expected to return folder "F5" because folder "F3" + * doesn't have any feeds + */ + @Test + fun `next session for F3 feedSetFolder`() { + val feedSet = FeedSet.folder("F3", setOf("30")) + val feed = createFeed("30") + val session = Session(feedSet, "F3", feed) + val sessionDs = SessionDataSource(session, folders, folderChildren) + + sessionDs.getNextSession()?.let { + Assert.assertNull(it.feed) + Assert.assertEquals("F5", it.folderName) + Assert.assertEquals("F5", it.feedSet.folderName) + Assert.assertEquals("50", it.feedSet.flatFeedIds.firstOrNull()) + } ?: Assert.fail("Next session is null for F5 feedSetFolder") + } + + /** + * Expected to return session for F1 feedSetFolder + */ + @Test + fun `next session for F5 feedSetFolder`() { + val feedSet = FeedSet.folder("F5", setOf("50")) + val feed = createFeed("50") + val session = Session(feedSet, "F5", feed) + val sessionDs = SessionDataSource(session, folders, folderChildren) + + sessionDs.getNextSession()?.let { + Assert.assertNull(it.feed) + Assert.assertEquals("F2", it.folderName) + Assert.assertEquals("F2", it.feedSet.folderName) + Assert.assertEquals(setOf("20", "21", "22"), it.feedSet.flatFeedIds) + } ?: Assert.fail("Next session is null for F5 feedSetFolder") + } + + private fun createFeed(id: String) = Feed().apply { + feedId = id + title = "Feed #$id" + } +} \ No newline at end of file