package org.wikipedia.page; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.graphics.Bitmap; import android.net.Uri; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.design.widget.BottomSheetDialog; import android.support.design.widget.BottomSheetDialogFragment; import android.support.design.widget.TabLayout; import android.support.v4.app.Fragment; import android.support.v4.content.ContextCompat; import android.support.v4.view.MenuItemCompat; import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.support.v7.view.ActionMode; import android.text.TextUtils; import android.util.Log; import android.util.TypedValue; import android.view.Gravity; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; import android.widget.TextView; import com.appenguin.onboarding.ToolTip; import org.json.JSONException; import org.json.JSONObject; import org.wikipedia.BackPressedHandler; import org.wikipedia.Constants; import org.wikipedia.LongPressHandler; import org.wikipedia.R; import org.wikipedia.WikipediaApp; import org.wikipedia.activity.FragmentUtil; import org.wikipedia.analytics.FindInPageFunnel; import org.wikipedia.analytics.GalleryFunnel; import org.wikipedia.analytics.PageScrollFunnel; import org.wikipedia.analytics.TabFunnel; import org.wikipedia.bridge.CommunicationBridge; import org.wikipedia.bridge.DarkModeMarshaller; import org.wikipedia.concurrency.CallbackTask; import org.wikipedia.dataclient.WikiSite; import org.wikipedia.dataclient.okhttp.OkHttpWebViewClient; import org.wikipedia.descriptions.DescriptionEditActivity; import org.wikipedia.edit.EditHandler; import org.wikipedia.gallery.GalleryActivity; import org.wikipedia.history.HistoryEntry; import org.wikipedia.history.UpdateHistoryTask; import org.wikipedia.language.LangLinksActivity; import org.wikipedia.onboarding.PrefsOnboardingStateMachine; import org.wikipedia.page.action.PageActionTab; import org.wikipedia.page.action.PageActionToolbarHideHandler; import org.wikipedia.page.leadimages.LeadImagesHandler; import org.wikipedia.page.leadimages.PageHeaderView; import org.wikipedia.page.snippet.CompatActionMode; import org.wikipedia.page.snippet.ShareHandler; import org.wikipedia.page.tabs.Tab; import org.wikipedia.page.tabs.TabsProvider; import org.wikipedia.readinglist.AddToReadingListDialog; import org.wikipedia.readinglist.ReadingList; import org.wikipedia.readinglist.ReadingListBookmarkMenu; import org.wikipedia.readinglist.page.ReadingListPage; import org.wikipedia.readinglist.page.database.ReadingListDaoProxy; import org.wikipedia.readinglist.page.database.ReadingListPageDao; import org.wikipedia.settings.Prefs; import org.wikipedia.tooltip.ToolTipUtil; import org.wikipedia.util.ActiveTimer; import org.wikipedia.util.DimenUtil; import org.wikipedia.util.FeedbackUtil; import org.wikipedia.util.ReleaseUtil; import org.wikipedia.util.ResourceUtil; import org.wikipedia.util.ShareUtil; import org.wikipedia.util.StringUtil; import org.wikipedia.util.ThrowableUtil; import org.wikipedia.util.UriUtil; import org.wikipedia.util.log.L; import org.wikipedia.views.ConfigurableTabLayout; import org.wikipedia.views.ObservableWebView; import org.wikipedia.views.SwipeRefreshLayoutWithScroll; import org.wikipedia.views.WikiDrawerLayout; import org.wikipedia.views.WikiErrorView; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.concurrent.TimeUnit; import static android.app.Activity.RESULT_OK; import static butterknife.ButterKnife.findById; import static org.wikipedia.util.DimenUtil.getContentTopOffset; import static org.wikipedia.util.DimenUtil.getContentTopOffsetPx; import static org.wikipedia.util.ResourceUtil.getThemedAttributeId; import static org.wikipedia.util.ThrowableUtil.isOffline; import static org.wikipedia.util.UriUtil.decodeURL; import static org.wikipedia.util.UriUtil.visitInExternalBrowser; public class PageFragment extends Fragment implements BackPressedHandler { public interface Callback { void onPageShowBottomSheet(@NonNull BottomSheetDialog dialog); void onPageShowBottomSheet(@NonNull BottomSheetDialogFragment dialog); void onPageDismissBottomSheet(); @Nullable PageToolbarHideHandler onPageGetToolbarHideHandler(); void onPageLoadPage(@NonNull PageTitle title, @NonNull HistoryEntry entry); void onPageLoadPage(@NonNull PageTitle title, @NonNull HistoryEntry entry, @NonNull TabsProvider.TabPosition tabPosition); void onPageShowLinkPreview(@NonNull PageTitle title, int source); void onPageLoadMainPageInForegroundTab(); void onPageUpdateProgressBar(boolean visible, boolean indeterminate, int value); void onPageSearchRequested(); boolean onPageIsSearching(); @Nullable Fragment onPageGetTopFragment(); void onPageShowThemeChooser(); @Nullable ActionMode onPageStartSupportActionMode(@NonNull ActionMode.Callback callback); void onPageShowToolbar(); void onPageHideSoftKeyboard(); @Nullable PageLoadCallbacks onPageGetPageLoadCallbacks(); void onPageAddToReadingList(@NonNull PageTitle title, @NonNull AddToReadingListDialog.InvokeSource source); void onPageRemoveFromReadingLists(@NonNull PageTitle title); @Nullable View onPageGetContentView(); @Nullable View onPageGetTabsContainerView(); void onPagePopFragment(); @Nullable AppCompatActivity getActivity(); void onPageInvalidateOptionsMenu(); void onPageLoadError(@NonNull PageTitle title); void onPageLoadErrorRetry(); void onPageLoadErrorBackPressed(); boolean shouldLoadFromBackStack(); boolean shouldShowTabList(); } public static final int TOC_ACTION_SHOW = 0; public static final int TOC_ACTION_HIDE = 1; public static final int TOC_ACTION_TOGGLE = 2; private boolean pageRefreshed; private boolean errorState = false; private static final int REFRESH_SPINNER_ADDITIONAL_OFFSET = (int) (16 * DimenUtil.getDensityScalar()); private PageFragmentLoadState pageFragmentLoadState; private PageViewModel model; @Nullable private PageInfo pageInfo; private boolean pageSavedToList; /** * List of tabs, each of which contains a backstack of page titles. * Since the list consists of Parcelable objects, it can be saved and restored from the * savedInstanceState of the fragment. */ @NonNull private final List<Tab> tabList = new ArrayList<>(); @NonNull private TabFunnel tabFunnel = new TabFunnel(); @Nullable private PageScrollFunnel pageScrollFunnel; private LeadImagesHandler leadImagesHandler; private PageToolbarHideHandler toolbarHideHandler; private ObservableWebView webView; private SwipeRefreshLayoutWithScroll refreshView; private WikiErrorView errorView; private WikiDrawerLayout tocDrawer; private ConfigurableTabLayout tabLayout; private CommunicationBridge bridge; private LinkHandler linkHandler; private EditHandler editHandler; private ActionMode findInPageActionMode; @NonNull private ShareHandler shareHandler; private TabsProvider tabsProvider; private ActiveTimer activeTimer = new ActiveTimer(); private WikipediaApp app; @NonNull private final SwipeRefreshLayout.OnRefreshListener pageRefreshListener = new SwipeRefreshLayout.OnRefreshListener() { @Override public void onRefresh() { refreshPage(); } }; @NonNull private final TabLayout.OnTabSelectedListener pageActionTabListener = new TabLayout.OnTabSelectedListener() { @Override public void onTabSelected(TabLayout.Tab tab) { if (tabLayout.isEnabled(tab)) { PageActionTab.of(tab.getPosition()).select(pageActionTabsCallback); } } @Override public void onTabUnselected(TabLayout.Tab tab) { } @Override public void onTabReselected(TabLayout.Tab tab) { onTabSelected(tab); } }; private PageActionTab.Callback pageActionTabsCallback = new PageActionTab.Callback() { @Override public void onAddToReadingListTabSelected() { if (pageSavedToList) { new ReadingListBookmarkMenu(tabLayout, new ReadingListBookmarkMenu.Callback() { @Override public void onAddRequest(@Nullable ReadingListPage page) { addToReadingList(AddToReadingListDialog.InvokeSource.BOOKMARK_BUTTON); } @Override public void onDeleted(@Nullable ReadingListPage page) { if (callback() != null) { callback().onPageRemoveFromReadingLists(getTitle()); } } }).show(getTitle()); } else { addToReadingList(AddToReadingListDialog.InvokeSource.BOOKMARK_BUTTON); } } @Override public void onSharePageTabSelected() { sharePageLink(); } @Override public void onChooseLangTabSelected() { startLangLinksActivity(); } @Override public void onFindInPageTabSelected() { showFindInPage(); } @Override public void onViewToCTabSelected() { toggleToC(TOC_ACTION_TOGGLE); } @Override public void updateBookmark(boolean pageSaved) { setBookmarkIconForPageSavedState(pageSaved); } }; public ObservableWebView getWebView() { return webView; } public PageTitle getTitle() { return model.getTitle(); } public PageTitle getTitleOriginal() { return model.getTitleOriginal(); } @NonNull public ShareHandler getShareHandler() { return shareHandler; } @Nullable public Page getPage() { return model.getPage(); } public HistoryEntry getHistoryEntry() { return model.getCurEntry(); } public EditHandler getEditHandler() { return editHandler; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); app = (WikipediaApp) getActivity().getApplicationContext(); model = new PageViewModel(); pageFragmentLoadState = new PageFragmentLoadState(); initTabs(); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, final Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_page, container, false); webView = (ObservableWebView) rootView.findViewById(R.id.page_web_view); initWebViewListeners(); tocDrawer = (WikiDrawerLayout) rootView.findViewById(R.id.page_toc_drawer); tocDrawer.setDragEdgeWidth(getResources().getDimensionPixelSize(R.dimen.drawer_drag_margin)); refreshView = (SwipeRefreshLayoutWithScroll) rootView .findViewById(R.id.page_refresh_container); int swipeOffset = getContentTopOffsetPx(getActivity()) + REFRESH_SPINNER_ADDITIONAL_OFFSET; refreshView.setProgressViewOffset(false, -swipeOffset, swipeOffset); refreshView.setColorSchemeResources(R.color.foundation_blue); refreshView.setScrollableChild(webView); refreshView.setOnRefreshListener(pageRefreshListener); tabLayout = (ConfigurableTabLayout) rootView.findViewById(R.id.page_actions_tab_layout); tabLayout.addOnTabSelectedListener(pageActionTabListener); PageActionToolbarHideHandler pageActionToolbarHideHandler = new PageActionToolbarHideHandler(tabLayout); pageActionToolbarHideHandler.setScrollView(webView); errorView = (WikiErrorView) rootView.findViewById(R.id.page_error); return rootView; } @Override public void onDestroyView() { //uninitialize the bridge, so that no further JS events can have any effect. bridge.cleanup(); tabsProvider.setTabsProviderListener(null); toolbarHideHandler.setScrollView(null); webView.destroy(); super.onDestroyView(); } @Override public void onDestroy() { super.onDestroy(); app.getRefWatcher().watch(this); } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); setHasOptionsMenu(true); updateFontSize(); // Explicitly set background color of the WebView (independently of CSS, because // the background may be shown momentarily while the WebView loads content, // creating a seizure-inducing effect, or at the very least, a migraine with aura). webView.setBackgroundColor(ContextCompat.getColor(getContext(), getThemedAttributeId(getActivity(), R.attr.page_background_color))); bridge = new CommunicationBridge(webView, "file:///android_asset/index.html"); setupMessageHandlers(); sendDecorOffsetMessage(); // make sure styles get injected before the DarkModeMarshaller and other handlers if (app.isCurrentThemeDark()) { new DarkModeMarshaller(bridge).turnOn(true); } errorView.setRetryClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (callback() != null) { callback().onPageLoadErrorRetry(); } refreshPage(); } }); errorView.setBackClickListener(new View.OnClickListener() { @Override public void onClick(View v) { boolean back = onBackPressed(); // Needed if we're coming from another activity or fragment if (!back && callback() != null) { // noinspection ConstantConditions callback().onPageLoadErrorBackPressed(); } } }); editHandler = new EditHandler(this, bridge); pageFragmentLoadState.setEditHandler(editHandler); tocHandler = new ToCHandler(this, tocDrawer, bridge); // TODO: initialize View references in onCreateView(). PageHeaderView pageHeaderView = findById(getView(), R.id.page_header_view); leadImagesHandler = new LeadImagesHandler(this, bridge, webView, pageHeaderView); toolbarHideHandler = getSearchBarHideHandler(); toolbarHideHandler.setScrollView(webView); shareHandler = new ShareHandler(this, bridge); tabsProvider = new TabsProvider(this, tabList); tabsProvider.setTabsProviderListener(tabsProviderListener); if (callback() != null) { LongPressHandler.WebViewContextMenuListener contextMenuListener = new PageFragmentLongPressHandler(callback()); new LongPressHandler(webView, HistoryEntry.SOURCE_INTERNAL_LINK, contextMenuListener); } pageFragmentLoadState.setUp(model, this, refreshView, webView, bridge, toolbarHideHandler, leadImagesHandler, getCurrentTab().getBackStack()); if (callback() != null) { if (savedInstanceState != null || callback().shouldLoadFromBackStack()) { pageFragmentLoadState.loadFromBackStack(); } if (callback().shouldShowTabList()) { showTabList(); } } } private void initWebViewListeners() { webView.addOnUpOrCancelMotionEventListener(new ObservableWebView.OnUpOrCancelMotionEventListener() { @Override public void onUpOrCancelMotionEvent() { // update our session, since it's possible for the user to remain on the page for // a long time, and we wouldn't want the session to time out. app.getSessionFunnel().touchSession(); } }); webView.addOnScrollChangeListener(new ObservableWebView.OnScrollChangeListener() { @Override public void onScrollChanged(int oldScrollY, int scrollY, boolean isHumanScroll) { if (pageScrollFunnel != null) { pageScrollFunnel.onPageScrolled(oldScrollY, scrollY, isHumanScroll); } } }); webView.setWebViewClient(new OkHttpWebViewClient()); } private void handleInternalLink(PageTitle title) { if (!isResumed()) { return; } // If it's a Special page, launch it in an external browser, since mobileview // doesn't support the Special namespace. // TODO: remove when Special pages are properly returned by the server // If this is a Talk page also show in external browser since we don't handle those pages // in the app very well at this time. if (title.isSpecial() || title.isTalkPage()) { visitInExternalBrowser(getActivity(), Uri.parse(title.getMobileUri())); return; } dismissBottomSheet(); if (title.namespace() != Namespace.MAIN || !app.isLinkPreviewEnabled()) { HistoryEntry historyEntry = new HistoryEntry(title, HistoryEntry.SOURCE_INTERNAL_LINK); loadPage(title, historyEntry); } else { showLinkPreview(title, HistoryEntry.SOURCE_INTERNAL_LINK); } } private TabsProvider.TabsProviderListener tabsProviderListener = new TabsProvider.TabsProviderListener() { @Override public void onEnterTabView() { tabFunnel = new TabFunnel(); tabFunnel.logEnterList(tabList.size()); leadImagesHandler.setAnimationPaused(true); } @Override public void onCancelTabView() { tabsProvider.exitTabMode(); tabFunnel.logCancel(tabList.size()); leadImagesHandler.setAnimationPaused(false); if (tabsProvider.shouldPopFragment()) { Callback callback = callback(); if (callback != null) { callback.onPagePopFragment(); } } } @Override public void onTabSelected(int position) { // move the selected tab to the bottom of the list, and navigate to it! // (but only if it's a different tab than the one currently in view! if (position != tabList.size() - 1) { Tab tab = tabList.remove(position); tabList.add(tab); tabsProvider.invalidate(); pageFragmentLoadState.updateCurrentBackStackItem(); pageFragmentLoadState.setBackStack(tab.getBackStack()); pageFragmentLoadState.loadFromBackStack(); } tabsProvider.exitTabMode(); tabFunnel.logSelect(tabList.size(), position); leadImagesHandler.setAnimationPaused(false); } @Override public void onNewTabRequested() { // just load the main page into a new tab... loadMainPageInForegroundTab(); tabFunnel.logCreateNew(tabList.size()); // Set the current tab to the new opened tab tabsProvider.exitTabMode(); leadImagesHandler.setAnimationPaused(false); } @Override public void onCloseTabRequested(int position) { if (!ReleaseUtil.isDevRelease() && (position < 0 || position >= tabList.size())) { // According to T109998, the position may possibly be out-of-bounds, but we can't // reproduce it. We'll handle this case, but only for non-dev builds, so that we // can investigate the issue further if we happen upon it ourselves. return; } tabList.remove(position); tabFunnel.logClose(tabList.size(), position); tabsProvider.invalidate(); getActivity().supportInvalidateOptionsMenu(); if (tabList.size() == 0) { tabFunnel.logCancel(tabList.size()); tabsProvider.exitTabMode(); // and if the last tab was closed, then finish the activity! if (!tabsProvider.shouldPopFragment()) { getActivity().finish(); } } else if (position == tabList.size()) { // if it's the topmost tab, then load the topmost page in the next tab. pageFragmentLoadState.setBackStack(getCurrentTab().getBackStack()); pageFragmentLoadState.loadFromBackStack(); } } @Override public void onCloseAllTabs() { tabList.clear(); Prefs.clearTabs(); getActivity().finish(); } }; @Override public void onPause() { super.onPause(); activeTimer.pause(); addTimeSpentReading(activeTimer.getElapsedSec()); pageFragmentLoadState.updateCurrentBackStackItem(); Prefs.setTabs(tabList); closePageScrollFunnel(); long time = tabList.size() >= 1 && !pageFragmentLoadState.backStackEmpty() ? System.currentTimeMillis() : 0; Prefs.pageLastShown(time); } @Override public void onResume() { super.onResume(); initPageScrollFunnel(); activeTimer.resume(); } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); sendDecorOffsetMessage(); // if the screen orientation changes, then re-layout the lead image container, // but only if we've finished fetching the page. if (!pageFragmentLoadState.isLoading() && !errorState) { pageFragmentLoadState.layoutLeadImage(); } tabsProvider.onConfigurationChanged(); } public Tab getCurrentTab() { return tabList.get(tabList.size() - 1); } public void invalidateTabs() { tabsProvider.invalidate(); } public void openInNewBackgroundTabFromMenu(PageTitle title, HistoryEntry entry) { if (noPagesOpen()) { openInNewForegroundTabFromMenu(title, entry); } else { openInNewTabFromMenu(title, entry, getBackgroundTabPosition()); tabsProvider.showAndHideTabs(); } } public void openInNewForegroundTabFromMenu(PageTitle title, HistoryEntry entry) { openInNewTabFromMenu(title, entry, getForegroundTabPosition()); pageFragmentLoadState.loadFromBackStack(); } public void openInNewTabFromMenu(PageTitle title, HistoryEntry entry, int position) { openInNewTab(title, entry, position); tabFunnel.logOpenInNew(tabList.size()); } public void loadPage(PageTitle title, HistoryEntry entry, boolean pushBackStack) { //is the new title the same as what's already being displayed? if (!getCurrentTab().getBackStack().isEmpty() && getCurrentTab().getBackStack().get(getCurrentTab().getBackStack().size() - 1) .getTitle().equals(title)) { if (model.getPage() == null) { pageFragmentLoadState.loadFromBackStack(); } else if (!TextUtils.isEmpty(title.getFragment())) { scrollToSection(title.getFragment()); } return; } loadPage(title, entry, pushBackStack, 0); } public void loadPage(PageTitle title, HistoryEntry entry, boolean pushBackStack, int stagedScrollY) { loadPage(title, entry, pushBackStack, stagedScrollY, false); } public void loadPage(PageTitle title, HistoryEntry entry, boolean pushBackStack, boolean pageRefreshed) { loadPage(title, entry, pushBackStack, 0, pageRefreshed); } /** * Load a new page into the WebView in this fragment. * This shall be the single point of entry for loading content into the WebView, whether it's * loading an entirely new page, refreshing the current page, retrying a failed network * request, etc. * @param title Title of the new page to load. * @param entry HistoryEntry associated with the new page. * @param pushBackStack Whether to push the new page onto the backstack. */ public void loadPage(PageTitle title, HistoryEntry entry, boolean pushBackStack, int stagedScrollY, boolean pageRefreshed) { // update the time spent reading of the current page, before loading the new one addTimeSpentReading(activeTimer.getElapsedSec()); activeTimer.reset(); // disable sliding of the ToC while sections are loading tocHandler.setEnabled(false); errorState = false; errorView.setVisibility(View.GONE); tabLayout.enableAllTabs(); model.setTitle(title); model.setTitleOriginal(title); model.setCurEntry(entry); updateProgressBar(true, true, 0); this.pageRefreshed = pageRefreshed; closePageScrollFunnel(); pageFragmentLoadState.load(pushBackStack, stagedScrollY); updateBookmark(); } public Bitmap getLeadImageBitmap() { return leadImagesHandler.getLeadImageBitmap(); } /** * Update the WebView's base font size, based on the specified font size from the app * preferences. */ public void updateFontSize() { webView.getSettings().setDefaultFontSize((int) app.getFontSize(getActivity().getWindow())); } public void updateBookmark() { ReadingList.DAO.anyListContainsTitleAsync(ReadingListDaoProxy.key(getTitle()), new CallbackTask.DefaultCallback<ReadingListPage>() { @Override public void success(@Nullable ReadingListPage page) { if (!isAdded()) { return; } if (page != null) { pageActionTabsCallback.updateBookmark(true); page.touch(); ReadingListPageDao.instance().upsert(page); if (page.isOffline()) { // TODO: mark the page outdated only if the revision ID from the server // is newer than the one on disk. ReadingListPageDao.instance().markOutdated(page); } } else { pageActionTabsCallback.updateBookmark(false); } } }); } public void onActionModeShown(CompatActionMode mode) { // make sure we have a page loaded, since shareHandler makes references to it. if (model.getPage() != null) { shareHandler.onTextSelected(mode); } } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == Constants.ACTIVITY_REQUEST_EDIT_SECTION && resultCode == EditHandler.RESULT_REFRESH_PAGE) { pageFragmentLoadState.backFromEditing(data); FeedbackUtil.showMessage(getActivity(), R.string.edit_saved_successfully); // and reload the page... loadPage(model.getTitleOriginal(), model.getCurEntry(), false); } else if (requestCode == Constants.ACTIVITY_REQUEST_DESCRIPTION_EDIT_TUTORIAL && resultCode == RESULT_OK) { PrefsOnboardingStateMachine.getInstance().setDescriptionEditTutorial(); startDescriptionEditActivity(); } else if (requestCode == Constants.ACTIVITY_REQUEST_DESCRIPTION_EDIT && resultCode == RESULT_OK) { refreshPage(); } } public void startDescriptionEditActivity() { startActivityForResult(DescriptionEditActivity.newIntent(getContext(), getTitle()), Constants.ACTIVITY_REQUEST_DESCRIPTION_EDIT); } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { if (!isAdded()) { return; } if (!isSearching()) { inflater.inflate(R.menu.menu_page_actions, menu); } } @Override public void onPrepareOptionsMenu(Menu menu) { super.onPrepareOptionsMenu(menu); if (!isAdded() || isSearching() || !(getHostTopFragment() instanceof PageFragment)) { return; } MenuItem otherLangItem = menu.findItem(R.id.menu_page_other_languages); MenuItem shareItem = menu.findItem(R.id.menu_page_share); MenuItem addToListItem = menu.findItem(R.id.menu_page_add_to_list); MenuItem findInPageItem = menu.findItem(R.id.menu_page_find_in_page); MenuItem contentIssues = menu.findItem(R.id.menu_page_content_issues); MenuItem similarTitles = menu.findItem(R.id.menu_page_similar_titles); MenuItem themeChooserItem = menu.findItem(R.id.menu_page_font_and_theme); MenuItem tabsItem = menu.findItem(R.id.menu_page_show_tabs); tabsItem.setIcon(ResourceUtil.getTabListIcon(tabList.size())); if (pageFragmentLoadState.isLoading() || errorState) { otherLangItem.setEnabled(false); shareItem.setEnabled(false); addToListItem.setEnabled(false); findInPageItem.setEnabled(false); contentIssues.setEnabled(false); similarTitles.setEnabled(false); themeChooserItem.setEnabled(false); } else { // Only display "Read in other languages" if the article is in other languages otherLangItem.setVisible(model.getPage() != null && model.getPage().getPageProperties().getLanguageCount() != 0); otherLangItem.setEnabled(true); shareItem.setEnabled(model.getPage() != null && model.getPage().isArticle()); addToListItem.setEnabled(model.getPage() != null && model.getPage().isArticle()); findInPageItem.setEnabled(true); updateMenuPageInfo(menu); themeChooserItem.setEnabled(true); } } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.homeAsUp: // TODO SEARCH: add up navigation, see also http://developer.android.com/training/implementing-navigation/ancestral.html return true; case R.id.menu_page_other_languages: startLangLinksActivity(); return true; case R.id.menu_page_share: sharePageLink(); return true; case R.id.menu_page_add_to_list: addToReadingList(AddToReadingListDialog.InvokeSource.PAGE_OVERFLOW_MENU); return true; case R.id.menu_page_find_in_page: showFindInPage(); return true; case R.id.menu_page_content_issues: showContentIssues(); return true; case R.id.menu_page_similar_titles: showSimilarTitles(); return true; case R.id.menu_page_font_and_theme: showThemeChooser(); return true; case R.id.menu_page_show_tabs: tabsProvider.enterTabMode(false); return true; case R.id.menu_page_search: if (callback() != null) { callback().onPageSearchRequested(); } return true; default: return super.onOptionsItemSelected(item); } } public void sharePageLink() { if (getPage() != null) { ShareUtil.shareText(getActivity(), getPage().getTitle()); } } public void showFindInPage() { if (model.getPage() == null) { return; } final FindInPageFunnel funnel = new FindInPageFunnel(app, model.getTitle().getWikiSite(), model.getPage().getPageProperties().getPageId()); final FindInPageActionProvider findInPageActionProvider = new FindInPageActionProvider(this, funnel); startSupportActionMode(new ActionMode.Callback() { private final String actionModeTag = "actionModeFindInPage"; @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { findInPageActionMode = mode; MenuItem menuItem = menu.add(R.string.menu_page_find_in_page); MenuItemCompat.setActionProvider(menuItem, findInPageActionProvider); return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { mode.setTag(actionModeTag); return false; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { return false; } @Override public void onDestroyActionMode(ActionMode mode) { findInPageActionMode = null; funnel.setPageHeight(webView.getContentHeight()); funnel.logDone(); webView.clearMatches(); showToolbar(); hideSoftKeyboard(); } }); } public boolean closeFindInPage() { if (findInPageActionMode != null) { findInPageActionMode.finish(); return true; } return false; } /** * Scroll to a specific section in the WebView. * @param sectionAnchor Anchor link of the section to scroll to. */ public void scrollToSection(String sectionAnchor) { if (!isAdded() || tocHandler == null) { return; } tocHandler.scrollToSection(sectionAnchor); } public void onPageLoadComplete() { refreshView.setEnabled(true); if (callback() != null) { callback().onPageInvalidateOptionsMenu(); } setupToC(model, pageFragmentLoadState.isFirstPage()); editHandler.setPage(model.getPage()); initPageScrollFunnel(); // TODO: update this title in the db to be queued for saving by the service. checkAndShowSelectTextOnboarding(); if (getPageLoadCallbacks() != null) { getPageLoadCallbacks().onLoadComplete(); } } public void onPageLoadError(Throwable caught) { if (!isAdded()) { return; } // in any case, make sure the TOC drawer is closed tocDrawer.closeDrawers(); updateProgressBar(false, true, 0); refreshView.setRefreshing(false); if (pageRefreshed) { pageRefreshed = false; } hidePageContent(); errorView.setError(caught); errorView.setVisibility(View.VISIBLE); View contentTopOffset = errorView.findViewById(R.id.view_wiki_error_article_content_top_offset); View tabLayoutOffset = errorView.findViewById(R.id.view_wiki_error_article_tab_layout_offset); contentTopOffset.setLayoutParams(getContentTopOffsetParams(getContext())); contentTopOffset.setVisibility(View.VISIBLE); tabLayoutOffset.setLayoutParams(getTabLayoutOffsetParams()); tabLayoutOffset.setVisibility(View.VISIBLE); disableActionTabs(caught); refreshView.setEnabled(!ThrowableUtil.is404(caught)); errorState = true; if (callback() != null) { callback().onPageLoadError(getTitle()); } if (getPageLoadCallbacks() != null) { getPageLoadCallbacks().onLoadError(caught); } } public void refreshPage() { if (pageFragmentLoadState.isLoading()) { refreshView.setRefreshing(false); return; } errorView.setVisibility(View.GONE); tabLayout.enableAllTabs(); errorState = false; model.setCurEntry(new HistoryEntry(model.getTitle(), HistoryEntry.SOURCE_HISTORY)); loadPage(model.getTitle(), model.getCurEntry(), false, true); } private ToCHandler tocHandler; public void toggleToC(int action) { // tocHandler could still be null while the page is loading if (tocHandler == null) { return; } switch (action) { case TOC_ACTION_SHOW: tocHandler.show(); break; case TOC_ACTION_HIDE: tocHandler.hide(); break; case TOC_ACTION_TOGGLE: if (tocHandler.isVisible()) { tocHandler.hide(); } else { tocHandler.show(); } break; default: throw new RuntimeException("Unknown action!"); } } private void setupToC(PageViewModel model, boolean isFirstPage) { tocHandler.setupToC(model.getPage(), model.getTitle().getWikiSite(), isFirstPage); tocHandler.setEnabled(true); } private void updateMenuPageInfo(@NonNull Menu menu) { MenuItem contentIssues = menu.findItem(R.id.menu_page_content_issues); MenuItem similarTitles = menu.findItem(R.id.menu_page_similar_titles); contentIssues.setVisible(pageInfo != null && pageInfo.hasContentIssues()); contentIssues.setEnabled(true); similarTitles.setVisible(pageInfo != null && pageInfo.hasSimilarTitles()); similarTitles.setEnabled(true); } private void setBookmarkIconForPageSavedState(boolean pageSaved) { pageSavedToList = pageSaved; TabLayout.Tab bookmarkTab = tabLayout.getTabAt(PageActionTab.ADD_TO_READING_LIST.code()); if (bookmarkTab != null) { bookmarkTab.setIcon(pageSaved ? R.drawable.ic_bookmark_white_24dp : R.drawable.ic_bookmark_border_white_24dp); } } private void showContentIssues() { showPageInfoDialog(false); } private void showSimilarTitles() { showPageInfoDialog(true); } private void showPageInfoDialog(boolean startAtDisambig) { showBottomSheet(new PageInfoDialog(this, pageInfo, startAtDisambig)); } private void showTabList() { // Doesn't seem to be a way around doing a post() here... // Without post(), the tab picker layout is inflated with wrong dimensions. webView.post(new Runnable() { @Override public void run() { tabsProvider.enterTabMode(true); } }); } private void openInNewTab(PageTitle title, HistoryEntry entry, int position) { if (shouldCreateNewTab()) { // create a new tab Tab tab = new Tab(); // if the requested position is at the top, then make its backstack current if (position == getForegroundTabPosition()) { pageFragmentLoadState.setBackStack(tab.getBackStack()); } // put this tab in the requested position tabList.add(position, tab); trimTabCount(); tabsProvider.invalidate(); // add the requested page to its backstack tab.getBackStack().add(new PageBackStackItem(title, entry)); getActivity().supportInvalidateOptionsMenu(); } else { getTopMostTab().getBackStack().add(new PageBackStackItem(title, entry)); } } private boolean noPagesOpen() { return tabList.isEmpty() || (tabList.size() == 1 && tabList.get(0).getBackStack().isEmpty()); } private Tab getTopMostTab() { return tabList.get(tabList.size() - 1); } private boolean shouldCreateNewTab() { return !getTopMostTab().getBackStack().isEmpty(); } private int getBackgroundTabPosition() { return Math.max(0, getForegroundTabPosition() - 1); } private int getForegroundTabPosition() { return tabList.size(); } private void setupMessageHandlers() { linkHandler = new LinkHandler(getActivity()) { @Override public void onPageLinkClicked(String anchor) { dismissBottomSheet(); JSONObject payload = new JSONObject(); try { payload.put("anchor", anchor); } catch (JSONException e) { throw new RuntimeException(e); } bridge.sendMessage("handleReference", payload); } @Override public void onInternalLinkClicked(PageTitle title) { handleInternalLink(title); } @Override public WikiSite getWikiSite() { return model.getTitle().getWikiSite(); } }; bridge.addListener("linkClicked", linkHandler); bridge.addListener("referenceClicked", new ReferenceHandler() { @Override protected void onReferenceClicked(String refHtml) { if (!isAdded()) { Log.d("PageFragment", "Detached from activity, so stopping reference click."); return; } showBottomSheet(new ReferenceDialog(getActivity(), linkHandler, refHtml)); } }); bridge.addListener("ipaSpan", new CommunicationBridge.JSEventListener() { @Override public void onMessage(String messageType, JSONObject messagePayload) { try { String text = messagePayload.getString("contents"); final int textSize = 30; TextView textView = new TextView(getActivity()); textView.setGravity(Gravity.CENTER); textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize); textView.setText(StringUtil.fromHtml(text)); AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); builder.setView(textView); builder.show(); } catch (JSONException e) { L.logRemoteErrorIfProd(e); } } }); bridge.addListener("imageClicked", new CommunicationBridge.JSEventListener() { @Override public void onMessage(String messageType, JSONObject messagePayload) { try { String href = decodeURL(messagePayload.getString("href")); if (href.startsWith("/wiki/")) { String filename = UriUtil.removeInternalLinkPrefix(href); WikiSite wiki = model.getTitle().getWikiSite(); startActivityForResult(GalleryActivity.newIntent(getContext(), model.getTitleOriginal(), filename, wiki, GalleryFunnel.SOURCE_NON_LEAD_IMAGE), Constants.ACTIVITY_REQUEST_GALLERY); } else { linkHandler.onUrlClick(href, messagePayload.optString("title")); } } catch (JSONException e) { L.logRemoteErrorIfProd(e); } } }); bridge.addListener("mediaClicked", new CommunicationBridge.JSEventListener() { @Override public void onMessage(String messageType, JSONObject messagePayload) { try { String href = decodeURL(messagePayload.getString("href")); String filename = StringUtil.removeUnderscores(UriUtil.removeInternalLinkPrefix(href)); WikiSite wiki = model.getTitle().getWikiSite(); startActivityForResult(GalleryActivity.newIntent(getContext(), model.getTitleOriginal(), filename, wiki, GalleryFunnel.SOURCE_NON_LEAD_IMAGE), Constants.ACTIVITY_REQUEST_GALLERY); } catch (JSONException e) { L.logRemoteErrorIfProd(e); } } }); } /** * Convenience method for hiding all the content of a page. */ private void hidePageContent() { leadImagesHandler.hide(); toolbarHideHandler.setFadeEnabled(false); pageFragmentLoadState.onHidePageContent(); webView.setVisibility(View.INVISIBLE); } @Override public boolean onBackPressed() { if (tocHandler != null && tocHandler.isVisible()) { tocHandler.hide(); return true; } if (closeFindInPage()) { return true; } if (pageFragmentLoadState.popBackStack()) { return true; } if (tabsProvider.onBackPressed()) { return true; } if (tabList.size() > 1) { // if we're at the end of the current tab's backstack, then pop the current tab. tabList.remove(tabList.size() - 1); tabsProvider.invalidate(); } return false; } public LinkHandler getLinkHandler() { return linkHandler; } public void updatePageInfo(@Nullable PageInfo pageInfo) { this.pageInfo = pageInfo; if (getActivity() != null) { getActivity().supportInvalidateOptionsMenu(); } } private void checkAndShowSelectTextOnboarding() { if (model.getPage().isArticle() && app.getOnboardingStateMachine().isSelectTextTutorialEnabled()) { showSelectTextOnboarding(); } } private void showSelectTextOnboarding() { final View targetView = getView().findViewById(R.id.fragment_page_tool_tip_select_text_target); targetView.postDelayed(new Runnable() { @Override public void run() { if (getActivity() != null) { ToolTipUtil.showToolTip(getActivity(), targetView, R.layout.inflate_tool_tip_select_text, ToolTip.Position.CENTER); app.getOnboardingStateMachine().setSelectTextTutorial(); } } }, TimeUnit.SECONDS.toMillis(1)); } private void initTabs() { if (Prefs.hasTabs()) { tabList.addAll(Prefs.getTabs()); } if (tabList.isEmpty()) { tabList.add(new Tab()); } } private void sendDecorOffsetMessage() { JSONObject payload = new JSONObject(); try { payload.put("offset", getContentTopOffset(getActivity())); } catch (JSONException e) { throw new RuntimeException(e); } bridge.sendMessage("setDecorOffset", payload); } private void initPageScrollFunnel() { if (model.getPage() != null) { pageScrollFunnel = new PageScrollFunnel(app, model.getPage().getPageProperties().getPageId()); } } private void closePageScrollFunnel() { if (pageScrollFunnel != null && webView.getContentHeight() > 0) { pageScrollFunnel.setViewportHeight(webView.getHeight()); pageScrollFunnel.setPageHeight(webView.getContentHeight()); pageScrollFunnel.logDone(); } pageScrollFunnel = null; } private class PageFragmentLongPressHandler extends PageContainerLongPressHandler implements LongPressHandler.WebViewContextMenuListener { PageFragmentLongPressHandler(@NonNull PageFragment.Callback callback) { super(callback); } @Override public WikiSite getWikiSite() { return model.getTitleOriginal().getWikiSite(); } } public void showBottomSheet(@NonNull BottomSheetDialog dialog) { Callback callback = callback(); if (callback != null) { callback.onPageShowBottomSheet(dialog); } } public void showBottomSheet(@NonNull BottomSheetDialogFragment dialog) { Callback callback = callback(); if (callback != null) { callback.onPageShowBottomSheet(dialog); } } private void dismissBottomSheet() { Callback callback = callback(); if (callback != null) { callback.onPageDismissBottomSheet(); } } @Nullable public PageToolbarHideHandler getSearchBarHideHandler() { PageToolbarHideHandler handler = null; Callback callback = callback(); if (callback != null) { handler = callback.onPageGetToolbarHideHandler(); } return handler; } public void loadPage(@NonNull PageTitle title, @NonNull HistoryEntry entry) { Callback callback = callback(); if (callback != null) { callback.onPageLoadPage(title, entry); } } private void showLinkPreview(@NonNull PageTitle title, int source) { Callback callback = callback(); if (callback != null) { callback.onPageShowLinkPreview(title, source); } } private void loadMainPageInForegroundTab() { Callback callback = callback(); if (callback != null) { callback.onPageLoadMainPageInForegroundTab(); } } private void updateProgressBar(boolean visible, boolean indeterminate, int value) { Callback callback = callback(); if (callback != null) { callback.onPageUpdateProgressBar(visible, indeterminate, value); } } private boolean isSearching() { boolean isSearching = false; Callback callback = callback(); if (callback != null) { isSearching = callback.onPageIsSearching(); } return isSearching; } @Nullable private Fragment getHostTopFragment() { Fragment fragment = null; Callback callback = callback(); if (callback != null) { fragment = callback.onPageGetTopFragment(); } return fragment; } private void showThemeChooser() { Callback callback = callback(); if (callback != null) { callback.onPageShowThemeChooser(); } } @Nullable public ActionMode startSupportActionMode(@NonNull ActionMode.Callback actionModeCallback) { ActionMode actionMode = null; Callback callback = callback(); if (callback != null) { actionMode = callback.onPageStartSupportActionMode(actionModeCallback); } return actionMode; } public void showToolbar() { Callback callback = callback(); if (callback != null) { callback.onPageShowToolbar(); } } public void hideSoftKeyboard() { Callback callback = callback(); if (callback != null) { callback.onPageHideSoftKeyboard(); } } @Nullable private PageLoadCallbacks getPageLoadCallbacks() { PageLoadCallbacks callbacks = null; Callback callback = callback(); if (callback != null) { callbacks = callback.onPageGetPageLoadCallbacks(); } return callbacks; } public void addToReadingList(@NonNull AddToReadingListDialog.InvokeSource source) { Callback callback = callback(); if (callback != null) { callback.onPageAddToReadingList(getTitle(), source); } } @Nullable public View getContentView() { View view = null; Callback callback = callback(); if (callback != null) { view = callback.onPageGetContentView(); } return view; } @Nullable public View getTabsContainerView() { View view = null; Callback callback = callback(); if (callback != null) { view = callback.onPageGetTabsContainerView(); } return view; } private void startLangLinksActivity() { Intent langIntent = new Intent(); langIntent.setClass(getActivity(), LangLinksActivity.class); langIntent.setAction(LangLinksActivity.ACTION_LANGLINKS_FOR_TITLE); langIntent.putExtra(LangLinksActivity.EXTRA_PAGETITLE, model.getTitle()); getActivity().startActivityForResult(langIntent, Constants.ACTIVITY_REQUEST_LANGLINKS); } private void trimTabCount() { while (tabList.size() > Constants.MAX_TABS) { tabList.remove(0); } } private void addTimeSpentReading(int timeSpentSec) { if (model.getCurEntry() == null) { return; } model.setCurEntry(new HistoryEntry(model.getCurEntry().getTitle(), new Date(), model.getCurEntry().getSource(), timeSpentSec)); new UpdateHistoryTask(model.getCurEntry(), app).execute(); } private LinearLayout.LayoutParams getContentTopOffsetParams(@NonNull Context context) { return new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, getContentTopOffsetPx(context)); } private LinearLayout.LayoutParams getTabLayoutOffsetParams() { return new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, tabLayout.getHeight()); } private void disableActionTabs(@Nullable Throwable caught) { boolean offline = caught != null && isOffline(caught); for (int i = 0; i < tabLayout.getTabCount(); i++) { if (!(offline && PageActionTab.of(i).equals(PageActionTab.ADD_TO_READING_LIST))) { tabLayout.disableTab(i); } } } @Nullable public Callback callback() { return FragmentUtil.getCallback(this, Callback.class); } }