1 /* 2 * Copyright (C) 2015 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; 18 19 import android.content.Context; 20 import android.database.Cursor; 21 import android.graphics.Bitmap.CompressFormat; 22 import android.media.tv.TvContract; 23 import android.media.tv.TvContract.Channels; 24 import android.net.Uri; 25 import android.os.AsyncTask; 26 import android.support.annotation.WorkerThread; 27 import android.text.TextUtils; 28 import android.util.Log; 29 30 import com.android.tv.util.AsyncDbTask; 31 import com.android.tv.util.BitmapUtils; 32 import com.android.tv.util.BitmapUtils.ScaledBitmapInfo; 33 import com.android.tv.util.PermissionUtils; 34 35 import java.io.BufferedReader; 36 import java.io.IOException; 37 import java.io.InputStreamReader; 38 import java.io.OutputStream; 39 import java.util.ArrayList; 40 import java.util.HashMap; 41 import java.util.HashSet; 42 import java.util.List; 43 import java.util.Locale; 44 import java.util.Map; 45 import java.util.Set; 46 47 /** 48 * Utility class for TMS data. 49 * This class is thread safe. 50 */ 51 public class ChannelLogoFetcher { 52 private static final String TAG = "ChannelLogoFetcher"; 53 private static final boolean DEBUG = false; 54 55 /** 56 * The name of the file which contains the TMS data. 57 * The file has multiple records and each of them is a string separated by '|' like 58 * STATION_NAME|SHORT_NAME|CALL_SIGN|LOGO_URI. 59 */ 60 private static final String TMS_US_TABLE_FILE = "tms_us.table"; 61 private static final String TMS_KR_TABLE_FILE = "tms_kr.table"; 62 private static final String FIELD_SEPARATOR = "\\|"; 63 private static final String NAME_SEPARATOR_FOR_TMS = "\\(|\\)|\\{|\\}|\\[|\\]"; 64 private static final String NAME_SEPARATOR_FOR_DB = "\\W"; 65 private static final int INDEX_NAME = 0; 66 private static final int INDEX_SHORT_NAME = 1; 67 private static final int INDEX_CALL_SIGN = 2; 68 private static final int INDEX_LOGO_URI = 3; 69 70 private static final String COLUMN_CHANNEL_LOGO = "logo"; 71 72 private static final Object sLock = new Object(); 73 private static final Set<Long> sChannelIdBlackListSet = new HashSet<>(); 74 private static LoadChannelTask sQueryTask; 75 private static FetchLogoTask sFetchTask; 76 77 /** 78 * Fetch the channel logos from TMS data and insert them into TvProvider. 79 * The previous task is canceled and a new task starts. 80 */ startFetchingChannelLogos(Context context)81 public static void startFetchingChannelLogos(Context context) { 82 if (!PermissionUtils.hasAccessAllEpg(context)) { 83 // TODO: support this feature for non-system LC app. b/23939816 84 return; 85 } 86 synchronized (sLock) { 87 stopFetchingChannelLogos(); 88 if (DEBUG) Log.d(TAG, "Request to start fetching logos."); 89 sQueryTask = new LoadChannelTask(context); 90 sQueryTask.executeOnDbThread(); 91 } 92 } 93 94 /** 95 * Stops the current fetching tasks. This can be called when the Activity pauses. 96 */ stopFetchingChannelLogos()97 public static void stopFetchingChannelLogos() { 98 synchronized (sLock) { 99 if (DEBUG) Log.d(TAG, "Request to stop fetching logos."); 100 if (sQueryTask != null) { 101 sQueryTask.cancel(true); 102 sQueryTask = null; 103 } 104 if (sFetchTask != null) { 105 sFetchTask.cancel(true); 106 sFetchTask = null; 107 } 108 } 109 } 110 ChannelLogoFetcher()111 private ChannelLogoFetcher() { 112 } 113 114 private static final class LoadChannelTask extends AsyncDbTask<Void, Void, List<Channel>> { 115 private final Context mContext; 116 LoadChannelTask(Context context)117 public LoadChannelTask(Context context) { 118 mContext = context; 119 } 120 121 @Override doInBackground(Void... arg)122 protected List<Channel> doInBackground(Void... arg) { 123 // Load channels which doesn't have channel logos. 124 if (DEBUG) Log.d(TAG, "Starts loading the channels from DB"); 125 String[] projection = 126 new String[] { Channels._ID, Channels.COLUMN_DISPLAY_NAME }; 127 String selection = COLUMN_CHANNEL_LOGO + " IS NULL AND " 128 + Channels.COLUMN_PACKAGE_NAME + "=?"; 129 String[] selectionArgs = new String[] { mContext.getPackageName() }; 130 try (Cursor c = mContext.getContentResolver().query(Channels.CONTENT_URI, 131 projection, selection, selectionArgs, null)) { 132 if (c == null) { 133 Log.e(TAG, "Query returns null cursor", new RuntimeException()); 134 return null; 135 } 136 List<Channel> channels = new ArrayList<>(); 137 while (!isCancelled() && c.moveToNext()) { 138 long channelId = c.getLong(0); 139 if (sChannelIdBlackListSet.contains(channelId)) { 140 continue; 141 } 142 channels.add(new Channel.Builder().setId(c.getLong(0)) 143 .setDisplayName(c.getString(1).toUpperCase(Locale.getDefault())) 144 .build()); 145 } 146 return channels; 147 } 148 } 149 150 @Override onPostExecute(List<Channel> channels)151 protected void onPostExecute(List<Channel> channels) { 152 synchronized (sLock) { 153 if (DEBUG) { 154 int count = channels == null ? 0 : channels.size(); 155 Log.d(TAG, count + " channels are loaded"); 156 } 157 if (sQueryTask == this) { 158 sQueryTask = null; 159 if (channels != null && !channels.isEmpty()) { 160 sFetchTask = new FetchLogoTask(mContext, channels); 161 sFetchTask.execute(); 162 } 163 } 164 } 165 } 166 } 167 168 private static final class FetchLogoTask extends AsyncTask<Void, Void, Void> { 169 private final Context mContext; 170 private final List<Channel> mChannels; 171 FetchLogoTask(Context context, List<Channel> channels)172 public FetchLogoTask(Context context, List<Channel> channels) { 173 mContext = context; 174 mChannels = channels; 175 } 176 177 @Override doInBackground(Void... arg)178 protected Void doInBackground(Void... arg) { 179 if (isCancelled()) { 180 if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); 181 return null; 182 } 183 // Load the TMS table data. 184 if (DEBUG) Log.d(TAG, "Loads TMS data"); 185 Map<String, String> channelNameLogoUriMap = new HashMap<>(); 186 try { 187 channelNameLogoUriMap.putAll(readTmsFile(mContext, TMS_US_TABLE_FILE)); 188 if (isCancelled()) { 189 if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); 190 return null; 191 } 192 channelNameLogoUriMap.putAll(readTmsFile(mContext, TMS_KR_TABLE_FILE)); 193 } catch (IOException e) { 194 Log.e(TAG, "Loading TMS data failed.", e); 195 return null; 196 } 197 if (isCancelled()) { 198 if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); 199 return null; 200 } 201 202 // Iterating channels. 203 for (Channel channel : mChannels) { 204 if (isCancelled()) { 205 if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); 206 return null; 207 } 208 // Download the channel logo. 209 if (TextUtils.isEmpty(channel.getDisplayName())) { 210 if (DEBUG) { 211 Log.d(TAG, "The channel with ID (" + channel.getId() 212 + ") doesn't have the display name."); 213 } 214 sChannelIdBlackListSet.add(channel.getId()); 215 continue; 216 } 217 String channelName = channel.getDisplayName().trim(); 218 String logoUri = channelNameLogoUriMap.get(channelName); 219 if (TextUtils.isEmpty(logoUri)) { 220 if (DEBUG) { 221 Log.d(TAG, "Can't find a logo URI for channel '" + channelName + "'"); 222 } 223 // Find the candidate names. If the channel name is CNN-HD, then find CNNHD 224 // and CNN. Or if the channel name is KQED+, then find KQED. 225 String[] splitNames = channelName.split(NAME_SEPARATOR_FOR_DB); 226 if (splitNames.length > 1) { 227 StringBuilder sb = new StringBuilder(); 228 for (String splitName : splitNames) { 229 sb.append(splitName); 230 } 231 logoUri = channelNameLogoUriMap.get(sb.toString()); 232 if (DEBUG) { 233 if (TextUtils.isEmpty(logoUri)) { 234 Log.d(TAG, "Can't find a logo URI for channel '" + sb.toString() 235 + "'"); 236 } 237 } 238 } 239 if (TextUtils.isEmpty(logoUri) 240 && splitNames[0].length() != channelName.length()) { 241 logoUri = channelNameLogoUriMap.get(splitNames[0]); 242 if (DEBUG) { 243 if (TextUtils.isEmpty(logoUri)) { 244 Log.d(TAG, "Can't find a logo URI for channel '" + splitNames[0] 245 + "'"); 246 } 247 } 248 } 249 } 250 if (TextUtils.isEmpty(logoUri)) { 251 sChannelIdBlackListSet.add(channel.getId()); 252 continue; 253 } 254 ScaledBitmapInfo bitmapInfo = BitmapUtils.decodeSampledBitmapFromUriString( 255 mContext, logoUri, Integer.MAX_VALUE, Integer.MAX_VALUE); 256 if (bitmapInfo == null) { 257 Log.e(TAG, "Failed to load bitmap. {channelName=" + channel.getDisplayName() 258 + ", " + "logoUri=" + logoUri + "}"); 259 sChannelIdBlackListSet.add(channel.getId()); 260 continue; 261 } 262 if (isCancelled()) { 263 if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); 264 return null; 265 } 266 267 // Insert the logo to DB. 268 Uri dstLogoUri = TvContract.buildChannelLogoUri(channel.getId()); 269 try (OutputStream os = mContext.getContentResolver().openOutputStream(dstLogoUri)) { 270 bitmapInfo.bitmap.compress(CompressFormat.PNG, 100, os); 271 } catch (IOException e) { 272 Log.e(TAG, "Failed to write " + logoUri + " to " + dstLogoUri, e); 273 continue; 274 } 275 if (DEBUG) { 276 Log.d(TAG, "Inserting logo file to DB succeeded. {from=" + logoUri + ", to=" 277 + dstLogoUri + "}"); 278 } 279 } 280 if (DEBUG) Log.d(TAG, "Fetching logos has been finished successfully."); 281 return null; 282 } 283 284 @WorkerThread readTmsFile(Context context, String fileName)285 private Map<String, String> readTmsFile(Context context, String fileName) 286 throws IOException { 287 try (BufferedReader reader = new BufferedReader(new InputStreamReader( 288 context.getAssets().open(fileName)))) { 289 Map<String, String> channelNameLogoUriMap = new HashMap<>(); 290 String line; 291 while ((line = reader.readLine()) != null && !isCancelled()) { 292 String[] data = line.split(FIELD_SEPARATOR); 293 if (data.length != INDEX_LOGO_URI + 1) { 294 if (DEBUG) Log.d(TAG, "Invalid or comment row: " + line); 295 continue; 296 } 297 addChannelNames(channelNameLogoUriMap, 298 data[INDEX_NAME].toUpperCase(Locale.getDefault()), 299 data[INDEX_LOGO_URI]); 300 addChannelNames(channelNameLogoUriMap, 301 data[INDEX_SHORT_NAME].toUpperCase(Locale.getDefault()), 302 data[INDEX_LOGO_URI]); 303 addChannelNames(channelNameLogoUriMap, 304 data[INDEX_CALL_SIGN].toUpperCase(Locale.getDefault()), 305 data[INDEX_LOGO_URI]); 306 } 307 return channelNameLogoUriMap; 308 } 309 } 310 addChannelNames(Map<String, String> channelNameLogoUriMap, String channelName, String logoUri)311 private void addChannelNames(Map<String, String> channelNameLogoUriMap, String channelName, 312 String logoUri) { 313 if (!TextUtils.isEmpty(channelName)) { 314 channelNameLogoUriMap.put(channelName, logoUri); 315 // Find the candidate names. 316 // If the name is like "W05AAD (W05AA-D)", then split the names into "W05AAD" and 317 // "W05AA-D" 318 String[] splitNames = channelName.split(NAME_SEPARATOR_FOR_TMS); 319 if (splitNames.length > 1) { 320 for (String name : splitNames) { 321 name = name.trim(); 322 if (channelNameLogoUriMap.get(name) == null) { 323 channelNameLogoUriMap.put(name, logoUri); 324 } 325 } 326 } 327 } 328 } 329 330 @Override onPostExecute(Void result)331 protected void onPostExecute(Void result) { 332 synchronized (sLock) { 333 if (sFetchTask == this) { 334 sFetchTask = null; 335 } 336 } 337 } 338 } 339 } 340