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