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