• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.internal.app;
17 
18 import android.annotation.IntDef;
19 import android.annotation.Nullable;
20 import android.annotation.NonNull;
21 import android.annotation.UserIdInt;
22 import android.app.AppGlobals;
23 import android.content.ContentResolver;
24 import android.content.Context;
25 import android.content.Intent;
26 import android.content.pm.IPackageManager;
27 import android.os.Trace;
28 import android.os.UserHandle;
29 import android.view.View;
30 import android.view.ViewGroup;
31 import android.widget.Button;
32 import android.widget.TextView;
33 
34 import com.android.internal.R;
35 import com.android.internal.annotations.VisibleForTesting;
36 import com.android.internal.widget.PagerAdapter;
37 import com.android.internal.widget.ViewPager;
38 
39 import java.util.HashSet;
40 import java.util.List;
41 import java.util.Objects;
42 import java.util.Set;
43 
44 /**
45  * Skeletal {@link PagerAdapter} implementation of a work or personal profile page for
46  * intent resolution (including share sheet).
47  */
48 public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter {
49 
50     private static final String TAG = "AbstractMultiProfilePagerAdapter";
51     static final int PROFILE_PERSONAL = 0;
52     static final int PROFILE_WORK = 1;
53 
54     @IntDef({PROFILE_PERSONAL, PROFILE_WORK})
55     @interface Profile {}
56 
57     private final Context mContext;
58     private int mCurrentPage;
59     private OnProfileSelectedListener mOnProfileSelectedListener;
60     private Set<Integer> mLoadedPages;
61     private final EmptyStateProvider mEmptyStateProvider;
62     private final UserHandle mWorkProfileUserHandle;
63     private final QuietModeManager mQuietModeManager;
64 
AbstractMultiProfilePagerAdapter(Context context, int currentPage, EmptyStateProvider emptyStateProvider, QuietModeManager quietModeManager, UserHandle workProfileUserHandle)65     AbstractMultiProfilePagerAdapter(Context context, int currentPage,
66             EmptyStateProvider emptyStateProvider,
67             QuietModeManager quietModeManager,
68             UserHandle workProfileUserHandle) {
69         mContext = Objects.requireNonNull(context);
70         mCurrentPage = currentPage;
71         mLoadedPages = new HashSet<>();
72         mWorkProfileUserHandle = workProfileUserHandle;
73         mEmptyStateProvider = emptyStateProvider;
74         mQuietModeManager = quietModeManager;
75     }
76 
isQuietModeEnabled(UserHandle workProfileUserHandle)77     private boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
78         return mQuietModeManager.isQuietModeEnabled(workProfileUserHandle);
79     }
80 
setOnProfileSelectedListener(OnProfileSelectedListener listener)81     void setOnProfileSelectedListener(OnProfileSelectedListener listener) {
82         mOnProfileSelectedListener = listener;
83     }
84 
getContext()85     Context getContext() {
86         return mContext;
87     }
88 
89     /**
90      * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets
91      * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed
92      * page and rebuilds the list.
93      */
setupViewPager(ViewPager viewPager)94     void setupViewPager(ViewPager viewPager) {
95         viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
96             @Override
97             public void onPageSelected(int position) {
98                 mCurrentPage = position;
99                 if (!mLoadedPages.contains(position)) {
100                     rebuildActiveTab(true);
101                     mLoadedPages.add(position);
102                 }
103                 if (mOnProfileSelectedListener != null) {
104                     mOnProfileSelectedListener.onProfileSelected(position);
105                 }
106             }
107 
108             @Override
109             public void onPageScrollStateChanged(int state) {
110                 if (mOnProfileSelectedListener != null) {
111                     mOnProfileSelectedListener.onProfilePageStateChanged(state);
112                 }
113             }
114         });
115         viewPager.setAdapter(this);
116         viewPager.setCurrentItem(mCurrentPage);
117         mLoadedPages.add(mCurrentPage);
118     }
119 
clearInactiveProfileCache()120     void clearInactiveProfileCache() {
121         if (mLoadedPages.size() == 1) {
122             return;
123         }
124         mLoadedPages.remove(1 - mCurrentPage);
125     }
126 
127     @Override
instantiateItem(ViewGroup container, int position)128     public ViewGroup instantiateItem(ViewGroup container, int position) {
129         final ProfileDescriptor profileDescriptor = getItem(position);
130         container.addView(profileDescriptor.rootView);
131         return profileDescriptor.rootView;
132     }
133 
134     @Override
destroyItem(ViewGroup container, int position, Object view)135     public void destroyItem(ViewGroup container, int position, Object view) {
136         container.removeView((View) view);
137     }
138 
139     @Override
getCount()140     public int getCount() {
141         return getItemCount();
142     }
143 
getCurrentPage()144     protected int getCurrentPage() {
145         return mCurrentPage;
146     }
147 
148     @VisibleForTesting
getCurrentUserHandle()149     public UserHandle getCurrentUserHandle() {
150         return getActiveListAdapter().mResolverListController.getUserHandle();
151     }
152 
153     @Override
isViewFromObject(View view, Object object)154     public boolean isViewFromObject(View view, Object object) {
155         return view == object;
156     }
157 
158     @Override
getPageTitle(int position)159     public CharSequence getPageTitle(int position) {
160         return null;
161     }
162 
163     /**
164      * Returns the {@link ProfileDescriptor} relevant to the given <code>pageIndex</code>.
165      * <ul>
166      * <li>For a device with only one user, <code>pageIndex</code> value of
167      * <code>0</code> would return the personal profile {@link ProfileDescriptor}.</li>
168      * <li>For a device with a work profile, <code>pageIndex</code> value of <code>0</code> would
169      * return the personal profile {@link ProfileDescriptor}, and <code>pageIndex</code> value of
170      * <code>1</code> would return the work profile {@link ProfileDescriptor}.</li>
171      * </ul>
172      */
getItem(int pageIndex)173     abstract ProfileDescriptor getItem(int pageIndex);
174 
175     /**
176      * Returns the number of {@link ProfileDescriptor} objects.
177      * <p>For a normal consumer device with only one user returns <code>1</code>.
178      * <p>For a device with a work profile returns <code>2</code>.
179      */
getItemCount()180     abstract int getItemCount();
181 
182     /**
183      * Performs view-related initialization procedures for the adapter specified
184      * by <code>pageIndex</code>.
185      */
setupListAdapter(int pageIndex)186     abstract void setupListAdapter(int pageIndex);
187 
188     /**
189      * Returns the adapter of the list view for the relevant page specified by
190      * <code>pageIndex</code>.
191      * <p>This method is meant to be implemented with an implementation-specific return type
192      * depending on the adapter type.
193      */
194     @VisibleForTesting
getAdapterForIndex(int pageIndex)195     public abstract Object getAdapterForIndex(int pageIndex);
196 
197     /**
198      * Returns the {@link ResolverListAdapter} instance of the profile that represents
199      * <code>userHandle</code>. If there is no such adapter for the specified
200      * <code>userHandle</code>, returns {@code null}.
201      * <p>For example, if there is a work profile on the device with user id 10, calling this method
202      * with <code>UserHandle.of(10)</code> returns the work profile {@link ResolverListAdapter}.
203      */
204     @Nullable
getListAdapterForUserHandle(UserHandle userHandle)205     abstract ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle);
206 
207     /**
208      * Returns the {@link ResolverListAdapter} instance of the profile that is currently visible
209      * to the user.
210      * <p>For example, if the user is viewing the work tab in the share sheet, this method returns
211      * the work profile {@link ResolverListAdapter}.
212      * @see #getInactiveListAdapter()
213      */
214     @VisibleForTesting
getActiveListAdapter()215     public abstract ResolverListAdapter getActiveListAdapter();
216 
217     /**
218      * If this is a device with a work profile, returns the {@link ResolverListAdapter} instance
219      * of the profile that is <b><i>not</i></b> currently visible to the user. Otherwise returns
220      * {@code null}.
221      * <p>For example, if the user is viewing the work tab in the share sheet, this method returns
222      * the personal profile {@link ResolverListAdapter}.
223      * @see #getActiveListAdapter()
224      */
225     @VisibleForTesting
getInactiveListAdapter()226     public abstract @Nullable ResolverListAdapter getInactiveListAdapter();
227 
getPersonalListAdapter()228     public abstract ResolverListAdapter getPersonalListAdapter();
229 
getWorkListAdapter()230     public abstract @Nullable ResolverListAdapter getWorkListAdapter();
231 
getCurrentRootAdapter()232     abstract Object getCurrentRootAdapter();
233 
getActiveAdapterView()234     abstract ViewGroup getActiveAdapterView();
235 
getInactiveAdapterView()236     abstract @Nullable ViewGroup getInactiveAdapterView();
237 
238     /**
239      * Rebuilds the tab that is currently visible to the user.
240      * <p>Returns {@code true} if rebuild has completed.
241      */
rebuildActiveTab(boolean doPostProcessing)242     boolean rebuildActiveTab(boolean doPostProcessing) {
243         Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab");
244         boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing);
245         Trace.endSection();
246         return result;
247     }
248 
249     /**
250      * Rebuilds the tab that is not currently visible to the user, if such one exists.
251      * <p>Returns {@code true} if rebuild has completed.
252      */
rebuildInactiveTab(boolean doPostProcessing)253     boolean rebuildInactiveTab(boolean doPostProcessing) {
254         Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab");
255         if (getItemCount() == 1) {
256             Trace.endSection();
257             return false;
258         }
259         boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing);
260         Trace.endSection();
261         return result;
262     }
263 
userHandleToPageIndex(UserHandle userHandle)264     private int userHandleToPageIndex(UserHandle userHandle) {
265         if (userHandle.equals(getPersonalListAdapter().mResolverListController.getUserHandle())) {
266             return PROFILE_PERSONAL;
267         } else {
268             return PROFILE_WORK;
269         }
270     }
271 
rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing)272     private boolean rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing) {
273         if (shouldSkipRebuild(activeListAdapter)) {
274             activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true);
275             return false;
276         }
277         return activeListAdapter.rebuildList(doPostProcessing);
278     }
279 
shouldSkipRebuild(ResolverListAdapter activeListAdapter)280     private boolean shouldSkipRebuild(ResolverListAdapter activeListAdapter) {
281         EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter);
282         return emptyState != null && emptyState.shouldSkipDataRebuild();
283     }
284 
285     /**
286      * The empty state screens are shown according to their priority:
287      * <ol>
288      * <li>(highest priority) cross-profile disabled by policy (handled in
289      * {@link #rebuildTab(ResolverListAdapter, boolean)})</li>
290      * <li>no apps available</li>
291      * <li>(least priority) work is off</li>
292      * </ol>
293      *
294      * The intention is to prevent the user from having to turn
295      * the work profile on if there will not be any apps resolved
296      * anyway.
297      */
showEmptyResolverListEmptyState(ResolverListAdapter listAdapter)298     void showEmptyResolverListEmptyState(ResolverListAdapter listAdapter) {
299         final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter);
300 
301         if (emptyState == null) {
302             return;
303         }
304 
305         emptyState.onEmptyStateShown();
306 
307         View.OnClickListener clickListener = null;
308 
309         if (emptyState.getButtonClickListener() != null) {
310             clickListener = v -> emptyState.getButtonClickListener().onClick(() -> {
311                 ProfileDescriptor descriptor = getItem(
312                         userHandleToPageIndex(listAdapter.getUserHandle()));
313                 AbstractMultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView());
314             });
315         }
316 
317         showEmptyState(listAdapter, emptyState, clickListener);
318     }
319 
320     /**
321      * Class to get user id of the current process
322      */
323     public static class MyUserIdProvider {
324         /**
325          * @return user id of the current process
326          */
getMyUserId()327         public int getMyUserId() {
328             return UserHandle.myUserId();
329         }
330     }
331 
332     /**
333      * Utility class to check if there are cross profile intents, it is in a separate class so
334      * it could be mocked in tests
335      */
336     public static class CrossProfileIntentsChecker {
337 
338         private final ContentResolver mContentResolver;
339 
CrossProfileIntentsChecker(@onNull ContentResolver contentResolver)340         public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) {
341             mContentResolver = contentResolver;
342         }
343 
344         /**
345          * Returns {@code true} if at least one of the provided {@code intents} can be forwarded
346          * from {@code source} (user id) to {@code target} (user id).
347          */
hasCrossProfileIntents(List<Intent> intents, @UserIdInt int source, @UserIdInt int target)348         public boolean hasCrossProfileIntents(List<Intent> intents, @UserIdInt int source,
349                 @UserIdInt int target) {
350             IPackageManager packageManager = AppGlobals.getPackageManager();
351 
352             return intents.stream().anyMatch(intent ->
353                     null != IntentForwarderActivity.canForward(intent, source, target,
354                             packageManager, mContentResolver));
355         }
356     }
357 
showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState, View.OnClickListener buttonOnClick)358     protected void showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState,
359             View.OnClickListener buttonOnClick) {
360         ProfileDescriptor descriptor = getItem(
361                 userHandleToPageIndex(activeListAdapter.getUserHandle()));
362         descriptor.rootView.findViewById(R.id.resolver_list).setVisibility(View.GONE);
363         ViewGroup emptyStateView = descriptor.getEmptyStateView();
364         resetViewVisibilitiesForEmptyState(emptyStateView);
365         emptyStateView.setVisibility(View.VISIBLE);
366 
367         View container = emptyStateView.findViewById(R.id.resolver_empty_state_container);
368         setupContainerPadding(container);
369 
370         TextView titleView = emptyStateView.findViewById(R.id.resolver_empty_state_title);
371         String title = emptyState.getTitle();
372         if (title != null) {
373             titleView.setVisibility(View.VISIBLE);
374             titleView.setText(title);
375         } else {
376             titleView.setVisibility(View.GONE);
377         }
378 
379         TextView subtitleView = emptyStateView.findViewById(R.id.resolver_empty_state_subtitle);
380         String subtitle = emptyState.getSubtitle();
381         if (subtitle != null) {
382             subtitleView.setVisibility(View.VISIBLE);
383             subtitleView.setText(subtitle);
384         } else {
385             subtitleView.setVisibility(View.GONE);
386         }
387 
388         View defaultEmptyText = emptyStateView.findViewById(R.id.empty);
389         defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE);
390 
391         Button button = emptyStateView.findViewById(R.id.resolver_empty_state_button);
392         button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE);
393         button.setOnClickListener(buttonOnClick);
394 
395         activeListAdapter.markTabLoaded();
396     }
397 
398     /**
399      * Sets up the padding of the view containing the empty state screens.
400      * <p>This method is meant to be overridden so that subclasses can customize the padding.
401      */
setupContainerPadding(View container)402     protected void setupContainerPadding(View container) {}
403 
showSpinner(View emptyStateView)404     private void showSpinner(View emptyStateView) {
405         emptyStateView.findViewById(R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE);
406         emptyStateView.findViewById(R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE);
407         emptyStateView.findViewById(R.id.resolver_empty_state_progress).setVisibility(View.VISIBLE);
408         emptyStateView.findViewById(R.id.empty).setVisibility(View.GONE);
409     }
410 
resetViewVisibilitiesForEmptyState(View emptyStateView)411     private void resetViewVisibilitiesForEmptyState(View emptyStateView) {
412         emptyStateView.findViewById(R.id.resolver_empty_state_title).setVisibility(View.VISIBLE);
413         emptyStateView.findViewById(R.id.resolver_empty_state_subtitle).setVisibility(View.VISIBLE);
414         emptyStateView.findViewById(R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE);
415         emptyStateView.findViewById(R.id.resolver_empty_state_progress).setVisibility(View.GONE);
416         emptyStateView.findViewById(R.id.empty).setVisibility(View.GONE);
417     }
418 
showListView(ResolverListAdapter activeListAdapter)419     protected void showListView(ResolverListAdapter activeListAdapter) {
420         ProfileDescriptor descriptor = getItem(
421                 userHandleToPageIndex(activeListAdapter.getUserHandle()));
422         descriptor.rootView.findViewById(R.id.resolver_list).setVisibility(View.VISIBLE);
423         View emptyStateView = descriptor.rootView.findViewById(R.id.resolver_empty_state);
424         emptyStateView.setVisibility(View.GONE);
425     }
426 
shouldShowEmptyStateScreen(ResolverListAdapter listAdapter)427     boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) {
428         int count = listAdapter.getUnfilteredCount();
429         return (count == 0 && listAdapter.getPlaceholderCount() == 0)
430                 || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle)
431                     && isQuietModeEnabled(mWorkProfileUserHandle));
432     }
433 
434     protected class ProfileDescriptor {
435         final ViewGroup rootView;
436         private final ViewGroup mEmptyStateView;
ProfileDescriptor(ViewGroup rootView)437         ProfileDescriptor(ViewGroup rootView) {
438             this.rootView = rootView;
439             mEmptyStateView = rootView.findViewById(R.id.resolver_empty_state);
440         }
441 
getEmptyStateView()442         protected ViewGroup getEmptyStateView() {
443             return mEmptyStateView;
444         }
445     }
446 
447     public interface OnProfileSelectedListener {
448         /**
449          * Callback for when the user changes the active tab from personal to work or vice versa.
450          * <p>This callback is only called when the intent resolver or share sheet shows
451          * the work and personal profiles.
452          * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or
453          * {@link #PROFILE_WORK} if the work profile was selected.
454          */
onProfileSelected(int profileIndex)455         void onProfileSelected(int profileIndex);
456 
457 
458         /**
459          * Callback for when the scroll state changes. Useful for discovering when the user begins
460          * dragging, when the pager is automatically settling to the current page, or when it is
461          * fully stopped/idle.
462          * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING}
463          *              or {@link ViewPager#SCROLL_STATE_SETTLING}
464          * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged
465          */
onProfilePageStateChanged(int state)466         void onProfilePageStateChanged(int state);
467     }
468 
469     /**
470      * Returns an empty state to show for the current profile page (tab) if necessary.
471      * This could be used e.g. to show a blocker on a tab if device management policy doesn't
472      * allow to use it or there are no apps available.
473      */
474     public interface EmptyStateProvider {
475         /**
476          * When a non-null empty state is returned the corresponding profile page will show
477          * this empty state
478          * @param resolverListAdapter the current adapter
479          */
480         @Nullable
getEmptyState(ResolverListAdapter resolverListAdapter)481         default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
482             return null;
483         }
484     }
485 
486     /**
487      * Empty state provider that combines multiple providers. Providers earlier in the list have
488      * priority, that is if there is a provider that returns non-null empty state then all further
489      * providers will be ignored.
490      */
491     public static class CompositeEmptyStateProvider implements EmptyStateProvider {
492 
493         private final EmptyStateProvider[] mProviders;
494 
CompositeEmptyStateProvider(EmptyStateProvider... providers)495         public CompositeEmptyStateProvider(EmptyStateProvider... providers) {
496             mProviders = providers;
497         }
498 
499         @Nullable
500         @Override
getEmptyState(ResolverListAdapter resolverListAdapter)501         public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
502             for (EmptyStateProvider provider : mProviders) {
503                 EmptyState emptyState = provider.getEmptyState(resolverListAdapter);
504                 if (emptyState != null) {
505                     return emptyState;
506                 }
507             }
508             return null;
509         }
510     }
511 
512     /**
513      * Describes how the blocked empty state should look like for a profile tab
514      */
515     public interface EmptyState {
516         /**
517          * Title that will be shown on the empty state
518          */
519         @Nullable
getTitle()520         default String getTitle() { return null; }
521 
522         /**
523          * Subtitle that will be shown underneath the title on the empty state
524          */
525         @Nullable
getSubtitle()526         default String getSubtitle()  { return null; }
527 
528         /**
529          * If non-null then a button will be shown and this listener will be called
530          * when the button is clicked
531          */
532         @Nullable
getButtonClickListener()533         default ClickListener getButtonClickListener()  { return null; }
534 
535         /**
536          * If true then default text ('No apps can perform this action') and style for the empty
537          * state will be applied, title and subtitle will be ignored.
538          */
useDefaultEmptyView()539         default boolean useDefaultEmptyView() { return false; }
540 
541         /**
542          * Returns true if for this empty state we should skip rebuilding of the apps list
543          * for this tab.
544          */
shouldSkipDataRebuild()545         default boolean shouldSkipDataRebuild() { return false; }
546 
547         /**
548          * Called when empty state is shown, could be used e.g. to track analytics events
549          */
onEmptyStateShown()550         default void onEmptyStateShown() {}
551 
552         interface ClickListener {
onClick(TabControl currentTab)553             void onClick(TabControl currentTab);
554         }
555 
556         interface TabControl {
showSpinner()557             void showSpinner();
558         }
559     }
560 
561     /**
562      * Listener for when the user switches on the work profile from the work tab.
563      */
564     interface OnSwitchOnWorkSelectedListener {
565         /**
566          * Callback for when the user switches on the work profile from the work tab.
567          */
onSwitchOnWorkSelected()568         void onSwitchOnWorkSelected();
569     }
570 
571     /**
572      * Describes an injector to be used for cross profile functionality. Overridable for testing.
573      */
574     public interface QuietModeManager {
575         /**
576          * Returns whether the given profile is in quiet mode or not.
577          */
isQuietModeEnabled(UserHandle workProfileUserHandle)578         boolean isQuietModeEnabled(UserHandle workProfileUserHandle);
579 
580         /**
581          * Enables or disables quiet mode for a managed profile.
582          */
requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle)583         void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle);
584 
585         /**
586          * Should be called when the work profile enabled broadcast received
587          */
markWorkProfileEnabledBroadcastReceived()588         void markWorkProfileEnabledBroadcastReceived();
589 
590         /**
591          * Returns true if enabling of work profile is in progress
592          */
isWaitingToEnableWorkProfile()593         boolean isWaitingToEnableWorkProfile();
594     }
595 }