/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.tv.recommendation; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Rect; import android.media.tv.TvInputInfo; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.UiThread; import android.text.TextUtils; import android.util.Log; import android.util.SparseLongArray; import android.view.View; import com.android.tv.MainActivityWrapper.OnCurrentChannelChangeListener; import com.android.tv.R; import com.android.tv.Starter; import com.android.tv.TvSingletons; import com.android.tv.common.CommonConstants; import com.android.tv.common.WeakHandler; import com.android.tv.data.api.Channel; import com.android.tv.data.api.Program; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; import com.android.tv.util.images.BitmapUtils; import com.android.tv.util.images.BitmapUtils.ScaledBitmapInfo; import com.android.tv.util.images.ImageLoader; import java.util.ArrayList; import java.util.List; /** A local service for notify recommendation at home launcher. */ public class NotificationService extends Service implements Recommender.Listener, OnCurrentChannelChangeListener { private static final String TAG = "NotificationService"; private static final boolean DEBUG = false; public static final String ACTION_SHOW_RECOMMENDATION = CommonConstants.BASE_PACKAGE + ".notification.ACTION_SHOW_RECOMMENDATION"; public static final String ACTION_HIDE_RECOMMENDATION = CommonConstants.BASE_PACKAGE + ".notification.ACTION_HIDE_RECOMMENDATION"; /** * Recommendation intent has an extra data for the recommendation type. It'll be also sent to a * TV input as a tune parameter. */ public static final String TUNE_PARAMS_RECOMMENDATION_TYPE = CommonConstants.BASE_PACKAGE + ".recommendation_type"; private static final String TYPE_RANDOM_RECOMMENDATION = "random"; private static final String TYPE_ROUTINE_WATCH_RECOMMENDATION = "routine_watch"; private static final String TYPE_ROUTINE_WATCH_AND_FAVORITE_CHANNEL_RECOMMENDATION = "routine_watch_and_favorite"; private static final String NOTIFY_TAG = "tv_recommendation"; // TODO: find out proper number of notifications and whether to make it dynamically // configurable from system property or etc. private static final int NOTIFICATION_COUNT = 3; private static final int MSG_INITIALIZE_RECOMMENDER = 1000; private static final int MSG_SHOW_RECOMMENDATION = 1001; private static final int MSG_UPDATE_RECOMMENDATION = 1002; private static final int MSG_HIDE_RECOMMENDATION = 1003; private static final long RECOMMENDATION_RETRY_TIME_MS = 5 * 60 * 1000; // 5 min private static final long RECOMMENDATION_THRESHOLD_LEFT_TIME_MS = 10 * 60 * 1000; // 10 min private static final int RECOMMENDATION_THRESHOLD_PROGRESS = 90; // 90% private static final int MAX_PROGRAM_UPDATE_COUNT = 20; private TvInputManagerHelper mTvInputManagerHelper; private Recommender mRecommender; private boolean mShowRecommendationAfterRecommenderReady; private NotificationManager mNotificationManager; private HandlerThread mHandlerThread; private Handler mHandler; private final String mRecommendationType; private int mCurrentNotificationCount; private long[] mNotificationChannels; private Channel mPlayingChannel; private float mNotificationCardMaxWidth; private float mNotificationCardHeight; private int mCardImageHeight; private int mCardImageMaxWidth; private int mCardImageMinWidth; private int mChannelLogoMaxWidth; private int mChannelLogoMaxHeight; private int mLogoPaddingStart; private int mLogoPaddingBottom; public NotificationService() { mRecommendationType = TYPE_ROUTINE_WATCH_AND_FAVORITE_CHANNEL_RECOMMENDATION; } @Override public void onCreate() { if (DEBUG) Log.d(TAG, "onCreate"); Starter.start(this); super.onCreate(); mCurrentNotificationCount = 0; mNotificationChannels = new long[NOTIFICATION_COUNT]; for (int i = 0; i < NOTIFICATION_COUNT; ++i) { mNotificationChannels[i] = Channel.INVALID_ID; } mNotificationCardMaxWidth = getResources().getDimensionPixelSize(R.dimen.notif_card_img_max_width); mNotificationCardHeight = getResources().getDimensionPixelSize(R.dimen.notif_card_img_height); mCardImageHeight = getResources().getDimensionPixelSize(R.dimen.notif_card_img_height); mCardImageMaxWidth = getResources().getDimensionPixelSize(R.dimen.notif_card_img_max_width); mCardImageMinWidth = getResources().getDimensionPixelSize(R.dimen.notif_card_img_min_width); mChannelLogoMaxWidth = getResources().getDimensionPixelSize(R.dimen.notif_ch_logo_max_width); mChannelLogoMaxHeight = getResources().getDimensionPixelSize(R.dimen.notif_ch_logo_max_height); mLogoPaddingStart = getResources().getDimensionPixelOffset(R.dimen.notif_ch_logo_padding_start); mLogoPaddingBottom = getResources().getDimensionPixelOffset(R.dimen.notif_ch_logo_padding_bottom); mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); TvSingletons tvSingletons = TvSingletons.getSingletons(this); mTvInputManagerHelper = tvSingletons.getTvInputManagerHelper(); mHandlerThread = new HandlerThread("tv notification"); mHandlerThread.start(); mHandler = new NotificationHandler(mHandlerThread.getLooper(), this); mHandler.sendEmptyMessage(MSG_INITIALIZE_RECOMMENDER); // Just called for early initialization. tvSingletons.getChannelDataManager(); tvSingletons.getProgramDataManager(); tvSingletons.getMainActivityWrapper().addOnCurrentChannelChangeListener(this); } @UiThread @Override public void onCurrentChannelChange(@Nullable Channel channel) { if (DEBUG) Log.d(TAG, "onCurrentChannelChange"); mPlayingChannel = channel; mHandler.removeMessages(MSG_SHOW_RECOMMENDATION); mHandler.sendEmptyMessage(MSG_SHOW_RECOMMENDATION); } private void handleInitializeRecommender() { mRecommender = new Recommender(NotificationService.this, NotificationService.this, true); if (TYPE_RANDOM_RECOMMENDATION.equals(mRecommendationType)) { mRecommender.registerEvaluator(new RandomEvaluator()); } else if (TYPE_ROUTINE_WATCH_RECOMMENDATION.equals(mRecommendationType)) { mRecommender.registerEvaluator(new RoutineWatchEvaluator()); } else if (TYPE_ROUTINE_WATCH_AND_FAVORITE_CHANNEL_RECOMMENDATION.equals( mRecommendationType)) { mRecommender.registerEvaluator(new FavoriteChannelEvaluator(), 0.5, 0.5); mRecommender.registerEvaluator(new RoutineWatchEvaluator(), 1.0, 1.0); } else { throw new IllegalStateException( "Undefined recommendation type: " + mRecommendationType); } } private void handleShowRecommendation() { if (mRecommender == null) { return; } if (!mRecommender.isReady()) { mShowRecommendationAfterRecommenderReady = true; } else { showRecommendation(); } } private void handleUpdateRecommendation(int notificationId, Channel channel) { if (mNotificationChannels[notificationId] == Channel.INVALID_ID || !sendNotification(channel.getId(), notificationId)) { changeRecommendation(notificationId); } } private void handleHideRecommendation() { if (mRecommender == null) { return; } if (!mRecommender.isReady()) { mShowRecommendationAfterRecommenderReady = false; } else { hideAllRecommendation(); } } @Override public void onDestroy() { TvSingletons.getSingletons(this) .getMainActivityWrapper() .removeOnCurrentChannelChangeListener(this); if (mRecommender != null) { mRecommender.release(); mRecommender = null; } if (mHandlerThread != null) { mHandlerThread.quit(); mHandlerThread = null; mHandler = null; } super.onDestroy(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (DEBUG) Log.d(TAG, "onStartCommand"); if (intent != null) { String action = intent.getAction(); if (ACTION_SHOW_RECOMMENDATION.equals(action)) { mHandler.removeMessages(MSG_SHOW_RECOMMENDATION); mHandler.removeMessages(MSG_HIDE_RECOMMENDATION); mHandler.obtainMessage(MSG_SHOW_RECOMMENDATION).sendToTarget(); } else if (ACTION_HIDE_RECOMMENDATION.equals(action)) { mHandler.removeMessages(MSG_SHOW_RECOMMENDATION); mHandler.removeMessages(MSG_UPDATE_RECOMMENDATION); mHandler.removeMessages(MSG_HIDE_RECOMMENDATION); mHandler.obtainMessage(MSG_HIDE_RECOMMENDATION).sendToTarget(); } } return START_STICKY; } @Override public IBinder onBind(Intent intent) { return null; } @Override public void onRecommenderReady() { if (DEBUG) Log.d(TAG, "onRecommendationReady"); if (mShowRecommendationAfterRecommenderReady) { mHandler.removeMessages(MSG_SHOW_RECOMMENDATION); mHandler.sendEmptyMessage(MSG_SHOW_RECOMMENDATION); mShowRecommendationAfterRecommenderReady = false; } } @Override public void onRecommendationChanged() { if (DEBUG) Log.d(TAG, "onRecommendationChanged"); // Update recommendation on the handler thread. mHandler.removeMessages(MSG_SHOW_RECOMMENDATION); mHandler.sendEmptyMessage(MSG_SHOW_RECOMMENDATION); } private void showRecommendation() { if (DEBUG) Log.d(TAG, "showRecommendation"); SparseLongArray notificationChannels = new SparseLongArray(); for (int i = 0; i < NOTIFICATION_COUNT; ++i) { if (mNotificationChannels[i] == Channel.INVALID_ID) { continue; } notificationChannels.put(i, mNotificationChannels[i]); } List channels = recommendChannels(); for (Channel c : channels) { int index = notificationChannels.indexOfValue(c.getId()); if (index >= 0) { notificationChannels.removeAt(index); } } // Cancel notification whose channels are not recommended anymore. if (notificationChannels.size() > 0) { for (int i = 0; i < notificationChannels.size(); ++i) { int notificationId = notificationChannels.keyAt(i); mNotificationManager.cancel(NOTIFY_TAG, notificationId); mNotificationChannels[notificationId] = Channel.INVALID_ID; --mCurrentNotificationCount; } } for (Channel c : channels) { if (mCurrentNotificationCount >= NOTIFICATION_COUNT) { break; } if (!isNotifiedChannel(c.getId())) { sendNotification(c.getId(), getAvailableNotificationId()); } } if (mCurrentNotificationCount < NOTIFICATION_COUNT) { mHandler.sendEmptyMessageDelayed(MSG_SHOW_RECOMMENDATION, RECOMMENDATION_RETRY_TIME_MS); } } private void changeRecommendation(int notificationId) { if (DEBUG) Log.d(TAG, "changeRecommendation"); List channels = recommendChannels(); if (mNotificationChannels[notificationId] != Channel.INVALID_ID) { mNotificationChannels[notificationId] = Channel.INVALID_ID; --mCurrentNotificationCount; } for (Channel c : channels) { if (!isNotifiedChannel(c.getId())) { if (sendNotification(c.getId(), notificationId)) { return; } } } mNotificationManager.cancel(NOTIFY_TAG, notificationId); } private List recommendChannels() { List channels = mRecommender.recommendChannels(); if (channels.contains(mPlayingChannel)) { channels = new ArrayList<>(channels); channels.remove(mPlayingChannel); } return channels; } private void hideAllRecommendation() { for (int i = 0; i < NOTIFICATION_COUNT; ++i) { if (mNotificationChannels[i] != Channel.INVALID_ID) { mNotificationChannels[i] = Channel.INVALID_ID; mNotificationManager.cancel(NOTIFY_TAG, i); } } mCurrentNotificationCount = 0; } private boolean sendNotification(final long channelId, final int notificationId) { final ChannelRecord cr = mRecommender.getChannelRecord(channelId); if (cr == null) { return false; } final Channel channel = cr.getChannel(); if (DEBUG) { Log.d( TAG, "sendNotification (channelName=" + channel.getDisplayName() + " notifyId=" + notificationId + ")"); } // TODO: Move some checking logic into TvRecommendation. String inputId = Utils.getInputIdForChannel(this, channel.getId()); if (TextUtils.isEmpty(inputId)) { return false; } TvInputInfo inputInfo = mTvInputManagerHelper.getTvInputInfo(inputId); if (inputInfo == null) { return false; } final Program program = Utils.getCurrentProgram(this, channel.getId()); if (program == null) { return false; } final long programDurationMs = program.getEndTimeUtcMillis() - program.getStartTimeUtcMillis(); long programLeftTimsMs = program.getEndTimeUtcMillis() - System.currentTimeMillis(); final int programProgress = (programDurationMs <= 0) ? -1 : 100 - (int) (programLeftTimsMs * 100 / programDurationMs); // We recommend those programs that meet the condition only. if (programProgress >= RECOMMENDATION_THRESHOLD_PROGRESS && programLeftTimsMs <= RECOMMENDATION_THRESHOLD_LEFT_TIME_MS) { return false; } // We don't trust TIS to provide us with proper sized image ScaledBitmapInfo posterArtBitmapInfo = BitmapUtils.decodeSampledBitmapFromUriString( this, program.getPosterArtUri(), (int) mNotificationCardMaxWidth, (int) mNotificationCardHeight); if (posterArtBitmapInfo == null) { Log.e(TAG, "Failed to decode poster image for " + program.getPosterArtUri()); return false; } final Bitmap posterArtBitmap = posterArtBitmapInfo.bitmap; channel.loadBitmap( this, Channel.LOAD_IMAGE_TYPE_CHANNEL_LOGO, mChannelLogoMaxWidth, mChannelLogoMaxHeight, createChannelLogoCallback(this, notificationId, channel, program, posterArtBitmap)); if (mNotificationChannels[notificationId] == Channel.INVALID_ID) { ++mCurrentNotificationCount; } mNotificationChannels[notificationId] = channel.getId(); return true; } private void sendNotification( int notificationId, Bitmap channelLogo, Channel channel, Bitmap posterArtBitmap, Program program) { final long programDurationMs = program.getEndTimeUtcMillis() - program.getStartTimeUtcMillis(); long programLeftTimsMs = program.getEndTimeUtcMillis() - System.currentTimeMillis(); final int programProgress = (programDurationMs <= 0) ? -1 : 100 - (int) (programLeftTimsMs * 100 / programDurationMs); Intent intent = new Intent(Intent.ACTION_VIEW, channel.getUri()); intent.putExtra(TUNE_PARAMS_RECOMMENDATION_TYPE, mRecommendationType); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); final PendingIntent notificationIntent = PendingIntent.getActivity(this, 0, intent, 0); // This callback will run on the main thread. Bitmap largeIconBitmap = (channelLogo == null) ? posterArtBitmap : overlayChannelLogo(channelLogo, posterArtBitmap); String channelDisplayName = channel.getDisplayName(); Notification notification = new Notification.Builder(this) .setContentIntent(notificationIntent) .setContentTitle(program.getTitle()) .setContentText( TextUtils.isEmpty(channelDisplayName) ? channel.getDisplayNumber() : channelDisplayName) .setContentInfo(channelDisplayName) .setAutoCancel(true) .setLargeIcon(largeIconBitmap) .setSmallIcon(R.drawable.ic_launcher_s) .setCategory(Notification.CATEGORY_RECOMMENDATION) .setProgress((programProgress > 0) ? 100 : 0, programProgress, false) .setSortKey(mRecommender.getChannelSortKey(channel.getId())) .build(); notification.color = getResources().getColor(R.color.recommendation_card_background, null); if (!TextUtils.isEmpty(program.getThumbnailUri())) { notification.extras.putString( Notification.EXTRA_BACKGROUND_IMAGE_URI, program.getThumbnailUri()); } mNotificationManager.notify(NOTIFY_TAG, notificationId, notification); Message msg = mHandler.obtainMessage(MSG_UPDATE_RECOMMENDATION, notificationId, 0, channel); mHandler.sendMessageDelayed(msg, programDurationMs / MAX_PROGRAM_UPDATE_COUNT); } @NonNull private static ImageLoader.ImageLoaderCallback createChannelLogoCallback( NotificationService service, final int notificationId, final Channel channel, final Program program, final Bitmap posterArtBitmap) { return new ImageLoader.ImageLoaderCallback(service) { @Override public void onBitmapLoaded(NotificationService service, Bitmap channelLogo) { service.sendNotification( notificationId, channelLogo, channel, posterArtBitmap, program); } }; } private Bitmap overlayChannelLogo(Bitmap logo, Bitmap background) { Bitmap result = BitmapUtils.getScaledMutableBitmap(background, Integer.MAX_VALUE, mCardImageHeight); Bitmap scaledLogo = BitmapUtils.scaleBitmap(logo, mChannelLogoMaxWidth, mChannelLogoMaxHeight); Canvas canvas; try { canvas = new Canvas(result); } catch (Exception e) { Log.w(TAG, "Failed to create Canvas", e); return background; } canvas.drawBitmap(result, new Matrix(), null); Rect rect = new Rect(); int startPadding; if (result.getWidth() < mCardImageMinWidth) { // TODO: check the positions. startPadding = mLogoPaddingStart; rect.bottom = result.getHeight() - mLogoPaddingBottom; rect.top = rect.bottom - scaledLogo.getHeight(); } else if (result.getWidth() < mCardImageMaxWidth) { startPadding = mLogoPaddingStart; rect.bottom = result.getHeight() - mLogoPaddingBottom; rect.top = rect.bottom - scaledLogo.getHeight(); } else { int marginStart = (result.getWidth() - mCardImageMaxWidth) / 2; startPadding = mLogoPaddingStart + marginStart; rect.bottom = result.getHeight() - mLogoPaddingBottom; rect.top = rect.bottom - scaledLogo.getHeight(); } if (getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_LTR) { rect.left = startPadding; rect.right = startPadding + scaledLogo.getWidth(); } else { rect.right = result.getWidth() - startPadding; rect.left = rect.right - scaledLogo.getWidth(); } Paint paint = new Paint(); paint.setAlpha(getResources().getInteger(R.integer.notif_card_ch_logo_alpha)); canvas.drawBitmap(scaledLogo, null, rect, paint); return result; } private boolean isNotifiedChannel(long channelId) { for (int i = 0; i < NOTIFICATION_COUNT; ++i) { if (mNotificationChannels[i] == channelId) { return true; } } return false; } private int getAvailableNotificationId() { for (int i = 0; i < NOTIFICATION_COUNT; ++i) { if (mNotificationChannels[i] == Channel.INVALID_ID) { return i; } } return -1; } private static class NotificationHandler extends WeakHandler { public NotificationHandler(@NonNull Looper looper, NotificationService ref) { super(looper, ref); } @Override public void handleMessage(Message msg, @NonNull NotificationService notificationService) { switch (msg.what) { case MSG_INITIALIZE_RECOMMENDER: { notificationService.handleInitializeRecommender(); break; } case MSG_SHOW_RECOMMENDATION: { notificationService.handleShowRecommendation(); break; } case MSG_UPDATE_RECOMMENDATION: { int notificationId = msg.arg1; Channel channel = ((Channel) msg.obj); notificationService.handleUpdateRecommendation(notificationId, channel); break; } case MSG_HIDE_RECOMMENDATION: { notificationService.handleHideRecommendation(); break; } default: { super.handleMessage(msg); } } } } }