Home | History | Annotate | Download | only in epg
      1 /*
      2  * Copyright (C) 2016 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.ContentResolver;
     21 import android.content.ContentValues;
     22 import android.content.Context;
     23 import android.content.OperationApplicationException;
     24 import android.database.Cursor;
     25 import android.media.tv.TvContract;
     26 import android.media.tv.TvContract.Programs;
     27 import android.media.tv.TvInputInfo;
     28 import android.media.tv.TvInputManager.TvInputCallback;
     29 import android.os.HandlerThread;
     30 import android.os.Looper;
     31 import android.os.Message;
     32 import android.os.RemoteException;
     33 import android.preference.PreferenceManager;
     34 import android.support.annotation.NonNull;
     35 import android.text.TextUtils;
     36 import android.util.Log;
     37 
     38 import com.android.tv.Features;
     39 import com.android.tv.TvApplication;
     40 import com.android.tv.common.WeakHandler;
     41 import com.android.tv.data.Channel;
     42 import com.android.tv.data.Program;
     43 import com.android.tv.util.RecurringRunner;
     44 import com.android.tv.util.TvInputManagerHelper;
     45 import com.android.tv.util.Utils;
     46 
     47 import java.util.ArrayList;
     48 import java.util.Collections;
     49 import java.util.List;
     50 import java.util.Objects;
     51 import java.util.concurrent.TimeUnit;
     52 
     53 /**
     54  * An utility class to fetch the EPG. This class isn't thread-safe.
     55  */
     56 public class EpgFetcher {
     57     private static final String TAG = "EpgFetcher";
     58     private static final boolean DEBUG = false;
     59 
     60     private static final int MSG_FETCH_EPG = 1;
     61 
     62     private static final long EPG_PREFETCH_RECURRING_PERIOD_MS = TimeUnit.HOURS.toMillis(4);
     63     private static final long EPG_READER_INIT_WAIT_MS = TimeUnit.MINUTES.toMillis(1);
     64     private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(30);
     65 
     66     private static final int BATCH_OPERATION_COUNT = 100;
     67 
     68     // Value: Long
     69     private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP =
     70             "com.android.tv.data.epg.EpgFetcher.LastUpdatedEpgTimestamp";
     71 
     72     private final Context mContext;
     73     private final TvInputManagerHelper mInputHelper;
     74     private final TvInputCallback mInputCallback;
     75     private HandlerThread mHandlerThread;
     76     private EpgFetcherHandler mHandler;
     77     private RecurringRunner mRecurringRunner;
     78 
     79     private long mLastEpgTimestamp = -1;
     80 
     81     public EpgFetcher(Context context) {
     82         mContext = context;
     83         mInputHelper = TvApplication.getSingletons(mContext).getTvInputManagerHelper();
     84         mInputCallback = new TvInputCallback() {
     85             @Override
     86             public void onInputAdded(String inputId) {
     87                 if (Utils.isInternalTvInput(mContext, inputId)) {
     88                     mHandler.removeMessages(MSG_FETCH_EPG);
     89                     mHandler.sendEmptyMessage(MSG_FETCH_EPG);
     90                 }
     91             }
     92         };
     93     }
     94 
     95     /**
     96      * Starts fetching EPG.
     97      */
     98     public void start() {
     99         if (DEBUG) Log.d(TAG, "Request to start fetching EPG.");
    100         if (!Features.FETCH_EPG.isEnabled(mContext)) {
    101             return;
    102         }
    103         if (mHandlerThread == null) {
    104             mHandlerThread = new HandlerThread("EpgFetcher");
    105             mHandlerThread.start();
    106             mHandler = new EpgFetcherHandler(mHandlerThread.getLooper(), this);
    107             mInputHelper.addCallback(mInputCallback);
    108             mRecurringRunner = new RecurringRunner(mContext, EPG_PREFETCH_RECURRING_PERIOD_MS,
    109                     new Runnable() {
    110                         @Override
    111                         public void run() {
    112                             mHandler.removeMessages(MSG_FETCH_EPG);
    113                             mHandler.sendEmptyMessage(MSG_FETCH_EPG);
    114                         }
    115                     }, null);
    116             mRecurringRunner.start();
    117         }
    118     }
    119 
    120     /**
    121      * Stops fetching EPG.
    122      */
    123     public void stop() {
    124         if (mHandlerThread == null) {
    125             return;
    126         }
    127         mRecurringRunner.stop();
    128         mHandler.removeCallbacksAndMessages(null);
    129         mHandler = null;
    130         mHandlerThread.quit();
    131         mHandlerThread = null;
    132     }
    133 
    134     private void onFetchEpg() {
    135         if (DEBUG) Log.d(TAG, "Start fetching EPG.");
    136         // Check for the internal inputs.
    137         boolean hasInternalInput = false;
    138         for (TvInputInfo input : mInputHelper.getTvInputInfos(true, true)) {
    139             if (Utils.isInternalTvInput(mContext, input.getId())) {
    140                 hasInternalInput = true;
    141                 break;
    142             }
    143         }
    144         if (!hasInternalInput) {
    145             if (DEBUG) Log.d(TAG, "No internal input found.");
    146             return;
    147         }
    148         // Check if EPG reader is available.
    149         EpgReader epgReader = new StubEpgReader(mContext);
    150         if (!epgReader.isAvailable()) {
    151             if (DEBUG) Log.d(TAG, "EPG reader is not temporarily available.");
    152             mHandler.removeMessages(MSG_FETCH_EPG);
    153             mHandler.sendEmptyMessageDelayed(MSG_FETCH_EPG, EPG_READER_INIT_WAIT_MS);
    154             return;
    155         }
    156         // Check the EPG Timestamp.
    157         long epgTimestamp = epgReader.getEpgTimestamp();
    158         if (epgTimestamp <= getLastUpdatedEpgTimestamp()) {
    159             if (DEBUG) Log.d(TAG, "No new EPG.");
    160             return;
    161         }
    162 
    163         List<Channel> channels = epgReader.getChannels();
    164         for (Channel channel : channels) {
    165             List<Program> programs = new ArrayList<>(epgReader.getPrograms(channel.getId()));
    166             Collections.sort(programs);
    167             if (DEBUG) {
    168                 Log.d(TAG, "Fetching " + programs.size() + " programs for channel " + channel);
    169             }
    170             updateEpg(channel.getId(), programs);
    171         }
    172 
    173         setLastUpdatedEpgTimestamp(epgTimestamp);
    174     }
    175 
    176     private long getLastUpdatedEpgTimestamp() {
    177         if (mLastEpgTimestamp < 0) {
    178             mLastEpgTimestamp = PreferenceManager.getDefaultSharedPreferences(mContext).getLong(
    179                     KEY_LAST_UPDATED_EPG_TIMESTAMP, 0);
    180         }
    181         return mLastEpgTimestamp;
    182     }
    183 
    184     private void setLastUpdatedEpgTimestamp(long timestamp) {
    185         mLastEpgTimestamp = timestamp;
    186         PreferenceManager.getDefaultSharedPreferences(mContext).edit().putLong(
    187                 KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp);
    188     }
    189 
    190     private void updateEpg(long channelId, List<Program> newPrograms) {
    191         final int fetchedProgramsCount = newPrograms.size();
    192         if (fetchedProgramsCount == 0) {
    193             return;
    194         }
    195         long startTimeMs = System.currentTimeMillis();
    196         long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION;
    197         List<Program> oldPrograms = queryPrograms(mContext.getContentResolver(), channelId,
    198                 startTimeMs, endTimeMs);
    199         Program currentOldProgram = oldPrograms.size() > 0 ? oldPrograms.get(0) : null;
    200         int oldProgramsIndex = 0;
    201         int newProgramsIndex = 0;
    202         // Skip the past programs. They will be automatically removed by the system.
    203         if (currentOldProgram != null) {
    204             long oldStartTimeUtcMillis = currentOldProgram.getStartTimeUtcMillis();
    205             for (Program program : newPrograms) {
    206                 if (program.getEndTimeUtcMillis() > oldStartTimeUtcMillis) {
    207                     break;
    208                 }
    209                 newProgramsIndex++;
    210             }
    211         }
    212         // Compare the new programs with old programs one by one and update/delete the old one
    213         // or insert new program if there is no matching program in the database.
    214         ArrayList<ContentProviderOperation> ops = new ArrayList<>();
    215         while (newProgramsIndex < fetchedProgramsCount) {
    216             // TODO: Extract to method and make test.
    217             Program oldProgram = oldProgramsIndex < oldPrograms.size()
    218                     ? oldPrograms.get(oldProgramsIndex) : null;
    219             Program newProgram = newPrograms.get(newProgramsIndex);
    220             boolean addNewProgram = false;
    221             if (oldProgram != null) {
    222                 if (oldProgram.equals(newProgram)) {
    223                     // Exact match. No need to update. Move on to the next programs.
    224                     oldProgramsIndex++;
    225                     newProgramsIndex++;
    226                 } else if (isSameTitleAndOverlap(oldProgram, newProgram)) {
    227                     if (!oldProgram.equals(oldProgram)) {
    228                         // Partial match. Update the old program with the new one.
    229                         // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There
    230                         // could be application specific settings which belong to the old program.
    231                         ops.add(ContentProviderOperation.newUpdate(
    232                                 TvContract.buildProgramUri(oldProgram.getId()))
    233                                 .withValues(toContentValues(newProgram))
    234                                 .build());
    235                     }
    236                     oldProgramsIndex++;
    237                     newProgramsIndex++;
    238                 } else if (oldProgram.getEndTimeUtcMillis()
    239                         < newProgram.getEndTimeUtcMillis()) {
    240                     // No match. Remove the old program first to see if the next program in
    241                     // {@code oldPrograms} partially matches the new program.
    242                     ops.add(ContentProviderOperation.newDelete(
    243                             TvContract.buildProgramUri(oldProgram.getId()))
    244                             .build());
    245                     oldProgramsIndex++;
    246                 } else {
    247                     // No match. The new program does not match any of the old programs. Insert
    248                     // it as a new program.
    249                     addNewProgram = true;
    250                     newProgramsIndex++;
    251                 }
    252             } else {
    253                 // No old programs. Just insert new programs.
    254                 addNewProgram = true;
    255                 newProgramsIndex++;
    256             }
    257             if (addNewProgram) {
    258                 ops.add(ContentProviderOperation
    259                         .newInsert(TvContract.Programs.CONTENT_URI)
    260                         .withValues(toContentValues(newProgram))
    261                         .build());
    262             }
    263             // Throttle the batch operation not to cause TransactionTooLargeException.
    264             if (ops.size() > BATCH_OPERATION_COUNT || newProgramsIndex >= fetchedProgramsCount) {
    265                 try {
    266                     if (DEBUG) {
    267                         int size = ops.size();
    268                         Log.d(TAG, "Running " + size + " operations for channel " + channelId);
    269                         for (int i = 0; i < size; ++i) {
    270                             Log.d(TAG, "Operation(" + i + "): " + ops.get(i));
    271                         }
    272                     }
    273                     mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
    274                 } catch (RemoteException | OperationApplicationException e) {
    275                     Log.e(TAG, "Failed to insert programs.", e);
    276                     return;
    277                 }
    278                 ops.clear();
    279             }
    280         }
    281         if (DEBUG) {
    282             Log.d(TAG, "Fetched " + fetchedProgramsCount + " programs for channel " + channelId);
    283         }
    284     }
    285 
    286     private List<Program> queryPrograms(ContentResolver contentResolver, long channelId,
    287             long startTimeMs, long endTimeMs) {
    288         try (Cursor c = mContext.getContentResolver().query(
    289                 TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs),
    290                 Program.PROJECTION, null, null, Programs.COLUMN_START_TIME_UTC_MILLIS)) {
    291             if (c == null) {
    292                 return Collections.EMPTY_LIST;
    293             }
    294             ArrayList<Program> programs = new ArrayList<>();
    295             while (c.moveToNext()) {
    296                 programs.add(Program.fromCursor(c));
    297             }
    298             return programs;
    299         }
    300     }
    301 
    302     /**
    303      * Returns {@code true} if the {@code oldProgram} program needs to be updated with the
    304      * {@code newProgram} program.
    305      */
    306     private boolean isSameTitleAndOverlap(Program oldProgram, Program newProgram) {
    307         // NOTE: Here, we update the old program if it has the same title and overlaps with the
    308         // new program. The test logic is just an example and you can modify this. E.g. check
    309         // whether the both programs have the same program ID if your EPG supports any ID for
    310         // the programs.
    311         return Objects.equals(oldProgram.getTitle(), newProgram.getTitle())
    312                 && oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis()
    313                 && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis();
    314     }
    315 
    316     private static ContentValues toContentValues(Program program) {
    317         ContentValues values = new ContentValues();
    318         values.put(TvContract.Programs.COLUMN_CHANNEL_ID, program.getChannelId());
    319         putValue(values, TvContract.Programs.COLUMN_TITLE, program.getTitle());
    320         putValue(values, TvContract.Programs.COLUMN_EPISODE_TITLE, program.getEpisodeTitle());
    321         putValue(values, TvContract.Programs.COLUMN_SEASON_NUMBER, program.getSeasonNumber());
    322         putValue(values, TvContract.Programs.COLUMN_EPISODE_NUMBER, program.getEpisodeNumber());
    323         putValue(values, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, program.getDescription());
    324         putValue(values, TvContract.Programs.COLUMN_POSTER_ART_URI, program.getPosterArtUri());
    325         values.put(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
    326                 program.getStartTimeUtcMillis());
    327         values.put(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, program.getEndTimeUtcMillis());
    328         return values;
    329     }
    330 
    331     private static void putValue(ContentValues contentValues, String key, String value) {
    332         if (TextUtils.isEmpty(value)) {
    333             contentValues.putNull(key);
    334         } else {
    335             contentValues.put(key, value);
    336         }
    337     }
    338 
    339     private static class EpgFetcherHandler extends WeakHandler<EpgFetcher> {
    340         public EpgFetcherHandler (@NonNull Looper looper, EpgFetcher ref) {
    341             super(looper, ref);
    342         }
    343 
    344         @Override
    345         public void handleMessage(Message msg, @NonNull EpgFetcher epgFetcher) {
    346             switch (msg.what) {
    347                 case MSG_FETCH_EPG:
    348                     epgFetcher.onFetchEpg();
    349                     break;
    350                 default:
    351                     super.handleMessage(msg);
    352                     break;
    353             }
    354         }
    355     }
    356 }
    357