blob: 870b3c1035bb7395fd8777332ef7606fdb1d2a51 [file] [log] [blame]
/*
* 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<Channel> 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<Channel> 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<Channel> 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,
PendingIntent.FLAG_IMMUTABLE);
// 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<NotificationService> createChannelLogoCallback(
NotificationService service,
final int notificationId,
final Channel channel,
final Program program,
final Bitmap posterArtBitmap) {
return new ImageLoader.ImageLoaderCallback<NotificationService>(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<NotificationService> {
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);
}
}
}
}
}