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