1/*
2 * Copyright (C) 2014 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.server.telecom;
18
19import android.app.Notification;
20import android.content.Context;
21import android.graphics.Bitmap;
22import android.graphics.drawable.BitmapDrawable;
23import android.graphics.drawable.Drawable;
24import android.telecom.Log;
25import android.net.Uri;
26import android.os.Handler;
27import android.os.HandlerThread;
28import android.os.Looper;
29import android.os.Message;
30
31// TODO: Needed for move to system service: import com.android.internal.R;
32
33import java.io.FileNotFoundException;
34import java.io.IOException;
35import java.io.InputStream;
36
37/**
38 * Helper class for loading contacts photo asynchronously.
39 */
40public class ContactsAsyncHelper {
41    private static final String LOG_TAG = ContactsAsyncHelper.class.getSimpleName();
42
43    /**
44     * Interface for a WorkerHandler result return.
45     */
46    public interface OnImageLoadCompleteListener {
47        /**
48         * Called when the image load is complete.
49         *
50         * @param token Integer passed in {@link ContactsAsyncHelper#startObtainPhotoAsync(int,
51         * Context, Uri, OnImageLoadCompleteListener, Object)}.
52         * @param photo Drawable object obtained by the async load.
53         * @param photoIcon Bitmap object obtained by the async load.
54         * @param cookie Object passed in {@link ContactsAsyncHelper#startObtainPhotoAsync(int,
55         * Context, Uri, OnImageLoadCompleteListener, Object)}. Can be null iff. the original
56         * cookie is null.
57         */
58        public void onImageLoadComplete(int token, Drawable photo, Bitmap photoIcon,
59                Object cookie);
60    }
61
62    /**
63     * Interface to enable stubbing of the call to openInputStream
64     */
65    public interface ContentResolverAdapter {
66        InputStream openInputStream(Context context, Uri uri) throws FileNotFoundException;
67    }
68
69    // constants
70    private static final int EVENT_LOAD_IMAGE = 1;
71
72    /** Handler run on a worker thread to load photo asynchronously. */
73    private Handler mThreadHandler;
74    private final ContentResolverAdapter mContentResolverAdapter;
75
76    public ContactsAsyncHelper(ContentResolverAdapter contentResolverAdapter) {
77        mContentResolverAdapter = contentResolverAdapter;
78    }
79
80    private static final class WorkerArgs {
81        public Context context;
82        public Uri displayPhotoUri;
83        public Drawable photo;
84        public Bitmap photoIcon;
85        public Object cookie;
86        public OnImageLoadCompleteListener listener;
87    }
88
89    /**
90     * Thread worker class that handles the task of opening the stream and loading
91     * the images.
92     */
93    private class WorkerHandler extends Handler {
94        public WorkerHandler(Looper looper) {
95            super(looper);
96        }
97
98        @Override
99        public void handleMessage(Message msg) {
100            WorkerArgs args = (WorkerArgs) msg.obj;
101
102            switch (msg.arg1) {
103                case EVENT_LOAD_IMAGE:
104                    InputStream inputStream = null;
105                    try {
106                        try {
107                            inputStream = mContentResolverAdapter.openInputStream(
108                                    args.context, args.displayPhotoUri);
109                        } catch (Exception e) {
110                            Log.e(this, e, "Error opening photo input stream");
111                        }
112
113                        if (inputStream != null) {
114                            args.photo = Drawable.createFromStream(inputStream,
115                                    args.displayPhotoUri.toString());
116
117                            // This assumes Drawable coming from contact database is usually
118                            // BitmapDrawable and thus we can have (down)scaled version of it.
119                            args.photoIcon = getPhotoIconWhenAppropriate(args.context, args.photo);
120
121                            Log.d(this, "Loading image: " + msg.arg1 +
122                                    " token: " + msg.what + " image URI: " + args.displayPhotoUri);
123                        } else {
124                            args.photo = null;
125                            args.photoIcon = null;
126                            Log.d(this, "Problem with image: " + msg.arg1 +
127                                    " token: " + msg.what + " image URI: " + args.displayPhotoUri +
128                                    ", using default image.");
129                        }
130                    } finally {
131                        if (inputStream != null) {
132                            try {
133                                inputStream.close();
134                            } catch (IOException e) {
135                                Log.e(this, e, "Unable to close input stream.");
136                            }
137                        }
138                    }
139
140                    // Listener will synchronize as needed
141                    Log.d(this, "Notifying listener: " + args.listener.toString() +
142                            " image: " + args.displayPhotoUri + " completed");
143                    args.listener.onImageLoadComplete(msg.what, args.photo, args.photoIcon,
144                                args.cookie);
145                    break;
146                default:
147                    break;
148            }
149        }
150
151        /**
152         * Returns a Bitmap object suitable for {@link Notification}'s large icon. This might
153         * return null when the given Drawable isn't BitmapDrawable, or if the system fails to
154         * create a scaled Bitmap for the Drawable.
155         */
156        private Bitmap getPhotoIconWhenAppropriate(Context context, Drawable photo) {
157            if (!(photo instanceof BitmapDrawable)) {
158                return null;
159            }
160            int iconSize = context.getResources()
161                    .getDimensionPixelSize(R.dimen.notification_icon_size);
162            Bitmap orgBitmap = ((BitmapDrawable) photo).getBitmap();
163            int orgWidth = orgBitmap.getWidth();
164            int orgHeight = orgBitmap.getHeight();
165            int longerEdge = orgWidth > orgHeight ? orgWidth : orgHeight;
166            // We want downscaled one only when the original icon is too big.
167            if (longerEdge > iconSize) {
168                float ratio = ((float) longerEdge) / iconSize;
169                int newWidth = (int) (orgWidth / ratio);
170                int newHeight = (int) (orgHeight / ratio);
171                // If the longer edge is much longer than the shorter edge, the latter may
172                // become 0 which will cause a crash.
173                if (newWidth <= 0 || newHeight <= 0) {
174                    Log.w(this, "Photo icon's width or height become 0.");
175                    return null;
176                }
177
178                // It is sure ratio >= 1.0f in any case and thus the newly created Bitmap
179                // should be smaller than the original.
180                return Bitmap.createScaledBitmap(orgBitmap, newWidth, newHeight, true);
181            } else {
182                return orgBitmap;
183            }
184        }
185    }
186
187    /**
188     * Starts an asynchronous image load. After finishing the load,
189     * {@link OnImageLoadCompleteListener#onImageLoadComplete(int, Drawable, Bitmap, Object)}
190     * will be called.
191     *
192     * @param token Arbitrary integer which will be returned as the first argument of
193     * {@link OnImageLoadCompleteListener#onImageLoadComplete(int, Drawable, Bitmap, Object)}
194     * @param context Context object used to do the time-consuming operation.
195     * @param displayPhotoUri Uri to be used to fetch the photo
196     * @param listener Callback object which will be used when the asynchronous load is done.
197     * Can be null, which means only the asynchronous load is done while there's no way to
198     * obtain the loaded photos.
199     * @param cookie Arbitrary object the caller wants to remember, which will become the
200     * fourth argument of {@link OnImageLoadCompleteListener#onImageLoadComplete(int, Drawable,
201     * Bitmap, Object)}. Can be null, at which the callback will also has null for the argument.
202     */
203    public void startObtainPhotoAsync(int token, Context context, Uri displayPhotoUri,
204            OnImageLoadCompleteListener listener, Object cookie) {
205        ensureAsyncHandlerStarted();
206
207        // in case the source caller info is null, the URI will be null as well.
208        // just update using the placeholder image in this case.
209        if (displayPhotoUri == null) {
210            Log.wtf(LOG_TAG, "Uri is missing");
211            return;
212        }
213
214        // Added additional Cookie field in the callee to handle arguments
215        // sent to the callback function.
216
217        // setup arguments
218        WorkerArgs args = new WorkerArgs();
219        args.cookie = cookie;
220        args.context = context;
221        args.displayPhotoUri = displayPhotoUri;
222        args.listener = listener;
223
224        // setup message arguments
225        Message msg = mThreadHandler.obtainMessage(token);
226        msg.arg1 = EVENT_LOAD_IMAGE;
227        msg.obj = args;
228
229        Log.d(LOG_TAG, "Begin loading image: " + args.displayPhotoUri +
230                ", displaying default image for now.");
231
232        // notify the thread to begin working
233        mThreadHandler.sendMessage(msg);
234    }
235
236    private void ensureAsyncHandlerStarted() {
237        if (mThreadHandler == null) {
238            HandlerThread thread = new HandlerThread("ContactsAsyncWorker");
239            thread.start();
240            mThreadHandler = new WorkerHandler(thread.getLooper());
241        }
242    }
243}
244