blob: 7f19d0e6c25c89f1e3fc244aba386e02179db698 [file] [log] [blame]
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.qs;
import static android.app.admin.DevicePolicyManager.DEVICE_OWNER_TYPE_FINANCED;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
import static com.android.systemui.qs.dagger.QSFragmentModule.QS_SECURITY_FOOTER_VIEW;
import android.app.AlertDialog;
import android.app.admin.DeviceAdminInfo;
import android.app.admin.DevicePolicyEventLogger;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.UserInfo;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.UserManager;
import android.provider.Settings;
import android.text.SpannableStringBuilder;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.VisibleForTesting;
import com.android.internal.util.FrameworkStatsLog;
import com.android.systemui.FontSizeUtils;
import com.android.systemui.R;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.qs.dagger.QSScope;
import com.android.systemui.settings.UserTracker;
import com.android.systemui.statusbar.phone.SystemUIDialog;
import com.android.systemui.statusbar.policy.SecurityController;
import javax.inject.Inject;
import javax.inject.Named;
@QSScope
class QSSecurityFooter implements OnClickListener, DialogInterface.OnClickListener {
protected static final String TAG = "QSSecurityFooter";
protected static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
private static final boolean DEBUG_FORCE_VISIBLE = false;
private final View mRootView;
private final TextView mFooterText;
private final ImageView mPrimaryFooterIcon;
private final Context mContext;
private final Callback mCallback = new Callback();
private final SecurityController mSecurityController;
private final ActivityStarter mActivityStarter;
private final Handler mMainHandler;
private final UserTracker mUserTracker;
private AlertDialog mDialog;
private QSTileHost mHost;
protected H mHandler;
private boolean mIsVisible;
private CharSequence mFooterTextContent = null;
private int mFooterIconId;
private Drawable mPrimaryFooterIconDrawable;
@Inject
QSSecurityFooter(@Named(QS_SECURITY_FOOTER_VIEW) View rootView,
UserTracker userTracker, @Main Handler mainHandler, ActivityStarter activityStarter,
SecurityController securityController, @Background Looper bgLooper) {
mRootView = rootView;
mRootView.setOnClickListener(this);
mFooterText = mRootView.findViewById(R.id.footer_text);
mPrimaryFooterIcon = mRootView.findViewById(R.id.primary_footer_icon);
mFooterIconId = R.drawable.ic_info_outline;
mContext = rootView.getContext();
mMainHandler = mainHandler;
mActivityStarter = activityStarter;
mSecurityController = securityController;
mHandler = new H(bgLooper);
mUserTracker = userTracker;
}
public void setHostEnvironment(QSTileHost host) {
mHost = host;
}
public void setListening(boolean listening) {
if (listening) {
mSecurityController.addCallback(mCallback);
refreshState();
} else {
mSecurityController.removeCallback(mCallback);
}
}
public void onConfigurationChanged() {
FontSizeUtils.updateFontSize(mFooterText, R.dimen.qs_tile_text_size);
Resources r = mContext.getResources();
mFooterText.setMaxLines(r.getInteger(R.integer.qs_security_footer_maxLines));
int padding = r.getDimensionPixelSize(R.dimen.qs_footer_padding);
mRootView.setPaddingRelative(padding, padding, padding, padding);
int bottomMargin = r.getDimensionPixelSize(R.dimen.qs_footers_margin_bottom);
ViewGroup.MarginLayoutParams lp =
(ViewGroup.MarginLayoutParams) mRootView.getLayoutParams();
lp.bottomMargin = bottomMargin;
lp.width = r.getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT
? MATCH_PARENT : WRAP_CONTENT;
mRootView.setLayoutParams(lp);
mRootView.setBackground(mContext.getDrawable(R.drawable.qs_security_footer_background));
}
public View getView() {
return mRootView;
}
public boolean hasFooter() {
return mRootView.getVisibility() != View.GONE;
}
@Override
public void onClick(View v) {
if (!hasFooter()) return;
mHandler.sendEmptyMessage(H.CLICK);
}
private void handleClick() {
showDeviceMonitoringDialog();
DevicePolicyEventLogger
.createEvent(FrameworkStatsLog.DEVICE_POLICY_EVENT__EVENT_ID__DO_USER_INFO_CLICKED)
.write();
}
public void showDeviceMonitoringDialog() {
createDialog();
}
public void refreshState() {
mHandler.sendEmptyMessage(H.REFRESH_STATE);
}
private void handleRefreshState() {
final boolean isDeviceManaged = mSecurityController.isDeviceManaged();
final UserInfo currentUser = mUserTracker.getUserInfo();
final boolean isDemoDevice = UserManager.isDeviceInDemoMode(mContext) && currentUser != null
&& currentUser.isDemo();
final boolean hasWorkProfile = mSecurityController.hasWorkProfile();
final boolean hasCACerts = mSecurityController.hasCACertInCurrentUser();
final boolean hasCACertsInWorkProfile = mSecurityController.hasCACertInWorkProfile();
final boolean isNetworkLoggingEnabled = mSecurityController.isNetworkLoggingEnabled();
final String vpnName = mSecurityController.getPrimaryVpnName();
final String vpnNameWorkProfile = mSecurityController.getWorkProfileVpnName();
final CharSequence organizationName = mSecurityController.getDeviceOwnerOrganizationName();
final CharSequence workProfileOrganizationName =
mSecurityController.getWorkProfileOrganizationName();
final boolean isProfileOwnerOfOrganizationOwnedDevice =
mSecurityController.isProfileOwnerOfOrganizationOwnedDevice();
final boolean isParentalControlsEnabled = mSecurityController.isParentalControlsEnabled();
final boolean isWorkProfileOn = mSecurityController.isWorkProfileOn();
final boolean hasDisclosableWorkProfilePolicy = hasCACertsInWorkProfile
|| vpnNameWorkProfile != null || (hasWorkProfile && isNetworkLoggingEnabled);
// Update visibility of footer
mIsVisible = (isDeviceManaged && !isDemoDevice)
|| hasCACerts
|| vpnName != null
|| isProfileOwnerOfOrganizationOwnedDevice
|| isParentalControlsEnabled
|| (hasDisclosableWorkProfilePolicy && isWorkProfileOn);
// Update the view to be untappable if the device is an organization-owned device with a
// managed profile and there is either:
// a) no policy set which requires a privacy disclosure.
// b) a specific work policy set but the work profile is turned off.
if (mIsVisible && isProfileOwnerOfOrganizationOwnedDevice
&& (!hasDisclosableWorkProfilePolicy || !isWorkProfileOn)) {
mRootView.setClickable(false);
mRootView.findViewById(R.id.footer_icon).setVisibility(View.GONE);
} else {
mRootView.setClickable(true);
mRootView.findViewById(R.id.footer_icon).setVisibility(View.VISIBLE);
}
// Update the string
mFooterTextContent = getFooterText(isDeviceManaged, hasWorkProfile,
hasCACerts, hasCACertsInWorkProfile, isNetworkLoggingEnabled, vpnName,
vpnNameWorkProfile, organizationName, workProfileOrganizationName,
isProfileOwnerOfOrganizationOwnedDevice, isParentalControlsEnabled,
isWorkProfileOn);
// Update the icon
int footerIconId = R.drawable.ic_info_outline;
if (vpnName != null || vpnNameWorkProfile != null) {
if (mSecurityController.isVpnBranded()) {
footerIconId = R.drawable.stat_sys_branded_vpn;
} else {
footerIconId = R.drawable.stat_sys_vpn_ic;
}
}
if (mFooterIconId != footerIconId) {
mFooterIconId = footerIconId;
}
// Update the primary icon
if (isParentalControlsEnabled) {
if (mPrimaryFooterIconDrawable == null) {
DeviceAdminInfo info = mSecurityController.getDeviceAdminInfo();
mPrimaryFooterIconDrawable = mSecurityController.getIcon(info);
}
} else {
mPrimaryFooterIconDrawable = null;
}
mMainHandler.post(mUpdatePrimaryIcon);
mMainHandler.post(mUpdateDisplayState);
}
protected CharSequence getFooterText(boolean isDeviceManaged, boolean hasWorkProfile,
boolean hasCACerts, boolean hasCACertsInWorkProfile, boolean isNetworkLoggingEnabled,
String vpnName, String vpnNameWorkProfile, CharSequence organizationName,
CharSequence workProfileOrganizationName,
boolean isProfileOwnerOfOrganizationOwnedDevice, boolean isParentalControlsEnabled,
boolean isWorkProfileOn) {
if (isParentalControlsEnabled) {
return mContext.getString(R.string.quick_settings_disclosure_parental_controls);
}
if (isDeviceManaged || DEBUG_FORCE_VISIBLE) {
if (hasCACerts || hasCACertsInWorkProfile || isNetworkLoggingEnabled) {
if (organizationName == null) {
return mContext.getString(
R.string.quick_settings_disclosure_management_monitoring);
}
return mContext.getString(
R.string.quick_settings_disclosure_named_management_monitoring,
organizationName);
}
if (vpnName != null && vpnNameWorkProfile != null) {
if (organizationName == null) {
return mContext.getString(R.string.quick_settings_disclosure_management_vpns);
}
return mContext.getString(R.string.quick_settings_disclosure_named_management_vpns,
organizationName);
}
if (vpnName != null || vpnNameWorkProfile != null) {
if (organizationName == null) {
return mContext.getString(
R.string.quick_settings_disclosure_management_named_vpn,
vpnName != null ? vpnName : vpnNameWorkProfile);
}
return mContext.getString(
R.string.quick_settings_disclosure_named_management_named_vpn,
organizationName,
vpnName != null ? vpnName : vpnNameWorkProfile);
}
if (organizationName == null) {
return mContext.getString(R.string.quick_settings_disclosure_management);
}
if (isFinancedDevice()) {
return mContext.getString(
R.string.quick_settings_financed_disclosure_named_management,
organizationName);
} else {
return mContext.getString(R.string.quick_settings_disclosure_named_management,
organizationName);
}
} // end if(isDeviceManaged)
if (hasCACertsInWorkProfile && isWorkProfileOn) {
if (workProfileOrganizationName == null) {
return mContext.getString(
R.string.quick_settings_disclosure_managed_profile_monitoring);
}
return mContext.getString(
R.string.quick_settings_disclosure_named_managed_profile_monitoring,
workProfileOrganizationName);
}
if (hasCACerts) {
return mContext.getString(R.string.quick_settings_disclosure_monitoring);
}
if (vpnName != null && vpnNameWorkProfile != null) {
return mContext.getString(R.string.quick_settings_disclosure_vpns);
}
if (vpnNameWorkProfile != null && isWorkProfileOn) {
return mContext.getString(R.string.quick_settings_disclosure_managed_profile_named_vpn,
vpnNameWorkProfile);
}
if (vpnName != null) {
if (hasWorkProfile) {
return mContext.getString(
R.string.quick_settings_disclosure_personal_profile_named_vpn,
vpnName);
}
return mContext.getString(R.string.quick_settings_disclosure_named_vpn,
vpnName);
}
if (hasWorkProfile && isNetworkLoggingEnabled && isWorkProfileOn) {
return mContext.getString(
R.string.quick_settings_disclosure_managed_profile_network_activity);
}
if (isProfileOwnerOfOrganizationOwnedDevice) {
if (workProfileOrganizationName == null) {
return mContext.getString(R.string.quick_settings_disclosure_management);
}
return mContext.getString(R.string.quick_settings_disclosure_named_management,
workProfileOrganizationName);
}
return null;
}
@Override
public void onClick(DialogInterface dialog, int which) {
if (which == DialogInterface.BUTTON_NEGATIVE) {
final Intent intent = new Intent(Settings.ACTION_ENTERPRISE_PRIVACY_SETTINGS);
mDialog.dismiss();
// This dismisses the shade on opening the activity
mActivityStarter.postStartActivityDismissingKeyguard(intent, 0);
}
}
private void createDialog() {
mDialog = new SystemUIDialog(mContext, 0); // Use mContext theme
mDialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
mDialog.setButton(DialogInterface.BUTTON_POSITIVE, getPositiveButton(), this);
mDialog.setButton(DialogInterface.BUTTON_NEGATIVE, getNegativeButton(), this);
mDialog.setView(createDialogView());
mDialog.show();
mDialog.getWindow().setLayout(MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
@VisibleForTesting
View createDialogView() {
if (mSecurityController.isParentalControlsEnabled()) {
return createParentalControlsDialogView();
}
return createOrganizationDialogView();
}
private View createOrganizationDialogView() {
final boolean isDeviceManaged = mSecurityController.isDeviceManaged();
final boolean hasWorkProfile = mSecurityController.hasWorkProfile();
final CharSequence deviceOwnerOrganization =
mSecurityController.getDeviceOwnerOrganizationName();
final boolean hasCACerts = mSecurityController.hasCACertInCurrentUser();
final boolean hasCACertsInWorkProfile = mSecurityController.hasCACertInWorkProfile();
final boolean isNetworkLoggingEnabled = mSecurityController.isNetworkLoggingEnabled();
final String vpnName = mSecurityController.getPrimaryVpnName();
final String vpnNameWorkProfile = mSecurityController.getWorkProfileVpnName();
View dialogView = LayoutInflater.from(mContext)
.inflate(R.layout.quick_settings_footer_dialog, null, false);
// device management section
TextView deviceManagementSubtitle =
dialogView.findViewById(R.id.device_management_subtitle);
deviceManagementSubtitle.setText(getManagementTitle(deviceOwnerOrganization));
CharSequence managementMessage = getManagementMessage(isDeviceManaged,
deviceOwnerOrganization);
if (managementMessage == null) {
dialogView.findViewById(R.id.device_management_disclosures).setVisibility(View.GONE);
} else {
dialogView.findViewById(R.id.device_management_disclosures).setVisibility(View.VISIBLE);
TextView deviceManagementWarning =
(TextView) dialogView.findViewById(R.id.device_management_warning);
deviceManagementWarning.setText(managementMessage);
mDialog.setButton(DialogInterface.BUTTON_NEGATIVE, getSettingsButton(), this);
}
// ca certificate section
CharSequence caCertsMessage = getCaCertsMessage(isDeviceManaged, hasCACerts,
hasCACertsInWorkProfile);
if (caCertsMessage == null) {
dialogView.findViewById(R.id.ca_certs_disclosures).setVisibility(View.GONE);
} else {
dialogView.findViewById(R.id.ca_certs_disclosures).setVisibility(View.VISIBLE);
TextView caCertsWarning = (TextView) dialogView.findViewById(R.id.ca_certs_warning);
caCertsWarning.setText(caCertsMessage);
// Make "Open trusted credentials"-link clickable
caCertsWarning.setMovementMethod(new LinkMovementMethod());
}
// network logging section
CharSequence networkLoggingMessage = getNetworkLoggingMessage(isDeviceManaged,
isNetworkLoggingEnabled);
if (networkLoggingMessage == null) {
dialogView.findViewById(R.id.network_logging_disclosures).setVisibility(View.GONE);
} else {
dialogView.findViewById(R.id.network_logging_disclosures).setVisibility(View.VISIBLE);
TextView networkLoggingWarning =
(TextView) dialogView.findViewById(R.id.network_logging_warning);
networkLoggingWarning.setText(networkLoggingMessage);
}
// vpn section
CharSequence vpnMessage = getVpnMessage(isDeviceManaged, hasWorkProfile, vpnName,
vpnNameWorkProfile);
if (vpnMessage == null) {
dialogView.findViewById(R.id.vpn_disclosures).setVisibility(View.GONE);
} else {
dialogView.findViewById(R.id.vpn_disclosures).setVisibility(View.VISIBLE);
TextView vpnWarning = (TextView) dialogView.findViewById(R.id.vpn_warning);
vpnWarning.setText(vpnMessage);
// Make "Open VPN Settings"-link clickable
vpnWarning.setMovementMethod(new LinkMovementMethod());
}
// Note: if a new section is added, should update configSubtitleVisibility to include
// the handling of the subtitle
configSubtitleVisibility(managementMessage != null,
caCertsMessage != null,
networkLoggingMessage != null,
vpnMessage != null,
dialogView);
return dialogView;
}
private View createParentalControlsDialogView() {
View dialogView = LayoutInflater.from(mContext)
.inflate(R.layout.quick_settings_footer_dialog_parental_controls, null, false);
DeviceAdminInfo info = mSecurityController.getDeviceAdminInfo();
Drawable icon = mSecurityController.getIcon(info);
if (icon != null) {
ImageView imageView = (ImageView) dialogView.findViewById(R.id.parental_controls_icon);
imageView.setImageDrawable(icon);
}
TextView parentalControlsTitle =
(TextView) dialogView.findViewById(R.id.parental_controls_title);
parentalControlsTitle.setText(mSecurityController.getLabel(info));
return dialogView;
}
protected void configSubtitleVisibility(boolean showDeviceManagement, boolean showCaCerts,
boolean showNetworkLogging, boolean showVpn, View dialogView) {
// Device Management title should always been shown
// When there is a Device Management message, all subtitles should be shown
if (showDeviceManagement) {
return;
}
// Hide the subtitle if there is only 1 message shown
int mSectionCountExcludingDeviceMgt = 0;
if (showCaCerts) { mSectionCountExcludingDeviceMgt++; }
if (showNetworkLogging) { mSectionCountExcludingDeviceMgt++; }
if (showVpn) { mSectionCountExcludingDeviceMgt++; }
// No work needed if there is no sections or more than 1 section
if (mSectionCountExcludingDeviceMgt != 1) {
return;
}
if (showCaCerts) {
dialogView.findViewById(R.id.ca_certs_subtitle).setVisibility(View.GONE);
}
if (showNetworkLogging) {
dialogView.findViewById(R.id.network_logging_subtitle).setVisibility(View.GONE);
}
if (showVpn) {
dialogView.findViewById(R.id.vpn_subtitle).setVisibility(View.GONE);
}
}
@VisibleForTesting
String getSettingsButton() {
return mContext.getString(R.string.monitoring_button_view_policies);
}
private String getPositiveButton() {
return mContext.getString(R.string.ok);
}
private String getNegativeButton() {
if (mSecurityController.isParentalControlsEnabled()) {
return mContext.getString(R.string.monitoring_button_view_controls);
}
return null;
}
protected CharSequence getManagementMessage(boolean isDeviceManaged,
CharSequence organizationName) {
if (!isDeviceManaged) {
return null;
}
if (organizationName != null) {
if (isFinancedDevice()) {
return mContext.getString(R.string.monitoring_financed_description_named_management,
organizationName, organizationName);
} else {
return mContext.getString(
R.string.monitoring_description_named_management, organizationName);
}
}
return mContext.getString(R.string.monitoring_description_management);
}
protected CharSequence getCaCertsMessage(boolean isDeviceManaged, boolean hasCACerts,
boolean hasCACertsInWorkProfile) {
if (!(hasCACerts || hasCACertsInWorkProfile)) return null;
if (isDeviceManaged) {
return mContext.getString(R.string.monitoring_description_management_ca_certificate);
}
if (hasCACertsInWorkProfile) {
return mContext.getString(
R.string.monitoring_description_managed_profile_ca_certificate);
}
return mContext.getString(R.string.monitoring_description_ca_certificate);
}
protected CharSequence getNetworkLoggingMessage(boolean isDeviceManaged,
boolean isNetworkLoggingEnabled) {
if (!isNetworkLoggingEnabled) return null;
if (isDeviceManaged) {
return mContext.getString(R.string.monitoring_description_management_network_logging);
} else {
return mContext.getString(
R.string.monitoring_description_managed_profile_network_logging);
}
}
protected CharSequence getVpnMessage(boolean isDeviceManaged, boolean hasWorkProfile,
String vpnName, String vpnNameWorkProfile) {
if (vpnName == null && vpnNameWorkProfile == null) return null;
final SpannableStringBuilder message = new SpannableStringBuilder();
if (isDeviceManaged) {
if (vpnName != null && vpnNameWorkProfile != null) {
message.append(mContext.getString(R.string.monitoring_description_two_named_vpns,
vpnName, vpnNameWorkProfile));
} else {
message.append(mContext.getString(R.string.monitoring_description_named_vpn,
vpnName != null ? vpnName : vpnNameWorkProfile));
}
} else {
if (vpnName != null && vpnNameWorkProfile != null) {
message.append(mContext.getString(R.string.monitoring_description_two_named_vpns,
vpnName, vpnNameWorkProfile));
} else if (vpnNameWorkProfile != null) {
message.append(mContext.getString(
R.string.monitoring_description_managed_profile_named_vpn,
vpnNameWorkProfile));
} else if (hasWorkProfile) {
message.append(mContext.getString(
R.string.monitoring_description_personal_profile_named_vpn, vpnName));
} else {
message.append(mContext.getString(R.string.monitoring_description_named_vpn,
vpnName));
}
}
message.append(mContext.getString(R.string.monitoring_description_vpn_settings_separator));
message.append(mContext.getString(R.string.monitoring_description_vpn_settings),
new VpnSpan(), 0);
return message;
}
@VisibleForTesting
CharSequence getManagementTitle(CharSequence deviceOwnerOrganization) {
if (deviceOwnerOrganization != null && isFinancedDevice()) {
return mContext.getString(R.string.monitoring_title_financed_device,
deviceOwnerOrganization);
} else {
return mContext.getString(R.string.monitoring_title_device_owned);
}
}
private boolean isFinancedDevice() {
return mSecurityController.isDeviceManaged()
&& mSecurityController.getDeviceOwnerType(
mSecurityController.getDeviceOwnerComponentOnAnyUser())
== DEVICE_OWNER_TYPE_FINANCED;
}
private final Runnable mUpdatePrimaryIcon = new Runnable() {
@Override
public void run() {
if (mPrimaryFooterIconDrawable != null) {
mPrimaryFooterIcon.setImageDrawable(mPrimaryFooterIconDrawable);
} else {
mPrimaryFooterIcon.setImageResource(mFooterIconId);
}
}
};
private final Runnable mUpdateDisplayState = new Runnable() {
@Override
public void run() {
if (mFooterTextContent != null) {
mFooterText.setText(mFooterTextContent);
}
mRootView.setVisibility(mIsVisible || DEBUG_FORCE_VISIBLE ? View.VISIBLE : View.GONE);
}
};
private class Callback implements SecurityController.SecurityControllerCallback {
@Override
public void onStateChanged() {
refreshState();
}
}
private class H extends Handler {
private static final int CLICK = 0;
private static final int REFRESH_STATE = 1;
private H(Looper looper) {
super(looper);
}
@Override
public void handleMessage(Message msg) {
String name = null;
try {
if (msg.what == REFRESH_STATE) {
name = "handleRefreshState";
handleRefreshState();
} else if (msg.what == CLICK) {
name = "handleClick";
handleClick();
}
} catch (Throwable t) {
final String error = "Error in " + name;
Log.w(TAG, error, t);
mHost.warn(error, t);
}
}
}
protected class VpnSpan extends ClickableSpan {
@Override
public void onClick(View widget) {
final Intent intent = new Intent(Settings.ACTION_VPN_SETTINGS);
mDialog.dismiss();
// This dismisses the shade on opening the activity
mActivityStarter.postStartActivityDismissingKeyguard(intent, 0);
}
// for testing, to compare two CharSequences containing VpnSpans
@Override
public boolean equals(Object object) {
return object instanceof VpnSpan;
}
@Override
public int hashCode() {
return 314159257; // prime
}
}
}