/*
* 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();
}
}
}