blob: 3843ca99877ed37b354cd43bb4365e7b25dc9ea0 [file] [log] [blame]
/*
* Copyright (C) 2017 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.content.ContentProviderOperation;
import android.content.ContentValues;
import android.content.Context;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.media.tv.TvContract;
import android.media.tv.TvContract.Programs;
import android.os.RemoteException;
import android.preference.PreferenceManager;
import android.support.annotation.WorkerThread;
import android.text.TextUtils;
import android.util.Log;
import com.android.tv.common.CommonConstants;
import com.android.tv.common.util.Clock;
import com.android.tv.data.Program;
import com.android.tv.data.api.Channel;
import com.android.tv.features.TvFeatures;
import com.android.tv.util.TvProviderUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/** The helper class for {@link EpgFetcher} */
class EpgFetchHelper {
private static final String TAG = "EpgFetchHelper";
private static final boolean DEBUG = false;
private static final long PROGRAM_QUERY_DURATION_MS = TimeUnit.DAYS.toMillis(30);
private static final int BATCH_OPERATION_COUNT = 100;
// Value: Long
private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP =
CommonConstants.BASE_PACKAGE + ".data.epg.EpgFetcher.LastUpdatedEpgTimestamp";
// Value: String
private static final String KEY_LAST_LINEUP_ID =
CommonConstants.BASE_PACKAGE + ".data.epg.EpgFetcher.LastLineupId";
private static long sLastEpgUpdatedTimestamp = -1;
private static String sLastLineupId;
private EpgFetchHelper() {}
/**
* Updates newly fetched EPG data for the given channel to local providers. The method will
* compare the broadcasting time and try to match each newly fetched program with old programs
* of that channel in the database one by one. It will update the matched old program, or insert
* the new program if there is no matching program can be found in the database and at the same
* time remove those old programs which conflicts with the inserted one.
*
* @param channelId the target channel ID.
* @param fetchedPrograms the newly fetched program data.
* @return {@code true} if new program data are successfully updated. Otherwise {@code false}.
*/
static boolean updateEpgData(
Context context, Clock clock, long channelId, List<Program> fetchedPrograms) {
final int fetchedProgramsCount = fetchedPrograms.size();
if (fetchedProgramsCount == 0) {
return false;
}
boolean updated = false;
long startTimeMs = clock.currentTimeMillis();
long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION_MS;
List<Program> oldPrograms = queryPrograms(context, channelId, startTimeMs, endTimeMs);
int oldProgramsIndex = 0;
int newProgramsIndex = 0;
// 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) {
Program oldProgram =
oldProgramsIndex < oldPrograms.size()
? oldPrograms.get(oldProgramsIndex)
: null;
Program newProgram = fetchedPrograms.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 (hasSameTitleAndOverlap(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(Program.toContentValues(newProgram, context))
.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(Programs.CONTENT_URI)
.withValues(Program.toContentValues(newProgram, context))
.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));
}
}
context.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;
}
@WorkerThread
static void updateNetworkAffiliation(Context context, Set<EpgReader.EpgChannel> channels) {
if (!TvFeatures.STORE_NETWORK_AFFILIATION.isEnabled(context)) {
return;
}
ArrayList<ContentProviderOperation> ops = new ArrayList<>();
for (EpgReader.EpgChannel epgChannel : channels) {
if (!epgChannel.getDbUpdateNeeded()) {
continue;
}
Channel channel = epgChannel.getChannel();
ContentValues values = new ContentValues();
values.put(
TvContract.Channels.COLUMN_NETWORK_AFFILIATION,
channel.getNetworkAffiliation());
ops.add(
ContentProviderOperation.newUpdate(TvContract.buildChannelUri(channel.getId()))
.withValues(values)
.build());
if (ops.size() >= BATCH_OPERATION_COUNT) {
try {
context.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
} catch (RemoteException | OperationApplicationException e) {
Log.e(TAG, "Failed to update channels.", e);
}
ops.clear();
}
}
try {
context.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
} catch (RemoteException | OperationApplicationException e) {
Log.e(TAG, "Failed to update channels.", e);
}
}
@WorkerThread
private static List<Program> queryPrograms(
Context context, long channelId, long startTimeMs, long endTimeMs) {
String[] projection = Program.PROJECTION;
if (TvProviderUtils.checkSeriesIdColumn(context, Programs.CONTENT_URI)) {
projection =
TvProviderUtils.addExtraColumnsToProjection(
projection, TvProviderUtils.EXTRA_PROGRAM_COLUMN_SERIES_ID);
}
try (Cursor c =
context.getContentResolver()
.query(
TvContract.buildProgramsUriForChannel(
channelId, startTimeMs, endTimeMs),
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} needs to be updated with the {@code
* newProgram}.
*/
private static boolean hasSameTitleAndOverlap(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 TextUtils.equals(oldProgram.getTitle(), newProgram.getTitle())
&& oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis()
&& newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis();
}
/**
* Sets the last known lineup ID into shared preferences for future usage. If channels are not
* re-scanned, EPG fetcher can directly use this value instead of checking the correct lineup ID
* every time when it needs to fetch EPG data.
*/
@WorkerThread
static synchronized void setLastLineupId(Context context, String lineupId) {
if (DEBUG) {
if (lineupId == null) {
Log.d(TAG, "Clear stored lineup id: " + sLastLineupId);
}
}
sLastLineupId = lineupId;
PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putString(KEY_LAST_LINEUP_ID, lineupId)
.apply();
}
/** Gets the last known lineup ID from shared preferences. */
static synchronized String getLastLineupId(Context context) {
if (sLastLineupId == null) {
sLastLineupId =
PreferenceManager.getDefaultSharedPreferences(context)
.getString(KEY_LAST_LINEUP_ID, null);
}
if (DEBUG) Log.d(TAG, "Last lineup is " + sLastLineupId);
return sLastLineupId;
}
/**
* Sets the last updated timestamp of EPG data into shared preferences. If the EPG data is not
* out-dated, it's not necessary for EPG fetcher to fetch EPG again.
*/
@WorkerThread
static synchronized void setLastEpgUpdatedTimestamp(Context context, long timestamp) {
sLastEpgUpdatedTimestamp = timestamp;
PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putLong(KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp)
.apply();
}
/** Gets the last updated timestamp of EPG data. */
static synchronized long getLastEpgUpdatedTimestamp(Context context) {
if (sLastEpgUpdatedTimestamp < 0) {
sLastEpgUpdatedTimestamp =
PreferenceManager.getDefaultSharedPreferences(context)
.getLong(KEY_LAST_UPDATED_EPG_TIMESTAMP, 0);
}
return sLastEpgUpdatedTimestamp;
}
}