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