Home | History | Annotate | Download | only in data
      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