/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.toolbar; import java.util.Arrays; import java.util.EnumSet; import java.util.List; import android.support.v4.content.ContextCompat; import org.mozilla.gecko.AboutPages; import org.mozilla.gecko.BrowserApp; import org.mozilla.gecko.R; import org.mozilla.gecko.reader.ReaderModeUtils; import org.mozilla.gecko.SiteIdentity; import org.mozilla.gecko.SiteIdentity.MixedMode; import org.mozilla.gecko.SiteIdentity.SecurityMode; import org.mozilla.gecko.SiteIdentity.TrackingMode; import org.mozilla.gecko.Tab; import org.mozilla.gecko.animation.PropertyAnimator; import org.mozilla.gecko.animation.ViewHelper; import org.mozilla.gecko.toolbar.BrowserToolbarTabletBase.ForwardButtonAnimation; import org.mozilla.gecko.Experiments; import org.mozilla.gecko.util.HardwareUtils; import org.mozilla.gecko.util.StringUtils; import org.mozilla.gecko.widget.themed.ThemedLinearLayout; import org.mozilla.gecko.widget.themed.ThemedTextView; import android.content.Context; import android.os.SystemClock; import android.support.annotation.NonNull; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.ForegroundColorSpan; import android.util.AttributeSet; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.widget.Button; import android.widget.ImageButton; import com.keepsafe.switchboard.SwitchBoard; /** * {@code ToolbarDisplayLayout} is the UI for when the toolbar is in * display state. It's used to display the state of the currently selected * tab. It should always be updated through a single entry point * (updateFromTab) and should never track any tab events or gecko messages * on its own to keep it as dumb as possible. * * The UI has two possible modes: progress and display which are triggered * when UpdateFlags.PROGRESS is used depending on the current tab state. * The progress mode is triggered when the tab is loading a page. Display mode * is used otherwise. * * {@code ToolbarDisplayLayout} is meant to be owned by {@code BrowserToolbar} * which is the main event bus for the toolbar subsystem. */ public class ToolbarDisplayLayout extends ThemedLinearLayout { private static final String LOGTAG = "GeckoToolbarDisplayLayout"; private boolean mTrackingProtectionEnabled; // To be used with updateFromTab() to allow the caller // to give enough context for the requested state change. enum UpdateFlags { TITLE, FAVICON, PROGRESS, SITE_IDENTITY, PRIVATE_MODE, // Disable any animation that might be // triggered from this state change. Mostly // used on tab switches, see BrowserToolbar. DISABLE_ANIMATIONS } private enum UIMode { PROGRESS, DISPLAY } interface OnStopListener { Tab onStop(); } interface OnTitleChangeListener { void onTitleChange(CharSequence title); } private final BrowserApp mActivity; private UIMode mUiMode; private boolean mIsAttached; private final ThemedTextView mTitle; private final int mTitlePadding; private ToolbarPrefs mPrefs; private OnTitleChangeListener mTitleChangeListener; private final ImageButton mSiteSecurity; private final ImageButton mStop; private OnStopListener mStopListener; private final PageActionLayout mPageActionLayout; private final SiteIdentityPopup mSiteIdentityPopup; private int mSecurityImageLevel; // Security level constants, which map to the icons / levels defined in: // http://dxr.mozilla.org/mozilla-central/source/mobile/android/base/java/org/mozilla/gecko/resources/drawable/site_security_level.xml // Default level (unverified pages) - globe icon: private static final int LEVEL_DEFAULT_GLOBE = 0; // Levels for displaying Mixed Content state icons. private static final int LEVEL_WARNING_MINOR = 3; private static final int LEVEL_LOCK_DISABLED = 4; // Levels for displaying Tracking Protection state icons. private static final int LEVEL_SHIELD_ENABLED = 5; private static final int LEVEL_SHIELD_DISABLED = 6; // Icon used for about:home private static final int LEVEL_SEARCH_ICON = 999; private final ForegroundColorSpan mUrlColorSpan; private final ForegroundColorSpan mPrivateUrlColorSpan; private final ForegroundColorSpan mBlockedColorSpan; private final ForegroundColorSpan mDomainColorSpan; private final ForegroundColorSpan mPrivateDomainColorSpan; private final ForegroundColorSpan mCertificateOwnerColorSpan; public ToolbarDisplayLayout(Context context, AttributeSet attrs) { super(context, attrs); setOrientation(HORIZONTAL); mActivity = (BrowserApp) context; LayoutInflater.from(context).inflate(R.layout.toolbar_display_layout, this); mTitle = (ThemedTextView) findViewById(R.id.url_bar_title); mTitlePadding = mTitle.getPaddingRight(); mUrlColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_urltext)); mPrivateUrlColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_urltext_private)); mBlockedColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_blockedtext)); mDomainColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_domaintext)); mPrivateDomainColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_domaintext_private)); mCertificateOwnerColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.affirmative_green)); mSiteSecurity = (ImageButton) findViewById(R.id.site_security); mSiteIdentityPopup = new SiteIdentityPopup(mActivity); mSiteIdentityPopup.setAnchor(this); mSiteIdentityPopup.setOnVisibilityChangeListener(mActivity); mStop = (ImageButton) findViewById(R.id.stop); mPageActionLayout = (PageActionLayout) findViewById(R.id.page_action_layout); } @Override public void onAttachedToWindow() { super.onAttachedToWindow(); mIsAttached = true; mSiteSecurity.setOnClickListener(new Button.OnClickListener() { @Override public void onClick(View view) { mSiteIdentityPopup.show(); } }); mStop.setOnClickListener(new Button.OnClickListener() { @Override public void onClick(View v) { if (mStopListener != null) { // Force toolbar to switch to Display mode // immediately based on the stopped tab. final Tab tab = mStopListener.onStop(); if (tab != null) { updateUiMode(UIMode.DISPLAY); } } } }); } @Override public void onDetachedFromWindow() { super.onDetachedFromWindow(); mIsAttached = false; } @Override public void setNextFocusDownId(int nextId) { mStop.setNextFocusDownId(nextId); mSiteSecurity.setNextFocusDownId(nextId); mPageActionLayout.setNextFocusDownId(nextId); } void setToolbarPrefs(final ToolbarPrefs prefs) { mPrefs = prefs; } void updateFromTab(@NonNull Tab tab, EnumSet flags) { // Several parts of ToolbarDisplayLayout's state depends // on the views being attached to the view tree. if (!mIsAttached) { return; } if (flags.contains(UpdateFlags.TITLE)) { updateTitle(tab); } if (flags.contains(UpdateFlags.SITE_IDENTITY)) { updateSiteIdentity(tab); } if (flags.contains(UpdateFlags.PROGRESS)) { updateProgress(tab); } if (flags.contains(UpdateFlags.PRIVATE_MODE)) { mTitle.setPrivateMode(tab.isPrivate()); } } void setTitle(CharSequence title) { mTitle.setText(title); if (mTitleChangeListener != null) { mTitleChangeListener.onTitleChange(title); } } private void updateTitle(@NonNull Tab tab) { // Keep the title unchanged if there's no selected tab, // or if the tab is entering reader mode. if (tab.isEnteringReaderMode()) { return; } final String url = tab.getURL(); // Setting a null title will ensure we just see the // "Enter Search or Address" placeholder text. if (AboutPages.isTitlelessAboutPage(url)) { setTitle(null); setContentDescription(null); return; } // Show the about:blocked page title in red, regardless of prefs if (tab.getErrorType() == Tab.ErrorType.BLOCKED) { final String title = tab.getDisplayTitle(); final SpannableStringBuilder builder = new SpannableStringBuilder(title); builder.setSpan(mBlockedColorSpan, 0, title.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); setTitle(builder); setContentDescription(null); return; } final String baseDomain = tab.getBaseDomain(); String strippedURL = stripAboutReaderURL(url); final boolean isHttpOrHttps = StringUtils.isHttpOrHttps(strippedURL); if (mPrefs.shouldTrimUrls()) { strippedURL = StringUtils.stripCommonSubdomains(StringUtils.stripScheme(strippedURL)); } // The URL bar does not support RTL currently (See bug 928688 and meta bug 702845). // Displaying a URL using RTL (or mixed) characters can lead to an undesired reordering // of elements of the URL. That's why we are forcing the URL to use LTR (bug 1284372). strippedURL = StringUtils.forceLTR(strippedURL); // This value is not visible to screen readers but we rely on it when running UI tests. Screen // readers will instead focus BrowserToolbar and read the "base domain" from there. UI tests // will read the content description to obtain the full URL for performing assertions. setContentDescription(strippedURL); final SiteIdentity siteIdentity = tab.getSiteIdentity(); if (siteIdentity.hasOwner() && SwitchBoard.isInExperiment(mActivity, Experiments.URLBAR_SHOW_EV_CERT_OWNER)) { // Show Owner of EV certificate as title updateTitleFromSiteIdentity(siteIdentity); } else if (isHttpOrHttps && !HardwareUtils.isTablet() && !TextUtils.isEmpty(baseDomain) && SwitchBoard.isInExperiment(mActivity, Experiments.URLBAR_SHOW_ORIGIN_ONLY)) { // Show just the base domain as title setTitle(baseDomain); } else { // Display full URL with base domain highlighted as title updateAndColorTitleFromFullURL(strippedURL, baseDomain, tab.isPrivate()); } } private void updateTitleFromSiteIdentity(SiteIdentity siteIdentity) { final String title; if (siteIdentity.hasCountry()) { title = String.format("%s (%s)", siteIdentity.getOwner(), siteIdentity.getCountry()); } else { title = siteIdentity.getOwner(); } final SpannableString spannable = new SpannableString(title); spannable.setSpan(mCertificateOwnerColorSpan, 0, title.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); setTitle(spannable); } private void updateAndColorTitleFromFullURL(String url, String baseDomain, boolean isPrivate) { if (TextUtils.isEmpty(baseDomain)) { setTitle(url); return; } int index = url.indexOf(baseDomain); if (index == -1) { setTitle(url); return; } final SpannableStringBuilder builder = new SpannableStringBuilder(url); builder.setSpan(isPrivate ? mPrivateUrlColorSpan : mUrlColorSpan, 0, url.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); builder.setSpan(isPrivate ? mPrivateDomainColorSpan : mDomainColorSpan, index, index + baseDomain.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); setTitle(builder); } private String stripAboutReaderURL(final String url) { if (!AboutPages.isAboutReader(url)) { return url; } return ReaderModeUtils.stripAboutReaderUrl(url); } private void updateSiteIdentity(@NonNull Tab tab) { final SiteIdentity siteIdentity = tab.getSiteIdentity(); mSiteIdentityPopup.setSiteIdentity(siteIdentity); final SecurityMode securityMode; final MixedMode activeMixedMode; final MixedMode displayMixedMode; final TrackingMode trackingMode; if (siteIdentity == null) { securityMode = SecurityMode.UNKNOWN; activeMixedMode = MixedMode.UNKNOWN; displayMixedMode = MixedMode.UNKNOWN; trackingMode = TrackingMode.UNKNOWN; } else { securityMode = siteIdentity.getSecurityMode(); activeMixedMode = siteIdentity.getMixedModeActive(); displayMixedMode = siteIdentity.getMixedModeDisplay(); trackingMode = siteIdentity.getTrackingMode(); } // This is a bit tricky, but we have one icon and three potential indicators. // Default to the identity level int imageLevel = securityMode.ordinal(); // about: pages should default to having no icon too (the same as SecurityMode.UNKNOWN), however // SecurityMode.CHROMEUI has a different ordinal - hence we need to manually reset it here. // (We then continue and process the tracking / mixed content icons as usual, even for about: pages, as they // can still load external sites.) if (securityMode == SecurityMode.CHROMEUI) { imageLevel = LEVEL_DEFAULT_GLOBE; // == SecurityMode.UNKNOWN.ordinal() } // Check to see if any protection was overridden first if (AboutPages.isTitlelessAboutPage(tab.getURL())) { // We always want to just show a search icon on about:home imageLevel = LEVEL_SEARCH_ICON; } else if (trackingMode == TrackingMode.TRACKING_CONTENT_LOADED) { imageLevel = LEVEL_SHIELD_DISABLED; } else if (trackingMode == TrackingMode.TRACKING_CONTENT_BLOCKED) { imageLevel = LEVEL_SHIELD_ENABLED; } else if (activeMixedMode == MixedMode.MIXED_CONTENT_LOADED) { imageLevel = LEVEL_LOCK_DISABLED; } else if (displayMixedMode == MixedMode.MIXED_CONTENT_LOADED) { imageLevel = LEVEL_WARNING_MINOR; } if (mSecurityImageLevel != imageLevel) { mSecurityImageLevel = imageLevel; mSiteSecurity.setImageLevel(mSecurityImageLevel); updatePageActions(); } mTrackingProtectionEnabled = trackingMode == TrackingMode.TRACKING_CONTENT_BLOCKED; } private void updateProgress(@NonNull Tab tab) { final boolean shouldShowThrobber = tab.getState() == Tab.STATE_LOADING; updateUiMode(shouldShowThrobber ? UIMode.PROGRESS : UIMode.DISPLAY); if (Tab.STATE_SUCCESS == tab.getState() && mTrackingProtectionEnabled) { mActivity.showTrackingProtectionPromptIfApplicable(); } } private void updateUiMode(UIMode uiMode) { if (mUiMode == uiMode) { return; } mUiMode = uiMode; // The "Throbber start" and "Throbber stop" log messages in this method // are needed by S1/S2 tests (http://mrcote.info/phonedash/#). // See discussion in Bug 804457. Bug 805124 tracks paring these down. if (mUiMode == UIMode.PROGRESS) { Log.i(LOGTAG, "zerdatime " + SystemClock.uptimeMillis() + " - Throbber start"); } else { Log.i(LOGTAG, "zerdatime " + SystemClock.uptimeMillis() + " - Throbber stop"); } updatePageActions(); } private void updatePageActions() { final boolean isShowingProgress = (mUiMode == UIMode.PROGRESS); mStop.setVisibility(isShowingProgress ? View.VISIBLE : View.GONE); mPageActionLayout.setVisibility(!isShowingProgress ? View.VISIBLE : View.GONE); // We want title to fill the whole space available for it when there are icons // being shown on the right side of the toolbar as the icons already have some // padding in them. This is just to avoid wasting space when icons are shown. mTitle.setPadding(0, 0, (!isShowingProgress ? mTitlePadding : 0), 0); } List getFocusOrder() { return Arrays.asList(mSiteSecurity, mPageActionLayout, mStop); } void setOnStopListener(OnStopListener listener) { mStopListener = listener; } void setOnTitleChangeListener(OnTitleChangeListener listener) { mTitleChangeListener = listener; } /** * Update the Site Identity popup anchor. * * Tablet UI has a tablet-specific doorhanger anchor, so update it after all the views * are inflated. * @param view View to use as the anchor for the Site Identity popup. */ void updateSiteIdentityAnchor(View view) { mSiteIdentityPopup.setAnchor(view); } void prepareForwardAnimation(PropertyAnimator anim, ForwardButtonAnimation animation, int width) { if (animation == ForwardButtonAnimation.HIDE) { // We animate these items individually, rather than this entire view, // so that we don't animate certain views, e.g. the stop button. anim.attach(mTitle, PropertyAnimator.Property.TRANSLATION_X, 0); anim.attach(mSiteSecurity, PropertyAnimator.Property.TRANSLATION_X, 0); // We're hiding the forward button. We're going to reset the margin before // the animation starts, so we shift these items to the right so that they don't // appear to move initially. ViewHelper.setTranslationX(mTitle, width); ViewHelper.setTranslationX(mSiteSecurity, width); } else { anim.attach(mTitle, PropertyAnimator.Property.TRANSLATION_X, width); anim.attach(mSiteSecurity, PropertyAnimator.Property.TRANSLATION_X, width); } } void finishForwardAnimation() { ViewHelper.setTranslationX(mTitle, 0); ViewHelper.setTranslationX(mSiteSecurity, 0); } void prepareStartEditingAnimation() { // Hide page actions/stop buttons immediately ViewHelper.setAlpha(mPageActionLayout, 0); ViewHelper.setAlpha(mStop, 0); } void prepareStopEditingAnimation(PropertyAnimator anim) { // Fade toolbar buttons (page actions, stop) after the entry // is shrunk back to its original size. anim.attach(mPageActionLayout, PropertyAnimator.Property.ALPHA, 1); anim.attach(mStop, PropertyAnimator.Property.ALPHA, 1); } boolean dismissSiteIdentityPopup() { if (mSiteIdentityPopup != null && mSiteIdentityPopup.isShowing()) { mSiteIdentityPopup.dismiss(); return true; } return false; } void destroy() { mSiteIdentityPopup.destroy(); } }