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