diff options
| author | Andrew Dolgov <fox@fakecake.org> | 2025-05-12 22:42:15 +0300 |
|---|---|---|
| committer | Andrew Dolgov <fox@fakecake.org> | 2025-05-12 22:42:15 +0300 |
| commit | c31152c7147c847edaf8b1f44f270c462d212427 (patch) | |
| tree | a86d48da6d8d0fc22a194f0411df339a81f6e4c6 | |
| parent | d367cf7a30e3d535820779f6a044c102733de8a9 (diff) | |
experimental headlines loader with diffutil
5 files changed, 454 insertions, 21 deletions
diff --git a/org.fox.ttrss/src/main/java/org/fox/ttrss/Application.java b/org.fox.ttrss/src/main/java/org/fox/ttrss/Application.java index 07f1a3b5..9ad069f1 100755 --- a/org.fox.ttrss/src/main/java/org/fox/ttrss/Application.java +++ b/org.fox.ttrss/src/main/java/org/fox/ttrss/Application.java @@ -1,5 +1,8 @@ package org.fox.ttrss; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; import android.os.Bundle; import org.fox.ttrss.types.ArticleList; @@ -17,6 +20,7 @@ public class Application extends android.app.Application { private String m_sessionId; private int m_apiLevel; public LinkedHashMap<String, String> m_customSortModes = new LinkedHashMap<>(); + ConnectivityManager m_cmgr; public static Application getInstance(){ return m_singleton; @@ -31,6 +35,7 @@ public class Application extends android.app.Application { super.onCreate(); m_singleton = this; + m_cmgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); } public String getSessionId() { @@ -68,6 +73,15 @@ public class Application extends android.app.Application { m_customSortModes.clear(); m_customSortModes.putAll(tmp); } - } + + public boolean isWifiConnected() { + NetworkInfo wifi = m_cmgr.getNetworkInfo(ConnectivityManager.TYPE_WIFI); + + if (wifi != null) + return wifi.isConnected(); + + return false; + } + } diff --git a/org.fox.ttrss/src/main/java/org/fox/ttrss/ArticlePager.java b/org.fox.ttrss/src/main/java/org/fox/ttrss/ArticlePager.java index da9dc8f3..46cf36ee 100755 --- a/org.fox.ttrss/src/main/java/org/fox/ttrss/ArticlePager.java +++ b/org.fox.ttrss/src/main/java/org/fox/ttrss/ArticlePager.java @@ -146,8 +146,12 @@ public class ArticlePager extends androidx.fragment.app.Fragment { return view; } - + protected void refresh(final boolean append) { + // + } + + /* protected void refresh(final boolean append) { if (!append) { m_lazyLoadDisabled = false; @@ -291,7 +295,7 @@ public class ArticlePager extends androidx.fragment.app.Fragment { Log.d(TAG, "[AP] request more headlines, firstId=" + m_firstId); req.execute(map); - } + } */ @Override public void onAttach(@NonNull Activity activity) { diff --git a/org.fox.ttrss/src/main/java/org/fox/ttrss/HeadlinesFragment.java b/org.fox.ttrss/src/main/java/org/fox/ttrss/HeadlinesFragment.java index ad023a7e..0030d2a6 100755 --- a/org.fox.ttrss/src/main/java/org/fox/ttrss/HeadlinesFragment.java +++ b/org.fox.ttrss/src/main/java/org/fox/ttrss/HeadlinesFragment.java @@ -44,11 +44,16 @@ import android.widget.PopupMenu; import android.widget.ProgressBar; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityOptionsCompat; import androidx.core.view.ViewCompat; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -84,7 +89,106 @@ import java.util.TimeZone; import jp.wasabeef.glide.transformations.CropCircleTransformation; -public class HeadlinesFragment extends androidx.fragment.app.Fragment { +public class HeadlinesFragment extends androidx.fragment.app.Fragment implements LoaderManager.LoaderCallbacks<ArticleList> { + + public class HeadlinesDiffutilCallback extends DiffUtil.Callback { + private ArticleList m_oldList; + private ArticleList m_newList; + + public HeadlinesDiffutilCallback(ArticleList oldList, ArticleList newList) { + m_oldList = oldList; + m_newList = newList; + } + + @Override + public int getOldListSize() { + return m_oldList != null ? m_oldList.size() : 0; + } + + @Override + public int getNewListSize() { + return m_newList != null ? m_newList.size() : 0; + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + return m_newList.get(newItemPosition).id == m_oldList.get(oldItemPosition).id; + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + return false; + } + } + + + private HeadlinesLoader m_loader; + + @NonNull + @Override + public Loader<ArticleList> onCreateLoader(int id, @Nullable Bundle args) { + return new HeadlinesLoader(getContext(), m_feed, m_activity.getResizeWidth()); + } + + @Override + public void onLoadFinished(@NonNull Loader<ArticleList> loader, ArticleList data) { + Log.d(TAG, "onLoadFinished loader=" + loader + " count=" + data.size()); + + HeadlinesLoader headlinesLoader = (HeadlinesLoader) loader; + + // successful update + if (data != null) { + ArticleList articles = Application.getArticles(); + + articles.stripFooters(); + + DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new HeadlinesDiffutilCallback(articles, data)); + + articles.clear(); + articles.addAll(data); + + diffResult.dispatchUpdatesTo(m_adapter); + + // detail activity does not use footers (see above) + if (!(m_activity instanceof DetailActivity)) { + articles.add(new Article(Article.TYPE_AMR_FOOTER)); + m_adapter.notifyItemInserted(articles.size()); + } + + if (!headlinesLoader.getAppend()) + m_list.scrollToPosition(0); + + //m_adapter.notifyDataSetChanged(); + + if (headlinesLoader.getFirstIdChanged()) { + //if (m_activity.isSmallScreen() || !m_activity.isPortrait()) { + Snackbar.make(getView(), R.string.headlines_row_top_changed, Snackbar.LENGTH_LONG) + .setAction(R.string.reload, v -> refresh(false)).show(); + //} + } + + } else { + if (headlinesLoader.getLastError() == ApiCommon.ApiError.LOGIN_FAILED) { + m_activity.login(); + } else { + + if (headlinesLoader.getLastErrorMessage() != null) { + m_activity.toast(m_activity.getString(headlinesLoader.getErrorMessage()) + "\n" + headlinesLoader.getLastErrorMessage()); + } else { + m_activity.toast(headlinesLoader.getErrorMessage()); + } + } + + } + + if (m_swipeLayout != null) + m_swipeLayout.setRefreshing(false); + } + + @Override + public void onLoaderReset(@NonNull Loader<ArticleList> loader) { + + } public enum ArticlesSelection { ALL, NONE, UNREAD } @@ -98,7 +202,7 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { private String m_searchQuery = ""; private boolean m_refreshInProgress = false; private int m_firstId = 0; - private boolean m_lazyLoadDisabled = false; + //private boolean m_lazyLoadDisabled = false; private SharedPreferences m_prefs; @@ -251,7 +355,6 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { m_activeArticleId = savedInstanceState.getInt("m_activeArticleId"); m_searchQuery = savedInstanceState.getString("m_searchQuery"); m_firstId = savedInstanceState.getInt("m_firstId"); - m_lazyLoadDisabled = savedInstanceState.getBoolean("m_lazyLoadDisabled"); m_compactLayoutMode = savedInstanceState.getBoolean("m_compactLayoutMode"); } @@ -268,7 +371,6 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { out.putInt("m_activeArticleId", m_activeArticleId); out.putString("m_searchQuery", m_searchQuery); out.putInt("m_firstId", m_firstId); - out.putBoolean("m_lazyLoadDisabled", m_lazyLoadDisabled); out.putBoolean("m_compactLayoutMode", m_compactLayoutMode); } @@ -287,7 +389,7 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { m_swipeLayout = view.findViewById(R.id.headlines_swipe_container); - m_swipeLayout.setOnRefreshListener(() -> refresh(false, true)); + m_swipeLayout.setOnRefreshListener(() -> refresh(false)); m_list = view.findViewById(R.id.headlines_list); registerForContextMenu(m_list); @@ -394,6 +496,11 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { new Handler().postDelayed(() -> m_activity.refresh(false), 100); } + + int lastVisibleItem = m_layoutManager.findLastVisibleItemPosition(); + + if (lastVisibleItem >= Application.getArticles().size() - 5) + new Handler().postDelayed(() -> refresh(true), 100); } } @@ -423,11 +530,13 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { } } - if (!m_refreshInProgress && !m_lazyLoadDisabled && lastVisibleItem >= Application.getArticles().size() - 5) { + /*if (!m_refreshInProgress && !m_lazyLoadDisabled && lastVisibleItem >= Application.getArticles().size() - 5) { m_refreshInProgress = true; new Handler().postDelayed(() -> refresh(true), 100); - } + }*/ + /* if (lastVisibleItem >= Application.getArticles().size() - 5) + new Handler().postDelayed(() -> refresh(true), 100); */ } }); @@ -444,7 +553,9 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { public void onResume() { super.onResume(); - if (Application.getArticles().isEmpty()) { + m_loader = (HeadlinesLoader) LoaderManager.getInstance(this).initLoader(0, null, this); + + if (Application.getArticles().getSizeWithoutFooters() == 0) { refresh(false); } else { Article activeArticle = Application.getArticles().getById(m_activeArticleId); @@ -464,11 +575,20 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { m_listener = (HeadlinesEventListener) activity; } - public void refresh(boolean append) { - refresh(append, false); + public void refresh(final boolean append) { + + if (!(m_activity instanceof DetailActivity)) { + // detail activity does not use footers because it would break 1-to-1 mapping with pager view + // pager will need to work on a footerless subset of shared article view before this is possible + + Application.getArticles().add(new Article(Article.TYPE_LOADMORE)); + m_adapter.notifyDataSetChanged(); + } + + m_loader.refresh(append); } - public void refresh(final boolean append, boolean userInitiated) { + /* public void __refresh(final boolean append) { Application.getArticles().stripFooters(); m_adapter.notifyDataSetChanged(); @@ -557,10 +677,9 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { final int skip = getSkip(append); final boolean allowForceUpdate = m_activity.getApiLevel() >= 9 && - !m_feed.is_cat && m_feed.id > 0 && !append && userInitiated && - skip == 0; + !m_feed.is_cat && m_feed.id > 0 && !append && skip == 0; - Log.d(TAG, "allowForceUpdate=" + allowForceUpdate + " userInitiated=" + userInitiated + " skip=" + skip); + Log.d(TAG, "allowForceUpdate=" + allowForceUpdate + " skip=" + skip); req.setOffset(skip); @@ -636,7 +755,7 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { } return skip; - } + } */ static class ArticleViewHolder extends RecyclerView.ViewHolder { public View view; @@ -1531,11 +1650,13 @@ public class HeadlinesFragment extends androidx.fragment.app.Fragment { } public void setSelection(ArticlesSelection select) { - for (Article a : Application.getArticles()) + ArticleList articlesWithoutFooters = Application.getArticles().getWithoutFooters(); + + for (Article a : articlesWithoutFooters) a.selected = false; if (select != ArticlesSelection.NONE) { - for (Article a : Application.getArticles()) { + for (Article a : articlesWithoutFooters) { if (select == ArticlesSelection.ALL || select == ArticlesSelection.UNREAD && a.unread) { a.selected = true; } diff --git a/org.fox.ttrss/src/main/java/org/fox/ttrss/HeadlinesLoader.java b/org.fox.ttrss/src/main/java/org/fox/ttrss/HeadlinesLoader.java new file mode 100755 index 00000000..5cd7503c --- /dev/null +++ b/org.fox.ttrss/src/main/java/org/fox/ttrss/HeadlinesLoader.java @@ -0,0 +1,292 @@ +package org.fox.ttrss; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.loader.content.AsyncTaskLoader; +import androidx.preference.PreferenceManager; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; + +import org.fox.ttrss.ApiCommon.ApiError; +import org.fox.ttrss.types.Article; +import org.fox.ttrss.types.ArticleList; +import org.fox.ttrss.types.Feed; + +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.List; + +public class HeadlinesLoader extends AsyncTaskLoader<ArticleList> implements ApiCommon.ApiCaller { + private final String TAG = this.getClass().getSimpleName(); + + private final int m_responseCode = 0; + protected String m_responseMessage; + private int m_apiStatusCode = 0; + + private Context m_context; + private String m_lastErrorMessage; + private ApiError m_lastError; + private ArticleList m_articles; + private Feed m_feed; + private SharedPreferences m_prefs; + private int m_firstId; + private String m_searchQuery = ""; + private boolean m_firstIdChanged; + private int m_offset; + private int m_amountLoaded; + private int m_resizeWidth; + private boolean m_append; + private boolean m_lazyLoadEnabled; + private boolean m_loadingInProgress; + + HeadlinesLoader(Context context, Feed feed, int resizeWidth) { + super(context); + + m_context = context; + m_lastError = ApiError.NO_ERROR; + m_feed = feed; + m_articles = new ArticleList(); + m_resizeWidth = resizeWidth; + m_prefs = PreferenceManager.getDefaultSharedPreferences(context); + } + + protected void refresh(boolean append) { + Log.d(TAG, "refresh, append=" + append + " inProgress=" + m_loadingInProgress + " lazyLoadEnabled=" + m_lazyLoadEnabled); + + if (!append) { + m_append = false; + m_lazyLoadEnabled = true; + + forceLoad(); + } else if (!m_loadingInProgress && m_lazyLoadEnabled) { + m_append = true; + forceLoad(); + } else { + deliverResult(m_articles); + } + } + + @Override + protected void onStartLoading() { + if (m_articles != null) { + deliverResult(m_articles); + } else { + forceLoad(); + } + } + + @Override + public void deliverResult(ArticleList data) { + m_articles = data; + + super.deliverResult(m_articles); + } + + public int getErrorMessage() { + return ApiCommon.getErrorMessage(m_lastError); + } + + ApiError getLastError() { + return m_lastError; + } + + String getLastErrorMessage() { + return m_lastErrorMessage; + } + + public boolean lazyLoadEnabled() { + return m_lazyLoadEnabled; + } + + @Override + public ArticleList loadInBackground() { + + m_loadingInProgress = true; + + final int skip = getSkip(m_append); + final boolean allowForceUpdate = Application.getInstance().getApiLevel() >= 9 && + !m_feed.is_cat && m_feed.id > 0 && !m_append && skip == 0; + + HashMap<String,String> params = new HashMap<>(); + + params.put("op", "getHeadlines"); + params.put("sid", Application.getInstance().getSessionId()); + params.put("feed_id", String.valueOf(m_feed.id)); + params.put("show_excerpt", "true"); + params.put("excerpt_length", String.valueOf(CommonActivity.EXCERPT_MAX_LENGTH)); + params.put("show_content", "true"); + params.put("include_attachments", "true"); + params.put("view_mode", m_prefs.getString("view_mode", "adaptive")); + params.put("limit", m_prefs.getString("headlines_request_size", "15")); + params.put("skip", String.valueOf(skip)); + params.put("include_nested", "true"); + params.put("has_sandbox", "true"); + params.put("order_by", m_prefs.getString("headlines_sort_mode", "default")); + + if (m_prefs.getBoolean("enable_image_downsampling", false)) { + if (m_prefs.getBoolean("always_downsample_images", false) || !Application.getInstance().isWifiConnected()) { + params.put("resize_width", String.valueOf(m_resizeWidth)); + } + } + + if (m_feed.is_cat) + params.put("is_cat", "true"); + + if (allowForceUpdate) { + params.put("force_update", "true"); + } + + if (m_searchQuery != null && !m_searchQuery.isEmpty()) { + params.put("search", m_searchQuery); + params.put("search_mode", ""); + params.put("match_on", "both"); + } + + if (m_firstId > 0) + params.put("check_first_id", String.valueOf(m_firstId)); + + if (Application.getInstance().getApiLevel() >= 12) { + params.put("include_header", "true"); + } + + Log.d(TAG, "request more headlines, firstId=" + m_firstId + ", append=" + m_append + ", skip=" + skip); + + JsonElement result = ApiCommon.performRequest(m_context, params, this); + + Log.d(TAG, "got result=" + result); + + if (result != null) { + try { + JsonArray content = result.getAsJsonArray(); + if (content != null) { + final List<Article> articlesJson; + final JsonObject header; + + if (Application.getInstance().getApiLevel() >= 12) { + header = content.get(0).getAsJsonObject(); + + m_firstIdChanged = header.get("first_id_changed") != null; + + try { + m_firstId = header.get("first_id").getAsInt(); + } catch (NumberFormatException e) { + m_firstId = 0; + } + + Log.d(TAG, "firstID=" + m_firstId + " firstIdChanged=" + m_firstIdChanged); + + Type listType = new TypeToken<List<Article>>() {}.getType(); + articlesJson = new Gson().fromJson(content.get(1), listType); + } else { + Type listType = new TypeToken<List<Article>>() {}.getType(); + articlesJson = new Gson().fromJson(content, listType); + } + + if (skip == 0) + m_articles.clear(); + /* else + m_articles.stripFooters(); */ + + m_amountLoaded = articlesJson.size(); + + for (Article f : articlesJson) + if (!m_articles.containsId(f.id)) { + f.collectMediaInfo(); + f.cleanupExcerpt(); + m_articles.add(f); + } + + if (m_firstIdChanged) { + Log.d(TAG, "first id changed, disabling lazy load"); + m_lazyLoadEnabled = false; + } + + if (m_amountLoaded < Integer.parseInt(m_prefs.getString("headlines_request_size", "15"))) { + Log.d(TAG, "amount loaded "+m_amountLoaded+" < request size, disabling lazy load"); + m_lazyLoadEnabled = false; + } + + m_loadingInProgress = false; + + return m_articles; + } + + } catch (Exception e) { + e.printStackTrace(); + } + } + + m_loadingInProgress = false; + + return null; + + /* TODO move to onLoaderFinished() if (m_lastError == ApiCommon.ApiError.LOGIN_FAILED) { + m_activity.login(); + } else { + + if (m_lastErrorMessage != null) { + m_activity.toast(m_activity.getString(getErrorMessage()) + "\n" + m_lastErrorMessage); + } else { + m_activity.toast(getErrorMessage()); + } + //m_activity.setLoadingStatus(getErrorMessage(), false); + } */ + } + + private int getSkip(boolean append) { + int skip = 0; + + if (append) { + // adaptive, all_articles, marked, published, unread + String viewMode = m_prefs.getString("view_mode", "adaptive"); + + int numUnread = Math.toIntExact(m_articles.getUnreadCount()); + int numAll = Math.toIntExact(m_articles.getSizeWithoutFooters()); + + if ("marked".equals(viewMode)) { + skip = numAll; + } else if ("published".equals(viewMode)) { + skip = numAll; + } else if ("unread".equals(viewMode)) { + skip = numUnread; + } else if (m_searchQuery != null && !m_searchQuery.isEmpty()) { + skip = numAll; + } else if ("adaptive".equals(viewMode)) { + skip = numUnread > 0 ? numUnread : numAll; + } else { + skip = numAll; + } + } + + return skip; + } + + @Override + public void setStatusCode(int statusCode) { + m_apiStatusCode = statusCode; + } + + @Override + public void setLastError(ApiError lastError) { + m_lastError = lastError; + } + + @Override + public void setLastErrorMessage(String message) { + m_lastErrorMessage = message; + } + + public boolean getFirstIdChanged() { + return m_firstIdChanged; + } + + public boolean getAppend() { + return m_append; + } +} diff --git a/org.fox.ttrss/src/main/java/org/fox/ttrss/types/Article.java b/org.fox.ttrss/src/main/java/org/fox/ttrss/types/Article.java index 7d476ad6..ec620337 100755 --- a/org.fox.ttrss/src/main/java/org/fox/ttrss/types/Article.java +++ b/org.fox.ttrss/src/main/java/org/fox/ttrss/types/Article.java @@ -3,6 +3,8 @@ package org.fox.ttrss.types; import android.os.Parcel; import android.os.Parcelable; +import androidx.annotation.NonNull; + import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; @@ -268,7 +270,7 @@ public class Article implements Parcelable { return false; } } - + @SuppressWarnings("rawtypes") public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { |