mirror of
https://github.com/viq/NewsBlur.git
synced 2025-09-18 21:43:31 +00:00
#1514 Replace AsyncTask. Kotlin
This commit is contained in:
parent
ce3986c45b
commit
90da885e47
3 changed files with 916 additions and 973 deletions
|
@ -1,5 +1,5 @@
|
|||
buildscript {
|
||||
ext.kotlin_version = '1.5.20'
|
||||
ext.kotlin_version = '1.5.21'
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven {
|
||||
|
@ -29,7 +29,7 @@ apply plugin: 'checkstyle'
|
|||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.4'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
|
||||
|
@ -57,7 +57,6 @@ android {
|
|||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
android.buildFeatures.viewBinding = true
|
||||
android.buildFeatures.dataBinding = true
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
|
|
|
@ -1,970 +0,0 @@
|
|||
package com.newsblur.fragment;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.PopupMenu;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.ContextMenu;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.View.OnClickListener;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.WebView.HitTestResult;
|
||||
|
||||
import com.google.android.material.chip.Chip;
|
||||
import com.newsblur.R;
|
||||
import com.newsblur.activity.FeedItemsList;
|
||||
import com.newsblur.activity.NbActivity;
|
||||
import com.newsblur.activity.Reading;
|
||||
import com.newsblur.databinding.FragmentReadingitemBinding;
|
||||
import com.newsblur.databinding.IncludeReadingItemCommentBinding;
|
||||
import com.newsblur.domain.Classifier;
|
||||
import com.newsblur.domain.Story;
|
||||
import com.newsblur.domain.UserDetails;
|
||||
import com.newsblur.network.APIManager;
|
||||
import com.newsblur.network.domain.StoryChangesResponse;
|
||||
import com.newsblur.service.OriginalTextService;
|
||||
import com.newsblur.util.DefaultFeedView;
|
||||
import com.newsblur.util.FeedSet;
|
||||
import com.newsblur.util.FeedUtils;
|
||||
import com.newsblur.util.Font;
|
||||
import com.newsblur.util.PrefConstants.ThemeValue;
|
||||
import com.newsblur.util.PrefsUtils;
|
||||
import com.newsblur.util.StoryChangesState;
|
||||
import com.newsblur.util.StoryUtils;
|
||||
import com.newsblur.util.UIUtils;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class ReadingItemFragment extends NbFragment implements PopupMenu.OnMenuItemClickListener {
|
||||
|
||||
private static final String BUNDLE_SCROLL_POS_REL = "scrollStateRel";
|
||||
public static final String TEXT_SIZE_CHANGED = "textSizeChanged";
|
||||
public static final String TEXT_SIZE_VALUE = "textSizeChangeValue";
|
||||
public static final String READING_FONT_CHANGED = "readingFontChanged";
|
||||
public Story story;
|
||||
private FeedSet fs;
|
||||
private String feedColor, feedTitle, feedFade, feedBorder, feedIconUrl, faviconText;
|
||||
private Classifier classifier;
|
||||
private BroadcastReceiver textSizeReceiver, readingFontReceiver;
|
||||
private boolean displayFeedDetails;
|
||||
private UserDetails user;
|
||||
private DefaultFeedView selectedFeedView;
|
||||
private boolean textViewUnavailable;
|
||||
private StoryChangesState storyChangesState = StoryChangesState.SHOW_CHANGES;
|
||||
|
||||
/** The story HTML, as provided by the 'content' element of the stories API. */
|
||||
private String storyContent;
|
||||
/** The text-mode story HTML, as retrived via the secondary original text API. */
|
||||
private String originalText;
|
||||
|
||||
private HashMap<String,String> imageAltTexts;
|
||||
private HashMap<String,String> imageUrlRemaps;
|
||||
private String sourceUserId;
|
||||
private int contentHash;
|
||||
|
||||
// these three flags are progressively set by async callbacks and unioned
|
||||
// to set isLoadFinished, when we trigger any final UI tricks.
|
||||
private boolean isContentLoadFinished;
|
||||
private boolean isWebLoadFinished;
|
||||
private boolean isSocialLoadFinished;
|
||||
private Boolean isLoadFinished = false;
|
||||
private float savedScrollPosRel = 0f;
|
||||
|
||||
private final Object WEBVIEW_CONTENT_MUTEX = new Object();
|
||||
|
||||
private FragmentReadingitemBinding binding;
|
||||
private IncludeReadingItemCommentBinding itemCommentBinding;
|
||||
|
||||
public static ReadingItemFragment newInstance(Story story, String feedTitle, String feedFaviconColor, String feedFaviconFade, String feedFaviconBorder, String faviconText, String faviconUrl, Classifier classifier, boolean displayFeedDetails, String sourceUserId) {
|
||||
ReadingItemFragment readingFragment = new ReadingItemFragment();
|
||||
|
||||
Bundle args = new Bundle();
|
||||
args.putSerializable("story", story);
|
||||
args.putString("feedTitle", feedTitle);
|
||||
args.putString("feedColor", feedFaviconColor);
|
||||
args.putString("feedFade", feedFaviconFade);
|
||||
args.putString("feedBorder", feedFaviconBorder);
|
||||
args.putString("faviconText", faviconText);
|
||||
args.putString("faviconUrl", faviconUrl);
|
||||
args.putBoolean("displayFeedDetails", displayFeedDetails);
|
||||
args.putSerializable("classifier", classifier);
|
||||
args.putString("sourceUserId", sourceUserId);
|
||||
readingFragment.setArguments(args);
|
||||
|
||||
return readingFragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
story = getArguments() != null ? (Story) getArguments().getSerializable("story") : null;
|
||||
|
||||
displayFeedDetails = getArguments().getBoolean("displayFeedDetails");
|
||||
|
||||
user = PrefsUtils.getUserDetails(getActivity());
|
||||
|
||||
feedIconUrl = getArguments().getString("faviconUrl");
|
||||
feedTitle = getArguments().getString("feedTitle");
|
||||
feedColor = getArguments().getString("feedColor");
|
||||
feedFade = getArguments().getString("feedFade");
|
||||
feedBorder = getArguments().getString("feedBorder");
|
||||
faviconText = getArguments().getString("faviconText");
|
||||
|
||||
classifier = (Classifier) getArguments().getSerializable("classifier");
|
||||
|
||||
sourceUserId = getArguments().getString("sourceUserId");
|
||||
|
||||
textSizeReceiver = new TextSizeReceiver();
|
||||
getActivity().registerReceiver(textSizeReceiver, new IntentFilter(TEXT_SIZE_CHANGED));
|
||||
readingFontReceiver = new ReadingFontReceiver();
|
||||
getActivity().registerReceiver(readingFontReceiver, new IntentFilter(READING_FONT_CHANGED));
|
||||
|
||||
if (savedInstanceState != null) {
|
||||
savedScrollPosRel = savedInstanceState.getFloat(BUNDLE_SCROLL_POS_REL);
|
||||
// we can't actually use the saved scroll position until the webview finishes loading
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
int heightm = binding.readingScrollview.getChildAt(0).getMeasuredHeight();
|
||||
int pos = binding.readingScrollview.getScrollY();
|
||||
outState.putFloat(BUNDLE_SCROLL_POS_REL, (((float)pos)/heightm));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
getActivity().unregisterReceiver(textSizeReceiver);
|
||||
getActivity().unregisterReceiver(readingFontReceiver);
|
||||
binding.readingWebview.setOnTouchListener(null);
|
||||
binding.getRoot().setOnTouchListener(null);
|
||||
getActivity().getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(null);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
// WebViews don't automatically pause content like audio and video when they lose focus. Chain our own
|
||||
// state into the webview so it behaves.
|
||||
@Override
|
||||
public void onPause() {
|
||||
if (this.binding.readingWebview != null ) { this.binding.readingWebview.onPause(); }
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
reloadStoryContent();
|
||||
if (this.binding.readingWebview != null ) { this.binding.readingWebview.onResume(); }
|
||||
}
|
||||
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.fragment_readingitem, container, false);
|
||||
binding = FragmentReadingitemBinding.bind(view);
|
||||
itemCommentBinding = IncludeReadingItemCommentBinding.bind(binding.getRoot());
|
||||
|
||||
Reading activity = (Reading) getActivity();
|
||||
fs = activity.getFeedSet();
|
||||
|
||||
selectedFeedView = PrefsUtils.getDefaultViewModeForFeed(activity, story.feedId);
|
||||
|
||||
registerForContextMenu(binding.readingWebview);
|
||||
binding.readingWebview.setCustomViewLayout(binding.customViewContainer);
|
||||
binding.readingWebview.setWebviewWrapperLayout(binding.readingContainer);
|
||||
binding.readingWebview.setBackgroundColor(Color.TRANSPARENT);
|
||||
binding.readingWebview.fragment = this;
|
||||
binding.readingWebview.activity = activity;
|
||||
|
||||
setupItemMetadata();
|
||||
updateTrainButton();
|
||||
updateShareButton();
|
||||
updateSaveButton();
|
||||
setupItemCommentsAndShares();
|
||||
|
||||
binding.readingScrollview.registerScrollChangeListener(activity);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
binding.storyContextMenuButton.setOnClickListener(v -> onClickMenuButton());
|
||||
itemCommentBinding.trainStoryButton.setOnClickListener(v -> clickTrain());
|
||||
itemCommentBinding.saveStoryButton.setOnClickListener(v -> clickSave());
|
||||
itemCommentBinding.shareStoryButton.setOnClickListener(v -> clickShare());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
|
||||
HitTestResult result = binding.readingWebview.getHitTestResult();
|
||||
if (result.getType() == HitTestResult.IMAGE_TYPE ||
|
||||
result.getType() == HitTestResult.SRC_IMAGE_ANCHOR_TYPE ) {
|
||||
// if the long-pressed item was an image, see if we can pop up a little dialogue
|
||||
// that presents the alt text. Note that images wrapped in links tend to get detected
|
||||
// as anchors, not images, and may not point to the corresponding image URL.
|
||||
String imageURL = result.getExtra();
|
||||
imageURL = imageURL.replace("file://", "");
|
||||
String mappedURL = imageUrlRemaps.get(imageURL);
|
||||
final String finalURL = mappedURL == null ? imageURL : mappedURL;
|
||||
final String altText = imageAltTexts.get(finalURL);
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
|
||||
builder.setTitle(finalURL);
|
||||
if (altText != null) {
|
||||
builder.setMessage(UIUtils.fromHtml(altText));
|
||||
} else {
|
||||
builder.setMessage(finalURL);
|
||||
}
|
||||
int actionRID = R.string.alert_dialog_openlink;
|
||||
if (result.getType() == HitTestResult.IMAGE_TYPE || result.getType() == HitTestResult.SRC_IMAGE_ANCHOR_TYPE ) {
|
||||
actionRID = R.string.alert_dialog_openimage;
|
||||
}
|
||||
builder.setPositiveButton(actionRID, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface dialog, int id) {
|
||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||
i.setData(Uri.parse(finalURL));
|
||||
try {
|
||||
startActivity(i);
|
||||
} catch (Exception e) {
|
||||
android.util.Log.wtf(this.getClass().getName(), "device cannot open URLs");
|
||||
}
|
||||
}
|
||||
});
|
||||
builder.setNegativeButton(R.string.alert_dialog_done, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface dialog, int id) {
|
||||
; // do nothing
|
||||
}
|
||||
});
|
||||
builder.show();
|
||||
} else if (result.getType() == HitTestResult.SRC_ANCHOR_TYPE) {
|
||||
String url = result.getExtra();
|
||||
Intent intent = new Intent(Intent.ACTION_SEND);
|
||||
intent.setType("text/plain");
|
||||
intent.putExtra(Intent.EXTRA_SUBJECT, UIUtils.fromHtml(story.title).toString());
|
||||
intent.putExtra(Intent.EXTRA_TEXT, url);
|
||||
startActivity(Intent.createChooser(intent, "Share using"));
|
||||
} else {
|
||||
super.onCreateContextMenu(menu, v, menuInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private void onClickMenuButton() {
|
||||
PopupMenu pm = new PopupMenu(getActivity(), binding.storyContextMenuButton);
|
||||
Menu menu = pm.getMenu();
|
||||
pm.getMenuInflater().inflate(R.menu.story_context, menu);
|
||||
|
||||
menu.findItem(R.id.menu_reading_save).setTitle(story.starred ? R.string.menu_unsave_story : R.string.menu_save_story);
|
||||
if (fs.isFilterSaved() || fs.isAllSaved() || (fs.getSingleSavedTag() != null)) menu.findItem(R.id.menu_reading_markunread).setVisible(false);
|
||||
|
||||
ThemeValue themeValue = PrefsUtils.getSelectedTheme(getActivity());
|
||||
if (themeValue == ThemeValue.LIGHT) {
|
||||
menu.findItem(R.id.menu_theme_light).setChecked(true);
|
||||
} else if (themeValue == ThemeValue.DARK) {
|
||||
menu.findItem(R.id.menu_theme_dark).setChecked(true);
|
||||
} else if (themeValue == ThemeValue.BLACK) {
|
||||
menu.findItem(R.id.menu_theme_black).setChecked(true);
|
||||
} else if (themeValue == ThemeValue.AUTO) {
|
||||
menu.findItem(R.id.menu_theme_auto).setChecked(true);
|
||||
}
|
||||
|
||||
pm.setOnMenuItemClickListener(this);
|
||||
pm.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuItemClick(MenuItem item) {
|
||||
if (item.getItemId() == R.id.menu_reading_original) {
|
||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||
i.setData(Uri.parse(story.permalink));
|
||||
try {
|
||||
startActivity(i);
|
||||
} catch (Exception e) {
|
||||
com.newsblur.util.Log.e(this, "device cannot open URLs");
|
||||
}
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_reading_sharenewsblur) {
|
||||
String sourceUserId = null;
|
||||
if (fs.getSingleSocialFeed() != null) sourceUserId = fs.getSingleSocialFeed().getKey();
|
||||
DialogFragment newFragment = ShareDialogFragment.newInstance(story, sourceUserId);
|
||||
newFragment.show(getActivity().getSupportFragmentManager(), "dialog");
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_send_story) {
|
||||
FeedUtils.sendStoryUrl(story, getActivity());
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_send_story_full) {
|
||||
FeedUtils.sendStoryFull(story, getActivity());
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_textsize) {
|
||||
TextSizeDialogFragment textSize = TextSizeDialogFragment.newInstance(PrefsUtils.getTextSize(getActivity()), TextSizeDialogFragment.TextSizeType.ReadingText);
|
||||
textSize.show(getActivity().getSupportFragmentManager(), TextSizeDialogFragment.class.getName());
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_font) {
|
||||
ReadingFontDialogFragment storyFont = ReadingFontDialogFragment.newInstance(PrefsUtils.getFontString(getActivity()));
|
||||
storyFont.show(getActivity().getSupportFragmentManager(), ReadingFontDialogFragment.class.getName());
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_reading_save) {
|
||||
if (story.starred) {
|
||||
FeedUtils.setStorySaved(story, false, getActivity(), null);
|
||||
} else {
|
||||
FeedUtils.setStorySaved(story.storyHash, true, getActivity());
|
||||
}
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_reading_markunread) {
|
||||
FeedUtils.markStoryUnread(story, getActivity());
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_theme_auto) {
|
||||
PrefsUtils.setSelectedTheme(getActivity(), ThemeValue.AUTO);
|
||||
UIUtils.restartActivity(getActivity());
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_theme_light) {
|
||||
PrefsUtils.setSelectedTheme(getActivity(), ThemeValue.LIGHT);
|
||||
UIUtils.restartActivity(getActivity());
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_theme_dark) {
|
||||
PrefsUtils.setSelectedTheme(getActivity(), ThemeValue.DARK);
|
||||
UIUtils.restartActivity(getActivity());
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_theme_black) {
|
||||
PrefsUtils.setSelectedTheme(getActivity(), ThemeValue.BLACK);
|
||||
UIUtils.restartActivity(getActivity());
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_intel) {
|
||||
if (story.feedId.equals("0")) return true; // cannot train on feedless stories
|
||||
clickTrain();
|
||||
return true;
|
||||
} else if(item.getItemId() == R.id.menu_go_to_feed){
|
||||
FeedItemsList.startActivity(getContext(), fs,
|
||||
FeedUtils.getFeed(story.feedId), null);
|
||||
return true;
|
||||
} else {
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void clickTrain() {
|
||||
StoryIntelTrainerFragment intelFrag = StoryIntelTrainerFragment.newInstance(story, fs);
|
||||
intelFrag.show(getActivity().getSupportFragmentManager(), StoryIntelTrainerFragment.class.getName());
|
||||
}
|
||||
|
||||
private void updateTrainButton() {
|
||||
itemCommentBinding.trainStoryButton.setVisibility(story.feedId.equals("0") ? View.GONE: View.VISIBLE);
|
||||
}
|
||||
|
||||
private void clickSave() {
|
||||
if (story.starred) {
|
||||
FeedUtils.setStorySaved(story.storyHash, false, getActivity());
|
||||
} else {
|
||||
FeedUtils.setStorySaved(story.storyHash,true, getActivity());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateSaveButton() {
|
||||
if (itemCommentBinding.saveStoryButton == null) return;
|
||||
itemCommentBinding.saveStoryButton.setText(story.starred ? R.string.unsave_this : R.string.save_this);
|
||||
}
|
||||
|
||||
private void clickShare() {
|
||||
DialogFragment newFragment = ShareDialogFragment.newInstance(story, sourceUserId);
|
||||
newFragment.show(getParentFragmentManager(), "dialog");
|
||||
}
|
||||
|
||||
private void updateShareButton() {
|
||||
if (itemCommentBinding.shareStoryButton == null) return;
|
||||
for (String userId : story.sharedUserIds) {
|
||||
if (TextUtils.equals(userId, user.id)) {
|
||||
itemCommentBinding.shareStoryButton.setText(R.string.already_shared);
|
||||
return;
|
||||
}
|
||||
}
|
||||
itemCommentBinding.shareStoryButton.setText(R.string.share_this);
|
||||
}
|
||||
|
||||
private void setupItemCommentsAndShares() {
|
||||
new SetupCommentSectionTask(this, binding.getRoot(), getLayoutInflater(), story).execute();
|
||||
}
|
||||
|
||||
private void setupItemMetadata() {
|
||||
if ((feedColor == null) ||
|
||||
(feedFade == null) ||
|
||||
TextUtils.equals(feedColor, "null") ||
|
||||
TextUtils.equals(feedFade, "null")) {
|
||||
feedColor = "303030";
|
||||
feedFade = "505050";
|
||||
feedBorder = "202020";
|
||||
}
|
||||
|
||||
int[] colors = {
|
||||
Color.parseColor("#" + feedColor),
|
||||
Color.parseColor("#" + feedFade),
|
||||
};
|
||||
GradientDrawable gradient = new GradientDrawable(GradientDrawable.Orientation.BOTTOM_TOP, colors);
|
||||
UIUtils.setViewBackground(binding.rowItemFeedHeader, gradient);
|
||||
binding.itemFeedBorder.setBackgroundColor(Color.parseColor("#" + feedBorder));
|
||||
|
||||
if (TextUtils.equals(faviconText, "black")) {
|
||||
binding.readingFeedTitle.setTextColor(UIUtils.getColor(getActivity(), R.color.text));
|
||||
binding.readingFeedTitle.setShadowLayer(1, 0, 1, UIUtils.getColor(getActivity(), R.color.half_white));
|
||||
} else {
|
||||
binding.readingFeedTitle.setTextColor(UIUtils.getColor(getActivity(), R.color.white));
|
||||
binding.readingFeedTitle.setShadowLayer(1, 0, 1, UIUtils.getColor(getActivity(), R.color.half_black));
|
||||
}
|
||||
|
||||
if (!displayFeedDetails) {
|
||||
binding.readingFeedTitle.setVisibility(View.GONE);
|
||||
binding.readingFeedIcon.setVisibility(View.GONE);
|
||||
} else {
|
||||
FeedUtils.iconLoader.displayImage(feedIconUrl, binding.readingFeedIcon, false);
|
||||
binding.readingFeedTitle.setText(feedTitle);
|
||||
}
|
||||
|
||||
binding.readingItemDate.setText(StoryUtils.formatLongDate(getActivity(), story.timestamp));
|
||||
|
||||
if (story.tags.length <= 0) {
|
||||
binding.readingItemTags.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (selectedFeedView == DefaultFeedView.STORY && story.hasModifications) {
|
||||
binding.readingStoryChanges.setVisibility(View.VISIBLE);
|
||||
binding.readingStoryChanges.setOnClickListener(v -> loadStoryChanges());
|
||||
}
|
||||
|
||||
if (story.starred && story.starredTimestamp != 0) {
|
||||
String savedTimestampText = String.format(getResources().getString(R.string.story_saved_timestamp),
|
||||
StoryUtils.formatLongDate(getActivity(), story.starredTimestamp));
|
||||
binding.readingItemSavedTimestamp.setVisibility(View.VISIBLE);
|
||||
binding.readingItemSavedTimestamp.setText(savedTimestampText);
|
||||
}
|
||||
|
||||
binding.readingItemAuthors.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (story.feedId.equals("0")) return; // cannot train on feedless stories
|
||||
StoryIntelTrainerFragment intelFrag = StoryIntelTrainerFragment.newInstance(story, fs);
|
||||
intelFrag.show(getParentFragmentManager(), StoryIntelTrainerFragment.class.getName());
|
||||
}
|
||||
});
|
||||
|
||||
binding.readingFeedTitle.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (story.feedId.equals("0")) return; // cannot train on feedless stories
|
||||
StoryIntelTrainerFragment intelFrag = StoryIntelTrainerFragment.newInstance(story, fs);
|
||||
intelFrag.show(getParentFragmentManager(), StoryIntelTrainerFragment.class.getName());
|
||||
}
|
||||
});
|
||||
|
||||
binding.readingItemTitle.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
try {
|
||||
UIUtils.handleUri(requireContext(), Uri.parse(story.permalink));
|
||||
} catch (Throwable t) {
|
||||
// we don't actually know if the user will successfully be able to open whatever string
|
||||
// was in the permalink or if the Intent could throw errors
|
||||
Log.e(this.getClass().getName(), "Error opening story by permalink URL.", t);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setupTagsAndIntel();
|
||||
}
|
||||
|
||||
private void setupTagsAndIntel() {
|
||||
binding.readingItemTags.removeAllViews();
|
||||
for (String tag : story.tags) {
|
||||
View v = getLayoutInflater().inflate(R.layout.chip_view, null);
|
||||
|
||||
Chip chip = v.findViewById(R.id.chip);
|
||||
chip.setText(tag);
|
||||
|
||||
if (classifier != null && classifier.tags.containsKey(tag)) {
|
||||
switch (classifier.tags.get(tag)) {
|
||||
case Classifier.LIKE:
|
||||
chip.setChipBackgroundColorResource(R.color.tag_green);
|
||||
chip.setTextColor(ContextCompat.getColor(requireContext(), R.color.tag_green_text));
|
||||
chip.setChipIcon(ContextCompat.getDrawable(requireContext(), R.drawable.ic_thumb_up));
|
||||
break;
|
||||
case Classifier.DISLIKE:
|
||||
chip.setChipBackgroundColorResource(R.color.tag_red);
|
||||
chip.setTextColor(ContextCompat.getColor(requireContext(), R.color.tag_red_text));
|
||||
chip.setChipIcon(ContextCompat.getDrawable(requireContext(), R.drawable.ic_thumb_down));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// tapping tags in saved stories doesn't bring up training
|
||||
if (!(fs.isAllSaved() || (fs.getSingleSavedTag() != null))) {
|
||||
v.setOnClickListener(view -> {
|
||||
if (story.feedId.equals("0")) return; // cannot train on feedless stories
|
||||
StoryIntelTrainerFragment intelFrag = StoryIntelTrainerFragment.newInstance(story, fs);
|
||||
intelFrag.show(getParentFragmentManager(), StoryIntelTrainerFragment.class.getName());
|
||||
});
|
||||
}
|
||||
|
||||
binding.readingItemTags.addView(v);
|
||||
}
|
||||
|
||||
binding.readingItemUserTags.removeAllViews();
|
||||
if (story.userTags.length > 0) {
|
||||
for (int i = 0; i <= story.userTags.length; i++) {
|
||||
View v = getLayoutInflater().inflate(R.layout.chip_view, null);
|
||||
Chip chip = v.findViewById(R.id.chip);
|
||||
|
||||
if (i < story.userTags.length) {
|
||||
chip.setText(story.userTags[i]);
|
||||
chip.setChipIcon(ContextCompat.getDrawable(requireContext(), R.drawable.tag));
|
||||
} else {
|
||||
chip.setText(getString(R.string.add_tag));
|
||||
chip.setChipIcon(ContextCompat.getDrawable(requireContext(), R.drawable.ic_add_gray75));
|
||||
}
|
||||
|
||||
v.setOnClickListener(view -> {
|
||||
StoryUserTagsFragment userTagsFragment = StoryUserTagsFragment.newInstance(story, fs);
|
||||
userTagsFragment.show(getChildFragmentManager(), StoryUserTagsFragment.class.getName());
|
||||
});
|
||||
binding.readingItemUserTags.addView(v);
|
||||
}
|
||||
|
||||
binding.readingItemUserTags.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(story.authors)) {
|
||||
binding.readingItemAuthors.setText("• " + story.authors);
|
||||
if (classifier != null && classifier.authors.containsKey(story.authors)) {
|
||||
switch (classifier.authors.get(story.authors)) {
|
||||
case Classifier.LIKE:
|
||||
binding.readingItemAuthors.setTextColor(UIUtils.getColor(getActivity(), R.color.positive));
|
||||
break;
|
||||
case Classifier.DISLIKE:
|
||||
binding.readingItemAuthors.setTextColor(UIUtils.getColor(getActivity(), R.color.negative));
|
||||
break;
|
||||
default:
|
||||
binding.readingItemAuthors.setTextColor(UIUtils.getThemedColor(getActivity(), R.attr.readingItemMetadata, android.R.attr.textColor));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String title = story.title;
|
||||
title = UIUtils.colourTitleFromClassifier(title, classifier);
|
||||
binding.readingItemTitle.setText(UIUtils.fromHtml(title));
|
||||
}
|
||||
|
||||
public void switchSelectedViewMode() {
|
||||
// if we were already in text mode, switch back to story mode
|
||||
if (selectedFeedView == DefaultFeedView.TEXT) {
|
||||
setViewMode(DefaultFeedView.STORY);
|
||||
} else {
|
||||
setViewMode(DefaultFeedView.TEXT);
|
||||
}
|
||||
|
||||
Reading activity = (Reading) getActivity();
|
||||
activity.viewModeChanged();
|
||||
// telling the activity to change modes will chain a call to viewModeChanged()
|
||||
}
|
||||
|
||||
private void setViewMode(DefaultFeedView newMode) {
|
||||
selectedFeedView = newMode;
|
||||
PrefsUtils.setDefaultViewModeForFeed(getActivity(), story.feedId, newMode);
|
||||
}
|
||||
|
||||
public void viewModeChanged() {
|
||||
synchronized (selectedFeedView) {
|
||||
selectedFeedView = PrefsUtils.getDefaultViewModeForFeed(getActivity(), story.feedId);
|
||||
}
|
||||
// these can come from async tasks
|
||||
Activity a = getActivity();
|
||||
if (a != null) {
|
||||
a.runOnUiThread(new Runnable() {
|
||||
public void run() {
|
||||
reloadStoryContent();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public DefaultFeedView getSelectedViewMode() {
|
||||
return selectedFeedView;
|
||||
}
|
||||
|
||||
private void reloadStoryContent() {
|
||||
// reset indicators
|
||||
binding.readingTextloading.setVisibility(View.GONE);
|
||||
binding.readingTextmodefailed.setVisibility(View.GONE);
|
||||
enableProgress(false);
|
||||
|
||||
boolean needStoryContent = false;
|
||||
boolean enableStoryChanges = false;
|
||||
|
||||
if (selectedFeedView == DefaultFeedView.STORY) {
|
||||
needStoryContent = true;
|
||||
enableStoryChanges = story != null && story.hasModifications;
|
||||
} else {
|
||||
if (textViewUnavailable) {
|
||||
binding.readingTextmodefailed.setVisibility(View.VISIBLE);
|
||||
needStoryContent = true;
|
||||
} else if (originalText == null) {
|
||||
binding.readingTextloading.setVisibility(View.VISIBLE);
|
||||
enableProgress(true);
|
||||
loadOriginalText();
|
||||
// still show the story mode version, as the text mode one may take some time
|
||||
needStoryContent = true;
|
||||
} else {
|
||||
setupWebview(originalText);
|
||||
onContentLoadFinished();
|
||||
}
|
||||
}
|
||||
|
||||
if (needStoryContent) {
|
||||
if (storyContent == null) {
|
||||
loadStoryContent();
|
||||
} else {
|
||||
setupWebview(storyContent);
|
||||
onContentLoadFinished();
|
||||
}
|
||||
}
|
||||
|
||||
binding.readingStoryChanges.setVisibility(enableStoryChanges ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private void enableProgress(boolean loading) {
|
||||
Activity parent = getActivity();
|
||||
if (parent == null) return;
|
||||
((Reading) parent).enableLeftProgressCircle(loading);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the pager offer us an updated version of our story when a new cursor is
|
||||
* cycled in. This class takes the responsibility of ensureing that the cursor
|
||||
* index has not shifted, though, by checking story IDs.
|
||||
*/
|
||||
public void offerStoryUpdate(Story story) {
|
||||
if (story == null) return;
|
||||
if (! TextUtils.equals(story.storyHash, this.story.storyHash)) {
|
||||
com.newsblur.util.Log.d(this, "prevented story list index offset shift");
|
||||
return;
|
||||
}
|
||||
this.story = story;
|
||||
//if (AppConstants.VERBOSE_LOG) com.newsblur.util.Log.d(this, "got fresh story");
|
||||
}
|
||||
|
||||
public void handleUpdate(int updateType) {
|
||||
if ((updateType & NbActivity.UPDATE_STORY) != 0) {
|
||||
updateSaveButton();
|
||||
updateShareButton();
|
||||
setupItemCommentsAndShares();
|
||||
}
|
||||
if ((updateType & NbActivity.UPDATE_TEXT) != 0) {
|
||||
reloadStoryContent();
|
||||
}
|
||||
if ((updateType & NbActivity.UPDATE_SOCIAL) != 0) {
|
||||
updateShareButton();
|
||||
setupItemCommentsAndShares();
|
||||
}
|
||||
if ((updateType & NbActivity.UPDATE_INTEL) != 0) {
|
||||
classifier = FeedUtils.dbHelper.getClassifierForFeed(story.feedId);
|
||||
setupTagsAndIntel();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadOriginalText() {
|
||||
if (story != null) {
|
||||
new AsyncTask<Void, Void, String>() {
|
||||
@Override
|
||||
protected String doInBackground(Void... arg) {
|
||||
return FeedUtils.getStoryText(story.storyHash);
|
||||
}
|
||||
@Override
|
||||
protected void onPostExecute(String result) {
|
||||
if (result != null) {
|
||||
if (OriginalTextService.NULL_STORY_TEXT.equals(result)) {
|
||||
// the server reported that text mode is not available. kick back to story mode
|
||||
com.newsblur.util.Log.d(this, "orig text not avail for story: " + story.storyHash);
|
||||
textViewUnavailable = true;
|
||||
} else {
|
||||
ReadingItemFragment.this.originalText = result;
|
||||
}
|
||||
reloadStoryContent();
|
||||
} else {
|
||||
com.newsblur.util.Log.d(this, "orig text not yet cached for story: " + story.storyHash);
|
||||
OriginalTextService.addPriorityHash(story.storyHash);
|
||||
triggerSync();
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadStoryContent() {
|
||||
if (story == null) return;
|
||||
new AsyncTask<Void, Void, String>() {
|
||||
@Override
|
||||
protected String doInBackground(Void... arg) {
|
||||
return FeedUtils.getStoryContent(story.storyHash);
|
||||
}
|
||||
@Override
|
||||
protected void onPostExecute(String result) {
|
||||
if (result != null) {
|
||||
ReadingItemFragment.this.storyContent = result;
|
||||
reloadStoryContent();
|
||||
} else {
|
||||
com.newsblur.util.Log.w(this, "couldn't find story content for existing story.");
|
||||
Activity act = getActivity();
|
||||
if (act != null) act.finish();
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
}
|
||||
|
||||
private void loadStoryChanges() {
|
||||
boolean showChanges = storyChangesState == null || storyChangesState == StoryChangesState.SHOW_CHANGES;
|
||||
if (story == null) return;
|
||||
new AsyncTask<Void, Void, StoryChangesResponse>() {
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
super.onPreExecute();
|
||||
binding.readingStoryChanges.setText(R.string.story_changes_loading);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected StoryChangesResponse doInBackground(Void... voids) {
|
||||
APIManager apiManager = new APIManager(requireContext());
|
||||
return apiManager.getStoryChanges(story.storyHash, showChanges);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(StoryChangesResponse response) {
|
||||
if (!response.isError() && response.getStory() != null) {
|
||||
ReadingItemFragment.this.storyContent = response.getStory().content;
|
||||
reloadStoryContent();
|
||||
binding.readingStoryChanges.setText(showChanges ? R.string.story_hide_changes : R.string.story_show_changes);
|
||||
storyChangesState = showChanges ? StoryChangesState.HIDE_CHANGES : StoryChangesState.SHOW_CHANGES;
|
||||
} else {
|
||||
binding.readingStoryChanges.setText(showChanges ? R.string.story_show_changes : R.string.story_hide_changes);
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
}
|
||||
|
||||
private void setupWebview(final String storyText) {
|
||||
if (getActivity() == null) {
|
||||
// sometimes we get called before the activity is ready. abort, since we will get a refresh when
|
||||
// the cursor loads
|
||||
return;
|
||||
}
|
||||
getActivity().runOnUiThread(new Runnable() {
|
||||
public void run() {
|
||||
_setupWebview(storyText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void _setupWebview(String storyText) {
|
||||
if (getActivity() == null) {
|
||||
// this method gets called by async UI bits that might hold stale fragment references with no assigned
|
||||
// activity. If this happens, just abort the call.
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (WEBVIEW_CONTENT_MUTEX) {
|
||||
// this method might get called repeatedly despite no content change, which is expensive
|
||||
int contentHash = storyText.hashCode();
|
||||
if (this.contentHash == contentHash) return;
|
||||
this.contentHash = contentHash;
|
||||
|
||||
sniffAltTexts(storyText);
|
||||
|
||||
storyText = swapInOfflineImages(storyText);
|
||||
|
||||
float currentSize = PrefsUtils.getTextSize(getActivity());
|
||||
Font font = PrefsUtils.getFont(getActivity());
|
||||
ThemeValue themeValue = PrefsUtils.getSelectedTheme(getActivity());
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("<html><head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=0\" />");
|
||||
builder.append(font.forWebView(currentSize));
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"reading.css\" />");
|
||||
if (themeValue == ThemeValue.LIGHT) {
|
||||
// builder.append("<meta name=\"color-scheme\" content=\"light\"/>");
|
||||
// builder.append("<meta name=\"supported-color-schemes\" content=\"light\"/>");
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"light_reading.css\" />");
|
||||
} else if (themeValue == ThemeValue.DARK) {
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"dark_reading.css\" />");
|
||||
} else if (themeValue == ThemeValue.BLACK) {
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"black_reading.css\" />");
|
||||
} else if (themeValue == ThemeValue.AUTO) {
|
||||
int nightModeFlags = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
|
||||
if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) {
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"dark_reading.css\" />");
|
||||
} else if (nightModeFlags == Configuration.UI_MODE_NIGHT_NO) {
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"light_reading.css\" />");
|
||||
} else if (nightModeFlags == Configuration.UI_MODE_NIGHT_UNDEFINED) {
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"light_reading.css\" />");
|
||||
}
|
||||
}
|
||||
builder.append("</head><body><div class=\"NB-story\">");
|
||||
builder.append(storyText);
|
||||
builder.append("<script type=\"text/javascript\" src=\"storyDetailView.js\"></script>");
|
||||
builder.append("</div></body></html>");
|
||||
binding.readingWebview.loadDataWithBaseURL("file:///android_asset/", builder.toString(), "text/html", "UTF-8", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern altSniff1 = Pattern.compile("<img[^>]*src=(['\"])((?:(?!\\1).)*)\\1[^>]*alt=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern altSniff2 = Pattern.compile("<img[^>]*alt=(['\"])((?:(?!\\1).)*)\\1[^>]*src=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern altSniff3 = Pattern.compile("<img[^>]*src=(['\"])((?:(?!\\1).)*)\\1[^>]*title=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern altSniff4 = Pattern.compile("<img[^>]*title=(['\"])((?:(?!\\1).)*)\\1[^>]*src=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private void sniffAltTexts(String html) {
|
||||
// Find images with alt tags and cache the text for use on long-press
|
||||
// NOTE: if doing this via regex has a smell, you have a good nose! This method is far from perfect
|
||||
// and may miss valid cases or trucate tags, but it works for popular feeds (read: XKCD) and doesn't
|
||||
// require us to import a proper parser lib of hundreds of kilobytes just for this one feature.
|
||||
imageAltTexts = new HashMap<String,String>();
|
||||
// sniff for alts first
|
||||
Matcher imgTagMatcher = altSniff1.matcher(html);
|
||||
while (imgTagMatcher.find()) {
|
||||
imageAltTexts.put(imgTagMatcher.group(2), imgTagMatcher.group(4));
|
||||
}
|
||||
imgTagMatcher = altSniff2.matcher(html);
|
||||
while (imgTagMatcher.find()) {
|
||||
imageAltTexts.put(imgTagMatcher.group(4), imgTagMatcher.group(2));
|
||||
}
|
||||
// then sniff for 'title' tags, so they will overwrite alts and take precedence
|
||||
imgTagMatcher = altSniff3.matcher(html);
|
||||
while (imgTagMatcher.find()) {
|
||||
imageAltTexts.put(imgTagMatcher.group(2), imgTagMatcher.group(4));
|
||||
}
|
||||
imgTagMatcher = altSniff4.matcher(html);
|
||||
while (imgTagMatcher.find()) {
|
||||
imageAltTexts.put(imgTagMatcher.group(4), imgTagMatcher.group(2));
|
||||
}
|
||||
|
||||
// while were are at it, create a place where we can later cache offline image remaps so that when
|
||||
// we do an alt-text lookup, we can search for the right URL key.
|
||||
imageUrlRemaps = new HashMap<String,String>();
|
||||
}
|
||||
|
||||
private static final Pattern imgSniff = Pattern.compile("<img[^>]*(src\\s*=\\s*)\"([^\"]*)\"[^>]*>", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private String swapInOfflineImages(String html) {
|
||||
Matcher imageTagMatcher = imgSniff.matcher(html);
|
||||
while (imageTagMatcher.find()) {
|
||||
String url = imageTagMatcher.group(2);
|
||||
String localPath = FeedUtils.storyImageCache.getCachedLocation(url);
|
||||
if (localPath == null) continue;
|
||||
html = html.replace(imageTagMatcher.group(1)+"\""+url+"\"", "src=\""+localPath+"\"");
|
||||
imageUrlRemaps.put(localPath, url);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/** We have pushed our desired content into the WebView. */
|
||||
private void onContentLoadFinished() {
|
||||
isContentLoadFinished = true;
|
||||
checkLoadStatus();
|
||||
}
|
||||
|
||||
/** The webview has finished loading our desired content. */
|
||||
public void onWebLoadFinished() {
|
||||
if (!isWebLoadFinished) {
|
||||
binding.readingWebview.evaluateJavascript("loadImages();", null);
|
||||
}
|
||||
isWebLoadFinished = true;
|
||||
checkLoadStatus();
|
||||
}
|
||||
|
||||
/** The social UI has finished loading from the DB. */
|
||||
public void onSocialLoadFinished() {
|
||||
isSocialLoadFinished = true;
|
||||
checkLoadStatus();
|
||||
}
|
||||
|
||||
private void checkLoadStatus() {
|
||||
synchronized (isLoadFinished) {
|
||||
if (isContentLoadFinished && isWebLoadFinished && isSocialLoadFinished) {
|
||||
// iff this is the first time all content has finished loading, trigger any UI
|
||||
// behaviour that is position-dependent
|
||||
if (!isLoadFinished) {
|
||||
onLoadFinished();
|
||||
}
|
||||
isLoadFinished = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook for performing actions that need to happen after all of the view has loaded, including
|
||||
* the story's HTML content, all metadata views, and all associated social views.
|
||||
*/
|
||||
private void onLoadFinished() {
|
||||
// if there was a scroll position saved, restore it
|
||||
if (savedScrollPosRel > 0f) {
|
||||
// ScrollViews containing WebViews are very particular about call timing. since the inner view
|
||||
// height can drastically change as viewport width changes, position has to be saved and restored
|
||||
// as a proportion of total inner view height. that height won't be known until all the various
|
||||
// async bits of the fragment have finished loading. however, even after the WebView calls back
|
||||
// onProgressChanged with a value of 100, immediate calls to get the size of the view will return
|
||||
// incorrect values. even posting a runnable to the very end of our UI event queue may be
|
||||
// insufficient time to allow the WebView to actually finish internally computing state and size.
|
||||
// an additional fixed delay is added in a last ditch attempt to give the black-box platform
|
||||
// threads a chance to finish their work.
|
||||
binding.readingScrollview.postDelayed(new Runnable() {
|
||||
public void run() {
|
||||
int relPos = Math.round(binding.readingScrollview.getChildAt(0).getMeasuredHeight() * savedScrollPosRel);
|
||||
binding.readingScrollview.scrollTo(0, relPos);
|
||||
}
|
||||
}, 75L);
|
||||
}
|
||||
}
|
||||
|
||||
public void flagWebviewError() {
|
||||
// TODO: enable a selective reload mechanism on load failures?
|
||||
}
|
||||
|
||||
public void updateStorySavedTagList(@NotNull ArrayList<String> savedTagList) {
|
||||
story.userTags = savedTagList.toArray(new String[]{});
|
||||
setupTagsAndIntel();
|
||||
}
|
||||
|
||||
private class TextSizeReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
binding.readingWebview.setTextSize(intent.getFloatExtra(TEXT_SIZE_VALUE, 1.0f));
|
||||
}
|
||||
}
|
||||
|
||||
private class ReadingFontReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
contentHash = 0; // Force reload since content hasn't changed
|
||||
reloadStoryContent();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,914 @@
|
|||
package com.newsblur.fragment
|
||||
|
||||
import android.content.*
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.*
|
||||
import android.view.ContextMenu.ContextMenuInfo
|
||||
import android.webkit.WebView.HitTestResult
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.newsblur.R
|
||||
import com.newsblur.activity.FeedItemsList
|
||||
import com.newsblur.activity.NbActivity
|
||||
import com.newsblur.activity.Reading
|
||||
import com.newsblur.databinding.FragmentReadingitemBinding
|
||||
import com.newsblur.databinding.IncludeReadingItemCommentBinding
|
||||
import com.newsblur.domain.Classifier
|
||||
import com.newsblur.domain.Story
|
||||
import com.newsblur.domain.UserDetails
|
||||
import com.newsblur.fragment.StoryUserTagsFragment.Companion.newInstance
|
||||
import com.newsblur.network.APIManager
|
||||
import com.newsblur.service.OriginalTextService
|
||||
import com.newsblur.util.*
|
||||
import com.newsblur.util.PrefConstants.ThemeValue
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
|
||||
|
||||
@JvmField
|
||||
var story: Story? = null
|
||||
|
||||
private var fs: FeedSet? = null
|
||||
private var feedColor: String? = null
|
||||
private var feedTitle: String? = null
|
||||
private var feedFade: String? = null
|
||||
private var feedBorder: String? = null
|
||||
private var feedIconUrl: String? = null
|
||||
private var faviconText: String? = null
|
||||
private var classifier: Classifier? = null
|
||||
private var textSizeReceiver: BroadcastReceiver? = null
|
||||
private var readingFontReceiver: BroadcastReceiver? = null
|
||||
private var displayFeedDetails = false
|
||||
private var user: UserDetails? = null
|
||||
|
||||
var selectedViewMode: DefaultFeedView? = null
|
||||
private set
|
||||
|
||||
private var textViewUnavailable = false
|
||||
private var storyChangesState: StoryChangesState? = StoryChangesState.SHOW_CHANGES
|
||||
|
||||
/** The story HTML, as provided by the 'content' element of the stories API. */
|
||||
private var storyContent: String? = null
|
||||
|
||||
/** The text-mode story HTML, as retrieved via the secondary original text API. */
|
||||
private var originalText: String? = null
|
||||
private var imageAltTexts: HashMap<String, String>? = null
|
||||
private var imageUrlRemaps: HashMap<String, String>? = null
|
||||
private var sourceUserId: String? = null
|
||||
private var contentHash = 0
|
||||
|
||||
// these three flags are progressively set by async callbacks and unioned
|
||||
// to set isLoadFinished, when we trigger any final UI tricks.
|
||||
private var isContentLoadFinished = false
|
||||
private var isWebLoadFinished = false
|
||||
private var isSocialLoadFinished = false
|
||||
private var isLoadFinished = false
|
||||
private var savedScrollPosRel = 0f
|
||||
private val webViewContentMutex = Any()
|
||||
|
||||
private lateinit var binding: FragmentReadingitemBinding
|
||||
private lateinit var itemCommentBinding: IncludeReadingItemCommentBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
story = requireArguments().getSerializable("story") as Story?
|
||||
|
||||
displayFeedDetails = requireArguments().getBoolean("displayFeedDetails")
|
||||
feedIconUrl = requireArguments().getString("faviconUrl")
|
||||
feedTitle = requireArguments().getString("feedTitle")
|
||||
feedColor = requireArguments().getString("feedColor")
|
||||
feedFade = requireArguments().getString("feedFade")
|
||||
feedBorder = requireArguments().getString("feedBorder")
|
||||
faviconText = requireArguments().getString("faviconText")
|
||||
classifier = requireArguments().getSerializable("classifier") as Classifier?
|
||||
sourceUserId = requireArguments().getString("sourceUserId")
|
||||
|
||||
user = PrefsUtils.getUserDetails(requireActivity())
|
||||
textSizeReceiver = TextSizeReceiver()
|
||||
|
||||
requireActivity().registerReceiver(textSizeReceiver, IntentFilter(TEXT_SIZE_CHANGED))
|
||||
readingFontReceiver = ReadingFontReceiver()
|
||||
requireActivity().registerReceiver(readingFontReceiver, IntentFilter(READING_FONT_CHANGED))
|
||||
|
||||
if (savedInstanceState != null) {
|
||||
savedScrollPosRel = savedInstanceState.getFloat(BUNDLE_SCROLL_POS_REL)
|
||||
// we can't actually use the saved scroll position until the webview finishes loading
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
val heightm = binding.readingScrollview.getChildAt(0).measuredHeight
|
||||
val pos = binding.readingScrollview.scrollY
|
||||
outState.putFloat(BUNDLE_SCROLL_POS_REL, pos.toFloat() / heightm)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
requireActivity().unregisterReceiver(textSizeReceiver)
|
||||
requireActivity().unregisterReceiver(readingFontReceiver)
|
||||
binding.readingWebview.setOnTouchListener(null)
|
||||
binding.root.setOnTouchListener(null)
|
||||
requireActivity().window.decorView.setOnSystemUiVisibilityChangeListener(null)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// WebViews don't automatically pause content like audio and video when they lose focus. Chain our own
|
||||
// state into the webview so it behaves.
|
||||
override fun onPause() {
|
||||
binding.readingWebview.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
reloadStoryContent()
|
||||
binding.readingWebview.onResume()
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_readingitem, container, false)
|
||||
binding = FragmentReadingitemBinding.bind(view)
|
||||
itemCommentBinding = IncludeReadingItemCommentBinding.bind(binding.root)
|
||||
|
||||
val readingActivity = requireActivity() as Reading
|
||||
fs = readingActivity.feedSet
|
||||
|
||||
selectedViewMode = PrefsUtils.getDefaultViewModeForFeed(readingActivity, story!!.feedId)
|
||||
|
||||
registerForContextMenu(binding.readingWebview)
|
||||
binding.readingWebview.setCustomViewLayout(binding.customViewContainer)
|
||||
binding.readingWebview.setWebviewWrapperLayout(binding.readingContainer)
|
||||
binding.readingWebview.setBackgroundColor(Color.TRANSPARENT)
|
||||
binding.readingWebview.fragment = this
|
||||
binding.readingWebview.activity = readingActivity
|
||||
|
||||
setupItemMetadata()
|
||||
updateTrainButton()
|
||||
updateShareButton()
|
||||
updateSaveButton()
|
||||
setupItemCommentsAndShares()
|
||||
|
||||
binding.readingScrollview.registerScrollChangeListener(readingActivity)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.storyContextMenuButton.setOnClickListener { onClickMenuButton() }
|
||||
itemCommentBinding.trainStoryButton.setOnClickListener { clickTrain() }
|
||||
itemCommentBinding.saveStoryButton.setOnClickListener { clickSave() }
|
||||
itemCommentBinding.shareStoryButton.setOnClickListener { clickShare() }
|
||||
}
|
||||
|
||||
override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenuInfo?) {
|
||||
val result = binding.readingWebview.hitTestResult
|
||||
if (result.type == HitTestResult.IMAGE_TYPE ||
|
||||
result.type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
|
||||
// if the long-pressed item was an image, see if we can pop up a little dialogue
|
||||
// that presents the alt text. Note that images wrapped in links tend to get detected
|
||||
// as anchors, not images, and may not point to the corresponding image URL.
|
||||
var imageURL = result.extra
|
||||
imageURL = imageURL!!.replace("file://", "")
|
||||
val mappedURL = imageUrlRemaps!![imageURL]
|
||||
val finalURL: String = mappedURL ?: imageURL
|
||||
val altText = imageAltTexts!![finalURL]
|
||||
val builder = AlertDialog.Builder(requireActivity())
|
||||
builder.setTitle(finalURL)
|
||||
if (altText != null) {
|
||||
builder.setMessage(UIUtils.fromHtml(altText))
|
||||
} else {
|
||||
builder.setMessage(finalURL)
|
||||
}
|
||||
var actionRID = R.string.alert_dialog_openlink
|
||||
if (result.type == HitTestResult.IMAGE_TYPE || result.type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
|
||||
actionRID = R.string.alert_dialog_openimage
|
||||
}
|
||||
builder.setPositiveButton(actionRID, object : DialogInterface.OnClickListener {
|
||||
override fun onClick(dialog: DialogInterface, id: Int) {
|
||||
val i = Intent(Intent.ACTION_VIEW)
|
||||
i.data = Uri.parse(finalURL)
|
||||
try {
|
||||
startActivity(i)
|
||||
} catch (e: Exception) {
|
||||
Log.wtf(this.javaClass.name, "device cannot open URLs")
|
||||
}
|
||||
}
|
||||
})
|
||||
builder.setNegativeButton(R.string.alert_dialog_done) { _, _ ->
|
||||
// do nothing
|
||||
}
|
||||
builder.show()
|
||||
} else if (result.type == HitTestResult.SRC_ANCHOR_TYPE) {
|
||||
val url = result.extra
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "text/plain"
|
||||
intent.putExtra(Intent.EXTRA_SUBJECT, UIUtils.fromHtml(story!!.title).toString())
|
||||
intent.putExtra(Intent.EXTRA_TEXT, url)
|
||||
startActivity(Intent.createChooser(intent, "Share using"))
|
||||
} else {
|
||||
super.onCreateContextMenu(menu, v, menuInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onClickMenuButton() {
|
||||
val pm = PopupMenu(requireActivity(), binding.storyContextMenuButton)
|
||||
val menu = pm.menu
|
||||
pm.menuInflater.inflate(R.menu.story_context, menu)
|
||||
|
||||
menu.findItem(R.id.menu_reading_save).setTitle(if (story!!.starred) R.string.menu_unsave_story else R.string.menu_save_story)
|
||||
if (fs!!.isFilterSaved || fs!!.isAllSaved || fs!!.singleSavedTag != null) menu.findItem(R.id.menu_reading_markunread).isVisible = false
|
||||
|
||||
when (PrefsUtils.getSelectedTheme(requireContext())) {
|
||||
ThemeValue.LIGHT -> menu.findItem(R.id.menu_theme_light).isChecked = true
|
||||
ThemeValue.DARK -> menu.findItem(R.id.menu_theme_dark).isChecked = true
|
||||
ThemeValue.BLACK -> menu.findItem(R.id.menu_theme_black).isChecked = true
|
||||
ThemeValue.AUTO -> menu.findItem(R.id.menu_theme_auto).isChecked = true
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
|
||||
pm.setOnMenuItemClickListener(this)
|
||||
pm.show()
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) {
|
||||
R.id.menu_reading_original -> {
|
||||
val i = Intent(Intent.ACTION_VIEW)
|
||||
i.data = Uri.parse(story!!.permalink)
|
||||
try {
|
||||
startActivity(i)
|
||||
} catch (e: Exception) {
|
||||
com.newsblur.util.Log.e(this, "device cannot open URLs")
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.menu_reading_sharenewsblur -> {
|
||||
var sourceUserId: String? = null
|
||||
if (fs!!.singleSocialFeed != null) sourceUserId = fs!!.singleSocialFeed.key
|
||||
val newFragment: DialogFragment = ShareDialogFragment.newInstance(story, sourceUserId)
|
||||
newFragment.show(requireActivity().supportFragmentManager, "dialog")
|
||||
true
|
||||
}
|
||||
R.id.menu_send_story -> {
|
||||
FeedUtils.sendStoryUrl(story, requireContext())
|
||||
true
|
||||
}
|
||||
R.id.menu_send_story_full -> {
|
||||
FeedUtils.sendStoryFull(story, requireContext())
|
||||
true
|
||||
}
|
||||
R.id.menu_textsize -> {
|
||||
val textSize = TextSizeDialogFragment.newInstance(PrefsUtils.getTextSize(requireContext()), TextSizeDialogFragment.TextSizeType.ReadingText)
|
||||
textSize.show(requireActivity().supportFragmentManager, TextSizeDialogFragment::class.java.name)
|
||||
true
|
||||
}
|
||||
R.id.menu_font -> {
|
||||
val storyFont = ReadingFontDialogFragment.newInstance(PrefsUtils.getFontString(requireContext()))
|
||||
storyFont.show(requireActivity().supportFragmentManager, ReadingFontDialogFragment::class.java.name)
|
||||
true
|
||||
}
|
||||
R.id.menu_reading_save -> {
|
||||
if (story!!.starred) {
|
||||
FeedUtils.setStorySaved(story, false, requireContext(), null)
|
||||
} else {
|
||||
FeedUtils.setStorySaved(story!!.storyHash, true, requireContext())
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.menu_reading_markunread -> {
|
||||
FeedUtils.markStoryUnread(story, requireContext())
|
||||
true
|
||||
}
|
||||
R.id.menu_theme_auto -> {
|
||||
PrefsUtils.setSelectedTheme(requireContext(), ThemeValue.AUTO)
|
||||
UIUtils.restartActivity(requireActivity())
|
||||
true
|
||||
}
|
||||
R.id.menu_theme_light -> {
|
||||
PrefsUtils.setSelectedTheme(requireContext(), ThemeValue.LIGHT)
|
||||
UIUtils.restartActivity(requireActivity())
|
||||
true
|
||||
}
|
||||
R.id.menu_theme_dark -> {
|
||||
PrefsUtils.setSelectedTheme(requireContext(), ThemeValue.DARK)
|
||||
UIUtils.restartActivity(requireActivity())
|
||||
true
|
||||
}
|
||||
R.id.menu_theme_black -> {
|
||||
PrefsUtils.setSelectedTheme(requireContext(), ThemeValue.BLACK)
|
||||
UIUtils.restartActivity(requireActivity())
|
||||
true
|
||||
}
|
||||
R.id.menu_intel -> {
|
||||
// check against training on feedless stories
|
||||
if (story!!.feedId != "0") {
|
||||
clickTrain()
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.menu_go_to_feed -> {
|
||||
FeedItemsList.startActivity(context, fs, FeedUtils.getFeed(story!!.feedId), null)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clickTrain() {
|
||||
val intelFrag = StoryIntelTrainerFragment.newInstance(story, fs)
|
||||
intelFrag.show(requireActivity().supportFragmentManager, StoryIntelTrainerFragment::class.java.name)
|
||||
}
|
||||
|
||||
private fun updateTrainButton() {
|
||||
itemCommentBinding.trainStoryButton.visibility = if (story!!.feedId == "0") View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
private fun clickSave() {
|
||||
if (story!!.starred) {
|
||||
FeedUtils.setStorySaved(story!!.storyHash, false, requireContext())
|
||||
} else {
|
||||
FeedUtils.setStorySaved(story!!.storyHash, true, requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSaveButton() {
|
||||
itemCommentBinding.saveStoryButton.setText(if (story!!.starred) R.string.unsave_this else R.string.save_this)
|
||||
}
|
||||
|
||||
private fun clickShare() {
|
||||
val newFragment: DialogFragment = ShareDialogFragment.newInstance(story, sourceUserId)
|
||||
newFragment.show(parentFragmentManager, "dialog")
|
||||
}
|
||||
|
||||
private fun updateShareButton() {
|
||||
for (userId in story!!.sharedUserIds) {
|
||||
if (TextUtils.equals(userId, user!!.id)) {
|
||||
itemCommentBinding.shareStoryButton.setText(R.string.already_shared)
|
||||
return
|
||||
}
|
||||
}
|
||||
itemCommentBinding.shareStoryButton.setText(R.string.share_this)
|
||||
}
|
||||
|
||||
private fun setupItemCommentsAndShares() {
|
||||
SetupCommentSectionTask(this, binding.root, layoutInflater, story).execute()
|
||||
}
|
||||
|
||||
private fun setupItemMetadata() {
|
||||
if (feedColor == null || feedFade == null || feedColor == "null" || feedFade == "null") {
|
||||
feedColor = "303030"
|
||||
feedFade = "505050"
|
||||
feedBorder = "202020"
|
||||
}
|
||||
val colors = intArrayOf(Color.parseColor("#$feedColor"), Color.parseColor("#$feedFade"))
|
||||
val gradient = GradientDrawable(GradientDrawable.Orientation.BOTTOM_TOP, colors)
|
||||
UIUtils.setViewBackground(binding.rowItemFeedHeader, gradient)
|
||||
binding.itemFeedBorder.setBackgroundColor(Color.parseColor("#$feedBorder"))
|
||||
|
||||
if (faviconText == "black") {
|
||||
binding.readingFeedTitle.setTextColor(UIUtils.getColor(requireContext(), R.color.text))
|
||||
binding.readingFeedTitle.setShadowLayer(1f, 0f, 1f, UIUtils.getColor(requireContext(), R.color.half_white))
|
||||
} else {
|
||||
binding.readingFeedTitle.setTextColor(UIUtils.getColor(requireContext(), R.color.white))
|
||||
binding.readingFeedTitle.setShadowLayer(1f, 0f, 1f, UIUtils.getColor(requireContext(), R.color.half_black))
|
||||
}
|
||||
if (!displayFeedDetails) {
|
||||
binding.readingFeedTitle.visibility = View.GONE
|
||||
binding.readingFeedIcon.visibility = View.GONE
|
||||
} else {
|
||||
FeedUtils.iconLoader.displayImage(feedIconUrl, binding.readingFeedIcon, false)
|
||||
binding.readingFeedTitle.text = feedTitle
|
||||
}
|
||||
|
||||
binding.readingItemDate.text = StoryUtils.formatLongDate(requireContext(), story!!.timestamp)
|
||||
|
||||
if (story!!.tags.isEmpty()) {
|
||||
binding.readingItemTags.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (selectedViewMode == DefaultFeedView.STORY && story!!.hasModifications) {
|
||||
binding.readingStoryChanges.visibility = View.VISIBLE
|
||||
binding.readingStoryChanges.setOnClickListener { loadStoryChanges() }
|
||||
}
|
||||
|
||||
if (story!!.starred && story!!.starredTimestamp != 0L) {
|
||||
val savedTimestampText = String.format(resources.getString(R.string.story_saved_timestamp),
|
||||
StoryUtils.formatLongDate(activity, story!!.starredTimestamp))
|
||||
binding.readingItemSavedTimestamp.visibility = View.VISIBLE
|
||||
binding.readingItemSavedTimestamp.text = savedTimestampText
|
||||
}
|
||||
|
||||
binding.readingItemAuthors.setOnClickListener(View.OnClickListener {
|
||||
if (story!!.feedId == "0") return@OnClickListener // cannot train on feedless stories
|
||||
val intelFrag = StoryIntelTrainerFragment.newInstance(story, fs)
|
||||
intelFrag.show(parentFragmentManager, StoryIntelTrainerFragment::class.java.name)
|
||||
})
|
||||
binding.readingFeedTitle.setOnClickListener(View.OnClickListener {
|
||||
if (story!!.feedId == "0") return@OnClickListener // cannot train on feedless stories
|
||||
val intelFrag = StoryIntelTrainerFragment.newInstance(story, fs)
|
||||
intelFrag.show(parentFragmentManager, StoryIntelTrainerFragment::class.java.name)
|
||||
})
|
||||
binding.readingItemTitle.setOnClickListener(object : View.OnClickListener {
|
||||
override fun onClick(v: View) {
|
||||
try {
|
||||
UIUtils.handleUri(requireContext(), Uri.parse(story!!.permalink))
|
||||
} catch (t: Throwable) {
|
||||
// we don't actually know if the user will successfully be able to open whatever string
|
||||
// was in the permalink or if the Intent could throw errors
|
||||
Log.e(this.javaClass.name, "Error opening story by permalink URL.", t)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setupTagsAndIntel()
|
||||
}
|
||||
|
||||
private fun setupTagsAndIntel() {
|
||||
binding.readingItemTags.removeAllViews()
|
||||
for (tag in story!!.tags) {
|
||||
val v = layoutInflater.inflate(R.layout.chip_view, null)
|
||||
|
||||
val chip: Chip = v.findViewById(R.id.chip)
|
||||
chip.text = tag
|
||||
|
||||
if (classifier != null && classifier!!.tags.containsKey(tag)) {
|
||||
when (classifier!!.tags[tag]) {
|
||||
Classifier.LIKE -> {
|
||||
chip.setChipBackgroundColorResource(R.color.tag_green)
|
||||
chip.setTextColor(ContextCompat.getColor(requireContext(), R.color.tag_green_text))
|
||||
chip.chipIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_thumb_up)
|
||||
}
|
||||
Classifier.DISLIKE -> {
|
||||
chip.setChipBackgroundColorResource(R.color.tag_red)
|
||||
chip.setTextColor(ContextCompat.getColor(requireContext(), R.color.tag_red_text))
|
||||
chip.chipIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_thumb_down)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tapping tags in saved stories doesn't bring up training
|
||||
if (!(fs!!.isAllSaved || fs!!.singleSavedTag != null)) {
|
||||
v.setOnClickListener {
|
||||
if (story!!.feedId == "0") return@setOnClickListener // cannot train on feedless stories
|
||||
val intelFrag = StoryIntelTrainerFragment.newInstance(story, fs)
|
||||
intelFrag.show(parentFragmentManager, StoryIntelTrainerFragment::class.java.name)
|
||||
}
|
||||
}
|
||||
|
||||
binding.readingItemTags.addView(v)
|
||||
}
|
||||
|
||||
binding.readingItemUserTags.removeAllViews()
|
||||
if (story!!.userTags.isNotEmpty()) {
|
||||
for (i in 0..story!!.userTags.size) {
|
||||
val v = layoutInflater.inflate(R.layout.chip_view, null)
|
||||
val chip: Chip = v.findViewById(R.id.chip)
|
||||
if (i < story!!.userTags.size) {
|
||||
chip.text = story!!.userTags[i]
|
||||
chip.chipIcon = ContextCompat.getDrawable(requireContext(), R.drawable.tag)
|
||||
} else {
|
||||
chip.text = getString(R.string.add_tag)
|
||||
chip.chipIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_add_gray75)
|
||||
}
|
||||
v.setOnClickListener {
|
||||
val userTagsFragment = newInstance(story!!, fs!!)
|
||||
userTagsFragment.show(childFragmentManager, StoryUserTagsFragment::class.java.name)
|
||||
}
|
||||
binding.readingItemUserTags.addView(v)
|
||||
}
|
||||
binding.readingItemUserTags.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(story!!.authors)) {
|
||||
binding.readingItemAuthors.text = "• " + story!!.authors
|
||||
if (classifier != null && classifier!!.authors.containsKey(story!!.authors)) {
|
||||
when (classifier!!.authors[story!!.authors]) {
|
||||
Classifier.LIKE -> binding.readingItemAuthors.setTextColor(UIUtils.getColor(requireContext(), R.color.positive))
|
||||
Classifier.DISLIKE -> binding.readingItemAuthors.setTextColor(UIUtils.getColor(requireContext(), R.color.negative))
|
||||
else -> binding.readingItemAuthors.setTextColor(UIUtils.getThemedColor(requireContext(), R.attr.readingItemMetadata, android.R.attr.textColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var title = story!!.title
|
||||
title = UIUtils.colourTitleFromClassifier(title, classifier)
|
||||
binding.readingItemTitle.text = UIUtils.fromHtml(title)
|
||||
}
|
||||
|
||||
fun switchSelectedViewMode() {
|
||||
// if we were already in text mode, switch back to story mode
|
||||
if (selectedViewMode == DefaultFeedView.TEXT) {
|
||||
setViewMode(DefaultFeedView.STORY)
|
||||
} else {
|
||||
setViewMode(DefaultFeedView.TEXT)
|
||||
}
|
||||
|
||||
(requireActivity() as Reading).viewModeChanged()
|
||||
// telling the activity to change modes will chain a call to viewModeChanged()
|
||||
}
|
||||
|
||||
private fun setViewMode(newMode: DefaultFeedView) {
|
||||
selectedViewMode = newMode
|
||||
PrefsUtils.setDefaultViewModeForFeed(requireContext(), story!!.feedId, newMode)
|
||||
}
|
||||
|
||||
fun viewModeChanged() {
|
||||
synchronized(selectedViewMode!!) {
|
||||
selectedViewMode = PrefsUtils.getDefaultViewModeForFeed(requireContext(), story!!.feedId)
|
||||
}
|
||||
// these can come from async tasks
|
||||
activity?.runOnUiThread { reloadStoryContent() }
|
||||
}
|
||||
|
||||
private fun reloadStoryContent() {
|
||||
// reset indicators
|
||||
binding.readingTextloading.visibility = View.GONE
|
||||
binding.readingTextmodefailed.visibility = View.GONE
|
||||
enableProgress(false)
|
||||
|
||||
var needStoryContent = false
|
||||
var enableStoryChanges = false
|
||||
|
||||
if (selectedViewMode == DefaultFeedView.STORY) {
|
||||
needStoryContent = true
|
||||
enableStoryChanges = story != null && story!!.hasModifications
|
||||
} else {
|
||||
when {
|
||||
textViewUnavailable -> {
|
||||
binding.readingTextmodefailed.visibility = View.VISIBLE
|
||||
needStoryContent = true
|
||||
}
|
||||
originalText == null -> {
|
||||
binding.readingTextloading.visibility = View.VISIBLE
|
||||
enableProgress(true)
|
||||
loadOriginalText()
|
||||
// still show the story mode version, as the text mode one may take some time
|
||||
needStoryContent = true
|
||||
}
|
||||
else -> {
|
||||
setupWebview(originalText!!)
|
||||
onContentLoadFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needStoryContent) {
|
||||
if (storyContent == null) {
|
||||
loadStoryContent()
|
||||
} else {
|
||||
setupWebview(storyContent!!)
|
||||
onContentLoadFinished()
|
||||
}
|
||||
}
|
||||
|
||||
binding.readingStoryChanges.visibility = if (enableStoryChanges) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun enableProgress(loading: Boolean) {
|
||||
(activity as Reading?)?.enableLeftProgressCircle(loading)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the pager offer us an updated version of our story when a new cursor is
|
||||
* cycled in. This class takes the responsibility of ensureing that the cursor
|
||||
* index has not shifted, though, by checking story IDs.
|
||||
*/
|
||||
fun offerStoryUpdate(story: Story?) {
|
||||
if (story == null) return
|
||||
if (story.storyHash != this.story!!.storyHash) {
|
||||
com.newsblur.util.Log.d(this, "prevented story list index offset shift")
|
||||
return
|
||||
}
|
||||
this.story = story
|
||||
//if (AppConstants.VERBOSE_LOG) com.newsblur.util.Log.d(this, "got fresh story");
|
||||
}
|
||||
|
||||
fun handleUpdate(updateType: Int) {
|
||||
if (updateType and NbActivity.UPDATE_STORY != 0) {
|
||||
updateSaveButton()
|
||||
updateShareButton()
|
||||
setupItemCommentsAndShares()
|
||||
}
|
||||
if (updateType and NbActivity.UPDATE_TEXT != 0) {
|
||||
reloadStoryContent()
|
||||
}
|
||||
if (updateType and NbActivity.UPDATE_SOCIAL != 0) {
|
||||
updateShareButton()
|
||||
setupItemCommentsAndShares()
|
||||
}
|
||||
if (updateType and NbActivity.UPDATE_INTEL != 0) {
|
||||
classifier = FeedUtils.dbHelper.getClassifierForFeed(story!!.feedId)
|
||||
setupTagsAndIntel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOriginalText() {
|
||||
story?.let { story ->
|
||||
lifecycleScope.executeAsyncTask(
|
||||
doInBackground = {
|
||||
FeedUtils.getStoryText(story.storyHash)
|
||||
},
|
||||
onPostExecute = { result ->
|
||||
if (result != null) {
|
||||
if (OriginalTextService.NULL_STORY_TEXT == result) {
|
||||
// the server reported that text mode is not available. kick back to story mode
|
||||
com.newsblur.util.Log.d(this, "orig text not avail for story: " + story.storyHash)
|
||||
textViewUnavailable = true
|
||||
} else {
|
||||
originalText = result
|
||||
}
|
||||
reloadStoryContent()
|
||||
} else {
|
||||
com.newsblur.util.Log.d(this, "orig text not yet cached for story: " + story.storyHash)
|
||||
OriginalTextService.addPriorityHash(story.storyHash)
|
||||
triggerSync()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadStoryContent() {
|
||||
story?.let { story ->
|
||||
lifecycleScope.executeAsyncTask(
|
||||
doInBackground = {
|
||||
FeedUtils.getStoryContent(story.storyHash)
|
||||
},
|
||||
onPostExecute = { result ->
|
||||
if (result != null) {
|
||||
storyContent = result
|
||||
reloadStoryContent()
|
||||
} else {
|
||||
com.newsblur.util.Log.w(this, "couldn't find story content for existing story.")
|
||||
activity?.finish()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadStoryChanges() {
|
||||
val showChanges = storyChangesState == null || storyChangesState === StoryChangesState.SHOW_CHANGES
|
||||
story?.let { story ->
|
||||
lifecycleScope.executeAsyncTask(
|
||||
onPreExecute = {
|
||||
binding.readingStoryChanges.setText(R.string.story_changes_loading)
|
||||
},
|
||||
doInBackground = {
|
||||
val apiManager = APIManager(requireContext())
|
||||
apiManager.getStoryChanges(story.storyHash, showChanges)
|
||||
},
|
||||
onPostExecute = { response ->
|
||||
if (!response.isError && response.story != null) {
|
||||
storyContent = response.story.content
|
||||
reloadStoryContent()
|
||||
binding.readingStoryChanges.setText(if (showChanges) R.string.story_hide_changes else R.string.story_show_changes)
|
||||
storyChangesState = if (showChanges) StoryChangesState.HIDE_CHANGES else StoryChangesState.SHOW_CHANGES
|
||||
} else {
|
||||
binding.readingStoryChanges.setText(if (showChanges) R.string.story_show_changes else R.string.story_hide_changes)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupWebview(storyText: String) {
|
||||
// sometimes we get called before the activity is ready. abort, since we will get a refresh when
|
||||
// the cursor loads
|
||||
activity?.let {
|
||||
it.runOnUiThread { _setupWebview(storyText) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun _setupWebview(storyTextString: String) {
|
||||
var storyText = storyTextString
|
||||
if (activity == null) {
|
||||
// this method gets called by async UI bits that might hold stale fragment references with no assigned
|
||||
// activity. If this happens, just abort the call.
|
||||
return
|
||||
}
|
||||
synchronized(webViewContentMutex) {
|
||||
// this method might get called repeatedly despite no content change, which is expensive
|
||||
val contentHash = storyText.hashCode()
|
||||
if (this.contentHash == contentHash) return
|
||||
this.contentHash = contentHash
|
||||
|
||||
sniffAltTexts(storyText)
|
||||
|
||||
storyText = swapInOfflineImages(storyText)
|
||||
val currentSize = PrefsUtils.getTextSize(requireContext())
|
||||
val font = PrefsUtils.getFont(requireContext())
|
||||
val themeValue = PrefsUtils.getSelectedTheme(requireContext())
|
||||
|
||||
val builder = StringBuilder()
|
||||
builder.append("<html><head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=0\" />")
|
||||
builder.append(font.forWebView(currentSize))
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"reading.css\" />")
|
||||
when (themeValue) {
|
||||
ThemeValue.LIGHT -> {
|
||||
// builder.append("<meta name=\"color-scheme\" content=\"light\"/>");
|
||||
// builder.append("<meta name=\"supported-color-schemes\" content=\"light\"/>");
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"light_reading.css\" />")
|
||||
}
|
||||
ThemeValue.DARK -> {
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"dark_reading.css\" />")
|
||||
}
|
||||
ThemeValue.BLACK -> {
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"black_reading.css\" />")
|
||||
}
|
||||
ThemeValue.AUTO -> {
|
||||
when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
|
||||
Configuration.UI_MODE_NIGHT_YES -> {
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"dark_reading.css\" />")
|
||||
}
|
||||
Configuration.UI_MODE_NIGHT_NO -> {
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"light_reading.css\" />")
|
||||
}
|
||||
Configuration.UI_MODE_NIGHT_UNDEFINED -> {
|
||||
builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"light_reading.css\" />")
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
|
||||
builder.append("</head><body><div class=\"NB-story\">")
|
||||
builder.append(storyText)
|
||||
builder.append("<script type=\"text/javascript\" src=\"storyDetailView.js\"></script>")
|
||||
builder.append("</div></body></html>")
|
||||
binding.readingWebview.loadDataWithBaseURL("file:///android_asset/", builder.toString(), "text/html", "UTF-8", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sniffAltTexts(html: String) {
|
||||
// Find images with alt tags and cache the text for use on long-press
|
||||
// NOTE: if doing this via regex has a smell, you have a good nose! This method is far from perfect
|
||||
// and may miss valid cases or trucate tags, but it works for popular feeds (read: XKCD) and doesn't
|
||||
// require us to import a proper parser lib of hundreds of kilobytes just for this one feature.
|
||||
imageAltTexts = HashMap()
|
||||
// sniff for alts first
|
||||
var imgTagMatcher = altSniff1.matcher(html)
|
||||
while (imgTagMatcher.find()) {
|
||||
imageAltTexts!![imgTagMatcher.group(2)] = imgTagMatcher.group(4)
|
||||
}
|
||||
imgTagMatcher = altSniff2.matcher(html)
|
||||
while (imgTagMatcher.find()) {
|
||||
imageAltTexts!![imgTagMatcher.group(4)] = imgTagMatcher.group(2)
|
||||
}
|
||||
// then sniff for 'title' tags, so they will overwrite alts and take precedence
|
||||
imgTagMatcher = altSniff3.matcher(html)
|
||||
while (imgTagMatcher.find()) {
|
||||
imageAltTexts!![imgTagMatcher.group(2)] = imgTagMatcher.group(4)
|
||||
}
|
||||
imgTagMatcher = altSniff4.matcher(html)
|
||||
while (imgTagMatcher.find()) {
|
||||
imageAltTexts!![imgTagMatcher.group(4)] = imgTagMatcher.group(2)
|
||||
}
|
||||
|
||||
// while were are at it, create a place where we can later cache offline image remaps so that when
|
||||
// we do an alt-text lookup, we can search for the right URL key.
|
||||
imageUrlRemaps = HashMap()
|
||||
}
|
||||
|
||||
private fun swapInOfflineImages(htmlString: String): String {
|
||||
var html = htmlString
|
||||
val imageTagMatcher = imgSniff.matcher(html)
|
||||
while (imageTagMatcher.find()) {
|
||||
val url = imageTagMatcher.group(2)
|
||||
val localPath = FeedUtils.storyImageCache.getCachedLocation(url) ?: continue
|
||||
html = html.replace(imageTagMatcher.group(1) + "\"" + url + "\"", "src=\"$localPath\"")
|
||||
imageUrlRemaps!![localPath] = url
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
/** We have pushed our desired content into the WebView. */
|
||||
private fun onContentLoadFinished() {
|
||||
isContentLoadFinished = true
|
||||
checkLoadStatus()
|
||||
}
|
||||
|
||||
/** The webview has finished loading our desired content. */
|
||||
fun onWebLoadFinished() {
|
||||
if (!isWebLoadFinished) {
|
||||
binding.readingWebview.evaluateJavascript("loadImages();", null)
|
||||
}
|
||||
isWebLoadFinished = true
|
||||
checkLoadStatus()
|
||||
}
|
||||
|
||||
/** The social UI has finished loading from the DB. */
|
||||
fun onSocialLoadFinished() {
|
||||
isSocialLoadFinished = true
|
||||
checkLoadStatus()
|
||||
}
|
||||
|
||||
private fun checkLoadStatus() {
|
||||
synchronized(isLoadFinished) {
|
||||
if (isContentLoadFinished && isWebLoadFinished && isSocialLoadFinished) {
|
||||
// iff this is the first time all content has finished loading, trigger any UI
|
||||
// behaviour that is position-dependent
|
||||
if (!isLoadFinished) {
|
||||
onLoadFinished()
|
||||
}
|
||||
isLoadFinished = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook for performing actions that need to happen after all of the view has loaded, including
|
||||
* the story's HTML content, all metadata views, and all associated social views.
|
||||
*/
|
||||
private fun onLoadFinished() {
|
||||
// if there was a scroll position saved, restore it
|
||||
if (savedScrollPosRel > 0f) {
|
||||
// ScrollViews containing WebViews are very particular about call timing. since the inner view
|
||||
// height can drastically change as viewport width changes, position has to be saved and restored
|
||||
// as a proportion of total inner view height. that height won't be known until all the various
|
||||
// async bits of the fragment have finished loading. however, even after the WebView calls back
|
||||
// onProgressChanged with a value of 100, immediate calls to get the size of the view will return
|
||||
// incorrect values. even posting a runnable to the very end of our UI event queue may be
|
||||
// insufficient time to allow the WebView to actually finish internally computing state and size.
|
||||
// an additional fixed delay is added in a last ditch attempt to give the black-box platform
|
||||
// threads a chance to finish their work.
|
||||
binding.readingScrollview.postDelayed({
|
||||
val relPos = (binding.readingScrollview.getChildAt(0).measuredHeight * savedScrollPosRel).roundToInt()
|
||||
binding.readingScrollview.scrollTo(0, relPos)
|
||||
}, 75L)
|
||||
}
|
||||
}
|
||||
|
||||
fun flagWebviewError() {
|
||||
// TODO: enable a selective reload mechanism on load failures?
|
||||
}
|
||||
|
||||
fun updateStorySavedTagList(savedTagList: ArrayList<String>) {
|
||||
story!!.userTags = savedTagList.toArray(arrayOf())
|
||||
setupTagsAndIntel()
|
||||
}
|
||||
|
||||
private inner class TextSizeReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
binding.readingWebview.setTextSize(intent.getFloatExtra(TEXT_SIZE_VALUE, 1.0f))
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ReadingFontReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
contentHash = 0 // Force reload since content hasn't changed
|
||||
reloadStoryContent()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BUNDLE_SCROLL_POS_REL = "scrollStateRel"
|
||||
const val TEXT_SIZE_CHANGED = "textSizeChanged"
|
||||
const val TEXT_SIZE_VALUE = "textSizeChangeValue"
|
||||
const val READING_FONT_CHANGED = "readingFontChanged"
|
||||
|
||||
@JvmStatic
|
||||
fun newInstance(story: Story?, feedTitle: String?, feedFaviconColor: String?, feedFaviconFade: String?, feedFaviconBorder: String?, faviconText: String?, faviconUrl: String?, classifier: Classifier?, displayFeedDetails: Boolean, sourceUserId: String?): ReadingItemFragment {
|
||||
val readingFragment = ReadingItemFragment()
|
||||
|
||||
val args = Bundle()
|
||||
args.putSerializable("story", story)
|
||||
args.putString("feedTitle", feedTitle)
|
||||
args.putString("feedColor", feedFaviconColor)
|
||||
args.putString("feedFade", feedFaviconFade)
|
||||
args.putString("feedBorder", feedFaviconBorder)
|
||||
args.putString("faviconText", faviconText)
|
||||
args.putString("faviconUrl", faviconUrl)
|
||||
args.putBoolean("displayFeedDetails", displayFeedDetails)
|
||||
args.putSerializable("classifier", classifier)
|
||||
args.putString("sourceUserId", sourceUserId)
|
||||
readingFragment.arguments = args
|
||||
|
||||
return readingFragment
|
||||
}
|
||||
|
||||
private val altSniff1 = Pattern.compile("<img[^>]*src=(['\"])((?:(?!\\1).)*)\\1[^>]*alt=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE)
|
||||
private val altSniff2 = Pattern.compile("<img[^>]*alt=(['\"])((?:(?!\\1).)*)\\1[^>]*src=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE)
|
||||
private val altSniff3 = Pattern.compile("<img[^>]*src=(['\"])((?:(?!\\1).)*)\\1[^>]*title=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE)
|
||||
private val altSniff4 = Pattern.compile("<img[^>]*title=(['\"])((?:(?!\\1).)*)\\1[^>]*src=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE)
|
||||
private val imgSniff = Pattern.compile("<img[^>]*(src\\s*=\\s*)\"([^\"]*)\"[^>]*>", Pattern.CASE_INSENSITIVE)
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue