blob: cd70ad179eba60833052301c938d118bb3d68620 [file] [log] [blame]
/*
* Copyright (C) 2014 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.providers.tv;
import android.annotation.SuppressLint;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.ContentProvider;
import android.content.ContentProviderOperation;
import android.content.ContentProviderResult;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.OperationApplicationException;
import android.content.UriMatcher;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteQueryBuilder;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.tv.TvContract;
import android.media.tv.TvContract.BaseTvColumns;
import android.media.tv.TvContract.Channels;
import android.media.tv.TvContract.Programs;
import android.media.tv.TvContract.Programs.Genres;
import android.media.tv.TvContract.WatchedPrograms;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Message;
import android.os.ParcelFileDescriptor;
import android.os.ParcelFileDescriptor.AutoCloseInputStream;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Log;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.os.SomeArgs;
import com.android.providers.tv.util.SqlParams;
import com.google.android.collect.Sets;
import libcore.io.IoUtils;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* TV content provider. The contract between this provider and applications is defined in
* {@link android.media.tv.TvContract}.
*/
public class TvProvider extends ContentProvider {
private static final boolean DEBUG = false;
private static final String TAG = "TvProvider";
// Operation names for createSqlParams().
private static final String OP_QUERY = "query";
private static final String OP_UPDATE = "update";
private static final String OP_DELETE = "delete";
private static final int DATABASE_VERSION = 23;
private static final String DATABASE_NAME = "tv.db";
private static final String CHANNELS_TABLE = "channels";
private static final String PROGRAMS_TABLE = "programs";
private static final String WATCHED_PROGRAMS_TABLE = "watched_programs";
private static final String DELETED_CHANNELS_TABLE = "deleted_channels"; // Deprecated
private static final String PROGRAMS_TABLE_PACKAGE_NAME_INDEX = "programs_package_name_index";
private static final String PROGRAMS_TABLE_CHANNEL_ID_INDEX = "programs_channel_id_index";
private static final String PROGRAMS_TABLE_START_TIME_INDEX = "programs_start_time_index";
private static final String PROGRAMS_TABLE_END_TIME_INDEX = "programs_end_time_index";
private static final String WATCHED_PROGRAMS_TABLE_CHANNEL_ID_INDEX =
"watched_programs_channel_id_index";
private static final String DEFAULT_CHANNELS_SORT_ORDER = Channels.COLUMN_DISPLAY_NUMBER
+ " ASC";
private static final String DEFAULT_PROGRAMS_SORT_ORDER = Programs.COLUMN_START_TIME_UTC_MILLIS
+ " ASC";
private static final String DEFAULT_WATCHED_PROGRAMS_SORT_ORDER =
WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS + " DESC";
private static final String CHANNELS_TABLE_INNER_JOIN_PROGRAMS_TABLE = CHANNELS_TABLE
+ " INNER JOIN " + PROGRAMS_TABLE
+ " ON (" + CHANNELS_TABLE + "." + Channels._ID + "="
+ PROGRAMS_TABLE + "." + Programs.COLUMN_CHANNEL_ID + ")";
private static final UriMatcher sUriMatcher;
private static final int MATCH_CHANNEL = 1;
private static final int MATCH_CHANNEL_ID = 2;
private static final int MATCH_CHANNEL_ID_LOGO = 3;
private static final int MATCH_PASSTHROUGH_ID = 4;
private static final int MATCH_PROGRAM = 5;
private static final int MATCH_PROGRAM_ID = 6;
private static final int MATCH_WATCHED_PROGRAM = 7;
private static final int MATCH_WATCHED_PROGRAM_ID = 8;
private static final String CHANNELS_COLUMN_LOGO = "logo";
private static final int MAX_LOGO_IMAGE_SIZE = 256;
// The internal column in the watched programs table to indicate whether the current log entry
// is consolidated or not. Unconsolidated entries may have columns with missing data.
private static final String WATCHED_PROGRAMS_COLUMN_CONSOLIDATED = "consolidated";
private static final long MAX_PROGRAM_DATA_DELAY_IN_MILLIS = 10 * 1000; // 10 seconds
private static Map<String, String> sChannelProjectionMap;
private static Map<String, String> sProgramProjectionMap;
private static Map<String, String> sWatchedProgramProjectionMap;
static {
sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
sUriMatcher.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL);
sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_ID);
sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#/logo", MATCH_CHANNEL_ID_LOGO);
sUriMatcher.addURI(TvContract.AUTHORITY, "passthrough/*", MATCH_PASSTHROUGH_ID);
sUriMatcher.addURI(TvContract.AUTHORITY, "program", MATCH_PROGRAM);
sUriMatcher.addURI(TvContract.AUTHORITY, "program/#", MATCH_PROGRAM_ID);
sUriMatcher.addURI(TvContract.AUTHORITY, "watched_program", MATCH_WATCHED_PROGRAM);
sUriMatcher.addURI(TvContract.AUTHORITY, "watched_program/#", MATCH_WATCHED_PROGRAM_ID);
sChannelProjectionMap = new HashMap<String, String>();
sChannelProjectionMap.put(Channels._ID, CHANNELS_TABLE + "." + Channels._ID);
sChannelProjectionMap.put(Channels.COLUMN_PACKAGE_NAME,
CHANNELS_TABLE + "." + Channels.COLUMN_PACKAGE_NAME);
sChannelProjectionMap.put(Channels.COLUMN_INPUT_ID,
CHANNELS_TABLE + "." + Channels.COLUMN_INPUT_ID);
sChannelProjectionMap.put(Channels.COLUMN_TYPE,
CHANNELS_TABLE + "." + Channels.COLUMN_TYPE);
sChannelProjectionMap.put(Channels.COLUMN_SERVICE_TYPE,
CHANNELS_TABLE + "." + Channels.COLUMN_SERVICE_TYPE);
sChannelProjectionMap.put(Channels.COLUMN_ORIGINAL_NETWORK_ID,
CHANNELS_TABLE + "." + Channels.COLUMN_ORIGINAL_NETWORK_ID);
sChannelProjectionMap.put(Channels.COLUMN_TRANSPORT_STREAM_ID,
CHANNELS_TABLE + "." + Channels.COLUMN_TRANSPORT_STREAM_ID);
sChannelProjectionMap.put(Channels.COLUMN_SERVICE_ID,
CHANNELS_TABLE + "." + Channels.COLUMN_SERVICE_ID);
sChannelProjectionMap.put(Channels.COLUMN_DISPLAY_NUMBER,
CHANNELS_TABLE + "." + Channels.COLUMN_DISPLAY_NUMBER);
sChannelProjectionMap.put(Channels.COLUMN_DISPLAY_NAME,
CHANNELS_TABLE + "." + Channels.COLUMN_DISPLAY_NAME);
sChannelProjectionMap.put(Channels.COLUMN_NETWORK_AFFILIATION,
CHANNELS_TABLE + "." + Channels.COLUMN_NETWORK_AFFILIATION);
sChannelProjectionMap.put(Channels.COLUMN_DESCRIPTION,
CHANNELS_TABLE + "." + Channels.COLUMN_DESCRIPTION);
sChannelProjectionMap.put(Channels.COLUMN_VIDEO_FORMAT,
CHANNELS_TABLE + "." + Channels.COLUMN_VIDEO_FORMAT);
sChannelProjectionMap.put(Channels.COLUMN_BROWSABLE,
CHANNELS_TABLE + "." + Channels.COLUMN_BROWSABLE);
sChannelProjectionMap.put(Channels.COLUMN_SEARCHABLE,
CHANNELS_TABLE + "." + Channels.COLUMN_SEARCHABLE);
sChannelProjectionMap.put(Channels.COLUMN_LOCKED,
CHANNELS_TABLE + "." + Channels.COLUMN_LOCKED);
sChannelProjectionMap.put(Channels.COLUMN_INTERNAL_PROVIDER_DATA,
CHANNELS_TABLE + "." + Channels.COLUMN_INTERNAL_PROVIDER_DATA);
sChannelProjectionMap.put(Channels.COLUMN_VERSION_NUMBER,
CHANNELS_TABLE + "." + Channels.COLUMN_VERSION_NUMBER);
sProgramProjectionMap = new HashMap<String, String>();
sProgramProjectionMap.put(Programs._ID, Programs._ID);
sProgramProjectionMap.put(Programs.COLUMN_PACKAGE_NAME, Programs.COLUMN_PACKAGE_NAME);
sProgramProjectionMap.put(Programs.COLUMN_CHANNEL_ID, Programs.COLUMN_CHANNEL_ID);
sProgramProjectionMap.put(Programs.COLUMN_TITLE, Programs.COLUMN_TITLE);
sProgramProjectionMap.put(Programs.COLUMN_SEASON_NUMBER, Programs.COLUMN_SEASON_NUMBER);
sProgramProjectionMap.put(Programs.COLUMN_EPISODE_NUMBER, Programs.COLUMN_EPISODE_NUMBER);
sProgramProjectionMap.put(Programs.COLUMN_EPISODE_TITLE, Programs.COLUMN_EPISODE_TITLE);
sProgramProjectionMap.put(Programs.COLUMN_START_TIME_UTC_MILLIS,
Programs.COLUMN_START_TIME_UTC_MILLIS);
sProgramProjectionMap.put(Programs.COLUMN_END_TIME_UTC_MILLIS,
Programs.COLUMN_END_TIME_UTC_MILLIS);
sProgramProjectionMap.put(Programs.COLUMN_BROADCAST_GENRE, Programs.COLUMN_BROADCAST_GENRE);
sProgramProjectionMap.put(Programs.COLUMN_CANONICAL_GENRE, Programs.COLUMN_CANONICAL_GENRE);
sProgramProjectionMap.put(Programs.COLUMN_SHORT_DESCRIPTION,
Programs.COLUMN_SHORT_DESCRIPTION);
sProgramProjectionMap.put(Programs.COLUMN_LONG_DESCRIPTION,
Programs.COLUMN_LONG_DESCRIPTION);
sProgramProjectionMap.put(Programs.COLUMN_VIDEO_WIDTH, Programs.COLUMN_VIDEO_WIDTH);
sProgramProjectionMap.put(Programs.COLUMN_VIDEO_HEIGHT, Programs.COLUMN_VIDEO_HEIGHT);
sProgramProjectionMap.put(Programs.COLUMN_AUDIO_LANGUAGE, Programs.COLUMN_AUDIO_LANGUAGE);
sProgramProjectionMap.put(Programs.COLUMN_CONTENT_RATING, Programs.COLUMN_CONTENT_RATING);
sProgramProjectionMap.put(Programs.COLUMN_POSTER_ART_URI, Programs.COLUMN_POSTER_ART_URI);
sProgramProjectionMap.put(Programs.COLUMN_THUMBNAIL_URI, Programs.COLUMN_THUMBNAIL_URI);
sProgramProjectionMap.put(Programs.COLUMN_INTERNAL_PROVIDER_DATA,
Programs.COLUMN_INTERNAL_PROVIDER_DATA);
sProgramProjectionMap.put(Programs.COLUMN_VERSION_NUMBER, Programs.COLUMN_VERSION_NUMBER);
sWatchedProgramProjectionMap = new HashMap<String, String>();
sWatchedProgramProjectionMap.put(WatchedPrograms._ID, WatchedPrograms._ID);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS,
WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_CHANNEL_ID,
WatchedPrograms.COLUMN_CHANNEL_ID);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_TITLE,
WatchedPrograms.COLUMN_TITLE);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS,
WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS,
WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_DESCRIPTION,
WatchedPrograms.COLUMN_DESCRIPTION);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_INTERNAL_TUNE_PARAMS,
WatchedPrograms.COLUMN_INTERNAL_TUNE_PARAMS);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN,
WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN);
sWatchedProgramProjectionMap.put(WATCHED_PROGRAMS_COLUMN_CONSOLIDATED,
WATCHED_PROGRAMS_COLUMN_CONSOLIDATED);
}
// Mapping from broadcast genre to canonical genre.
private static Map<String, String> sGenreMap;
private static final String PERMISSION_ACCESS_ALL_EPG_DATA =
"com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA";
private static final String PERMISSION_ACCESS_WATCHED_PROGRAMS =
"com.android.providers.tv.permission.ACCESS_WATCHED_PROGRAMS";
private static class DatabaseHelper extends SQLiteOpenHelper {
private final Context mContext;
DatabaseHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
mContext = context;
}
@Override
public void onConfigure(SQLiteDatabase db) {
db.setForeignKeyConstraintsEnabled(true);
}
@Override
public void onCreate(SQLiteDatabase db) {
if (DEBUG) {
Log.d(TAG, "Creating database");
}
// Set up the database schema.
db.execSQL("CREATE TABLE " + CHANNELS_TABLE + " ("
+ Channels._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ Channels.COLUMN_PACKAGE_NAME + " TEXT NOT NULL,"
+ Channels.COLUMN_INPUT_ID + " TEXT NOT NULL,"
+ Channels.COLUMN_TYPE + " TEXT NOT NULL DEFAULT '" + Channels.TYPE_OTHER + "',"
+ Channels.COLUMN_SERVICE_TYPE + " TEXT NOT NULL DEFAULT '"
+ Channels.SERVICE_TYPE_AUDIO_VIDEO + "',"
+ Channels.COLUMN_ORIGINAL_NETWORK_ID + " INTEGER NOT NULL DEFAULT 0,"
+ Channels.COLUMN_TRANSPORT_STREAM_ID + " INTEGER NOT NULL DEFAULT 0,"
+ Channels.COLUMN_SERVICE_ID + " INTEGER NOT NULL DEFAULT 0,"
+ Channels.COLUMN_DISPLAY_NUMBER + " TEXT,"
+ Channels.COLUMN_DISPLAY_NAME + " TEXT,"
+ Channels.COLUMN_NETWORK_AFFILIATION + " TEXT,"
+ Channels.COLUMN_DESCRIPTION + " TEXT,"
+ Channels.COLUMN_VIDEO_FORMAT + " TEXT,"
+ Channels.COLUMN_BROWSABLE + " INTEGER NOT NULL DEFAULT 0,"
+ Channels.COLUMN_SEARCHABLE + " INTEGER NOT NULL DEFAULT 1,"
+ Channels.COLUMN_LOCKED + " INTEGER NOT NULL DEFAULT 0,"
+ Channels.COLUMN_INTERNAL_PROVIDER_DATA + " BLOB,"
+ CHANNELS_COLUMN_LOGO + " BLOB,"
+ Channels.COLUMN_VERSION_NUMBER + " INTEGER,"
// Needed for foreign keys in other tables.
+ "UNIQUE(" + Channels._ID + "," + Channels.COLUMN_PACKAGE_NAME + ")"
+ ");");
db.execSQL("CREATE TABLE " + PROGRAMS_TABLE + " ("
+ Programs._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ Programs.COLUMN_PACKAGE_NAME + " TEXT NOT NULL,"
+ Programs.COLUMN_CHANNEL_ID + " INTEGER,"
+ Programs.COLUMN_TITLE + " TEXT,"
+ Programs.COLUMN_SEASON_NUMBER + " INTEGER,"
+ Programs.COLUMN_EPISODE_NUMBER + " INTEGER,"
+ Programs.COLUMN_EPISODE_TITLE + " TEXT,"
+ Programs.COLUMN_START_TIME_UTC_MILLIS + " INTEGER,"
+ Programs.COLUMN_END_TIME_UTC_MILLIS + " INTEGER,"
+ Programs.COLUMN_BROADCAST_GENRE + " TEXT,"
+ Programs.COLUMN_CANONICAL_GENRE + " TEXT,"
+ Programs.COLUMN_SHORT_DESCRIPTION + " TEXT,"
+ Programs.COLUMN_LONG_DESCRIPTION + " TEXT,"
+ Programs.COLUMN_VIDEO_WIDTH + " INTEGER,"
+ Programs.COLUMN_VIDEO_HEIGHT + " INTEGER,"
+ Programs.COLUMN_AUDIO_LANGUAGE + " TEXT,"
+ Programs.COLUMN_CONTENT_RATING + " TEXT,"
+ Programs.COLUMN_POSTER_ART_URI + " TEXT,"
+ Programs.COLUMN_THUMBNAIL_URI + " TEXT,"
+ Programs.COLUMN_INTERNAL_PROVIDER_DATA + " BLOB,"
+ Programs.COLUMN_VERSION_NUMBER + " INTEGER,"
+ "FOREIGN KEY("
+ Programs.COLUMN_CHANNEL_ID + "," + Programs.COLUMN_PACKAGE_NAME
+ ") REFERENCES " + CHANNELS_TABLE + "("
+ Channels._ID + "," + Channels.COLUMN_PACKAGE_NAME
+ ") ON UPDATE CASCADE ON DELETE CASCADE"
+ ");");
db.execSQL("CREATE INDEX " + PROGRAMS_TABLE_PACKAGE_NAME_INDEX + " ON " + PROGRAMS_TABLE
+ "(" + Programs.COLUMN_PACKAGE_NAME + ");");
db.execSQL("CREATE INDEX " + PROGRAMS_TABLE_CHANNEL_ID_INDEX + " ON " + PROGRAMS_TABLE
+ "(" + Programs.COLUMN_CHANNEL_ID + ");");
db.execSQL("CREATE INDEX " + PROGRAMS_TABLE_START_TIME_INDEX + " ON " + PROGRAMS_TABLE
+ "(" + Programs.COLUMN_START_TIME_UTC_MILLIS + ");");
db.execSQL("CREATE INDEX " + PROGRAMS_TABLE_END_TIME_INDEX + " ON " + PROGRAMS_TABLE
+ "(" + Programs.COLUMN_END_TIME_UTC_MILLIS + ");");
db.execSQL("CREATE TABLE " + WATCHED_PROGRAMS_TABLE + " ("
+ WatchedPrograms._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ WatchedPrograms.COLUMN_PACKAGE_NAME + " TEXT NOT NULL,"
+ WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS
+ " INTEGER NOT NULL DEFAULT 0,"
+ WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS
+ " INTEGER NOT NULL DEFAULT 0,"
+ WatchedPrograms.COLUMN_CHANNEL_ID + " INTEGER,"
+ WatchedPrograms.COLUMN_TITLE + " TEXT,"
+ WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS + " INTEGER,"
+ WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS + " INTEGER,"
+ WatchedPrograms.COLUMN_DESCRIPTION + " TEXT,"
+ WatchedPrograms.COLUMN_INTERNAL_TUNE_PARAMS + " TEXT,"
+ WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN + " TEXT NOT NULL,"
+ WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + " INTEGER NOT NULL DEFAULT 0,"
+ "FOREIGN KEY("
+ WatchedPrograms.COLUMN_CHANNEL_ID + ","
+ WatchedPrograms.COLUMN_PACKAGE_NAME
+ ") REFERENCES " + CHANNELS_TABLE + "("
+ Channels._ID + "," + Channels.COLUMN_PACKAGE_NAME
+ ") ON UPDATE CASCADE ON DELETE CASCADE"
+ ");");
db.execSQL("CREATE INDEX " + WATCHED_PROGRAMS_TABLE_CHANNEL_ID_INDEX + " ON "
+ WATCHED_PROGRAMS_TABLE + "(" + WatchedPrograms.COLUMN_CHANNEL_ID + ");");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.i(TAG, "Upgrading from version " + oldVersion + " to " + newVersion
+ ", data will be lost!");
db.execSQL("DROP TABLE IF EXISTS " + DELETED_CHANNELS_TABLE);
db.execSQL("DROP TABLE IF EXISTS " + WATCHED_PROGRAMS_TABLE);
db.execSQL("DROP TABLE IF EXISTS " + PROGRAMS_TABLE);
db.execSQL("DROP TABLE IF EXISTS " + CHANNELS_TABLE);
onCreate(db);
}
}
private DatabaseHelper mOpenHelper;
private final Handler mLogHandler = new WatchLogHandler();
@Override
public boolean onCreate() {
if (DEBUG) {
Log.d(TAG, "Creating TvProvider");
}
mOpenHelper = new DatabaseHelper(getContext());
deleteUnconsolidatedWatchedProgramsRows();
scheduleEpgDataCleanup();
buildGenreMap();
return true;
}
@VisibleForTesting
void scheduleEpgDataCleanup() {
Intent intent = new Intent(EpgDataCleanupService.ACTION_CLEAN_UP_EPG_DATA);
intent.setClass(getContext(), EpgDataCleanupService.class);
PendingIntent pendingIntent = PendingIntent.getService(
getContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
AlarmManager alarmManager =
(AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE);
alarmManager.setInexactRepeating(AlarmManager.RTC, System.currentTimeMillis(),
AlarmManager.INTERVAL_HALF_DAY, pendingIntent);
}
private void buildGenreMap() {
if (sGenreMap != null) {
return;
}
sGenreMap = new HashMap<String, String>();
buildGenreMap(R.array.genre_mapping_atsc);
buildGenreMap(R.array.genre_mapping_dvb);
buildGenreMap(R.array.genre_mapping_isdb);
buildGenreMap(R.array.genre_mapping_isdb_br);
}
@SuppressLint("DefaultLocale")
private void buildGenreMap(int id) {
String[] maps = getContext().getResources().getStringArray(id);
for (String map : maps) {
String[] arr = map.split("\\|");
if (arr.length != 2) {
throw new IllegalArgumentException("Invalid genre mapping : " + map);
}
sGenreMap.put(arr[0].toUpperCase(), arr[1]);
}
}
@VisibleForTesting
String getCallingPackage_() {
return getCallingPackage();
}
@Override
public String getType(Uri uri) {
switch (sUriMatcher.match(uri)) {
case MATCH_CHANNEL:
return Channels.CONTENT_TYPE;
case MATCH_CHANNEL_ID:
return Channels.CONTENT_ITEM_TYPE;
case MATCH_CHANNEL_ID_LOGO:
return "image/png";
case MATCH_PASSTHROUGH_ID:
return Channels.CONTENT_ITEM_TYPE;
case MATCH_PROGRAM:
return Programs.CONTENT_TYPE;
case MATCH_PROGRAM_ID:
return Programs.CONTENT_ITEM_TYPE;
case MATCH_WATCHED_PROGRAM:
return WatchedPrograms.CONTENT_TYPE;
case MATCH_WATCHED_PROGRAM_ID:
return WatchedPrograms.CONTENT_ITEM_TYPE;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
String sortOrder) {
if (needsToLimitPackage(uri) && !TextUtils.isEmpty(sortOrder)) {
throw new SecurityException("Sort order not allowed for " + uri);
}
SqlParams params = createSqlParams(OP_QUERY, uri, selection, selectionArgs);
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(params.getTables());
String orderBy;
if (params.getTables().equals(PROGRAMS_TABLE)) {
queryBuilder.setProjectionMap(sProgramProjectionMap);
orderBy = DEFAULT_PROGRAMS_SORT_ORDER;
} else if (params.getTables().equals(WATCHED_PROGRAMS_TABLE)) {
queryBuilder.setProjectionMap(sWatchedProgramProjectionMap);
orderBy = DEFAULT_WATCHED_PROGRAMS_SORT_ORDER;
} else {
queryBuilder.setProjectionMap(sChannelProjectionMap);
orderBy = DEFAULT_CHANNELS_SORT_ORDER;
}
// Use the default sort order only if no sort order is specified.
if (!TextUtils.isEmpty(sortOrder)) {
orderBy = sortOrder;
}
// Get the database and run the query.
SQLiteDatabase db = mOpenHelper.getReadableDatabase();
Cursor c = queryBuilder.query(db, projection, params.getSelection(),
params.getSelectionArgs(), null, null, orderBy);
// Tell the cursor what URI to watch, so it knows when its source data changes.
c.setNotificationUri(getContext().getContentResolver(), uri);
return c;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
switch (sUriMatcher.match(uri)) {
case MATCH_CHANNEL:
return insertChannel(uri, values);
case MATCH_PROGRAM:
return insertProgram(uri, values);
case MATCH_WATCHED_PROGRAM:
return insertWatchedProgram(uri, values);
case MATCH_CHANNEL_ID:
case MATCH_CHANNEL_ID_LOGO:
case MATCH_PASSTHROUGH_ID:
case MATCH_PROGRAM_ID:
case MATCH_WATCHED_PROGRAM_ID:
throw new UnsupportedOperationException("Cannot insert into that URI: " + uri);
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
}
private Uri insertChannel(Uri uri, ContentValues values) {
// Mark the owner package of this channel.
values.put(Channels.COLUMN_PACKAGE_NAME, getCallingPackage_());
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
long rowId = db.insert(CHANNELS_TABLE, null, values);
if (rowId > 0) {
Uri channelUri = TvContract.buildChannelUri(rowId);
notifyChange(channelUri);
return channelUri;
}
throw new SQLException("Failed to insert row into " + uri);
}
private Uri insertProgram(Uri uri, ContentValues values) {
// Mark the owner package of this program.
values.put(Programs.COLUMN_PACKAGE_NAME, getCallingPackage_());
checkAndConvertGenre(values);
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
long rowId = db.insert(PROGRAMS_TABLE, null, values);
if (rowId > 0) {
Uri programUri = TvContract.buildProgramUri(rowId);
notifyChange(programUri);
return programUri;
}
throw new SQLException("Failed to insert row into " + uri);
}
private Uri insertWatchedProgram(Uri uri, ContentValues values) {
if (DEBUG) {
Log.d(TAG, "insertWatchedProgram(uri=" + uri + ", values={" + values + "})");
}
Long watchStartTime = values.getAsLong(WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS);
Long watchEndTime = values.getAsLong(WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS);
// The system sends only two kinds of watch events:
// 1. The user tunes to a new channel. (COLUMN_WATCH_START_TIME_UTC_MILLIS)
// 2. The user stops watching. (COLUMN_WATCH_END_TIME_UTC_MILLIS)
if (watchStartTime != null && watchEndTime == null) {
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
long rowId = db.insert(WATCHED_PROGRAMS_TABLE, null, values);
if (rowId > 0) {
mLogHandler.removeMessages(WatchLogHandler.MSG_TRY_CONSOLIDATE_ALL);
mLogHandler.sendEmptyMessageDelayed(WatchLogHandler.MSG_TRY_CONSOLIDATE_ALL,
MAX_PROGRAM_DATA_DELAY_IN_MILLIS);
return TvContract.buildWatchedProgramUri(rowId);
}
throw new SQLException("Failed to insert row into " + uri);
} else if (watchStartTime == null && watchEndTime != null) {
SomeArgs args = SomeArgs.obtain();
args.arg1 = values.getAsString(WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN);
args.arg2 = watchEndTime;
Message msg = mLogHandler.obtainMessage(WatchLogHandler.MSG_CONSOLIDATE, args);
mLogHandler.sendMessageDelayed(msg, MAX_PROGRAM_DATA_DELAY_IN_MILLIS);
return null;
}
// All the other cases are invalid.
throw new IllegalArgumentException("Only one of COLUMN_WATCH_START_TIME_UTC_MILLIS and"
+ " COLUMN_WATCH_END_TIME_UTC_MILLIS should be specified");
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
SqlParams params = createSqlParams(OP_DELETE, uri, selection, selectionArgs);
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
int count = 0;
switch (sUriMatcher.match(uri)) {
case MATCH_CHANNEL_ID_LOGO:
ContentValues values = new ContentValues();
values.putNull(CHANNELS_COLUMN_LOGO);
count = db.update(params.getTables(), values, params.getSelection(),
params.getSelectionArgs());
break;
case MATCH_CHANNEL:
case MATCH_PROGRAM:
case MATCH_WATCHED_PROGRAM:
case MATCH_CHANNEL_ID:
case MATCH_PASSTHROUGH_ID:
case MATCH_PROGRAM_ID:
case MATCH_WATCHED_PROGRAM_ID:
count = db.delete(params.getTables(), params.getSelection(),
params.getSelectionArgs());
break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
if (count > 0) {
notifyChange(uri);
}
return count;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
SqlParams params = createSqlParams(OP_UPDATE, uri, selection, selectionArgs);
if (params.getTables().equals(CHANNELS_TABLE)) {
if (values.containsKey(Channels.COLUMN_LOCKED)
&& !callerHasModifyParentalControlsPermission()) {
throw new SecurityException("Not allowed to modify Channels.COLUMN_LOCKED");
}
} else if (params.getTables().equals(PROGRAMS_TABLE)) {
checkAndConvertGenre(values);
}
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
int count = db.update(params.getTables(), values, params.getSelection(),
params.getSelectionArgs());
if (count > 0) {
notifyChange(uri);
}
return count;
}
private SqlParams createSqlParams(String operation, Uri uri, String selection,
String[] selectionArgs) {
SqlParams params = new SqlParams(null, selection, selectionArgs);
if (needsToLimitPackage(uri)) {
if (!TextUtils.isEmpty(selection)) {
throw new SecurityException("Selection not allowed for " + uri);
}
params.setWhere(BaseTvColumns.COLUMN_PACKAGE_NAME + "=?", getCallingPackage_());
}
switch (sUriMatcher.match(uri)) {
case MATCH_CHANNEL:
String genre = uri.getQueryParameter(TvContract.PARAM_CANONICAL_GENRE);
if (genre == null) {
params.setTables(CHANNELS_TABLE);
} else {
if (!operation.equals(OP_QUERY)) {
throw new SecurityException(capitalize(operation)
+ " not allowed for " + uri);
}
if (!Genres.isCanonical(genre)) {
throw new IllegalArgumentException("Not a canonical genre : " + genre);
}
params.setTables(CHANNELS_TABLE_INNER_JOIN_PROGRAMS_TABLE);
String curTime = String.valueOf(System.currentTimeMillis());
params.appendWhere("LIKE(?, " + Programs.COLUMN_CANONICAL_GENRE + ") AND "
+ Programs.COLUMN_START_TIME_UTC_MILLIS + "<=? AND "
+ Programs.COLUMN_END_TIME_UTC_MILLIS + ">=?",
"%" + genre + "%", curTime, curTime);
}
String inputId = uri.getQueryParameter(TvContract.PARAM_INPUT);
if (inputId != null) {
params.appendWhere(Channels.COLUMN_INPUT_ID + "=?", inputId);
}
boolean browsableOnly = uri.getBooleanQueryParameter(
TvContract.PARAM_BROWSABLE_ONLY, false);
if (browsableOnly) {
params.appendWhere(Channels.COLUMN_BROWSABLE + "=1");
}
break;
case MATCH_CHANNEL_ID:
params.setTables(CHANNELS_TABLE);
params.appendWhere(Channels._ID + "=?", uri.getLastPathSegment());
break;
case MATCH_PROGRAM:
params.setTables(PROGRAMS_TABLE);
String paramChannelId = uri.getQueryParameter(TvContract.PARAM_CHANNEL);
if (paramChannelId != null) {
String channelId = String.valueOf(Long.parseLong(paramChannelId));
params.appendWhere(Programs.COLUMN_CHANNEL_ID + "=?", channelId);
}
String paramStartTime = uri.getQueryParameter(TvContract.PARAM_START_TIME);
String paramEndTime = uri.getQueryParameter(TvContract.PARAM_END_TIME);
if (paramStartTime != null && paramEndTime != null) {
String startTime = String.valueOf(Long.parseLong(paramStartTime));
String endTime = String.valueOf(Long.parseLong(paramEndTime));
params.appendWhere(Programs.COLUMN_START_TIME_UTC_MILLIS + "<=? AND "
+ Programs.COLUMN_END_TIME_UTC_MILLIS + ">=?", endTime, startTime);
}
break;
case MATCH_PROGRAM_ID:
params.setTables(PROGRAMS_TABLE);
params.appendWhere(Programs._ID + "=?", uri.getLastPathSegment());
break;
case MATCH_WATCHED_PROGRAM:
params.setTables(WATCHED_PROGRAMS_TABLE);
params.appendWhere(WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=?", "1");
break;
case MATCH_WATCHED_PROGRAM_ID:
params.setTables(WATCHED_PROGRAMS_TABLE);
params.appendWhere(WatchedPrograms._ID + "=?", uri.getLastPathSegment());
params.appendWhere(WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=?", "1");
break;
case MATCH_CHANNEL_ID_LOGO:
if (operation.equals(OP_DELETE)) {
params.setTables(CHANNELS_TABLE);
params.appendWhere(Channels._ID + "=?", uri.getPathSegments().get(1));
break;
}
// fall-through
case MATCH_PASSTHROUGH_ID:
throw new UnsupportedOperationException("Cannot " + operation + " that URI: "
+ uri);
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
return params;
}
private static String capitalize(String str) {
return Character.toUpperCase(str.charAt(0)) + str.substring(1);
}
@SuppressLint("DefaultLocale")
private void checkAndConvertGenre(ContentValues values) {
String canonicalGenres = values.getAsString(Programs.COLUMN_CANONICAL_GENRE);
if (!TextUtils.isEmpty(canonicalGenres)) {
// Check if the canonical genres are valid. If not, clear them.
String[] genres = Genres.decode(canonicalGenres);
for (String genre : genres) {
if (!Genres.isCanonical(genre)) {
values.putNull(Programs.COLUMN_CANONICAL_GENRE);
canonicalGenres = null;
break;
}
}
}
if (TextUtils.isEmpty(canonicalGenres)) {
// If the canonical genre is not set, try to map the broadcast genre to the canonical
// genre.
String broadcastGenres = values.getAsString(Programs.COLUMN_BROADCAST_GENRE);
if (!TextUtils.isEmpty(broadcastGenres)) {
Set<String> genreSet = new HashSet<String>();
String[] genres = Genres.decode(broadcastGenres);
for (String genre : genres) {
String canonicalGenre = sGenreMap.get(genre.toUpperCase());
if (Genres.isCanonical(canonicalGenre)) {
genreSet.add(canonicalGenre);
}
}
if (genreSet.size() > 0) {
values.put(Programs.COLUMN_CANONICAL_GENRE,
Genres.encode(genreSet.toArray(new String[0])));
}
}
}
}
// We might have more than one thread trying to make its way through applyBatch() so the
// notification coalescing needs to be thread-local to work correctly.
private final ThreadLocal<Set<Uri>> mTLBatchNotifications =
new ThreadLocal<Set<Uri>>();
private Set<Uri> getBatchNotificationsSet() {
return mTLBatchNotifications.get();
}
private void setBatchNotificationsSet(Set<Uri> batchNotifications) {
mTLBatchNotifications.set(batchNotifications);
}
@Override
public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
throws OperationApplicationException {
setBatchNotificationsSet(Sets.<Uri>newHashSet());
Context context = getContext();
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
db.beginTransaction();
try {
ContentProviderResult[] results = super.applyBatch(operations);
db.setTransactionSuccessful();
return results;
} finally {
db.endTransaction();
final Set<Uri> notifications = getBatchNotificationsSet();
setBatchNotificationsSet(null);
for (final Uri uri : notifications) {
context.getContentResolver().notifyChange(uri, null);
}
}
}
@Override
public int bulkInsert(Uri uri, ContentValues[] values) {
setBatchNotificationsSet(Sets.<Uri>newHashSet());
Context context = getContext();
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
db.beginTransaction();
try {
int result = super.bulkInsert(uri, values);
db.setTransactionSuccessful();
return result;
} finally {
db.endTransaction();
final Set<Uri> notifications = getBatchNotificationsSet();
setBatchNotificationsSet(null);
for (final Uri notificationUri : notifications) {
context.getContentResolver().notifyChange(notificationUri, null);
}
}
}
private void notifyChange(Uri uri) {
final Set<Uri> batchNotifications = getBatchNotificationsSet();
if (batchNotifications != null) {
batchNotifications.add(uri);
} else {
getContext().getContentResolver().notifyChange(uri, null);
}
}
// When an application tries to create/read/update/delete channel or program data, we need to
// ensure that such an access is limited to the data entries it owns, unless it has the full
// access permission.
// Note that the user's watch log is treated with more caution and we should block any access
// from an application that doesn't have the proper permission.
private boolean needsToLimitPackage(Uri uri) {
int match = sUriMatcher.match(uri);
if (match == MATCH_WATCHED_PROGRAM || match == MATCH_WATCHED_PROGRAM_ID) {
if (!callerHasAccessWatchedProgramsPermission()) {
throw new SecurityException("Access not allowed for " + uri);
}
}
return !callerHasAccessAllEpgDataPermission();
}
private boolean callerHasAccessAllEpgDataPermission() {
return getContext().checkCallingOrSelfPermission(PERMISSION_ACCESS_ALL_EPG_DATA)
== PackageManager.PERMISSION_GRANTED;
}
private boolean callerHasAccessWatchedProgramsPermission() {
return getContext().checkCallingOrSelfPermission(PERMISSION_ACCESS_WATCHED_PROGRAMS)
== PackageManager.PERMISSION_GRANTED;
}
private boolean callerHasModifyParentalControlsPermission() {
return getContext().checkCallingOrSelfPermission(
android.Manifest.permission.MODIFY_PARENTAL_CONTROLS)
== PackageManager.PERMISSION_GRANTED;
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
switch (sUriMatcher.match(uri)) {
case MATCH_CHANNEL_ID_LOGO:
return openLogoFile(uri, mode);
default:
throw new FileNotFoundException(uri.toString());
}
}
private ParcelFileDescriptor openLogoFile(Uri uri, String mode) throws FileNotFoundException {
long channelId = Long.parseLong(uri.getPathSegments().get(1));
SqlParams params = new SqlParams(CHANNELS_TABLE, Channels._ID + "=?",
String.valueOf(channelId));
if (!callerHasAccessAllEpgDataPermission()) {
params.appendWhere(Channels.COLUMN_PACKAGE_NAME + "=?", getCallingPackage_());
}
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(params.getTables());
// We don't write the database here.
SQLiteDatabase db = mOpenHelper.getReadableDatabase();
if (mode.equals("r")) {
String sql = queryBuilder.buildQuery(new String[] { CHANNELS_COLUMN_LOGO },
params.getSelection(), null, null, null, null);
ParcelFileDescriptor fd = DatabaseUtils.blobFileDescriptorForQuery(db, sql, params.getSelectionArgs());
if (fd == null) {
throw new FileNotFoundException(uri.toString());
}
return fd;
} else {
try (Cursor cursor = queryBuilder.query(db, new String[] { Channels._ID },
params.getSelection(), params.getSelectionArgs(), null, null, null)) {
if (cursor.getCount() < 1) {
// Fails early if corresponding channel does not exist.
// PipeMonitor may still fail to update DB later.
throw new FileNotFoundException(uri.toString());
}
}
try {
ParcelFileDescriptor[] pipeFds = ParcelFileDescriptor.createPipe();
PipeMonitor pipeMonitor = new PipeMonitor(pipeFds[0], channelId, params);
pipeMonitor.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
return pipeFds[1];
} catch (IOException ioe) {
FileNotFoundException fne = new FileNotFoundException(uri.toString());
fne.initCause(ioe);
throw fne;
}
}
}
private class PipeMonitor extends AsyncTask<Void, Void, Void> {
private final ParcelFileDescriptor mPfd;
private final long mChannelId;
private final SqlParams mParams;
private PipeMonitor(ParcelFileDescriptor pfd, long channelId, SqlParams params) {
mPfd = pfd;
mChannelId = channelId;
mParams = params;
}
@Override
protected Void doInBackground(Void... params) {
AutoCloseInputStream is = new AutoCloseInputStream(mPfd);
ByteArrayOutputStream baos = null;
int count = 0;
try {
Bitmap bitmap = BitmapFactory.decodeStream(is);
if (bitmap == null) {
Log.e(TAG, "Failed to decode logo image for channel ID " + mChannelId);
return null;
}
float scaleFactor = Math.min(1f, ((float) MAX_LOGO_IMAGE_SIZE) /
Math.max(bitmap.getWidth(), bitmap.getHeight()));
if (scaleFactor < 1f) {
bitmap = Bitmap.createScaledBitmap(bitmap,
(int) (bitmap.getWidth() * scaleFactor),
(int) (bitmap.getHeight() * scaleFactor), false);
}
baos = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos);
byte[] bytes = baos.toByteArray();
ContentValues values = new ContentValues();
values.put(CHANNELS_COLUMN_LOGO, bytes);
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
count = db.update(mParams.getTables(), values, mParams.getSelection(),
mParams.getSelectionArgs());
if (count > 0) {
Uri uri = TvContract.buildChannelLogoUri(mChannelId);
notifyChange(uri);
}
} finally {
if (count == 0) {
try {
mPfd.closeWithError("Failed to write logo for channel ID " + mChannelId);
} catch (IOException ioe) {
Log.e(TAG, "Failed to close pipe", ioe);
}
}
IoUtils.closeQuietly(baos);
IoUtils.closeQuietly(is);
}
return null;
}
}
private final void deleteUnconsolidatedWatchedProgramsRows() {
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
db.delete(WATCHED_PROGRAMS_TABLE, WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=0", null);
}
private final class WatchLogHandler extends Handler {
private static final int MSG_CONSOLIDATE = 1;
private static final int MSG_TRY_CONSOLIDATE_ALL = 2;
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MSG_CONSOLIDATE: {
SomeArgs args = (SomeArgs) msg.obj;
String sessionToken = (String) args.arg1;
long watchEndTime = (long) args.arg2;
onConsolidate(sessionToken, watchEndTime);
args.recycle();
return;
}
case MSG_TRY_CONSOLIDATE_ALL: {
onTryConsolidateAll();
return;
}
default: {
Log.w(TAG, "Unhandled message code: " + msg.what);
return;
}
}
}
// Consolidates all WatchedPrograms rows for a given session with watch end time information
// of the most recent log entry. After this method is called, it is guaranteed that there
// remain consolidated rows only for that session.
private final void onConsolidate(String sessionToken, long watchEndTime) {
if (DEBUG) {
Log.d(TAG, "onConsolidate(sessionToken=" + sessionToken + ", watchEndTime="
+ watchEndTime + ")");
}
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(WATCHED_PROGRAMS_TABLE);
SQLiteDatabase db = mOpenHelper.getReadableDatabase();
// Pick up the last row with the same session token.
String[] projection = {
WatchedPrograms._ID,
WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
WatchedPrograms.COLUMN_CHANNEL_ID
};
String selection = WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=? AND "
+ WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN + "=?";
String[] selectionArgs = {
"0",
sessionToken
};
String sortOrder = WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS + " DESC";
int consolidatedRowCount = 0;
try (Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null,
null, sortOrder)) {
long oldWatchStartTime = watchEndTime;
while (cursor != null && cursor.moveToNext()) {
long id = cursor.getLong(0);
long watchStartTime = cursor.getLong(1);
long channelId = cursor.getLong(2);
consolidatedRowCount += consolidateRow(id, watchStartTime, oldWatchStartTime,
channelId, false);
oldWatchStartTime = watchStartTime;
}
}
if (consolidatedRowCount > 0) {
deleteUnsearchable();
}
}
// Tries to consolidate all WatchedPrograms rows regardless of the session. After this
// method is called, it is guaranteed that we have at most one unconsolidated log entry per
// session that represents the user's ongoing watch activity.
// Also, this method automatically schedules the next consolidation if there still remains
// an unconsolidated entry.
private final void onTryConsolidateAll() {
if (DEBUG) {
Log.d(TAG, "onTryConsolidateAll()");
}
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(WATCHED_PROGRAMS_TABLE);
SQLiteDatabase db = mOpenHelper.getReadableDatabase();
// Pick up all unconsolidated rows grouped by session. The most recent log entry goes on
// top.
String[] projection = {
WatchedPrograms._ID,
WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
WatchedPrograms.COLUMN_CHANNEL_ID,
WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN
};
String selection = WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=0";
String sortOrder = WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN + " DESC,"
+ WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS + " DESC";
int consolidatedRowCount = 0;
try (Cursor cursor = queryBuilder.query(db, projection, selection, null, null, null,
sortOrder)) {
long oldWatchStartTime = 0;
String oldSessionToken = null;
while (cursor != null && cursor.moveToNext()) {
long id = cursor.getLong(0);
long watchStartTime = cursor.getLong(1);
long channelId = cursor.getLong(2);
String sessionToken = cursor.getString(3);
if (!sessionToken.equals(oldSessionToken)) {
// The most recent log entry for the current session, which may be still
// active. Just go through a dry run with the current time to see if this
// entry can be split into multiple rows.
consolidatedRowCount += consolidateRow(id, watchStartTime,
System.currentTimeMillis(), channelId, true);
oldSessionToken = sessionToken;
} else {
// The later entries after the most recent one all fall into here. We now
// know that this watch activity ended exactly at the same time when the
// next activity started.
consolidatedRowCount += consolidateRow(id, watchStartTime,
oldWatchStartTime, channelId, false);
}
oldWatchStartTime = watchStartTime;
}
}
if (consolidatedRowCount > 0) {
deleteUnsearchable();
}
scheduleConsolidationIfNeeded();
}
// Consolidates a WatchedPrograms row.
// A row is 'consolidated' if and only if the following information is complete:
// 1. WatchedPrograms.COLUMN_CHANNEL_ID
// 2. WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS
// 3. WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS
// where COLUMN_WATCH_START_TIME_UTC_MILLIS <= COLUMN_WATCH_END_TIME_UTC_MILLIS.
// This is the minimal but useful enough set of information to comprise the user's watch
// history. (The program data are considered optional although we do try to fill them while
// consolidating the row.) It is guaranteed that the target row is either consolidated or
// deleted after this method is called.
// Set {@code dryRun} to {@code true} if you think it's necessary to split the row without
// consolidating the most recent row because the user stayed on the same channel for a very
// long time.
// This method returns the number of consolidated rows, which can be 0 or more.
private final int consolidateRow(long id, long watchStartTime, long watchEndTime,
long channelId, boolean dryRun) {
if (DEBUG) {
Log.d(TAG, "consolidateRow(id=" + id + ", watchStartTime=" + watchStartTime
+ ", watchEndTime=" + watchEndTime + ", channelId=" + channelId
+ ", dryRun=" + dryRun + ")");
}
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
if (watchStartTime > watchEndTime) {
Log.e(TAG, "watchEndTime cannot be less than watchStartTime");
db.delete(WATCHED_PROGRAMS_TABLE, WatchedPrograms._ID + "=" + String.valueOf(id),
null);
return 0;
}
ContentValues values = getProgramValues(channelId, watchStartTime);
Long endTime = values.getAsLong(WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS);
boolean needsToSplit = endTime != null && endTime < watchEndTime;
values.put(WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
String.valueOf(watchStartTime));
if (!dryRun || needsToSplit) {
values.put(WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS,
String.valueOf(needsToSplit ? endTime : watchEndTime));
values.put(WATCHED_PROGRAMS_COLUMN_CONSOLIDATED, "1");
db.update(WATCHED_PROGRAMS_TABLE, values,
WatchedPrograms._ID + "=" + String.valueOf(id), null);
// Treat the watched program is inserted when WATCHED_PROGRAMS_COLUMN_CONSOLIDATED
// becomes 1.
notifyChange(TvContract.buildWatchedProgramUri(id));
} else {
db.update(WATCHED_PROGRAMS_TABLE, values,
WatchedPrograms._ID + "=" + String.valueOf(id), null);
}
int count = dryRun ? 0 : 1;
if (needsToSplit) {
// This means that the program ended before the user stops watching the current
// channel. In this case we duplicate the log entry as many as the number of
// programs watched on the same channel. Here the end time of the current program
// becomes the new watch start time of the next program.
long duplicatedId = duplicateRow(id);
if (duplicatedId > 0) {
count += consolidateRow(duplicatedId, endTime, watchEndTime, channelId, dryRun);
}
}
return count;
}
// Deletes the log entries from unsearchable channels. Note that only consolidated log
// entries are safe to delete.
private final void deleteUnsearchable() {
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
String deleteWhere = WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=1 AND "
+ WatchedPrograms.COLUMN_CHANNEL_ID + " IN (SELECT " + Channels._ID
+ " FROM " + CHANNELS_TABLE + " WHERE " + Channels.COLUMN_SEARCHABLE + "=0)";
db.delete(WATCHED_PROGRAMS_TABLE, deleteWhere, null);
}
private final void scheduleConsolidationIfNeeded() {
if (DEBUG) {
Log.d(TAG, "scheduleConsolidationIfNeeded()");
}
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(WATCHED_PROGRAMS_TABLE);
SQLiteDatabase db = mOpenHelper.getReadableDatabase();
// Pick up all unconsolidated rows.
String[] projection = {
WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
WatchedPrograms.COLUMN_CHANNEL_ID,
};
String selection = WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=0";
try (Cursor cursor = queryBuilder.query(db, projection, selection, null, null, null,
null)) {
// Find the earliest time that any of the currently watching programs ends and
// schedule the next consolidation at that time.
long minEndTime = Long.MAX_VALUE;
while (cursor != null && cursor.moveToNext()) {
long watchStartTime = cursor.getLong(0);
long channelId = cursor.getLong(1);
ContentValues values = getProgramValues(channelId, watchStartTime);
Long endTime = values.getAsLong(WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS);
if (endTime != null && endTime < minEndTime
&& endTime > System.currentTimeMillis()) {
minEndTime = endTime;
}
}
if (minEndTime != Long.MAX_VALUE) {
sendEmptyMessageAtTime(MSG_TRY_CONSOLIDATE_ALL, minEndTime);
if (DEBUG) {
CharSequence minEndTimeStr = DateUtils.getRelativeTimeSpanString(
minEndTime, System.currentTimeMillis(), DateUtils.SECOND_IN_MILLIS);
Log.d(TAG, "onTryConsolidateAll() scheduled " + minEndTimeStr);
}
}
}
}
// Returns non-null ContentValues of the program data that the user watched on the channel
// {@code channelId} at the time {@code time}.
private final ContentValues getProgramValues(long channelId, long time) {
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(PROGRAMS_TABLE);
SQLiteDatabase db = mOpenHelper.getReadableDatabase();
String[] projection = {
Programs.COLUMN_TITLE,
Programs.COLUMN_START_TIME_UTC_MILLIS,
Programs.COLUMN_END_TIME_UTC_MILLIS,
Programs.COLUMN_SHORT_DESCRIPTION
};
String selection = Programs.COLUMN_CHANNEL_ID + "=? AND "
+ Programs.COLUMN_START_TIME_UTC_MILLIS + "<=? AND "
+ Programs.COLUMN_END_TIME_UTC_MILLIS + ">?";
String[] selectionArgs = {
String.valueOf(channelId),
String.valueOf(time),
String.valueOf(time)
};
String sortOrder = Programs.COLUMN_START_TIME_UTC_MILLIS + " ASC";
try (Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null,
null, sortOrder)) {
ContentValues values = new ContentValues();
if (cursor != null && cursor.moveToNext()) {
values.put(WatchedPrograms.COLUMN_TITLE, cursor.getString(0));
values.put(WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS, cursor.getLong(1));
values.put(WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS, cursor.getLong(2));
values.put(WatchedPrograms.COLUMN_DESCRIPTION, cursor.getString(3));
}
return values;
}
}
// Duplicates the WatchedPrograms row with a given ID and returns the ID of the duplicated
// row. Returns -1 if failed.
private final long duplicateRow(long id) {
if (DEBUG) {
Log.d(TAG, "duplicateRow(" + id + ")");
}
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(WATCHED_PROGRAMS_TABLE);
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
String[] projection = {
WatchedPrograms.COLUMN_PACKAGE_NAME,
WatchedPrograms.COLUMN_CHANNEL_ID,
WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN
};
String selection = WatchedPrograms._ID + "=" + String.valueOf(id);
try (Cursor cursor = queryBuilder.query(db, projection, selection, null, null, null,
null)) {
long rowId = -1;
if (cursor != null && cursor.moveToNext()) {
ContentValues values = new ContentValues();
values.put(WatchedPrograms.COLUMN_PACKAGE_NAME, cursor.getString(0));
values.put(WatchedPrograms.COLUMN_CHANNEL_ID, cursor.getLong(1));
values.put(WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN, cursor.getString(2));
rowId = db.insert(WATCHED_PROGRAMS_TABLE, null, values);
}
return rowId;
}
}
}
}