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 EpgFetcher(Context context)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 */ start()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 */ stop()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 onFetchEpg()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 getLastUpdatedEpgTimestamp()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 setLastUpdatedEpgTimestamp(long timestamp)184 private void setLastUpdatedEpgTimestamp(long timestamp) { 185 mLastEpgTimestamp = timestamp; 186 PreferenceManager.getDefaultSharedPreferences(mContext).edit().putLong( 187 KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp); 188 } 189 updateEpg(long channelId, List<Program> newPrograms)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