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