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