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