1 /*
2  * Copyright (C) 2017 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.ContentValues;
21 import android.content.Context;
22 import android.content.OperationApplicationException;
23 import android.database.Cursor;
24 import android.media.tv.TvContract;
25 import android.media.tv.TvContract.Programs;
26 import android.os.RemoteException;
27 import android.preference.PreferenceManager;
28 import android.support.annotation.WorkerThread;
29 import android.text.TextUtils;
30 import android.util.Log;
31 
32 import com.android.tv.common.CommonConstants;
33 import com.android.tv.common.util.Clock;
34 import com.android.tv.data.ProgramImpl;
35 import com.android.tv.data.api.Channel;
36 import com.android.tv.data.api.Program;
37 import com.android.tv.features.TvFeatures;
38 import com.android.tv.util.TvProviderUtils;
39 
40 import java.util.ArrayList;
41 import java.util.Collections;
42 import java.util.List;
43 import java.util.Set;
44 import java.util.concurrent.TimeUnit;
45 
46 /** The helper class for {@link EpgFetcher} */
47 class EpgFetchHelper {
48     private static final String TAG = "EpgFetchHelper";
49     private static final boolean DEBUG = false;
50 
51     private static final long PROGRAM_QUERY_DURATION_MS = TimeUnit.DAYS.toMillis(30);
52     private static final int BATCH_OPERATION_COUNT = 100;
53 
54     // Value: Long
55     private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP =
56             CommonConstants.BASE_PACKAGE + ".data.epg.EpgFetcher.LastUpdatedEpgTimestamp";
57     // Value: String
58     private static final String KEY_LAST_LINEUP_ID =
59             CommonConstants.BASE_PACKAGE + ".data.epg.EpgFetcher.LastLineupId";
60 
61     private static long sLastEpgUpdatedTimestamp = -1;
62     private static String sLastLineupId;
63 
EpgFetchHelper()64     private EpgFetchHelper() {}
65 
66     /**
67      * Updates newly fetched EPG data for the given channel to local providers. The method will
68      * compare the broadcasting time and try to match each newly fetched program with old programs
69      * of that channel in the database one by one. It will update the matched old program, or insert
70      * the new program if there is no matching program can be found in the database and at the same
71      * time remove those old programs which conflicts with the inserted one.
72      *
73      * @param channelId the target channel ID.
74      * @param fetchedPrograms the newly fetched program data.
75      * @return {@code true} if new program data are successfully updated. Otherwise {@code false}.
76      */
updateEpgData( Context context, Clock clock, long channelId, List<Program> fetchedPrograms)77     static boolean updateEpgData(
78             Context context, Clock clock, long channelId, List<Program> fetchedPrograms) {
79         final int fetchedProgramsCount = fetchedPrograms.size();
80         if (fetchedProgramsCount == 0) {
81             return false;
82         }
83         boolean updated = false;
84         long startTimeMs = clock.currentTimeMillis();
85         long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION_MS;
86         List<Program> oldPrograms = queryPrograms(context, channelId, startTimeMs, endTimeMs);
87         int oldProgramsIndex = 0;
88         int newProgramsIndex = 0;
89 
90         // Compare the new programs with old programs one by one and update/delete the old one
91         // or insert new program if there is no matching program in the database.
92         ArrayList<ContentProviderOperation> ops = new ArrayList<>();
93         while (newProgramsIndex < fetchedProgramsCount) {
94             Program oldProgram =
95                     oldProgramsIndex < oldPrograms.size()
96                             ? oldPrograms.get(oldProgramsIndex)
97                             : null;
98             Program newProgram = fetchedPrograms.get(newProgramsIndex);
99             boolean addNewProgram = false;
100             if (oldProgram != null) {
101                 if (oldProgram.equals(newProgram)) {
102                     // Exact match. No need to update. Move on to the next programs.
103                     oldProgramsIndex++;
104                     newProgramsIndex++;
105                 } else if (hasSameTitleAndOverlap(oldProgram, newProgram)) {
106                     // Partial match. Update the old program with the new one.
107                     // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There
108                     // could be application specific settings which belong to the old program.
109                     ops.add(
110                             ContentProviderOperation.newUpdate(
111                                             TvContract.buildProgramUri(oldProgram.getId()))
112                                     .withValues(ProgramImpl.toContentValues(newProgram, context))
113                                     .build());
114                     oldProgramsIndex++;
115                     newProgramsIndex++;
116                 } else if (oldProgram.getEndTimeUtcMillis() < newProgram.getEndTimeUtcMillis()) {
117                     // No match. Remove the old program first to see if the next program in
118                     // {@code oldPrograms} partially matches the new program.
119                     ops.add(
120                             ContentProviderOperation.newDelete(
121                                             TvContract.buildProgramUri(oldProgram.getId()))
122                                     .build());
123                     oldProgramsIndex++;
124                 } else {
125                     // No match. The new program does not match any of the old programs. Insert
126                     // it as a new program.
127                     addNewProgram = true;
128                     newProgramsIndex++;
129                 }
130             } else {
131                 // No old programs. Just insert new programs.
132                 addNewProgram = true;
133                 newProgramsIndex++;
134             }
135             if (addNewProgram) {
136                 ops.add(
137                         ContentProviderOperation.newInsert(Programs.CONTENT_URI)
138                                 .withValues(ProgramImpl.toContentValues(newProgram, context))
139                                 .build());
140             }
141             // Throttle the batch operation not to cause TransactionTooLargeException.
142             if (ops.size() > BATCH_OPERATION_COUNT || newProgramsIndex >= fetchedProgramsCount) {
143                 try {
144                     if (DEBUG) {
145                         int size = ops.size();
146                         Log.d(TAG, "Running " + size + " operations for channel " + channelId);
147                         for (int i = 0; i < size; ++i) {
148                             Log.d(TAG, "Operation(" + i + "): " + ops.get(i));
149                         }
150                     }
151                     context.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
152                     updated = true;
153                 } catch (RemoteException | OperationApplicationException e) {
154                     Log.e(TAG, "Failed to insert programs.", e);
155                     return updated;
156                 }
157                 ops.clear();
158             }
159         }
160         if (DEBUG) {
161             Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId);
162         }
163         return updated;
164     }
165 
166     @WorkerThread
updateNetworkAffiliation(Context context, Set<EpgReader.EpgChannel> channels)167     static void updateNetworkAffiliation(Context context, Set<EpgReader.EpgChannel> channels) {
168         if (!TvFeatures.STORE_NETWORK_AFFILIATION.isEnabled(context)) {
169             return;
170         }
171         ArrayList<ContentProviderOperation> ops = new ArrayList<>();
172         for (EpgReader.EpgChannel epgChannel : channels) {
173             if (!epgChannel.getDbUpdateNeeded()) {
174                 continue;
175             }
176             Channel channel = epgChannel.getChannel();
177 
178             ContentValues values = new ContentValues();
179             values.put(
180                     TvContract.Channels.COLUMN_NETWORK_AFFILIATION,
181                     channel.getNetworkAffiliation());
182             ops.add(
183                     ContentProviderOperation.newUpdate(TvContract.buildChannelUri(channel.getId()))
184                             .withValues(values)
185                             .build());
186             if (ops.size() >= BATCH_OPERATION_COUNT) {
187                 try {
188                     context.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
189                 } catch (RemoteException | OperationApplicationException e) {
190                     Log.e(TAG, "Failed to update channels.", e);
191                 }
192                 ops.clear();
193             }
194         }
195         try {
196             context.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
197         } catch (RemoteException | OperationApplicationException e) {
198             Log.e(TAG, "Failed to update channels.", e);
199         }
200     }
201 
202     @WorkerThread
queryPrograms( Context context, long channelId, long startTimeMs, long endTimeMs)203     private static List<Program> queryPrograms(
204             Context context, long channelId, long startTimeMs, long endTimeMs) {
205         String[] projection = ProgramImpl.PROJECTION;
206         if (TvProviderUtils.checkSeriesIdColumn(context, Programs.CONTENT_URI)) {
207             projection =
208                     TvProviderUtils.addExtraColumnsToProjection(
209                             projection, TvProviderUtils.EXTRA_PROGRAM_COLUMN_SERIES_ID);
210         }
211         try (Cursor c =
212                 context.getContentResolver()
213                         .query(
214                                 TvContract.buildProgramsUriForChannel(
215                                         channelId, startTimeMs, endTimeMs),
216                                 projection,
217                                 null,
218                                 null,
219                                 Programs.COLUMN_START_TIME_UTC_MILLIS)) {
220             if (c == null) {
221                 return Collections.emptyList();
222             }
223             ArrayList<Program> programs = new ArrayList<>();
224             while (c.moveToNext()) {
225                 programs.add(ProgramImpl.fromCursor(c));
226             }
227             return programs;
228         }
229     }
230 
231     /**
232      * Returns {@code true} if the {@code oldProgram} needs to be updated with the {@code
233      * newProgram}.
234      */
hasSameTitleAndOverlap(Program oldProgram, Program newProgram)235     private static boolean hasSameTitleAndOverlap(Program oldProgram, Program newProgram) {
236         // NOTE: Here, we update the old program if it has the same title and overlaps with the
237         // new program. The test logic is just an example and you can modify this. E.g. check
238         // whether the both programs have the same program ID if your EPG supports any ID for
239         // the programs.
240         return TextUtils.equals(oldProgram.getTitle(), newProgram.getTitle())
241                 && oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis()
242                 && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis();
243     }
244 
245     /**
246      * Sets the last known lineup ID into shared preferences for future usage. If channels are not
247      * re-scanned, EPG fetcher can directly use this value instead of checking the correct lineup ID
248      * every time when it needs to fetch EPG data.
249      */
250     @WorkerThread
setLastLineupId(Context context, String lineupId)251     static synchronized void setLastLineupId(Context context, String lineupId) {
252         if (DEBUG) {
253             if (lineupId == null) {
254                 Log.d(TAG, "Clear stored lineup id: " + sLastLineupId);
255             }
256         }
257         sLastLineupId = lineupId;
258         PreferenceManager.getDefaultSharedPreferences(context)
259                 .edit()
260                 .putString(KEY_LAST_LINEUP_ID, lineupId)
261                 .apply();
262     }
263 
264     /** Gets the last known lineup ID from shared preferences. */
getLastLineupId(Context context)265     static synchronized String getLastLineupId(Context context) {
266         if (sLastLineupId == null) {
267             sLastLineupId =
268                     PreferenceManager.getDefaultSharedPreferences(context)
269                             .getString(KEY_LAST_LINEUP_ID, null);
270         }
271         if (DEBUG) Log.d(TAG, "Last lineup is " + sLastLineupId);
272         return sLastLineupId;
273     }
274 
275     /**
276      * Sets the last updated timestamp of EPG data into shared preferences. If the EPG data is not
277      * out-dated, it's not necessary for EPG fetcher to fetch EPG again.
278      */
279     @WorkerThread
setLastEpgUpdatedTimestamp(Context context, long timestamp)280     static synchronized void setLastEpgUpdatedTimestamp(Context context, long timestamp) {
281         sLastEpgUpdatedTimestamp = timestamp;
282         PreferenceManager.getDefaultSharedPreferences(context)
283                 .edit()
284                 .putLong(KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp)
285                 .apply();
286     }
287 
288     /** Gets the last updated timestamp of EPG data. */
getLastEpgUpdatedTimestamp(Context context)289     static synchronized long getLastEpgUpdatedTimestamp(Context context) {
290         if (sLastEpgUpdatedTimestamp < 0) {
291             sLastEpgUpdatedTimestamp =
292                     PreferenceManager.getDefaultSharedPreferences(context)
293                             .getLong(KEY_LAST_UPDATED_EPG_TIMESTAMP, 0);
294         }
295         return sLastEpgUpdatedTimestamp;
296     }
297 }
298