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