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