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 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