Home | History | Annotate | Download | only in datausage
      1 /*
      2  * Copyright (C) 2016 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
      5  * except in compliance with the License. You may obtain a copy of the License at
      6  *
      7  *      http://www.apache.org/licenses/LICENSE-2.0
      8  *
      9  * Unless required by applicable law or agreed to in writing, software distributed under the
     10  * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
     11  * KIND, either express or implied. See the License for the specific language governing
     12  * permissions and limitations under the License.
     13  */
     14 
     15 package com.android.settings.datausage;
     16 
     17 import static android.net.ConnectivityManager.TYPE_MOBILE;
     18 import static android.net.NetworkPolicyManager.POLICY_REJECT_METERED_BACKGROUND;
     19 import static android.net.TrafficStats.UID_REMOVED;
     20 import static android.net.TrafficStats.UID_TETHERING;
     21 import static android.telephony.TelephonyManager.SIM_STATE_READY;
     22 
     23 import android.app.ActivityManager;
     24 import android.app.LoaderManager.LoaderCallbacks;
     25 import android.content.Context;
     26 import android.content.Intent;
     27 import android.content.Loader;
     28 import android.content.pm.UserInfo;
     29 import android.graphics.Color;
     30 import android.net.ConnectivityManager;
     31 import android.net.INetworkStatsSession;
     32 import android.net.NetworkPolicy;
     33 import android.net.NetworkStats;
     34 import android.net.NetworkStatsHistory;
     35 import android.net.NetworkTemplate;
     36 import android.net.TrafficStats;
     37 import android.os.AsyncTask;
     38 import android.os.Bundle;
     39 import android.os.RemoteException;
     40 import android.os.SystemProperties;
     41 import android.os.UserHandle;
     42 import android.os.UserManager;
     43 import android.provider.Settings;
     44 import android.support.annotation.VisibleForTesting;
     45 import android.support.v7.preference.Preference;
     46 import android.support.v7.preference.PreferenceGroup;
     47 import android.telephony.SubscriptionInfo;
     48 import android.telephony.SubscriptionManager;
     49 import android.telephony.TelephonyManager;
     50 import android.text.format.DateUtils;
     51 import android.util.Log;
     52 import android.util.SparseArray;
     53 import android.view.View;
     54 import android.widget.AdapterView;
     55 import android.widget.AdapterView.OnItemSelectedListener;
     56 import android.widget.Spinner;
     57 
     58 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
     59 import com.android.settings.R;
     60 import com.android.settings.core.SubSettingLauncher;
     61 import com.android.settings.datausage.CycleAdapter.SpinnerInterface;
     62 import com.android.settings.widget.LoadingViewController;
     63 import com.android.settingslib.AppItem;
     64 import com.android.settingslib.net.ChartData;
     65 import com.android.settingslib.net.ChartDataLoader;
     66 import com.android.settingslib.net.SummaryForAllUidLoader;
     67 import com.android.settingslib.net.UidDetailProvider;
     68 
     69 import java.util.ArrayList;
     70 import java.util.Collections;
     71 import java.util.List;
     72 
     73 /**
     74  * Panel showing data usage history across various networks, including options
     75  * to inspect based on usage cycle and control through {@link NetworkPolicy}.
     76  */
     77 public class DataUsageList extends DataUsageBase {
     78 
     79     public static final String EXTRA_SUB_ID = "sub_id";
     80     public static final String EXTRA_NETWORK_TEMPLATE = "network_template";
     81 
     82     private static final String TAG = "DataUsage";
     83     private static final boolean LOGD = false;
     84 
     85     private static final String KEY_USAGE_AMOUNT = "usage_amount";
     86     private static final String KEY_CHART_DATA = "chart_data";
     87     private static final String KEY_APPS_GROUP = "apps_group";
     88 
     89     private static final int LOADER_CHART_DATA = 2;
     90     private static final int LOADER_SUMMARY = 3;
     91 
     92     private final CellDataPreference.DataStateListener mDataStateListener =
     93             new CellDataPreference.DataStateListener() {
     94                 @Override
     95                 public void onChange(boolean selfChange) {
     96                     updatePolicy();
     97                 }
     98             };
     99 
    100     private INetworkStatsSession mStatsSession;
    101     private ChartDataUsagePreference mChart;
    102 
    103     @VisibleForTesting
    104     NetworkTemplate mTemplate;
    105     @VisibleForTesting
    106     int mSubId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
    107     private ChartData mChartData;
    108 
    109     private LoadingViewController mLoadingViewController;
    110     private UidDetailProvider mUidDetailProvider;
    111     private CycleAdapter mCycleAdapter;
    112     private Spinner mCycleSpinner;
    113     private Preference mUsageAmount;
    114     private PreferenceGroup mApps;
    115     private View mHeader;
    116 
    117 
    118     @Override
    119     public int getMetricsCategory() {
    120         return MetricsEvent.DATA_USAGE_LIST;
    121     }
    122 
    123     @Override
    124     public void onCreate(Bundle savedInstanceState) {
    125         super.onCreate(savedInstanceState);
    126         final Context context = getActivity();
    127 
    128         if (!isBandwidthControlEnabled()) {
    129             Log.w(TAG, "No bandwidth control; leaving");
    130             getActivity().finish();
    131         }
    132 
    133         try {
    134             mStatsSession = services.mStatsService.openSession();
    135         } catch (RemoteException e) {
    136             throw new RuntimeException(e);
    137         }
    138 
    139         mUidDetailProvider = new UidDetailProvider(context);
    140 
    141         addPreferencesFromResource(R.xml.data_usage_list);
    142         mUsageAmount = findPreference(KEY_USAGE_AMOUNT);
    143         mChart = (ChartDataUsagePreference) findPreference(KEY_CHART_DATA);
    144         mApps = (PreferenceGroup) findPreference(KEY_APPS_GROUP);
    145         processArgument();
    146     }
    147 
    148     @Override
    149     public void onViewCreated(View v, Bundle savedInstanceState) {
    150         super.onViewCreated(v, savedInstanceState);
    151 
    152         mHeader = setPinnedHeaderView(R.layout.apps_filter_spinner);
    153         mHeader.findViewById(R.id.filter_settings).setOnClickListener(btn -> {
    154             final Bundle args = new Bundle();
    155             args.putParcelable(DataUsageList.EXTRA_NETWORK_TEMPLATE, mTemplate);
    156             new SubSettingLauncher(getContext())
    157                     .setDestination(BillingCycleSettings.class.getName())
    158                     .setTitle(R.string.billing_cycle)
    159                     .setSourceMetricsCategory(getMetricsCategory())
    160                     .setArguments(args)
    161                     .launch();
    162         });
    163         mCycleSpinner = mHeader.findViewById(R.id.filter_spinner);
    164         mCycleAdapter = new CycleAdapter(mCycleSpinner.getContext(), new SpinnerInterface() {
    165             @Override
    166             public void setAdapter(CycleAdapter cycleAdapter) {
    167                 mCycleSpinner.setAdapter(cycleAdapter);
    168             }
    169 
    170             @Override
    171             public void setOnItemSelectedListener(OnItemSelectedListener listener) {
    172                 mCycleSpinner.setOnItemSelectedListener(listener);
    173             }
    174 
    175             @Override
    176             public Object getSelectedItem() {
    177                 return mCycleSpinner.getSelectedItem();
    178             }
    179 
    180             @Override
    181             public void setSelection(int position) {
    182                 mCycleSpinner.setSelection(position);
    183             }
    184         }, mCycleListener, true);
    185 
    186         mLoadingViewController = new LoadingViewController(
    187                 getView().findViewById(R.id.loading_container), getListView());
    188         mLoadingViewController.showLoadingViewDelayed();
    189     }
    190 
    191     @Override
    192     public void onResume() {
    193         super.onResume();
    194         mDataStateListener.setListener(true, mSubId, getContext());
    195         updateBody();
    196 
    197         // kick off background task to update stats
    198         new AsyncTask<Void, Void, Void>() {
    199             @Override
    200             protected Void doInBackground(Void... params) {
    201                 try {
    202                     // wait a few seconds before kicking off
    203                     Thread.sleep(2 * DateUtils.SECOND_IN_MILLIS);
    204                     services.mStatsService.forceUpdate();
    205                 } catch (InterruptedException e) {
    206                 } catch (RemoteException e) {
    207                 }
    208                 return null;
    209             }
    210 
    211             @Override
    212             protected void onPostExecute(Void result) {
    213                 if (isAdded()) {
    214                     updateBody();
    215                 }
    216             }
    217         }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    218     }
    219 
    220     @Override
    221     public void onPause() {
    222         super.onPause();
    223         mDataStateListener.setListener(false, mSubId, getContext());
    224     }
    225 
    226     @Override
    227     public void onDestroy() {
    228         mUidDetailProvider.clearCache();
    229         mUidDetailProvider = null;
    230 
    231         TrafficStats.closeQuietly(mStatsSession);
    232 
    233         super.onDestroy();
    234     }
    235 
    236     void processArgument() {
    237         final Bundle args = getArguments();
    238         if (args != null) {
    239             mSubId = args.getInt(EXTRA_SUB_ID, SubscriptionManager.INVALID_SUBSCRIPTION_ID);
    240             mTemplate = args.getParcelable(EXTRA_NETWORK_TEMPLATE);
    241         }
    242         if (mTemplate == null && mSubId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) {
    243             final Intent intent = getIntent();
    244             mSubId = intent.getIntExtra(Settings.EXTRA_SUB_ID,
    245                     SubscriptionManager.INVALID_SUBSCRIPTION_ID);
    246             mTemplate = intent.getParcelableExtra(Settings.EXTRA_NETWORK_TEMPLATE);
    247         }
    248     }
    249 
    250     /**
    251      * Update body content based on current tab. Loads
    252      * {@link NetworkStatsHistory} and {@link NetworkPolicy} from system, and
    253      * binds them to visible controls.
    254      */
    255     private void updateBody() {
    256         if (!isAdded()) return;
    257 
    258         final Context context = getActivity();
    259 
    260         // kick off loader for network history
    261         // TODO: consider chaining two loaders together instead of reloading
    262         // network history when showing app detail.
    263         getLoaderManager().restartLoader(LOADER_CHART_DATA,
    264                 ChartDataLoader.buildArgs(mTemplate, null), mChartDataCallbacks);
    265 
    266         // detail mode can change visible menus, invalidate
    267         getActivity().invalidateOptionsMenu();
    268 
    269         int seriesColor = context.getColor(R.color.sim_noitification);
    270         if (mSubId != SubscriptionManager.INVALID_SUBSCRIPTION_ID) {
    271             final SubscriptionInfo sir = services.mSubscriptionManager
    272                     .getActiveSubscriptionInfo(mSubId);
    273 
    274             if (sir != null) {
    275                 seriesColor = sir.getIconTint();
    276             }
    277         }
    278 
    279         final int secondaryColor = Color.argb(127, Color.red(seriesColor), Color.green(seriesColor),
    280                 Color.blue(seriesColor));
    281         mChart.setColors(seriesColor, secondaryColor);
    282     }
    283 
    284     /**
    285      * Update chart sweeps and cycle list to reflect {@link NetworkPolicy} for
    286      * current {@link #mTemplate}.
    287      */
    288     private void updatePolicy() {
    289         final NetworkPolicy policy = services.mPolicyEditor.getPolicy(mTemplate);
    290         final View configureButton = mHeader.findViewById(R.id.filter_settings);
    291         //SUB SELECT
    292         if (isNetworkPolicyModifiable(policy, mSubId) && isMobileDataAvailable(mSubId)) {
    293             mChart.setNetworkPolicy(policy);
    294             configureButton.setVisibility(View.VISIBLE);
    295         } else {
    296             // controls are disabled; don't bind warning/limit sweeps
    297             mChart.setNetworkPolicy(null);
    298             configureButton.setVisibility(View.GONE);
    299         }
    300 
    301         // generate cycle list based on policy and available history
    302         if (mCycleAdapter.updateCycleList(policy, mChartData)) {
    303             updateDetailData();
    304         }
    305     }
    306 
    307     /**
    308      * Update details based on {@link #mChart} inspection range depending on
    309      * current mode. Updates {@link #mAdapter} with sorted list
    310      * of applications data usage.
    311      */
    312     private void updateDetailData() {
    313         if (LOGD) Log.d(TAG, "updateDetailData()");
    314 
    315         final long start = mChart.getInspectStart();
    316         final long end = mChart.getInspectEnd();
    317         final long now = System.currentTimeMillis();
    318 
    319         final Context context = getActivity();
    320 
    321         NetworkStatsHistory.Entry entry = null;
    322         if (mChartData != null) {
    323             entry = mChartData.network.getValues(start, end, now, null);
    324         }
    325 
    326         // kick off loader for detailed stats
    327         getLoaderManager().restartLoader(LOADER_SUMMARY,
    328                 SummaryForAllUidLoader.buildArgs(mTemplate, start, end), mSummaryCallbacks);
    329 
    330         final long totalBytes = entry != null ? entry.rxBytes + entry.txBytes : 0;
    331         final CharSequence totalPhrase = DataUsageUtils.formatDataUsage(context, totalBytes);
    332         mUsageAmount.setTitle(getString(R.string.data_used_template, totalPhrase));
    333     }
    334 
    335     /**
    336      * Bind the given {@link NetworkStats}, or {@code null} to clear list.
    337      */
    338     public void bindStats(NetworkStats stats, int[] restrictedUids) {
    339         ArrayList<AppItem> items = new ArrayList<>();
    340         long largest = 0;
    341 
    342         final int currentUserId = ActivityManager.getCurrentUser();
    343         UserManager userManager = UserManager.get(getContext());
    344         final List<UserHandle> profiles = userManager.getUserProfiles();
    345         final SparseArray<AppItem> knownItems = new SparseArray<AppItem>();
    346 
    347         NetworkStats.Entry entry = null;
    348         final int size = stats != null ? stats.size() : 0;
    349         for (int i = 0; i < size; i++) {
    350             entry = stats.getValues(i, entry);
    351 
    352             // Decide how to collapse items together
    353             final int uid = entry.uid;
    354 
    355             final int collapseKey;
    356             final int category;
    357             final int userId = UserHandle.getUserId(uid);
    358             if (UserHandle.isApp(uid)) {
    359                 if (profiles.contains(new UserHandle(userId))) {
    360                     if (userId != currentUserId) {
    361                         // Add to a managed user item.
    362                         final int managedKey = UidDetailProvider.buildKeyForUser(userId);
    363                         largest = accumulate(managedKey, knownItems, entry, AppItem.CATEGORY_USER,
    364                                 items, largest);
    365                     }
    366                     // Add to app item.
    367                     collapseKey = uid;
    368                     category = AppItem.CATEGORY_APP;
    369                 } else {
    370                     // If it is a removed user add it to the removed users' key
    371                     final UserInfo info = userManager.getUserInfo(userId);
    372                     if (info == null) {
    373                         collapseKey = UID_REMOVED;
    374                         category = AppItem.CATEGORY_APP;
    375                     } else {
    376                         // Add to other user item.
    377                         collapseKey = UidDetailProvider.buildKeyForUser(userId);
    378                         category = AppItem.CATEGORY_USER;
    379                     }
    380                 }
    381             } else if (uid == UID_REMOVED || uid == UID_TETHERING) {
    382                 collapseKey = uid;
    383                 category = AppItem.CATEGORY_APP;
    384             } else {
    385                 collapseKey = android.os.Process.SYSTEM_UID;
    386                 category = AppItem.CATEGORY_APP;
    387             }
    388             largest = accumulate(collapseKey, knownItems, entry, category, items, largest);
    389         }
    390 
    391         final int restrictedUidsMax = restrictedUids.length;
    392         for (int i = 0; i < restrictedUidsMax; ++i) {
    393             final int uid = restrictedUids[i];
    394             // Only splice in restricted state for current user or managed users
    395             if (!profiles.contains(new UserHandle(UserHandle.getUserId(uid)))) {
    396                 continue;
    397             }
    398 
    399             AppItem item = knownItems.get(uid);
    400             if (item == null) {
    401                 item = new AppItem(uid);
    402                 item.total = -1;
    403                 items.add(item);
    404                 knownItems.put(item.key, item);
    405             }
    406             item.restricted = true;
    407         }
    408 
    409         Collections.sort(items);
    410         mApps.removeAll();
    411         for (int i = 0; i < items.size(); i++) {
    412             final int percentTotal = largest != 0 ? (int) (items.get(i).total * 100 / largest) : 0;
    413             AppDataUsagePreference preference = new AppDataUsagePreference(getContext(),
    414                     items.get(i), percentTotal, mUidDetailProvider);
    415             preference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
    416                 @Override
    417                 public boolean onPreferenceClick(Preference preference) {
    418                     AppDataUsagePreference pref = (AppDataUsagePreference) preference;
    419                     AppItem item = pref.getItem();
    420                     startAppDataUsage(item);
    421                     return true;
    422                 }
    423             });
    424             mApps.addPreference(preference);
    425         }
    426     }
    427 
    428     private void startAppDataUsage(AppItem item) {
    429         final Bundle args = new Bundle();
    430         args.putParcelable(AppDataUsage.ARG_APP_ITEM, item);
    431         args.putParcelable(AppDataUsage.ARG_NETWORK_TEMPLATE, mTemplate);
    432 
    433         new SubSettingLauncher(getContext())
    434                 .setDestination(AppDataUsage.class.getName())
    435                 .setTitle(R.string.app_data_usage)
    436                 .setArguments(args)
    437                 .setSourceMetricsCategory(getMetricsCategory())
    438                 .launch();
    439     }
    440 
    441     /**
    442      * Accumulate data usage of a network stats entry for the item mapped by the collapse key.
    443      * Creates the item if needed.
    444      *
    445      * @param collapseKey  the collapse key used to map the item.
    446      * @param knownItems   collection of known (already existing) items.
    447      * @param entry        the network stats entry to extract data usage from.
    448      * @param itemCategory the item is categorized on the list view by this category. Must be
    449      */
    450     private static long accumulate(int collapseKey, final SparseArray<AppItem> knownItems,
    451             NetworkStats.Entry entry, int itemCategory, ArrayList<AppItem> items, long largest) {
    452         final int uid = entry.uid;
    453         AppItem item = knownItems.get(collapseKey);
    454         if (item == null) {
    455             item = new AppItem(collapseKey);
    456             item.category = itemCategory;
    457             items.add(item);
    458             knownItems.put(item.key, item);
    459         }
    460         item.addUid(uid);
    461         item.total += entry.rxBytes + entry.txBytes;
    462         return Math.max(largest, item.total);
    463     }
    464 
    465     /**
    466      * Test if device has a mobile data radio with SIM in ready state.
    467      */
    468     public static boolean hasReadyMobileRadio(Context context) {
    469         if (DataUsageUtils.TEST_RADIOS) {
    470             return SystemProperties.get(DataUsageUtils.TEST_RADIOS_PROP).contains("mobile");
    471         }
    472 
    473         final ConnectivityManager conn = ConnectivityManager.from(context);
    474         final TelephonyManager tele = TelephonyManager.from(context);
    475 
    476         final List<SubscriptionInfo> subInfoList =
    477                 SubscriptionManager.from(context).getActiveSubscriptionInfoList();
    478         // No activated Subscriptions
    479         if (subInfoList == null) {
    480             if (LOGD) Log.d(TAG, "hasReadyMobileRadio: subInfoList=null");
    481             return false;
    482         }
    483         // require both supported network and ready SIM
    484         boolean isReady = true;
    485         for (SubscriptionInfo subInfo : subInfoList) {
    486             isReady = isReady & tele.getSimState(subInfo.getSimSlotIndex()) == SIM_STATE_READY;
    487             if (LOGD) Log.d(TAG, "hasReadyMobileRadio: subInfo=" + subInfo);
    488         }
    489         boolean retVal = conn.isNetworkSupported(TYPE_MOBILE) && isReady;
    490         if (LOGD) {
    491             Log.d(TAG, "hasReadyMobileRadio:"
    492                     + " conn.isNetworkSupported(TYPE_MOBILE)="
    493                     + conn.isNetworkSupported(TYPE_MOBILE)
    494                     + " isReady=" + isReady);
    495         }
    496         return retVal;
    497     }
    498 
    499     /*
    500      * TODO: consider adding to TelephonyManager or SubscriptionManager.
    501      */
    502     public static boolean hasReadyMobileRadio(Context context, int subId) {
    503         if (DataUsageUtils.TEST_RADIOS) {
    504             return SystemProperties.get(DataUsageUtils.TEST_RADIOS_PROP).contains("mobile");
    505         }
    506 
    507         final ConnectivityManager conn = ConnectivityManager.from(context);
    508         final TelephonyManager tele = TelephonyManager.from(context);
    509         final int slotId = SubscriptionManager.getSlotIndex(subId);
    510         final boolean isReady = tele.getSimState(slotId) == SIM_STATE_READY;
    511 
    512         boolean retVal = conn.isNetworkSupported(TYPE_MOBILE) && isReady;
    513         if (LOGD) {
    514             Log.d(TAG, "hasReadyMobileRadio: subId=" + subId
    515                     + " conn.isNetworkSupported(TYPE_MOBILE)="
    516                     + conn.isNetworkSupported(TYPE_MOBILE)
    517                     + " isReady=" + isReady);
    518         }
    519         return retVal;
    520     }
    521 
    522     private OnItemSelectedListener mCycleListener = new OnItemSelectedListener() {
    523         @Override
    524         public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
    525             final CycleAdapter.CycleItem cycle = (CycleAdapter.CycleItem)
    526                     mCycleSpinner.getSelectedItem();
    527 
    528             if (LOGD) {
    529                 Log.d(TAG, "showing cycle " + cycle + ", start=" + cycle.start + ", end="
    530                         + cycle.end + "]");
    531             }
    532 
    533             // update chart to show selected cycle, and update detail data
    534             // to match updated sweep bounds.
    535             mChart.setVisibleRange(cycle.start, cycle.end);
    536 
    537             updateDetailData();
    538         }
    539 
    540         @Override
    541         public void onNothingSelected(AdapterView<?> parent) {
    542             // ignored
    543         }
    544     };
    545 
    546     private final LoaderCallbacks<ChartData> mChartDataCallbacks = new LoaderCallbacks<
    547             ChartData>() {
    548         @Override
    549         public Loader<ChartData> onCreateLoader(int id, Bundle args) {
    550             return new ChartDataLoader(getActivity(), mStatsSession, args);
    551         }
    552 
    553         @Override
    554         public void onLoadFinished(Loader<ChartData> loader, ChartData data) {
    555             mLoadingViewController.showContent(false /* animate */);
    556             mChartData = data;
    557             mChart.setNetworkStats(mChartData.network);
    558 
    559             // calculate policy cycles based on available data
    560             updatePolicy();
    561         }
    562 
    563         @Override
    564         public void onLoaderReset(Loader<ChartData> loader) {
    565             mChartData = null;
    566             mChart.setNetworkStats(null);
    567         }
    568     };
    569 
    570     private final LoaderCallbacks<NetworkStats> mSummaryCallbacks = new LoaderCallbacks<
    571             NetworkStats>() {
    572         @Override
    573         public Loader<NetworkStats> onCreateLoader(int id, Bundle args) {
    574             return new SummaryForAllUidLoader(getActivity(), mStatsSession, args);
    575         }
    576 
    577         @Override
    578         public void onLoadFinished(Loader<NetworkStats> loader, NetworkStats data) {
    579             final int[] restrictedUids = services.mPolicyManager.getUidsWithPolicy(
    580                     POLICY_REJECT_METERED_BACKGROUND);
    581             bindStats(data, restrictedUids);
    582             updateEmptyVisible();
    583         }
    584 
    585         @Override
    586         public void onLoaderReset(Loader<NetworkStats> loader) {
    587             bindStats(null, new int[0]);
    588             updateEmptyVisible();
    589         }
    590 
    591         private void updateEmptyVisible() {
    592             if ((mApps.getPreferenceCount() != 0) !=
    593                     (getPreferenceScreen().getPreferenceCount() != 0)) {
    594                 if (mApps.getPreferenceCount() != 0) {
    595                     getPreferenceScreen().addPreference(mUsageAmount);
    596                     getPreferenceScreen().addPreference(mApps);
    597                 } else {
    598                     getPreferenceScreen().removeAll();
    599                 }
    600             }
    601         }
    602     };
    603 }
    604