mobile/android/base/home/PanelLayout.java

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

     1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
     2  * This Source Code Form is subject to the terms of the Mozilla Public
     3  * License, v. 2.0. If a copy of the MPL was not distributed with this
     4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     6 package org.mozilla.gecko.home;
     8 import org.mozilla.gecko.R;
     9 import org.mozilla.gecko.db.BrowserContract.HomeItems;
    10 import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
    11 import org.mozilla.gecko.home.HomeConfig.EmptyViewConfig;
    12 import org.mozilla.gecko.home.HomeConfig.ItemHandler;
    13 import org.mozilla.gecko.home.HomeConfig.PanelConfig;
    14 import org.mozilla.gecko.home.HomeConfig.ViewConfig;
    15 import org.mozilla.gecko.util.StringUtils;
    17 import android.content.Context;
    18 import android.database.Cursor;
    19 import android.os.Parcel;
    20 import android.os.Parcelable;
    21 import android.text.TextUtils;
    22 import android.util.Log;
    23 import android.util.SparseArray;
    24 import android.view.KeyEvent;
    25 import android.view.LayoutInflater;
    26 import android.view.View;
    27 import android.view.ViewGroup;
    28 import android.widget.FrameLayout;
    29 import android.widget.ImageView;
    30 import android.widget.LinearLayout;
    31 import android.widget.TextView;
    33 import java.lang.ref.SoftReference;
    34 import java.util.EnumSet;
    35 import java.util.LinkedList;
    36 import java.util.Map;
    37 import java.util.WeakHashMap;
    39 import com.squareup.picasso.Picasso;
    41 /**
    42  * {@code PanelLayout} is the base class for custom layouts to be
    43  * used in {@code DynamicPanel}. It provides the basic framework
    44  * that enables custom layouts to request and reset datasets and
    45  * create panel views. Furthermore, it automates most of the process
    46  * of binding panel views with their respective datasets.
    47  *
    48  * {@code PanelLayout} abstracts the implemention details of how
    49  * datasets are actually loaded through the {@DatasetHandler} interface.
    50  * {@code DatasetHandler} provides two operations: request and reset.
    51  * The results of the dataset requests done via the {@code DatasetHandler}
    52  * are delivered to the {@code PanelLayout} with the {@code deliverDataset()}
    53  * method.
    54  *
    55  * Subclasses of {@code PanelLayout} should simply use the utilities
    56  * provided by {@code PanelLayout}. Namely:
    57  *
    58  * {@code requestDataset()} - To fetch datasets and auto-bind them to
    59  *                            the existing panel views backed by them.
    60  *
    61  * {@code resetDataset()} - To release any resources associated with a
    62  *                          previously loaded dataset.
    63  *
    64  * {@code createPanelView()} - To create a panel view for a ViewConfig
    65  *                             associated with the panel.
    66  *
    67  * {@code disposePanelView()} - To dispose any dataset references associated
    68  *                              with the given view.
    69  *
    70  * {@code PanelLayout} subclasses should always use {@code createPanelView()}
    71  * to create the views dynamically created based on {@code ViewConfig}. This
    72  * allows {@code PanelLayout} to auto-bind datasets with panel views.
    73  * {@code PanelLayout} subclasses are free to have any type of views to arrange
    74  * the panel views in different ways.
    75  */
    76 abstract class PanelLayout extends FrameLayout {
    77     private static final String LOGTAG = "GeckoPanelLayout";
    79     protected final SparseArray<ViewState> mViewStates;
    80     private final PanelConfig mPanelConfig;
    81     private final DatasetHandler mDatasetHandler;
    82     private final OnUrlOpenListener mUrlOpenListener;
    83     private final ContextMenuRegistry mContextMenuRegistry;
    85     /**
    86      * To be used by panel views to express that they are
    87      * backed by datasets.
    88      */
    89     public interface DatasetBacked {
    90         public void setDataset(Cursor cursor);
    91         public void setFilterManager(FilterManager manager);
    92     }
    94     /**
    95      * To be used by requests made to {@code DatasetHandler}s to couple dataset ID with current
    96      * filter for queries on the database.
    97      */
    98     public static class DatasetRequest implements Parcelable {
    99         public enum Type implements Parcelable {
   100             DATASET_LOAD,
   101             FILTER_PUSH,
   102             FILTER_POP;
   104             @Override
   105             public int describeContents() {
   106                 return 0;
   107             }
   109             @Override
   110             public void writeToParcel(Parcel dest, int flags) {
   111                 dest.writeInt(ordinal());
   112             }
   114             public static final Creator<Type> CREATOR = new Creator<Type>() {
   115                 @Override
   116                 public Type createFromParcel(final Parcel source) {
   117                     return Type.values()[source.readInt()];
   118                 }
   120                 @Override
   121                 public Type[] newArray(final int size) {
   122                     return new Type[size];
   123                 }
   124             };
   125         }
   127         private final int mViewIndex;
   128         private final Type mType;
   129         private final String mDatasetId;
   130         private final FilterDetail mFilterDetail;
   132         private DatasetRequest(Parcel in) {
   133             this.mViewIndex = in.readInt();
   134             this.mType = (Type) in.readParcelable(getClass().getClassLoader());
   135             this.mDatasetId = in.readString();
   136             this.mFilterDetail = (FilterDetail) in.readParcelable(getClass().getClassLoader());
   137         }
   139         public DatasetRequest(int index, String datasetId, FilterDetail filterDetail) {
   140             this(index, Type.DATASET_LOAD, datasetId, filterDetail);
   141         }
   143         public DatasetRequest(int index, Type type, String datasetId, FilterDetail filterDetail) {
   144             this.mViewIndex = index;
   145             this.mType = type;
   146             this.mDatasetId = datasetId;
   147             this.mFilterDetail = filterDetail;
   148         }
   150         public int getViewIndex() {
   151             return mViewIndex;
   152         }
   154         public Type getType() {
   155             return mType;
   156         }
   158         public String getDatasetId() {
   159             return mDatasetId;
   160         }
   162         public String getFilter() {
   163             return (mFilterDetail != null ? mFilterDetail.filter : null);
   164         }
   166         public FilterDetail getFilterDetail() {
   167             return mFilterDetail;
   168         }
   170         @Override
   171         public int describeContents() {
   172             return 0;
   173         }
   175         @Override
   176         public void writeToParcel(Parcel dest, int flags) {
   177             dest.writeInt(mViewIndex);
   178             dest.writeParcelable(mType, 0);
   179             dest.writeString(mDatasetId);
   180             dest.writeParcelable(mFilterDetail, 0);
   181         }
   183         public String toString() {
   184             return "{ index: " + mViewIndex +
   185                    ", type: " + mType +
   186                    ", dataset: " + mDatasetId +
   187                    ", filter: " + mFilterDetail +
   188                    " }";
   189         }
   191         public static final Creator<DatasetRequest> CREATOR = new Creator<DatasetRequest>() {
   192             public DatasetRequest createFromParcel(Parcel in) {
   193                 return new DatasetRequest(in);
   194             }
   196             public DatasetRequest[] newArray(int size) {
   197                 return new DatasetRequest[size];
   198             }
   199         };
   200     }
   202     /**
   203      * Defines the contract with the component that is responsible
   204      * for handling datasets requests.
   205      */
   206     public interface DatasetHandler {
   207         /**
   208          * Requests a dataset to be fetched and auto-bound to the
   209          * panel views backed by it.
   210          */
   211         public void requestDataset(DatasetRequest request);
   213         /**
   214          * Releases any resources associated with a panel view. It will
   215          * do nothing if the view with the given index been created
   216          * before.
   217          */
   218         public void resetDataset(int viewIndex);
   219     }
   221     public interface PanelView {
   222         public void setOnItemOpenListener(OnItemOpenListener listener);
   223         public void setOnKeyListener(OnKeyListener listener);
   224         public void setContextMenuInfoFactory(HomeContextMenuInfo.Factory factory);
   225     }
   227     public interface FilterManager {
   228         public FilterDetail getPreviousFilter();
   229         public boolean canGoBack();
   230         public void goBack();
   231     }
   233     public interface ContextMenuRegistry {
   234         public void register(View view);
   235     }
   237     public PanelLayout(Context context, PanelConfig panelConfig, DatasetHandler datasetHandler,
   238             OnUrlOpenListener urlOpenListener, ContextMenuRegistry contextMenuRegistry) {
   239         super(context);
   240         mViewStates = new SparseArray<ViewState>();
   241         mPanelConfig = panelConfig;
   242         mDatasetHandler = datasetHandler;
   243         mUrlOpenListener = urlOpenListener;
   244         mContextMenuRegistry = contextMenuRegistry;
   245     }
   247     @Override
   248     public void onDetachedFromWindow() {
   249         super.onDetachedFromWindow();
   251         final int count = mViewStates.size();
   252         for (int i = 0; i < count; i++) {
   253             final ViewState viewState = mViewStates.valueAt(i);
   255             final View view = viewState.getView();
   256             if (view != null) {
   257                 maybeSetDataset(view, null);
   258             }
   259         }
   260         mViewStates.clear();
   261     }
   263     /**
   264      * Delivers the dataset as a {@code Cursor} to be bound to the
   265      * panel view backed by it. This is used by the {@code DatasetHandler}
   266      * in response to a dataset request.
   267      */
   268     public final void deliverDataset(DatasetRequest request, Cursor cursor) {
   269         Log.d(LOGTAG, "Delivering request: " + request);
   270         final ViewState viewState = mViewStates.get(request.getViewIndex());
   271         if (viewState == null) {
   272             return;
   273         }
   275         switch (request.getType()) {
   276             case FILTER_PUSH:
   277                 viewState.pushFilter(request.getFilterDetail());
   278                 break;
   279             case FILTER_POP:
   280                 viewState.popFilter();
   281                 break;
   282         }
   284         final View activeView = viewState.getActiveView();
   285         if (activeView == null) {
   286             throw new IllegalStateException("No active view for view state: " + viewState.getIndex());
   287         }
   289         final ViewConfig viewConfig = viewState.getViewConfig();
   291         final View newView;
   292         if (cursor == null || cursor.getCount() == 0) {
   293             newView = createEmptyView(viewConfig);
   294             maybeSetDataset(activeView, null);
   295         } else {
   296             newView = createPanelView(viewConfig);
   297             maybeSetDataset(newView, cursor);
   298         }
   300         if (activeView != newView) {
   301             replacePanelView(activeView, newView);
   302         }
   303     }
   305     /**
   306      * Releases any references to the given dataset from all
   307      * existing panel views.
   308      */
   309     public final void releaseDataset(int viewIndex) {
   310         Log.d(LOGTAG, "Releasing dataset: " + viewIndex);
   311         final ViewState viewState = mViewStates.get(viewIndex);
   312         if (viewState == null) {
   313             return;
   314         }
   316         final View view = viewState.getView();
   317         if (view != null) {
   318             maybeSetDataset(view, null);
   319         }
   320     }
   322     /**
   323      * Requests a dataset to be loaded and bound to any existing
   324      * panel view backed by it.
   325      */
   326     protected final void requestDataset(DatasetRequest request) {
   327         Log.d(LOGTAG, "Requesting request: " + request);
   328         if (mViewStates.get(request.getViewIndex()) == null) {
   329             return;
   330         }
   332         mDatasetHandler.requestDataset(request);
   333     }
   335     /**
   336      * Releases any resources associated with a panel view.
   337      * e.g. close any associated {@code Cursor}.
   338      */
   339     protected final void resetDataset(int viewIndex) {
   340         Log.d(LOGTAG, "Resetting view with index: " + viewIndex);
   341         if (mViewStates.get(viewIndex) == null) {
   342             return;
   343         }
   345         mDatasetHandler.resetDataset(viewIndex);
   346     }
   348     /**
   349      * Factory method to create instance of panels from a given
   350      * {@code ViewConfig}. All panel views defined in {@code PanelConfig}
   351      * should be created using this method so that {@PanelLayout} can
   352      * keep track of panel views and their associated datasets.
   353      */
   354     protected final View createPanelView(ViewConfig viewConfig) {
   355         Log.d(LOGTAG, "Creating panel view: " + viewConfig.getType());
   357         ViewState viewState = mViewStates.get(viewConfig.getIndex());
   358         if (viewState == null) {
   359             viewState = new ViewState(viewConfig);
   360             mViewStates.put(viewConfig.getIndex(), viewState);
   361         }
   363         View view = viewState.getView();
   364         if (view == null) {
   365             switch(viewConfig.getType()) {
   366                 case LIST:
   367                     view = new PanelListView(getContext(), viewConfig);
   368                     break;
   370                 case GRID:
   371                     view = new PanelGridView(getContext(), viewConfig);
   372                     break;
   374                 default:
   375                     throw new IllegalStateException("Unrecognized view type in " + getClass().getSimpleName());
   376             }
   378             PanelView panelView = (PanelView) view;
   379             panelView.setOnItemOpenListener(new PanelOnItemOpenListener(viewState));
   380             panelView.setOnKeyListener(new PanelKeyListener(viewState));
   381             panelView.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() {
   382                 @Override
   383                 public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) {
   384                     final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id);
   385                     info.url = cursor.getString(cursor.getColumnIndexOrThrow(HomeItems.URL));
   386                     info.title = cursor.getString(cursor.getColumnIndexOrThrow(HomeItems.TITLE));
   387                     return info;
   388                 }
   389             });
   391             mContextMenuRegistry.register(view);
   393             if (view instanceof DatasetBacked) {
   394                 DatasetBacked datasetBacked = (DatasetBacked) view;
   395                 datasetBacked.setFilterManager(new PanelFilterManager(viewState));
   397                 // XXX: Disabled because of bug 1010986
   398                 // if (viewConfig.isRefreshEnabled()) {
   399                 //     view = new PanelRefreshLayout(getContext(), view,
   400                 //                                   mPanelConfig.getId(), viewConfig.getIndex());
   401                 // }
   402             }
   404             viewState.setView(view);
   405         }
   407         return view;
   408     }
   410     /**
   411      * Dispose any dataset references associated with the
   412      * given view.
   413      */
   414     protected final void disposePanelView(View view) {
   415         Log.d(LOGTAG, "Disposing panel view");
   416         final int count = mViewStates.size();
   417         for (int i = 0; i < count; i++) {
   418             final ViewState viewState = mViewStates.valueAt(i);
   420             if (viewState.getView() == view) {
   421                 maybeSetDataset(view, null);
   422                 mViewStates.remove(viewState.getIndex());
   423                 break;
   424             }
   425         }
   426     }
   428     private void maybeSetDataset(View view, Cursor cursor) {
   429         if (view instanceof DatasetBacked) {
   430             final DatasetBacked dsb = (DatasetBacked) view;
   431             dsb.setDataset(cursor);
   432         }
   433     }
   435     private View createEmptyView(ViewConfig viewConfig) {
   436         Log.d(LOGTAG, "Creating empty view: " + viewConfig.getType());
   438         ViewState viewState = mViewStates.get(viewConfig.getIndex());
   439         if (viewState == null) {
   440             throw new IllegalStateException("No view state found for view index: " + viewConfig.getIndex());
   441         }
   443         View view = viewState.getEmptyView();
   444         if (view == null) {
   445             view = LayoutInflater.from(getContext()).inflate(R.layout.home_empty_panel, null);
   447             final EmptyViewConfig emptyViewConfig = viewConfig.getEmptyViewConfig();
   449             // XXX: Refactor this into a custom view (bug 985134)
   450             final String text = (emptyViewConfig == null) ? null : emptyViewConfig.getText();
   451             final TextView textView = (TextView) view.findViewById(R.id.home_empty_text);
   452             if (TextUtils.isEmpty(text)) {
   453                 textView.setText(R.string.home_default_empty);
   454             } else {
   455                 textView.setText(text);
   456             }
   458             final String imageUrl = (emptyViewConfig == null) ? null : emptyViewConfig.getImageUrl();
   459             final ImageView imageView = (ImageView) view.findViewById(R.id.home_empty_image);
   461             if (TextUtils.isEmpty(imageUrl)) {
   462                 imageView.setImageResource(R.drawable.icon_home_empty_firefox);
   463             } else {
   464                 Picasso.with(getContext())
   465                        .load(imageUrl)
   466                        .error(R.drawable.icon_home_empty_firefox)
   467                        .into(imageView);
   468             }
   470             viewState.setEmptyView(view);
   471         }
   473         return view;
   474     }
   476     private void replacePanelView(View currentView, View newView) {
   477         final ViewGroup parent = (ViewGroup) currentView.getParent();
   478         parent.addView(newView, parent.indexOfChild(currentView), currentView.getLayoutParams());
   479         parent.removeView(currentView);
   480     }
   482     /**
   483      * Must be implemented by {@code PanelLayout} subclasses to define
   484      * what happens then the layout is first loaded. Should set initial
   485      * UI state and request any necessary datasets.
   486      */
   487     public abstract void load();
   489     /**
   490      * Represents a 'live' instance of a panel view associated with
   491      * the {@code PanelLayout}. Is responsible for tracking the history stack of filters.
   492      */
   493     protected class ViewState {
   494         private final ViewConfig mViewConfig;
   495         private SoftReference<View> mView;
   496         private SoftReference<View> mEmptyView;
   497         private LinkedList<FilterDetail> mFilterStack;
   499         public ViewState(ViewConfig viewConfig) {
   500             mViewConfig = viewConfig;
   501             mView = new SoftReference<View>(null);
   502             mEmptyView = new SoftReference<View>(null);
   503         }
   505         public ViewConfig getViewConfig() {
   506             return mViewConfig;
   507         }
   509         public int getIndex() {
   510             return mViewConfig.getIndex();
   511         }
   513         public View getView() {
   514             return mView.get();
   515         }
   517         public void setView(View view) {
   518             mView = new SoftReference<View>(view);
   519         }
   521         public View getEmptyView() {
   522             return mEmptyView.get();
   523         }
   525         public void setEmptyView(View view) {
   526             mEmptyView = new SoftReference<View>(view);
   527         }
   529         public View getActiveView() {
   530             final View view = getView();
   531             if (view != null && view.getParent() != null) {
   532                 return view;
   533             }
   535             final View emptyView = getEmptyView();
   536             if (emptyView != null && emptyView.getParent() != null) {
   537                 return emptyView;
   538             }
   540             return null;
   541         }
   543         public String getDatasetId() {
   544             return mViewConfig.getDatasetId();
   545         }
   547         public ItemHandler getItemHandler() {
   548             return mViewConfig.getItemHandler();
   549         }
   551         /**
   552          * Get the current filter that this view is displaying, or null if none.
   553          */
   554         public FilterDetail getCurrentFilter() {
   555             if (mFilterStack == null) {
   556                 return null;
   557             } else {
   558                 return mFilterStack.peek();
   559             }
   560         }
   562         /**
   563          * Get the previous filter that this view was displaying, or null if none.
   564          */
   565         public FilterDetail getPreviousFilter() {
   566             if (!canPopFilter()) {
   567                 return null;
   568             }
   570             return mFilterStack.get(1);
   571         }
   573         /**
   574          * Adds a filter to the history stack for this view.
   575          */
   576         public void pushFilter(FilterDetail filter) {
   577             if (mFilterStack == null) {
   578                 mFilterStack = new LinkedList<FilterDetail>();
   580                 // Initialize with the initial filter.
   581                 mFilterStack.push(new FilterDetail(mViewConfig.getFilter(),
   582                                                    mPanelConfig.getTitle()));
   583             }
   585             mFilterStack.push(filter);
   586         }
   588         /**
   589          * Remove the most recent filter from the stack.
   590          *
   591          * @return whether the filter was popped
   592          */
   593         public boolean popFilter() {
   594             if (!canPopFilter()) {
   595                 return false;
   596             }
   598             mFilterStack.pop();
   599             return true;
   600         }
   602         public boolean canPopFilter() {
   603             return (mFilterStack != null && mFilterStack.size() > 1);
   604         }
   605     }
   607     static class FilterDetail implements Parcelable {
   608         final String filter;
   609         final String title;
   611         private FilterDetail(Parcel in) {
   612             this.filter = in.readString();
   613             this.title = in.readString();
   614         }
   616         public FilterDetail(String filter, String title) {
   617             this.filter = filter;
   618             this.title = title;
   619         }
   621         @Override
   622         public int describeContents() {
   623             return 0;
   624         }
   626         @Override
   627         public void writeToParcel(Parcel dest, int flags) {
   628             dest.writeString(filter);
   629             dest.writeString(title);
   630         }
   632         public static final Creator<FilterDetail> CREATOR = new Creator<FilterDetail>() {
   633             public FilterDetail createFromParcel(Parcel in) {
   634                 return new FilterDetail(in);
   635             }
   637             public FilterDetail[] newArray(int size) {
   638                 return new FilterDetail[size];
   639             }
   640         };
   641     }
   643     /**
   644      * Pushes filter to {@code ViewState}'s stack and makes request for new filter value.
   645      */
   646     private void pushFilterOnView(ViewState viewState, FilterDetail filterDetail) {
   647         final int index = viewState.getIndex();
   648         final String datasetId = viewState.getDatasetId();
   650         mDatasetHandler.requestDataset(new DatasetRequest(index,
   651                                                           DatasetRequest.Type.FILTER_PUSH,
   652                                                           datasetId,
   653                                                           filterDetail));
   654     }
   656     /**
   657      * Pops filter from {@code ViewState}'s stack and makes request for previous filter value.
   658      *
   659      * @return whether the filter has changed
   660      */
   661     private boolean popFilterOnView(ViewState viewState) {
   662         if (viewState.canPopFilter()) {
   663             final int index = viewState.getIndex();
   664             final String datasetId = viewState.getDatasetId();
   665             final FilterDetail filterDetail = viewState.getPreviousFilter();
   667             mDatasetHandler.requestDataset(new DatasetRequest(index,
   668                                                               DatasetRequest.Type.FILTER_POP,
   669                                                               datasetId,
   670                                                               filterDetail));
   672             return true;
   673         } else {
   674             return false;
   675         }
   676     }
   678     public interface OnItemOpenListener {
   679         public void onItemOpen(String url, String title);
   680     }
   682     private class PanelOnItemOpenListener implements OnItemOpenListener {
   683         private ViewState mViewState;
   685         public PanelOnItemOpenListener(ViewState viewState) {
   686             mViewState = viewState;
   687         }
   689         @Override
   690         public void onItemOpen(String url, String title) {
   691             if (StringUtils.isFilterUrl(url)) {
   692                 FilterDetail filterDetail = new FilterDetail(StringUtils.getFilterFromUrl(url), title);
   693                 pushFilterOnView(mViewState, filterDetail);
   694             } else {
   695                 EnumSet<OnUrlOpenListener.Flags> flags = EnumSet.noneOf(OnUrlOpenListener.Flags.class);
   696                 if (mViewState.getItemHandler() == ItemHandler.INTENT) {
   697                     flags.add(OnUrlOpenListener.Flags.OPEN_WITH_INTENT);
   698                 }
   700                 mUrlOpenListener.onUrlOpen(url, flags);
   701             }
   702         }
   703     }
   705     private class PanelKeyListener implements View.OnKeyListener {
   706         private ViewState mViewState;
   708         public PanelKeyListener(ViewState viewState) {
   709             mViewState = viewState;
   710         }
   712         @Override
   713         public boolean onKey(View v, int keyCode, KeyEvent event) {
   714             if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
   715                 return popFilterOnView(mViewState);
   716             }
   718             return false;
   719         }
   720     }
   722     private class PanelFilterManager implements FilterManager {
   723         private final ViewState mViewState;
   725         public PanelFilterManager(ViewState viewState) {
   726             mViewState = viewState;
   727         }
   729         @Override
   730         public FilterDetail getPreviousFilter() {
   731             return mViewState.getPreviousFilter();
   732         }
   734         @Override
   735         public boolean canGoBack() {
   736             return mViewState.canPopFilter();
   737         }
   739         @Override
   740         public void goBack() {
   741             popFilterOnView(mViewState);
   742         }
   743     }
   744 }

mercurial