Home | History | Annotate | Download | only in epg
      1 /*
      2  * Copyright (C) 2017 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.tv.data.epg;
     18 
     19 import android.content.ContentProviderOperation;
     20 import android.content.Context;
     21 import android.content.OperationApplicationException;
     22 import android.database.Cursor;
     23 import android.media.tv.TvContract;
     24 import android.media.tv.TvContract.Programs;
     25 import android.os.RemoteException;
     26 import android.preference.PreferenceManager;
     27 import android.support.annotation.WorkerThread;
     28 import android.text.TextUtils;
     29 import android.util.Log;
     30 
     31 import com.android.tv.data.Program;
     32 
     33 import java.util.ArrayList;
     34 import java.util.Collections;
     35 import java.util.List;
     36 import java.util.concurrent.TimeUnit;
     37 
     38 /** The helper class for {@link com.android.tv.data.epg.EpgFetcher} */
     39 class EpgFetchHelper {
     40     private static final String TAG = "EpgFetchHelper";
     41     private static final boolean DEBUG = false;
     42 
     43     private static final long PROGRAM_QUERY_DURATION_MS = TimeUnit.DAYS.toMillis(30);
     44     private static final int BATCH_OPERATION_COUNT = 100;
     45 
     46     // Value: Long
     47     private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP =
     48             "com.android.tv.data.epg.EpgFetcher.LastUpdatedEpgTimestamp";
     49     // Value: String
     50     private static final String KEY_LAST_LINEUP_ID =
     51             "com.android.tv.data.epg.EpgFetcher.LastLineupId";
     52 
     53     private static long sLastEpgUpdatedTimestamp = -1;
     54     private static String sLastLineupId;
     55 
     56     private EpgFetchHelper() { }
     57 
     58     /**
     59      * Updates newly fetched EPG data for the given channel to local providers. The method will
     60      * compare the broadcasting time and try to match each newly fetched program with old programs
     61      * of that channel in the database one by one. It will update the matched old program, or insert
     62      * the new program if there is no matching program can be found in the database and at the same
     63      * time remove those old programs which conflicts with the inserted one.
     64 
     65      * @param channelId the target channel ID.
     66      * @param fetchedPrograms the newly fetched program data.
     67      * @return {@code true} if new program data are successfully updated. Otherwise {@code false}.
     68      */
     69     static boolean updateEpgData(Context context, long channelId, List<Program> fetchedPrograms) {
     70         final int fetchedProgramsCount = fetchedPrograms.size();
     71         if (fetchedProgramsCount == 0) {
     72             return false;
     73         }
     74         boolean updated = false;
     75         long startTimeMs = System.currentTimeMillis();
     76         long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION_MS;
     77         List<Program> oldPrograms = queryPrograms(context, channelId, startTimeMs, endTimeMs);
     78         int oldProgramsIndex = 0;
     79         int newProgramsIndex = 0;
     80 
     81         // Compare the new programs with old programs one by one and update/delete the old one
     82         // or insert new program if there is no matching program in the database.
     83         ArrayList<ContentProviderOperation> ops = new ArrayList<>();
     84         while (newProgramsIndex < fetchedProgramsCount) {
     85             Program oldProgram = oldProgramsIndex < oldPrograms.size()
     86                     ? oldPrograms.get(oldProgramsIndex) : null;
     87             Program newProgram = fetchedPrograms.get(newProgramsIndex);
     88             boolean addNewProgram = false;
     89             if (oldProgram != null) {
     90                 if (oldProgram.equals(newProgram)) {
     91                     // Exact match. No need to update. Move on to the next programs.
     92                     oldProgramsIndex++;
     93                     newProgramsIndex++;
     94                 } else if (hasSameTitleAndOverlap(oldProgram, newProgram)) {
     95                     // Partial match. Update the old program with the new one.
     96                     // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There
     97                     // could be application specific settings which belong to the old program.
     98                     ops.add(ContentProviderOperation.newUpdate(
     99                             TvContract.buildProgramUri(oldProgram.getId()))
    100                             .withValues(Program.toContentValues(newProgram))
    101                             .build());
    102                     oldProgramsIndex++;
    103                     newProgramsIndex++;
    104                 } else if (oldProgram.getEndTimeUtcMillis() < newProgram.getEndTimeUtcMillis()) {
    105                     // No match. Remove the old program first to see if the next program in
    106                     // {@code oldPrograms} partially matches the new program.
    107                     ops.add(ContentProviderOperation.newDelete(
    108                             TvContract.buildProgramUri(oldProgram.getId()))
    109                             .build());
    110                     oldProgramsIndex++;
    111                 } else {
    112                     // No match. The new program does not match any of the old programs. Insert
    113                     // it as a new program.
    114                     addNewProgram = true;
    115                     newProgramsIndex++;
    116                 }
    117             } else {
    118                 // No old programs. Just insert new programs.
    119                 addNewProgram = true;
    120                 newProgramsIndex++;
    121             }
    122             if (addNewProgram) {
    123                 ops.add(ContentProviderOperation
    124                         .newInsert(Programs.CONTENT_URI)
    125                         .withValues(Program.toContentValues(newProgram))
    126                         .build());
    127             }
    128             // Throttle the batch operation not to cause TransactionTooLargeException.
    129             if (ops.size() > BATCH_OPERATION_COUNT || newProgramsIndex >= fetchedProgramsCount) {
    130                 try {
    131                     if (DEBUG) {
    132                         int size = ops.size();
    133                         Log.d(TAG, "Running " + size + " operations for channel " + channelId);
    134                         for (int i = 0; i < size; ++i) {
    135                             Log.d(TAG, "Operation(" + i + "): " + ops.get(i));
    136                         }
    137                     }
    138                     context.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
    139                     updated = true;
    140                 } catch (RemoteException | OperationApplicationException e) {
    141                     Log.e(TAG, "Failed to insert programs.", e);
    142                     return updated;
    143                 }
    144                 ops.clear();
    145             }
    146         }
    147         if (DEBUG) {
    148             Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId);
    149         }
    150         return updated;
    151     }
    152 
    153     private static List<Program> queryPrograms(Context context, long channelId,
    154             long startTimeMs, long endTimeMs) {
    155         try (Cursor c = context.getContentResolver().query(
    156                 TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs),
    157                 Program.PROJECTION, null, null, Programs.COLUMN_START_TIME_UTC_MILLIS)) {
    158             if (c == null) {
    159                 return Collections.emptyList();
    160             }
    161             ArrayList<Program> programs = new ArrayList<>();
    162             while (c.moveToNext()) {
    163                 programs.add(Program.fromCursor(c));
    164             }
    165             return programs;
    166         }
    167     }
    168 
    169     /**
    170      * Returns {@code true} if the {@code oldProgram} needs to be updated with the
    171      * {@code newProgram}.
    172      */
    173     private static boolean hasSameTitleAndOverlap(Program oldProgram, Program newProgram) {
    174         // NOTE: Here, we update the old program if it has the same title and overlaps with the
    175         // new program. The test logic is just an example and you can modify this. E.g. check
    176         // whether the both programs have the same program ID if your EPG supports any ID for
    177         // the programs.
    178         return TextUtils.equals(oldProgram.getTitle(), newProgram.getTitle())
    179                 && oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis()
    180                 && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis();
    181     }
    182 
    183     /**
    184      * Sets the last known lineup ID into shared preferences for future usage. If channels are not
    185      * re-scanned, EPG fetcher can directly use this value instead of checking the correct lineup ID
    186      * every time when it needs to fetch EPG data.
    187      */
    188     @WorkerThread
    189     synchronized static void setLastLineupId(Context context, String lineupId) {
    190         if (DEBUG) {
    191             if (lineupId == null) {
    192                 Log.d(TAG, "Clear stored lineup id: " + sLastLineupId);
    193             }
    194         }
    195         sLastLineupId = lineupId;
    196         PreferenceManager.getDefaultSharedPreferences(context).edit()
    197                 .putString(KEY_LAST_LINEUP_ID, lineupId).apply();
    198     }
    199 
    200     /**
    201      * Gets the last known lineup ID from shared preferences.
    202      */
    203     synchronized static String getLastLineupId(Context context) {
    204         if (sLastLineupId == null) {
    205             sLastLineupId = PreferenceManager.getDefaultSharedPreferences(context)
    206                     .getString(KEY_LAST_LINEUP_ID, null);
    207         }
    208         if (DEBUG) Log.d(TAG, "Last lineup is " + sLastLineupId);
    209         return sLastLineupId;
    210     }
    211 
    212     /**
    213      * Sets the last updated timestamp of EPG data into shared preferences. If the EPG data is not
    214      * out-dated, it's not necessary for EPG fetcher to fetch EPG again.
    215      */
    216     @WorkerThread
    217     synchronized static void setLastEpgUpdatedTimestamp(Context context, long timestamp) {
    218         sLastEpgUpdatedTimestamp = timestamp;
    219         PreferenceManager.getDefaultSharedPreferences(context).edit().putLong(
    220                 KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp).apply();
    221     }
    222 
    223     /**
    224      * Gets the last updated timestamp of EPG data.
    225      */
    226     synchronized static long getLastEpgUpdatedTimestamp(Context context) {
    227         if (sLastEpgUpdatedTimestamp < 0) {
    228             sLastEpgUpdatedTimestamp = PreferenceManager.getDefaultSharedPreferences(context)
    229                     .getLong(KEY_LAST_UPDATED_EPG_TIMESTAMP, 0);
    230         }
    231         return sLastEpgUpdatedTimestamp;
    232     }
    233 }