/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.tv.data; import android.content.Context; import android.database.ContentObserver; import android.database.Cursor; import android.media.tv.TvContract; import android.net.Uri; import android.os.HandlerThread; import android.test.AndroidTestCase; import android.test.mock.MockContentProvider; import android.test.mock.MockContentResolver; import android.test.mock.MockCursor; import android.test.suitebuilder.annotation.SmallTest; import android.util.Log; import android.util.SparseArray; import com.android.tv.testing.Constants; import com.android.tv.testing.ProgramInfo; import com.android.tv.testing.FakeClock; import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; /** * Test for {@link com.android.tv.data.ProgramDataManager} */ @SmallTest public class ProgramDataManagerTest extends AndroidTestCase { private static final boolean DEBUG = false; private static final String TAG = "ProgramDataManagerTest"; // Wait time for expected success. private static final long WAIT_TIME_OUT_MS = 1000L; // Wait time for expected failure. private static final long FAILURE_TIME_OUT_MS = 300L; // TODO: Use TvContract constants, once they become public. private static final String PARAM_CHANNEL = "channel"; private static final String PARAM_START_TIME = "start_time"; private static final String PARAM_END_TIME = "end_time"; private ProgramDataManager mProgramDataManager; private FakeClock mClock; private HandlerThread mHandlerThread; private TestProgramDataManagerListener mListener; private FakeContentResolver mContentResolver; private FakeContentProvider mContentProvider; @Override protected void setUp() throws Exception { super.setUp(); mClock = FakeClock.createWithCurrentTime(); mListener = new TestProgramDataManagerListener(); mContentProvider = new FakeContentProvider(getContext()); mContentResolver = new FakeContentResolver(); mContentResolver.addProvider(TvContract.AUTHORITY, mContentProvider); mHandlerThread = new HandlerThread(TAG); mHandlerThread.start(); mProgramDataManager = new ProgramDataManager( mContentResolver, mClock, mHandlerThread.getLooper()); mProgramDataManager.setPrefetchEnabled(true); mProgramDataManager.addListener(mListener); } @Override protected void tearDown() throws Exception { super.tearDown(); mHandlerThread.quitSafely(); mProgramDataManager.stop(); } private void startAndWaitForComplete() throws Exception { mProgramDataManager.start(); assertTrue(mListener.programUpdatedLatch.await(WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS)); } /** * Test for {@link ProgramInfo#getIndex} and {@link ProgramInfo#getStartTimeMs}. */ public void testProgramUtils() { ProgramInfo stub = ProgramInfo.create(); for (long channelId = 1; channelId < Constants.UNIT_TEST_CHANNEL_COUNT; channelId++) { int index = stub.getIndex(mClock.currentTimeMillis(), channelId); long startTimeMs = stub.getStartTimeMs(index, channelId); ProgramInfo programAt = stub.build(getContext(), index); assertTrue(startTimeMs <= mClock.currentTimeMillis()); assertTrue(mClock.currentTimeMillis() < startTimeMs + programAt.durationMs); } } /** * Test for following methods. * *

* {@link ProgramDataManager#getCurrentProgram(long)}, * {@link ProgramDataManager#getPrograms(long, long)}, * {@link ProgramDataManager#setPrefetchTimeRange(long)}. *

*/ public void testGetPrograms() throws Exception { // Initial setup to test {@link ProgramDataManager#setPrefetchTimeRange(long)}. long preventSnapDelayMs = ProgramDataManager.PROGRAM_GUIDE_SNAP_TIME_MS * 2; long prefetchTimeRangeStartMs = System.currentTimeMillis() + preventSnapDelayMs; mClock.setCurrentTimeMillis(prefetchTimeRangeStartMs + preventSnapDelayMs); mProgramDataManager.setPrefetchTimeRange(prefetchTimeRangeStartMs); startAndWaitForComplete(); for (long channelId = 1; channelId <= Constants.UNIT_TEST_CHANNEL_COUNT; channelId++) { Program currentProgram = mProgramDataManager.getCurrentProgram(channelId); // Test {@link ProgramDataManager#getCurrentProgram(long)}. assertTrue(currentProgram.getStartTimeUtcMillis() <= mClock.currentTimeMillis() && mClock.currentTimeMillis() <= currentProgram.getEndTimeUtcMillis()); // Test {@link ProgramDataManager#getPrograms(long)}. // Case #1: Normal case List programs = mProgramDataManager.getPrograms(channelId, mClock.currentTimeMillis()); ProgramInfo stub = ProgramInfo.create(); int index = stub.getIndex(mClock.currentTimeMillis(), channelId); for (Program program : programs) { ProgramInfo programInfoAt = stub.build(getContext(), index); long startTimeMs = stub.getStartTimeMs(index, channelId); assertProgramEquals(startTimeMs, programInfoAt, program); index++; } // Case #2: Corner cases where there's a program that starts at the start of the range. long startTimeMs = programs.get(0).getStartTimeUtcMillis(); programs = mProgramDataManager.getPrograms(channelId, startTimeMs); assertEquals(startTimeMs, programs.get(0).getStartTimeUtcMillis()); // Test {@link ProgramDataManager#setPrefetchTimeRange(long)}. programs = mProgramDataManager.getPrograms(channelId, prefetchTimeRangeStartMs - TimeUnit.HOURS.toMillis(1)); for (Program program : programs) { assertTrue(program.getEndTimeUtcMillis() >= prefetchTimeRangeStartMs); } } } /** * Test for following methods. * *

* {@link ProgramDataManager#addOnCurrentProgramUpdatedListener}, * {@link ProgramDataManager#removeOnCurrentProgramUpdatedListener}. *

*/ public void testCurrentProgramListener() throws Exception { final long testChannelId = 1; ProgramInfo stub = ProgramInfo.create(); int index = stub.getIndex(mClock.currentTimeMillis(), testChannelId); // Set current time to few seconds before the current program ends, // so we can see if callback is called as expected. long nextProgramStartTimeMs = stub.getStartTimeMs(index + 1, testChannelId); ProgramInfo nextProgramInfo = stub.build(getContext(), index + 1); mClock.setCurrentTimeMillis(nextProgramStartTimeMs - (WAIT_TIME_OUT_MS / 2)); startAndWaitForComplete(); // Note that changing current time doesn't affect the current program // because current program is updated after waiting for the program's duration. // See {@link ProgramDataManager#updateCurrentProgram}. mClock.setCurrentTimeMillis(mClock.currentTimeMillis() + WAIT_TIME_OUT_MS); TestProgramDataManagerOnCurrentProgramUpdatedListener listener = new TestProgramDataManagerOnCurrentProgramUpdatedListener(); mProgramDataManager.addOnCurrentProgramUpdatedListener(testChannelId, listener); assertTrue( listener.currentProgramUpdatedLatch.await(WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS)); assertEquals(testChannelId, listener.updatedChannelId); Program currentProgram = mProgramDataManager.getCurrentProgram(testChannelId); assertProgramEquals(nextProgramStartTimeMs, nextProgramInfo, currentProgram); assertEquals(listener.updatedProgram, currentProgram); } /** * Test if program data is refreshed after the program insertion. */ public void testContentProviderUpdate() throws Exception { final long testChannelId = 1; startAndWaitForComplete(); // Force program data manager to update program data whenever it's changes. mProgramDataManager.setProgramPrefetchUpdateWait(0); mListener.reset(); List programList = mProgramDataManager.getPrograms(testChannelId, mClock.currentTimeMillis()); assertNotNull(programList); long lastProgramEndTime = programList.get(programList.size() - 1).getEndTimeUtcMillis(); // Make change in content provider mContentProvider.simulateAppend(testChannelId); assertTrue(mListener.programUpdatedLatch.await(WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS)); programList = mProgramDataManager.getPrograms(testChannelId, mClock.currentTimeMillis()); assertTrue( lastProgramEndTime < programList.get(programList.size() - 1).getEndTimeUtcMillis()); } /** * Test for {@link ProgramDataManager#setPauseProgramUpdate(boolean)}. */ public void testSetPauseProgramUpdate() throws Exception { final long testChannelId = 1; startAndWaitForComplete(); // Force program data manager to update program data whenever it's changes. mProgramDataManager.setProgramPrefetchUpdateWait(0); mListener.reset(); mProgramDataManager.setPauseProgramUpdate(true); mContentProvider.simulateAppend(testChannelId); assertFalse(mListener.programUpdatedLatch.await(FAILURE_TIME_OUT_MS, TimeUnit.MILLISECONDS)); } public static void assertProgramEquals(long expectedStartTime, ProgramInfo expectedInfo, Program actualProgram) { assertEquals("title", expectedInfo.title, actualProgram.getTitle()); assertEquals("episode", expectedInfo.episode, actualProgram.getEpisodeTitle()); assertEquals("description", expectedInfo.description, actualProgram.getDescription()); assertEquals("startTime", expectedStartTime, actualProgram.getStartTimeUtcMillis()); assertEquals("endTime", expectedStartTime + expectedInfo.durationMs, actualProgram.getEndTimeUtcMillis()); } private class FakeContentResolver extends MockContentResolver { @Override public void notifyChange(Uri uri, ContentObserver observer, boolean syncToNetwork) { super.notifyChange(uri, observer, syncToNetwork); if (DEBUG) { Log.d(TAG, "onChanged(uri=" + uri + ")"); } if (observer != null) { observer.dispatchChange(false, uri); } else { mProgramDataManager.getContentObserver().dispatchChange(false, uri); } } } private static class ProgramInfoWrapper { private final int index; private final long startTimeMs; private final ProgramInfo programInfo; public ProgramInfoWrapper(int index, long startTimeMs, ProgramInfo programInfo) { this.index = index; this.startTimeMs = startTimeMs; this.programInfo = programInfo; } } // This implements the minimal methods in content resolver // and detailed assumptions are written in each method. private class FakeContentProvider extends MockContentProvider { private final SparseArray> mProgramInfoList = new SparseArray<>(); /** * Constructor for FakeContentProvider *

* This initializes program info assuming that * channel IDs are 1, 2, 3, ... {@link Constants#UNIT_TEST_CHANNEL_COUNT}. *

*/ public FakeContentProvider(Context context) { super(context); long startTimeMs = Utils.floorTime( mClock.currentTimeMillis() - ProgramDataManager.PROGRAM_GUIDE_SNAP_TIME_MS, ProgramDataManager.PROGRAM_GUIDE_SNAP_TIME_MS); long endTimeMs = startTimeMs + (ProgramDataManager.PROGRAM_GUIDE_MAX_TIME_RANGE / 2); for (int i = 1; i <= Constants.UNIT_TEST_CHANNEL_COUNT; i++) { List programInfoList = new ArrayList<>(); ProgramInfo stub = ProgramInfo.create(); int index = stub.getIndex(startTimeMs, i); long programStartTimeMs = stub.getStartTimeMs(index, i); while (programStartTimeMs < endTimeMs) { ProgramInfo programAt = stub.build(getContext(), index); programInfoList.add( new ProgramInfoWrapper(index, programStartTimeMs, programAt)); index++; programStartTimeMs += programAt.durationMs; } mProgramInfoList.put(i, programInfoList); } } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { if (DEBUG) { Log.d(TAG, "dump query"); Log.d(TAG, " uri=" + uri); Log.d(TAG, " projection=" + Arrays.toString(projection)); Log.d(TAG, " selection=" + selection); } long startTimeMs = Long.parseLong(uri.getQueryParameter(PARAM_START_TIME)); long endTimeMs = Long.parseLong(uri.getQueryParameter(PARAM_END_TIME)); if (startTimeMs == 0 || endTimeMs == 0) { throw new UnsupportedOperationException(); } assertProgramUri(uri); long channelId; try { channelId = Long.parseLong(uri.getQueryParameter(PARAM_CHANNEL)); } catch (NumberFormatException e) { channelId = -1; } return new FakeCursor(projection, channelId, startTimeMs, endTimeMs); } /** * Simulate program data appends at the end of the existing programs. * This appends programs until the maximum program query range * ({@link ProgramDataManager#PROGRAM_GUIDE_MAX_TIME_RANGE}) * where we started with the inserting half of it. */ public void simulateAppend(long channelId) { long endTimeMs = mClock.currentTimeMillis() + ProgramDataManager.PROGRAM_GUIDE_MAX_TIME_RANGE; List programList = mProgramInfoList.get((int) channelId); if (mProgramInfoList == null) { return; } ProgramInfo stub = ProgramInfo.create(); ProgramInfoWrapper last = programList.get(programList.size() - 1); while (last.startTimeMs < endTimeMs) { ProgramInfo nextProgramInfo = stub.build(getContext(), last.index + 1); ProgramInfoWrapper next = new ProgramInfoWrapper(last.index + 1, last.startTimeMs + last.programInfo.durationMs, nextProgramInfo); programList.add(next); last = next; } mContentResolver.notifyChange(TvContract.Programs.CONTENT_URI, null); } private void assertProgramUri(Uri uri) { assertTrue("Uri(" + uri + ") isn't channel uri", uri.toString().startsWith(TvContract.Programs.CONTENT_URI.toString())); } public ProgramInfoWrapper get(long channelId, int position) { List programList = mProgramInfoList.get((int) channelId); if (programList == null || position >= programList.size()) { return null; } return programList.get(position); } } private class FakeCursor extends MockCursor { private final String[] ALL_COLUMNS = { TvContract.Programs.COLUMN_CHANNEL_ID, TvContract.Programs.COLUMN_TITLE, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, TvContract.Programs.COLUMN_EPISODE_TITLE, TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS}; private final String[] mColumns; private final boolean mIsQueryForSingleChannel; private final long mStartTimeMs; private final long mEndTimeMs; private final int mCount; private long mChannelId; private int mProgramPosition; private ProgramInfoWrapper mCurrentProgram; /** * Constructor * @param columns the same as projection passed from {@link FakeContentProvider#query}. * Can be null for query all. * @param channelId channel ID to query programs belongs to the specified channel. * Can be negative to indicate all channels. * @param startTimeMs start of the time range to query programs. * @param endTimeMs end of the time range to query programs. */ public FakeCursor(String[] columns, long channelId, long startTimeMs, long endTimeMs) { mColumns = (columns == null) ? ALL_COLUMNS : columns; mIsQueryForSingleChannel = (channelId > 0); mChannelId = channelId; mProgramPosition = -1; mStartTimeMs = startTimeMs; mEndTimeMs = endTimeMs; int count = 0; while (moveToNext()) { count++; } mCount = count; // Rewind channel Id and program index. mChannelId = channelId; mProgramPosition = -1; if (DEBUG) { Log.d(TAG, "FakeCursor(columns=" + Arrays.toString(columns) + ", channelId=" + channelId + ", startTimeMs=" + startTimeMs + ", endTimeMs=" + endTimeMs + ") has mCount=" + mCount); } } @Override public String getColumnName(int columnIndex) { return mColumns[columnIndex]; } @Override public int getColumnIndex(String columnName) { for (int i = 0; i < mColumns.length; i++) { if (mColumns[i].equalsIgnoreCase(columnName)) { return i; } } return -1; } @Override public int getInt(int columnIndex) { if (DEBUG) { Log.d(TAG, "Column (" + getColumnName(columnIndex) + ") is ignored in getInt()"); } return 0; } @Override public long getLong(int columnIndex) { String columnName = getColumnName(columnIndex); switch (columnName) { case TvContract.Programs.COLUMN_CHANNEL_ID: return mChannelId; case TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS: return mCurrentProgram.startTimeMs; case TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS: return mCurrentProgram.startTimeMs + mCurrentProgram.programInfo.durationMs; } if (DEBUG) { Log.d(TAG, "Column (" + columnName + ") is ignored in getLong()"); } return 0; } @Override public String getString(int columnIndex) { String columnName = getColumnName(columnIndex); switch (columnName) { case TvContract.Programs.COLUMN_TITLE: return mCurrentProgram.programInfo.title; case TvContract.Programs.COLUMN_SHORT_DESCRIPTION: return mCurrentProgram.programInfo.description; case TvContract.Programs.COLUMN_EPISODE_TITLE: return mCurrentProgram.programInfo.episode; } if (DEBUG) { Log.d(TAG, "Column (" + columnName + ") is ignored in getString()"); } return null; } @Override public int getCount() { return mCount; } @Override public boolean moveToNext() { while (true) { ProgramInfoWrapper program = mContentProvider.get(mChannelId, ++mProgramPosition); if (program == null || program.startTimeMs >= mEndTimeMs) { if (mIsQueryForSingleChannel) { return false; } else { if (++mChannelId > Constants.UNIT_TEST_CHANNEL_COUNT) { return false; } mProgramPosition = -1; } } else if (program.startTimeMs + program.programInfo.durationMs >= mStartTimeMs) { mCurrentProgram = program; break; } } return true; } @Override public void close() { // No-op. } } private class TestProgramDataManagerListener implements ProgramDataManager.Listener { public CountDownLatch programUpdatedLatch = new CountDownLatch(1); @Override public void onProgramUpdated() { programUpdatedLatch.countDown(); } public void reset() { programUpdatedLatch = new CountDownLatch(1); } } private class TestProgramDataManagerOnCurrentProgramUpdatedListener implements OnCurrentProgramUpdatedListener { public final CountDownLatch currentProgramUpdatedLatch = new CountDownLatch(1); public long updatedChannelId = -1; public Program updatedProgram = null; @Override public void onCurrentProgramUpdated(long channelId, Program program) { updatedChannelId = channelId; updatedProgram = program; currentProgramUpdatedLatch.countDown(); } } }