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