blob: 3b093b6a00d6fa807c320845dfc037a8add130fc [file] [log] [blame]
/*
* Copyright (C) 2016 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.data.epg;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.ContentProviderOperation;
import android.content.ContentValues;
import android.content.Context;
import android.content.OperationApplicationException;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.location.Address;
import android.media.tv.TvContentRating;
import android.media.tv.TvContract;
import android.media.tv.TvContract.Programs;
import android.media.tv.TvContract.Programs.Genres;
import android.media.tv.TvInputInfo;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.os.RemoteException;
import android.preference.PreferenceManager;
import android.support.annotation.MainThread;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.os.BuildCompat;
import android.text.TextUtils;
import android.util.Log;
import com.android.tv.TvApplication;
import com.android.tv.common.WeakHandler;
import com.android.tv.data.Channel;
import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.InternalDataUtils;
import com.android.tv.data.Lineup;
import com.android.tv.data.Program;
import com.android.tv.util.LocationUtils;
import com.android.tv.util.RecurringRunner;
import com.android.tv.util.Utils;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* An utility class to fetch the EPG. This class isn't thread-safe.
*/
public class EpgFetcher {
private static final String TAG = "EpgFetcher";
private static final boolean DEBUG = false;
private static final int MSG_FETCH_EPG = 1;
private static final long EPG_PREFETCH_RECURRING_PERIOD_MS = TimeUnit.HOURS.toMillis(4);
private static final long EPG_READER_INIT_WAIT_MS = TimeUnit.MINUTES.toMillis(1);
private static final long LOCATION_INIT_WAIT_MS = TimeUnit.SECONDS.toMillis(10);
private static final long LOCATION_ERROR_WAIT_MS = TimeUnit.HOURS.toMillis(1);
private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(30);
private static final int BATCH_OPERATION_COUNT = 100;
private static final String SUPPORTED_COUNTRY_CODE = Locale.US.getCountry();
private static final String CONTENT_RATING_SEPARATOR = ",";
// Value: Long
private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP =
"com.android.tv.data.epg.EpgFetcher.LastUpdatedEpgTimestamp";
// Value: String
private static final String KEY_LAST_LINEUP_ID =
"com.android.tv.data.epg.EpgFetcher.LastLineupId";
private static EpgFetcher sInstance;
private final Context mContext;
private final ChannelDataManager mChannelDataManager;
private final EpgReader mEpgReader;
private EpgFetcherHandler mHandler;
private RecurringRunner mRecurringRunner;
private boolean mStarted;
private long mLastEpgTimestamp = -1;
private String mLineupId;
public static synchronized EpgFetcher getInstance(Context context) {
if (sInstance == null) {
sInstance = new EpgFetcher(context.getApplicationContext());
}
return sInstance;
}
/**
* Creates and returns {@link EpgReader}.
*/
public static EpgReader createEpgReader(Context context) {
return new StubEpgReader(context);
}
private EpgFetcher(Context context) {
mContext = context;
mEpgReader = new StubEpgReader(mContext);
mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager();
mChannelDataManager.addListener(new ChannelDataManager.Listener() {
@Override
public void onLoadFinished() {
if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()");
handleChannelChanged();
}
@Override
public void onChannelListUpdated() {
if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()");
handleChannelChanged();
}
@Override
public void onChannelBrowsableChanged() {
if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelBrowsableChanged()");
handleChannelChanged();
}
});
}
private void handleChannelChanged() {
if (mStarted) {
if (needToStop()) {
stop();
}
} else {
start();
}
}
private boolean needToStop() {
return !canStart();
}
private boolean canStart() {
if (DEBUG) Log.d(TAG, "canStart()");
boolean hasInternalTunerChannel = false;
for (TvInputInfo input : TvApplication.getSingletons(mContext).getTvInputManagerHelper()
.getTvInputInfos(true, true)) {
String inputId = input.getId();
if (Utils.isInternalTvInput(mContext, inputId)
&& mChannelDataManager.getChannelCountForInput(inputId) > 0) {
hasInternalTunerChannel = true;
break;
}
}
if (!hasInternalTunerChannel) {
if (DEBUG) Log.d(TAG, "No internal tuner channels.");
return false;
}
if (!TextUtils.isEmpty(getLastLineupId())) {
return true;
}
if (mContext.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
if (DEBUG) Log.d(TAG, "No permission to check the current location.");
return false;
}
try {
Address address = LocationUtils.getCurrentAddress(mContext);
if (address != null
&& !TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) {
if (DEBUG) Log.d(TAG, "Country not supported: " + address.getCountryCode());
return false;
}
} catch (SecurityException e) {
Log.w(TAG, "No permission to get the current location", e);
return false;
} catch (IOException e) {
Log.w(TAG, "IO Exception when getting the current location", e);
}
return true;
}
/**
* Starts fetching EPG.
*/
@MainThread
public void start() {
if (DEBUG) Log.d(TAG, "start()");
if (mStarted) {
if (DEBUG) Log.d(TAG, "EpgFetcher thread already started.");
return;
}
if (!canStart()) {
return;
}
mStarted = true;
if (DEBUG) Log.d(TAG, "Starting EpgFetcher thread.");
HandlerThread handlerThread = new HandlerThread("EpgFetcher");
handlerThread.start();
mHandler = new EpgFetcherHandler(handlerThread.getLooper(), this);
mRecurringRunner = new RecurringRunner(mContext, EPG_PREFETCH_RECURRING_PERIOD_MS,
new EpgRunner(), null);
mRecurringRunner.start();
if (DEBUG) Log.d(TAG, "EpgFetcher thread started successfully.");
}
/**
* Starts fetching EPG immediately if possible without waiting for the timer.
*/
@MainThread
public void startImmediately() {
start();
if (mStarted) {
if (DEBUG) Log.d(TAG, "Starting fetcher immediately");
fetchEpg();
}
}
/**
* Stops fetching EPG.
*/
@MainThread
public void stop() {
if (DEBUG) Log.d(TAG, "stop()");
if (!mStarted) {
return;
}
mStarted = false;
mRecurringRunner.stop();
mHandler.removeCallbacksAndMessages(null);
mHandler.getLooper().quit();
}
private void fetchEpg() {
fetchEpg(0);
}
private void fetchEpg(long delay) {
mHandler.removeMessages(MSG_FETCH_EPG);
mHandler.sendEmptyMessageDelayed(MSG_FETCH_EPG, delay);
}
private void onFetchEpg() {
if (DEBUG) Log.d(TAG, "Start fetching EPG.");
if (!mEpgReader.isAvailable()) {
if (DEBUG) Log.d(TAG, "EPG reader is not temporarily available.");
fetchEpg(EPG_READER_INIT_WAIT_MS);
return;
}
String lineupId = getLastLineupId();
if (lineupId == null) {
Address address;
try {
address = LocationUtils.getCurrentAddress(mContext);
} catch (IOException e) {
if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e);
fetchEpg(LOCATION_ERROR_WAIT_MS);
return;
} catch (SecurityException e) {
Log.w(TAG, "No permission to get the current location.");
return;
}
if (address == null) {
if (DEBUG) Log.d(TAG, "Null address returned.");
fetchEpg(LOCATION_INIT_WAIT_MS);
return;
}
if (DEBUG) Log.d(TAG, "Current location is " + address);
lineupId = getLineupForAddress(address);
if (lineupId != null) {
if (DEBUG) Log.d(TAG, "Saving lineup " + lineupId + "found for " + address);
setLastLineupId(lineupId);
} else {
if (DEBUG) Log.d(TAG, "No lineup found for " + address);
return;
}
}
// Check the EPG Timestamp.
long epgTimestamp = mEpgReader.getEpgTimestamp();
if (epgTimestamp <= getLastUpdatedEpgTimestamp()) {
if (DEBUG) Log.d(TAG, "No new EPG.");
return;
}
boolean updated = false;
List<Channel> channels = mEpgReader.getChannels(lineupId);
for (Channel channel : channels) {
List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channel.getId()));
Collections.sort(programs);
if (DEBUG) {
Log.d(TAG, "Fetched " + programs.size() + " programs for channel " + channel);
}
if (updateEpg(channel.getId(), programs)) {
updated = true;
}
}
final boolean epgUpdated = updated;
setLastUpdatedEpgTimestamp(epgTimestamp);
mHandler.removeMessages(MSG_FETCH_EPG);
if (DEBUG) Log.d(TAG, "Fetching EPG is finished.");
}
@Nullable
private String getLineupForAddress(Address address) {
String lineup = null;
if (TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) {
String postalCode = address.getPostalCode();
if (!TextUtils.isEmpty(postalCode)) {
lineup = getLineupForPostalCode(postalCode);
}
}
return lineup;
}
@Nullable
private String getLineupForPostalCode(String postalCode) {
List<Lineup> lineups = mEpgReader.getLineups(postalCode);
for (Lineup lineup : lineups) {
// TODO(EPG): handle more than OTA digital
if (lineup.type == Lineup.LINEUP_BROADCAST_DIGITAL) {
if (DEBUG) Log.d(TAG, "Setting lineup to " + lineup.name + "(" + lineup.id + ")");
return lineup.id;
}
}
return null;
}
private long getLastUpdatedEpgTimestamp() {
if (mLastEpgTimestamp < 0) {
mLastEpgTimestamp = PreferenceManager.getDefaultSharedPreferences(mContext).getLong(
KEY_LAST_UPDATED_EPG_TIMESTAMP, 0);
}
return mLastEpgTimestamp;
}
private void setLastUpdatedEpgTimestamp(long timestamp) {
mLastEpgTimestamp = timestamp;
PreferenceManager.getDefaultSharedPreferences(mContext).edit().putLong(
KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp).commit();
}
private String getLastLineupId() {
if (mLineupId == null) {
mLineupId = PreferenceManager.getDefaultSharedPreferences(mContext)
.getString(KEY_LAST_LINEUP_ID, null);
}
if (DEBUG) Log.d(TAG, "Last lineup_id " + mLineupId);
return mLineupId;
}
private void setLastLineupId(String lineupId) {
mLineupId = lineupId;
PreferenceManager.getDefaultSharedPreferences(mContext).edit()
.putString(KEY_LAST_LINEUP_ID, lineupId).commit();
}
private boolean updateEpg(long channelId, List<Program> newPrograms) {
final int fetchedProgramsCount = newPrograms.size();
if (fetchedProgramsCount == 0) {
return false;
}
boolean updated = false;
long startTimeMs = System.currentTimeMillis();
long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION;
List<Program> oldPrograms = queryPrograms(channelId, startTimeMs, endTimeMs);
Program currentOldProgram = oldPrograms.size() > 0 ? oldPrograms.get(0) : null;
int oldProgramsIndex = 0;
int newProgramsIndex = 0;
// Skip the past programs. They will be automatically removed by the system.
if (currentOldProgram != null) {
long oldStartTimeUtcMillis = currentOldProgram.getStartTimeUtcMillis();
for (Program program : newPrograms) {
if (program.getEndTimeUtcMillis() > oldStartTimeUtcMillis) {
break;
}
newProgramsIndex++;
}
}
// Compare the new programs with old programs one by one and update/delete the old one
// or insert new program if there is no matching program in the database.
ArrayList<ContentProviderOperation> ops = new ArrayList<>();
while (newProgramsIndex < fetchedProgramsCount) {
// TODO: Extract to method and make test.
Program oldProgram = oldProgramsIndex < oldPrograms.size()
? oldPrograms.get(oldProgramsIndex) : null;
Program newProgram = newPrograms.get(newProgramsIndex);
boolean addNewProgram = false;
if (oldProgram != null) {
if (oldProgram.equals(newProgram)) {
// Exact match. No need to update. Move on to the next programs.
oldProgramsIndex++;
newProgramsIndex++;
} else if (isSameTitleAndOverlap(oldProgram, newProgram)) {
// Partial match. Update the old program with the new one.
// NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There
// could be application specific settings which belong to the old program.
ops.add(ContentProviderOperation.newUpdate(
TvContract.buildProgramUri(oldProgram.getId()))
.withValues(toContentValues(newProgram))
.build());
oldProgramsIndex++;
newProgramsIndex++;
} else if (oldProgram.getEndTimeUtcMillis()
< newProgram.getEndTimeUtcMillis()) {
// No match. Remove the old program first to see if the next program in
// {@code oldPrograms} partially matches the new program.
ops.add(ContentProviderOperation.newDelete(
TvContract.buildProgramUri(oldProgram.getId()))
.build());
oldProgramsIndex++;
} else {
// No match. The new program does not match any of the old programs. Insert
// it as a new program.
addNewProgram = true;
newProgramsIndex++;
}
} else {
// No old programs. Just insert new programs.
addNewProgram = true;
newProgramsIndex++;
}
if (addNewProgram) {
ops.add(ContentProviderOperation
.newInsert(TvContract.Programs.CONTENT_URI)
.withValues(toContentValues(newProgram))
.build());
}
// Throttle the batch operation not to cause TransactionTooLargeException.
if (ops.size() > BATCH_OPERATION_COUNT || newProgramsIndex >= fetchedProgramsCount) {
try {
if (DEBUG) {
int size = ops.size();
Log.d(TAG, "Running " + size + " operations for channel " + channelId);
for (int i = 0; i < size; ++i) {
Log.d(TAG, "Operation(" + i + "): " + ops.get(i));
}
}
mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
updated = true;
} catch (RemoteException | OperationApplicationException e) {
Log.e(TAG, "Failed to insert programs.", e);
return updated;
}
ops.clear();
}
}
if (DEBUG) {
Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId);
}
return updated;
}
private List<Program> queryPrograms(long channelId, long startTimeMs, long endTimeMs) {
try (Cursor c = mContext.getContentResolver().query(
TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs),
Program.PROJECTION, null, null, Programs.COLUMN_START_TIME_UTC_MILLIS)) {
if (c == null) {
return Collections.emptyList();
}
ArrayList<Program> programs = new ArrayList<>();
while (c.moveToNext()) {
programs.add(Program.fromCursor(c));
}
return programs;
}
}
/**
* Returns {@code true} if the {@code oldProgram} program needs to be updated with the
* {@code newProgram} program.
*/
private boolean isSameTitleAndOverlap(Program oldProgram, Program newProgram) {
// NOTE: Here, we update the old program if it has the same title and overlaps with the
// new program. The test logic is just an example and you can modify this. E.g. check
// whether the both programs have the same program ID if your EPG supports any ID for
// the programs.
return Objects.equals(oldProgram.getTitle(), newProgram.getTitle())
&& oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis()
&& newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis();
}
@SuppressLint("InlinedApi")
@SuppressWarnings("deprecation")
private static ContentValues toContentValues(Program program) {
ContentValues values = new ContentValues();
values.put(TvContract.Programs.COLUMN_CHANNEL_ID, program.getChannelId());
putValue(values, TvContract.Programs.COLUMN_TITLE, program.getTitle());
putValue(values, TvContract.Programs.COLUMN_EPISODE_TITLE, program.getEpisodeTitle());
if (BuildCompat.isAtLeastN()) {
putValue(values, TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER,
program.getSeasonNumber());
putValue(values, TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER,
program.getEpisodeNumber());
} else {
putValue(values, TvContract.Programs.COLUMN_SEASON_NUMBER, program.getSeasonNumber());
putValue(values, TvContract.Programs.COLUMN_EPISODE_NUMBER, program.getEpisodeNumber());
}
putValue(values, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, program.getDescription());
putValue(values, TvContract.Programs.COLUMN_POSTER_ART_URI, program.getPosterArtUri());
putValue(values, TvContract.Programs.COLUMN_THUMBNAIL_URI, program.getThumbnailUri());
String[] canonicalGenres = program.getCanonicalGenres();
if (canonicalGenres != null && canonicalGenres.length > 0) {
putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE,
Genres.encode(canonicalGenres));
} else {
putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, "");
}
TvContentRating[] ratings = program.getContentRatings();
if (ratings != null && ratings.length > 0) {
StringBuilder sb = new StringBuilder(ratings[0].flattenToString());
for (int i = 1; i < ratings.length; ++i) {
sb.append(CONTENT_RATING_SEPARATOR);
sb.append(ratings[i].flattenToString());
}
putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, sb.toString());
} else {
putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, "");
}
values.put(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
program.getStartTimeUtcMillis());
values.put(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, program.getEndTimeUtcMillis());
putValue(values, TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA,
InternalDataUtils.serializeInternalProviderData(program));
return values;
}
private static void putValue(ContentValues contentValues, String key, String value) {
if (TextUtils.isEmpty(value)) {
contentValues.putNull(key);
} else {
contentValues.put(key, value);
}
}
private static void putValue(ContentValues contentValues, String key, byte[] value) {
if (value == null || value.length == 0) {
contentValues.putNull(key);
} else {
contentValues.put(key, value);
}
}
private static class EpgFetcherHandler extends WeakHandler<EpgFetcher> {
public EpgFetcherHandler (@NonNull Looper looper, EpgFetcher ref) {
super(looper, ref);
}
@Override
public void handleMessage(Message msg, @NonNull EpgFetcher epgFetcher) {
switch (msg.what) {
case MSG_FETCH_EPG:
epgFetcher.onFetchEpg();
break;
default:
super.handleMessage(msg);
break;
}
}
}
private class EpgRunner implements Runnable {
@Override
public void run() {
fetchEpg();
}
}
}