1/*
2 * Copyright (C) 2015 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
17package com.android.tv.recommendation;
18
19import android.content.Context;
20import android.support.annotation.VisibleForTesting;
21import android.util.Log;
22import android.util.Pair;
23
24import com.android.tv.data.Channel;
25
26import java.util.ArrayList;
27import java.util.Collection;
28import java.util.Collections;
29import java.util.Comparator;
30import java.util.HashMap;
31import java.util.List;
32import java.util.Map;
33import java.util.concurrent.TimeUnit;
34
35public class Recommender implements RecommendationDataManager.Listener {
36    private static final String TAG = "Recommender";
37
38    @VisibleForTesting
39    static final String INVALID_CHANNEL_SORT_KEY = "INVALID";
40    private static final long MINIMUM_RECOMMENDATION_UPDATE_PERIOD = TimeUnit.MINUTES.toMillis(5);
41    private static final Comparator<Pair<Channel, Double>> mChannelScoreComparator =
42            new Comparator<Pair<Channel, Double>>() {
43                @Override
44                public int compare(Pair<Channel, Double> lhs, Pair<Channel, Double> rhs) {
45                    // Sort the scores with descending order.
46                    return rhs.second.compareTo(lhs.second);
47                }
48            };
49
50    private final List<EvaluatorWrapper> mEvaluators = new ArrayList<>();
51    private final boolean mIncludeRecommendedOnly;
52    private final Listener mListener;
53
54    private final Map<Long, String> mChannelSortKey = new HashMap<>();
55    private final RecommendationDataManager mDataManager;
56    private List<Channel> mPreviousRecommendedChannels = new ArrayList<>();
57    private long mLastRecommendationUpdatedTimeUtcMillis;
58    private boolean mChannelRecordLoaded;
59
60    /**
61     * Create a recommender object.
62     *
63     * @param includeRecommendedOnly true to include only recommended results, or false.
64     */
65    public Recommender(Context context, Listener listener, boolean includeRecommendedOnly) {
66        mListener = listener;
67        mIncludeRecommendedOnly = includeRecommendedOnly;
68        mDataManager = RecommendationDataManager.acquireManager(context, this);
69    }
70
71    @VisibleForTesting
72    Recommender(Listener listener, boolean includeRecommendedOnly,
73            RecommendationDataManager dataManager) {
74        mListener = listener;
75        mIncludeRecommendedOnly = includeRecommendedOnly;
76        mDataManager = dataManager;
77    }
78
79    public boolean isReady() {
80        return mChannelRecordLoaded;
81    }
82
83    public void release() {
84        mDataManager.release(this);
85    }
86
87    public void registerEvaluator(Evaluator evaluator) {
88        registerEvaluator(evaluator,
89                EvaluatorWrapper.DEFAULT_BASE_SCORE, EvaluatorWrapper.DEFAULT_WEIGHT);
90    }
91
92    /**
93     * Register the evaluator used in recommendation.
94     *
95     * The range of evaluated scores by this evaluator will be between {@code baseScore} and
96     * {@code baseScore} + {@code weight} (inclusive).
97
98     * @param evaluator The evaluator to register inside this recommender.
99     * @param baseScore Base(Minimum) score of the score evaluated by {@code evaluator}.
100     * @param weight Weight value to rearrange the score evaluated by {@code evaluator}.
101     */
102    public void registerEvaluator(Evaluator evaluator, double baseScore, double weight) {
103        mEvaluators.add(new EvaluatorWrapper(this, evaluator, baseScore, weight));
104    }
105
106    public List<Channel> recommendChannels() {
107        return recommendChannels(mDataManager.getChannelRecordCount());
108    }
109
110    /**
111     * Return the channel list of recommendation up to {@code n} or the number of channels.
112     * During the evaluation, this method updates the channel sort key of recommended channels.
113     *
114     * @param size The number of channels that might be recommended.
115     * @return Top {@code size} channels recommended sorted by score in descending order. If
116     *         {@code size} is bigger than the number of channels, the number of results could
117     *         be less than {@code size}.
118     */
119    public List<Channel> recommendChannels(int size) {
120        List<Pair<Channel, Double>> records = new ArrayList<>();
121        Collection<ChannelRecord> channelRecordList = mDataManager.getChannelRecords();
122        for (ChannelRecord cr : channelRecordList) {
123            double maxScore = Evaluator.NOT_RECOMMENDED;
124            for (EvaluatorWrapper evaluator : mEvaluators) {
125                double score = evaluator.getScaledEvaluatorScore(cr.getChannel().getId());
126                if (score > maxScore) {
127                    maxScore = score;
128                }
129            }
130            if (!mIncludeRecommendedOnly || maxScore != Evaluator.NOT_RECOMMENDED) {
131                records.add(new Pair<>(cr.getChannel(), maxScore));
132            }
133        }
134        if (size > records.size()) {
135            size = records.size();
136        }
137        Collections.sort(records, mChannelScoreComparator);
138
139        List<Channel> results = new ArrayList<>();
140
141        mChannelSortKey.clear();
142        String sortKeyFormat = "%0" + String.valueOf(size).length() + "d";
143        for (int i = 0; i < size; ++i) {
144            // Channel with smaller sort key has higher priority.
145            mChannelSortKey.put(records.get(i).first.getId(), String.format(sortKeyFormat, i));
146            results.add(records.get(i).first);
147        }
148        return results;
149    }
150
151    /**
152     * Returns the {@link Channel} object for a given channel ID from the channel pool that this
153     * recommendation engine has.
154     *
155     * @param channelId The channel ID to retrieve the {@link Channel} object for.
156     * @return the {@link Channel} object for the given channel ID, {@code null} if such a channel
157     *         is not found.
158     */
159    public Channel getChannel(long channelId) {
160        ChannelRecord record = mDataManager.getChannelRecord(channelId);
161        return record == null ? null : record.getChannel();
162    }
163
164    /**
165     * Returns the {@link ChannelRecord} object for a given channel ID.
166     *
167     * @param channelId The channel ID to receive the {@link ChannelRecord} object for.
168     * @return the {@link ChannelRecord} object for the given channel ID.
169     */
170    public ChannelRecord getChannelRecord(long channelId) {
171        return mDataManager.getChannelRecord(channelId);
172    }
173
174    /**
175     * Returns the sort key of a given channel Id. Sort key is determined in
176     * {@link #recommendChannels()} and getChannelSortKey must be called after that.
177     *
178     * If getChannelSortKey was called before evaluating the channels or trying to get sort key
179     * of non-recommended channel, it returns {@link #INVALID_CHANNEL_SORT_KEY}.
180     */
181    public String getChannelSortKey(long channelId) {
182        String key = mChannelSortKey.get(channelId);
183        return key == null ? INVALID_CHANNEL_SORT_KEY : key;
184    }
185
186    @Override
187    public void onChannelRecordLoaded() {
188        mChannelRecordLoaded = true;
189        mListener.onRecommenderReady();
190        List<ChannelRecord> channels = new ArrayList<>(mDataManager.getChannelRecords());
191        for (EvaluatorWrapper evaluator : mEvaluators) {
192            evaluator.onChannelListChanged(Collections.unmodifiableList(channels));
193        }
194    }
195
196    @Override
197    public void onNewWatchLog(ChannelRecord channelRecord) {
198        for (EvaluatorWrapper evaluator : mEvaluators) {
199            evaluator.onNewWatchLog(channelRecord);
200        }
201        checkRecommendationChanged();
202    }
203
204    @Override
205    public void onChannelRecordChanged() {
206        if (mChannelRecordLoaded) {
207            List<ChannelRecord> channels = new ArrayList<>(mDataManager.getChannelRecords());
208            for (EvaluatorWrapper evaluator : mEvaluators) {
209                evaluator.onChannelListChanged(Collections.unmodifiableList(channels));
210            }
211        }
212        checkRecommendationChanged();
213    }
214
215    private void checkRecommendationChanged() {
216        long currentTimeUtcMillis = System.currentTimeMillis();
217        if (currentTimeUtcMillis - mLastRecommendationUpdatedTimeUtcMillis
218                < MINIMUM_RECOMMENDATION_UPDATE_PERIOD) {
219            return;
220        }
221        mLastRecommendationUpdatedTimeUtcMillis = currentTimeUtcMillis;
222        List<Channel> recommendedChannels = recommendChannels();
223        if (!recommendedChannels.equals(mPreviousRecommendedChannels)) {
224            mPreviousRecommendedChannels = recommendedChannels;
225            mListener.onRecommendationChanged();
226        }
227    }
228
229    @VisibleForTesting
230    void setLastRecommendationUpdatedTimeUtcMs(long newUpdatedTimeMs) {
231        mLastRecommendationUpdatedTimeUtcMillis = newUpdatedTimeMs;
232    }
233
234    public static abstract class Evaluator {
235        public static final double NOT_RECOMMENDED = -1.0;
236        private Recommender mRecommender;
237
238        protected Evaluator() {}
239
240        protected void onChannelRecordListChanged(List<ChannelRecord> channelRecords) {
241        }
242
243        /**
244         * This will be called when a new watch log comes into WatchedPrograms table.
245         *
246         * @param channelRecord The channel record corresponds to the new watch log.
247         */
248        protected void onNewWatchLog(ChannelRecord channelRecord) {
249        }
250
251        /**
252         * The implementation should return the recommendation score for the given channel ID.
253         * The return value should be in the range of [0.0, 1.0] or NOT_RECOMMENDED for denoting
254         * that it gives up to calculate the score for the channel.
255         *
256         * @param channelId The channel ID which will be evaluated by this recommender.
257         * @return The recommendation score
258         */
259        protected abstract double evaluateChannel(final long channelId);
260
261        protected void setRecommender(Recommender recommender) {
262            mRecommender = recommender;
263        }
264
265        protected Recommender getRecommender() {
266            return mRecommender;
267        }
268    }
269
270    private static class EvaluatorWrapper {
271        private static final double DEFAULT_BASE_SCORE = 0.0;
272        private static final double DEFAULT_WEIGHT = 1.0;
273
274        private final Evaluator mEvaluator;
275        // The minimum score of the Recommender unless it gives up to provide the score.
276        private final double mBaseScore;
277        // The weight of the recommender. The return-value of getScore() will be multiplied by
278        // this value.
279        private final double mWeight;
280
281        public EvaluatorWrapper(Recommender recommender, Evaluator evaluator,
282                double baseScore, double weight) {
283            mEvaluator = evaluator;
284            evaluator.setRecommender(recommender);
285            mBaseScore = baseScore;
286            mWeight = weight;
287        }
288
289        /**
290         * This returns the scaled score for the given channel ID based on the returned value
291         * of evaluateChannel().
292         *
293         * @param channelId The channel ID which will be evaluated by the recommender.
294         * @return Returns the scaled score (mBaseScore + score * mWeight) when evaluateChannel() is
295         *         in the range of [0.0, 1.0]. If evaluateChannel() returns NOT_RECOMMENDED or any
296         *         negative numbers, it returns NOT_RECOMMENDED. If calculateScore() returns more
297         *         than 1.0, it returns (mBaseScore + mWeight).
298         */
299        private double getScaledEvaluatorScore(long channelId) {
300            double score = mEvaluator.evaluateChannel(channelId);
301            if (score < 0.0) {
302                if (score != Evaluator.NOT_RECOMMENDED) {
303                    Log.w(TAG, "Unexpected score (" + score + ") from the recommender"
304                            + mEvaluator);
305                }
306                // If the recommender gives up to calculate the score, return 0.0
307                return Evaluator.NOT_RECOMMENDED;
308            } else if (score > 1.0) {
309                Log.w(TAG, "Unexpected score (" + score + ") from the recommender"
310                        + mEvaluator);
311                score = 1.0;
312            }
313            return mBaseScore + score * mWeight;
314        }
315
316        public void onNewWatchLog(ChannelRecord channelRecord) {
317            mEvaluator.onNewWatchLog(channelRecord);
318        }
319
320        public void onChannelListChanged(List<ChannelRecord> channelRecords) {
321            mEvaluator.onChannelRecordListChanged(channelRecords);
322        }
323    }
324
325    public interface Listener {
326        /**
327         * Called after channel record map is loaded.
328         */
329        void onRecommenderReady();
330
331        /**
332         * Called when the recommendation changes.
333         */
334        void onRecommendationChanged();
335    }
336}
337