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.Manifest; 20 import android.annotation.SuppressLint; 21 import android.content.ContentProviderOperation; 22 import android.content.ContentValues; 23 import android.content.Context; 24 import android.content.OperationApplicationException; 25 import android.content.pm.PackageManager; 26 import android.database.Cursor; 27 import android.location.Address; 28 import android.media.tv.TvContentRating; 29 import android.media.tv.TvContract; 30 import android.media.tv.TvContract.Programs; 31 import android.media.tv.TvContract.Programs.Genres; 32 import android.media.tv.TvInputInfo; 33 import android.os.HandlerThread; 34 import android.os.Looper; 35 import android.os.Message; 36 import android.os.RemoteException; 37 import android.preference.PreferenceManager; 38 import android.support.annotation.MainThread; 39 import android.support.annotation.NonNull; 40 import android.support.annotation.Nullable; 41 import android.support.v4.os.BuildCompat; 42 import android.text.TextUtils; 43 import android.util.Log; 44 45 import com.android.tv.TvApplication; 46 import com.android.tv.common.WeakHandler; 47 import com.android.tv.data.Channel; 48 import com.android.tv.data.ChannelDataManager; 49 import com.android.tv.data.InternalDataUtils; 50 import com.android.tv.data.Lineup; 51 import com.android.tv.data.Program; 52 import com.android.tv.util.LocationUtils; 53 import com.android.tv.util.RecurringRunner; 54 import com.android.tv.util.Utils; 55 56 import java.io.IOException; 57 import java.util.ArrayList; 58 import java.util.Collections; 59 import java.util.List; 60 import java.util.Locale; 61 import java.util.Objects; 62 import java.util.concurrent.TimeUnit; 63 64 /** 65 * An utility class to fetch the EPG. This class isn't thread-safe. 66 */ 67 public class EpgFetcher { 68 private static final String TAG = "EpgFetcher"; 69 private static final boolean DEBUG = false; 70 71 private static final int MSG_FETCH_EPG = 1; 72 73 private static final long EPG_PREFETCH_RECURRING_PERIOD_MS = TimeUnit.HOURS.toMillis(4); 74 private static final long EPG_READER_INIT_WAIT_MS = TimeUnit.MINUTES.toMillis(1); 75 private static final long LOCATION_INIT_WAIT_MS = TimeUnit.SECONDS.toMillis(10); 76 private static final long LOCATION_ERROR_WAIT_MS = TimeUnit.HOURS.toMillis(1); 77 private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(30); 78 79 private static final int BATCH_OPERATION_COUNT = 100; 80 81 private static final String SUPPORTED_COUNTRY_CODE = Locale.US.getCountry(); 82 private static final String CONTENT_RATING_SEPARATOR = ","; 83 84 // Value: Long 85 private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP = 86 "com.android.tv.data.epg.EpgFetcher.LastUpdatedEpgTimestamp"; 87 // Value: String 88 private static final String KEY_LAST_LINEUP_ID = 89 "com.android.tv.data.epg.EpgFetcher.LastLineupId"; 90 91 private static EpgFetcher sInstance; 92 93 private final Context mContext; 94 private final ChannelDataManager mChannelDataManager; 95 private final EpgReader mEpgReader; 96 private EpgFetcherHandler mHandler; 97 private RecurringRunner mRecurringRunner; 98 private boolean mStarted; 99 100 private long mLastEpgTimestamp = -1; 101 private String mLineupId; 102 getInstance(Context context)103 public static synchronized EpgFetcher getInstance(Context context) { 104 if (sInstance == null) { 105 sInstance = new EpgFetcher(context.getApplicationContext()); 106 } 107 return sInstance; 108 } 109 110 /** 111 * Creates and returns {@link EpgReader}. 112 */ createEpgReader(Context context)113 public static EpgReader createEpgReader(Context context) { 114 return new StubEpgReader(context); 115 } 116 EpgFetcher(Context context)117 private EpgFetcher(Context context) { 118 mContext = context; 119 mEpgReader = new StubEpgReader(mContext); 120 mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager(); 121 mChannelDataManager.addListener(new ChannelDataManager.Listener() { 122 @Override 123 public void onLoadFinished() { 124 if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()"); 125 handleChannelChanged(); 126 } 127 128 @Override 129 public void onChannelListUpdated() { 130 if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()"); 131 handleChannelChanged(); 132 } 133 134 @Override 135 public void onChannelBrowsableChanged() { 136 if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelBrowsableChanged()"); 137 handleChannelChanged(); 138 } 139 }); 140 } 141 handleChannelChanged()142 private void handleChannelChanged() { 143 if (mStarted) { 144 if (needToStop()) { 145 stop(); 146 } 147 } else { 148 start(); 149 } 150 } 151 needToStop()152 private boolean needToStop() { 153 return !canStart(); 154 } 155 canStart()156 private boolean canStart() { 157 if (DEBUG) Log.d(TAG, "canStart()"); 158 boolean hasInternalTunerChannel = false; 159 for (TvInputInfo input : TvApplication.getSingletons(mContext).getTvInputManagerHelper() 160 .getTvInputInfos(true, true)) { 161 String inputId = input.getId(); 162 if (Utils.isInternalTvInput(mContext, inputId) 163 && mChannelDataManager.getChannelCountForInput(inputId) > 0) { 164 hasInternalTunerChannel = true; 165 break; 166 } 167 } 168 if (!hasInternalTunerChannel) { 169 if (DEBUG) Log.d(TAG, "No internal tuner channels."); 170 return false; 171 } 172 173 if (!TextUtils.isEmpty(getLastLineupId())) { 174 return true; 175 } 176 if (mContext.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) 177 != PackageManager.PERMISSION_GRANTED) { 178 if (DEBUG) Log.d(TAG, "No permission to check the current location."); 179 return false; 180 } 181 182 try { 183 Address address = LocationUtils.getCurrentAddress(mContext); 184 if (address != null 185 && !TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) { 186 if (DEBUG) Log.d(TAG, "Country not supported: " + address.getCountryCode()); 187 return false; 188 } 189 } catch (SecurityException e) { 190 Log.w(TAG, "No permission to get the current location", e); 191 return false; 192 } catch (IOException e) { 193 Log.w(TAG, "IO Exception when getting the current location", e); 194 } 195 return true; 196 } 197 198 /** 199 * Starts fetching EPG. 200 */ 201 @MainThread start()202 public void start() { 203 if (DEBUG) Log.d(TAG, "start()"); 204 if (mStarted) { 205 if (DEBUG) Log.d(TAG, "EpgFetcher thread already started."); 206 return; 207 } 208 if (!canStart()) { 209 return; 210 } 211 mStarted = true; 212 if (DEBUG) Log.d(TAG, "Starting EpgFetcher thread."); 213 HandlerThread handlerThread = new HandlerThread("EpgFetcher"); 214 handlerThread.start(); 215 mHandler = new EpgFetcherHandler(handlerThread.getLooper(), this); 216 mRecurringRunner = new RecurringRunner(mContext, EPG_PREFETCH_RECURRING_PERIOD_MS, 217 new EpgRunner(), null); 218 mRecurringRunner.start(); 219 if (DEBUG) Log.d(TAG, "EpgFetcher thread started successfully."); 220 } 221 222 /** 223 * Starts fetching EPG immediately if possible without waiting for the timer. 224 */ 225 @MainThread startImmediately()226 public void startImmediately() { 227 start(); 228 if (mStarted) { 229 if (DEBUG) Log.d(TAG, "Starting fetcher immediately"); 230 fetchEpg(); 231 } 232 } 233 234 /** 235 * Stops fetching EPG. 236 */ 237 @MainThread stop()238 public void stop() { 239 if (DEBUG) Log.d(TAG, "stop()"); 240 if (!mStarted) { 241 return; 242 } 243 mStarted = false; 244 mRecurringRunner.stop(); 245 mHandler.removeCallbacksAndMessages(null); 246 mHandler.getLooper().quit(); 247 } 248 fetchEpg()249 private void fetchEpg() { 250 fetchEpg(0); 251 } 252 fetchEpg(long delay)253 private void fetchEpg(long delay) { 254 mHandler.removeMessages(MSG_FETCH_EPG); 255 mHandler.sendEmptyMessageDelayed(MSG_FETCH_EPG, delay); 256 } 257 onFetchEpg()258 private void onFetchEpg() { 259 if (DEBUG) Log.d(TAG, "Start fetching EPG."); 260 if (!mEpgReader.isAvailable()) { 261 if (DEBUG) Log.d(TAG, "EPG reader is not temporarily available."); 262 fetchEpg(EPG_READER_INIT_WAIT_MS); 263 return; 264 } 265 String lineupId = getLastLineupId(); 266 if (lineupId == null) { 267 Address address; 268 try { 269 address = LocationUtils.getCurrentAddress(mContext); 270 } catch (IOException e) { 271 if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e); 272 fetchEpg(LOCATION_ERROR_WAIT_MS); 273 return; 274 } catch (SecurityException e) { 275 Log.w(TAG, "No permission to get the current location."); 276 return; 277 } 278 if (address == null) { 279 if (DEBUG) Log.d(TAG, "Null address returned."); 280 fetchEpg(LOCATION_INIT_WAIT_MS); 281 return; 282 } 283 if (DEBUG) Log.d(TAG, "Current location is " + address); 284 285 lineupId = getLineupForAddress(address); 286 if (lineupId != null) { 287 if (DEBUG) Log.d(TAG, "Saving lineup " + lineupId + "found for " + address); 288 setLastLineupId(lineupId); 289 } else { 290 if (DEBUG) Log.d(TAG, "No lineup found for " + address); 291 return; 292 } 293 } 294 295 // Check the EPG Timestamp. 296 long epgTimestamp = mEpgReader.getEpgTimestamp(); 297 if (epgTimestamp <= getLastUpdatedEpgTimestamp()) { 298 if (DEBUG) Log.d(TAG, "No new EPG."); 299 return; 300 } 301 302 boolean updated = false; 303 List<Channel> channels = mEpgReader.getChannels(lineupId); 304 for (Channel channel : channels) { 305 List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channel.getId())); 306 Collections.sort(programs); 307 if (DEBUG) { 308 Log.d(TAG, "Fetched " + programs.size() + " programs for channel " + channel); 309 } 310 if (updateEpg(channel.getId(), programs)) { 311 updated = true; 312 } 313 } 314 315 final boolean epgUpdated = updated; 316 setLastUpdatedEpgTimestamp(epgTimestamp); 317 mHandler.removeMessages(MSG_FETCH_EPG); 318 if (DEBUG) Log.d(TAG, "Fetching EPG is finished."); 319 } 320 321 @Nullable getLineupForAddress(Address address)322 private String getLineupForAddress(Address address) { 323 String lineup = null; 324 if (TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) { 325 String postalCode = address.getPostalCode(); 326 if (!TextUtils.isEmpty(postalCode)) { 327 lineup = getLineupForPostalCode(postalCode); 328 } 329 } 330 return lineup; 331 } 332 333 @Nullable getLineupForPostalCode(String postalCode)334 private String getLineupForPostalCode(String postalCode) { 335 List<Lineup> lineups = mEpgReader.getLineups(postalCode); 336 for (Lineup lineup : lineups) { 337 // TODO(EPG): handle more than OTA digital 338 if (lineup.type == Lineup.LINEUP_BROADCAST_DIGITAL) { 339 if (DEBUG) Log.d(TAG, "Setting lineup to " + lineup.name + "(" + lineup.id + ")"); 340 return lineup.id; 341 } 342 } 343 return null; 344 } 345 getLastUpdatedEpgTimestamp()346 private long getLastUpdatedEpgTimestamp() { 347 if (mLastEpgTimestamp < 0) { 348 mLastEpgTimestamp = PreferenceManager.getDefaultSharedPreferences(mContext).getLong( 349 KEY_LAST_UPDATED_EPG_TIMESTAMP, 0); 350 } 351 return mLastEpgTimestamp; 352 } 353 setLastUpdatedEpgTimestamp(long timestamp)354 private void setLastUpdatedEpgTimestamp(long timestamp) { 355 mLastEpgTimestamp = timestamp; 356 PreferenceManager.getDefaultSharedPreferences(mContext).edit().putLong( 357 KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp).commit(); 358 } 359 getLastLineupId()360 private String getLastLineupId() { 361 if (mLineupId == null) { 362 mLineupId = PreferenceManager.getDefaultSharedPreferences(mContext) 363 .getString(KEY_LAST_LINEUP_ID, null); 364 } 365 if (DEBUG) Log.d(TAG, "Last lineup_id " + mLineupId); 366 return mLineupId; 367 } 368 setLastLineupId(String lineupId)369 private void setLastLineupId(String lineupId) { 370 mLineupId = lineupId; 371 PreferenceManager.getDefaultSharedPreferences(mContext).edit() 372 .putString(KEY_LAST_LINEUP_ID, lineupId).commit(); 373 } 374 updateEpg(long channelId, List<Program> newPrograms)375 private boolean updateEpg(long channelId, List<Program> newPrograms) { 376 final int fetchedProgramsCount = newPrograms.size(); 377 if (fetchedProgramsCount == 0) { 378 return false; 379 } 380 boolean updated = false; 381 long startTimeMs = System.currentTimeMillis(); 382 long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION; 383 List<Program> oldPrograms = queryPrograms(channelId, startTimeMs, endTimeMs); 384 Program currentOldProgram = oldPrograms.size() > 0 ? oldPrograms.get(0) : null; 385 int oldProgramsIndex = 0; 386 int newProgramsIndex = 0; 387 // Skip the past programs. They will be automatically removed by the system. 388 if (currentOldProgram != null) { 389 long oldStartTimeUtcMillis = currentOldProgram.getStartTimeUtcMillis(); 390 for (Program program : newPrograms) { 391 if (program.getEndTimeUtcMillis() > oldStartTimeUtcMillis) { 392 break; 393 } 394 newProgramsIndex++; 395 } 396 } 397 // Compare the new programs with old programs one by one and update/delete the old one 398 // or insert new program if there is no matching program in the database. 399 ArrayList<ContentProviderOperation> ops = new ArrayList<>(); 400 while (newProgramsIndex < fetchedProgramsCount) { 401 // TODO: Extract to method and make test. 402 Program oldProgram = oldProgramsIndex < oldPrograms.size() 403 ? oldPrograms.get(oldProgramsIndex) : null; 404 Program newProgram = newPrograms.get(newProgramsIndex); 405 boolean addNewProgram = false; 406 if (oldProgram != null) { 407 if (oldProgram.equals(newProgram)) { 408 // Exact match. No need to update. Move on to the next programs. 409 oldProgramsIndex++; 410 newProgramsIndex++; 411 } else if (isSameTitleAndOverlap(oldProgram, newProgram)) { 412 // Partial match. Update the old program with the new one. 413 // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There 414 // could be application specific settings which belong to the old program. 415 ops.add(ContentProviderOperation.newUpdate( 416 TvContract.buildProgramUri(oldProgram.getId())) 417 .withValues(toContentValues(newProgram)) 418 .build()); 419 oldProgramsIndex++; 420 newProgramsIndex++; 421 } else if (oldProgram.getEndTimeUtcMillis() 422 < newProgram.getEndTimeUtcMillis()) { 423 // No match. Remove the old program first to see if the next program in 424 // {@code oldPrograms} partially matches the new program. 425 ops.add(ContentProviderOperation.newDelete( 426 TvContract.buildProgramUri(oldProgram.getId())) 427 .build()); 428 oldProgramsIndex++; 429 } else { 430 // No match. The new program does not match any of the old programs. Insert 431 // it as a new program. 432 addNewProgram = true; 433 newProgramsIndex++; 434 } 435 } else { 436 // No old programs. Just insert new programs. 437 addNewProgram = true; 438 newProgramsIndex++; 439 } 440 if (addNewProgram) { 441 ops.add(ContentProviderOperation 442 .newInsert(TvContract.Programs.CONTENT_URI) 443 .withValues(toContentValues(newProgram)) 444 .build()); 445 } 446 // Throttle the batch operation not to cause TransactionTooLargeException. 447 if (ops.size() > BATCH_OPERATION_COUNT || newProgramsIndex >= fetchedProgramsCount) { 448 try { 449 if (DEBUG) { 450 int size = ops.size(); 451 Log.d(TAG, "Running " + size + " operations for channel " + channelId); 452 for (int i = 0; i < size; ++i) { 453 Log.d(TAG, "Operation(" + i + "): " + ops.get(i)); 454 } 455 } 456 mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); 457 updated = true; 458 } catch (RemoteException | OperationApplicationException e) { 459 Log.e(TAG, "Failed to insert programs.", e); 460 return updated; 461 } 462 ops.clear(); 463 } 464 } 465 if (DEBUG) { 466 Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId); 467 } 468 return updated; 469 } 470 queryPrograms(long channelId, long startTimeMs, long endTimeMs)471 private List<Program> queryPrograms(long channelId, long startTimeMs, long endTimeMs) { 472 try (Cursor c = mContext.getContentResolver().query( 473 TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs), 474 Program.PROJECTION, null, null, Programs.COLUMN_START_TIME_UTC_MILLIS)) { 475 if (c == null) { 476 return Collections.emptyList(); 477 } 478 ArrayList<Program> programs = new ArrayList<>(); 479 while (c.moveToNext()) { 480 programs.add(Program.fromCursor(c)); 481 } 482 return programs; 483 } 484 } 485 486 /** 487 * Returns {@code true} if the {@code oldProgram} program needs to be updated with the 488 * {@code newProgram} program. 489 */ isSameTitleAndOverlap(Program oldProgram, Program newProgram)490 private boolean isSameTitleAndOverlap(Program oldProgram, Program newProgram) { 491 // NOTE: Here, we update the old program if it has the same title and overlaps with the 492 // new program. The test logic is just an example and you can modify this. E.g. check 493 // whether the both programs have the same program ID if your EPG supports any ID for 494 // the programs. 495 return Objects.equals(oldProgram.getTitle(), newProgram.getTitle()) 496 && oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis() 497 && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis(); 498 } 499 500 @SuppressLint("InlinedApi") 501 @SuppressWarnings("deprecation") toContentValues(Program program)502 private static ContentValues toContentValues(Program program) { 503 ContentValues values = new ContentValues(); 504 values.put(TvContract.Programs.COLUMN_CHANNEL_ID, program.getChannelId()); 505 putValue(values, TvContract.Programs.COLUMN_TITLE, program.getTitle()); 506 putValue(values, TvContract.Programs.COLUMN_EPISODE_TITLE, program.getEpisodeTitle()); 507 if (BuildCompat.isAtLeastN()) { 508 putValue(values, TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER, 509 program.getSeasonNumber()); 510 putValue(values, TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER, 511 program.getEpisodeNumber()); 512 } else { 513 putValue(values, TvContract.Programs.COLUMN_SEASON_NUMBER, program.getSeasonNumber()); 514 putValue(values, TvContract.Programs.COLUMN_EPISODE_NUMBER, program.getEpisodeNumber()); 515 } 516 putValue(values, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, program.getDescription()); 517 putValue(values, TvContract.Programs.COLUMN_POSTER_ART_URI, program.getPosterArtUri()); 518 putValue(values, TvContract.Programs.COLUMN_THUMBNAIL_URI, program.getThumbnailUri()); 519 String[] canonicalGenres = program.getCanonicalGenres(); 520 if (canonicalGenres != null && canonicalGenres.length > 0) { 521 putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, 522 Genres.encode(canonicalGenres)); 523 } else { 524 putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, ""); 525 } 526 TvContentRating[] ratings = program.getContentRatings(); 527 if (ratings != null && ratings.length > 0) { 528 StringBuilder sb = new StringBuilder(ratings[0].flattenToString()); 529 for (int i = 1; i < ratings.length; ++i) { 530 sb.append(CONTENT_RATING_SEPARATOR); 531 sb.append(ratings[i].flattenToString()); 532 } 533 putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, sb.toString()); 534 } else { 535 putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, ""); 536 } 537 values.put(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, 538 program.getStartTimeUtcMillis()); 539 values.put(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, program.getEndTimeUtcMillis()); 540 putValue(values, TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA, 541 InternalDataUtils.serializeInternalProviderData(program)); 542 return values; 543 } 544 putValue(ContentValues contentValues, String key, String value)545 private static void putValue(ContentValues contentValues, String key, String value) { 546 if (TextUtils.isEmpty(value)) { 547 contentValues.putNull(key); 548 } else { 549 contentValues.put(key, value); 550 } 551 } 552 putValue(ContentValues contentValues, String key, byte[] value)553 private static void putValue(ContentValues contentValues, String key, byte[] value) { 554 if (value == null || value.length == 0) { 555 contentValues.putNull(key); 556 } else { 557 contentValues.put(key, value); 558 } 559 } 560 561 private static class EpgFetcherHandler extends WeakHandler<EpgFetcher> { EpgFetcherHandler(@onNull Looper looper, EpgFetcher ref)562 public EpgFetcherHandler (@NonNull Looper looper, EpgFetcher ref) { 563 super(looper, ref); 564 } 565 566 @Override handleMessage(Message msg, @NonNull EpgFetcher epgFetcher)567 public void handleMessage(Message msg, @NonNull EpgFetcher epgFetcher) { 568 switch (msg.what) { 569 case MSG_FETCH_EPG: 570 epgFetcher.onFetchEpg(); 571 break; 572 default: 573 super.handleMessage(msg); 574 break; 575 } 576 } 577 } 578 579 private class EpgRunner implements Runnable { 580 @Override run()581 public void run() { 582 fetchEpg(); 583 } 584 } 585 } 586