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.data;
18
19import android.content.Context;
20import android.database.ContentObserver;
21import android.database.Cursor;
22import android.media.tv.TvContract;
23import android.net.Uri;
24import android.os.HandlerThread;
25import android.test.AndroidTestCase;
26import android.test.mock.MockContentProvider;
27import android.test.mock.MockContentResolver;
28import android.test.mock.MockCursor;
29import android.test.suitebuilder.annotation.SmallTest;
30import android.util.Log;
31import android.util.SparseArray;
32
33import com.android.tv.testing.Constants;
34import com.android.tv.testing.ProgramInfo;
35import com.android.tv.testing.FakeClock;
36import com.android.tv.util.Utils;
37
38import java.util.ArrayList;
39import java.util.Arrays;
40import java.util.List;
41import java.util.concurrent.CountDownLatch;
42import java.util.concurrent.TimeUnit;
43
44/**
45 * Test for {@link com.android.tv.data.ProgramDataManager}
46 */
47@SmallTest
48public class ProgramDataManagerTest extends AndroidTestCase {
49    private static final boolean DEBUG = false;
50    private static final String TAG = "ProgramDataManagerTest";
51
52    // Wait time for expected success.
53    private static final long WAIT_TIME_OUT_MS = 1000L;
54    // Wait time for expected failure.
55    private static final long FAILURE_TIME_OUT_MS = 300L;
56
57    // TODO: Use TvContract constants, once they become public.
58    private static final String PARAM_CHANNEL = "channel";
59    private static final String PARAM_START_TIME = "start_time";
60    private static final String PARAM_END_TIME = "end_time";
61
62    private ProgramDataManager mProgramDataManager;
63    private FakeClock mClock;
64    private HandlerThread mHandlerThread;
65    private TestProgramDataManagerListener mListener;
66    private FakeContentResolver mContentResolver;
67    private FakeContentProvider mContentProvider;
68
69    @Override
70    protected void setUp() throws Exception {
71        super.setUp();
72
73        mClock = FakeClock.createWithCurrentTime();
74        mListener = new TestProgramDataManagerListener();
75        mContentProvider = new FakeContentProvider(getContext());
76        mContentResolver = new FakeContentResolver();
77        mContentResolver.addProvider(TvContract.AUTHORITY, mContentProvider);
78        mHandlerThread = new HandlerThread(TAG);
79        mHandlerThread.start();
80        mProgramDataManager = new ProgramDataManager(
81                mContentResolver, mClock, mHandlerThread.getLooper());
82        mProgramDataManager.setPrefetchEnabled(true);
83        mProgramDataManager.addListener(mListener);
84    }
85
86    @Override
87    protected void tearDown() throws Exception {
88        super.tearDown();
89        mHandlerThread.quitSafely();
90        mProgramDataManager.stop();
91    }
92
93    private void startAndWaitForComplete() throws Exception {
94        mProgramDataManager.start();
95        assertTrue(mListener.programUpdatedLatch.await(WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS));
96    }
97
98    /**
99     * Test for {@link ProgramInfo#getIndex} and {@link ProgramInfo#getStartTimeMs}.
100     */
101    public void testProgramUtils() {
102        ProgramInfo stub = ProgramInfo.create();
103        for (long channelId = 1; channelId < Constants.UNIT_TEST_CHANNEL_COUNT; channelId++) {
104            int index = stub.getIndex(mClock.currentTimeMillis(), channelId);
105            long startTimeMs = stub.getStartTimeMs(index, channelId);
106            ProgramInfo programAt = stub.build(getContext(), index);
107            assertTrue(startTimeMs <= mClock.currentTimeMillis());
108            assertTrue(mClock.currentTimeMillis() < startTimeMs + programAt.durationMs);
109        }
110    }
111
112    /**
113     * Test for following methods.
114     *
115     * <p>
116     * {@link ProgramDataManager#getCurrentProgram(long)},
117     * {@link ProgramDataManager#getPrograms(long, long)},
118     * {@link ProgramDataManager#setPrefetchTimeRange(long)}.
119     * </p>
120     */
121    public void testGetPrograms() throws Exception {
122        // Initial setup to test {@link ProgramDataManager#setPrefetchTimeRange(long)}.
123        long preventSnapDelayMs = ProgramDataManager.PROGRAM_GUIDE_SNAP_TIME_MS * 2;
124        long prefetchTimeRangeStartMs = System.currentTimeMillis() + preventSnapDelayMs;
125        mClock.setCurrentTimeMillis(prefetchTimeRangeStartMs + preventSnapDelayMs);
126        mProgramDataManager.setPrefetchTimeRange(prefetchTimeRangeStartMs);
127
128        startAndWaitForComplete();
129
130        for (long channelId = 1; channelId <= Constants.UNIT_TEST_CHANNEL_COUNT; channelId++) {
131            Program currentProgram = mProgramDataManager.getCurrentProgram(channelId);
132            // Test {@link ProgramDataManager#getCurrentProgram(long)}.
133            assertTrue(currentProgram.getStartTimeUtcMillis() <= mClock.currentTimeMillis()
134                    && mClock.currentTimeMillis() <= currentProgram.getEndTimeUtcMillis());
135
136            // Test {@link ProgramDataManager#getPrograms(long)}.
137            // Case #1: Normal case
138            List<Program> programs =
139                    mProgramDataManager.getPrograms(channelId, mClock.currentTimeMillis());
140            ProgramInfo stub = ProgramInfo.create();
141            int index = stub.getIndex(mClock.currentTimeMillis(), channelId);
142            for (Program program : programs) {
143                ProgramInfo programInfoAt = stub.build(getContext(), index);
144                long startTimeMs = stub.getStartTimeMs(index, channelId);
145                assertProgramEquals(startTimeMs, programInfoAt, program);
146                index++;
147            }
148            // Case #2: Corner cases where there's a program that starts at the start of the range.
149            long startTimeMs = programs.get(0).getStartTimeUtcMillis();
150            programs = mProgramDataManager.getPrograms(channelId, startTimeMs);
151            assertEquals(startTimeMs, programs.get(0).getStartTimeUtcMillis());
152
153            // Test {@link ProgramDataManager#setPrefetchTimeRange(long)}.
154            programs = mProgramDataManager.getPrograms(channelId,
155                    prefetchTimeRangeStartMs - TimeUnit.HOURS.toMillis(1));
156            for (Program program : programs) {
157                assertTrue(program.getEndTimeUtcMillis() >= prefetchTimeRangeStartMs);
158            }
159        }
160    }
161
162    /**
163     * Test for following methods.
164     *
165     * <p>
166     * {@link ProgramDataManager#addOnCurrentProgramUpdatedListener},
167     * {@link ProgramDataManager#removeOnCurrentProgramUpdatedListener}.
168     * </p>
169     */
170    public void testCurrentProgramListener() throws Exception {
171        final long testChannelId = 1;
172        ProgramInfo stub = ProgramInfo.create();
173        int index = stub.getIndex(mClock.currentTimeMillis(), testChannelId);
174        // Set current time to few seconds before the current program ends,
175        // so we can see if callback is called as expected.
176        long nextProgramStartTimeMs = stub.getStartTimeMs(index + 1, testChannelId);
177        ProgramInfo nextProgramInfo = stub.build(getContext(), index + 1);
178        mClock.setCurrentTimeMillis(nextProgramStartTimeMs - (WAIT_TIME_OUT_MS / 2));
179
180        startAndWaitForComplete();
181        // Note that changing current time doesn't affect the current program
182        // because current program is updated after waiting for the program's duration.
183        // See {@link ProgramDataManager#updateCurrentProgram}.
184        mClock.setCurrentTimeMillis(mClock.currentTimeMillis() + WAIT_TIME_OUT_MS);
185        TestProgramDataManagerOnCurrentProgramUpdatedListener listener =
186                new TestProgramDataManagerOnCurrentProgramUpdatedListener();
187        mProgramDataManager.addOnCurrentProgramUpdatedListener(testChannelId, listener);
188        assertTrue(
189                listener.currentProgramUpdatedLatch.await(WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS));
190        assertEquals(testChannelId, listener.updatedChannelId);
191        Program currentProgram = mProgramDataManager.getCurrentProgram(testChannelId);
192        assertProgramEquals(nextProgramStartTimeMs, nextProgramInfo, currentProgram);
193        assertEquals(listener.updatedProgram, currentProgram);
194    }
195
196    /**
197     * Test if program data is refreshed after the program insertion.
198     */
199    public void testContentProviderUpdate() throws Exception {
200        final long testChannelId = 1;
201        startAndWaitForComplete();
202        // Force program data manager to update program data whenever it's changes.
203        mProgramDataManager.setProgramPrefetchUpdateWait(0);
204        mListener.reset();
205        List<Program> programList =
206                mProgramDataManager.getPrograms(testChannelId, mClock.currentTimeMillis());
207        assertNotNull(programList);
208        long lastProgramEndTime = programList.get(programList.size() - 1).getEndTimeUtcMillis();
209        // Make change in content provider
210        mContentProvider.simulateAppend(testChannelId);
211        assertTrue(mListener.programUpdatedLatch.await(WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS));
212        programList = mProgramDataManager.getPrograms(testChannelId, mClock.currentTimeMillis());
213        assertTrue(
214                lastProgramEndTime < programList.get(programList.size() - 1).getEndTimeUtcMillis());
215    }
216
217    /**
218     * Test for {@link ProgramDataManager#setPauseProgramUpdate(boolean)}.
219     */
220    public void testSetPauseProgramUpdate() throws Exception {
221        final long testChannelId = 1;
222        startAndWaitForComplete();
223        // Force program data manager to update program data whenever it's changes.
224        mProgramDataManager.setProgramPrefetchUpdateWait(0);
225        mListener.reset();
226        mProgramDataManager.setPauseProgramUpdate(true);
227        mContentProvider.simulateAppend(testChannelId);
228        assertFalse(mListener.programUpdatedLatch.await(FAILURE_TIME_OUT_MS,
229                TimeUnit.MILLISECONDS));
230    }
231
232    public static void assertProgramEquals(long expectedStartTime, ProgramInfo expectedInfo,
233            Program actualProgram) {
234        assertEquals("title", expectedInfo.title, actualProgram.getTitle());
235        assertEquals("episode", expectedInfo.episode, actualProgram.getEpisodeTitle());
236        assertEquals("description", expectedInfo.description, actualProgram.getDescription());
237        assertEquals("startTime", expectedStartTime, actualProgram.getStartTimeUtcMillis());
238        assertEquals("endTime", expectedStartTime + expectedInfo.durationMs,
239                actualProgram.getEndTimeUtcMillis());
240    }
241
242    private class FakeContentResolver extends MockContentResolver {
243        @Override
244        public void notifyChange(Uri uri, ContentObserver observer, boolean syncToNetwork) {
245            super.notifyChange(uri, observer, syncToNetwork);
246            if (DEBUG) {
247                Log.d(TAG, "onChanged(uri=" + uri + ")");
248            }
249            if (observer != null) {
250                observer.dispatchChange(false, uri);
251            } else {
252                mProgramDataManager.getContentObserver().dispatchChange(false, uri);
253            }
254        }
255    }
256
257    private static class ProgramInfoWrapper {
258        private final int index;
259        private final long startTimeMs;
260        private final ProgramInfo programInfo;
261
262        public ProgramInfoWrapper(int index, long startTimeMs, ProgramInfo programInfo) {
263            this.index = index;
264            this.startTimeMs = startTimeMs;
265            this.programInfo = programInfo;
266        }
267    }
268
269    // This implements the minimal methods in content resolver
270    // and detailed assumptions are written in each method.
271    private class FakeContentProvider extends MockContentProvider {
272        private final SparseArray<List<ProgramInfoWrapper>> mProgramInfoList = new SparseArray<>();
273
274        /**
275         * Constructor for FakeContentProvider
276         * <p>
277         * This initializes program info assuming that
278         * channel IDs are 1, 2, 3, ... {@link Constants#UNIT_TEST_CHANNEL_COUNT}.
279         * </p>
280         */
281        public FakeContentProvider(Context context) {
282            super(context);
283            long startTimeMs = Utils.floorTime(
284                    mClock.currentTimeMillis() - ProgramDataManager.PROGRAM_GUIDE_SNAP_TIME_MS,
285                    ProgramDataManager.PROGRAM_GUIDE_SNAP_TIME_MS);
286            long endTimeMs = startTimeMs + (ProgramDataManager.PROGRAM_GUIDE_MAX_TIME_RANGE / 2);
287            for (int i = 1; i <= Constants.UNIT_TEST_CHANNEL_COUNT; i++) {
288                List<ProgramInfoWrapper> programInfoList = new ArrayList<>();
289                ProgramInfo stub = ProgramInfo.create();
290                int index = stub.getIndex(startTimeMs, i);
291                long programStartTimeMs = stub.getStartTimeMs(index, i);
292                while (programStartTimeMs < endTimeMs) {
293                    ProgramInfo programAt = stub.build(getContext(), index);
294                    programInfoList.add(
295                            new ProgramInfoWrapper(index, programStartTimeMs, programAt));
296                    index++;
297                    programStartTimeMs += programAt.durationMs;
298                }
299                mProgramInfoList.put(i, programInfoList);
300            }
301        }
302
303        @Override
304        public Cursor query(Uri uri, String[] projection, String selection,
305                String[] selectionArgs, String sortOrder) {
306            if (DEBUG) {
307                Log.d(TAG, "dump query");
308                Log.d(TAG, "  uri=" + uri);
309                Log.d(TAG, "  projection=" + Arrays.toString(projection));
310                Log.d(TAG, "  selection=" + selection);
311            }
312            long startTimeMs = Long.parseLong(uri.getQueryParameter(PARAM_START_TIME));
313            long endTimeMs = Long.parseLong(uri.getQueryParameter(PARAM_END_TIME));
314            if (startTimeMs == 0 || endTimeMs == 0) {
315                throw new UnsupportedOperationException();
316            }
317            assertProgramUri(uri);
318            long channelId;
319            try {
320                channelId = Long.parseLong(uri.getQueryParameter(PARAM_CHANNEL));
321            } catch (NumberFormatException e) {
322                channelId = -1;
323            }
324            return new FakeCursor(projection, channelId, startTimeMs, endTimeMs);
325        }
326
327        /**
328         * Simulate program data appends at the end of the existing programs.
329         * This appends programs until the maximum program query range
330         * ({@link ProgramDataManager#PROGRAM_GUIDE_MAX_TIME_RANGE})
331         * where we started with the inserting half of it.
332         */
333        public void simulateAppend(long channelId) {
334            long endTimeMs =
335                    mClock.currentTimeMillis() + ProgramDataManager.PROGRAM_GUIDE_MAX_TIME_RANGE;
336            List<ProgramInfoWrapper> programList = mProgramInfoList.get((int) channelId);
337            if (mProgramInfoList == null) {
338                return;
339            }
340            ProgramInfo stub = ProgramInfo.create();
341            ProgramInfoWrapper last = programList.get(programList.size() - 1);
342            while (last.startTimeMs < endTimeMs) {
343                ProgramInfo nextProgramInfo = stub.build(getContext(), last.index + 1);
344                ProgramInfoWrapper next = new ProgramInfoWrapper(last.index + 1,
345                        last.startTimeMs + last.programInfo.durationMs, nextProgramInfo);
346                programList.add(next);
347                last = next;
348            }
349            mContentResolver.notifyChange(TvContract.Programs.CONTENT_URI, null);
350        }
351
352        private void assertProgramUri(Uri uri) {
353            assertTrue("Uri(" + uri + ") isn't channel uri",
354                    uri.toString().startsWith(TvContract.Programs.CONTENT_URI.toString()));
355        }
356
357        public ProgramInfoWrapper get(long channelId, int position) {
358            List<ProgramInfoWrapper> programList = mProgramInfoList.get((int) channelId);
359            if (programList == null || position >= programList.size()) {
360                return null;
361            }
362            return programList.get(position);
363        }
364    }
365
366    private class FakeCursor extends MockCursor {
367        private final String[] ALL_COLUMNS =  {
368                TvContract.Programs.COLUMN_CHANNEL_ID,
369                TvContract.Programs.COLUMN_TITLE,
370                TvContract.Programs.COLUMN_SHORT_DESCRIPTION,
371                TvContract.Programs.COLUMN_EPISODE_TITLE,
372                TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
373                TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS};
374        private final String[] mColumns;
375        private final boolean mIsQueryForSingleChannel;
376        private final long mStartTimeMs;
377        private final long mEndTimeMs;
378        private final int mCount;
379        private long mChannelId;
380        private int mProgramPosition;
381        private ProgramInfoWrapper mCurrentProgram;
382
383        /**
384         * Constructor
385         * @param columns the same as projection passed from {@link FakeContentProvider#query}.
386         *                Can be null for query all.
387         * @param channelId channel ID to query programs belongs to the specified channel.
388         *                  Can be negative to indicate all channels.
389         * @param startTimeMs start of the time range to query programs.
390         * @param endTimeMs end of the time range to query programs.
391         */
392        public FakeCursor(String[] columns, long channelId, long startTimeMs, long endTimeMs) {
393            mColumns = (columns == null) ? ALL_COLUMNS : columns;
394            mIsQueryForSingleChannel = (channelId > 0);
395            mChannelId = channelId;
396            mProgramPosition = -1;
397            mStartTimeMs = startTimeMs;
398            mEndTimeMs = endTimeMs;
399            int count = 0;
400            while (moveToNext()) {
401                count++;
402            }
403            mCount = count;
404            // Rewind channel Id and program index.
405            mChannelId = channelId;
406            mProgramPosition = -1;
407            if (DEBUG) {
408                Log.d(TAG, "FakeCursor(columns=" + Arrays.toString(columns)
409                        + ", channelId=" + channelId + ", startTimeMs=" + startTimeMs
410                        + ", endTimeMs=" + endTimeMs + ") has mCount=" + mCount);
411            }
412        }
413
414        @Override
415        public String getColumnName(int columnIndex) {
416            return mColumns[columnIndex];
417        }
418
419        @Override
420        public int getColumnIndex(String columnName) {
421            for (int i = 0; i < mColumns.length; i++) {
422                if (mColumns[i].equalsIgnoreCase(columnName)) {
423                    return i;
424                }
425            }
426            return -1;
427        }
428
429        @Override
430        public int getInt(int columnIndex) {
431            if (DEBUG) {
432                Log.d(TAG, "Column (" + getColumnName(columnIndex) + ") is ignored in getInt()");
433            }
434            return 0;
435        }
436
437        @Override
438        public long getLong(int columnIndex) {
439            String columnName = getColumnName(columnIndex);
440            switch (columnName) {
441                case TvContract.Programs.COLUMN_CHANNEL_ID:
442                    return mChannelId;
443                case TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS:
444                    return mCurrentProgram.startTimeMs;
445                case TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS:
446                    return mCurrentProgram.startTimeMs + mCurrentProgram.programInfo.durationMs;
447            }
448            if (DEBUG) {
449                Log.d(TAG, "Column (" + columnName + ") is ignored in getLong()");
450            }
451            return 0;
452        }
453
454        @Override
455        public String getString(int columnIndex) {
456            String columnName = getColumnName(columnIndex);
457            switch (columnName) {
458                case TvContract.Programs.COLUMN_TITLE:
459                    return mCurrentProgram.programInfo.title;
460                case TvContract.Programs.COLUMN_SHORT_DESCRIPTION:
461                    return mCurrentProgram.programInfo.description;
462                case TvContract.Programs.COLUMN_EPISODE_TITLE:
463                    return mCurrentProgram.programInfo.episode;
464            }
465            if (DEBUG) {
466                Log.d(TAG, "Column (" + columnName + ") is ignored in getString()");
467            }
468            return null;
469        }
470
471        @Override
472        public int getCount() {
473            return mCount;
474        }
475
476        @Override
477        public boolean moveToNext() {
478            while (true) {
479                ProgramInfoWrapper program = mContentProvider.get(mChannelId, ++mProgramPosition);
480                if (program == null || program.startTimeMs >= mEndTimeMs) {
481                    if (mIsQueryForSingleChannel) {
482                        return false;
483                    } else {
484                        if (++mChannelId > Constants.UNIT_TEST_CHANNEL_COUNT) {
485                            return false;
486                        }
487                        mProgramPosition = -1;
488                    }
489                } else if (program.startTimeMs + program.programInfo.durationMs >= mStartTimeMs) {
490                    mCurrentProgram = program;
491                    break;
492                }
493            }
494            return true;
495        }
496
497        @Override
498        public void close() {
499            // No-op.
500        }
501    }
502
503    private class TestProgramDataManagerListener implements ProgramDataManager.Listener {
504        public CountDownLatch programUpdatedLatch = new CountDownLatch(1);
505
506        @Override
507        public void onProgramUpdated() {
508            programUpdatedLatch.countDown();
509        }
510
511        public void reset() {
512            programUpdatedLatch = new CountDownLatch(1);
513        }
514    }
515
516    private class TestProgramDataManagerOnCurrentProgramUpdatedListener implements
517            OnCurrentProgramUpdatedListener {
518        public final CountDownLatch currentProgramUpdatedLatch = new CountDownLatch(1);
519        public long updatedChannelId = -1;
520        public Program updatedProgram = null;
521
522        @Override
523        public void onCurrentProgramUpdated(long channelId, Program program) {
524            updatedChannelId = channelId;
525            updatedProgram = program;
526            currentProgramUpdatedLatch.countDown();
527        }
528    }
529}
530