1package com.android.tv.data;
2
3import android.content.Context;
4import android.content.SharedPreferences;
5import android.content.SharedPreferences.Editor;
6import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
7import android.os.AsyncTask;
8import android.os.Handler;
9import android.os.Looper;
10import android.support.annotation.MainThread;
11import android.support.annotation.NonNull;
12import android.support.annotation.VisibleForTesting;
13import android.util.Log;
14
15import com.android.tv.common.SharedPreferencesUtils;
16
17import java.util.ArrayList;
18import java.util.Collections;
19import java.util.HashMap;
20import java.util.List;
21import java.util.Map;
22import java.util.Objects;
23import java.util.Scanner;
24import java.util.concurrent.TimeUnit;
25
26/**
27 * A class to manage watched history.
28 *
29 * <p>When there is no access to watched table of TvProvider,
30 * this class is used to build up watched history and to compute recent channels.
31 */
32public class WatchedHistoryManager {
33    private final static String TAG = "WatchedHistoryManager";
34    private final boolean DEBUG = false;
35
36    private static final int MAX_HISTORY_SIZE = 10000;
37    private static final String PREF_KEY_LAST_INDEX = "last_index";
38    private static final long MIN_DURATION_MS = TimeUnit.SECONDS.toMillis(10);
39    private static final long RECENT_CHANNEL_THRESHOLD_MS = TimeUnit.MINUTES.toMillis(5);
40
41    private final List<WatchedRecord> mWatchedHistory = new ArrayList<>();
42    private final List<WatchedRecord> mPendingRecords = new ArrayList<>();
43    private long mLastIndex;
44    private boolean mStarted;
45    private boolean mLoaded;
46    private SharedPreferences mSharedPreferences;
47    private final OnSharedPreferenceChangeListener mOnSharedPreferenceChangeListener =
48            new OnSharedPreferenceChangeListener() {
49                @Override
50                @MainThread
51                public void onSharedPreferenceChanged(SharedPreferences sharedPreferences,
52                        String key) {
53                    if (key.equals(PREF_KEY_LAST_INDEX)) {
54                        final long lastIndex = mSharedPreferences.getLong(PREF_KEY_LAST_INDEX, -1);
55                        if (lastIndex <= mLastIndex) {
56                            return;
57                        }
58                        // onSharedPreferenceChanged is always called in a main thread.
59                        // onNewRecordAdded will be called in the same thread as the thread
60                        // which created this instance.
61                        mHandler.post(new Runnable() {
62                            @Override
63                            public void run() {
64                                for (long i = mLastIndex + 1; i <= lastIndex; ++i) {
65                                    WatchedRecord record = decode(
66                                            mSharedPreferences.getString(getSharedPreferencesKey(i),
67                                                    null));
68                                    if (record != null) {
69                                        mWatchedHistory.add(record);
70                                        if (mListener != null) {
71                                            mListener.onNewRecordAdded(record);
72                                        }
73                                    }
74                                }
75                                mLastIndex = lastIndex;
76                            }
77                        });
78                    }
79                }
80            };
81
82    private final Context mContext;
83    private Listener mListener;
84    private final int mMaxHistorySize;
85    private final Handler mHandler;
86
87    public WatchedHistoryManager(Context context) {
88        this(context, MAX_HISTORY_SIZE);
89    }
90
91    @VisibleForTesting
92    WatchedHistoryManager(Context context, int maxHistorySize) {
93        mContext = context.getApplicationContext();
94        mMaxHistorySize = maxHistorySize;
95        if (Looper.myLooper() == null) {
96            mHandler = new Handler(Looper.getMainLooper());
97        } else {
98            mHandler = new Handler();
99        }
100    }
101
102    /**
103     * Starts the manager. It loads history data from {@link SharedPreferences}.
104     */
105    public void start() {
106        if (mStarted) {
107            return;
108        }
109        mStarted = true;
110        new AsyncTask<Void, Void, Void>() {
111            @Override
112            protected Void doInBackground(Void... params) {
113                mSharedPreferences = mContext.getSharedPreferences(
114                        SharedPreferencesUtils.SHARED_PREF_WATCHED_HISTORY, Context.MODE_PRIVATE);
115                mLastIndex = mSharedPreferences.getLong(PREF_KEY_LAST_INDEX, -1);
116                if (mLastIndex >= 0 && mLastIndex < mMaxHistorySize) {
117                    for (int i = 0; i <= mLastIndex; ++i) {
118                        WatchedRecord record =
119                                decode(mSharedPreferences.getString(getSharedPreferencesKey(i),
120                                        null));
121                        if (record != null) {
122                            mWatchedHistory.add(record);
123                        }
124                    }
125                } else if (mLastIndex >= mMaxHistorySize) {
126                    for (long i = mLastIndex - mMaxHistorySize + 1; i <= mLastIndex; ++i) {
127                        WatchedRecord record = decode(mSharedPreferences.getString(
128                                getSharedPreferencesKey(i), null));
129                        if (record != null) {
130                            mWatchedHistory.add(record);
131                        }
132                    }
133                }
134                return null;
135            }
136
137            @Override
138            protected void onPostExecute(Void params) {
139                mLoaded = true;
140                if (DEBUG) {
141                    Log.d(TAG, "Loaded: size=" + mWatchedHistory.size() + " index=" + mLastIndex);
142                }
143                if (!mPendingRecords.isEmpty()) {
144                    Editor editor = mSharedPreferences.edit();
145                    for (WatchedRecord record : mPendingRecords) {
146                        mWatchedHistory.add(record);
147                        ++mLastIndex;
148                        editor.putString(getSharedPreferencesKey(mLastIndex), encode(record));
149                    }
150                    editor.putLong(PREF_KEY_LAST_INDEX, mLastIndex).apply();
151                    mPendingRecords.clear();
152                }
153                if (mListener != null) {
154                    mListener.onLoadFinished();
155                }
156                mSharedPreferences.registerOnSharedPreferenceChangeListener(
157                        mOnSharedPreferenceChangeListener);
158            }
159        }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
160    }
161
162    @VisibleForTesting
163    public boolean isLoaded() {
164        return mLoaded;
165    }
166
167    /**
168     * Logs the record of the watched channel.
169     */
170    public void logChannelViewStop(Channel channel, long endTime, long duration) {
171        if (duration < MIN_DURATION_MS) {
172            return;
173        }
174        WatchedRecord record = new WatchedRecord(channel.getId(), endTime - duration, duration);
175        if (mLoaded) {
176            if (DEBUG) Log.d(TAG, "Log a watched record. " + record);
177            mWatchedHistory.add(record);
178            ++mLastIndex;
179            mSharedPreferences.edit()
180                    .putString(getSharedPreferencesKey(mLastIndex), encode(record))
181                    .putLong(PREF_KEY_LAST_INDEX, mLastIndex)
182                    .apply();
183            if (mListener != null) {
184                mListener.onNewRecordAdded(record);
185            }
186        } else {
187            mPendingRecords.add(record);
188        }
189    }
190
191    /**
192     * Sets {@link Listener}.
193     */
194    public void setListener(Listener listener) {
195        mListener = listener;
196    }
197
198    /**
199     * Returns watched history in the ascending order of time. In other words, the first element
200     * is the oldest and the last element is the latest record.
201     */
202    @NonNull
203    public List<WatchedRecord> getWatchedHistory() {
204        return Collections.unmodifiableList(mWatchedHistory);
205    }
206
207    /**
208     * Returns the list of recently watched channels.
209     */
210    public List<Channel> buildRecentChannel(ChannelDataManager channelDataManager, int maxCount) {
211        List<Channel> list = new ArrayList<>();
212        Map<Long, Long> durationMap = new HashMap<>();
213        for (int i = mWatchedHistory.size() - 1; i >= 0; --i) {
214            WatchedRecord record = mWatchedHistory.get(i);
215            long channelId = record.channelId;
216            Channel channel = channelDataManager.getChannel(channelId);
217            if (channel == null || !channel.isBrowsable()) {
218                continue;
219            }
220            Long duration = durationMap.get(channelId);
221            if (duration == null) {
222                duration = 0l;
223            }
224            if (duration >= RECENT_CHANNEL_THRESHOLD_MS) {
225                continue;
226            }
227            if (list.isEmpty()) {
228                // We put the first recent channel regardless of RECENT_CHANNEL_THREASHOLD.
229                // It has the similar functionality as the previous channel in a usual remote
230                // controller.
231                list.add(channel);
232                durationMap.put(channelId, RECENT_CHANNEL_THRESHOLD_MS);
233            } else {
234                duration += record.duration;
235                durationMap.put(channelId, duration);
236                if (duration >= RECENT_CHANNEL_THRESHOLD_MS) {
237                    list.add(channel);
238                }
239            }
240            if (list.size() >= maxCount) {
241                break;
242            }
243        }
244        if (DEBUG) {
245            Log.d(TAG, "Build recent channel");
246            for (Channel channel : list) {
247                Log.d(TAG, "recent channel: " + channel);
248            }
249        }
250        return list;
251    }
252
253    @VisibleForTesting
254    WatchedRecord getRecord(int reverseIndex) {
255        return mWatchedHistory.get(mWatchedHistory.size() - 1 - reverseIndex);
256    }
257
258    @VisibleForTesting
259    WatchedRecord getRecordFromSharedPreferences(int reverseIndex) {
260        long lastIndex = mSharedPreferences.getLong(PREF_KEY_LAST_INDEX, -1);
261        long index = lastIndex - reverseIndex;
262        return decode(mSharedPreferences.getString(getSharedPreferencesKey(index), null));
263    }
264
265    private String getSharedPreferencesKey(long index) {
266        return Long.toString(index % mMaxHistorySize);
267    }
268
269    public static class WatchedRecord {
270        public final long channelId;
271        public final long watchedStartTime;
272        public final long duration;
273
274        WatchedRecord(long channelId, long watchedStartTime, long duration) {
275            this.channelId = channelId;
276            this.watchedStartTime = watchedStartTime;
277            this.duration = duration;
278        }
279
280        @Override
281        public String toString() {
282            return "WatchedRecord: id=" + channelId + ",watchedStartTime=" + watchedStartTime
283                    + ",duration=" + duration;
284        }
285
286        @Override
287        public boolean equals(Object o) {
288            if (o instanceof WatchedRecord) {
289                WatchedRecord that = (WatchedRecord) o;
290                return Objects.equals(channelId, that.channelId)
291                        && Objects.equals(watchedStartTime, that.watchedStartTime)
292                        && Objects.equals(duration, that.duration);
293            }
294            return false;
295        }
296
297        @Override
298        public int hashCode() {
299            return Objects.hash(channelId, watchedStartTime, duration);
300        }
301    }
302
303    @VisibleForTesting
304    String encode(WatchedRecord record) {
305        return record.channelId + " " + record.watchedStartTime + " " + record.duration;
306    }
307
308    @VisibleForTesting
309    WatchedRecord decode(String encodedString) {
310        try (Scanner scanner = new Scanner(encodedString)) {
311            long channelId = scanner.nextLong();
312            long watchedStartTime = scanner.nextLong();
313            long duration = scanner.nextLong();
314            return new WatchedRecord(channelId, watchedStartTime, duration);
315        } catch (Exception e) {
316            return null;
317        }
318    }
319
320    public interface Listener {
321        /**
322         * Called when history is loaded.
323         */
324        void onLoadFinished();
325        void onNewRecordAdded(WatchedRecord watchedRecord);
326    }
327}
328