1/*
2 * Copyright (C) 2013 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.camera.data;
18
19import android.content.ContentResolver;
20import android.content.Context;
21import android.net.Uri;
22import android.os.AsyncTask;
23import android.view.View;
24
25import com.android.camera.Storage;
26import com.android.camera.data.FilmstripItem.VideoClickedCallback;
27import com.android.camera.debug.Log;
28import com.android.camera.util.Callback;
29import com.google.common.base.Optional;
30
31import java.util.ArrayList;
32import java.util.Comparator;
33import java.util.Date;
34import java.util.List;
35
36/**
37 * A {@link LocalFilmstripDataAdapter} that provides data in the camera folder.
38 */
39public class CameraFilmstripDataAdapter implements LocalFilmstripDataAdapter {
40    private static final Log.Tag TAG = new Log.Tag("CameraDataAdapter");
41
42    private static final int DEFAULT_DECODE_SIZE = 1600;
43
44    private final Context mContext;
45    private final PhotoItemFactory mPhotoItemFactory;
46    private final VideoItemFactory mVideoItemFactory;
47
48    private FilmstripItemList mFilmstripItems;
49
50
51    private Listener mListener;
52    private FilmstripItemListener mFilmstripItemListener;
53
54    private int mSuggestedWidth = DEFAULT_DECODE_SIZE;
55    private int mSuggestedHeight = DEFAULT_DECODE_SIZE;
56    private long mLastPhotoId = FilmstripItemBase.QUERY_ALL_MEDIA_ID;
57
58    private FilmstripItem mFilmstripItemToDelete;
59
60    public CameraFilmstripDataAdapter(Context context,
61            PhotoItemFactory photoItemFactory, VideoItemFactory videoItemFactory) {
62        mContext = context;
63        mFilmstripItems = new FilmstripItemList();
64        mPhotoItemFactory = photoItemFactory;
65        mVideoItemFactory = videoItemFactory;
66    }
67
68    @Override
69    public void setLocalDataListener(FilmstripItemListener listener) {
70        mFilmstripItemListener = listener;
71    }
72
73    @Override
74    public void requestLoadNewPhotos() {
75        LoadNewPhotosTask ltask = new LoadNewPhotosTask(mContext, mLastPhotoId);
76        ltask.execute(mContext.getContentResolver());
77    }
78
79    @Override
80    public void requestLoad(Callback<Void> onDone) {
81        QueryTask qtask = new QueryTask(onDone);
82        qtask.execute(mContext);
83    }
84
85    @Override
86    public AsyncTask updateMetadataAt(int index) {
87        return updateMetadataAt(index, false);
88    }
89
90    private AsyncTask updateMetadataAt(int index, boolean forceItemUpdate) {
91        MetadataUpdateTask result = new MetadataUpdateTask(forceItemUpdate);
92        result.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, index);
93        return result;
94    }
95
96    @Override
97    public boolean isMetadataUpdatedAt(int index) {
98        if (index < 0 || index >= mFilmstripItems.size()) {
99            return true;
100        }
101        return mFilmstripItems.get(index).getMetadata().isLoaded();
102    }
103
104    @Override
105    public int getItemViewType(int index) {
106        if (index < 0 || index >= mFilmstripItems.size()) {
107            return -1;
108        }
109
110        return mFilmstripItems.get(index).getItemViewType().ordinal();
111    }
112
113    @Override
114    public FilmstripItem getItemAt(int index) {
115        if (index < 0 || index >= mFilmstripItems.size()) {
116            return null;
117        }
118        return mFilmstripItems.get(index);
119    }
120
121    @Override
122    public int getTotalNumber() {
123        return mFilmstripItems.size();
124    }
125
126    @Override
127    public FilmstripItem getFilmstripItemAt(int index) {
128        return getItemAt(index);
129    }
130
131    @Override
132    public void suggestViewSizeBound(int w, int h) {
133        mSuggestedWidth = w;
134        mSuggestedHeight = h;
135    }
136
137    @Override
138    public View getView(View recycled, int index,
139            VideoClickedCallback videoClickedCallback) {
140        if (index >= mFilmstripItems.size() || index < 0) {
141            return null;
142        }
143
144        FilmstripItem item = mFilmstripItems.get(index);
145        item.setSuggestedSize(mSuggestedWidth, mSuggestedHeight);
146
147        return item.getView(Optional.fromNullable(recycled), this, /* inProgress */ false,
148              videoClickedCallback);
149    }
150
151    @Override
152    public void setListener(Listener listener) {
153        mListener = listener;
154        if (mFilmstripItems.size() != 0) {
155            mListener.onFilmstripItemLoaded();
156        }
157    }
158
159    @Override
160    public void removeAt(int index) {
161        FilmstripItem d = mFilmstripItems.remove(index);
162        if (d == null) {
163            return;
164        }
165
166        // Delete previously removed data first.
167        executeDeletion();
168        mFilmstripItemToDelete = d;
169        mListener.onFilmstripItemRemoved(index, d);
170    }
171
172    @Override
173    public boolean addOrUpdate(FilmstripItem item) {
174        final Uri uri = item.getData().getUri();
175        int pos = findByContentUri(uri);
176        if (pos != -1) {
177            // a duplicate one, just do a substitute.
178            Log.v(TAG, "found duplicate data: " + uri);
179            updateItemAt(pos, item);
180            return false;
181        } else {
182            // a new data.
183            insertItem(item);
184            return true;
185        }
186    }
187
188    @Override
189    public int findByContentUri(Uri uri) {
190        // LocalDataList will return in O(1) if the uri is not contained.
191        // Otherwise the performance is O(n), but this is acceptable as we will
192        // most often call this to find an element at the beginning of the list.
193        return mFilmstripItems.indexOf(uri);
194    }
195
196    @Override
197    public boolean undoDeletion() {
198        if (mFilmstripItemToDelete == null) {
199            return false;
200        }
201        FilmstripItem d = mFilmstripItemToDelete;
202        mFilmstripItemToDelete = null;
203        insertItem(d);
204        return true;
205    }
206
207    @Override
208    public boolean executeDeletion() {
209        if (mFilmstripItemToDelete == null) {
210            return false;
211        }
212
213        DeletionTask task = new DeletionTask();
214        task.execute(mFilmstripItemToDelete);
215        mFilmstripItemToDelete = null;
216        return true;
217    }
218
219    @Override
220    public void clear() {
221        replaceItemList(new FilmstripItemList());
222    }
223
224    @Override
225    public void refresh(Uri uri) {
226        final int pos = findByContentUri(uri);
227        if (pos == -1) {
228            return;
229        }
230
231        FilmstripItem data = mFilmstripItems.get(pos);
232        FilmstripItem refreshedData = data.refresh();
233
234        // Refresh failed. Probably removed already.
235        if (refreshedData == null && mListener != null) {
236            mListener.onFilmstripItemRemoved(pos, data);
237            return;
238        }
239        updateItemAt(pos, refreshedData);
240    }
241
242    @Override
243    public void updateItemAt(final int pos, FilmstripItem item) {
244        mFilmstripItems.set(pos, item);
245        updateMetadataAt(pos, true /* forceItemUpdate */);
246    }
247
248    private void insertItem(FilmstripItem item) {
249        // Since this function is mostly for adding the newest data,
250        // a simple linear search should yield the best performance over a
251        // binary search.
252        int pos = 0;
253        Comparator<FilmstripItem> comp = new NewestFirstComparator(
254                new Date());
255        for (; pos < mFilmstripItems.size()
256                && comp.compare(item, mFilmstripItems.get(pos)) > 0; pos++) {
257        }
258        mFilmstripItems.add(pos, item);
259        if (mListener != null) {
260            mListener.onFilmstripItemInserted(pos, item);
261        }
262    }
263
264    /** Update all the data */
265    private void replaceItemList(FilmstripItemList list) {
266        if (list.size() == 0 && mFilmstripItems.size() == 0) {
267            return;
268        }
269        mFilmstripItems = list;
270        if (mListener != null) {
271            mListener.onFilmstripItemLoaded();
272        }
273    }
274
275    @Override
276    public List<AsyncTask> preloadItems(List<Integer> items) {
277        List<AsyncTask> result = new ArrayList<>();
278        for (Integer id : items) {
279            if (!isMetadataUpdatedAt(id)) {
280                result.add(updateMetadataAt(id));
281            }
282        }
283        return result;
284    }
285
286    @Override
287    public void cancelItems(List<AsyncTask> loadTokens) {
288        for (AsyncTask asyncTask : loadTokens) {
289            if (asyncTask != null) {
290                asyncTask.cancel(false);
291            }
292        }
293    }
294
295    @Override
296    public List<Integer> getItemsInRange(int startPosition, int endPosition) {
297        List<Integer> result = new ArrayList<>();
298        for (int i = Math.max(0, startPosition); i < endPosition; i++) {
299            result.add(i);
300        }
301        return result;
302    }
303
304    @Override
305    public int getCount() {
306        return getTotalNumber();
307    }
308
309    private class LoadNewPhotosTask extends AsyncTask<ContentResolver, Void, List<PhotoItem>> {
310
311        private final long mMinPhotoId;
312        private final Context mContext;
313
314        public LoadNewPhotosTask(Context context, long lastPhotoId) {
315            mContext = context;
316            mMinPhotoId = lastPhotoId;
317        }
318
319        /**
320         * Loads any new photos added to our storage directory since our last query.
321         * @param contentResolvers {@link android.content.ContentResolver} to load data.
322         * @return An {@link java.util.ArrayList} containing any new data.
323         */
324        @Override
325        protected List<PhotoItem> doInBackground(ContentResolver... contentResolvers) {
326            if (mMinPhotoId != FilmstripItemBase.QUERY_ALL_MEDIA_ID) {
327                Log.v(TAG, "updating media metadata with photos newer than id: " + mMinPhotoId);
328                final ContentResolver cr = contentResolvers[0];
329                return mPhotoItemFactory.queryAll(PhotoDataQuery.CONTENT_URI, mMinPhotoId);
330            }
331            return new ArrayList<>(0);
332        }
333
334        @Override
335        protected void onPostExecute(List<PhotoItem> newPhotoData) {
336            if (newPhotoData == null) {
337                Log.w(TAG, "null data returned from new photos query");
338                return;
339            }
340            Log.v(TAG, "new photos query return num items: " + newPhotoData.size());
341            if (!newPhotoData.isEmpty()) {
342                FilmstripItem newestPhoto = newPhotoData.get(0);
343                // We may overlap with another load task or a query task, in which case we want
344                // to be sure we never decrement the oldest seen id.
345                long newLastPhotoId = newestPhoto.getData().getContentId();
346                Log.v(TAG, "updating last photo id (old:new) " +
347                        mLastPhotoId + ":" + newLastPhotoId);
348                mLastPhotoId = Math.max(mLastPhotoId, newLastPhotoId);
349            }
350            // We may add data that is already present, but if we do, it will be deduped in addOrUpdate.
351            // addOrUpdate does not dedupe session items, so we ignore them here
352            for (FilmstripItem filmstripItem : newPhotoData) {
353                Uri sessionUri = Storage.getSessionUriFromContentUri(
354                      filmstripItem.getData().getUri());
355                if (sessionUri == null) {
356                    addOrUpdate(filmstripItem);
357                }
358            }
359        }
360    }
361
362    private class QueryTaskResult {
363        public FilmstripItemList mFilmstripItemList;
364        public long mLastPhotoId;
365
366        public QueryTaskResult(FilmstripItemList filmstripItemList, long lastPhotoId) {
367            mFilmstripItemList = filmstripItemList;
368            mLastPhotoId = lastPhotoId;
369        }
370    }
371
372    private class QueryTask extends AsyncTask<Context, Void, QueryTaskResult> {
373        // The maximum number of data to load metadata for in a single task.
374        private static final int MAX_METADATA = 5;
375
376        private final Callback<Void> mDoneCallback;
377
378        public QueryTask(Callback<Void> doneCallback) {
379            mDoneCallback = doneCallback;
380        }
381
382        /**
383         * Loads all the photo and video data in the camera folder in background
384         * and combine them into one single list.
385         *
386         * @param contexts {@link Context} to load all the data.
387         * @return An {@link CameraFilmstripDataAdapter.QueryTaskResult} containing
388         *  all loaded data and the highest photo id in the dataset.
389         */
390        @Override
391        protected QueryTaskResult doInBackground(Context... contexts) {
392            final Context context = contexts[0];
393            FilmstripItemList l = new FilmstripItemList();
394            // Photos and videos
395            List<PhotoItem> photoData = mPhotoItemFactory.queryAll();
396            List<VideoItem> videoData = mVideoItemFactory.queryAll();
397
398            long lastPhotoId = FilmstripItemBase.QUERY_ALL_MEDIA_ID;
399            if (photoData != null && !photoData.isEmpty()) {
400                // This relies on {@link LocalMediaData.QUERY_ORDER} returning
401                // items sorted descending by ID, as such we can just pull the
402                // ID from the first item in the result to establish the last
403                // (max) photo ID.
404                FilmstripItemData firstPhotoData = photoData.get(0).getData();
405
406                if(firstPhotoData != null) {
407                    lastPhotoId = firstPhotoData.getContentId();
408                }
409            }
410
411            if (photoData != null) {
412                Log.v(TAG, "retrieved photo metadata, number of items: " + photoData.size());
413                l.addAll(photoData);
414            }
415            if (videoData != null) {
416                Log.v(TAG, "retrieved video metadata, number of items: " + videoData.size());
417                l.addAll(videoData);
418            }
419            Log.v(TAG, "sorting video/photo metadata");
420            // Photos should be sorted within photo/video by ID, which in most
421            // cases should correlate well to the date taken/modified. This sort
422            // operation makes all photos/videos sorted by date in one list.
423            l.sort(new NewestFirstComparator(new Date()));
424            Log.v(TAG, "sorted video/photo metadata");
425
426            // Load enough metadata so it's already loaded when we open the filmstrip.
427            for (int i = 0; i < MAX_METADATA && i < l.size(); i++) {
428                FilmstripItem data = l.get(i);
429                MetadataLoader.loadMetadata(context, data);
430            }
431            return new QueryTaskResult(l, lastPhotoId);
432        }
433
434        @Override
435        protected void onPostExecute(QueryTaskResult result) {
436            // Since we're wiping away all of our data, we should always replace any existing last
437            // photo id with the new one we just obtained so it matches the data we're showing.
438            mLastPhotoId = result.mLastPhotoId;
439            replaceItemList(result.mFilmstripItemList);
440            if (mDoneCallback != null) {
441                mDoneCallback.onCallback(null);
442            }
443            // Now check for any photos added since this task was kicked off
444            LoadNewPhotosTask ltask = new LoadNewPhotosTask(mContext, mLastPhotoId);
445            ltask.execute(mContext.getContentResolver());
446        }
447    }
448
449    private class DeletionTask extends AsyncTask<FilmstripItem, Void, Void> {
450        @Override
451        protected Void doInBackground(FilmstripItem... items) {
452            for (FilmstripItem item : items) {
453                if (!item.getAttributes().canDelete()) {
454                    Log.v(TAG, "Deletion is not supported:" + item);
455                    continue;
456                }
457                item.delete();
458            }
459            return null;
460        }
461    }
462
463    private class MetadataUpdateTask extends AsyncTask<Integer, Void, List<Integer> > {
464        private final boolean mForceUpdate;
465
466        MetadataUpdateTask(boolean forceUpdate) {
467            super();
468            mForceUpdate = forceUpdate;
469        }
470
471        MetadataUpdateTask() {
472            this(false);
473        }
474
475        @Override
476        protected List<Integer> doInBackground(Integer... dataId) {
477            List<Integer> updatedList = new ArrayList<>();
478            for (Integer id : dataId) {
479                if (id < 0 || id >= mFilmstripItems.size()) {
480                    continue;
481                }
482                final FilmstripItem data = mFilmstripItems.get(id);
483                if (MetadataLoader.loadMetadata(mContext, data) || mForceUpdate) {
484                    updatedList.add(id);
485                }
486            }
487            return updatedList;
488        }
489
490        @Override
491        protected void onPostExecute(final List<Integer> updatedData) {
492            // Since the metadata will affect the width and height of the data
493            // if it's a video, we need to notify the DataAdapter listener
494            // because ImageData.getWidth() and ImageData.getHeight() now may
495            // return different values due to the metadata.
496            if (mListener != null) {
497                mListener.onFilmstripItemUpdated(new UpdateReporter() {
498                    @Override
499                    public boolean isDataRemoved(int index) {
500                        return false;
501                    }
502
503                    @Override
504                    public boolean isDataUpdated(int index) {
505                        return updatedData.contains(index);
506                    }
507                });
508            }
509            if (mFilmstripItemListener == null) {
510                return;
511            }
512            mFilmstripItemListener.onMetadataUpdated(updatedData);
513        }
514    }
515}
516