1 /*
2  * Copyright (C) 2014 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.providers.tv;
18 
19 import android.annotation.SuppressLint;
20 import android.app.AlarmManager;
21 import android.app.PendingIntent;
22 import android.content.ContentProvider;
23 import android.content.ContentProviderOperation;
24 import android.content.ContentProviderResult;
25 import android.content.ContentValues;
26 import android.content.Context;
27 import android.content.Intent;
28 import android.content.OperationApplicationException;
29 import android.content.UriMatcher;
30 import android.content.pm.PackageManager;
31 import android.database.Cursor;
32 import android.database.DatabaseUtils;
33 import android.database.SQLException;
34 import android.database.sqlite.SQLiteDatabase;
35 import android.database.sqlite.SQLiteOpenHelper;
36 import android.database.sqlite.SQLiteQueryBuilder;
37 import android.graphics.Bitmap;
38 import android.graphics.BitmapFactory;
39 import android.media.tv.TvContract;
40 import android.media.tv.TvContract.BaseTvColumns;
41 import android.media.tv.TvContract.Channels;
42 import android.media.tv.TvContract.Programs;
43 import android.media.tv.TvContract.Programs.Genres;
44 import android.media.tv.TvContract.WatchedPrograms;
45 import android.net.Uri;
46 import android.os.AsyncTask;
47 import android.os.Handler;
48 import android.os.Message;
49 import android.os.ParcelFileDescriptor;
50 import android.os.ParcelFileDescriptor.AutoCloseInputStream;
51 import android.text.TextUtils;
52 import android.text.format.DateUtils;
53 import android.util.Log;
54 
55 import com.android.internal.annotations.VisibleForTesting;
56 import com.android.internal.os.SomeArgs;
57 import com.android.providers.tv.util.SqlParams;
58 import com.google.android.collect.Sets;
59 
60 import libcore.io.IoUtils;
61 
62 import java.io.ByteArrayOutputStream;
63 import java.io.File;
64 import java.io.FileNotFoundException;
65 import java.io.IOException;
66 import java.util.ArrayList;
67 import java.util.HashMap;
68 import java.util.HashSet;
69 import java.util.Map;
70 import java.util.Set;
71 
72 /**
73  * TV content provider. The contract between this provider and applications is defined in
74  * {@link android.media.tv.TvContract}.
75  */
76 public class TvProvider extends ContentProvider {
77     private static final boolean DEBUG = false;
78     private static final String TAG = "TvProvider";
79 
80     // Operation names for createSqlParams().
81     private static final String OP_QUERY = "query";
82     private static final String OP_UPDATE = "update";
83     private static final String OP_DELETE = "delete";
84 
85     private static final int DATABASE_VERSION = 23;
86     private static final String DATABASE_NAME = "tv.db";
87     private static final String CHANNELS_TABLE = "channels";
88     private static final String PROGRAMS_TABLE = "programs";
89     private static final String WATCHED_PROGRAMS_TABLE = "watched_programs";
90     private static final String DELETED_CHANNELS_TABLE = "deleted_channels";  // Deprecated
91     private static final String PROGRAMS_TABLE_PACKAGE_NAME_INDEX = "programs_package_name_index";
92     private static final String PROGRAMS_TABLE_CHANNEL_ID_INDEX = "programs_channel_id_index";
93     private static final String PROGRAMS_TABLE_START_TIME_INDEX = "programs_start_time_index";
94     private static final String PROGRAMS_TABLE_END_TIME_INDEX = "programs_end_time_index";
95     private static final String WATCHED_PROGRAMS_TABLE_CHANNEL_ID_INDEX =
96             "watched_programs_channel_id_index";
97     private static final String DEFAULT_CHANNELS_SORT_ORDER = Channels.COLUMN_DISPLAY_NUMBER
98             + " ASC";
99     private static final String DEFAULT_PROGRAMS_SORT_ORDER = Programs.COLUMN_START_TIME_UTC_MILLIS
100             + " ASC";
101     private static final String DEFAULT_WATCHED_PROGRAMS_SORT_ORDER =
102             WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS + " DESC";
103     private static final String CHANNELS_TABLE_INNER_JOIN_PROGRAMS_TABLE = CHANNELS_TABLE
104             + " INNER JOIN " + PROGRAMS_TABLE
105             + " ON (" + CHANNELS_TABLE + "." + Channels._ID + "="
106             + PROGRAMS_TABLE + "." + Programs.COLUMN_CHANNEL_ID + ")";
107 
108     private static final UriMatcher sUriMatcher;
109     private static final int MATCH_CHANNEL = 1;
110     private static final int MATCH_CHANNEL_ID = 2;
111     private static final int MATCH_CHANNEL_ID_LOGO = 3;
112     private static final int MATCH_PASSTHROUGH_ID = 4;
113     private static final int MATCH_PROGRAM = 5;
114     private static final int MATCH_PROGRAM_ID = 6;
115     private static final int MATCH_WATCHED_PROGRAM = 7;
116     private static final int MATCH_WATCHED_PROGRAM_ID = 8;
117 
118     private static final String CHANNELS_COLUMN_LOGO = "logo";
119     private static final int MAX_LOGO_IMAGE_SIZE = 256;
120 
121     // The internal column in the watched programs table to indicate whether the current log entry
122     // is consolidated or not. Unconsolidated entries may have columns with missing data.
123     private static final String WATCHED_PROGRAMS_COLUMN_CONSOLIDATED = "consolidated";
124 
125     private static final long MAX_PROGRAM_DATA_DELAY_IN_MILLIS = 10 * 1000; // 10 seconds
126 
127     private static Map<String, String> sChannelProjectionMap;
128     private static Map<String, String> sProgramProjectionMap;
129     private static Map<String, String> sWatchedProgramProjectionMap;
130 
131     static {
132         sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
sUriMatcher.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL)133         sUriMatcher.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL);
sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_ID)134         sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_ID);
sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#/logo", MATCH_CHANNEL_ID_LOGO)135         sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#/logo", MATCH_CHANNEL_ID_LOGO);
sUriMatcher.addURI(TvContract.AUTHORITY, "passthrough/*", MATCH_PASSTHROUGH_ID)136         sUriMatcher.addURI(TvContract.AUTHORITY, "passthrough/*", MATCH_PASSTHROUGH_ID);
sUriMatcher.addURI(TvContract.AUTHORITY, "program", MATCH_PROGRAM)137         sUriMatcher.addURI(TvContract.AUTHORITY, "program", MATCH_PROGRAM);
sUriMatcher.addURI(TvContract.AUTHORITY, "program/#", MATCH_PROGRAM_ID)138         sUriMatcher.addURI(TvContract.AUTHORITY, "program/#", MATCH_PROGRAM_ID);
sUriMatcher.addURI(TvContract.AUTHORITY, "watched_program", MATCH_WATCHED_PROGRAM)139         sUriMatcher.addURI(TvContract.AUTHORITY, "watched_program", MATCH_WATCHED_PROGRAM);
sUriMatcher.addURI(TvContract.AUTHORITY, "watched_program/#", MATCH_WATCHED_PROGRAM_ID)140         sUriMatcher.addURI(TvContract.AUTHORITY, "watched_program/#", MATCH_WATCHED_PROGRAM_ID);
141 
142         sChannelProjectionMap = new HashMap<String, String>();
sChannelProjectionMap.put(Channels._ID, CHANNELS_TABLE + "." + Channels._ID)143         sChannelProjectionMap.put(Channels._ID, CHANNELS_TABLE + "." + Channels._ID);
sChannelProjectionMap.put(Channels.COLUMN_PACKAGE_NAME, CHANNELS_TABLE + "." + Channels.COLUMN_PACKAGE_NAME)144         sChannelProjectionMap.put(Channels.COLUMN_PACKAGE_NAME,
145                 CHANNELS_TABLE + "." + Channels.COLUMN_PACKAGE_NAME);
sChannelProjectionMap.put(Channels.COLUMN_INPUT_ID, CHANNELS_TABLE + "." + Channels.COLUMN_INPUT_ID)146         sChannelProjectionMap.put(Channels.COLUMN_INPUT_ID,
147                 CHANNELS_TABLE + "." + Channels.COLUMN_INPUT_ID);
sChannelProjectionMap.put(Channels.COLUMN_TYPE, CHANNELS_TABLE + "." + Channels.COLUMN_TYPE)148         sChannelProjectionMap.put(Channels.COLUMN_TYPE,
149                 CHANNELS_TABLE + "." + Channels.COLUMN_TYPE);
sChannelProjectionMap.put(Channels.COLUMN_SERVICE_TYPE, CHANNELS_TABLE + "." + Channels.COLUMN_SERVICE_TYPE)150         sChannelProjectionMap.put(Channels.COLUMN_SERVICE_TYPE,
151                 CHANNELS_TABLE + "." + Channels.COLUMN_SERVICE_TYPE);
sChannelProjectionMap.put(Channels.COLUMN_ORIGINAL_NETWORK_ID, CHANNELS_TABLE + "." + Channels.COLUMN_ORIGINAL_NETWORK_ID)152         sChannelProjectionMap.put(Channels.COLUMN_ORIGINAL_NETWORK_ID,
153                 CHANNELS_TABLE + "." + Channels.COLUMN_ORIGINAL_NETWORK_ID);
sChannelProjectionMap.put(Channels.COLUMN_TRANSPORT_STREAM_ID, CHANNELS_TABLE + "." + Channels.COLUMN_TRANSPORT_STREAM_ID)154         sChannelProjectionMap.put(Channels.COLUMN_TRANSPORT_STREAM_ID,
155                 CHANNELS_TABLE + "." + Channels.COLUMN_TRANSPORT_STREAM_ID);
sChannelProjectionMap.put(Channels.COLUMN_SERVICE_ID, CHANNELS_TABLE + "." + Channels.COLUMN_SERVICE_ID)156         sChannelProjectionMap.put(Channels.COLUMN_SERVICE_ID,
157                 CHANNELS_TABLE + "." + Channels.COLUMN_SERVICE_ID);
sChannelProjectionMap.put(Channels.COLUMN_DISPLAY_NUMBER, CHANNELS_TABLE + "." + Channels.COLUMN_DISPLAY_NUMBER)158         sChannelProjectionMap.put(Channels.COLUMN_DISPLAY_NUMBER,
159                 CHANNELS_TABLE + "." + Channels.COLUMN_DISPLAY_NUMBER);
sChannelProjectionMap.put(Channels.COLUMN_DISPLAY_NAME, CHANNELS_TABLE + "." + Channels.COLUMN_DISPLAY_NAME)160         sChannelProjectionMap.put(Channels.COLUMN_DISPLAY_NAME,
161                 CHANNELS_TABLE + "." + Channels.COLUMN_DISPLAY_NAME);
sChannelProjectionMap.put(Channels.COLUMN_NETWORK_AFFILIATION, CHANNELS_TABLE + "." + Channels.COLUMN_NETWORK_AFFILIATION)162         sChannelProjectionMap.put(Channels.COLUMN_NETWORK_AFFILIATION,
163                 CHANNELS_TABLE + "." + Channels.COLUMN_NETWORK_AFFILIATION);
sChannelProjectionMap.put(Channels.COLUMN_DESCRIPTION, CHANNELS_TABLE + "." + Channels.COLUMN_DESCRIPTION)164         sChannelProjectionMap.put(Channels.COLUMN_DESCRIPTION,
165                 CHANNELS_TABLE + "." + Channels.COLUMN_DESCRIPTION);
sChannelProjectionMap.put(Channels.COLUMN_VIDEO_FORMAT, CHANNELS_TABLE + "." + Channels.COLUMN_VIDEO_FORMAT)166         sChannelProjectionMap.put(Channels.COLUMN_VIDEO_FORMAT,
167                 CHANNELS_TABLE + "." + Channels.COLUMN_VIDEO_FORMAT);
sChannelProjectionMap.put(Channels.COLUMN_BROWSABLE, CHANNELS_TABLE + "." + Channels.COLUMN_BROWSABLE)168         sChannelProjectionMap.put(Channels.COLUMN_BROWSABLE,
169                 CHANNELS_TABLE + "." + Channels.COLUMN_BROWSABLE);
sChannelProjectionMap.put(Channels.COLUMN_SEARCHABLE, CHANNELS_TABLE + "." + Channels.COLUMN_SEARCHABLE)170         sChannelProjectionMap.put(Channels.COLUMN_SEARCHABLE,
171                 CHANNELS_TABLE + "." + Channels.COLUMN_SEARCHABLE);
sChannelProjectionMap.put(Channels.COLUMN_LOCKED, CHANNELS_TABLE + "." + Channels.COLUMN_LOCKED)172         sChannelProjectionMap.put(Channels.COLUMN_LOCKED,
173                 CHANNELS_TABLE + "." + Channels.COLUMN_LOCKED);
sChannelProjectionMap.put(Channels.COLUMN_INTERNAL_PROVIDER_DATA, CHANNELS_TABLE + "." + Channels.COLUMN_INTERNAL_PROVIDER_DATA)174         sChannelProjectionMap.put(Channels.COLUMN_INTERNAL_PROVIDER_DATA,
175                 CHANNELS_TABLE + "." + Channels.COLUMN_INTERNAL_PROVIDER_DATA);
sChannelProjectionMap.put(Channels.COLUMN_VERSION_NUMBER, CHANNELS_TABLE + "." + Channels.COLUMN_VERSION_NUMBER)176         sChannelProjectionMap.put(Channels.COLUMN_VERSION_NUMBER,
177                 CHANNELS_TABLE + "." + Channels.COLUMN_VERSION_NUMBER);
178 
179         sProgramProjectionMap = new HashMap<String, String>();
sProgramProjectionMap.put(Programs._ID, Programs._ID)180         sProgramProjectionMap.put(Programs._ID, Programs._ID);
sProgramProjectionMap.put(Programs.COLUMN_PACKAGE_NAME, Programs.COLUMN_PACKAGE_NAME)181         sProgramProjectionMap.put(Programs.COLUMN_PACKAGE_NAME, Programs.COLUMN_PACKAGE_NAME);
sProgramProjectionMap.put(Programs.COLUMN_CHANNEL_ID, Programs.COLUMN_CHANNEL_ID)182         sProgramProjectionMap.put(Programs.COLUMN_CHANNEL_ID, Programs.COLUMN_CHANNEL_ID);
sProgramProjectionMap.put(Programs.COLUMN_TITLE, Programs.COLUMN_TITLE)183         sProgramProjectionMap.put(Programs.COLUMN_TITLE, Programs.COLUMN_TITLE);
sProgramProjectionMap.put(Programs.COLUMN_SEASON_NUMBER, Programs.COLUMN_SEASON_NUMBER)184         sProgramProjectionMap.put(Programs.COLUMN_SEASON_NUMBER, Programs.COLUMN_SEASON_NUMBER);
sProgramProjectionMap.put(Programs.COLUMN_EPISODE_NUMBER, Programs.COLUMN_EPISODE_NUMBER)185         sProgramProjectionMap.put(Programs.COLUMN_EPISODE_NUMBER, Programs.COLUMN_EPISODE_NUMBER);
sProgramProjectionMap.put(Programs.COLUMN_EPISODE_TITLE, Programs.COLUMN_EPISODE_TITLE)186         sProgramProjectionMap.put(Programs.COLUMN_EPISODE_TITLE, Programs.COLUMN_EPISODE_TITLE);
sProgramProjectionMap.put(Programs.COLUMN_START_TIME_UTC_MILLIS, Programs.COLUMN_START_TIME_UTC_MILLIS)187         sProgramProjectionMap.put(Programs.COLUMN_START_TIME_UTC_MILLIS,
188                 Programs.COLUMN_START_TIME_UTC_MILLIS);
sProgramProjectionMap.put(Programs.COLUMN_END_TIME_UTC_MILLIS, Programs.COLUMN_END_TIME_UTC_MILLIS)189         sProgramProjectionMap.put(Programs.COLUMN_END_TIME_UTC_MILLIS,
190                 Programs.COLUMN_END_TIME_UTC_MILLIS);
sProgramProjectionMap.put(Programs.COLUMN_BROADCAST_GENRE, Programs.COLUMN_BROADCAST_GENRE)191         sProgramProjectionMap.put(Programs.COLUMN_BROADCAST_GENRE, Programs.COLUMN_BROADCAST_GENRE);
sProgramProjectionMap.put(Programs.COLUMN_CANONICAL_GENRE, Programs.COLUMN_CANONICAL_GENRE)192         sProgramProjectionMap.put(Programs.COLUMN_CANONICAL_GENRE, Programs.COLUMN_CANONICAL_GENRE);
sProgramProjectionMap.put(Programs.COLUMN_SHORT_DESCRIPTION, Programs.COLUMN_SHORT_DESCRIPTION)193         sProgramProjectionMap.put(Programs.COLUMN_SHORT_DESCRIPTION,
194                 Programs.COLUMN_SHORT_DESCRIPTION);
sProgramProjectionMap.put(Programs.COLUMN_LONG_DESCRIPTION, Programs.COLUMN_LONG_DESCRIPTION)195         sProgramProjectionMap.put(Programs.COLUMN_LONG_DESCRIPTION,
196                 Programs.COLUMN_LONG_DESCRIPTION);
sProgramProjectionMap.put(Programs.COLUMN_VIDEO_WIDTH, Programs.COLUMN_VIDEO_WIDTH)197         sProgramProjectionMap.put(Programs.COLUMN_VIDEO_WIDTH, Programs.COLUMN_VIDEO_WIDTH);
sProgramProjectionMap.put(Programs.COLUMN_VIDEO_HEIGHT, Programs.COLUMN_VIDEO_HEIGHT)198         sProgramProjectionMap.put(Programs.COLUMN_VIDEO_HEIGHT, Programs.COLUMN_VIDEO_HEIGHT);
sProgramProjectionMap.put(Programs.COLUMN_AUDIO_LANGUAGE, Programs.COLUMN_AUDIO_LANGUAGE)199         sProgramProjectionMap.put(Programs.COLUMN_AUDIO_LANGUAGE, Programs.COLUMN_AUDIO_LANGUAGE);
sProgramProjectionMap.put(Programs.COLUMN_CONTENT_RATING, Programs.COLUMN_CONTENT_RATING)200         sProgramProjectionMap.put(Programs.COLUMN_CONTENT_RATING, Programs.COLUMN_CONTENT_RATING);
sProgramProjectionMap.put(Programs.COLUMN_POSTER_ART_URI, Programs.COLUMN_POSTER_ART_URI)201         sProgramProjectionMap.put(Programs.COLUMN_POSTER_ART_URI, Programs.COLUMN_POSTER_ART_URI);
sProgramProjectionMap.put(Programs.COLUMN_THUMBNAIL_URI, Programs.COLUMN_THUMBNAIL_URI)202         sProgramProjectionMap.put(Programs.COLUMN_THUMBNAIL_URI, Programs.COLUMN_THUMBNAIL_URI);
sProgramProjectionMap.put(Programs.COLUMN_INTERNAL_PROVIDER_DATA, Programs.COLUMN_INTERNAL_PROVIDER_DATA)203         sProgramProjectionMap.put(Programs.COLUMN_INTERNAL_PROVIDER_DATA,
204                 Programs.COLUMN_INTERNAL_PROVIDER_DATA);
sProgramProjectionMap.put(Programs.COLUMN_VERSION_NUMBER, Programs.COLUMN_VERSION_NUMBER)205         sProgramProjectionMap.put(Programs.COLUMN_VERSION_NUMBER, Programs.COLUMN_VERSION_NUMBER);
206 
207         sWatchedProgramProjectionMap = new HashMap<String, String>();
sWatchedProgramProjectionMap.put(WatchedPrograms._ID, WatchedPrograms._ID)208         sWatchedProgramProjectionMap.put(WatchedPrograms._ID, WatchedPrograms._ID);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS, WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS)209         sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
210                 WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS, WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS)211         sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS,
212                 WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_CHANNEL_ID, WatchedPrograms.COLUMN_CHANNEL_ID)213         sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_CHANNEL_ID,
214                 WatchedPrograms.COLUMN_CHANNEL_ID);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_TITLE, WatchedPrograms.COLUMN_TITLE)215         sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_TITLE,
216                 WatchedPrograms.COLUMN_TITLE);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS, WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS)217         sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS,
218                 WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS, WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS)219         sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS,
220                 WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_DESCRIPTION, WatchedPrograms.COLUMN_DESCRIPTION)221         sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_DESCRIPTION,
222                 WatchedPrograms.COLUMN_DESCRIPTION);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_INTERNAL_TUNE_PARAMS, WatchedPrograms.COLUMN_INTERNAL_TUNE_PARAMS)223         sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_INTERNAL_TUNE_PARAMS,
224                 WatchedPrograms.COLUMN_INTERNAL_TUNE_PARAMS);
sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN, WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN)225         sWatchedProgramProjectionMap.put(WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN,
226                 WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN);
sWatchedProgramProjectionMap.put(WATCHED_PROGRAMS_COLUMN_CONSOLIDATED, WATCHED_PROGRAMS_COLUMN_CONSOLIDATED)227         sWatchedProgramProjectionMap.put(WATCHED_PROGRAMS_COLUMN_CONSOLIDATED,
228                 WATCHED_PROGRAMS_COLUMN_CONSOLIDATED);
229     }
230 
231     // Mapping from broadcast genre to canonical genre.
232     private static Map<String, String> sGenreMap;
233 
234     private static final String PERMISSION_ACCESS_ALL_EPG_DATA =
235             "com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA";
236 
237     private static final String PERMISSION_ACCESS_WATCHED_PROGRAMS =
238             "com.android.providers.tv.permission.ACCESS_WATCHED_PROGRAMS";
239 
240     private static class DatabaseHelper extends SQLiteOpenHelper {
241         private final Context mContext;
242 
DatabaseHelper(Context context)243         DatabaseHelper(Context context) {
244             super(context, DATABASE_NAME, null, DATABASE_VERSION);
245             mContext = context;
246         }
247 
248         @Override
onConfigure(SQLiteDatabase db)249         public void onConfigure(SQLiteDatabase db) {
250             db.setForeignKeyConstraintsEnabled(true);
251         }
252 
253         @Override
onCreate(SQLiteDatabase db)254         public void onCreate(SQLiteDatabase db) {
255             if (DEBUG) {
256                 Log.d(TAG, "Creating database");
257             }
258             // Set up the database schema.
259             db.execSQL("CREATE TABLE " + CHANNELS_TABLE + " ("
260                     + Channels._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
261                     + Channels.COLUMN_PACKAGE_NAME + " TEXT NOT NULL,"
262                     + Channels.COLUMN_INPUT_ID + " TEXT NOT NULL,"
263                     + Channels.COLUMN_TYPE + " TEXT NOT NULL DEFAULT '" + Channels.TYPE_OTHER + "',"
264                     + Channels.COLUMN_SERVICE_TYPE + " TEXT NOT NULL DEFAULT '"
265                     + Channels.SERVICE_TYPE_AUDIO_VIDEO + "',"
266                     + Channels.COLUMN_ORIGINAL_NETWORK_ID + " INTEGER NOT NULL DEFAULT 0,"
267                     + Channels.COLUMN_TRANSPORT_STREAM_ID + " INTEGER NOT NULL DEFAULT 0,"
268                     + Channels.COLUMN_SERVICE_ID + " INTEGER NOT NULL DEFAULT 0,"
269                     + Channels.COLUMN_DISPLAY_NUMBER + " TEXT,"
270                     + Channels.COLUMN_DISPLAY_NAME + " TEXT,"
271                     + Channels.COLUMN_NETWORK_AFFILIATION + " TEXT,"
272                     + Channels.COLUMN_DESCRIPTION + " TEXT,"
273                     + Channels.COLUMN_VIDEO_FORMAT + " TEXT,"
274                     + Channels.COLUMN_BROWSABLE + " INTEGER NOT NULL DEFAULT 0,"
275                     + Channels.COLUMN_SEARCHABLE + " INTEGER NOT NULL DEFAULT 1,"
276                     + Channels.COLUMN_LOCKED + " INTEGER NOT NULL DEFAULT 0,"
277                     + Channels.COLUMN_INTERNAL_PROVIDER_DATA + " BLOB,"
278                     + CHANNELS_COLUMN_LOGO + " BLOB,"
279                     + Channels.COLUMN_VERSION_NUMBER + " INTEGER,"
280                     // Needed for foreign keys in other tables.
281                     + "UNIQUE(" + Channels._ID + "," + Channels.COLUMN_PACKAGE_NAME + ")"
282                     + ");");
283             db.execSQL("CREATE TABLE " + PROGRAMS_TABLE + " ("
284                     + Programs._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
285                     + Programs.COLUMN_PACKAGE_NAME + " TEXT NOT NULL,"
286                     + Programs.COLUMN_CHANNEL_ID + " INTEGER,"
287                     + Programs.COLUMN_TITLE + " TEXT,"
288                     + Programs.COLUMN_SEASON_NUMBER + " INTEGER,"
289                     + Programs.COLUMN_EPISODE_NUMBER + " INTEGER,"
290                     + Programs.COLUMN_EPISODE_TITLE + " TEXT,"
291                     + Programs.COLUMN_START_TIME_UTC_MILLIS + " INTEGER,"
292                     + Programs.COLUMN_END_TIME_UTC_MILLIS + " INTEGER,"
293                     + Programs.COLUMN_BROADCAST_GENRE + " TEXT,"
294                     + Programs.COLUMN_CANONICAL_GENRE + " TEXT,"
295                     + Programs.COLUMN_SHORT_DESCRIPTION + " TEXT,"
296                     + Programs.COLUMN_LONG_DESCRIPTION + " TEXT,"
297                     + Programs.COLUMN_VIDEO_WIDTH + " INTEGER,"
298                     + Programs.COLUMN_VIDEO_HEIGHT + " INTEGER,"
299                     + Programs.COLUMN_AUDIO_LANGUAGE + " TEXT,"
300                     + Programs.COLUMN_CONTENT_RATING + " TEXT,"
301                     + Programs.COLUMN_POSTER_ART_URI + " TEXT,"
302                     + Programs.COLUMN_THUMBNAIL_URI + " TEXT,"
303                     + Programs.COLUMN_INTERNAL_PROVIDER_DATA + " BLOB,"
304                     + Programs.COLUMN_VERSION_NUMBER + " INTEGER,"
305                     + "FOREIGN KEY("
306                             + Programs.COLUMN_CHANNEL_ID + "," + Programs.COLUMN_PACKAGE_NAME
307                             + ") REFERENCES " + CHANNELS_TABLE + "("
308                             + Channels._ID + "," + Channels.COLUMN_PACKAGE_NAME
309                             + ") ON UPDATE CASCADE ON DELETE CASCADE"
310                     + ");");
311             db.execSQL("CREATE INDEX " + PROGRAMS_TABLE_PACKAGE_NAME_INDEX + " ON " + PROGRAMS_TABLE
312                     + "(" + Programs.COLUMN_PACKAGE_NAME + ");");
313             db.execSQL("CREATE INDEX " + PROGRAMS_TABLE_CHANNEL_ID_INDEX + " ON " + PROGRAMS_TABLE
314                     + "(" + Programs.COLUMN_CHANNEL_ID + ");");
315             db.execSQL("CREATE INDEX " + PROGRAMS_TABLE_START_TIME_INDEX + " ON " + PROGRAMS_TABLE
316                     + "(" + Programs.COLUMN_START_TIME_UTC_MILLIS + ");");
317             db.execSQL("CREATE INDEX " + PROGRAMS_TABLE_END_TIME_INDEX + " ON " + PROGRAMS_TABLE
318                     + "(" + Programs.COLUMN_END_TIME_UTC_MILLIS + ");");
319             db.execSQL("CREATE TABLE " + WATCHED_PROGRAMS_TABLE + " ("
320                     + WatchedPrograms._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
321                     + WatchedPrograms.COLUMN_PACKAGE_NAME + " TEXT NOT NULL,"
322                     + WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS
323                     + " INTEGER NOT NULL DEFAULT 0,"
324                     + WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS
325                     + " INTEGER NOT NULL DEFAULT 0,"
326                     + WatchedPrograms.COLUMN_CHANNEL_ID + " INTEGER,"
327                     + WatchedPrograms.COLUMN_TITLE + " TEXT,"
328                     + WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS + " INTEGER,"
329                     + WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS + " INTEGER,"
330                     + WatchedPrograms.COLUMN_DESCRIPTION + " TEXT,"
331                     + WatchedPrograms.COLUMN_INTERNAL_TUNE_PARAMS + " TEXT,"
332                     + WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN + " TEXT NOT NULL,"
333                     + WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + " INTEGER NOT NULL DEFAULT 0,"
334                     + "FOREIGN KEY("
335                             + WatchedPrograms.COLUMN_CHANNEL_ID + ","
336                             + WatchedPrograms.COLUMN_PACKAGE_NAME
337                             + ") REFERENCES " + CHANNELS_TABLE + "("
338                             + Channels._ID + "," + Channels.COLUMN_PACKAGE_NAME
339                             + ") ON UPDATE CASCADE ON DELETE CASCADE"
340                     + ");");
341             db.execSQL("CREATE INDEX " + WATCHED_PROGRAMS_TABLE_CHANNEL_ID_INDEX + " ON "
342                     + WATCHED_PROGRAMS_TABLE + "(" + WatchedPrograms.COLUMN_CHANNEL_ID + ");");
343         }
344 
345         @Override
onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)346         public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
347             Log.i(TAG, "Upgrading from version " + oldVersion + " to " + newVersion
348                     + ", data will be lost!");
349             db.execSQL("DROP TABLE IF EXISTS " + DELETED_CHANNELS_TABLE);
350             db.execSQL("DROP TABLE IF EXISTS " + WATCHED_PROGRAMS_TABLE);
351             db.execSQL("DROP TABLE IF EXISTS " + PROGRAMS_TABLE);
352             db.execSQL("DROP TABLE IF EXISTS " + CHANNELS_TABLE);
353 
354             onCreate(db);
355         }
356     }
357 
358     private DatabaseHelper mOpenHelper;
359 
360     private final Handler mLogHandler = new WatchLogHandler();
361 
362     @Override
onCreate()363     public boolean onCreate() {
364         if (DEBUG) {
365             Log.d(TAG, "Creating TvProvider");
366         }
367         mOpenHelper = new DatabaseHelper(getContext());
368         deleteUnconsolidatedWatchedProgramsRows();
369         scheduleEpgDataCleanup();
370         buildGenreMap();
371         return true;
372     }
373 
374     @VisibleForTesting
scheduleEpgDataCleanup()375     void scheduleEpgDataCleanup() {
376         Intent intent = new Intent(EpgDataCleanupService.ACTION_CLEAN_UP_EPG_DATA);
377         intent.setClass(getContext(), EpgDataCleanupService.class);
378         PendingIntent pendingIntent = PendingIntent.getService(
379                 getContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
380         AlarmManager alarmManager =
381                 (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE);
382         alarmManager.setInexactRepeating(AlarmManager.RTC, System.currentTimeMillis(),
383                 AlarmManager.INTERVAL_HALF_DAY, pendingIntent);
384     }
385 
buildGenreMap()386     private void buildGenreMap() {
387         if (sGenreMap != null) {
388             return;
389         }
390 
391         sGenreMap = new HashMap<String, String>();
392         buildGenreMap(R.array.genre_mapping_atsc);
393         buildGenreMap(R.array.genre_mapping_dvb);
394         buildGenreMap(R.array.genre_mapping_isdb);
395         buildGenreMap(R.array.genre_mapping_isdb_br);
396     }
397 
398     @SuppressLint("DefaultLocale")
buildGenreMap(int id)399     private void buildGenreMap(int id) {
400         String[] maps = getContext().getResources().getStringArray(id);
401         for (String map : maps) {
402             String[] arr = map.split("\\|");
403             if (arr.length != 2) {
404                 throw new IllegalArgumentException("Invalid genre mapping : " + map);
405             }
406             sGenreMap.put(arr[0].toUpperCase(), arr[1]);
407         }
408     }
409 
410     @VisibleForTesting
getCallingPackage_()411     String getCallingPackage_() {
412         return getCallingPackage();
413     }
414 
415     @Override
getType(Uri uri)416     public String getType(Uri uri) {
417         switch (sUriMatcher.match(uri)) {
418             case MATCH_CHANNEL:
419                 return Channels.CONTENT_TYPE;
420             case MATCH_CHANNEL_ID:
421                 return Channels.CONTENT_ITEM_TYPE;
422             case MATCH_CHANNEL_ID_LOGO:
423                 return "image/png";
424             case MATCH_PASSTHROUGH_ID:
425                 return Channels.CONTENT_ITEM_TYPE;
426             case MATCH_PROGRAM:
427                 return Programs.CONTENT_TYPE;
428             case MATCH_PROGRAM_ID:
429                 return Programs.CONTENT_ITEM_TYPE;
430             case MATCH_WATCHED_PROGRAM:
431                 return WatchedPrograms.CONTENT_TYPE;
432             case MATCH_WATCHED_PROGRAM_ID:
433                 return WatchedPrograms.CONTENT_ITEM_TYPE;
434             default:
435                 throw new IllegalArgumentException("Unknown URI " + uri);
436         }
437     }
438 
439     @Override
query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)440     public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
441             String sortOrder) {
442         if (needsToLimitPackage(uri) && !TextUtils.isEmpty(sortOrder)) {
443             throw new SecurityException("Sort order not allowed for " + uri);
444         }
445         SqlParams params = createSqlParams(OP_QUERY, uri, selection, selectionArgs);
446 
447         SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
448         queryBuilder.setTables(params.getTables());
449         String orderBy;
450         if (params.getTables().equals(PROGRAMS_TABLE)) {
451             queryBuilder.setProjectionMap(sProgramProjectionMap);
452             orderBy = DEFAULT_PROGRAMS_SORT_ORDER;
453         } else if (params.getTables().equals(WATCHED_PROGRAMS_TABLE)) {
454             queryBuilder.setProjectionMap(sWatchedProgramProjectionMap);
455             orderBy = DEFAULT_WATCHED_PROGRAMS_SORT_ORDER;
456         } else {
457             queryBuilder.setProjectionMap(sChannelProjectionMap);
458             orderBy = DEFAULT_CHANNELS_SORT_ORDER;
459         }
460 
461         // Use the default sort order only if no sort order is specified.
462         if (!TextUtils.isEmpty(sortOrder)) {
463             orderBy = sortOrder;
464         }
465 
466         // Get the database and run the query.
467         SQLiteDatabase db = mOpenHelper.getReadableDatabase();
468         Cursor c = queryBuilder.query(db, projection, params.getSelection(),
469                 params.getSelectionArgs(), null, null, orderBy);
470 
471         // Tell the cursor what URI to watch, so it knows when its source data changes.
472         c.setNotificationUri(getContext().getContentResolver(), uri);
473         return c;
474     }
475 
476     @Override
insert(Uri uri, ContentValues values)477     public Uri insert(Uri uri, ContentValues values) {
478         switch (sUriMatcher.match(uri)) {
479             case MATCH_CHANNEL:
480                 return insertChannel(uri, values);
481             case MATCH_PROGRAM:
482                 return insertProgram(uri, values);
483             case MATCH_WATCHED_PROGRAM:
484                 return insertWatchedProgram(uri, values);
485             case MATCH_CHANNEL_ID:
486             case MATCH_CHANNEL_ID_LOGO:
487             case MATCH_PASSTHROUGH_ID:
488             case MATCH_PROGRAM_ID:
489             case MATCH_WATCHED_PROGRAM_ID:
490                 throw new UnsupportedOperationException("Cannot insert into that URI: " + uri);
491             default:
492                 throw new IllegalArgumentException("Unknown URI " + uri);
493         }
494     }
495 
insertChannel(Uri uri, ContentValues values)496     private Uri insertChannel(Uri uri, ContentValues values) {
497         // Mark the owner package of this channel.
498         values.put(Channels.COLUMN_PACKAGE_NAME, getCallingPackage_());
499 
500         SQLiteDatabase db = mOpenHelper.getWritableDatabase();
501         long rowId = db.insert(CHANNELS_TABLE, null, values);
502         if (rowId > 0) {
503             Uri channelUri = TvContract.buildChannelUri(rowId);
504             notifyChange(channelUri);
505             return channelUri;
506         }
507 
508         throw new SQLException("Failed to insert row into " + uri);
509     }
510 
insertProgram(Uri uri, ContentValues values)511     private Uri insertProgram(Uri uri, ContentValues values) {
512         // Mark the owner package of this program.
513         values.put(Programs.COLUMN_PACKAGE_NAME, getCallingPackage_());
514 
515         checkAndConvertGenre(values);
516 
517         SQLiteDatabase db = mOpenHelper.getWritableDatabase();
518         long rowId = db.insert(PROGRAMS_TABLE, null, values);
519         if (rowId > 0) {
520             Uri programUri = TvContract.buildProgramUri(rowId);
521             notifyChange(programUri);
522             return programUri;
523         }
524 
525         throw new SQLException("Failed to insert row into " + uri);
526     }
527 
insertWatchedProgram(Uri uri, ContentValues values)528     private Uri insertWatchedProgram(Uri uri, ContentValues values) {
529         if (DEBUG) {
530             Log.d(TAG, "insertWatchedProgram(uri=" + uri + ", values={" + values + "})");
531         }
532         Long watchStartTime = values.getAsLong(WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS);
533         Long watchEndTime = values.getAsLong(WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS);
534         // The system sends only two kinds of watch events:
535         // 1. The user tunes to a new channel. (COLUMN_WATCH_START_TIME_UTC_MILLIS)
536         // 2. The user stops watching. (COLUMN_WATCH_END_TIME_UTC_MILLIS)
537         if (watchStartTime != null && watchEndTime == null) {
538             SQLiteDatabase db = mOpenHelper.getWritableDatabase();
539             long rowId = db.insert(WATCHED_PROGRAMS_TABLE, null, values);
540             if (rowId > 0) {
541                 mLogHandler.removeMessages(WatchLogHandler.MSG_TRY_CONSOLIDATE_ALL);
542                 mLogHandler.sendEmptyMessageDelayed(WatchLogHandler.MSG_TRY_CONSOLIDATE_ALL,
543                         MAX_PROGRAM_DATA_DELAY_IN_MILLIS);
544                 return TvContract.buildWatchedProgramUri(rowId);
545             }
546             throw new SQLException("Failed to insert row into " + uri);
547         } else if (watchStartTime == null && watchEndTime != null) {
548             SomeArgs args = SomeArgs.obtain();
549             args.arg1 = values.getAsString(WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN);
550             args.arg2 = watchEndTime;
551             Message msg = mLogHandler.obtainMessage(WatchLogHandler.MSG_CONSOLIDATE, args);
552             mLogHandler.sendMessageDelayed(msg, MAX_PROGRAM_DATA_DELAY_IN_MILLIS);
553             return null;
554         }
555         // All the other cases are invalid.
556         throw new IllegalArgumentException("Only one of COLUMN_WATCH_START_TIME_UTC_MILLIS and"
557                 + " COLUMN_WATCH_END_TIME_UTC_MILLIS should be specified");
558     }
559 
560     @Override
delete(Uri uri, String selection, String[] selectionArgs)561     public int delete(Uri uri, String selection, String[] selectionArgs) {
562         SqlParams params = createSqlParams(OP_DELETE, uri, selection, selectionArgs);
563         SQLiteDatabase db = mOpenHelper.getWritableDatabase();
564         int count = 0;
565         switch (sUriMatcher.match(uri)) {
566             case MATCH_CHANNEL_ID_LOGO:
567                 ContentValues values = new ContentValues();
568                 values.putNull(CHANNELS_COLUMN_LOGO);
569                 count = db.update(params.getTables(), values, params.getSelection(),
570                         params.getSelectionArgs());
571                 break;
572             case MATCH_CHANNEL:
573             case MATCH_PROGRAM:
574             case MATCH_WATCHED_PROGRAM:
575             case MATCH_CHANNEL_ID:
576             case MATCH_PASSTHROUGH_ID:
577             case MATCH_PROGRAM_ID:
578             case MATCH_WATCHED_PROGRAM_ID:
579                 count = db.delete(params.getTables(), params.getSelection(),
580                         params.getSelectionArgs());
581                 break;
582             default:
583                 throw new IllegalArgumentException("Unknown URI " + uri);
584         }
585         if (count > 0) {
586             notifyChange(uri);
587         }
588         return count;
589     }
590 
591     @Override
update(Uri uri, ContentValues values, String selection, String[] selectionArgs)592     public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
593         SqlParams params = createSqlParams(OP_UPDATE, uri, selection, selectionArgs);
594         if (params.getTables().equals(CHANNELS_TABLE)) {
595             if (values.containsKey(Channels.COLUMN_LOCKED)
596                     && !callerHasModifyParentalControlsPermission()) {
597                 throw new SecurityException("Not allowed to modify Channels.COLUMN_LOCKED");
598             }
599         } else if (params.getTables().equals(PROGRAMS_TABLE)) {
600             checkAndConvertGenre(values);
601         }
602         SQLiteDatabase db = mOpenHelper.getWritableDatabase();
603         int count = db.update(params.getTables(), values, params.getSelection(),
604                 params.getSelectionArgs());
605         if (count > 0) {
606             notifyChange(uri);
607         }
608         return count;
609     }
610 
createSqlParams(String operation, Uri uri, String selection, String[] selectionArgs)611     private SqlParams createSqlParams(String operation, Uri uri, String selection,
612             String[] selectionArgs) {
613         SqlParams params = new SqlParams(null, selection, selectionArgs);
614         if (needsToLimitPackage(uri)) {
615             if (!TextUtils.isEmpty(selection)) {
616                 throw new SecurityException("Selection not allowed for " + uri);
617             }
618             params.setWhere(BaseTvColumns.COLUMN_PACKAGE_NAME + "=?", getCallingPackage_());
619         }
620         switch (sUriMatcher.match(uri)) {
621             case MATCH_CHANNEL:
622                 String genre = uri.getQueryParameter(TvContract.PARAM_CANONICAL_GENRE);
623                 if (genre == null) {
624                     params.setTables(CHANNELS_TABLE);
625                 } else {
626                     if (!operation.equals(OP_QUERY)) {
627                         throw new SecurityException(capitalize(operation)
628                                 + " not allowed for " + uri);
629                     }
630                     if (!Genres.isCanonical(genre)) {
631                         throw new IllegalArgumentException("Not a canonical genre : " + genre);
632                     }
633                     params.setTables(CHANNELS_TABLE_INNER_JOIN_PROGRAMS_TABLE);
634                     String curTime = String.valueOf(System.currentTimeMillis());
635                     params.appendWhere("LIKE(?, " + Programs.COLUMN_CANONICAL_GENRE + ") AND "
636                             + Programs.COLUMN_START_TIME_UTC_MILLIS + "<=? AND "
637                             + Programs.COLUMN_END_TIME_UTC_MILLIS + ">=?",
638                             "%" + genre + "%", curTime, curTime);
639                 }
640                 String inputId = uri.getQueryParameter(TvContract.PARAM_INPUT);
641                 if (inputId != null) {
642                     params.appendWhere(Channels.COLUMN_INPUT_ID + "=?", inputId);
643                 }
644                 boolean browsableOnly = uri.getBooleanQueryParameter(
645                         TvContract.PARAM_BROWSABLE_ONLY, false);
646                 if (browsableOnly) {
647                     params.appendWhere(Channels.COLUMN_BROWSABLE + "=1");
648                 }
649                 break;
650             case MATCH_CHANNEL_ID:
651                 params.setTables(CHANNELS_TABLE);
652                 params.appendWhere(Channels._ID + "=?", uri.getLastPathSegment());
653                 break;
654             case MATCH_PROGRAM:
655                 params.setTables(PROGRAMS_TABLE);
656                 String paramChannelId = uri.getQueryParameter(TvContract.PARAM_CHANNEL);
657                 if (paramChannelId != null) {
658                     String channelId = String.valueOf(Long.parseLong(paramChannelId));
659                     params.appendWhere(Programs.COLUMN_CHANNEL_ID + "=?", channelId);
660                 }
661                 String paramStartTime = uri.getQueryParameter(TvContract.PARAM_START_TIME);
662                 String paramEndTime = uri.getQueryParameter(TvContract.PARAM_END_TIME);
663                 if (paramStartTime != null && paramEndTime != null) {
664                     String startTime = String.valueOf(Long.parseLong(paramStartTime));
665                     String endTime = String.valueOf(Long.parseLong(paramEndTime));
666                     params.appendWhere(Programs.COLUMN_START_TIME_UTC_MILLIS + "<=? AND "
667                             + Programs.COLUMN_END_TIME_UTC_MILLIS + ">=?", endTime, startTime);
668                 }
669                 break;
670             case MATCH_PROGRAM_ID:
671                 params.setTables(PROGRAMS_TABLE);
672                 params.appendWhere(Programs._ID + "=?", uri.getLastPathSegment());
673                 break;
674             case MATCH_WATCHED_PROGRAM:
675                 params.setTables(WATCHED_PROGRAMS_TABLE);
676                 params.appendWhere(WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=?", "1");
677                 break;
678             case MATCH_WATCHED_PROGRAM_ID:
679                 params.setTables(WATCHED_PROGRAMS_TABLE);
680                 params.appendWhere(WatchedPrograms._ID + "=?", uri.getLastPathSegment());
681                 params.appendWhere(WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=?", "1");
682                 break;
683             case MATCH_CHANNEL_ID_LOGO:
684                 if (operation.equals(OP_DELETE)) {
685                     params.setTables(CHANNELS_TABLE);
686                     params.appendWhere(Channels._ID + "=?", uri.getPathSegments().get(1));
687                     break;
688                 }
689                 // fall-through
690             case MATCH_PASSTHROUGH_ID:
691                 throw new UnsupportedOperationException("Cannot " + operation + " that URI: "
692                         + uri);
693             default:
694                 throw new IllegalArgumentException("Unknown URI " + uri);
695         }
696         return params;
697     }
698 
capitalize(String str)699     private static String capitalize(String str) {
700         return Character.toUpperCase(str.charAt(0)) + str.substring(1);
701     }
702 
703     @SuppressLint("DefaultLocale")
checkAndConvertGenre(ContentValues values)704     private void checkAndConvertGenre(ContentValues values) {
705         String canonicalGenres = values.getAsString(Programs.COLUMN_CANONICAL_GENRE);
706 
707         if (!TextUtils.isEmpty(canonicalGenres)) {
708             // Check if the canonical genres are valid. If not, clear them.
709             String[] genres = Genres.decode(canonicalGenres);
710             for (String genre : genres) {
711                 if (!Genres.isCanonical(genre)) {
712                     values.putNull(Programs.COLUMN_CANONICAL_GENRE);
713                     canonicalGenres = null;
714                     break;
715                 }
716             }
717         }
718 
719         if (TextUtils.isEmpty(canonicalGenres)) {
720             // If the canonical genre is not set, try to map the broadcast genre to the canonical
721             // genre.
722             String broadcastGenres = values.getAsString(Programs.COLUMN_BROADCAST_GENRE);
723             if (!TextUtils.isEmpty(broadcastGenres)) {
724                 Set<String> genreSet = new HashSet<String>();
725                 String[] genres = Genres.decode(broadcastGenres);
726                 for (String genre : genres) {
727                     String canonicalGenre = sGenreMap.get(genre.toUpperCase());
728                     if (Genres.isCanonical(canonicalGenre)) {
729                         genreSet.add(canonicalGenre);
730                     }
731                 }
732                 if (genreSet.size() > 0) {
733                     values.put(Programs.COLUMN_CANONICAL_GENRE,
734                             Genres.encode(genreSet.toArray(new String[0])));
735                 }
736             }
737         }
738     }
739 
740     // We might have more than one thread trying to make its way through applyBatch() so the
741     // notification coalescing needs to be thread-local to work correctly.
742     private final ThreadLocal<Set<Uri>> mTLBatchNotifications =
743             new ThreadLocal<Set<Uri>>();
744 
getBatchNotificationsSet()745     private Set<Uri> getBatchNotificationsSet() {
746         return mTLBatchNotifications.get();
747     }
748 
setBatchNotificationsSet(Set<Uri> batchNotifications)749     private void setBatchNotificationsSet(Set<Uri> batchNotifications) {
750         mTLBatchNotifications.set(batchNotifications);
751     }
752 
753     @Override
applyBatch(ArrayList<ContentProviderOperation> operations)754     public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
755             throws OperationApplicationException {
756         setBatchNotificationsSet(Sets.<Uri>newHashSet());
757         Context context = getContext();
758         SQLiteDatabase db = mOpenHelper.getWritableDatabase();
759         db.beginTransaction();
760         try {
761             ContentProviderResult[] results = super.applyBatch(operations);
762             db.setTransactionSuccessful();
763             return results;
764         } finally {
765             db.endTransaction();
766             final Set<Uri> notifications = getBatchNotificationsSet();
767             setBatchNotificationsSet(null);
768             for (final Uri uri : notifications) {
769                 context.getContentResolver().notifyChange(uri, null);
770             }
771         }
772     }
773 
774     @Override
bulkInsert(Uri uri, ContentValues[] values)775     public int bulkInsert(Uri uri, ContentValues[] values) {
776         setBatchNotificationsSet(Sets.<Uri>newHashSet());
777         Context context = getContext();
778         SQLiteDatabase db = mOpenHelper.getWritableDatabase();
779         db.beginTransaction();
780         try {
781             int result = super.bulkInsert(uri, values);
782             db.setTransactionSuccessful();
783             return result;
784         } finally {
785             db.endTransaction();
786             final Set<Uri> notifications = getBatchNotificationsSet();
787             setBatchNotificationsSet(null);
788             for (final Uri notificationUri : notifications) {
789                 context.getContentResolver().notifyChange(notificationUri, null);
790             }
791         }
792     }
793 
notifyChange(Uri uri)794     private void notifyChange(Uri uri) {
795         final Set<Uri> batchNotifications = getBatchNotificationsSet();
796         if (batchNotifications != null) {
797             batchNotifications.add(uri);
798         } else {
799             getContext().getContentResolver().notifyChange(uri, null);
800         }
801     }
802 
803     // When an application tries to create/read/update/delete channel or program data, we need to
804     // ensure that such an access is limited to the data entries it owns, unless it has the full
805     // access permission.
806     // Note that the user's watch log is treated with more caution and we should block any access
807     // from an application that doesn't have the proper permission.
needsToLimitPackage(Uri uri)808     private boolean needsToLimitPackage(Uri uri) {
809         int match = sUriMatcher.match(uri);
810         if (match == MATCH_WATCHED_PROGRAM || match == MATCH_WATCHED_PROGRAM_ID) {
811             if (!callerHasAccessWatchedProgramsPermission()) {
812                 throw new SecurityException("Access not allowed for " + uri);
813             }
814         }
815         return !callerHasAccessAllEpgDataPermission();
816     }
817 
callerHasAccessAllEpgDataPermission()818     private boolean callerHasAccessAllEpgDataPermission() {
819         return getContext().checkCallingOrSelfPermission(PERMISSION_ACCESS_ALL_EPG_DATA)
820                 == PackageManager.PERMISSION_GRANTED;
821     }
822 
callerHasAccessWatchedProgramsPermission()823     private boolean callerHasAccessWatchedProgramsPermission() {
824         return getContext().checkCallingOrSelfPermission(PERMISSION_ACCESS_WATCHED_PROGRAMS)
825                 == PackageManager.PERMISSION_GRANTED;
826     }
827 
callerHasModifyParentalControlsPermission()828     private boolean callerHasModifyParentalControlsPermission() {
829         return getContext().checkCallingOrSelfPermission(
830                 android.Manifest.permission.MODIFY_PARENTAL_CONTROLS)
831                 == PackageManager.PERMISSION_GRANTED;
832     }
833 
834     @Override
openFile(Uri uri, String mode)835     public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
836         switch (sUriMatcher.match(uri)) {
837             case MATCH_CHANNEL_ID_LOGO:
838                 return openLogoFile(uri, mode);
839             default:
840                 throw new FileNotFoundException(uri.toString());
841         }
842     }
843 
openLogoFile(Uri uri, String mode)844     private ParcelFileDescriptor openLogoFile(Uri uri, String mode) throws FileNotFoundException {
845         long channelId = Long.parseLong(uri.getPathSegments().get(1));
846 
847         SqlParams params = new SqlParams(CHANNELS_TABLE, Channels._ID + "=?",
848                 String.valueOf(channelId));
849         if (!callerHasAccessAllEpgDataPermission()) {
850             params.appendWhere(Channels.COLUMN_PACKAGE_NAME + "=?", getCallingPackage_());
851         }
852 
853         SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
854         queryBuilder.setTables(params.getTables());
855 
856         // We don't write the database here.
857         SQLiteDatabase db = mOpenHelper.getReadableDatabase();
858         if (mode.equals("r")) {
859             String sql = queryBuilder.buildQuery(new String[] { CHANNELS_COLUMN_LOGO },
860                     params.getSelection(), null, null, null, null);
861             ParcelFileDescriptor fd = DatabaseUtils.blobFileDescriptorForQuery(db, sql, params.getSelectionArgs());
862             if (fd == null) {
863                 throw new FileNotFoundException(uri.toString());
864             }
865             return fd;
866         } else {
867             try (Cursor cursor = queryBuilder.query(db, new String[] { Channels._ID },
868                     params.getSelection(), params.getSelectionArgs(), null, null, null)) {
869                 if (cursor.getCount() < 1) {
870                     // Fails early if corresponding channel does not exist.
871                     // PipeMonitor may still fail to update DB later.
872                     throw new FileNotFoundException(uri.toString());
873                 }
874             }
875 
876             try {
877                 ParcelFileDescriptor[] pipeFds = ParcelFileDescriptor.createPipe();
878                 PipeMonitor pipeMonitor = new PipeMonitor(pipeFds[0], channelId, params);
879                 pipeMonitor.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
880                 return pipeFds[1];
881             } catch (IOException ioe) {
882                 FileNotFoundException fne = new FileNotFoundException(uri.toString());
883                 fne.initCause(ioe);
884                 throw fne;
885             }
886         }
887     }
888 
889     private class PipeMonitor extends AsyncTask<Void, Void, Void> {
890         private final ParcelFileDescriptor mPfd;
891         private final long mChannelId;
892         private final SqlParams mParams;
893 
PipeMonitor(ParcelFileDescriptor pfd, long channelId, SqlParams params)894         private PipeMonitor(ParcelFileDescriptor pfd, long channelId, SqlParams params) {
895             mPfd = pfd;
896             mChannelId = channelId;
897             mParams = params;
898         }
899 
900         @Override
doInBackground(Void... params)901         protected Void doInBackground(Void... params) {
902             AutoCloseInputStream is = new AutoCloseInputStream(mPfd);
903             ByteArrayOutputStream baos = null;
904             int count = 0;
905             try {
906                 Bitmap bitmap = BitmapFactory.decodeStream(is);
907                 if (bitmap == null) {
908                     Log.e(TAG, "Failed to decode logo image for channel ID " + mChannelId);
909                     return null;
910                 }
911 
912                 float scaleFactor = Math.min(1f, ((float) MAX_LOGO_IMAGE_SIZE) /
913                         Math.max(bitmap.getWidth(), bitmap.getHeight()));
914                 if (scaleFactor < 1f) {
915                     bitmap = Bitmap.createScaledBitmap(bitmap,
916                             (int) (bitmap.getWidth() * scaleFactor),
917                             (int) (bitmap.getHeight() * scaleFactor), false);
918                 }
919 
920                 baos = new ByteArrayOutputStream();
921                 bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos);
922                 byte[] bytes = baos.toByteArray();
923 
924                 ContentValues values = new ContentValues();
925                 values.put(CHANNELS_COLUMN_LOGO, bytes);
926 
927                 SQLiteDatabase db = mOpenHelper.getWritableDatabase();
928                 count = db.update(mParams.getTables(), values, mParams.getSelection(),
929                         mParams.getSelectionArgs());
930                 if (count > 0) {
931                     Uri uri = TvContract.buildChannelLogoUri(mChannelId);
932                     notifyChange(uri);
933                 }
934             } finally {
935                 if (count == 0) {
936                     try {
937                         mPfd.closeWithError("Failed to write logo for channel ID " + mChannelId);
938                     } catch (IOException ioe) {
939                         Log.e(TAG, "Failed to close pipe", ioe);
940                     }
941                 }
942                 IoUtils.closeQuietly(baos);
943                 IoUtils.closeQuietly(is);
944             }
945             return null;
946         }
947     }
948 
deleteUnconsolidatedWatchedProgramsRows()949     private final void deleteUnconsolidatedWatchedProgramsRows() {
950         SQLiteDatabase db = mOpenHelper.getWritableDatabase();
951         db.delete(WATCHED_PROGRAMS_TABLE, WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=0", null);
952     }
953 
954     private final class WatchLogHandler extends Handler {
955         private static final int MSG_CONSOLIDATE = 1;
956         private static final int MSG_TRY_CONSOLIDATE_ALL = 2;
957 
958         @Override
handleMessage(Message msg)959         public void handleMessage(Message msg) {
960             switch (msg.what) {
961                 case MSG_CONSOLIDATE: {
962                     SomeArgs args = (SomeArgs) msg.obj;
963                     String sessionToken = (String) args.arg1;
964                     long watchEndTime = (long) args.arg2;
965                     onConsolidate(sessionToken, watchEndTime);
966                     args.recycle();
967                     return;
968                 }
969                 case MSG_TRY_CONSOLIDATE_ALL: {
970                     onTryConsolidateAll();
971                     return;
972                 }
973                 default: {
974                     Log.w(TAG, "Unhandled message code: " + msg.what);
975                     return;
976                 }
977             }
978         }
979 
980         // Consolidates all WatchedPrograms rows for a given session with watch end time information
981         // of the most recent log entry. After this method is called, it is guaranteed that there
982         // remain consolidated rows only for that session.
onConsolidate(String sessionToken, long watchEndTime)983         private final void onConsolidate(String sessionToken, long watchEndTime) {
984             if (DEBUG) {
985                 Log.d(TAG, "onConsolidate(sessionToken=" + sessionToken + ", watchEndTime="
986                         + watchEndTime + ")");
987             }
988 
989             SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
990             queryBuilder.setTables(WATCHED_PROGRAMS_TABLE);
991             SQLiteDatabase db = mOpenHelper.getReadableDatabase();
992 
993             // Pick up the last row with the same session token.
994             String[] projection = {
995                     WatchedPrograms._ID,
996                     WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
997                     WatchedPrograms.COLUMN_CHANNEL_ID
998             };
999             String selection = WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=? AND "
1000                     + WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN + "=?";
1001             String[] selectionArgs = {
1002                     "0",
1003                     sessionToken
1004             };
1005             String sortOrder = WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS + " DESC";
1006 
1007             int consolidatedRowCount = 0;
1008             try (Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null,
1009                     null, sortOrder)) {
1010                 long oldWatchStartTime = watchEndTime;
1011                 while (cursor != null && cursor.moveToNext()) {
1012                     long id = cursor.getLong(0);
1013                     long watchStartTime = cursor.getLong(1);
1014                     long channelId = cursor.getLong(2);
1015                     consolidatedRowCount += consolidateRow(id, watchStartTime, oldWatchStartTime,
1016                             channelId, false);
1017                     oldWatchStartTime = watchStartTime;
1018                 }
1019             }
1020             if (consolidatedRowCount > 0) {
1021                 deleteUnsearchable();
1022             }
1023         }
1024 
1025         // Tries to consolidate all WatchedPrograms rows regardless of the session. After this
1026         // method is called, it is guaranteed that we have at most one unconsolidated log entry per
1027         // session that represents the user's ongoing watch activity.
1028         // Also, this method automatically schedules the next consolidation if there still remains
1029         // an unconsolidated entry.
onTryConsolidateAll()1030         private final void onTryConsolidateAll() {
1031             if (DEBUG) {
1032                 Log.d(TAG, "onTryConsolidateAll()");
1033             }
1034 
1035             SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
1036             queryBuilder.setTables(WATCHED_PROGRAMS_TABLE);
1037             SQLiteDatabase db = mOpenHelper.getReadableDatabase();
1038 
1039             // Pick up all unconsolidated rows grouped by session. The most recent log entry goes on
1040             // top.
1041             String[] projection = {
1042                     WatchedPrograms._ID,
1043                     WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
1044                     WatchedPrograms.COLUMN_CHANNEL_ID,
1045                     WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN
1046             };
1047             String selection = WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=0";
1048             String sortOrder = WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN + " DESC,"
1049                     + WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS + " DESC";
1050 
1051             int consolidatedRowCount = 0;
1052             try (Cursor cursor = queryBuilder.query(db, projection, selection, null, null, null,
1053                     sortOrder)) {
1054                 long oldWatchStartTime = 0;
1055                 String oldSessionToken = null;
1056                 while (cursor != null && cursor.moveToNext()) {
1057                     long id = cursor.getLong(0);
1058                     long watchStartTime = cursor.getLong(1);
1059                     long channelId = cursor.getLong(2);
1060                     String sessionToken = cursor.getString(3);
1061 
1062                     if (!sessionToken.equals(oldSessionToken)) {
1063                         // The most recent log entry for the current session, which may be still
1064                         // active. Just go through a dry run with the current time to see if this
1065                         // entry can be split into multiple rows.
1066                         consolidatedRowCount += consolidateRow(id, watchStartTime,
1067                                 System.currentTimeMillis(), channelId, true);
1068                         oldSessionToken = sessionToken;
1069                     } else {
1070                         // The later entries after the most recent one all fall into here. We now
1071                         // know that this watch activity ended exactly at the same time when the
1072                         // next activity started.
1073                         consolidatedRowCount += consolidateRow(id, watchStartTime,
1074                                 oldWatchStartTime, channelId, false);
1075                     }
1076                     oldWatchStartTime = watchStartTime;
1077                 }
1078             }
1079             if (consolidatedRowCount > 0) {
1080                 deleteUnsearchable();
1081             }
1082             scheduleConsolidationIfNeeded();
1083         }
1084 
1085         // Consolidates a WatchedPrograms row.
1086         // A row is 'consolidated' if and only if the following information is complete:
1087         // 1. WatchedPrograms.COLUMN_CHANNEL_ID
1088         // 2. WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS
1089         // 3. WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS
1090         // where COLUMN_WATCH_START_TIME_UTC_MILLIS <= COLUMN_WATCH_END_TIME_UTC_MILLIS.
1091         // This is the minimal but useful enough set of information to comprise the user's watch
1092         // history. (The program data are considered optional although we do try to fill them while
1093         // consolidating the row.) It is guaranteed that the target row is either consolidated or
1094         // deleted after this method is called.
1095         // Set {@code dryRun} to {@code true} if you think it's necessary to split the row without
1096         // consolidating the most recent row because the user stayed on the same channel for a very
1097         // long time.
1098         // This method returns the number of consolidated rows, which can be 0 or more.
consolidateRow(long id, long watchStartTime, long watchEndTime, long channelId, boolean dryRun)1099         private final int consolidateRow(long id, long watchStartTime, long watchEndTime,
1100                 long channelId, boolean dryRun) {
1101             if (DEBUG) {
1102                 Log.d(TAG, "consolidateRow(id=" + id + ", watchStartTime=" + watchStartTime
1103                         + ", watchEndTime=" + watchEndTime + ", channelId=" + channelId
1104                         + ", dryRun=" + dryRun + ")");
1105             }
1106 
1107             SQLiteDatabase db = mOpenHelper.getWritableDatabase();
1108 
1109             if (watchStartTime > watchEndTime) {
1110                 Log.e(TAG, "watchEndTime cannot be less than watchStartTime");
1111                 db.delete(WATCHED_PROGRAMS_TABLE, WatchedPrograms._ID + "=" + String.valueOf(id),
1112                         null);
1113                 return 0;
1114             }
1115 
1116             ContentValues values = getProgramValues(channelId, watchStartTime);
1117             Long endTime = values.getAsLong(WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS);
1118             boolean needsToSplit = endTime != null && endTime < watchEndTime;
1119 
1120             values.put(WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
1121                     String.valueOf(watchStartTime));
1122             if (!dryRun || needsToSplit) {
1123                 values.put(WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS,
1124                         String.valueOf(needsToSplit ? endTime : watchEndTime));
1125                 values.put(WATCHED_PROGRAMS_COLUMN_CONSOLIDATED, "1");
1126                 db.update(WATCHED_PROGRAMS_TABLE, values,
1127                         WatchedPrograms._ID + "=" + String.valueOf(id), null);
1128                 // Treat the watched program is inserted when WATCHED_PROGRAMS_COLUMN_CONSOLIDATED
1129                 // becomes 1.
1130                 notifyChange(TvContract.buildWatchedProgramUri(id));
1131             } else {
1132                 db.update(WATCHED_PROGRAMS_TABLE, values,
1133                         WatchedPrograms._ID + "=" + String.valueOf(id), null);
1134             }
1135             int count = dryRun ? 0 : 1;
1136             if (needsToSplit) {
1137                 // This means that the program ended before the user stops watching the current
1138                 // channel. In this case we duplicate the log entry as many as the number of
1139                 // programs watched on the same channel. Here the end time of the current program
1140                 // becomes the new watch start time of the next program.
1141                 long duplicatedId = duplicateRow(id);
1142                 if (duplicatedId > 0) {
1143                     count += consolidateRow(duplicatedId, endTime, watchEndTime, channelId, dryRun);
1144                 }
1145             }
1146             return count;
1147         }
1148 
1149         // Deletes the log entries from unsearchable channels. Note that only consolidated log
1150         // entries are safe to delete.
deleteUnsearchable()1151         private final void deleteUnsearchable() {
1152             SQLiteDatabase db = mOpenHelper.getWritableDatabase();
1153             String deleteWhere = WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=1 AND "
1154                     + WatchedPrograms.COLUMN_CHANNEL_ID + " IN (SELECT " + Channels._ID
1155                     + " FROM " + CHANNELS_TABLE + " WHERE " + Channels.COLUMN_SEARCHABLE + "=0)";
1156             db.delete(WATCHED_PROGRAMS_TABLE, deleteWhere, null);
1157         }
1158 
scheduleConsolidationIfNeeded()1159         private final void scheduleConsolidationIfNeeded() {
1160             if (DEBUG) {
1161                 Log.d(TAG, "scheduleConsolidationIfNeeded()");
1162             }
1163             SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
1164             queryBuilder.setTables(WATCHED_PROGRAMS_TABLE);
1165             SQLiteDatabase db = mOpenHelper.getReadableDatabase();
1166 
1167             // Pick up all unconsolidated rows.
1168             String[] projection = {
1169                     WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
1170                     WatchedPrograms.COLUMN_CHANNEL_ID,
1171             };
1172             String selection = WATCHED_PROGRAMS_COLUMN_CONSOLIDATED + "=0";
1173 
1174             try (Cursor cursor = queryBuilder.query(db, projection, selection, null, null, null,
1175                     null)) {
1176                 // Find the earliest time that any of the currently watching programs ends and
1177                 // schedule the next consolidation at that time.
1178                 long minEndTime = Long.MAX_VALUE;
1179                 while (cursor != null && cursor.moveToNext()) {
1180                     long watchStartTime = cursor.getLong(0);
1181                     long channelId = cursor.getLong(1);
1182                     ContentValues values = getProgramValues(channelId, watchStartTime);
1183                     Long endTime = values.getAsLong(WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS);
1184 
1185                     if (endTime != null && endTime < minEndTime
1186                             && endTime > System.currentTimeMillis()) {
1187                         minEndTime = endTime;
1188                     }
1189                 }
1190                 if (minEndTime != Long.MAX_VALUE) {
1191                     sendEmptyMessageAtTime(MSG_TRY_CONSOLIDATE_ALL, minEndTime);
1192                     if (DEBUG) {
1193                         CharSequence minEndTimeStr = DateUtils.getRelativeTimeSpanString(
1194                                 minEndTime, System.currentTimeMillis(), DateUtils.SECOND_IN_MILLIS);
1195                         Log.d(TAG, "onTryConsolidateAll() scheduled " + minEndTimeStr);
1196                     }
1197                 }
1198             }
1199         }
1200 
1201         // Returns non-null ContentValues of the program data that the user watched on the channel
1202         // {@code channelId} at the time {@code time}.
getProgramValues(long channelId, long time)1203         private final ContentValues getProgramValues(long channelId, long time) {
1204             SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
1205             queryBuilder.setTables(PROGRAMS_TABLE);
1206             SQLiteDatabase db = mOpenHelper.getReadableDatabase();
1207 
1208             String[] projection = {
1209                     Programs.COLUMN_TITLE,
1210                     Programs.COLUMN_START_TIME_UTC_MILLIS,
1211                     Programs.COLUMN_END_TIME_UTC_MILLIS,
1212                     Programs.COLUMN_SHORT_DESCRIPTION
1213             };
1214             String selection = Programs.COLUMN_CHANNEL_ID + "=? AND "
1215                     + Programs.COLUMN_START_TIME_UTC_MILLIS + "<=? AND "
1216                     + Programs.COLUMN_END_TIME_UTC_MILLIS + ">?";
1217             String[] selectionArgs = {
1218                     String.valueOf(channelId),
1219                     String.valueOf(time),
1220                     String.valueOf(time)
1221             };
1222             String sortOrder = Programs.COLUMN_START_TIME_UTC_MILLIS + " ASC";
1223 
1224             try (Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null,
1225                     null, sortOrder)) {
1226                 ContentValues values = new ContentValues();
1227                 if (cursor != null && cursor.moveToNext()) {
1228                     values.put(WatchedPrograms.COLUMN_TITLE, cursor.getString(0));
1229                     values.put(WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS, cursor.getLong(1));
1230                     values.put(WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS, cursor.getLong(2));
1231                     values.put(WatchedPrograms.COLUMN_DESCRIPTION, cursor.getString(3));
1232                 }
1233                 return values;
1234             }
1235         }
1236 
1237         // Duplicates the WatchedPrograms row with a given ID and returns the ID of the duplicated
1238         // row. Returns -1 if failed.
duplicateRow(long id)1239         private final long duplicateRow(long id) {
1240             if (DEBUG) {
1241                 Log.d(TAG, "duplicateRow(" + id + ")");
1242             }
1243 
1244             SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
1245             queryBuilder.setTables(WATCHED_PROGRAMS_TABLE);
1246             SQLiteDatabase db = mOpenHelper.getWritableDatabase();
1247 
1248             String[] projection = {
1249                     WatchedPrograms.COLUMN_PACKAGE_NAME,
1250                     WatchedPrograms.COLUMN_CHANNEL_ID,
1251                     WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN
1252             };
1253             String selection = WatchedPrograms._ID + "=" + String.valueOf(id);
1254 
1255             try (Cursor cursor = queryBuilder.query(db, projection, selection, null, null, null,
1256                     null)) {
1257                 long rowId = -1;
1258                 if (cursor != null && cursor.moveToNext()) {
1259                     ContentValues values = new ContentValues();
1260                     values.put(WatchedPrograms.COLUMN_PACKAGE_NAME, cursor.getString(0));
1261                     values.put(WatchedPrograms.COLUMN_CHANNEL_ID, cursor.getLong(1));
1262                     values.put(WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN, cursor.getString(2));
1263                     rowId = db.insert(WATCHED_PROGRAMS_TABLE, null, values);
1264                 }
1265                 return rowId;
1266             }
1267         }
1268     }
1269 }
1270