ContactPhotoManager.java revision fb231aa77da10a939559f9b0b267ab3b94e8d35c
1/*
2 * Copyright (C) 2010 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
17package com.android.contacts;
18
19import com.android.contacts.model.AccountTypeManager;
20import com.google.android.collect.Lists;
21
22import android.content.ContentResolver;
23import android.content.Context;
24import android.content.res.Resources;
25import android.database.Cursor;
26import android.graphics.Bitmap;
27import android.graphics.BitmapFactory;
28import android.net.Uri;
29import android.os.Handler;
30import android.os.Handler.Callback;
31import android.os.HandlerThread;
32import android.os.Message;
33import android.provider.ContactsContract;
34import android.provider.ContactsContract.Contacts;
35import android.provider.ContactsContract.Contacts.Photo;
36import android.provider.ContactsContract.Data;
37import android.provider.ContactsContract.Directory;
38import android.util.Log;
39import android.util.LruCache;
40import android.widget.ImageView;
41
42import java.io.ByteArrayOutputStream;
43import java.io.InputStream;
44import java.lang.ref.SoftReference;
45import java.util.ArrayList;
46import java.util.Iterator;
47import java.util.concurrent.ConcurrentHashMap;
48
49/**
50 * Asynchronously loads contact photos and maintains a cache of photos.
51 */
52public abstract class ContactPhotoManager {
53
54    static final String TAG = "ContactPhotoManager";
55
56    public static final String CONTACT_PHOTO_SERVICE = "contactPhotos";
57
58    /**
59     * The resource ID of the image to be used when the photo is unavailable or being
60     * loaded.
61     */
62    protected final int mDefaultResourceId = R.drawable.ic_contact_picture;
63
64    /**
65     * Requests the singleton instance of {@link AccountTypeManager} with data bound from
66     * the available authenticators. This method can safely be called from the UI thread.
67     */
68    public static ContactPhotoManager getInstance(Context context) {
69        ContactPhotoManager service =
70                (ContactPhotoManager) context.getSystemService(CONTACT_PHOTO_SERVICE);
71        if (service == null) {
72            service = createContactPhotoManager(context);
73            Log.e(TAG, "No contact photo service in context: " + context);
74        }
75        return service;
76    }
77
78    public static synchronized ContactPhotoManager createContactPhotoManager(Context context) {
79        return new ContactPhotoManagerImpl(context);
80    }
81
82    /**
83     * Load photo into the supplied image view.  If the photo is already cached,
84     * it is displayed immediately.  Otherwise a request is sent to load the photo
85     * from the database.
86     */
87    public abstract void loadPhoto(ImageView view, long photoId);
88
89    /**
90     * Load photo into the supplied image view.  If the photo is already cached,
91     * it is displayed immediately.  Otherwise a request is sent to load the photo
92     * from the location specified by the URI.
93     */
94    public abstract void loadPhoto(ImageView view, Uri photoUri);
95
96    /**
97     * Temporarily stops loading photos from the database.
98     */
99    public abstract void pause();
100
101    /**
102     * Resumes loading photos from the database.
103     */
104    public abstract void resume();
105
106    /**
107     * Marks all cached photos for reloading.  We can continue using cache but should
108     * also make sure the photos haven't changed in the background and notify the views
109     * if so.
110     */
111    public abstract void refreshCache();
112
113    /**
114     * Initiates a background process that over time will fill up cache with
115     * preload photos.
116     */
117    public abstract void preloadPhotosInBackground();
118}
119
120class ContactPhotoManagerImpl extends ContactPhotoManager implements Callback {
121    private static final String LOADER_THREAD_NAME = "ContactPhotoLoader";
122
123    /**
124     * Type of message sent by the UI thread to itself to indicate that some photos
125     * need to be loaded.
126     */
127    private static final int MESSAGE_REQUEST_LOADING = 1;
128
129    /**
130     * Type of message sent by the loader thread to indicate that some photos have
131     * been loaded.
132     */
133    private static final int MESSAGE_PHOTOS_LOADED = 2;
134
135    private static final String[] EMPTY_STRING_ARRAY = new String[0];
136
137    private static final String[] COLUMNS = new String[] { Photo._ID, Photo.PHOTO };
138
139    /**
140     * Maintains the state of a particular photo.
141     */
142    private static class BitmapHolder {
143        final byte[] bytes;
144
145        volatile boolean fresh;
146        Bitmap bitmap;
147        SoftReference<Bitmap> bitmapRef;
148
149        public BitmapHolder(byte[] bytes) {
150            this.bytes = bytes;
151            this.fresh = true;
152        }
153    }
154
155    private final Context mContext;
156
157    /**
158     * An LRU cache for bitmap holders. The cache contains bytes for photos just
159     * as they come from the database. Each holder has a soft reference to the
160     * actual bitmap.
161     */
162    private final LruCache<Object, BitmapHolder> mBitmapHolderCache;
163
164    /**
165     * Cache size threshold at which bitmaps will not be preloaded.
166     */
167    private final int mBitmapHolderCacheRedZoneBytes;
168
169    /**
170     * Level 2 LRU cache for bitmaps. This is a smaller cache that holds
171     * the most recently used bitmaps to save time on decoding
172     * them from bytes (the bytes are stored in {@link #mBitmapHolderCache}.
173     */
174    private final LruCache<Object, Bitmap> mBitmapCache;
175
176    /**
177     * A map from ImageView to the corresponding photo ID. Please note that this
178     * photo ID may change before the photo loading request is started.
179     */
180    private final ConcurrentHashMap<ImageView, Object> mPendingRequests =
181            new ConcurrentHashMap<ImageView, Object>();
182
183    /**
184     * Handler for messages sent to the UI thread.
185     */
186    private final Handler mMainThreadHandler = new Handler(this);
187
188    /**
189     * Thread responsible for loading photos from the database. Created upon
190     * the first request.
191     */
192    private LoaderThread mLoaderThread;
193
194    /**
195     * A gate to make sure we only send one instance of MESSAGE_PHOTOS_NEEDED at a time.
196     */
197    private boolean mLoadingRequested;
198
199    /**
200     * Flag indicating if the image loading is paused.
201     */
202    private boolean mPaused;
203
204    public ContactPhotoManagerImpl(Context context) {
205        mContext = context;
206
207        Resources resources = context.getResources();
208        mBitmapCache = new LruCache<Object, Bitmap>(
209                resources.getInteger(R.integer.config_photo_cache_max_bitmaps));
210        int maxBytes = resources.getInteger(R.integer.config_photo_cache_max_bytes);
211        mBitmapHolderCache = new LruCache<Object, BitmapHolder>(maxBytes) {
212            @Override protected int sizeOf(Object key, BitmapHolder value) {
213                return value.bytes.length;
214            }
215        };
216        mBitmapHolderCacheRedZoneBytes = (int) (maxBytes * 0.75);
217    }
218
219    @Override
220    public void preloadPhotosInBackground() {
221        ensureLoaderThread();
222        mLoaderThread.requestPreloading();
223    }
224
225    @Override
226    public void loadPhoto(ImageView view, long photoId) {
227        if (photoId == 0) {
228            // No photo is needed
229            view.setImageResource(mDefaultResourceId);
230            mPendingRequests.remove(view);
231        } else {
232            loadPhotoByIdOrUri(view, photoId);
233        }
234    }
235
236    @Override
237    public void loadPhoto(ImageView view, Uri photoUri) {
238        if (photoUri == null) {
239            // No photo is needed
240            view.setImageResource(mDefaultResourceId);
241            mPendingRequests.remove(view);
242        } else {
243            loadPhotoByIdOrUri(view, photoUri);
244        }
245    }
246
247    private void loadPhotoByIdOrUri(ImageView view, Object key) {
248        boolean loaded = loadCachedPhoto(view, key);
249        if (loaded) {
250            mPendingRequests.remove(view);
251        } else {
252            mPendingRequests.put(view, key);
253            if (!mPaused) {
254                // Send a request to start loading photos
255                requestLoading();
256            }
257        }
258    }
259
260    @Override
261    public void refreshCache() {
262        for (BitmapHolder holder : mBitmapHolderCache.snapshot().values()) {
263            holder.fresh = false;
264        }
265    }
266
267    /**
268     * Checks if the photo is present in cache.  If so, sets the photo on the view.
269     *
270     * @return false if the photo needs to be (re)loaded from the provider.
271     */
272    private boolean loadCachedPhoto(ImageView view, Object key) {
273        BitmapHolder holder = mBitmapHolderCache.get(key);
274        if (holder == null) {
275            // The bitmap has not been loaded - should display the placeholder image.
276            view.setImageResource(mDefaultResourceId);
277            return false;
278        }
279
280        if (holder.bytes == null) {
281            view.setImageResource(mDefaultResourceId);
282            return holder.fresh;
283        }
284
285        // Optionally decode bytes into a bitmap
286        inflateBitmap(holder);
287
288        view.setImageBitmap(holder.bitmap);
289
290        // Put the bitmap in the LRU cache
291        mBitmapCache.put(key, holder.bitmap);
292
293        // Soften the reference
294        holder.bitmap = null;
295
296        return holder.fresh;
297    }
298
299    /**
300     * If necessary, decodes bytes stored in the holder to Bitmap.  As long as the
301     * bitmap is held either by {@link #mBitmapCache} or by a soft reference in
302     * the holder, it will not be necessary to decode the bitmap.
303     */
304    private void inflateBitmap(BitmapHolder holder) {
305        byte[] bytes = holder.bytes;
306        if (bytes == null || bytes.length == 0) {
307            return;
308        }
309
310        // Check the soft reference.  If will be retained if the bitmap is also
311        // in the LRU cache, so we don't need to check the LRU cache explicitly.
312        if (holder.bitmapRef != null) {
313            holder.bitmap = holder.bitmapRef.get();
314            if (holder.bitmap != null) {
315                return;
316            }
317        }
318
319        try {
320            Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, null);
321            holder.bitmap = bitmap;
322            holder.bitmapRef = new SoftReference<Bitmap>(bitmap);
323        } catch (OutOfMemoryError e) {
324            // Do nothing - the photo will appear to be missing
325        }
326    }
327
328    public void clear() {
329        mPendingRequests.clear();
330        mBitmapHolderCache.evictAll();
331    }
332
333    @Override
334    public void pause() {
335        mPaused = true;
336    }
337
338    @Override
339    public void resume() {
340        mPaused = false;
341        if (!mPendingRequests.isEmpty()) {
342            requestLoading();
343        }
344    }
345
346    /**
347     * Sends a message to this thread itself to start loading images.  If the current
348     * view contains multiple image views, all of those image views will get a chance
349     * to request their respective photos before any of those requests are executed.
350     * This allows us to load images in bulk.
351     */
352    private void requestLoading() {
353        if (!mLoadingRequested) {
354            mLoadingRequested = true;
355            mMainThreadHandler.sendEmptyMessage(MESSAGE_REQUEST_LOADING);
356        }
357    }
358
359    /**
360     * Processes requests on the main thread.
361     */
362    public boolean handleMessage(Message msg) {
363        switch (msg.what) {
364            case MESSAGE_REQUEST_LOADING: {
365                mLoadingRequested = false;
366                if (!mPaused) {
367                    ensureLoaderThread();
368                    mLoaderThread.requestLoading();
369                }
370                return true;
371            }
372
373            case MESSAGE_PHOTOS_LOADED: {
374                if (!mPaused) {
375                    processLoadedImages();
376                }
377                return true;
378            }
379        }
380        return false;
381    }
382
383    public void ensureLoaderThread() {
384        if (mLoaderThread == null) {
385            mLoaderThread = new LoaderThread(mContext.getContentResolver());
386            mLoaderThread.start();
387        }
388    }
389
390    /**
391     * Goes over pending loading requests and displays loaded photos.  If some of the
392     * photos still haven't been loaded, sends another request for image loading.
393     */
394    private void processLoadedImages() {
395        Iterator<ImageView> iterator = mPendingRequests.keySet().iterator();
396        while (iterator.hasNext()) {
397            ImageView view = iterator.next();
398            Object key = mPendingRequests.get(view);
399            boolean loaded = loadCachedPhoto(view, key);
400            if (loaded) {
401                iterator.remove();
402            }
403        }
404
405        softenCache();
406
407        if (!mPendingRequests.isEmpty()) {
408            requestLoading();
409        }
410    }
411
412    /**
413     * Removes strong references to loaded bitmaps to allow them to be garbage collected
414     * if needed.  Some of the bitmaps will still be retained by {@link #mBitmapCache}.
415     */
416    private void softenCache() {
417        for (BitmapHolder holder : mBitmapHolderCache.snapshot().values()) {
418            holder.bitmap = null;
419        }
420    }
421
422    /**
423     * Stores the supplied bitmap in cache.
424     */
425    private void cacheBitmap(Object key, byte[] bytes, boolean preloading) {
426        BitmapHolder holder = new BitmapHolder(bytes);
427        holder.fresh = true;
428
429        // Unless this image is being preloaded, decode it right away while
430        // we are still on the background thread.
431        if (!preloading) {
432            inflateBitmap(holder);
433        }
434
435        mBitmapHolderCache.put(key, holder);
436    }
437
438    /**
439     * Populates an array of photo IDs that need to be loaded.
440     */
441    private void obtainPhotoIdsAndUrisToLoad(ArrayList<Long> photoIds,
442            ArrayList<String> photoIdsAsStrings, ArrayList<Uri> uris) {
443        photoIds.clear();
444        photoIdsAsStrings.clear();
445        uris.clear();
446
447        /*
448         * Since the call is made from the loader thread, the map could be
449         * changing during the iteration. That's not really a problem:
450         * ConcurrentHashMap will allow those changes to happen without throwing
451         * exceptions. Since we may miss some requests in the situation of
452         * concurrent change, we will need to check the map again once loading
453         * is complete.
454         */
455        Iterator<Object> iterator = mPendingRequests.values().iterator();
456        while (iterator.hasNext()) {
457            Object key = iterator.next();
458            BitmapHolder holder = mBitmapHolderCache.get(key);
459            if (holder == null || !holder.fresh) {
460                if (key instanceof Long) {
461                    photoIds.add((Long)key);
462                    photoIdsAsStrings.add(key.toString());
463                } else {
464                    uris.add((Uri)key);
465                }
466            }
467        }
468    }
469
470    /**
471     * The thread that performs loading of photos from the database.
472     */
473    private class LoaderThread extends HandlerThread implements Callback {
474        private static final int BUFFER_SIZE = 1024*16;
475        private static final int MESSAGE_PRELOAD_PHOTOS = 0;
476        private static final int MESSAGE_LOAD_PHOTOS = 1;
477
478        /**
479         * A pause between preload batches that yields to the UI thread.
480         */
481        private static final int PHOTO_PRELOAD_DELAY = 50;
482
483        /**
484         * Number of photos to preload per batch.
485         */
486        private static final int PRELOAD_BATCH = 25;
487
488        /**
489         * Maximum number of photos to preload.  If the cache size is 2Mb and
490         * the expected average size of a photo is 4kb, then this number should be 2Mb/4kb = 500.
491         */
492        private static final int MAX_PHOTOS_TO_PRELOAD = 500;
493
494        private final ContentResolver mResolver;
495        private final StringBuilder mStringBuilder = new StringBuilder();
496        private final ArrayList<Long> mPhotoIds = Lists.newArrayList();
497        private final ArrayList<String> mPhotoIdsAsStrings = Lists.newArrayList();
498        private final ArrayList<Uri> mPhotoUris = Lists.newArrayList();
499        private ArrayList<Long> mPreloadPhotoIds = Lists.newArrayList();
500
501        private Handler mLoaderThreadHandler;
502        private byte mBuffer[];
503
504        private static final int PRELOAD_STATUS_NOT_STARTED = 0;
505        private static final int PRELOAD_STATUS_IN_PROGRESS = 1;
506        private static final int PRELOAD_STATUS_DONE = 2;
507
508        private int mPreloadStatus = PRELOAD_STATUS_NOT_STARTED;
509
510        public LoaderThread(ContentResolver resolver) {
511            super(LOADER_THREAD_NAME);
512            mResolver = resolver;
513        }
514
515        public void ensureHandler() {
516            if (mLoaderThreadHandler == null) {
517                mLoaderThreadHandler = new Handler(getLooper(), this);
518            }
519        }
520
521        /**
522         * Kicks off preloading of the next batch of photos on the background thread.
523         * Preloading will happen after a delay: we want to yield to the UI thread
524         * as much as possible.
525         * <p>
526         * If preloading is already complete, does nothing.
527         */
528        public void requestPreloading() {
529            if (mPreloadStatus == PRELOAD_STATUS_DONE) {
530                return;
531            }
532
533            ensureHandler();
534            if (mLoaderThreadHandler.hasMessages(MESSAGE_LOAD_PHOTOS)) {
535                return;
536            }
537
538            mLoaderThreadHandler.sendEmptyMessageDelayed(
539                    MESSAGE_PRELOAD_PHOTOS, PHOTO_PRELOAD_DELAY);
540        }
541
542        /**
543         * Sends a message to this thread to load requested photos.  Cancels a preloading
544         * request, if any: we don't want preloading to impede loading of the photos
545         * we need to display now.
546         */
547        public void requestLoading() {
548            ensureHandler();
549            mLoaderThreadHandler.removeMessages(MESSAGE_PRELOAD_PHOTOS);
550            mLoaderThreadHandler.sendEmptyMessage(MESSAGE_LOAD_PHOTOS);
551        }
552
553        /**
554         * Receives the above message, loads photos and then sends a message
555         * to the main thread to process them.
556         */
557        public boolean handleMessage(Message msg) {
558            switch (msg.what) {
559                case MESSAGE_PRELOAD_PHOTOS:
560                    preloadPhotosInBackground();
561                    break;
562                case MESSAGE_LOAD_PHOTOS:
563                    loadPhotosInBackground();
564                    break;
565            }
566            return true;
567        }
568
569        /**
570         * The first time it is called, figures out which photos need to be preloaded.
571         * Each subsequent call preloads the next batch of photos and requests
572         * another cycle of preloading after a delay.  The whole process ends when
573         * we either run out of photos to preload or fill up cache.
574         */
575        private void preloadPhotosInBackground() {
576            if (mPreloadStatus == PRELOAD_STATUS_DONE) {
577                return;
578            }
579
580            if (mPreloadStatus == PRELOAD_STATUS_NOT_STARTED) {
581                queryPhotosForPreload();
582                if (mPreloadPhotoIds.isEmpty()) {
583                    mPreloadStatus = PRELOAD_STATUS_DONE;
584                } else {
585                    mPreloadStatus = PRELOAD_STATUS_IN_PROGRESS;
586                }
587                requestPreloading();
588                return;
589            }
590
591            if (mBitmapHolderCache.size() > mBitmapHolderCacheRedZoneBytes) {
592                mPreloadStatus = PRELOAD_STATUS_DONE;
593                return;
594            }
595
596            mPhotoIds.clear();
597            mPhotoIdsAsStrings.clear();
598
599            int count = 0;
600            int preloadSize = mPreloadPhotoIds.size();
601            while(preloadSize > 0 && mPhotoIds.size() < PRELOAD_BATCH) {
602                preloadSize--;
603                count++;
604                Long photoId = mPreloadPhotoIds.get(preloadSize);
605                mPhotoIds.add(photoId);
606                mPhotoIdsAsStrings.add(photoId.toString());
607                mPreloadPhotoIds.remove(preloadSize);
608            }
609
610            loadPhotosFromDatabase(false);
611
612            if (preloadSize == 0) {
613                mPreloadStatus = PRELOAD_STATUS_DONE;
614            }
615
616            Log.v(TAG, "Preloaded " + count + " photos. Photos in cache: "
617                    + mBitmapHolderCache.size()
618                    + ". Total size: " + mBitmapHolderCache.size());
619
620            requestPreloading();
621        }
622
623        private void queryPhotosForPreload() {
624            Cursor cursor = null;
625            try {
626                Uri uri = Contacts.CONTENT_URI.buildUpon().appendQueryParameter(
627                        ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT))
628                        .build();
629                cursor = mResolver.query(uri, new String[] { Contacts.PHOTO_ID },
630                        Contacts.PHOTO_ID + " NOT NULL AND " + Contacts.PHOTO_ID + "!=0",
631                        null,
632                        Contacts.STARRED + " DESC, " + Contacts.LAST_TIME_CONTACTED + " DESC"
633                                + " LIMIT " + MAX_PHOTOS_TO_PRELOAD);
634
635                if (cursor != null) {
636                    while (cursor.moveToNext()) {
637                        // Insert them in reverse order, because we will be taking
638                        // them from the end of the list for loading.
639                        mPreloadPhotoIds.add(0, cursor.getLong(0));
640                    }
641                }
642            } finally {
643                if (cursor != null) {
644                    cursor.close();
645                }
646            }
647        }
648
649        private void loadPhotosInBackground() {
650            obtainPhotoIdsAndUrisToLoad(mPhotoIds, mPhotoIdsAsStrings, mPhotoUris);
651            loadPhotosFromDatabase(true);
652            loadRemotePhotos();
653            requestPreloading();
654        }
655
656        private void loadPhotosFromDatabase(boolean preloading) {
657            int count = mPhotoIds.size();
658            if (count == 0) {
659                return;
660            }
661
662            // Remove loaded photos from the preload queue: we don't want
663            // the preloading process to load them again.
664            if (!preloading && mPreloadStatus == PRELOAD_STATUS_IN_PROGRESS) {
665                for (int i = 0; i < count; i++) {
666                    mPreloadPhotoIds.remove(mPhotoIds.get(i));
667                }
668                if (mPreloadPhotoIds.isEmpty()) {
669                    mPreloadStatus = PRELOAD_STATUS_DONE;
670                }
671            }
672
673            mStringBuilder.setLength(0);
674            mStringBuilder.append(Photo._ID + " IN(");
675            for (int i = 0; i < count; i++) {
676                if (i != 0) {
677                    mStringBuilder.append(',');
678                }
679                mStringBuilder.append('?');
680            }
681            mStringBuilder.append(')');
682
683            Cursor cursor = null;
684            try {
685                cursor = mResolver.query(Data.CONTENT_URI,
686                        COLUMNS,
687                        mStringBuilder.toString(),
688                        mPhotoIdsAsStrings.toArray(EMPTY_STRING_ARRAY),
689                        null);
690
691                if (cursor != null) {
692                    while (cursor.moveToNext()) {
693                        Long id = cursor.getLong(0);
694                        byte[] bytes = cursor.getBlob(1);
695                        cacheBitmap(id, bytes, preloading);
696                        mPhotoIds.remove(id);
697                    }
698                }
699            } finally {
700                if (cursor != null) {
701                    cursor.close();
702                }
703            }
704
705            // Remaining photos were not found in the database - mark the cache accordingly.
706            count = mPhotoIds.size();
707            for (int i = 0; i < count; i++) {
708                cacheBitmap(mPhotoIds.get(i), null, preloading);
709            }
710
711            mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED);
712        }
713
714        private void loadRemotePhotos() {
715            int count = mPhotoUris.size();
716            for (int i = 0; i < count; i++) {
717                Uri uri = mPhotoUris.get(i);
718                if (mBuffer == null) {
719                    mBuffer = new byte[BUFFER_SIZE];
720                }
721                try {
722                    InputStream is = mResolver.openInputStream(uri);
723                    if (is != null) {
724                        ByteArrayOutputStream baos = new ByteArrayOutputStream();
725                        try {
726                            int size;
727                            while ((size = is.read(mBuffer)) != -1) {
728                                baos.write(mBuffer, 0, size);
729                            }
730                        } finally {
731                            is.close();
732                        }
733                        cacheBitmap(uri, baos.toByteArray(), false);
734                        mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED);
735                    } else {
736                        Log.v(TAG, "Cannot load photo " + uri);
737                        cacheBitmap(uri, null, false);
738                    }
739                } catch (Exception ex) {
740                    Log.v(TAG, "Cannot load photo " + uri, ex);
741                    cacheBitmap(uri, null, false);
742                }
743            }
744        }
745    }
746}
747