diff options
Diffstat (limited to 'src/com/android/tv/data/epg/EpgFetcher.java')
-rw-r--r-- | src/com/android/tv/data/epg/EpgFetcher.java | 988 |
1 files changed, 569 insertions, 419 deletions
diff --git a/src/com/android/tv/data/epg/EpgFetcher.java b/src/com/android/tv/data/epg/EpgFetcher.java index 3b093b6a..24f8b826 100644 --- a/src/com/android/tv/data/epg/EpgFetcher.java +++ b/src/com/android/tv/data/epg/EpgFetcher.java @@ -16,570 +16,720 @@ package com.android.tv.data.epg; -import android.Manifest; -import android.annotation.SuppressLint; -import android.content.ContentProviderOperation; -import android.content.ContentValues; +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; import android.content.Context; -import android.content.OperationApplicationException; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.location.Address; -import android.media.tv.TvContentRating; -import android.media.tv.TvContract; -import android.media.tv.TvContract.Programs; -import android.media.tv.TvContract.Programs.Genres; import android.media.tv.TvInputInfo; +import android.net.TrafficStats; +import android.os.AsyncTask; +import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; -import android.os.RemoteException; -import android.preference.PreferenceManager; +import android.support.annotation.AnyThread; import android.support.annotation.MainThread; -import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.support.v4.os.BuildCompat; +import android.support.annotation.WorkerThread; import android.text.TextUtils; import android.util.Log; +import com.android.tv.ApplicationSingletons; +import com.android.tv.Features; import com.android.tv.TvApplication; -import com.android.tv.common.WeakHandler; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.TvCommonUtils; +import com.android.tv.config.RemoteConfigUtils; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; -import com.android.tv.data.InternalDataUtils; +import com.android.tv.data.ChannelLogoFetcher; import com.android.tv.data.Lineup; import com.android.tv.data.Program; +import com.android.tv.perf.EventNames; +import com.android.tv.perf.PerformanceMonitor; +import com.android.tv.perf.TimerEvent; +import com.android.tv.tuner.util.PostalCodeUtils; import com.android.tv.util.LocationUtils; -import com.android.tv.util.RecurringRunner; +import com.android.tv.util.NetworkTrafficTags; import com.android.tv.util.Utils; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; -import java.util.Locale; -import java.util.Objects; +import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; /** - * An utility class to fetch the EPG. This class isn't thread-safe. + * The service class to fetch EPG routinely or on-demand during channel scanning + * + * <p>Since the default executor of {@link AsyncTask} is {@link AsyncTask#SERIAL_EXECUTOR}, only one + * task can run at a time. Because fetching EPG takes long time, the fetching task shouldn't run on + * the serial executor. Instead, it should run on the {@link AsyncTask#THREAD_POOL_EXECUTOR}. */ public class EpgFetcher { private static final String TAG = "EpgFetcher"; private static final boolean DEBUG = false; - private static final int MSG_FETCH_EPG = 1; + private static final int EPG_ROUTINELY_FETCHING_JOB_ID = 101; + + private static final long INITIAL_BACKOFF_MS = TimeUnit.SECONDS.toMillis(10); + + private static final int REASON_EPG_READER_NOT_READY = 1; + private static final int REASON_LOCATION_INFO_UNAVAILABLE = 2; + private static final int REASON_LOCATION_PERMISSION_NOT_GRANTED = 3; + private static final int REASON_NO_EPG_DATA_RETURNED = 4; + private static final int REASON_NO_NEW_EPG = 5; - private static final long EPG_PREFETCH_RECURRING_PERIOD_MS = TimeUnit.HOURS.toMillis(4); - private static final long EPG_READER_INIT_WAIT_MS = TimeUnit.MINUTES.toMillis(1); - private static final long LOCATION_INIT_WAIT_MS = TimeUnit.SECONDS.toMillis(10); - private static final long LOCATION_ERROR_WAIT_MS = TimeUnit.HOURS.toMillis(1); - private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(30); + private static final long FETCH_DURING_SCAN_WAIT_TIME_MS = TimeUnit.SECONDS.toMillis(10); - private static final int BATCH_OPERATION_COUNT = 100; + private static final long FETCH_DURING_SCAN_DURATION_SEC = TimeUnit.HOURS.toSeconds(3); + private static final long FAST_FETCH_DURATION_SEC = TimeUnit.DAYS.toSeconds(2); - private static final String SUPPORTED_COUNTRY_CODE = Locale.US.getCountry(); - private static final String CONTENT_RATING_SEPARATOR = ","; + private static final int DEFAULT_ROUTINE_INTERVAL_HOUR = 4; + private static final String KEY_ROUTINE_INTERVAL = "live_channels_epg_fetcher_interval_hour"; - // Value: Long - private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP = - "com.android.tv.data.epg.EpgFetcher.LastUpdatedEpgTimestamp"; - // Value: String - private static final String KEY_LAST_LINEUP_ID = - "com.android.tv.data.epg.EpgFetcher.LastLineupId"; + private static final int MSG_PREPARE_FETCH_DURING_SCAN = 1; + private static final int MSG_CHANNEL_UPDATED_DURING_SCAN = 2; + private static final int MSG_FINISH_FETCH_DURING_SCAN = 3; + private static final int MSG_RETRY_PREPARE_FETCH_DURING_SCAN = 4; + + private static final int QUERY_CHANNEL_COUNT = 50; + private static final int MINIMUM_CHANNELS_TO_DECIDE_LINEUP = 3; private static EpgFetcher sInstance; private final Context mContext; private final ChannelDataManager mChannelDataManager; private final EpgReader mEpgReader; - private EpgFetcherHandler mHandler; - private RecurringRunner mRecurringRunner; - private boolean mStarted; - - private long mLastEpgTimestamp = -1; - private String mLineupId; - - public static synchronized EpgFetcher getInstance(Context context) { + private final PerformanceMonitor mPerformanceMonitor; + private FetchAsyncTask mFetchTask; + private FetchDuringScanHandler mFetchDuringScanHandler; + private long mEpgTimeStamp; + private List<Lineup> mPossibleLineups; + private final Object mPossibleLineupsLock = new Object(); + private final Object mFetchDuringScanHandlerLock = new Object(); + // A flag to block the re-entrance of onChannelScanStarted and onChannelScanFinished. + private boolean mScanStarted; + + private final long mRoutineIntervalMs; + private final long mEpgDataExpiredTimeLimitMs; + private final long mFastFetchDurationSec; + + public static EpgFetcher getInstance(Context context) { if (sInstance == null) { - sInstance = new EpgFetcher(context.getApplicationContext()); + sInstance = new EpgFetcher(context); } return sInstance; } - /** - * Creates and returns {@link EpgReader}. - */ - public static EpgReader createEpgReader(Context context) { + /** Creates and returns {@link EpgReader}. */ + public static EpgReader createEpgReader(Context context, String region) { return new StubEpgReader(context); } private EpgFetcher(Context context) { - mContext = context; - mEpgReader = new StubEpgReader(mContext); - mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager(); - mChannelDataManager.addListener(new ChannelDataManager.Listener() { - @Override - public void onLoadFinished() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()"); - handleChannelChanged(); + mContext = context.getApplicationContext(); + ApplicationSingletons applicationSingletons = TvApplication.getSingletons(mContext); + mChannelDataManager = applicationSingletons.getChannelDataManager(); + mPerformanceMonitor = applicationSingletons.getPerformanceMonitor(); + mEpgReader = createEpgReader(mContext, LocationUtils.getCurrentCountry(mContext)); + + int remoteInteval = + (int) RemoteConfigUtils.getRemoteConfig( + context, KEY_ROUTINE_INTERVAL, DEFAULT_ROUTINE_INTERVAL_HOUR); + mRoutineIntervalMs = + remoteInteval < 0 + ? TimeUnit.HOURS.toMillis(DEFAULT_ROUTINE_INTERVAL_HOUR) + : TimeUnit.HOURS.toMillis(remoteInteval); + mEpgDataExpiredTimeLimitMs = mRoutineIntervalMs * 2; + mFastFetchDurationSec = FAST_FETCH_DURATION_SEC + mRoutineIntervalMs / 1000; + } + + /** + * Starts the routine service of EPG fetching. It use {@link JobScheduler} to schedule the EPG + * fetching routine. The EPG fetching routine will be started roughly every 4 hours, unless + * the channel scanning of tuner input is started. + */ + @MainThread + public void startRoutineService() { + JobScheduler jobScheduler = + (JobScheduler) mContext.getSystemService(Context.JOB_SCHEDULER_SERVICE); + for (JobInfo job : jobScheduler.getAllPendingJobs()) { + if (job.getId() == EPG_ROUTINELY_FETCHING_JOB_ID) { + return; } + } + JobInfo job = + new JobInfo.Builder( + EPG_ROUTINELY_FETCHING_JOB_ID, + new ComponentName(mContext, EpgFetchService.class)) + .setPeriodic(mRoutineIntervalMs) + .setBackoffCriteria(INITIAL_BACKOFF_MS, JobInfo.BACKOFF_POLICY_EXPONENTIAL) + .setPersisted(true) + .build(); + jobScheduler.schedule(job); + Log.i(TAG, "EPG fetching routine service started."); + } + /** + * Fetches EPG immediately if current EPG data are out-dated, i.e., not successfully updated + * by routine fetching service due to various reasons. + */ + @MainThread + public void fetchImmediatelyIfNeeded() { + if (TvCommonUtils.isRunningInTest()) { + // Do not run EpgFetcher in test. + return; + } + new AsyncTask<Void, Void, Long>() { @Override - public void onChannelListUpdated() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()"); - handleChannelChanged(); + protected Long doInBackground(Void... args) { + return EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext); } @Override - public void onChannelBrowsableChanged() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelBrowsableChanged()"); - handleChannelChanged(); + protected void onPostExecute(Long result) { + if (System.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext) + > mEpgDataExpiredTimeLimitMs) { + Log.i(TAG, "EPG data expired. Start fetching immediately."); + fetchImmediately(); + } } - }); + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } - private void handleChannelChanged() { - if (mStarted) { - if (needToStop()) { - stop(); - } - } else { - start(); - } - } + /** + * Fetches EPG immediately. + */ + @MainThread + public void fetchImmediately() { + if (!mChannelDataManager.isDbLoadFinished()) { + mChannelDataManager.addListener(new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + mChannelDataManager.removeListener(this); + executeFetchTaskIfPossible(null, null); + } - private boolean needToStop() { - return !canStart(); - } + @Override + public void onChannelListUpdated() { } - private boolean canStart() { - if (DEBUG) Log.d(TAG, "canStart()"); - boolean hasInternalTunerChannel = false; - for (TvInputInfo input : TvApplication.getSingletons(mContext).getTvInputManagerHelper() - .getTvInputInfos(true, true)) { - String inputId = input.getId(); - if (Utils.isInternalTvInput(mContext, inputId) - && mChannelDataManager.getChannelCountForInput(inputId) > 0) { - hasInternalTunerChannel = true; - break; - } - } - if (!hasInternalTunerChannel) { - if (DEBUG) Log.d(TAG, "No internal tuner channels."); - return false; + @Override + public void onChannelBrowsableChanged() { } + }); + } else { + executeFetchTaskIfPossible(null, null); } + } - if (!TextUtils.isEmpty(getLastLineupId())) { - return true; - } - if (mContext.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) - != PackageManager.PERMISSION_GRANTED) { - if (DEBUG) Log.d(TAG, "No permission to check the current location."); - return false; + /** + * Notifies EPG fetch service that channel scanning is started. + */ + @MainThread + public void onChannelScanStarted() { + if (mScanStarted || !Features.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) { + return; } - - try { - Address address = LocationUtils.getCurrentAddress(mContext); - if (address != null - && !TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) { - if (DEBUG) Log.d(TAG, "Country not supported: " + address.getCountryCode()); - return false; + mScanStarted = true; + stopFetchingJob(); + synchronized (mFetchDuringScanHandlerLock) { + if (mFetchDuringScanHandler == null) { + HandlerThread thread = new HandlerThread("EpgFetchDuringScan"); + thread.start(); + mFetchDuringScanHandler = new FetchDuringScanHandler(thread.getLooper()); } - } catch (SecurityException e) { - Log.w(TAG, "No permission to get the current location", e); - return false; - } catch (IOException e) { - Log.w(TAG, "IO Exception when getting the current location", e); + mFetchDuringScanHandler.sendEmptyMessage(MSG_PREPARE_FETCH_DURING_SCAN); } - return true; + Log.i(TAG, "EPG fetching on channel scanning started."); } /** - * Starts fetching EPG. + * Notifies EPG fetch service that channel scanning is finished. */ @MainThread - public void start() { - if (DEBUG) Log.d(TAG, "start()"); - if (mStarted) { - if (DEBUG) Log.d(TAG, "EpgFetcher thread already started."); + public void onChannelScanFinished() { + if (!mScanStarted) { return; } - if (!canStart()) { - return; + mScanStarted = false; + mFetchDuringScanHandler.sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN); + } + + @MainThread + private void stopFetchingJob() { + if (DEBUG) Log.d(TAG, "Try to stop routinely fetching job..."); + if (mFetchTask != null) { + mFetchTask.cancel(true); + mFetchTask = null; + Log.i(TAG, "EPG routinely fetching job stopped."); } - mStarted = true; - if (DEBUG) Log.d(TAG, "Starting EpgFetcher thread."); - HandlerThread handlerThread = new HandlerThread("EpgFetcher"); - handlerThread.start(); - mHandler = new EpgFetcherHandler(handlerThread.getLooper(), this); - mRecurringRunner = new RecurringRunner(mContext, EPG_PREFETCH_RECURRING_PERIOD_MS, - new EpgRunner(), null); - mRecurringRunner.start(); - if (DEBUG) Log.d(TAG, "EpgFetcher thread started successfully."); } - /** - * Starts fetching EPG immediately if possible without waiting for the timer. - */ @MainThread - public void startImmediately() { - start(); - if (mStarted) { - if (DEBUG) Log.d(TAG, "Starting fetcher immediately"); - fetchEpg(); + private boolean executeFetchTaskIfPossible(JobService service, JobParameters params) { + SoftPreconditions.checkState(mChannelDataManager.isDbLoadFinished()); + if (!TvCommonUtils.isRunningInTest() && checkFetchPrerequisite()) { + mFetchTask = new FetchAsyncTask(service, params); + mFetchTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + return true; } + return false; } - /** - * Stops fetching EPG. - */ @MainThread - public void stop() { - if (DEBUG) Log.d(TAG, "stop()"); - if (!mStarted) { - return; + private boolean checkFetchPrerequisite() { + if (DEBUG) Log.d(TAG, "Check prerequisite of routinely fetching job."); + if (!Features.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) { + Log.i(TAG, "Cannot start routine service: country not supported: " + + LocationUtils.getCurrentCountry(mContext)); + return false; + } + if (mFetchTask != null) { + // Fetching job is already running or ready to run, no need to start again. + return false; + } + if (mFetchDuringScanHandler != null) { + if (DEBUG) Log.d(TAG, "Cannot start routine service: scanning channels."); + return false; + } + if (getTunerChannelCount() == 0) { + if (DEBUG) Log.d(TAG, "Cannot start routine service: no internal tuner channels."); + return false; } - mStarted = false; - mRecurringRunner.stop(); - mHandler.removeCallbacksAndMessages(null); - mHandler.getLooper().quit(); + if (!TextUtils.isEmpty(EpgFetchHelper.getLastLineupId(mContext))) { + return true; + } + if (!TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return true; + } + return true; } - private void fetchEpg() { - fetchEpg(0); + @MainThread + private int getTunerChannelCount() { + for (TvInputInfo input : TvApplication.getSingletons(mContext) + .getTvInputManagerHelper().getTvInputInfos(true, true)) { + String inputId = input.getId(); + if (Utils.isInternalTvInput(mContext, inputId)) { + return mChannelDataManager.getChannelCountForInput(inputId); + } + } + return 0; } - private void fetchEpg(long delay) { - mHandler.removeMessages(MSG_FETCH_EPG); - mHandler.sendEmptyMessageDelayed(MSG_FETCH_EPG, delay); + @AnyThread + private void clearUnusedLineups(@Nullable String lineupId) { + synchronized (mPossibleLineupsLock) { + if (mPossibleLineups == null) { + return; + } + for (Lineup lineup : mPossibleLineups) { + if (!TextUtils.equals(lineupId, lineup.id)) { + mEpgReader.clearCachedChannels(lineup.id); + } + } + mPossibleLineups = null; + } } - private void onFetchEpg() { - if (DEBUG) Log.d(TAG, "Start fetching EPG."); + @WorkerThread + private Integer prepareFetchEpg(boolean forceUpdatePossibleLineups) { if (!mEpgReader.isAvailable()) { - if (DEBUG) Log.d(TAG, "EPG reader is not temporarily available."); - fetchEpg(EPG_READER_INIT_WAIT_MS); - return; + Log.i(TAG, "EPG reader is temporarily unavailable."); + return REASON_EPG_READER_NOT_READY; } - String lineupId = getLastLineupId(); - if (lineupId == null) { - Address address; - try { - address = LocationUtils.getCurrentAddress(mContext); - } catch (IOException e) { - if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e); - fetchEpg(LOCATION_ERROR_WAIT_MS); - return; - } catch (SecurityException e) { - Log.w(TAG, "No permission to get the current location."); - return; + // Checks the EPG Timestamp. + mEpgTimeStamp = mEpgReader.getEpgTimestamp(); + if (mEpgTimeStamp <= EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext)) { + if (DEBUG) Log.d(TAG, "No new EPG."); + return REASON_NO_NEW_EPG; + } + // Updates postal code. + boolean postalCodeChanged = false; + try { + postalCodeChanged = PostalCodeUtils.updatePostalCode(mContext); + } catch (IOException e) { + if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e); + if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return REASON_LOCATION_INFO_UNAVAILABLE; } - if (address == null) { - if (DEBUG) Log.d(TAG, "Null address returned."); - fetchEpg(LOCATION_INIT_WAIT_MS); - return; + } catch (SecurityException e) { + Log.w(TAG, "No permission to get the current location."); + if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return REASON_LOCATION_PERMISSION_NOT_GRANTED; } - if (DEBUG) Log.d(TAG, "Current location is " + address); - - lineupId = getLineupForAddress(address); - if (lineupId != null) { - if (DEBUG) Log.d(TAG, "Saving lineup " + lineupId + "found for " + address); - setLastLineupId(lineupId); - } else { - if (DEBUG) Log.d(TAG, "No lineup found for " + address); - return; + } catch (PostalCodeUtils.NoPostalCodeException e) { + Log.i(TAG, "Cannot get address or postal code."); + return REASON_LOCATION_INFO_UNAVAILABLE; + } + // Updates possible lineups if necessary. + SoftPreconditions.checkState(mPossibleLineups == null, TAG, "Possible lineups not reset."); + if (postalCodeChanged || forceUpdatePossibleLineups + || EpgFetchHelper.getLastLineupId(mContext) == null) { + // To prevent main thread being blocked, though theoretically it should not happen. + List<Lineup> possibleLineups = + mEpgReader.getLineups(PostalCodeUtils.getLastPostalCode(mContext)); + if (possibleLineups.isEmpty()) { + return REASON_NO_EPG_DATA_RETURNED; + } + for (Lineup lineup : possibleLineups) { + mEpgReader.preloadChannels(lineup.id); + } + synchronized (mPossibleLineupsLock) { + mPossibleLineups = possibleLineups; } + EpgFetchHelper.setLastLineupId(mContext, null); } + return null; + } - // Check the EPG Timestamp. - long epgTimestamp = mEpgReader.getEpgTimestamp(); - if (epgTimestamp <= getLastUpdatedEpgTimestamp()) { - if (DEBUG) Log.d(TAG, "No new EPG."); + @WorkerThread + private void batchFetchEpg(List<Channel> channels, long durationSec) { + Log.i(TAG, "Start batch fetching (" + durationSec + ")...." + channels.size()); + if (channels.size() == 0) { return; } - - boolean updated = false; - List<Channel> channels = mEpgReader.getChannels(lineupId); + List<Long> queryChannelIds = new ArrayList<>(QUERY_CHANNEL_COUNT); for (Channel channel : channels) { - List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channel.getId())); - Collections.sort(programs); - if (DEBUG) { - Log.d(TAG, "Fetched " + programs.size() + " programs for channel " + channel); - } - if (updateEpg(channel.getId(), programs)) { - updated = true; + queryChannelIds.add(channel.getId()); + if (queryChannelIds.size() >= QUERY_CHANNEL_COUNT) { + batchUpdateEpg(mEpgReader.getPrograms(queryChannelIds, durationSec)); + queryChannelIds.clear(); } } - - final boolean epgUpdated = updated; - setLastUpdatedEpgTimestamp(epgTimestamp); - mHandler.removeMessages(MSG_FETCH_EPG); - if (DEBUG) Log.d(TAG, "Fetching EPG is finished."); + if (!queryChannelIds.isEmpty()) { + batchUpdateEpg(mEpgReader.getPrograms(queryChannelIds, durationSec)); + } } - @Nullable - private String getLineupForAddress(Address address) { - String lineup = null; - if (TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) { - String postalCode = address.getPostalCode(); - if (!TextUtils.isEmpty(postalCode)) { - lineup = getLineupForPostalCode(postalCode); + @WorkerThread + private void batchUpdateEpg(Map<Long, List<Program>> allPrograms) { + for (Map.Entry<Long, List<Program>> entry : allPrograms.entrySet()) { + List<Program> programs = entry.getValue(); + if (programs == null) { + continue; } + Collections.sort(programs); + Log.i(TAG, "Batch fetched " + programs.size() + " programs for channel " + + entry.getKey()); + EpgFetchHelper.updateEpgData(mContext, entry.getKey(), programs); } - return lineup; } @Nullable - private String getLineupForPostalCode(String postalCode) { - List<Lineup> lineups = mEpgReader.getLineups(postalCode); - for (Lineup lineup : lineups) { - // TODO(EPG): handle more than OTA digital - if (lineup.type == Lineup.LINEUP_BROADCAST_DIGITAL) { - if (DEBUG) Log.d(TAG, "Setting lineup to " + lineup.name + "(" + lineup.id + ")"); - return lineup.id; + @WorkerThread + private String pickBestLineupId(List<Channel> currentChannelList) { + String maxLineupId = null; + synchronized (mPossibleLineupsLock) { + if (mPossibleLineups == null) { + return null; + } + int maxCount = 0; + for (Lineup lineup : mPossibleLineups) { + int count = getMatchedChannelCount(lineup.id, currentChannelList); + Log.i(TAG, lineup.name + " (" + lineup.id + ") - " + count + " matches"); + if (count > maxCount) { + maxCount = count; + maxLineupId = lineup.id; + } } } - return null; + return maxLineupId; } - private long getLastUpdatedEpgTimestamp() { - if (mLastEpgTimestamp < 0) { - mLastEpgTimestamp = PreferenceManager.getDefaultSharedPreferences(mContext).getLong( - KEY_LAST_UPDATED_EPG_TIMESTAMP, 0); + @WorkerThread + private int getMatchedChannelCount(String lineupId, List<Channel> currentChannelList) { + // Construct a list of display numbers for existing channels. + if (currentChannelList.isEmpty()) { + if (DEBUG) Log.d(TAG, "No existing channel to compare"); + return 0; + } + List<String> numbers = new ArrayList<>(currentChannelList.size()); + for (Channel channel : currentChannelList) { + // We only support channels from internal tuner inputs. + if (Utils.isInternalTvInput(mContext, channel.getInputId())) { + numbers.add(channel.getDisplayNumber()); + } } - return mLastEpgTimestamp; + numbers.retainAll(mEpgReader.getChannelNumbers(lineupId)); + return numbers.size(); } - private void setLastUpdatedEpgTimestamp(long timestamp) { - mLastEpgTimestamp = timestamp; - PreferenceManager.getDefaultSharedPreferences(mContext).edit().putLong( - KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp).commit(); - } + public static class EpgFetchService extends JobService { + private EpgFetcher mEpgFetcher; - private String getLastLineupId() { - if (mLineupId == null) { - mLineupId = PreferenceManager.getDefaultSharedPreferences(mContext) - .getString(KEY_LAST_LINEUP_ID, null); + @Override + public void onCreate() { + super.onCreate(); + TvApplication.setCurrentRunningProcess(this, true); + mEpgFetcher = EpgFetcher.getInstance(this); } - if (DEBUG) Log.d(TAG, "Last lineup_id " + mLineupId); - return mLineupId; - } - private void setLastLineupId(String lineupId) { - mLineupId = lineupId; - PreferenceManager.getDefaultSharedPreferences(mContext).edit() - .putString(KEY_LAST_LINEUP_ID, lineupId).commit(); - } + @Override + public boolean onStartJob(JobParameters params) { + if (!mEpgFetcher.mChannelDataManager.isDbLoadFinished()) { + mEpgFetcher.mChannelDataManager.addListener(new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + mEpgFetcher.mChannelDataManager.removeListener(this); + if (!mEpgFetcher.executeFetchTaskIfPossible(EpgFetchService.this, params)) { + jobFinished(params, false); + } + } - private boolean updateEpg(long channelId, List<Program> newPrograms) { - final int fetchedProgramsCount = newPrograms.size(); - if (fetchedProgramsCount == 0) { + @Override + public void onChannelListUpdated() { } + + @Override + public void onChannelBrowsableChanged() { } + }); + return true; + } else { + return mEpgFetcher.executeFetchTaskIfPossible(this, params); + } + } + + @Override + public boolean onStopJob(JobParameters params) { + mEpgFetcher.stopFetchingJob(); return false; } - boolean updated = false; - long startTimeMs = System.currentTimeMillis(); - long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION; - List<Program> oldPrograms = queryPrograms(channelId, startTimeMs, endTimeMs); - Program currentOldProgram = oldPrograms.size() > 0 ? oldPrograms.get(0) : null; - int oldProgramsIndex = 0; - int newProgramsIndex = 0; - // Skip the past programs. They will be automatically removed by the system. - if (currentOldProgram != null) { - long oldStartTimeUtcMillis = currentOldProgram.getStartTimeUtcMillis(); - for (Program program : newPrograms) { - if (program.getEndTimeUtcMillis() > oldStartTimeUtcMillis) { - break; - } - newProgramsIndex++; - } + } + + private class FetchAsyncTask extends AsyncTask<Void, Void, Integer> { + private final JobService mService; + private final JobParameters mParams; + private List<Channel> mCurrentChannelList; + private TimerEvent mTimerEvent; + + private FetchAsyncTask(JobService service, JobParameters params) { + mService = service; + mParams = params; } - // Compare the new programs with old programs one by one and update/delete the old one - // or insert new program if there is no matching program in the database. - ArrayList<ContentProviderOperation> ops = new ArrayList<>(); - while (newProgramsIndex < fetchedProgramsCount) { - // TODO: Extract to method and make test. - Program oldProgram = oldProgramsIndex < oldPrograms.size() - ? oldPrograms.get(oldProgramsIndex) : null; - Program newProgram = newPrograms.get(newProgramsIndex); - boolean addNewProgram = false; - if (oldProgram != null) { - if (oldProgram.equals(newProgram)) { - // Exact match. No need to update. Move on to the next programs. - oldProgramsIndex++; - newProgramsIndex++; - } else if (isSameTitleAndOverlap(oldProgram, newProgram)) { - // Partial match. Update the old program with the new one. - // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There - // could be application specific settings which belong to the old program. - ops.add(ContentProviderOperation.newUpdate( - TvContract.buildProgramUri(oldProgram.getId())) - .withValues(toContentValues(newProgram)) - .build()); - oldProgramsIndex++; - newProgramsIndex++; - } else if (oldProgram.getEndTimeUtcMillis() - < newProgram.getEndTimeUtcMillis()) { - // No match. Remove the old program first to see if the next program in - // {@code oldPrograms} partially matches the new program. - ops.add(ContentProviderOperation.newDelete( - TvContract.buildProgramUri(oldProgram.getId())) - .build()); - oldProgramsIndex++; + + @Override + protected void onPreExecute() { + mTimerEvent = mPerformanceMonitor.startTimer(); + mCurrentChannelList = mChannelDataManager.getChannelList(); + } + + @Override + protected Integer doInBackground(Void... args) { + final int oldTag = TrafficStats.getThreadStatsTag(); + TrafficStats.setThreadStatsTag(NetworkTrafficTags.EPG_FETCH); + try { + if (DEBUG) Log.d(TAG, "Start EPG routinely fetching."); + Integer failureReason = prepareFetchEpg(false); + // InterruptedException might be caught by RPC, we should check it here. + if (failureReason != null || this.isCancelled()) { + return failureReason; + } + String lineupId = EpgFetchHelper.getLastLineupId(mContext); + lineupId = lineupId == null ? pickBestLineupId(mCurrentChannelList) : lineupId; + if (lineupId != null) { + Log.i(TAG, "Selecting the lineup " + lineupId); + // During normal fetching process, the lineup ID should be confirmed since all + // channels are known, clear up possible lineups to save resources. + EpgFetchHelper.setLastLineupId(mContext, lineupId); + clearUnusedLineups(lineupId); } else { - // No match. The new program does not match any of the old programs. Insert - // it as a new program. - addNewProgram = true; - newProgramsIndex++; + Log.i(TAG, "Failed to get lineup id"); + return REASON_NO_EPG_DATA_RETURNED; } - } else { - // No old programs. Just insert new programs. - addNewProgram = true; - newProgramsIndex++; - } - if (addNewProgram) { - ops.add(ContentProviderOperation - .newInsert(TvContract.Programs.CONTENT_URI) - .withValues(toContentValues(newProgram)) - .build()); - } - // Throttle the batch operation not to cause TransactionTooLargeException. - if (ops.size() > BATCH_OPERATION_COUNT || newProgramsIndex >= fetchedProgramsCount) { - try { - if (DEBUG) { - int size = ops.size(); - Log.d(TAG, "Running " + size + " operations for channel " + channelId); - for (int i = 0; i < size; ++i) { - Log.d(TAG, "Operation(" + i + "): " + ops.get(i)); - } + final List<Channel> channels = mEpgReader.getChannels(lineupId); + // InterruptedException might be caught by RPC, we should check it here. + if (this.isCancelled()) { + return null; + } + if (channels.isEmpty()) { + Log.i(TAG, "Failed to get EPG channels."); + return REASON_NO_EPG_DATA_RETURNED; + } + if (System.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext) + > mEpgDataExpiredTimeLimitMs) { + batchFetchEpg(channels, mFastFetchDurationSec); + } + new Handler(mContext.getMainLooper()) + .post( + new Runnable() { + @Override + public void run() { + ChannelLogoFetcher.startFetchingChannelLogos( + mContext, channels); + } + }); + for (Channel channel : channels) { + if (this.isCancelled()) { + return null; } - mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); - updated = true; - } catch (RemoteException | OperationApplicationException e) { - Log.e(TAG, "Failed to insert programs.", e); - return updated; + long channelId = channel.getId(); + List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channelId)); + // InterruptedException might be caught by RPC, we should check it here. + Collections.sort(programs); + Log.i(TAG, "Fetched " + programs.size() + " programs for channel " + channelId); + EpgFetchHelper.updateEpgData(mContext, channelId, programs); } - ops.clear(); + EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, mEpgTimeStamp); + if (DEBUG) Log.d(TAG, "Fetching EPG is finished."); + return null; + } finally { + TrafficStats.setThreadStatsTag(oldTag); } } - if (DEBUG) { - Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId); - } - return updated; - } - private List<Program> queryPrograms(long channelId, long startTimeMs, long endTimeMs) { - try (Cursor c = mContext.getContentResolver().query( - TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs), - Program.PROJECTION, null, null, Programs.COLUMN_START_TIME_UTC_MILLIS)) { - if (c == null) { - return Collections.emptyList(); - } - ArrayList<Program> programs = new ArrayList<>(); - while (c.moveToNext()) { - programs.add(Program.fromCursor(c)); + @Override + protected void onPostExecute(Integer failureReason) { + mFetchTask = null; + if (failureReason == null || failureReason == REASON_LOCATION_PERMISSION_NOT_GRANTED + || failureReason == REASON_NO_NEW_EPG) { + jobFinished(false); + } else { + // Applies back-off policy + jobFinished(true); } - return programs; + mPerformanceMonitor.stopTimer(mTimerEvent, EventNames.FETCH_EPG_TASK); + mPerformanceMonitor.recordMemory(EventNames.FETCH_EPG_TASK); } - } - /** - * Returns {@code true} if the {@code oldProgram} program needs to be updated with the - * {@code newProgram} program. - */ - private boolean isSameTitleAndOverlap(Program oldProgram, Program newProgram) { - // NOTE: Here, we update the old program if it has the same title and overlaps with the - // new program. The test logic is just an example and you can modify this. E.g. check - // whether the both programs have the same program ID if your EPG supports any ID for - // the programs. - return Objects.equals(oldProgram.getTitle(), newProgram.getTitle()) - && oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis() - && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis(); - } + @Override + protected void onCancelled(Integer failureReason) { + clearUnusedLineups(null); + jobFinished(false); + } - @SuppressLint("InlinedApi") - @SuppressWarnings("deprecation") - private static ContentValues toContentValues(Program program) { - ContentValues values = new ContentValues(); - values.put(TvContract.Programs.COLUMN_CHANNEL_ID, program.getChannelId()); - putValue(values, TvContract.Programs.COLUMN_TITLE, program.getTitle()); - putValue(values, TvContract.Programs.COLUMN_EPISODE_TITLE, program.getEpisodeTitle()); - if (BuildCompat.isAtLeastN()) { - putValue(values, TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER, - program.getSeasonNumber()); - putValue(values, TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER, - program.getEpisodeNumber()); - } else { - putValue(values, TvContract.Programs.COLUMN_SEASON_NUMBER, program.getSeasonNumber()); - putValue(values, TvContract.Programs.COLUMN_EPISODE_NUMBER, program.getEpisodeNumber()); - } - putValue(values, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, program.getDescription()); - putValue(values, TvContract.Programs.COLUMN_POSTER_ART_URI, program.getPosterArtUri()); - putValue(values, TvContract.Programs.COLUMN_THUMBNAIL_URI, program.getThumbnailUri()); - String[] canonicalGenres = program.getCanonicalGenres(); - if (canonicalGenres != null && canonicalGenres.length > 0) { - putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, - Genres.encode(canonicalGenres)); - } else { - putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, ""); - } - TvContentRating[] ratings = program.getContentRatings(); - if (ratings != null && ratings.length > 0) { - StringBuilder sb = new StringBuilder(ratings[0].flattenToString()); - for (int i = 1; i < ratings.length; ++i) { - sb.append(CONTENT_RATING_SEPARATOR); - sb.append(ratings[i].flattenToString()); + private void jobFinished(boolean reschedule) { + if (mService != null && mParams != null) { + // Task is executed from JobService, need to report jobFinished. + mService.jobFinished(mParams, reschedule); } - putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, sb.toString()); - } else { - putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, ""); - } - values.put(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, - program.getStartTimeUtcMillis()); - values.put(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, program.getEndTimeUtcMillis()); - putValue(values, TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA, - InternalDataUtils.serializeInternalProviderData(program)); - return values; - } - - private static void putValue(ContentValues contentValues, String key, String value) { - if (TextUtils.isEmpty(value)) { - contentValues.putNull(key); - } else { - contentValues.put(key, value); } } - private static void putValue(ContentValues contentValues, String key, byte[] value) { - if (value == null || value.length == 0) { - contentValues.putNull(key); - } else { - contentValues.put(key, value); - } - } + @WorkerThread + private class FetchDuringScanHandler extends Handler { + private final Set<Long> mFetchedChannelIdsDuringScan = new HashSet<>(); + private String mPossibleLineupId; + + private final ChannelDataManager.Listener mDuringScanChannelListener = + new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()"); + if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP + && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + Message.obtain(FetchDuringScanHandler.this, + MSG_CHANNEL_UPDATED_DURING_SCAN, new ArrayList<>( + mChannelDataManager.getChannelList())).sendToTarget(); + } + } + + @Override + public void onChannelListUpdated() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()"); + if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP + && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + Message.obtain(FetchDuringScanHandler.this, + MSG_CHANNEL_UPDATED_DURING_SCAN, + mChannelDataManager.getChannelList()).sendToTarget(); + } + } + + @Override + public void onChannelBrowsableChanged() { + // Do nothing + } + }; - private static class EpgFetcherHandler extends WeakHandler<EpgFetcher> { - public EpgFetcherHandler (@NonNull Looper looper, EpgFetcher ref) { - super(looper, ref); + @AnyThread + private FetchDuringScanHandler(Looper looper) { + super(looper); } @Override - public void handleMessage(Message msg, @NonNull EpgFetcher epgFetcher) { + public void handleMessage(Message msg) { switch (msg.what) { - case MSG_FETCH_EPG: - epgFetcher.onFetchEpg(); + case MSG_PREPARE_FETCH_DURING_SCAN: + case MSG_RETRY_PREPARE_FETCH_DURING_SCAN: + onPrepareFetchDuringScan(); break; - default: - super.handleMessage(msg); + case MSG_CHANNEL_UPDATED_DURING_SCAN: + if (!hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + onChannelUpdatedDuringScan((List<Channel>) msg.obj); + } + break; + case MSG_FINISH_FETCH_DURING_SCAN: + removeMessages(MSG_RETRY_PREPARE_FETCH_DURING_SCAN); + if (hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN); + } else { + onFinishFetchDuringScan(); + } break; } } - } - private class EpgRunner implements Runnable { - @Override - public void run() { - fetchEpg(); + private void onPrepareFetchDuringScan() { + Integer failureReason = prepareFetchEpg(true); + if (failureReason != null) { + sendEmptyMessageDelayed( + MSG_RETRY_PREPARE_FETCH_DURING_SCAN, FETCH_DURING_SCAN_WAIT_TIME_MS); + return; + } + mChannelDataManager.addListener(mDuringScanChannelListener); + } + + private void onChannelUpdatedDuringScan(List<Channel> currentChannelList) { + String lineupId = pickBestLineupId(currentChannelList); + Log.i(TAG, "Fast fetch channels for lineup ID: " + lineupId); + if (TextUtils.isEmpty(lineupId)) { + if (TextUtils.isEmpty(mPossibleLineupId)) { + return; + } + } else if (!TextUtils.equals(lineupId, mPossibleLineupId)) { + mFetchedChannelIdsDuringScan.clear(); + mPossibleLineupId = lineupId; + } + List<Long> currentChannelIds = new ArrayList<>(); + for (Channel channel : currentChannelList) { + currentChannelIds.add(channel.getId()); + } + mFetchedChannelIdsDuringScan.retainAll(currentChannelIds); + List<Channel> newChannels = new ArrayList<>(); + for (Channel channel : mEpgReader.getChannels(mPossibleLineupId)) { + if (!mFetchedChannelIdsDuringScan.contains(channel.getId())) { + newChannels.add(channel); + mFetchedChannelIdsDuringScan.add(channel.getId()); + } + } + batchFetchEpg(newChannels, FETCH_DURING_SCAN_DURATION_SEC); + } + + private void onFinishFetchDuringScan() { + mChannelDataManager.removeListener(mDuringScanChannelListener); + EpgFetchHelper.setLastLineupId(mContext, mPossibleLineupId); + clearUnusedLineups(null); + mFetchedChannelIdsDuringScan.clear(); + synchronized (mFetchDuringScanHandlerLock) { + if (!hasMessages(MSG_PREPARE_FETCH_DURING_SCAN)) { + removeCallbacksAndMessages(null); + getLooper().quit(); + mFetchDuringScanHandler = null; + } + } + // Clear timestamp to make routine service start right away. + EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, 0); + Log.i(TAG, "EPG Fetching during channel scanning finished."); + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + fetchImmediately(); + } + }); } } } |