package org.wikipedia.page; import android.content.Intent; import android.content.res.Resources; import android.os.Build; import android.support.annotation.DimenRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.SparseArray; import android.view.View; import android.view.ViewGroup; import org.json.JSONException; import org.json.JSONObject; import org.mediawiki.api.json.ApiException; import org.wikipedia.Constants; import org.wikipedia.R; import org.wikipedia.WikipediaApp; import org.wikipedia.bridge.CommunicationBridge; import org.wikipedia.database.contract.PageImageHistoryContract; import org.wikipedia.dataclient.ServiceError; import org.wikipedia.dataclient.mwapi.MwQueryResponse; import org.wikipedia.dataclient.page.PageClient; import org.wikipedia.dataclient.page.PageClientFactory; import org.wikipedia.dataclient.page.PageLead; import org.wikipedia.dataclient.page.PageRemaining; import org.wikipedia.edit.EditHandler; import org.wikipedia.edit.EditSectionActivity; import org.wikipedia.history.HistoryEntry; import org.wikipedia.login.User; import org.wikipedia.page.bottomcontent.BottomContentHandler; import org.wikipedia.page.bottomcontent.BottomContentInterface; import org.wikipedia.page.leadimages.LeadImagesHandler; import org.wikipedia.pageimages.PageImage; import org.wikipedia.pageimages.PageImagesClient; import org.wikipedia.util.DeviceUtil; import org.wikipedia.util.DimenUtil; import org.wikipedia.util.L10nUtil; import org.wikipedia.util.ReleaseUtil; import org.wikipedia.util.ResourceUtil; import org.wikipedia.util.log.L; import org.wikipedia.views.ObservableWebView; import org.wikipedia.views.SwipeRefreshLayoutWithScroll; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; import retrofit2.Call; import retrofit2.Response; import static org.wikipedia.util.DimenUtil.calculateLeadImageWidth; import static org.wikipedia.util.L10nUtil.getStringsForArticleLanguage; /** * Our old page load strategy, which uses the JSON MW API directly and loads a page in multiple steps: * First it loads the lead section (sections=0). * Then it loads the remaining sections (sections=1-). * <p/> * This class tracks: * - the states the page loading goes through, * - a backstack of pages and page positions visited, * - and many handlers. */ public class PageFragmentLoadState { private interface ErrorCallback { void call(@Nullable Throwable error); } private boolean loading; /** * List of lightweight history items to serve as the backstack for this fragment. * Since the list consists of Parcelable objects, it can be saved and restored from the * savedInstanceState of the fragment. */ @NonNull private List<PageBackStackItem> backStack = new ArrayList<>(); @NonNull private final SequenceNumber sequenceNumber = new SequenceNumber(); /** * The y-offset position to which the page will be scrolled once it's fully loaded * (or loaded to the point where it can be scrolled to the correct position). */ private int stagedScrollY; private int sectionTargetFromIntent; private String sectionTargetFromTitle; private ErrorCallback networkErrorCallback; // copied fields private PageViewModel model; private PageFragment fragment; private CommunicationBridge bridge; private ObservableWebView webView; private SwipeRefreshLayoutWithScroll refreshView; @NonNull private final WikipediaApp app = WikipediaApp.getInstance(); private LeadImagesHandler leadImagesHandler; private PageToolbarHideHandler toolbarHideHandler; private EditHandler editHandler; private BottomContentInterface bottomContentHandler; @SuppressWarnings("checkstyle:parameternumber") public void setUp(@NonNull PageViewModel model, @NonNull PageFragment fragment, @NonNull SwipeRefreshLayoutWithScroll refreshView, @NonNull ObservableWebView webView, @NonNull CommunicationBridge bridge, @NonNull PageToolbarHideHandler toolbarHideHandler, @NonNull LeadImagesHandler leadImagesHandler, @NonNull List<PageBackStackItem> backStack) { this.model = model; this.fragment = fragment; this.refreshView = refreshView; this.webView = webView; this.bridge = bridge; this.toolbarHideHandler = toolbarHideHandler; this.leadImagesHandler = leadImagesHandler; setUpBridgeListeners(); bottomContentHandler = new BottomContentHandler(fragment, bridge, webView, fragment.getLinkHandler(), (ViewGroup) fragment.getView().findViewById(R.id.bottom_content_container)); this.backStack = backStack; } public void load(boolean pushBackStack, int stagedScrollY) { if (pushBackStack) { // update the topmost entry in the backstack, before we start overwriting things. updateCurrentBackStackItem(); pushBackStack(); } loading = true; // increment our sequence number, so that any async tasks that depend on the sequence // will invalidate themselves upon completion. sequenceNumber.increase(); fragment.updatePageInfo(null); // kick off an event to the WebView that will cause it to clear its contents, // and then report back to us when the clearing is complete, so that we can synchronize // the transitions of our native components to the new page content. // The callback event from the WebView will then call the loadOnWebViewReady() // function, which will continue the loading process. leadImagesHandler.hide(); bottomContentHandler.hide(); fragment.getSearchBarHideHandler().setFadeEnabled(false); try { JSONObject wrapper = new JSONObject(); // whatever we pass to this event will be passed back to us by the WebView! wrapper.put("sequence", sequenceNumber.get()); wrapper.put("stagedScrollY", stagedScrollY); bridge.sendMessage("beginNewPage", wrapper); } catch (JSONException e) { L.logRemoteErrorIfProd(e); } } public boolean isLoading() { return loading; } public void loadFromBackStack() { if (backStack.isEmpty()) { return; } PageBackStackItem item = backStack.get(backStack.size() - 1); // display the page based on the backstack item, stage the scrollY position based on // the backstack item. fragment.loadPage(item.getTitle(), item.getHistoryEntry(), false, item.getScrollY()); L.d("Loaded page " + item.getTitle().getDisplayText() + " from backstack"); } public void updateCurrentBackStackItem() { if (backStack.isEmpty()) { return; } PageBackStackItem item = backStack.get(backStack.size() - 1); item.setScrollY(webView.getScrollY()); if (model.getTitle() != null) { // Preserve metadata of the current PageTitle into our backstack, so that // this data would be available immediately upon loading PageFragment, instead // of only after loading the lead section. item.getTitle().setDescription(model.getTitle().getDescription()); item.getTitle().setThumbUrl(model.getTitle().getThumbUrl()); } } public void setBackStack(@NonNull List<PageBackStackItem> backStack) { this.backStack = backStack; } public boolean popBackStack() { if (!backStack.isEmpty()) { backStack.remove(backStack.size() - 1); } if (!backStack.isEmpty()) { loadFromBackStack(); return true; } return false; } public boolean backStackEmpty() { return backStack.isEmpty(); } public void onHidePageContent() { bottomContentHandler.hide(); } public void setEditHandler(EditHandler editHandler) { this.editHandler = editHandler; } public void backFromEditing(Intent data) { //Retrieve section ID from intent, and find correct section, so where know where to scroll to sectionTargetFromIntent = data.getIntExtra(EditSectionActivity.EXTRA_SECTION_ID, 0); //reset our scroll offset, since we have a section scroll target stagedScrollY = 0; } public void layoutLeadImage() { leadImagesHandler.beginLayout(new LeadImagesHandler.OnLeadImageLayoutListener() { @Override public void onLayoutComplete(int sequence) { if (fragment.isAdded()) { toolbarHideHandler.setFadeEnabled(leadImagesHandler.isLeadImageEnabled()); } } }, sequenceNumber.get()); } public boolean isFirstPage() { return backStack.size() <= 1 && !webView.canGoBack(); } @VisibleForTesting protected void loadLeadSection(final int startSequenceNum) { app.getSessionFunnel().leadSectionFetchStart(); PageClientFactory .create(model.getTitle().getWikiSite(), model.getTitle().namespace()) .lead(null, PageClient.CacheOption.CACHE, model.getTitle().getPrefixedText(), calculateLeadImageWidth(), !app.isImageDownloadEnabled()) .enqueue(new retrofit2.Callback<PageLead>() { @Override public void onResponse(Call<PageLead> call, Response<PageLead> rsp) { app.getSessionFunnel().leadSectionFetchEnd(); PageLead lead = rsp.body(); onLeadSectionLoaded(lead, startSequenceNum); } @Override public void onFailure(Call<PageLead> call, Throwable t) { L.e("PageLead error: ", t); commonSectionFetchOnCatch(t, startSequenceNum); } }); } @VisibleForTesting protected void commonSectionFetchOnCatch(Throwable caught, int startSequenceNum) { ErrorCallback callback = networkErrorCallback; networkErrorCallback = null; loading = false; if (fragment.callback() != null) { fragment.callback().onPageInvalidateOptionsMenu(); } if (!sequenceNumber.inSync(startSequenceNum)) { return; } if (callback != null) { callback.call(caught); } } private void setUpBridgeListeners() { bridge.addListener("onBeginNewPage", new SynchronousBridgeListener() { @Override public void onMessage(JSONObject payload) { try { stagedScrollY = payload.getInt("stagedScrollY"); loadOnWebViewReady(); } catch (JSONException e) { L.logRemoteErrorIfProd(e); } } }); bridge.addListener("requestSection", new SynchronousBridgeListener() { @Override public void onMessage(JSONObject payload) { try { displayNonLeadSection(payload.getInt("index")); } catch (JSONException e) { L.logRemoteErrorIfProd(e); } } }); bridge.addListener("pageLoadComplete", new SynchronousBridgeListener() { @Override public void onMessage(JSONObject payload) { // Do any other stuff that should happen upon page load completion... if (fragment.callback() != null) { fragment.callback().onPageUpdateProgressBar(false, true, 0); } // trigger layout of the bottom content // Check to see if the page title has changed (e.g. due to following a redirect), // because if it has then the handler needs the new title to make sure it doesn't // accidentally display the current article as a "read more" suggestion bottomContentHandler.setTitle(model.getTitle()); bottomContentHandler.beginLayout(); } }); bridge.addListener("pageInfo", new CommunicationBridge.JSEventListener() { @Override public void onMessage(String message, JSONObject payload) { if (fragment.isAdded()) { PageInfo pageInfo = PageInfoUnmarshaller.unmarshal(model.getTitle(), model.getTitle().getWikiSite(), payload); fragment.updatePageInfo(pageInfo); } } }); } private void loadOnWebViewReady() { // stage any section-specific link target from the title, since the title may be // replaced (normalized) sectionTargetFromTitle = model.getTitle().getFragment(); L10nUtil.setupDirectionality(model.getTitle().getWikiSite().languageCode(), Locale.getDefault().getLanguage(), bridge); loadFromNetwork(new ErrorCallback() { @Override public void call(final Throwable networkError) { fragment.onPageLoadError(networkError); } }); } private void loadFromNetwork(final ErrorCallback errorCallback) { networkErrorCallback = errorCallback; if (!fragment.isAdded()) { return; } loading = true; if (fragment.callback() != null) { fragment.callback().onPageInvalidateOptionsMenu(); fragment.callback().onPageUpdateProgressBar(true, true, 0); } loadLeadSection(sequenceNumber.get()); } private void updateThumbnail(String thumbUrl) { model.getTitle().setThumbUrl(thumbUrl); model.getTitleOriginal().setThumbUrl(thumbUrl); fragment.invalidateTabs(); } /** * Push the current page title onto the backstack. */ private void pushBackStack() { PageBackStackItem item = new PageBackStackItem(model.getTitleOriginal(), model.getCurEntry()); backStack.add(item); } private void layoutLeadImage(@Nullable Runnable runnable) { leadImagesHandler.beginLayout(new LeadImageLayoutListener(runnable), sequenceNumber.get()); } private void displayLeadSection() { Page page = model.getPage(); sendMarginPayload(); sendLeadSectionPayload(page); sendMiscPayload(page); if (webView.getVisibility() != View.VISIBLE) { webView.setVisibility(View.VISIBLE); } refreshView.setRefreshing(false); if (fragment.callback() != null) { fragment.callback().onPageUpdateProgressBar(true, true, 0); } } private void sendMarginPayload() { JSONObject marginPayload = marginPayload(); bridge.sendMessage("setMargins", marginPayload); } private JSONObject marginPayload() { int horizontalMargin = DimenUtil.roundedPxToDp(getDimension(R.dimen.content_margin)); int verticalMargin = DimenUtil.roundedPxToDp(getDimension(R.dimen.activity_vertical_margin)); try { return new JSONObject() .put("marginTop", verticalMargin) .put("marginLeft", horizontalMargin) .put("marginRight", horizontalMargin); } catch (JSONException e) { throw new RuntimeException(e); } } private void sendLeadSectionPayload(Page page) { JSONObject leadSectionPayload = leadSectionPayload(page); bridge.sendMessage("displayLeadSection", leadSectionPayload); L.d("Sent message 'displayLeadSection' for page: " + page.getDisplayTitle()); } private JSONObject leadSectionPayload(Page page) { SparseArray<String> localizedStrings = localizedStrings(page); try { return new JSONObject() .put("sequence", sequenceNumber.get()) .put("title", page.getDisplayTitle()) .put("section", page.getSections().get(0).toJSON()) .put("string_table_infobox", localizedStrings.get(R.string.table_infobox)) .put("string_table_other", localizedStrings.get(R.string.table_other)) .put("string_table_close", localizedStrings.get(R.string.table_close)) .put("string_expand_refs", localizedStrings.get(R.string.expand_refs)) .put("isBeta", ReleaseUtil.isPreProdRelease()) // True for any non-production release type .put("siteLanguage", model.getTitle().getWikiSite().languageCode()) .put("siteBaseUrl", model.getTitle().getWikiSite().url()) .put("isMainPage", page.isMainPage()) .put("fromRestBase", page.isFromRestBase()) .put("isNetworkMetered", DeviceUtil.isNetworkMetered(app)) .put("apiLevel", Build.VERSION.SDK_INT); } catch (JSONException e) { throw new RuntimeException(e); } } private SparseArray<String> localizedStrings(Page page) { return getStringsForArticleLanguage(page.getTitle(), ResourceUtil.getIdArray(fragment.getContext(), R.array.page_localized_string_ids)); } private void sendMiscPayload(Page page) { JSONObject miscPayload = miscPayload(page); bridge.sendMessage("setPageProtected", miscPayload); } private JSONObject miscPayload(Page page) { try { return new JSONObject() .put("noedit", !isPageEditable(page)) // Controls whether edit pencils are visible. .put("protect", page.isProtected()); } catch (JSONException e) { throw new RuntimeException(e); } } private boolean isPageEditable(Page page) { return (User.isLoggedIn() || !isAnonEditingDisabled()) && !page.isFilePage() && !page.isMainPage(); } private boolean isAnonEditingDisabled() { return getRemoteConfig().optBoolean("disableAnonEditing", false); } private JSONObject getRemoteConfig() { return app.getRemoteConfig().getConfig(); } private void displayNonLeadSection(int index) { if (fragment.callback() != null) { fragment.callback().onPageUpdateProgressBar(true, false, Constants.PROGRESS_BAR_MAX_VALUE / model.getPage() .getSections().size() * index); } try { final Page page = model.getPage(); JSONObject wrapper = new JSONObject(); wrapper.put("sequence", sequenceNumber.get()); boolean lastSection = index == page.getSections().size(); if (!lastSection) { JSONObject section = page.getSections().get(index).toJSON(); wrapper.put("section", section); wrapper.put("index", index); if (sectionTargetFromIntent > 0 && sectionTargetFromIntent < page.getSections().size()) { //if we have a section to scroll to (from our Intent): wrapper.put("fragment", page.getSections().get(sectionTargetFromIntent).getAnchor()); } else if (sectionTargetFromTitle != null) { //if we have a section to scroll to (from our PageTitle): wrapper.put("fragment", sectionTargetFromTitle); } else if (!TextUtils.isEmpty(model.getTitle().getFragment())) { // It's possible, that the link was a redirect and the new title has a fragment // scroll to it, if there was no fragment so far wrapper.put("fragment", model.getTitle().getFragment()); } } else { wrapper.put("noMore", true); } //give it our expected scroll position, in case we need the page to be pre-scrolled upon loading. wrapper.put("scrollY", (int) (stagedScrollY / DimenUtil.getDensityScalar())); bridge.sendMessage("displaySection", wrapper); } catch (JSONException e) { L.logRemoteErrorIfProd(e); } } private void onLeadSectionLoaded(PageLead pageLead, int startSequenceNum) { if (!fragment.isAdded() || !sequenceNumber.inSync(startSequenceNum)) { return; } if (pageLead.hasError()) { ServiceError error = pageLead.getError(); if (error != null) { ApiException apiException = new ApiException(error.getTitle(), error.getDetails()); commonSectionFetchOnCatch(apiException, startSequenceNum); } else { ApiException apiException = new ApiException("unknown", "unexpected pageLead response"); commonSectionFetchOnCatch(apiException, startSequenceNum); } return; } Page page = pageLead.toPage(model.getTitle()); model.setPage(page); model.setTitle(page.getTitle()); editHandler.setPage(model.getPage()); layoutLeadImage(new Runnable() { @Override public void run() { if (!fragment.isAdded()) { return; } fragment.callback().onPageInvalidateOptionsMenu(); loadRemainingSections(sequenceNumber.get()); } }); // Update our history entry, in case the Title was changed (i.e. normalized) final HistoryEntry curEntry = model.getCurEntry(); model.setCurEntry( new HistoryEntry(model.getTitle(), curEntry.getTimestamp(), curEntry.getSource())); // Fetch larger thumbnail URL from the network, and save it to our DB. new PageImagesClient().request(model.getTitle().getWikiSite(), Collections.singletonList(model.getTitle()), new PageImagesClient.Callback() { @Override public void success(@NonNull Call<MwQueryResponse<MwQueryResponse.Pages>> call, @NonNull Map<PageTitle, PageImage> results) { if (results.containsKey(model.getTitle())) { PageImage pageImage = results.get(model.getTitle()); app.getDatabaseClient(PageImage.class) .upsert(pageImage, PageImageHistoryContract.Image.SELECTION); updateThumbnail(pageImage.getImageName()); } } @Override public void failure(@NonNull Call<MwQueryResponse<MwQueryResponse.Pages>> call, @NonNull Throwable caught) { L.w(caught); } }); } private void loadRemainingSections(final int startSequenceNum) { app.getSessionFunnel().restSectionsFetchStart(); PageClientFactory .create(model.getTitle().getWikiSite(), model.getTitle().namespace()) .sections(null, PageClient.CacheOption.CACHE, model.getTitle().getPrefixedText(), !app.isImageDownloadEnabled()) .enqueue(new retrofit2.Callback<PageRemaining>() { @Override public void onResponse(Call<PageRemaining> call, Response<PageRemaining> rsp) { app.getSessionFunnel().restSectionsFetchEnd(); PageRemaining sections = rsp.body(); onRemainingSectionsLoaded(sections, startSequenceNum); } @Override public void onFailure(Call<PageRemaining> call, Throwable t) { L.e("PageRemaining error: ", t); commonSectionFetchOnCatch(t, startSequenceNum); } }); } private void onRemainingSectionsLoaded(PageRemaining pageRemaining, int startSequenceNum) { networkErrorCallback = null; if (!fragment.isAdded() || !sequenceNumber.inSync(startSequenceNum)) { return; } pageRemaining.mergeInto(model.getPage()); displayNonLeadSection(1); loading = false; fragment.onPageLoadComplete(); } private float getDimension(@DimenRes int id) { return getResources().getDimension(id); } private Resources getResources() { return fragment.getResources(); } private class LeadImageLayoutListener implements LeadImagesHandler.OnLeadImageLayoutListener { @Nullable private final Runnable runnable; LeadImageLayoutListener(@Nullable Runnable runnable) { this.runnable = runnable; } @Override public void onLayoutComplete(int sequence) { if (!fragment.isAdded() || !sequenceNumber.inSync(sequence)) { return; } toolbarHideHandler.setFadeEnabled(leadImagesHandler.isLeadImageEnabled()); if (runnable != null) { // when the lead image is laid out, load the lead section and the rest // of the sections into the webview. displayLeadSection(); runnable.run(); } } } private abstract class SynchronousBridgeListener implements CommunicationBridge.JSEventListener { private static final String BRIDGE_PAYLOAD_SEQUENCE = "sequence"; @Override public void onMessage(String message, JSONObject payload) { if (fragment.isAdded() && inSync(payload)) { onMessage(payload); } } protected abstract void onMessage(JSONObject payload); private boolean inSync(JSONObject payload) { return sequenceNumber.inSync(payload.optInt(BRIDGE_PAYLOAD_SEQUENCE, sequenceNumber.get() - 1)); } } /** * Monotonically increasing sequence number to maintain synchronization when loading page * content asynchronously between the Java and JavaScript layers, as well as between synchronous * methods and asynchronous callbacks on the UI thread. */ private static class SequenceNumber { private int sequence; void increase() { ++sequence; } int get() { return sequence; } boolean inSync(int sequence) { return this.sequence == sequence; } } }