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
17package com.android.tv.data;
18
19import android.content.Context;
20import android.database.Cursor;
21import android.graphics.Bitmap.CompressFormat;
22import android.media.tv.TvContract;
23import android.media.tv.TvContract.Channels;
24import android.net.Uri;
25import android.os.AsyncTask;
26import android.support.annotation.WorkerThread;
27import android.text.TextUtils;
28import android.util.Log;
29
30import com.android.tv.util.AsyncDbTask;
31import com.android.tv.util.BitmapUtils;
32import com.android.tv.util.BitmapUtils.ScaledBitmapInfo;
33import com.android.tv.util.PermissionUtils;
34
35import java.io.BufferedReader;
36import java.io.IOException;
37import java.io.InputStreamReader;
38import java.io.OutputStream;
39import java.util.ArrayList;
40import java.util.HashMap;
41import java.util.HashSet;
42import java.util.List;
43import java.util.Locale;
44import java.util.Map;
45import java.util.Set;
46
47/**
48 * Utility class for TMS data.
49 * This class is thread safe.
50 */
51public 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     */
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     */
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
111    private ChannelLogoFetcher() {
112    }
113
114    private static final class LoadChannelTask extends AsyncDbTask<Void, Void, List<Channel>> {
115        private final Context mContext;
116
117        public LoadChannelTask(Context context) {
118            mContext = context;
119        }
120
121        @Override
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
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
172        public FetchLogoTask(Context context, List<Channel> channels) {
173            mContext = context;
174            mChannels = channels;
175        }
176
177        @Override
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
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
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
331        protected void onPostExecute(Void result) {
332            synchronized (sLock) {
333                if (sFetchTask == this) {
334                    sFetchTask = null;
335                }
336            }
337        }
338    }
339}
340