diff --git a/build.gradle.kts b/build.gradle.kts index 9aeab6309..68ad26c6a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(libs.commons.text) implementation(libs.okhttp) + implementation(libs.picasso) implementation(libs.netcipher.webkit) implementation(libs.media3.exoplayer) implementation(libs.media3.ui) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 706c20a26..251223136 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ kotlinx-serialization = "1.9.0" media3 = "1.8.0" netcipher = "2.1.0" okhttp = "5.3.0" +picasso = "2.8" pmd = "6.55.0" zstd = "1.5.7-6" @@ -70,6 +71,7 @@ media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } netcipher-webkit = { module = "info.guardianproject.netcipher:netcipher-webkit", version.ref = "netcipher" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +picasso = { module = "com.squareup.picasso:picasso", version.ref = "picasso" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } zstd = { module = "com.github.luben:zstd-jni", version.ref = "zstd" } diff --git a/src/main/java/org/quantumbadger/redreader/activities/MainActivity.java b/src/main/java/org/quantumbadger/redreader/activities/MainActivity.java index 92e0dc19c..a71068702 100644 --- a/src/main/java/org/quantumbadger/redreader/activities/MainActivity.java +++ b/src/main/java/org/quantumbadger/redreader/activities/MainActivity.java @@ -67,6 +67,7 @@ import org.quantumbadger.redreader.listingcontrollers.CommentListingController; import org.quantumbadger.redreader.listingcontrollers.PostListingController; import org.quantumbadger.redreader.reddit.PostCommentSort; +import org.quantumbadger.redreader.reddit.PostFilter; import org.quantumbadger.redreader.reddit.PostSort; import org.quantumbadger.redreader.reddit.RedditSubredditHistory; import org.quantumbadger.redreader.reddit.UserCommentSort; @@ -912,6 +913,12 @@ public void onSortSelected(final PostSort order) { requestRefresh(RefreshableFragment.POSTS, false); } + @Override + public void onPostFilterSelected(final PostFilter filter) { + postListingController.setFilter(filter); + requestRefresh(RefreshableFragment.POSTS, false); + } + @Override public void onSearchPosts() { PostListingActivity.onSearchPosts(postListingController, this); @@ -1124,6 +1131,15 @@ public PostSort getPostSort() { return postListingController.getSort(); } + @Override + public PostFilter getPostFilter() { + if(postListingController == null) { + return PostFilter.ALL; + } + + return postListingController.getFilter(); + } + @Override public OptionsMenuUtility.Sort getCommentSort() { if(commentListingController == null) { diff --git a/src/main/java/org/quantumbadger/redreader/activities/OptionsMenuUtility.java b/src/main/java/org/quantumbadger/redreader/activities/OptionsMenuUtility.java index 34bf62b53..a8e82caeb 100644 --- a/src/main/java/org/quantumbadger/redreader/activities/OptionsMenuUtility.java +++ b/src/main/java/org/quantumbadger/redreader/activities/OptionsMenuUtility.java @@ -41,6 +41,7 @@ import org.quantumbadger.redreader.common.UnexpectedInternalStateException; import org.quantumbadger.redreader.fragments.AccountListDialog; import org.quantumbadger.redreader.reddit.PostCommentSort; +import org.quantumbadger.redreader.reddit.PostFilter; import org.quantumbadger.redreader.reddit.PostSort; import org.quantumbadger.redreader.reddit.UserCommentSort; import org.quantumbadger.redreader.reddit.api.SubredditSubscriptionState; @@ -68,7 +69,8 @@ public enum AppbarItemsPref { SETTINGS, CLOSE_ALL, REPLY, - SEARCH + SEARCH, + FILTER } public static final int DO_NOT_SHOW = -1; @@ -128,6 +130,10 @@ public static void prepare( false); } else if(!subredditsVisible && postsVisible && !commentsVisible) { + addAllPostFilters( + activity, + menu, + getOrThrow(appbarItemsPrefs, AppbarItemsPref.FILTER)); if(postsSortable) { if(areSearchResults) { @@ -258,6 +264,10 @@ public static void prepare( } else { if(postsVisible && commentsVisible) { + addAllPostFilters( + activity, + menu, + getOrThrow(appbarItemsPrefs, AppbarItemsPref.FILTER)); if(getOrThrow(appbarItemsPrefs, AppbarItemsPref.SORT) != DO_NOT_SHOW) { final SubMenu sortMenu = menu.addSubMenu( Menu.NONE, @@ -293,6 +303,10 @@ public static void prepare( } } } else if(postsVisible) { + addAllPostFilters( + activity, + menu, + getOrThrow(appbarItemsPrefs, AppbarItemsPref.FILTER)); if(postsSortable) { if(areSearchResults) { addAllSearchSorts( @@ -1142,6 +1156,44 @@ private static void addAllPostSorts( sortPosts.setGroupCheckable(Menu.NONE, true, true); } + private static void addAllPostFilters( + final AppCompatActivity activity, + final Menu menu, + final int showAsAction) { + + if(showAsAction == DO_NOT_SHOW) { + return; + } + + final SubMenu filterPosts = menu.addSubMenu( + Menu.NONE, + AppbarItemsPref.FILTER.ordinal(), + Menu.NONE, + R.string.options_filter_posts); + + if(showAsAction != MenuItem.SHOW_AS_ACTION_NEVER) { + filterPosts.getItem().setIcon(R.drawable.ic_filter_dark); + filterPosts.getItem().setShowAsAction(handleShowAsActionIfRoom(showAsAction)); + } + + final PostFilter activeFilter = ((OptionsMenuPostsListener)activity).getPostFilter(); + + for(final PostFilter filter : PostFilter.values()) { + final MenuItem menuItem = filterPosts.add(activity.getString(filter.getMenuTitle())) + .setCheckable(true) + .setOnMenuItemClickListener(item -> { + ((OptionsMenuPostsListener)activity).onPostFilterSelected(filter); + return true; + }); + + if(filter.equals(activeFilter)) { + menuItem.setChecked(true); + } + } + + filterPosts.setGroupCheckable(Menu.NONE, true, true); + } + private static void addAllSearchSorts( final AppCompatActivity activity, final Menu menu, @@ -1422,6 +1474,8 @@ public interface OptionsMenuPostsListener extends OptionsMenuListener { void onSortSelected(PostSort order); + void onPostFilterSelected(PostFilter filter); + void onSearchPosts(); void onSubscribe(); @@ -1439,6 +1493,8 @@ public interface OptionsMenuPostsListener extends OptionsMenuListener { void onUnblock(); PostSort getPostSort(); + + PostFilter getPostFilter(); } public interface OptionsMenuCommentsListener extends OptionsMenuListener { diff --git a/src/main/java/org/quantumbadger/redreader/activities/PostListingActivity.java b/src/main/java/org/quantumbadger/redreader/activities/PostListingActivity.java index 2513715b4..4d27b936d 100644 --- a/src/main/java/org/quantumbadger/redreader/activities/PostListingActivity.java +++ b/src/main/java/org/quantumbadger/redreader/activities/PostListingActivity.java @@ -40,6 +40,7 @@ import org.quantumbadger.redreader.fragments.PostListingFragment; import org.quantumbadger.redreader.fragments.SessionListDialog; import org.quantumbadger.redreader.listingcontrollers.PostListingController; +import org.quantumbadger.redreader.reddit.PostFilter; import org.quantumbadger.redreader.reddit.PostSort; import org.quantumbadger.redreader.reddit.api.RedditSubredditSubscriptionManager; import org.quantumbadger.redreader.reddit.api.SubredditSubscriptionState; @@ -65,6 +66,7 @@ public class PostListingActivity extends RefreshableActivity private static final String SAVEDSTATE_SESSION = "pla_session"; private static final String SAVEDSTATE_SORT = "pla_sort"; + private static final String SAVEDSTATE_FILTER = "pla_filter"; private static final String SAVEDSTATE_FRAGMENT = "pla_fragment"; private PostListingFragment fragment; @@ -125,6 +127,11 @@ public void onCreate(final Bundle savedInstanceState) { savedInstanceState.getString(SAVEDSTATE_SORT))); } + if(savedInstanceState.containsKey(SAVEDSTATE_FILTER)) { + controller.setFilter(PostFilter.valueOf( + savedInstanceState.getString(SAVEDSTATE_FILTER))); + } + if(savedInstanceState.containsKey(SAVEDSTATE_FRAGMENT)) { fragmentSavedInstanceState = savedInstanceState.getBundle( SAVEDSTATE_FRAGMENT); @@ -157,6 +164,8 @@ protected void onSaveInstanceState(@NonNull final Bundle outState) { outState.putString(SAVEDSTATE_SORT, sort.name()); } + outState.putString(SAVEDSTATE_FILTER, controller.getFilter().name()); + if(fragment != null) { outState.putBundle(SAVEDSTATE_FRAGMENT, fragment.onSaveInstanceState()); } @@ -327,6 +336,13 @@ public void onSortSelected(final PostSort order) { invalidateOptionsMenu(); } + @Override + public void onPostFilterSelected(final PostFilter filter) { + controller.setFilter(filter); + requestRefresh(RefreshableFragment.POSTS, false); + invalidateOptionsMenu(); + } + @Override public void onSearchPosts() { onSearchPosts(controller, this); @@ -577,4 +593,9 @@ protected boolean baseActivityAllowToolbarHideOnScroll() { public PostSort getPostSort() { return controller.getSort(); } + + @Override + public PostFilter getPostFilter() { + return controller.getFilter(); + } } diff --git a/src/main/java/org/quantumbadger/redreader/adapters/GroupedRecyclerViewAdapter.java b/src/main/java/org/quantumbadger/redreader/adapters/GroupedRecyclerViewAdapter.java index fbac79732..4573e535e 100644 --- a/src/main/java/org/quantumbadger/redreader/adapters/GroupedRecyclerViewAdapter.java +++ b/src/main/java/org/quantumbadger/redreader/adapters/GroupedRecyclerViewAdapter.java @@ -17,9 +17,11 @@ package org.quantumbadger.redreader.adapters; +import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; import java.util.ArrayList; import java.util.Collection; @@ -31,6 +33,12 @@ public class GroupedRecyclerViewAdapter extends RecyclerView.Adapter { private final long mUniqueId = ITEM_UNIQUE_ID_GENERATOR.incrementAndGet(); @@ -54,6 +62,7 @@ private void onBindViewHolderInner( private final ArrayList>[] mItems; private final HashMap, Integer> mItemViewTypeMap = new HashMap<>(); private final HashMap> mViewTypeItemMap = new HashMap<>(); + private FullSpanChecker mFullSpanChecker; public GroupedRecyclerViewAdapter(final int groups) { //noinspection unchecked @@ -66,6 +75,10 @@ public GroupedRecyclerViewAdapter(final int groups) { setHasStableIds(true); } + public void setFullSpanChecker(final FullSpanChecker fullSpanChecker) { + mFullSpanChecker = fullSpanChecker; + } + private int getItemPositionInternal(final int groupId, final Item item) { final ArrayList> group = mItems[groupId]; @@ -168,6 +181,43 @@ public void onBindViewHolder( @NonNull final RecyclerView.ViewHolder viewHolder, final int position) { getItemInternal(position).onBindViewHolderInner(viewHolder); + + if(mFullSpanChecker != null) { + applyFullSpan(viewHolder.itemView, position); + } + } + + @Override + public void onViewAttachedToWindow( + @NonNull final RecyclerView.ViewHolder viewHolder) { + super.onViewAttachedToWindow(viewHolder); + + if(mFullSpanChecker == null) { + return; + } + + final int position = viewHolder.getLayoutPosition(); + + if(position != RecyclerView.NO_POSITION) { + applyFullSpan(viewHolder.itemView, position); + } + } + + // In staggered (masonry) grid layouts items are confined to a single column; + // chrome items (headers, loading spinners, load-more buttons, errors) must + // span the full width so the masonry flow isn't broken. In list mode (or any + // other layout manager) this is a no-op, because the layout params never are + // StaggeredGridLayoutManager.LayoutParams. + private void applyFullSpan( + @NonNull final View itemView, + final int position) { + + final ViewGroup.LayoutParams layoutParams = itemView.getLayoutParams(); + + if(layoutParams instanceof StaggeredGridLayoutManager.LayoutParams) { + ((StaggeredGridLayoutManager.LayoutParams)layoutParams) + .setFullSpan(mFullSpanChecker.isFullSpan(position)); + } } @Override @@ -223,6 +273,34 @@ public Item getItemAtPosition(final int position) { return getItemInternal(position); } + public int getGroupIdAtPosition(final int position) { + + int currentPosition = 0; + + for(int groupId = 0; groupId < mItems.length; groupId++) { + + for(int positionInGroup = 0; + positionInGroup < mItems[groupId].size(); + positionInGroup++) { + + final Item item = mItems[groupId].get(positionInGroup); + + if(!item.mCurrentlyHidden) { + + if(currentPosition == position) { + return groupId; + } + + currentPosition++; + } + } + } + + throw new RuntimeException("Item position " + + position + + " is too high"); + } + public void appendToGroup(final int group, final Item item) { final int position = getItemPositionInternal(group + 1, 0); diff --git a/src/main/java/org/quantumbadger/redreader/adapters/RedditListingManager.java b/src/main/java/org/quantumbadger/redreader/adapters/RedditListingManager.java index 5052e8c17..3035a102e 100644 --- a/src/main/java/org/quantumbadger/redreader/adapters/RedditListingManager.java +++ b/src/main/java/org/quantumbadger/redreader/adapters/RedditListingManager.java @@ -20,6 +20,7 @@ import android.content.Context; import android.view.View; import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; import org.quantumbadger.redreader.common.General; import org.quantumbadger.redreader.views.LoadingSpinnerView; import org.quantumbadger.redreader.views.RedditPostHeaderView; @@ -30,7 +31,7 @@ public abstract class RedditListingManager { private final GroupedRecyclerViewAdapter mAdapter = new GroupedRecyclerViewAdapter(7); - private LinearLayoutManager mLayoutManager; + private RecyclerView.LayoutManager mLayoutManager; private static final int GROUP_HEADER = 0; private static final int GROUP_NOTIFICATIONS = 1; @@ -53,15 +54,24 @@ protected RedditListingManager(final Context context) { mAdapter.appendToGroup(GROUP_LOADING, mLoadingItem); } - public void setLayoutManager(final LinearLayoutManager layoutManager) { + public void setLayoutManager(final RecyclerView.LayoutManager layoutManager) { General.checkThisIsUIThread(); mLayoutManager = layoutManager; + + // In grid mode the adapter marks chrome items (headers, load-more button, + // loading spinner, footer errors) as spanning the full width; in list mode + // this is a no-op because those layout params are never used. + mAdapter.setFullSpanChecker(this::isGridFullSpanItem); } // Workaround for RecyclerView scrolling behaviour private void doWorkaround() { if(!mWorkaroundDone && mLayoutManager != null) { - mLayoutManager.scrollToPositionWithOffset(0, 0); + if(mLayoutManager instanceof LinearLayoutManager) { + ((LinearLayoutManager)mLayoutManager).scrollToPositionWithOffset(0, 0); + } else { + mLayoutManager.scrollToPosition(0); + } mWorkaroundDone = true; } } @@ -149,4 +159,22 @@ public void updateHiddenStatus() { public GroupedRecyclerViewAdapter.Item getItemAtPosition(final int position) { return mAdapter.getItemAtPosition(position); } + + // Used for grid layouts: only items in GROUP_ITEMS (i.e. the posts themselves) + // occupy a single grid cell; headers, notifications, the load-more button, + // the loading spinner and footer errors span the full width. + public boolean isGridPostItem(final int position) { + return mAdapter.getGroupIdAtPosition(position) == GROUP_ITEMS; + } + + public boolean isGridFullSpanItem(final int position) { + + // Defensive: the layout manager can query positions that are temporarily + // out of range while the list is being updated + if(position >= mAdapter.getItemCount()) { + return true; + } + + return !isGridPostItem(position); + } } diff --git a/src/main/java/org/quantumbadger/redreader/common/PrefsUtility.java b/src/main/java/org/quantumbadger/redreader/common/PrefsUtility.java index 7b253111b..3e65bb829 100644 --- a/src/main/java/org/quantumbadger/redreader/common/PrefsUtility.java +++ b/src/main/java/org/quantumbadger/redreader/common/PrefsUtility.java @@ -683,6 +683,27 @@ public enum AppearancePostSubtitleItem { COMMENTS } + public enum AppearancePostLayout { + LIST(1), GRID_2(2), GRID_3(3), GRID_4(4); + + public final int columnCount; + + AppearancePostLayout(final int columnCount) { + this.columnCount = columnCount; + } + } + + public static AppearancePostLayout appearance_post_layout() { + + try { + return AppearancePostLayout.valueOf(StringUtils.asciiUppercase(getString( + R.string.pref_appearance_post_layout_key, + "list"))); + } catch(final IllegalArgumentException e) { + return AppearancePostLayout.LIST; + } + } + public static EnumSet appearance_post_subtitle_items() { final Set strings = getStringSet( @@ -1644,7 +1665,11 @@ public static EnumMap pref_menus_ap new AppbarItemInfo( OptionsMenuUtility.AppbarItemsPref.SEARCH, R.string.pref_menus_appbar_search_key, - MenuItem.SHOW_AS_ACTION_NEVER) + MenuItem.SHOW_AS_ACTION_NEVER), + new AppbarItemInfo( + OptionsMenuUtility.AppbarItemsPref.FILTER, + R.string.pref_menus_appbar_filter_key, + MenuItem.SHOW_AS_ACTION_ALWAYS) }; diff --git a/src/main/java/org/quantumbadger/redreader/common/RedReaderPicasso.kt b/src/main/java/org/quantumbadger/redreader/common/RedReaderPicasso.kt new file mode 100644 index 000000000..9055d3b22 --- /dev/null +++ b/src/main/java/org/quantumbadger/redreader/common/RedReaderPicasso.kt @@ -0,0 +1,79 @@ +/******************************************************************************* + * This file is part of RedReader. + * + * RedReader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * RedReader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with RedReader. If not, see . + ******************************************************************************/ + +package org.quantumbadger.redreader.common + +import android.content.Context +import com.squareup.picasso.OkHttp3Downloader +import com.squareup.picasso.Picasso +import okhttp3.Cache +import org.quantumbadger.redreader.http.okhttp.OKHTTPBackend +import java.io.File + +// Provides the project-wide Picasso instance, backed by RedReader's own +// OkHttpClient so that image downloads honour the user's proxy/Tor settings +// (the default Picasso downloader would bypass them). +object RedReaderPicasso { + + private const val CACHE_SIZE_BYTES = 50L * 1024 * 1024 + + @Volatile + private var mInstance: Picasso? = null + + @Volatile + private var mContext: Context? = null + + fun get(context: Context): Picasso { + + mInstance?.let { return it } + + synchronized(this) { + + mInstance?.let { return it } + + val appContext = context.applicationContext + mContext = appContext + val picasso = build(appContext) + mInstance = picasso + return picasso + } + } + + // Rebuilds the instance with the current HTTP backend configuration + // (e.g. after the user toggles the Tor/proxy setting). + fun recreate() { + synchronized(this) { + val context = mContext ?: return + mInstance = build(context) + } + } + + private fun build(context: Context): Picasso { + + // Clone the backend client (so Tor/proxy/timeout settings carry over) + // and give it a persistent disk cache, so images are only downloaded + // once instead of on every launch. + val cacheDir = File(context.cacheDir, "picasso-images") + val client = OKHTTPBackend.getClient().newBuilder() + .cache(Cache(cacheDir, CACHE_SIZE_BYTES)) + .build() + + return Picasso.Builder(context) + .downloader(OkHttp3Downloader(client)) + .build() + } +} diff --git a/src/main/java/org/quantumbadger/redreader/fragments/PostListingFragment.java b/src/main/java/org/quantumbadger/redreader/fragments/PostListingFragment.java index ad6f08728..9beb70207 100644 --- a/src/main/java/org/quantumbadger/redreader/fragments/PostListingFragment.java +++ b/src/main/java/org/quantumbadger/redreader/fragments/PostListingFragment.java @@ -33,6 +33,7 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; import org.apache.commons.text.StringEscapeUtils; import org.quantumbadger.redreader.R; @@ -71,6 +72,7 @@ import org.quantumbadger.redreader.image.ImageInfo; import org.quantumbadger.redreader.io.RequestResponseHandler; import org.quantumbadger.redreader.listingcontrollers.CommentListingController; +import org.quantumbadger.redreader.reddit.PostFilter; import org.quantumbadger.redreader.reddit.PostSort; import org.quantumbadger.redreader.reddit.RedditPostListItem; import org.quantumbadger.redreader.reddit.RedditSubredditManager; @@ -123,6 +125,9 @@ public class PostListingFragment extends RRFragment private final PostListingManager mPostListingManager; private final RecyclerView mRecyclerView; + private final PrefsUtility.AppearancePostLayout mPostLayout; + private final PostFilter mPostFilter; + private final View mOuter; private RedditIdAndType mAfter = null; @@ -150,6 +155,10 @@ public PostListingFragment( super(parent, savedInstanceState); mPostListingManager = new PostListingManager(parent); + mPostLayout = PrefsUtility.appearance_post_layout(); + mPostFilter = parent instanceof OptionsMenuUtility.OptionsMenuPostsListener + ? ((OptionsMenuUtility.OptionsMenuPostsListener)parent).getPostFilter() + : PostFilter.ALL; if(savedInstanceState != null) { mPreviousFirstVisibleItemPosition = savedInstanceState.getInt( @@ -220,7 +229,27 @@ public PostListingFragment( } mRecyclerView = recyclerViewManager.getRecyclerView(); - mPostListingManager.setLayoutManager((LinearLayoutManager)mRecyclerView.getLayoutManager()); + + if(mPostLayout == PrefsUtility.AppearancePostLayout.LIST) { + mPostListingManager.setLayoutManager( + (LinearLayoutManager)mRecyclerView.getLayoutManager()); + + } else { + final StaggeredGridLayoutManager gridLayoutManager + = new StaggeredGridLayoutManager( + mPostLayout.columnCount, + StaggeredGridLayoutManager.VERTICAL); + + // Unlike GridLayoutManager (where every card in a row is forced to the + // height of the tallest card, leaving gaps under the shorter ones), + // each column fills independently here, so there are no vertical gaps. + // Full-width chrome items (headers, load-more, spinner, errors) are + // handled by the adapter via StaggeredGridLayoutManager.LayoutParams + // setFullSpan(). + + recyclerViewManager.setLayoutManager(gridLayoutManager); + mPostListingManager.setLayoutManager(gridLayoutManager); + } mRecyclerView.setAdapter(mPostListingManager.getAdapter()); @@ -396,15 +425,49 @@ public Bundle onSaveInstanceState() { final Bundle bundle = new Bundle(); - final LinearLayoutManager layoutManager - = (LinearLayoutManager)mRecyclerView.getLayoutManager(); bundle.putInt( SAVEDSTATE_FIRST_VISIBLE_POS, - layoutManager.findFirstVisibleItemPosition()); + findFirstVisibleItemPosition(mRecyclerView.getLayoutManager())); return bundle; } + // findFirstVisibleItemPosition()/findLastVisibleItemPosition() are only + // available on LinearLayoutManager; StaggeredGridLayoutManager instead + // provides findFirstVisibleItemPositions()/findLastVisibleItemPositions(), + // which return one position per column. + private int findFirstVisibleItemPosition( + final RecyclerView.LayoutManager layoutManager) { + + if(layoutManager instanceof StaggeredGridLayoutManager) { + final int[] positions + = ((StaggeredGridLayoutManager)layoutManager) + .findFirstVisibleItemPositions(null); + if(positions == null || positions.length == 0) { + return RecyclerView.NO_POSITION; + } + return positions[0]; + } + + return ((LinearLayoutManager)layoutManager).findFirstVisibleItemPosition(); + } + + private int findLastVisibleItemPosition( + final RecyclerView.LayoutManager layoutManager) { + + if(layoutManager instanceof StaggeredGridLayoutManager) { + final int[] positions + = ((StaggeredGridLayoutManager)layoutManager) + .findLastVisibleItemPositions(null); + if(positions == null || positions.length == 0) { + return RecyclerView.NO_POSITION; + } + return positions[0]; + } + + return ((LinearLayoutManager)layoutManager).findLastVisibleItemPosition(); + } + public void cancel() { if(mRequest != null) { mRequest.cancel(); @@ -509,10 +572,10 @@ private void onLoadMoreItemsCheck() { if(mReadyToDownloadMore && mAfter != null && !mAfter.equals(mLastAfter)) { - final LinearLayoutManager layoutManager - = (LinearLayoutManager)mRecyclerView.getLayoutManager(); + final RecyclerView.LayoutManager layoutManager + = mRecyclerView.getLayoutManager(); - if((layoutManager.getItemCount() - layoutManager.findLastVisibleItemPosition() + if((layoutManager.getItemCount() - findLastVisibleItemPosition(layoutManager) < 20 && (mPostCountLimit <= 0 || mPostRefreshCount.get() > 0) || (mPreviousFirstVisibleItemPosition != null @@ -614,13 +677,19 @@ public void onPostsAdded() { return; } - final LinearLayoutManager layoutManager - = (LinearLayoutManager)mRecyclerView.getLayoutManager(); + final RecyclerView.LayoutManager layoutManager + = mRecyclerView.getLayoutManager(); if(layoutManager.getItemCount() > mPreviousFirstVisibleItemPosition) { - layoutManager.scrollToPositionWithOffset( - mPreviousFirstVisibleItemPosition, - 0); + + if(layoutManager instanceof LinearLayoutManager) { + ((LinearLayoutManager)layoutManager).scrollToPositionWithOffset( + mPreviousFirstVisibleItemPosition, + 0); + } else { + layoutManager.scrollToPosition(mPreviousFirstVisibleItemPosition); + } + mPreviousFirstVisibleItemPosition = null; } else { @@ -815,6 +884,11 @@ public void onDataStreamComplete( mAfter = post.getName(); + if(!mPostFilter.matches(post)) { + mPostsNotShown = true; + continue; + } + final boolean isPostBlocked = subredditFilteringEnabled && blockedSubreddits.contains( new SubredditCanonicalId(post.getSubreddit().getDecoded())); @@ -898,7 +972,9 @@ public void onSuccess(final ImageInfo info) { preparedPost, PostListingFragment.this, activity, - leftHandedMode)); + leftHandedMode, + mPostLayout + != PrefsUtility.AppearancePostLayout.LIST)); mPostCount++; mPostRefreshCount.decrementAndGet(); @@ -916,7 +992,9 @@ public void onSuccess(final ImageInfo info) { && (mAfter == null || mAfter.equals(mLastAfter))) { @StringRes final int emptyViewText; - if(mPostsNotShown) { + if(mPostFilter != PostFilter.ALL) { + emptyViewText = R.string.no_posts_match_filter; + } else if(mPostsNotShown) { if(mPostListingURL.pathType() == RedditURLParser.SEARCH_POST_LISTING_URL) { emptyViewText = R.string.no_search_results_hidden; diff --git a/src/main/java/org/quantumbadger/redreader/http/okhttp/OKHTTPBackend.kt b/src/main/java/org/quantumbadger/redreader/http/okhttp/OKHTTPBackend.kt index cd05b5d4c..d01509d93 100644 --- a/src/main/java/org/quantumbadger/redreader/http/okhttp/OKHTTPBackend.kt +++ b/src/main/java/org/quantumbadger/redreader/http/okhttp/OKHTTPBackend.kt @@ -36,6 +36,7 @@ import org.quantumbadger.redreader.common.General.isSensitiveDebugLoggingEnabled import org.quantumbadger.redreader.common.Optional import org.quantumbadger.redreader.common.PrefsUtility import org.quantumbadger.redreader.common.RRError +import org.quantumbadger.redreader.common.RedReaderPicasso import org.quantumbadger.redreader.common.Result import org.quantumbadger.redreader.common.TorCommon import org.quantumbadger.redreader.common.UriString @@ -125,6 +126,10 @@ class OKHTTPBackend private constructor() : HTTPBackend() { @Synchronized override fun recreateHttpBackend() { httpBackend = OKHTTPBackend() + + // Rebuild Picasso with the new client so image downloads keep using the + // current proxy/Tor configuration. + RedReaderPicasso.recreate() } override fun resolveRedirectUri( @@ -325,5 +330,9 @@ class OKHTTPBackend private constructor() : HTTPBackend() { } return httpBackend!! } + + fun getClient(): OkHttpClient { + return (getHttpBackend() as OKHTTPBackend).mClient + } } } diff --git a/src/main/java/org/quantumbadger/redreader/listingcontrollers/PostListingController.java b/src/main/java/org/quantumbadger/redreader/listingcontrollers/PostListingController.java index 9b3bbc444..951326534 100644 --- a/src/main/java/org/quantumbadger/redreader/listingcontrollers/PostListingController.java +++ b/src/main/java/org/quantumbadger/redreader/listingcontrollers/PostListingController.java @@ -23,6 +23,7 @@ import androidx.appcompat.app.AppCompatActivity; import org.quantumbadger.redreader.common.PrefsUtility; import org.quantumbadger.redreader.fragments.PostListingFragment; +import org.quantumbadger.redreader.reddit.PostFilter; import org.quantumbadger.redreader.reddit.PostSort; import org.quantumbadger.redreader.reddit.things.InvalidSubredditNameException; import org.quantumbadger.redreader.reddit.things.SubredditCanonicalId; @@ -38,6 +39,7 @@ public class PostListingController { private UUID session = null; private PostListingURL url; + private PostFilter filter = PostFilter.ALL; public void setSession(final UUID session) { this.session = session; @@ -131,6 +133,14 @@ public final PostSort getSort() { return null; } + public final void setFilter(final PostFilter filter) { + this.filter = filter; + } + + public final PostFilter getFilter() { + return filter; + } + public Uri getUri() { return url.generateJsonUri(); } diff --git a/src/main/java/org/quantumbadger/redreader/reddit/PostFilter.kt b/src/main/java/org/quantumbadger/redreader/reddit/PostFilter.kt new file mode 100644 index 000000000..a58c6236b --- /dev/null +++ b/src/main/java/org/quantumbadger/redreader/reddit/PostFilter.kt @@ -0,0 +1,135 @@ +/******************************************************************************* + * This file is part of RedReader. + * + * RedReader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * RedReader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with RedReader. If not, see . + ******************************************************************************/ +package org.quantumbadger.redreader.reddit + +import androidx.annotation.StringRes +import org.quantumbadger.redreader.R +import org.quantumbadger.redreader.common.LinkHandler +import org.quantumbadger.redreader.reddit.kthings.MaybeParseError +import org.quantumbadger.redreader.reddit.kthings.RedditMediaMetadata +import org.quantumbadger.redreader.reddit.kthings.RedditPost +import java.util.Locale + +enum class PostFilter(@param:StringRes val menuTitle: Int) { + ALL(R.string.filter_posts_all), + IMAGE(R.string.filter_posts_image), + VIDEO(R.string.filter_posts_video), + LINK(R.string.filter_posts_link), + TEXT(R.string.filter_posts_text); + + fun matches(post: RedditPost): Boolean = this == ALL || classify(post) == this + + companion object { + private val videoDomains = setOf( + "v.redd.it", + "gfycat.com", + "redgifs.com", + "streamable.com", + "giphy.com", + "youtube.com", + "youtu.be", + "vimeo.com") + + @JvmStatic + fun classify(post: RedditPost): PostFilter { + if (post.is_self) { + return TEXT + } + + if (isVideo(post)) { + return VIDEO + } + + if (post.gallery_data != null) { + return if (hasAnimatedGalleryMedia(post)) VIDEO else IMAGE + } + + if (post.post_hint.equals("image", ignoreCase = true) + || LinkHandler.isProbablyAnImage(post.findUrl())) { + return IMAGE + } + + return LINK + } + + private fun isVideo(post: RedditPost): Boolean { + if (post.is_video || post.media?.reddit_video != null) { + return true + } + + val postHint = post.post_hint?.lowercase(Locale.ROOT) + if (postHint == "hosted:video" + || postHint == "rich:video" + || postHint == "video") { + return true + } + + val preview = post.preview + if (preview?.reddit_video_preview != null + || preview?.images?.any { it.variants.mp4 != null } == true) { + return true + } + + if (isVideoDomain(post.domain?.decoded)) { + return true + } + + return listOfNotNull( + post.url?.decoded, + post.url_overridden_by_dest?.decoded, + post.findUrl()?.value) + .any { isVideoDomain(it) || hasVideoExtension(it) } + } + + private fun isVideoDomain(value: String?): Boolean { + val normalized = value?.lowercase(Locale.ROOT) ?: return false + val domain = normalized + .substringAfter("://", normalized) + .removePrefix("www.") + .substringBefore('/') + .substringBefore(':') + + return videoDomains.any { domain == it || domain.endsWith(".$it") } + } + + private fun hasVideoExtension(url: String): Boolean { + val path = url.lowercase(Locale.ROOT) + .substringBefore('?') + .substringBefore('#') + + return path.endsWith(".gif") + || path.endsWith(".gifv") + || path.endsWith(".mp4") + || path.endsWith(".webm") + || path.endsWith(".h264") + || path.endsWith(".mkv") + || path.endsWith(".3gp") + } + + private fun hasAnimatedGalleryMedia(post: RedditPost): Boolean { + return post.media_metadata?.values?.any { entry -> + val metadata = (entry as? MaybeParseError.Ok)?.value + ?: return@any false + + metadata.e.equals("AnimatedImage", ignoreCase = true) + || metadata.e.equals("Video", ignoreCase = true) + || metadata.s.mp4 != null + || metadata.s.gif != null + } == true + } + } +} diff --git a/src/main/java/org/quantumbadger/redreader/reddit/RedditPostListItem.java b/src/main/java/org/quantumbadger/redreader/reddit/RedditPostListItem.java index 45d1cee0c..284d9abd2 100644 --- a/src/main/java/org/quantumbadger/redreader/reddit/RedditPostListItem.java +++ b/src/main/java/org/quantumbadger/redreader/reddit/RedditPostListItem.java @@ -35,17 +35,20 @@ public class RedditPostListItem private final RedditPreparedPost mPost; private final boolean mLeftHandedMode; + private final boolean mGridMode; public RedditPostListItem( final RedditPreparedPost post, final PostListingFragment fragment, final BaseActivity activity, - final boolean leftHandedMode) { + final boolean leftHandedMode, + final boolean gridMode) { mFragment = fragment; mActivity = activity; mPost = post; mLeftHandedMode = leftHandedMode; + mGridMode = gridMode; } @Override @@ -60,7 +63,8 @@ public RecyclerView.ViewHolder onCreateViewHolder(final ViewGroup viewGroup) { mActivity, mFragment, mActivity, - mLeftHandedMode); + mLeftHandedMode, + mGridMode); return new RecyclerView.ViewHolder(view) { }; diff --git a/src/main/java/org/quantumbadger/redreader/reddit/kthings/RedditPost.kt b/src/main/java/org/quantumbadger/redreader/reddit/kthings/RedditPost.kt index ef036c3ad..24271ce9d 100644 --- a/src/main/java/org/quantumbadger/redreader/reddit/kthings/RedditPost.kt +++ b/src/main/java/org/quantumbadger/redreader/reddit/kthings/RedditPost.kt @@ -69,6 +69,7 @@ data class RedditPost( val preview: Preview? = null, val is_video: Boolean = false, + val post_hint: String? = null, val distinguished: String? = null, val suggested_sort: String? = null, // TODO enum type diff --git a/src/main/java/org/quantumbadger/redreader/reddit/prepared/RedditParsedPost.kt b/src/main/java/org/quantumbadger/redreader/reddit/prepared/RedditParsedPost.kt index 8db5d2d8a..979450829 100644 --- a/src/main/java/org/quantumbadger/redreader/reddit/prepared/RedditParsedPost.kt +++ b/src/main/java/org/quantumbadger/redreader/reddit/prepared/RedditParsedPost.kt @@ -20,6 +20,7 @@ import androidx.appcompat.app.AppCompatActivity import org.quantumbadger.redreader.R import org.quantumbadger.redreader.common.UriString import org.quantumbadger.redreader.reddit.PostCommentSort +import org.quantumbadger.redreader.reddit.kthings.MaybeParseError import org.quantumbadger.redreader.reddit.kthings.RedditIdAndType import org.quantumbadger.redreader.reddit.kthings.RedditPost import org.quantumbadger.redreader.reddit.prepared.bodytext.BodyElement @@ -143,38 +144,81 @@ class RedditParsedPost( @JvmField val height: Int ) - fun getPreview(minWidth: Int, minHeight: Int) = src.preview?.images?.get(0)?.run { - getPreviewInternal(this, minWidth, minHeight) + fun getPreview(minWidth: Int, minHeight: Int): ImagePreviewDetails? { + val preview = src.preview?.images?.get(0)?.let { + getPreviewInternal(it.source, it.resolutions, minWidth, minHeight) + } + return preview ?: getGalleryPreview(minWidth, minHeight) } fun getPreviewMP4(minWidth: Int, minHeight: Int) - = src.preview?.images?.get(0)?.variants?.mp4?.apply { - getPreviewInternal(this, minWidth, minHeight) + = src.preview?.images?.get(0)?.variants?.mp4?.let { + getPreviewInternal(it.source, it.resolutions, minWidth, minHeight) + } + + // Gallery posts ("multiple images") don't include a preview field in the + // reddit API response; use the first image from media_metadata instead, so + // they can be shown like any other image post (e.g. in grid/column view). + private fun getGalleryPreview(minWidth: Int, minHeight: Int): ImagePreviewDetails? { + + val galleryData = src.gallery_data ?: return null + + val firstItem = (galleryData.items.firstOrNull() as? MaybeParseError.Ok)?.value + ?: return null + + val mediaMetadataEntry = (src.media_metadata?.get(firstItem.media_id) + as? MaybeParseError.Ok)?.value ?: return null + + val source = mediaMetadataEntry.s + + val sourceDetails = (source.u ?: source.mp4 ?: source.gif)?.let { + RedditPost.Preview.ImageDetails( + it, + source.x.toInt(), + source.y.toInt()) + } + + val resolutionDetails = mediaMetadataEntry.p?.mapNotNull { meta -> + val url = meta.u ?: meta.mp4 ?: meta.gif ?: return@mapNotNull null + RedditPost.Preview.ImageDetails(url, meta.x.toInt(), meta.y.toInt()) + } + + return getPreviewInternal( + sourceDetails, + resolutionDetails, + minWidth, + minHeight) + } + + // Number of images in a gallery ("multiple images") post, 0 otherwise. + fun getGalleryImageCount(): Int { + return src.gallery_data?.items?.size ?: 0 } private fun getPreviewInternal( - image: RedditPost.Preview.ImageBase, + source: RedditPost.Preview.ImageDetails?, + resolutions: List?, minWidth: Int, minHeight: Int ): ImagePreviewDetails? { - val resolutions = image.resolutions + val resolutionList = resolutions - if (resolutions.isNullOrEmpty()) { + if (resolutionList.isNullOrEmpty()) { return null } var best: RedditPost.Preview.ImageDetails? = null - val sourceWidth = image.source?.width - val sourceHeight = image.source?.height + val sourceWidth = source?.width + val sourceHeight = source?.height - for (i in -1 until resolutions.size) { + for (i in -1 until resolutionList.size) { val resolution = if (i == -1) { - image.source ?: continue + source ?: continue } else { - resolutions[i] + resolutionList[i] } if (resolution.width < 50 || resolution.height < 50) { diff --git a/src/main/java/org/quantumbadger/redreader/reddit/prepared/RedditPreparedPost.java b/src/main/java/org/quantumbadger/redreader/reddit/prepared/RedditPreparedPost.java index 67e3a4568..63438600a 100644 --- a/src/main/java/org/quantumbadger/redreader/reddit/prepared/RedditPreparedPost.java +++ b/src/main/java/org/quantumbadger/redreader/reddit/prepared/RedditPreparedPost.java @@ -135,6 +135,21 @@ public boolean shouldShowInlinePreview() { || "v.redd.it".equals(src.getDomain())); } + // In grid ("column") mode the card image is the post's high-res preview, + // shown at full card width with its real aspect ratio (like other Reddit + // clients, e.g. Boost), rather than the tiny thumbnail. Gated on the same + // setting as inline previews, so NSFW/spoiler and download prefs apply. + public boolean shouldShowGridImage() { + return mShowInlinePreviews && src.getPreview(300, 0) != null; + } + + // For posts where reddit provided no preview data (so shouldShowGridImage() + // is false) but the post links directly to an image: whether to fetch and + // display that image, gated on the same setting as grid card images. + public boolean shouldShowGridImageFallback() { + return mShowInlinePreviews; + } + public boolean isVideoPreview() { return src.isVideoPreview(); } @@ -159,6 +174,13 @@ public int computeScore() { public SpannableStringBuilder buildSubtitle( final Context context, final boolean headerMode) { + return buildSubtitle(context, headerMode, true); + } + + public SpannableStringBuilder buildSubtitle( + final Context context, + final boolean headerMode, + final boolean includeScore) { final EnumSet mPostSubtitleItems; final int mPostAgeUnits; @@ -302,7 +324,9 @@ public SpannableStringBuilder buildSubtitle( 0); } - if(mPostSubtitleItems.contains(PrefsUtility.AppearancePostSubtitleItem.SCORE)) { + if(includeScore + && mPostSubtitleItems.contains( + PrefsUtility.AppearancePostSubtitleItem.SCORE)) { postListDescSb.append( String.valueOf(score), BetterSSB.BOLD | BetterSSB.FOREGROUND_COLOR, @@ -768,6 +792,10 @@ public void onFailure(@NonNull final RRError error) { + error, error.t); } + + if(thumbnailCallback != null) { + thumbnailCallback.thumbnailDownloadFailed(usageId); + } } })); } @@ -813,9 +841,11 @@ public void onRedditDataChange(final RedditIdAndType thingIdAndType) { } } - // TODO handle download failure - show red "X" or something + // The view is notified on success (betterThumbnailAvailable) and on failure + // (thumbnailDownloadFailed); it decides how to render either outcome. public interface ThumbnailLoadedCallback { void betterThumbnailAvailable(Bitmap thumbnail, int usageId); + default void thumbnailDownloadFailed(final int usageId) {} } public void markAsRead(final Context context) { diff --git a/src/main/java/org/quantumbadger/redreader/views/RedditPostView.java b/src/main/java/org/quantumbadger/redreader/views/RedditPostView.java index 6bce3e40d..867b1903a 100644 --- a/src/main/java/org/quantumbadger/redreader/views/RedditPostView.java +++ b/src/main/java/org/quantumbadger/redreader/views/RedditPostView.java @@ -22,11 +22,13 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Rect; +import android.graphics.drawable.Drawable; import android.os.Handler; import android.os.Looper; import android.os.Message; import android.util.Log; import android.util.TypedValue; +import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -41,6 +43,10 @@ import androidx.annotation.UiThread; import androidx.constraintlayout.widget.ConstraintLayout; +import com.squareup.picasso.Callback; +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; + import org.quantumbadger.redreader.R; import org.quantumbadger.redreader.account.RedditAccountManager; import org.quantumbadger.redreader.activities.BaseActivity; @@ -53,11 +59,14 @@ import org.quantumbadger.redreader.common.DisplayUtils; import org.quantumbadger.redreader.common.General; import org.quantumbadger.redreader.common.GenericFactory; +import org.quantumbadger.redreader.common.LinkHandler; import org.quantumbadger.redreader.common.Optional; import org.quantumbadger.redreader.common.PrefsUtility; import org.quantumbadger.redreader.common.Priority; +import org.quantumbadger.redreader.common.RedReaderPicasso; import org.quantumbadger.redreader.common.RRError; import org.quantumbadger.redreader.common.SharedPrefsWrapper; +import org.quantumbadger.redreader.common.UriString; import org.quantumbadger.redreader.common.datastream.SeekableInputStream; import org.quantumbadger.redreader.common.time.TimestampUTC; import org.quantumbadger.redreader.fragments.PostListingFragment; @@ -80,6 +89,8 @@ public final class RedditPostView extends FlingableItemView private static final String PROMPT_PREF_KEY = "inline_image_prompt_accepted"; + private static final int GRID_THUMBNAIL_HEIGHT_DP = 110; + private static final AtomicInteger sInlinePreviewsShownThisSession = new AtomicInteger(0); private final AccessibilityActionManager mAccessibilityActionManager; @@ -95,6 +106,8 @@ public final class RedditPostView extends FlingableItemView @NonNull private final LinearLayout mInnerView; @NonNull private final LinearLayout mCommentsButton; @NonNull private final TextView mCommentsText; + @Nullable private TextView mScoreText = null; + @Nullable private TextView mGridImageCount = null; @NonNull private final LinearLayout mPostErrors; @NonNull private final FrameLayout mImagePreviewHolder; @NonNull private final ImageView mImagePreviewImageView; @@ -103,6 +116,19 @@ public final class RedditPostView extends FlingableItemView @NonNull private final LoadingSpinnerView mImagePreviewLoadingSpinner; @NonNull private final LinearLayout mFooter; + // These are assigned only in the constructor, but only when mGridMode is true + // (the grid layout has these views; the regular layout does not). mGridMode is + // set from a constructor parameter, so javac's definite-assignment rules mean + // these cannot be declared final — hence the PMD suppressions. + @SuppressWarnings("PMD.ImmutableField") + private ConstraintLayout mGridImageArea; + @SuppressWarnings("PMD.ImmutableField") + private FrameLayout mGridImageHolder; + @SuppressWarnings("PMD.ImmutableField") + private ConstraintLayout mGridPlayOverlay; + @SuppressWarnings("PMD.ImmutableField") + private LoadingSpinnerView mGridLoadingSpinner; + private int mUsageId = 0; private final Handler thumbnailHandler; @@ -122,6 +148,8 @@ public final class RedditPostView extends FlingableItemView private final int mThumbnailSizePrefPixels; + private final boolean mGridMode; + @Override protected void onSetItemFlingPosition(final float position) { mOuterView.setTranslationX(position); @@ -185,10 +213,12 @@ public RedditPostView( final Context context, final PostListingFragment fragmentParent, final BaseActivity activity, - final boolean leftHandedMode) { + final boolean leftHandedMode, + final boolean gridMode) { super(context); mActivity = activity; + mGridMode = gridMode; mAccessibilityActionManager = new AccessibilityActionManager( this, @@ -200,7 +230,17 @@ public void handleMessage(@NonNull final Message msg) { if(mUsageId != msg.what) { return; } + if(msg.obj == null) { + // Thumbnail download failed + if(mGridMode) { + mGridLoadingSpinner.setVisibility(GONE); + } + return; + } mThumbnailView.setImageBitmap((Bitmap)msg.obj); + if(mGridMode) { + mGridLoadingSpinner.setVisibility(GONE); + } } }; @@ -210,7 +250,10 @@ public void handleMessage(@NonNull final Message msg) { final float subtitleFontScale = PrefsUtility.appearance_fontscale_post_subtitles(); final View rootView = - LayoutInflater.from(context).inflate(R.layout.reddit_post, this, true); + LayoutInflater.from(context).inflate( + mGridMode ? R.layout.reddit_post_grid : R.layout.reddit_post, + this, + true); mOuterView = Objects.requireNonNull(rootView.findViewById(R.id.reddit_post_layout_outer)); mInnerView = Objects.requireNonNull(rootView.findViewById(R.id.reddit_post_layout_inner)); @@ -235,6 +278,28 @@ public void handleMessage(@NonNull final Message msg) { mImagePreviewLoadingSpinner = new LoadingSpinnerView(activity); mImagePreviewHolder.addView(mImagePreviewLoadingSpinner); + if(mGridMode) { + mGridImageArea = Objects.requireNonNull( + rootView.findViewById(R.id.reddit_post_grid_image_area)); + + mGridImageHolder = Objects.requireNonNull( + rootView.findViewById(R.id.reddit_post_grid_image_holder)); + + mGridPlayOverlay = Objects.requireNonNull( + rootView.findViewById(R.id.reddit_post_grid_play_overlay)); + + mGridLoadingSpinner = new LoadingSpinnerView(activity); + final FrameLayout.LayoutParams gridSpinnerLayoutParams + = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.CENTER); + mGridImageHolder.addView(mGridLoadingSpinner, gridSpinnerLayoutParams); + + mGridImageCount = Objects.requireNonNull( + rootView.findViewById(R.id.reddit_post_grid_image_count)); + } + mThumbnailView = Objects.requireNonNull( rootView.findViewById(R.id.reddit_post_thumbnail_view)); @@ -249,12 +314,15 @@ public void handleMessage(@NonNull final Message msg) { mCommentsButton = rootView.findViewById(R.id.reddit_post_comments_button); mCommentsText = mCommentsButton.findViewById(R.id.reddit_post_comments_text); + mScoreText = mCommentsButton.findViewById(R.id.reddit_post_score_text); if(!mCommentsButtonPref) { mInnerView.removeView(mCommentsButton); } - if(leftHandedMode) { + // The elements in the layout below get reversed in left-hand mode. This only + // makes sense for the horizontal list layout, not for the vertical grid cards. + if(leftHandedMode && !mGridMode) { final ArrayList innerViewElements = new ArrayList<>(3); for(int i = mInnerView.getChildCount() - 1; i >= 0; i--) { innerViewElements.add(mInnerView.getChildAt(i)); @@ -357,6 +425,11 @@ public void reset(@NonNull final RedditPreparedPost newPost) { mUsageId++; + // Cancel any in-flight grid image load for this view, so a stale + // request from a previous bind can't paint over the new post's + // content (Picasso only cancels when a new request targets the view). + RedReaderPicasso.INSTANCE.get(mActivity).cancelRequest(mThumbnailView); + resetSwipeState(); title.setText(newPost.src.getTitle()); @@ -364,9 +437,10 @@ public void reset(@NonNull final RedditPreparedPost newPost) { mCommentsText.setText(String.valueOf(newPost.src.getSrc().getNum_comments())); } - final boolean showInlinePreview = newPost.shouldShowInlinePreview(); + final boolean showInlinePreview = !mGridMode && newPost.shouldShowInlinePreview(); + final boolean showGridImage = mGridMode && newPost.shouldShowGridImage(); - final boolean showThumbnail = !showInlinePreview && newPost.hasThumbnail; + final boolean showThumbnail = !showInlinePreview && !showGridImage && newPost.hasThumbnail; if(showInlinePreview) { downloadInlinePreview(newPost, mUsageId); @@ -377,12 +451,31 @@ public void reset(@NonNull final RedditPreparedPost newPost) { setBottomMargin(false); } - if(showThumbnail) { + if(mGridMode) { + + if(showGridImage) { + // Full-width high-res preview with the image's real aspect ratio + mGridImageArea.setVisibility(VISIBLE); + downloadGridPreview(newPost, mUsageId); + + } else if(showThumbnail) { + // Try the linked image first (for posts that link directly to an + // image but have no preview data), falling back to the strip. + mGridImageArea.setVisibility(VISIBLE); + downloadGridFallbackImage(newPost, mUsageId); + + } else { + mGridImageArea.setVisibility(GONE); + mInnerView.setMinimumHeight(General.dpToPixels(mActivity, 64)); + } + + } else if(showThumbnail) { final Bitmap thumbnail = newPost.getThumbnail(this, mUsageId); mThumbnailView.setImageBitmap(thumbnail); mThumbnailView.setVisibility(VISIBLE); + mThumbnailView.setMinimumWidth(mThumbnailSizePrefPixels); General.setLayoutWidthHeight( @@ -397,6 +490,24 @@ public void reset(@NonNull final RedditPreparedPost newPost) { mThumbnailView.setVisibility(GONE); mInnerView.setMinimumHeight(General.dpToPixels(mActivity, 64)); } + + if(mGridMode) { + // Grid card footer: upvote count and multi-image indicator + if(mScoreText != null) { + mScoreText.setText(formatScore(newPost.computeScore())); + } + + if(mGridImageCount != null) { + final int imageCount = newPost.src.getGalleryImageCount(); + + if(imageCount > 1) { + mGridImageCount.setText("[1/" + imageCount + "]"); + mGridImageCount.setVisibility(VISIBLE); + } else { + mGridImageCount.setVisibility(GONE); + } + } + } } if(mPost != null) { @@ -428,7 +539,9 @@ public void updateAppearance() { title.setContentDescription(mPost.buildAccessibilityTitle(mActivity, false)); - subtitle.setText(mPost.buildSubtitle(mActivity, false)); + subtitle.setText(mGridMode + ? mPost.buildSubtitle(mActivity, false, false) + : mPost.buildSubtitle(mActivity, false)); subtitle.setContentDescription(mPost.buildAccessibilitySubtitle(mActivity, false)); boolean overlayVisible = true; @@ -472,6 +585,14 @@ public void betterThumbnailAvailable( thumbnailHandler.sendMessage(msg); } + @Override + public void thumbnailDownloadFailed(final int callbackUsageId) { + final Message msg = Message.obtain(); + msg.obj = null; + msg.what = callbackUsageId; + thumbnailHandler.sendMessage(msg); + } + public interface PostSelectionListener { void onPostSelected(RedditPreparedPost post); @@ -627,6 +748,244 @@ public void onFailure(@NonNull final RRError error) { )); } + private void downloadGridPreview( + @NonNull final RedditPreparedPost post, + final int usageId) { + + final Rect windowVisibleDisplayFrame + = DisplayUtils.getWindowVisibleDisplayFrame(mActivity); + + // Grid cells are a fraction of the screen width (2-4 columns) + final int previewWidth = Math.max(320, windowVisibleDisplayFrame.width() / 2); + + final RedditParsedPost.ImagePreviewDetails preview + = post.src.getPreview(previewWidth, 0); + + if(preview == null || preview.width < 10 || preview.height < 10) { + showGridFallbackThumbnail(post, usageId); + return; + } + + mThumbnailView.setVisibility(VISIBLE); + + // Size the card image to the image's real aspect ratio, so the whole + // image is visible without cropping. Reset the holder to a + // constraint-sized height so the ratio is actually applied (a previous + // bind may have left an explicit fixed height behind). + final ConstraintLayout.LayoutParams imageHolderLayoutParams + = (ConstraintLayout.LayoutParams)mGridImageHolder.getLayoutParams(); + + imageHolderLayoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + imageHolderLayoutParams.height = 0; + + imageHolderLayoutParams.dimensionRatio + = String.valueOf((float)preview.width / (float)preview.height); + mGridImageHolder.setLayoutParams(imageHolderLayoutParams); + + mGridLoadingSpinner.setVisibility(VISIBLE); + mGridPlayOverlay.setVisibility(GONE); + + // Load the preview through Picasso with fit(), which downsamples the + // decoded bitmap to the card's actual size (a fraction of the screen + // width) instead of decoding the full-resolution image into memory. + RedReaderPicasso.INSTANCE.get(mActivity) + .load(preview.url.value) + .fit() + .centerInside() + .into(mThumbnailView, new Callback() { + @Override + public void onSuccess() { + + if(usageId != mUsageId) { + return; + } + + mGridLoadingSpinner.setVisibility(GONE); + + if(post.isVideoPreview()) { + mGridPlayOverlay.setVisibility(VISIBLE); + } + } + + @Override + public void onError(final Exception e) { + + if(usageId != mUsageId) { + return; + } + + Log.e(TAG, "Failed to download grid preview: " + e, e); + + showGridFallbackThumbnail(post, usageId); + } + }); + } + + // For posts where reddit didn't include usable preview data but the post + // links directly to an image (e.g. a preview.redd.it URL): fetch the image + // itself and display it at the card width with its real aspect ratio, + // instead of the small fixed-height thumbnail strip. + private void downloadGridFallbackImage( + @NonNull final RedditPreparedPost post, + final int usageId) { + + if(!post.shouldShowGridImageFallback()) { + showGridFallbackThumbnail(post, usageId); + return; + } + + final UriString url = post.src.getUrl(); + + if(url == null || !LinkHandler.isProbablyAnImage(url)) { + showGridFallbackThumbnail(post, usageId); + return; + } + + mThumbnailView.setVisibility(VISIBLE); + mGridLoadingSpinner.setVisibility(VISIBLE); + mGridPlayOverlay.setVisibility(GONE); + + // Provisional square aspect ratio, corrected once the image is loaded. + // Reset the holder to a constraint-sized height so the ratio actually + // applies (a previous bind may have left an explicit fixed height). + final ConstraintLayout.LayoutParams imageHolderLayoutParams + = (ConstraintLayout.LayoutParams)mGridImageHolder.getLayoutParams(); + + imageHolderLayoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + imageHolderLayoutParams.height = 0; + imageHolderLayoutParams.dimensionRatio = "1:1"; + mGridImageHolder.setLayoutParams(imageHolderLayoutParams); + + // Decode at roughly the card size rather than the full-resolution + // image, so memory stays low; the loaded bitmap's dimensions give the + // image's real aspect ratio. + final Rect windowVisibleDisplayFrame + = DisplayUtils.getWindowVisibleDisplayFrame(mActivity); + + final int targetSize = Math.max(320, windowVisibleDisplayFrame.width() / 2); + + RedReaderPicasso.INSTANCE.get(mActivity) + .load(url.value) + .resize(targetSize, targetSize) + .centerInside() + .onlyScaleDown() + .into(new Target() { + + @Override + public void onBitmapLoaded( + @NonNull final Bitmap bitmap, + @NonNull final Picasso.LoadedFrom from) { + + if(usageId != mUsageId) { + return; + } + + final int width = bitmap.getWidth(); + final int height = bitmap.getHeight(); + + if(width < 10 || height < 10) { + showGridFallbackThumbnail(post, usageId); + return; + } + + imageHolderLayoutParams.dimensionRatio + = String.valueOf((float)width / (float)height); + mGridImageHolder.setLayoutParams(imageHolderLayoutParams); + + mThumbnailView.setImageBitmap(bitmap); + mGridLoadingSpinner.setVisibility(GONE); + + if(post.isVideoPreview()) { + mGridPlayOverlay.setVisibility(VISIBLE); + } + } + + @Override + public void onBitmapFailed( + @NonNull final Exception e, + @Nullable final Drawable errorDrawable) { + + if(usageId != mUsageId) { + return; + } + + Log.e(TAG, "Failed to download grid fallback image: " + e, e); + + showGridFallbackThumbnail(post, usageId); + } + + @Override + public void onPrepareLoad( + @Nullable final Drawable placeHolderDrawable) { + + // The loading spinner is already shown + } + }); + } + + // Formats a post score compactly, e.g. 23567 -> "23.6K", 1500 -> "1.5K", + // 1234567 -> "1.2M", and smaller scores as plain numbers. + private static String formatScore(final int score) { + + final boolean negative = score < 0; + final long absolute = Math.abs((long)score); + + final String result; + if(absolute >= 1_000_000L) { + result = formatCompact(absolute / 1_000_000.0, "M"); + } else if(absolute >= 1_000L) { + final String compact = formatCompact(absolute / 1_000.0, "K"); + result = "1000K".equals(compact) ? "1M" : compact; + } else { + result = String.valueOf(absolute); + } + + return negative ? "-" + result : result; + } + + private static String formatCompact(final double value, final String suffix) { + + final long tenths = Math.round(value * 10.0); + + if(tenths % 10 == 0) { + return (tenths / 10) + suffix; + } + + return (tenths / 10.0) + suffix; + } + + private void showGridFallbackThumbnail( + @NonNull final RedditPreparedPost post, + final int usageId) { + + mGridLoadingSpinner.setVisibility(GONE); + mGridPlayOverlay.setVisibility(GONE); + + // A fixed-height strip, as used before the high-res grid previews + final ConstraintLayout.LayoutParams imageHolderLayoutParams + = (ConstraintLayout.LayoutParams)mGridImageHolder.getLayoutParams(); + + imageHolderLayoutParams.dimensionRatio = null; + mGridImageHolder.setLayoutParams(imageHolderLayoutParams); + + General.setLayoutWidthHeight( + mGridImageHolder, + ViewGroup.LayoutParams.MATCH_PARENT, + General.dpToPixels(mActivity, GRID_THUMBNAIL_HEIGHT_DP)); + + final Bitmap thumbnail = post.getThumbnail(this, usageId); + mThumbnailView.setImageBitmap(thumbnail); + mThumbnailView.setVisibility(VISIBLE); + + // Only show the spinner when a thumbnail download is actually in flight: + // for preview-enabled posts the constructor skips the thumbnail download, + // and showing a spinner that can never resolve is worse than the fallback + // background. + if(thumbnail == null && post.hasThumbnail && !post.shouldShowInlinePreview()) { + mGridLoadingSpinner.setVisibility(VISIBLE); + } + } + private void showPrefPrompt() { final SharedPrefsWrapper sharedPrefs diff --git a/src/main/java/org/quantumbadger/redreader/views/ScrollbarRecyclerViewManager.java b/src/main/java/org/quantumbadger/redreader/views/ScrollbarRecyclerViewManager.java index 2b9da9061..5fcd9cfd9 100644 --- a/src/main/java/org/quantumbadger/redreader/views/ScrollbarRecyclerViewManager.java +++ b/src/main/java/org/quantumbadger/redreader/views/ScrollbarRecyclerViewManager.java @@ -25,6 +25,7 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import org.quantumbadger.redreader.R; @@ -35,6 +36,7 @@ public class ScrollbarRecyclerViewManager { private final RecyclerView mRecyclerView; private final FrameLayout mScrollbarFrame; private final View mScrollbar; + private RecyclerView.LayoutManager mLayoutManager; private boolean mScrollUnnecessary = false; @@ -54,19 +56,20 @@ public ScrollbarRecyclerViewManager( mSwipeRefreshLayout.setEnabled(false); final LinearLayoutManager linearLayoutManager = new LinearLayoutManager(context); - mRecyclerView.setLayoutManager(linearLayoutManager); - mRecyclerView.setHasFixedSize(true); linearLayoutManager.setSmoothScrollbarEnabled(false); + mLayoutManager = linearLayoutManager; + mRecyclerView.setLayoutManager(mLayoutManager); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { private void updateScroll() { - final int firstVisible - = linearLayoutManager.findFirstVisibleItemPosition(); - final int lastVisible = linearLayoutManager.findLastVisibleItemPosition(); + final int firstVisible = findFirstVisibleItemPosition(); + final int lastVisible = findLastVisibleItemPosition(); final int itemsVisible = lastVisible - firstVisible + 1; - final int totalCount = linearLayoutManager.getItemCount(); + final int totalCount = mLayoutManager.getItemCount(); final boolean scrollUnnecessary = (itemsVisible == totalCount); @@ -118,6 +121,44 @@ public void onScrollStateChanged( }); } + // Replaces the layout manager used by the RecyclerView. The internal scrollbar + // logic reads positions from this layout manager, so it must be kept in sync. + // Grid layouts (GridLayoutManager, StaggeredGridLayoutManager) work too. + public void setLayoutManager(@NonNull final RecyclerView.LayoutManager layoutManager) { + mLayoutManager = layoutManager; + mRecyclerView.setLayoutManager(layoutManager); + } + + // findFirstVisibleItemPosition()/findLastVisibleItemPosition() are only + // available on LinearLayoutManager; StaggeredGridLayoutManager instead + // provides findFirstVisibleItemPositions()/findLastVisibleItemPositions(), + // which return one position per column. + private int findFirstVisibleItemPosition() { + if(mLayoutManager instanceof StaggeredGridLayoutManager) { + final int[] positions + = ((StaggeredGridLayoutManager)mLayoutManager) + .findFirstVisibleItemPositions(null); + if(positions == null || positions.length == 0) { + return RecyclerView.NO_POSITION; + } + return positions[0]; + } + return ((LinearLayoutManager)mLayoutManager).findFirstVisibleItemPosition(); + } + + private int findLastVisibleItemPosition() { + if(mLayoutManager instanceof StaggeredGridLayoutManager) { + final int[] positions + = ((StaggeredGridLayoutManager)mLayoutManager) + .findLastVisibleItemPositions(null); + if(positions == null || positions.length == 0) { + return RecyclerView.NO_POSITION; + } + return positions[0]; + } + return ((LinearLayoutManager)mLayoutManager).findLastVisibleItemPosition(); + } + public void enablePullToRefresh(@NonNull final SwipeRefreshLayout.OnRefreshListener listener) { mSwipeRefreshLayout.setOnRefreshListener(listener); mSwipeRefreshLayout.setEnabled(true); diff --git a/src/main/res/drawable/ic_filter_dark.xml b/src/main/res/drawable/ic_filter_dark.xml new file mode 100644 index 000000000..1ff645cca --- /dev/null +++ b/src/main/res/drawable/ic_filter_dark.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/layout/reddit_post_grid.xml b/src/main/res/layout/reddit_post_grid.xml new file mode 100644 index 000000000..872ef689a --- /dev/null +++ b/src/main/res/layout/reddit_post_grid.xml @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/values/arrays.xml b/src/main/res/values/arrays.xml index 0f3e81ed2..82557d8ae 100644 --- a/src/main/res/values/arrays.xml +++ b/src/main/res/values/arrays.xml @@ -31,6 +31,21 @@ force + + @string/pref_appearance_post_layout_list + @string/pref_appearance_post_layout_grid_2 + @string/pref_appearance_post_layout_grid_3 + @string/pref_appearance_post_layout_grid_4 + + + + + list + grid_2 + grid_3 + grid_4 + + @string/action_upvote @string/action_downvote diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 2ceccd41e..c496419bf 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -136,6 +136,12 @@ Sort Subreddits Sort Posts Sort Comments + Filter Posts + All Posts + Image + Video + Link + Text @@ -1114,6 +1120,12 @@ Posts + pref_appearance_post_layout + Post layout + List (1 column) + Grid (2 columns) + Grid (3 columns) + Grid (4 columns) pref_appearance_post_subtitle_items_key Post subtitle entries Sticky tag @@ -1209,6 +1221,7 @@ Do not show pref_menus_appbar pref_menus_appbar_sort + pref_menus_appbar_filter pref_menus_appbar_refresh pref_menus_appbar_past pref_menus_appbar_submit_post @@ -1573,6 +1586,7 @@ No posts yet. No posts shown due to settings. + No posts match this filter. No search results shown due to settings. Relevance: This Hour diff --git a/src/main/res/xml/prefs_appearance.xml b/src/main/res/xml/prefs_appearance.xml index d8970bdc4..86e4b989e 100644 --- a/src/main/res/xml/prefs_appearance.xml +++ b/src/main/res/xml/prefs_appearance.xml @@ -76,6 +76,12 @@ + + + + . + ******************************************************************************/ +package org.quantumbadger.redreader.test.reddit + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.quantumbadger.redreader.common.time.TimestampUTC +import org.quantumbadger.redreader.reddit.PostFilter +import org.quantumbadger.redreader.reddit.kthings.ImageMetadata +import org.quantumbadger.redreader.reddit.kthings.MaybeParseError +import org.quantumbadger.redreader.reddit.kthings.RedditIdAndType +import org.quantumbadger.redreader.reddit.kthings.RedditMediaMetadata +import org.quantumbadger.redreader.reddit.kthings.RedditPost +import org.quantumbadger.redreader.reddit.kthings.RedditTimestampUTC +import org.quantumbadger.redreader.reddit.kthings.UrlEncodedString + +class PostFilterTest { + + @Test + fun selfPostIsText() { + assertEquals( + PostFilter.TEXT, + PostFilter.classify(post(isSelf = true, url = "https://example.com"))) + } + + @Test + fun imagePostIsImage() { + assertEquals( + PostFilter.IMAGE, + PostFilter.classify(post(url = "https://i.redd.it/image.jpg"))) + } + + @Test + fun redditVideoIsVideo() { + assertEquals( + PostFilter.VIDEO, + PostFilter.classify(post(isVideo = true))) + } + + @Test + fun videoLinkIsVideo() { + assertEquals( + PostFilter.VIDEO, + PostFilter.classify(post(url = "https://www.youtube.com/watch?v=video"))) + } + + @Test + fun animatedGifIsVideo() { + assertEquals( + PostFilter.VIDEO, + PostFilter.classify( + post(url = "https://i.redd.it/animation.gif", postHint = "image"))) + } + + @Test + fun staticGalleryIsImage() { + assertEquals(PostFilter.IMAGE, PostFilter.classify(gallery("Image"))) + } + + @Test + fun animatedGalleryIsVideo() { + assertEquals(PostFilter.VIDEO, PostFilter.classify(gallery("AnimatedImage"))) + } + + @Test + fun articleIsLink() { + assertEquals( + PostFilter.LINK, + PostFilter.classify(post(url = "https://example.com/article"))) + } + + private fun post( + url: String? = null, + isSelf: Boolean = false, + isVideo: Boolean = false, + postHint: String? = null, + galleryData: RedditPost.GalleryData? = null, + mediaMetadata: Map>? = null + ) = RedditPost( + id = "id", + name = RedditIdAndType("t3_id"), + subreddit = UrlEncodedString("test"), + num_comments = 0, + score = 0, + permalink = UrlEncodedString("/r/test/comments/id"), + created_utc = RedditTimestampUTC(TimestampUTC.ZERO), + url = url?.let(::UrlEncodedString), + is_self = isSelf, + is_video = isVideo, + post_hint = postHint, + gallery_data = galleryData, + media_metadata = mediaMetadata + ) + + private fun gallery(mediaType: String): RedditPost { + val mediaId = UrlEncodedString("media") + val metadata = RedditMediaMetadata( + status = "valid", + e = mediaType, + m = "image/jpeg", + s = ImageMetadata( + x = 100, + y = 100, + u = UrlEncodedString("https://i.redd.it/image.jpg")), + id = "media") + val galleryItem: MaybeParseError + = MaybeParseError.Ok(RedditPost.GalleryData.GalleryItem(mediaId)) + val metadataEntry: MaybeParseError = MaybeParseError.Ok(metadata) + + return post( + url = "https://www.reddit.com/gallery/id", + galleryData = RedditPost.GalleryData(listOf(galleryItem)), + mediaMetadata = mapOf(mediaId to metadataEntry)) + } +}