1/*
2 * Copyright (C) 2015 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.tv.recommendation;
18
19import android.app.Notification;
20import android.app.NotificationManager;
21import android.app.PendingIntent;
22import android.app.Service;
23import android.content.Context;
24import android.content.Intent;
25import android.graphics.Bitmap;
26import android.graphics.Canvas;
27import android.graphics.Matrix;
28import android.graphics.Paint;
29import android.graphics.Rect;
30import android.media.tv.TvInputInfo;
31import android.os.Build;
32import android.os.Handler;
33import android.os.HandlerThread;
34import android.os.IBinder;
35import android.os.Looper;
36import android.os.Message;
37import android.support.annotation.NonNull;
38import android.support.annotation.Nullable;
39import android.support.annotation.UiThread;
40import android.text.TextUtils;
41import android.util.Log;
42import android.util.SparseLongArray;
43import android.view.View;
44
45import com.android.tv.ApplicationSingletons;
46import com.android.tv.MainActivityWrapper.OnCurrentChannelChangeListener;
47import com.android.tv.R;
48import com.android.tv.TvApplication;
49import com.android.tv.common.WeakHandler;
50import com.android.tv.data.Channel;
51import com.android.tv.data.Program;
52import com.android.tv.util.BitmapUtils;
53import com.android.tv.util.BitmapUtils.ScaledBitmapInfo;
54import com.android.tv.util.ImageLoader;
55import com.android.tv.util.PermissionUtils;
56import com.android.tv.util.TvInputManagerHelper;
57import com.android.tv.util.Utils;
58
59import java.util.ArrayList;
60import java.util.List;
61
62/**
63 * A local service for notify recommendation at home launcher.
64 */
65public class NotificationService extends Service implements Recommender.Listener,
66        OnCurrentChannelChangeListener {
67    private static final String TAG = "NotificationService";
68    private static final boolean DEBUG = false;
69
70    public static final String ACTION_SHOW_RECOMMENDATION =
71            "com.android.tv.notification.ACTION_SHOW_RECOMMENDATION";
72    public static final String ACTION_HIDE_RECOMMENDATION =
73            "com.android.tv.notification.ACTION_HIDE_RECOMMENDATION";
74
75    /**
76     * Recommendation intent has an extra data for the recommendation type. It'll be also
77     * sent to a TV input as a tune parameter.
78     */
79    public static final String TUNE_PARAMS_RECOMMENDATION_TYPE =
80            "com.android.tv.recommendation_type";
81
82    private static final String TYPE_RANDOM_RECOMMENDATION = "random";
83    private static final String TYPE_ROUTINE_WATCH_RECOMMENDATION = "routine_watch";
84    private static final String TYPE_ROUTINE_WATCH_AND_FAVORITE_CHANNEL_RECOMMENDATION =
85            "routine_watch_and_favorite";
86
87    private static final String NOTIFY_TAG = "tv_recommendation";
88    // TODO: find out proper number of notifications and whether to make it dynamically
89    // configurable from system property or etc.
90    private static final int NOTIFICATION_COUNT = 3;
91
92    private static final int MSG_INITIALIZE_RECOMMENDER = 1000;
93    private static final int MSG_SHOW_RECOMMENDATION = 1001;
94    private static final int MSG_UPDATE_RECOMMENDATION = 1002;
95    private static final int MSG_HIDE_RECOMMENDATION = 1003;
96
97    private static final long RECOMMENDATION_RETRY_TIME_MS = 5 * 60 * 1000;  // 5 min
98    private static final long RECOMMENDATION_THRESHOLD_LEFT_TIME_MS = 10 * 60 * 1000;  // 10 min
99    private static final int RECOMMENDATION_THRESHOLD_PROGRESS = 90;  // 90%
100    private static final int MAX_PROGRAM_UPDATE_COUNT = 20;
101
102    private TvInputManagerHelper mTvInputManagerHelper;
103    private Recommender mRecommender;
104    private boolean mShowRecommendationAfterRecommenderReady;
105    private NotificationManager mNotificationManager;
106    private HandlerThread mHandlerThread;
107    private Handler mHandler;
108    private final String mRecommendationType;
109    private int mCurrentNotificationCount;
110    private long[] mNotificationChannels;
111
112    private Channel mPlayingChannel;
113
114    private float mNotificationCardMaxWidth;
115    private float mNotificationCardHeight;
116    private int mCardImageHeight;
117    private int mCardImageMaxWidth;
118    private int mCardImageMinWidth;
119    private int mChannelLogoMaxWidth;
120    private int mChannelLogoMaxHeight;
121    private int mLogoPaddingStart;
122    private int mLogoPaddingBottom;
123
124    public NotificationService() {
125        mRecommendationType = TYPE_ROUTINE_WATCH_AND_FAVORITE_CHANNEL_RECOMMENDATION;
126    }
127
128    @Override
129    public void onCreate() {
130        if (DEBUG) Log.d(TAG, "onCreate");
131        super.onCreate();
132        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M
133                && !PermissionUtils.hasAccessAllEpg(this)) {
134            Log.w(TAG, "Live TV requires the system permission on this platform.");
135            stopSelf();
136            return;
137        }
138
139        mCurrentNotificationCount = 0;
140        mNotificationChannels = new long[NOTIFICATION_COUNT];
141        for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
142            mNotificationChannels[i] = Channel.INVALID_ID;
143        }
144        mNotificationCardMaxWidth = getResources().getDimensionPixelSize(
145                R.dimen.notif_card_img_max_width);
146        mNotificationCardHeight = getResources().getDimensionPixelSize(
147                R.dimen.notif_card_img_height);
148        mCardImageHeight = getResources().getDimensionPixelSize(R.dimen.notif_card_img_height);
149        mCardImageMaxWidth = getResources().getDimensionPixelSize(R.dimen.notif_card_img_max_width);
150        mCardImageMinWidth = getResources().getDimensionPixelSize(R.dimen.notif_card_img_min_width);
151        mChannelLogoMaxWidth =
152                getResources().getDimensionPixelSize(R.dimen.notif_ch_logo_max_width);
153        mChannelLogoMaxHeight =
154                getResources().getDimensionPixelSize(R.dimen.notif_ch_logo_max_height);
155        mLogoPaddingStart =
156                getResources().getDimensionPixelOffset(R.dimen.notif_ch_logo_padding_start);
157        mLogoPaddingBottom =
158                getResources().getDimensionPixelOffset(R.dimen.notif_ch_logo_padding_bottom);
159
160        mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
161        ApplicationSingletons appSingletons = TvApplication.getSingletons(this);
162        mTvInputManagerHelper = appSingletons.getTvInputManagerHelper();
163        mHandlerThread = new HandlerThread("tv notification");
164        mHandlerThread.start();
165        mHandler = new NotificationHandler(mHandlerThread.getLooper(), this);
166        mHandler.sendEmptyMessage(MSG_INITIALIZE_RECOMMENDER);
167
168        // Just called for early initialization.
169        appSingletons.getChannelDataManager();
170        appSingletons.getProgramDataManager();
171        appSingletons.getMainActivityWrapper().addOnCurrentChannelChangeListener(this);
172    }
173
174    @UiThread
175    @Override
176    public void onCurrentChannelChange(@Nullable Channel channel) {
177        if (DEBUG) Log.d(TAG, "onCurrentChannelChange");
178        mPlayingChannel = channel;
179        mHandler.removeMessages(MSG_SHOW_RECOMMENDATION);
180        mHandler.sendEmptyMessage(MSG_SHOW_RECOMMENDATION);
181    }
182
183    private void handleInitializeRecommender() {
184        mRecommender = new Recommender(NotificationService.this, NotificationService.this, true);
185        if (TYPE_RANDOM_RECOMMENDATION.equals(mRecommendationType)) {
186            mRecommender.registerEvaluator(new RandomEvaluator());
187        } else if (TYPE_ROUTINE_WATCH_RECOMMENDATION.equals(mRecommendationType)) {
188            mRecommender.registerEvaluator(new RoutineWatchEvaluator());
189        } else if (TYPE_ROUTINE_WATCH_AND_FAVORITE_CHANNEL_RECOMMENDATION
190                .equals(mRecommendationType)) {
191            mRecommender.registerEvaluator(new FavoriteChannelEvaluator(), 0.5, 0.5);
192            mRecommender.registerEvaluator(new RoutineWatchEvaluator(), 1.0, 1.0);
193        } else {
194            throw new IllegalStateException(
195                    "Undefined recommendation type: " + mRecommendationType);
196        }
197    }
198
199    private void handleShowRecommendation() {
200        if (!mRecommender.isReady()) {
201            mShowRecommendationAfterRecommenderReady = true;
202        } else {
203            showRecommendation();
204        }
205    }
206
207    private void handleUpdateRecommendation(int notificationId, Channel channel) {
208        if (mNotificationChannels[notificationId] == Channel.INVALID_ID || !sendNotification(
209                channel.getId(), notificationId)) {
210            changeRecommendation(notificationId);
211        }
212    }
213
214    private void handleHideRecommendation() {
215        if (!mRecommender.isReady()) {
216            mShowRecommendationAfterRecommenderReady = false;
217        } else {
218            hideAllRecommendation();
219        }
220    }
221
222    @Override
223    public void onDestroy() {
224        TvApplication.getSingletons(this).getMainActivityWrapper()
225                .removeOnCurrentChannelChangeListener(this);
226        if (mRecommender != null) {
227            mRecommender.release();
228            mRecommender = null;
229        }
230        if (mHandlerThread != null) {
231            mHandlerThread.quit();
232            mHandlerThread = null;
233            mHandler = null;
234        }
235        super.onDestroy();
236    }
237
238    @Override
239    public int onStartCommand(Intent intent, int flags, int startId) {
240        if (DEBUG) Log.d(TAG, "onStartCommand");
241        if (intent != null) {
242            String action = intent.getAction();
243            if (ACTION_SHOW_RECOMMENDATION.equals(action)) {
244                mHandler.removeMessages(MSG_SHOW_RECOMMENDATION);
245                mHandler.removeMessages(MSG_HIDE_RECOMMENDATION);
246                mHandler.obtainMessage(MSG_SHOW_RECOMMENDATION).sendToTarget();
247            } else if (ACTION_HIDE_RECOMMENDATION.equals(action)) {
248                mHandler.removeMessages(MSG_SHOW_RECOMMENDATION);
249                mHandler.removeMessages(MSG_UPDATE_RECOMMENDATION);
250                mHandler.removeMessages(MSG_HIDE_RECOMMENDATION);
251                mHandler.obtainMessage(MSG_HIDE_RECOMMENDATION).sendToTarget();
252            }
253        }
254        return START_STICKY;
255    }
256
257    @Override
258    public IBinder onBind(Intent intent) {
259        return null;
260    }
261
262    @Override
263    public void onRecommenderReady() {
264        if (DEBUG) Log.d(TAG, "onRecommendationReady");
265        if (mShowRecommendationAfterRecommenderReady) {
266            mHandler.removeMessages(MSG_SHOW_RECOMMENDATION);
267            mHandler.sendEmptyMessage(MSG_SHOW_RECOMMENDATION);
268            mShowRecommendationAfterRecommenderReady = false;
269        }
270    }
271
272    @Override
273    public void onRecommendationChanged() {
274        if (DEBUG) Log.d(TAG, "onRecommendationChanged");
275        // Update recommendation on the handler thread.
276        mHandler.removeMessages(MSG_SHOW_RECOMMENDATION);
277        mHandler.sendEmptyMessage(MSG_SHOW_RECOMMENDATION);
278    }
279
280    private void showRecommendation() {
281        if (DEBUG) Log.d(TAG, "showRecommendation");
282        SparseLongArray notificationChannels = new SparseLongArray();
283        for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
284            if (mNotificationChannels[i] == Channel.INVALID_ID) {
285                continue;
286            }
287            notificationChannels.put(i, mNotificationChannels[i]);
288        }
289        List<Channel> channels = recommendChannels();
290        for (Channel c : channels) {
291            int index = notificationChannels.indexOfValue(c.getId());
292            if (index >= 0) {
293                notificationChannels.removeAt(index);
294            }
295        }
296        // Cancel notification whose channels are not recommended anymore.
297        if (notificationChannels.size() > 0) {
298            for (int i = 0; i < notificationChannels.size(); ++i) {
299                int notificationId = notificationChannels.keyAt(i);
300                mNotificationManager.cancel(NOTIFY_TAG, notificationId);
301                mNotificationChannels[notificationId] = Channel.INVALID_ID;
302                --mCurrentNotificationCount;
303            }
304        }
305        for (Channel c : channels) {
306            if (mCurrentNotificationCount >= NOTIFICATION_COUNT) {
307                break;
308            }
309            if (!isNotifiedChannel(c.getId())) {
310                sendNotification(c.getId(), getAvailableNotificationId());
311            }
312        }
313        if (mCurrentNotificationCount < NOTIFICATION_COUNT) {
314            mHandler.sendEmptyMessageDelayed(MSG_SHOW_RECOMMENDATION, RECOMMENDATION_RETRY_TIME_MS);
315        }
316    }
317
318    private void changeRecommendation(int notificationId) {
319        if (DEBUG) Log.d(TAG, "changeRecommendation");
320        List<Channel> channels = recommendChannels();
321        if (mNotificationChannels[notificationId] != Channel.INVALID_ID) {
322            mNotificationChannels[notificationId] = Channel.INVALID_ID;
323            --mCurrentNotificationCount;
324        }
325        for (Channel c : channels) {
326            if (!isNotifiedChannel(c.getId())) {
327                if(sendNotification(c.getId(), notificationId)) {
328                    return;
329                }
330            }
331        }
332        mNotificationManager.cancel(NOTIFY_TAG, notificationId);
333    }
334
335    private List<Channel> recommendChannels() {
336        List channels = mRecommender.recommendChannels();
337        if (channels.contains(mPlayingChannel)) {
338            channels = new ArrayList<>(channels);
339            channels.remove(mPlayingChannel);
340        }
341        return channels;
342    }
343
344    private void hideAllRecommendation() {
345       for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
346           if (mNotificationChannels[i] != Channel.INVALID_ID) {
347               mNotificationChannels[i] = Channel.INVALID_ID;
348               mNotificationManager.cancel(NOTIFY_TAG, i);
349           }
350       }
351       mCurrentNotificationCount = 0;
352    }
353
354    private boolean sendNotification(final long channelId, final int notificationId) {
355        final ChannelRecord cr = mRecommender.getChannelRecord(channelId);
356        if (cr == null) {
357            return false;
358        }
359        final Channel channel = cr.getChannel();
360        if (DEBUG) {
361            Log.d(TAG, "sendNotification (channelName=" + channel.getDisplayName() + " notifyId="
362                    + notificationId + ")");
363        }
364
365        // TODO: Move some checking logic into TvRecommendation.
366        String inputId = Utils.getInputIdForChannel(this, channel.getId());
367        if (TextUtils.isEmpty(inputId)) {
368            return false;
369        }
370        TvInputInfo inputInfo = mTvInputManagerHelper.getTvInputInfo(inputId);
371        if (inputInfo == null) {
372            return false;
373        }
374        final String inputDisplayName = inputInfo.loadLabel(this).toString();
375
376        final Program program = Utils.getCurrentProgram(this, channel.getId());
377        if (program == null) {
378            return false;
379        }
380        final long programDurationMs = program.getEndTimeUtcMillis()
381                - program.getStartTimeUtcMillis();
382        long programLeftTimsMs = program.getEndTimeUtcMillis() - System.currentTimeMillis();
383        final int programProgress = (programDurationMs <= 0) ? -1
384                : 100 - (int) (programLeftTimsMs * 100 / programDurationMs);
385
386        // We recommend those programs that meet the condition only.
387        if (programProgress >= RECOMMENDATION_THRESHOLD_PROGRESS
388                && programLeftTimsMs <= RECOMMENDATION_THRESHOLD_LEFT_TIME_MS) {
389            return false;
390        }
391
392        // We don't trust TIS to provide us with proper sized image
393        ScaledBitmapInfo posterArtBitmapInfo = BitmapUtils.decodeSampledBitmapFromUriString(this,
394                program.getPosterArtUri(), (int) mNotificationCardMaxWidth,
395                (int) mNotificationCardHeight);
396        if (posterArtBitmapInfo == null) {
397            Log.e(TAG, "Failed to decode poster image for " + program.getPosterArtUri());
398            return false;
399        }
400        final Bitmap posterArtBitmap = posterArtBitmapInfo.bitmap;
401
402        channel.loadBitmap(this, Channel.LOAD_IMAGE_TYPE_CHANNEL_LOGO, mChannelLogoMaxWidth,
403                mChannelLogoMaxHeight,
404                createChannelLogoCallback(this, notificationId, inputDisplayName, channel, program,
405                        posterArtBitmap));
406
407        if (mNotificationChannels[notificationId] == Channel.INVALID_ID) {
408            ++mCurrentNotificationCount;
409        }
410        mNotificationChannels[notificationId] = channel.getId();
411
412        return true;
413    }
414
415    @NonNull
416    private static ImageLoader.ImageLoaderCallback<NotificationService> createChannelLogoCallback(
417            NotificationService service, final int notificationId, final String inputDisplayName,
418            final Channel channel, final Program program, final Bitmap posterArtBitmap) {
419        return new ImageLoader.ImageLoaderCallback<NotificationService>(service) {
420            @Override
421            public void onBitmapLoaded(NotificationService service, Bitmap channelLogo) {
422                service.sendNotification(notificationId, channelLogo, channel, posterArtBitmap,
423                        program, inputDisplayName);
424            }
425        };
426    }
427
428    private void sendNotification(int notificationId, Bitmap channelLogo, Channel channel,
429            Bitmap posterArtBitmap, Program program, String inputDisplayName1) {
430
431        final long programDurationMs = program.getEndTimeUtcMillis() - program
432                .getStartTimeUtcMillis();
433        long programLeftTimsMs = program.getEndTimeUtcMillis() - System.currentTimeMillis();
434        final int programProgress = (programDurationMs <= 0) ? -1
435                : 100 - (int) (programLeftTimsMs * 100 / programDurationMs);
436        Intent intent = new Intent(Intent.ACTION_VIEW, channel.getUri());
437        intent.putExtra(TUNE_PARAMS_RECOMMENDATION_TYPE, mRecommendationType);
438        final PendingIntent notificationIntent = PendingIntent.getActivity(this, 0, intent, 0);
439
440        // This callback will run on the main thread.
441        Bitmap largeIconBitmap = (channelLogo == null) ? posterArtBitmap
442                : overlayChannelLogo(channelLogo, posterArtBitmap);
443        String channelDisplayName = channel.getDisplayName();
444        Notification notification = new Notification.Builder(this)
445                .setContentIntent(notificationIntent).setContentTitle(program.getTitle())
446                .setContentText(inputDisplayName1 + " " +
447                        (TextUtils.isEmpty(channelDisplayName) ? channel.getDisplayNumber()
448                                : channelDisplayName)).setContentInfo(channelDisplayName)
449                .setAutoCancel(true).setLargeIcon(largeIconBitmap)
450                .setSmallIcon(R.drawable.ic_launcher_s)
451                .setCategory(Notification.CATEGORY_RECOMMENDATION)
452                .setProgress((programProgress > 0) ? 100 : 0, programProgress, false)
453                .setSortKey(mRecommender.getChannelSortKey(channel.getId())).build();
454        notification.color = Utils.getColor(getResources(), R.color.recommendation_card_background);
455        if (!TextUtils.isEmpty(program.getThumbnailUri())) {
456            notification.extras
457                    .putString(Notification.EXTRA_BACKGROUND_IMAGE_URI, program.getThumbnailUri());
458        }
459        mNotificationManager.notify(NOTIFY_TAG, notificationId, notification);
460        Message msg = mHandler.obtainMessage(MSG_UPDATE_RECOMMENDATION, notificationId, 0, channel);
461        mHandler.sendMessageDelayed(msg, programDurationMs / MAX_PROGRAM_UPDATE_COUNT);
462    }
463
464    private Bitmap overlayChannelLogo(Bitmap logo, Bitmap background) {
465        Bitmap result = BitmapUtils.scaleBitmap(
466                background, Integer.MAX_VALUE, mCardImageHeight);
467        Bitmap scaledLogo = BitmapUtils.scaleBitmap(
468                logo, mChannelLogoMaxWidth, mChannelLogoMaxHeight);
469        Canvas canvas = new Canvas(result);
470        canvas.drawBitmap(result, new Matrix(), null);
471        Rect rect = new Rect();
472        int startPadding;
473        if (result.getWidth() < mCardImageMinWidth) {
474            // TODO: check the positions.
475            startPadding = mLogoPaddingStart;
476            rect.bottom = result.getHeight() - mLogoPaddingBottom;
477            rect.top = rect.bottom - scaledLogo.getHeight();
478        } else if (result.getWidth() < mCardImageMaxWidth) {
479            startPadding = mLogoPaddingStart;
480            rect.bottom = result.getHeight() - mLogoPaddingBottom;
481            rect.top = rect.bottom - scaledLogo.getHeight();
482        } else {
483            int marginStart = (result.getWidth() - mCardImageMaxWidth) / 2;
484            startPadding = mLogoPaddingStart + marginStart;
485            rect.bottom = result.getHeight() - mLogoPaddingBottom;
486            rect.top = rect.bottom - scaledLogo.getHeight();
487        }
488        if (getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_LTR) {
489            rect.left = startPadding;
490            rect.right = startPadding + scaledLogo.getWidth();
491        } else {
492            rect.right = result.getWidth() - startPadding;
493            rect.left = rect.right - scaledLogo.getWidth();
494        }
495        Paint paint = new Paint();
496        paint.setAlpha(getResources().getInteger(R.integer.notif_card_ch_logo_alpha));
497        canvas.drawBitmap(scaledLogo, null, rect, paint);
498        return result;
499    }
500
501    private boolean isNotifiedChannel(long channelId) {
502        for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
503            if (mNotificationChannels[i] == channelId) {
504                return true;
505            }
506        }
507        return false;
508    }
509
510    private int getAvailableNotificationId() {
511        for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
512            if (mNotificationChannels[i] == Channel.INVALID_ID) {
513                return i;
514            }
515        }
516        return -1;
517    }
518
519    private static class NotificationHandler extends WeakHandler<NotificationService> {
520        public NotificationHandler(@NonNull Looper looper, NotificationService ref) {
521            super(looper, ref);
522        }
523
524        @Override
525        public void handleMessage(Message msg, @NonNull NotificationService notificationService) {
526            switch (msg.what) {
527                case MSG_INITIALIZE_RECOMMENDER: {
528                    notificationService.handleInitializeRecommender();
529                    break;
530                }
531                case MSG_SHOW_RECOMMENDATION: {
532                    notificationService.handleShowRecommendation();
533                    break;
534                }
535                case MSG_UPDATE_RECOMMENDATION: {
536                    int notificationId = msg.arg1;
537                    Channel channel = ((Channel) msg.obj);
538                    notificationService.handleUpdateRecommendation(notificationId, channel);
539                    break;
540                }
541                case MSG_HIDE_RECOMMENDATION: {
542                    notificationService.handleHideRecommendation();
543                    break;
544                }
545                default: {
546                    super.handleMessage(msg);
547                }
548            }
549        }
550    }
551}
552