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.Drawable; 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.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 android.widget.ImageView; import android.widget.TextView; 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.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.StoryUtils; import com.newsblur.util.UIUtils; import com.newsblur.view.ReadingScrollView; 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 LayoutInflater inflater; private String feedColor, feedTitle, feedFade, feedBorder, feedIconUrl, faviconText; private Classifier classifier; private BroadcastReceiver textSizeReceiver, readingFontReceiver; private boolean displayFeedDetails; private View view; private UserDetails user; private DefaultFeedView selectedFeedView; private boolean textViewUnavailable; /** 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 imageAltTexts; private HashMap 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); view.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) { this.inflater = inflater; view = inflater.inflate(R.layout.fragment_readingitem, null); 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.readingScrollview); binding.readingWebview.setBackgroundColor(Color.TRANSPARENT); binding.readingWebview.fragment = this; binding.readingWebview.activity = activity; setupItemMetadata(); updateShareButton(); updateSaveButton(); setupItemCommentsAndShares(); ReadingScrollView scrollView = (ReadingScrollView) view.findViewById(R.id.reading_scrollview); scrollView.registerScrollChangeListener(activity); return view; } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); binding.storyContextMenuButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { onClickMenuButton(); } }); itemCommentBinding.saveStoryButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { clickSave(); } }); itemCommentBinding.shareStoryButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View 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.sendStoryBrief(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 StoryIntelTrainerFragment intelFrag = StoryIntelTrainerFragment.newInstance(story, fs); intelFrag.show(getActivity().getSupportFragmentManager(), StoryIntelTrainerFragment.class.getName()); 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 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, view, inflater, story).execute(); } private void setupItemMetadata() { View feedHeader = view.findViewById(R.id.row_item_feed_header); View feedHeaderBorder = view.findViewById(R.id.item_feed_border); TextView itemDate = (TextView) view.findViewById(R.id.reading_item_date); ImageView feedIcon = (ImageView) view.findViewById(R.id.reading_feed_icon); 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(feedHeader, gradient); feedHeaderBorder.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); feedIcon.setVisibility(View.GONE); } else { FeedUtils.iconLoader.displayImage(feedIconUrl, feedIcon, 0, false); binding.readingFeedTitle.setText(feedTitle); } itemDate.setText(StoryUtils.formatLongDate(getActivity(), story.timestamp)); if (story.tags.length <= 0) { binding.readingItemTags.setVisibility(View.GONE); } 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() { int tag_green_text = UIUtils.getColor(getActivity(), R.color.tag_green_text); int tag_red_text = UIUtils.getColor(getActivity(), R.color.tag_red_text); Drawable tag_green_background = UIUtils.getDrawable(getActivity(), R.drawable.tag_background_positive); Drawable tag_red_background = UIUtils.getDrawable(getActivity(), R.drawable.tag_background_negative); binding.readingItemTags.removeAllViews(); for (String tag : story.tags) { // TODO: these textviews with compound images are buggy, but stubbed in to let colourblind users // see what is going on. these should be replaced with proper Chips when the v28 Chip lib // is in full release. View v = inflater.inflate(R.layout.tag_view, null); TextView tagText = (TextView) v.findViewById(R.id.tag_text); tagText.setText(tag); if (classifier != null && classifier.tags.containsKey(tag)) { switch (classifier.tags.get(tag)) { case Classifier.LIKE: UIUtils.setViewBackground(tagText, tag_green_background); tagText.setTextColor(tag_green_text); Drawable icon_like = UIUtils.getDrawable(getActivity(), R.drawable.ic_like_active); icon_like.setBounds(0, 0, 30, 30); tagText.setCompoundDrawables(null, null, icon_like, null); tagText.setCompoundDrawablePadding(8); break; case Classifier.DISLIKE: UIUtils.setViewBackground(tagText, tag_red_background); tagText.setTextColor(tag_red_text); Drawable icon_dislike = UIUtils.getDrawable(getActivity(), R.drawable.ic_dislike_active); icon_dislike.setBounds(0, 0, 30, 30); tagText.setCompoundDrawables(null, null, icon_dislike, null); tagText.setCompoundDrawablePadding(8); break; } } // tapping tags in saved stories doesn't bring up training if (!(fs.isAllSaved() || (fs.getSingleSavedTag() != null))) { v.setOnClickListener(new OnClickListener() { @Override public void onClick(View 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); } 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; if (selectedFeedView == DefaultFeedView.STORY) { needStoryContent = true; } 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(); } } } 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() { @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() { @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 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(""); builder.append(font.forWebView(currentSize)); builder.append(""); if (themeValue == ThemeValue.LIGHT) { // builder.append(""); // builder.append(""); builder.append(""); } else if (themeValue == ThemeValue.DARK) { builder.append(""); } else if (themeValue == ThemeValue.BLACK) { builder.append(""); } else if (themeValue == ThemeValue.AUTO) { int nightModeFlags = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) { builder.append(""); } else if (nightModeFlags == Configuration.UI_MODE_NIGHT_NO) { builder.append(""); } else if (nightModeFlags == Configuration.UI_MODE_NIGHT_UNDEFINED) { builder.append(""); } } builder.append("
"); builder.append(storyText); builder.append("
"); binding.readingWebview.loadDataWithBaseURL("file:///android_asset/", builder.toString(), "text/html", "UTF-8", null); } } private static final Pattern altSniff1 = Pattern.compile("]*src=(['\"])((?:(?!\\1).)*)\\1[^>]*alt=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE); private static final Pattern altSniff2 = Pattern.compile("]*alt=(['\"])((?:(?!\\1).)*)\\1[^>]*src=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE); private static final Pattern altSniff3 = Pattern.compile("]*src=(['\"])((?:(?!\\1).)*)\\1[^>]*title=(['\"])((?:(?!\\3).)*)\\3[^>]*>", Pattern.CASE_INSENSITIVE); private static final Pattern altSniff4 = Pattern.compile("]*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(); // 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(); } private static final Pattern imgSniff = Pattern.compile("]*(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() { 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? } 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(); } } }