• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 package com.android.tv.data;
2 
3 import android.content.Context;
4 import android.content.SharedPreferences;
5 import android.content.SharedPreferences.Editor;
6 import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
7 import android.os.AsyncTask;
8 import android.os.Handler;
9 import android.os.Looper;
10 import android.support.annotation.MainThread;
11 import android.support.annotation.NonNull;
12 import android.support.annotation.VisibleForTesting;
13 import android.util.Log;
14 
15 import com.android.tv.common.SharedPreferencesUtils;
16 
17 import java.util.ArrayList;
18 import java.util.Collections;
19 import java.util.HashMap;
20 import java.util.List;
21 import java.util.Map;
22 import java.util.Objects;
23 import java.util.Scanner;
24 import 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  */
32 public 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 
WatchedHistoryManager(Context context)87     public WatchedHistoryManager(Context context) {
88         this(context, MAX_HISTORY_SIZE);
89     }
90 
91     @VisibleForTesting
WatchedHistoryManager(Context context, int maxHistorySize)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      */
start()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
isLoaded()163     public boolean isLoaded() {
164         return mLoaded;
165     }
166 
167     /**
168      * Logs the record of the watched channel.
169      */
logChannelViewStop(Channel channel, long endTime, long duration)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      */
setListener(Listener listener)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
getWatchedHistory()203     public List<WatchedRecord> getWatchedHistory() {
204         return Collections.unmodifiableList(mWatchedHistory);
205     }
206 
207     /**
208      * Returns the list of recently watched channels.
209      */
buildRecentChannel(ChannelDataManager channelDataManager, int maxCount)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
getRecord(int reverseIndex)254     WatchedRecord getRecord(int reverseIndex) {
255         return mWatchedHistory.get(mWatchedHistory.size() - 1 - reverseIndex);
256     }
257 
258     @VisibleForTesting
getRecordFromSharedPreferences(int reverseIndex)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 
getSharedPreferencesKey(long index)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 
WatchedRecord(long channelId, long watchedStartTime, long duration)274         WatchedRecord(long channelId, long watchedStartTime, long duration) {
275             this.channelId = channelId;
276             this.watchedStartTime = watchedStartTime;
277             this.duration = duration;
278         }
279 
280         @Override
toString()281         public String toString() {
282             return "WatchedRecord: id=" + channelId + ",watchedStartTime=" + watchedStartTime
283                     + ",duration=" + duration;
284         }
285 
286         @Override
equals(Object o)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
hashCode()298         public int hashCode() {
299             return Objects.hash(channelId, watchedStartTime, duration);
300         }
301     }
302 
303     @VisibleForTesting
encode(WatchedRecord record)304     String encode(WatchedRecord record) {
305         return record.channelId + " " + record.watchedStartTime + " " + record.duration;
306     }
307 
308     @VisibleForTesting
decode(String encodedString)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          */
onLoadFinished()324         void onLoadFinished();
onNewRecordAdded(WatchedRecord watchedRecord)325         void onNewRecordAdded(WatchedRecord watchedRecord);
326     }
327 }
328