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