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