/*
* 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 static androidx.test.InstrumentationRegistry.getInstrumentation;
import static androidx.test.InstrumentationRegistry.getTargetContext;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import android.content.ContentProvider;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.database.ContentObserver;
import android.database.Cursor;
import android.media.tv.TvContract;
import android.media.tv.TvContract.Channels;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.test.MoreAsserts;
import android.test.mock.MockContentProvider;
import android.test.mock.MockContentResolver;
import android.test.mock.MockCursor;
import android.text.TextUtils;
import android.util.Log;
import android.util.SparseArray;
import androidx.test.filters.SmallTest;
import androidx.test.runner.AndroidJUnit4;
import com.android.tv.data.api.Channel;
import com.android.tv.testing.constants.Constants;
import com.android.tv.testing.data.ChannelInfo;
import com.android.tv.util.TvInputManagerHelper;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
/**
* Test for {@link ChannelDataManager}
*
*
A test method may include tests for multiple methods to minimize the DB access. Note that all
* the methods of {@link ChannelDataManager} should be called from the UI thread.
*/
@SmallTest
@RunWith(AndroidJUnit4.class)
public class ChannelDataManagerTest {
private static final boolean DEBUG = false;
private static final String TAG = "ChannelDataManagerTest";
// Wait time for expected success.
private static final long WAIT_TIME_OUT_MS = 1000L;
private static final String DUMMY_INPUT_ID = "dummy";
private static final String COLUMN_BROWSABLE = "browsable";
private static final String COLUMN_LOCKED = "locked";
private ChannelDataManager mChannelDataManager;
private TestChannelDataManagerListener mListener;
private FakeContentResolver mContentResolver;
private FakeContentProvider mContentProvider;
@Before
public void setUp() {
assertWithMessage("More than 2 channels to test")
.that(Constants.UNIT_TEST_CHANNEL_COUNT > 2)
.isTrue();
mContentProvider = new FakeContentProvider(getTargetContext());
mContentResolver = new FakeContentResolver();
mContentResolver.addProvider(TvContract.AUTHORITY, mContentProvider);
mListener = new TestChannelDataManagerListener();
getInstrumentation()
.runOnMainSync(
new Runnable() {
@Override
public void run() {
TvInputManagerHelper mockHelper = Mockito.mock(TvInputManagerHelper.class);
Mockito.when(mockHelper.hasTvInputInfo(ArgumentMatchers.anyString()))
.thenReturn(true);
Context mockContext = Mockito.mock(Context.class);
Mockito.when(mockContext.getContentResolver()).thenReturn(mContentResolver);
Mockito.when(mockContext.checkSelfPermission(ArgumentMatchers.anyString()))
.thenAnswer(
invocation -> {
Object[] args = invocation.getArguments();
return getTargetContext().checkSelfPermission(((String) args[0]));
});
mChannelDataManager =
new ChannelDataManager(
mockContext, mockHelper, AsyncTask.SERIAL_EXECUTOR, mContentResolver);
mChannelDataManager.addListener(mListener);
}
});
}
@After
public void tearDown() {
getInstrumentation()
.runOnMainSync(
new Runnable() {
@Override
public void run() {
mChannelDataManager.stop();
}
});
}
private void startAndWaitForComplete() throws InterruptedException {
getInstrumentation()
.runOnMainSync(
new Runnable() {
@Override
public void run() {
mChannelDataManager.start();
}
});
assertThat(mListener.loadFinishedLatch.await(WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS))
.isTrue();
}
private void restart() throws InterruptedException {
getInstrumentation()
.runOnMainSync(
new Runnable() {
@Override
public void run() {
mChannelDataManager.stop();
mListener.reset();
}
});
startAndWaitForComplete();
}
@Test
public void testIsDbLoadFinished() throws InterruptedException {
startAndWaitForComplete();
assertThat(mChannelDataManager.isDbLoadFinished()).isTrue();
}
/**
* Test for following methods - {@link ChannelDataManager#getChannelCount} - {@link
* ChannelDataManager#getChannelList} - {@link ChannelDataManager#getChannel}
*/
@Test
public void testGetChannels() throws InterruptedException {
startAndWaitForComplete();
// Test {@link ChannelDataManager#getChannelCount}
assertThat(mChannelDataManager.getChannelCount())
.isEqualTo(Constants.UNIT_TEST_CHANNEL_COUNT);
// Test {@link ChannelDataManager#getChannelList}
List channelInfoList = new ArrayList<>();
for (int i = 1; i <= Constants.UNIT_TEST_CHANNEL_COUNT; i++) {
channelInfoList.add(ChannelInfo.create(getTargetContext(), i));
}
List channelList = mChannelDataManager.getChannelList();
for (Channel channel : channelList) {
boolean found = false;
for (ChannelInfo channelInfo : channelInfoList) {
if (TextUtils.equals(channelInfo.name, channel.getDisplayName())) {
found = true;
channelInfoList.remove(channelInfo);
break;
}
}
assertWithMessage("Cannot find (" + channel + ")").that(found).isTrue();
}
// Test {@link ChannelDataManager#getChannelIndex()}
for (Channel channel : channelList) {
assertThat(mChannelDataManager.getChannel(channel.getId())).isEqualTo(channel);
}
}
/** Test for {@link ChannelDataManager#getChannelCount} when no channel is available. */
@Test
public void testGetChannels_noChannels() throws InterruptedException {
mContentProvider.clear();
startAndWaitForComplete();
assertThat(mChannelDataManager.getChannelCount()).isEqualTo(0);
}
/**
* Test for following methods and channel listener with notifying change. - {@link
* ChannelDataManager#updateBrowsable} - {@link ChannelDataManager#applyUpdatedValuesToDb}
*/
@Test
public void testBrowsable() throws InterruptedException {
startAndWaitForComplete();
// Test if all channels are browsable
List channelList = mChannelDataManager.getChannelList();
List browsableChannelList = mChannelDataManager.getBrowsableChannelList();
for (Channel browsableChannel : browsableChannelList) {
boolean found = channelList.remove(browsableChannel);
assertWithMessage("Cannot find (" + browsableChannel + ")").that(found).isTrue();
}
assertThat(channelList).isEmpty();
// Prepare for next tests.
channelList = mChannelDataManager.getChannelList();
TestChannelDataManagerChannelListener channelListener =
new TestChannelDataManagerChannelListener();
Channel channel1 = channelList.get(0);
mChannelDataManager.addChannelListener(channel1.getId(), channelListener);
// Test {@link ChannelDataManager#updateBrowsable} & notification.
mChannelDataManager.updateBrowsable(channel1.getId(), false, false);
assertThat(mListener.channelBrowsableChangedCalled).isTrue();
assertThat(mChannelDataManager.getBrowsableChannelList()).doesNotContain(channel1);
MoreAsserts.assertContentsInAnyOrder(channelListener.updatedChannels, channel1);
channelListener.reset();
// Test {@link ChannelDataManager#applyUpdatedValuesToDb}
// Disable the update notification to avoid the unwanted call of "onLoadFinished".
mContentResolver.mNotifyDisabled = true;
mChannelDataManager.applyUpdatedValuesToDb();
restart();
browsableChannelList = mChannelDataManager.getBrowsableChannelList();
assertThat(browsableChannelList).hasSize(Constants.UNIT_TEST_CHANNEL_COUNT - 1);
assertThat(browsableChannelList).doesNotContain(channel1);
}
/**
* Test for following methods and channel listener without notifying change. - {@link
* ChannelDataManager#updateBrowsable} - {@link ChannelDataManager#applyUpdatedValuesToDb}
*/
@Test
public void testBrowsable_skipNotification() throws InterruptedException {
startAndWaitForComplete();
List channels = mChannelDataManager.getChannelList();
// Prepare for next tests.
TestChannelDataManagerChannelListener channelListener =
new TestChannelDataManagerChannelListener();
Channel channel1 = channels.get(0);
Channel channel2 = channels.get(1);
mChannelDataManager.addChannelListener(channel1.getId(), channelListener);
mChannelDataManager.addChannelListener(channel2.getId(), channelListener);
// Test {@link ChannelDataManager#updateBrowsable} & skip notification.
mChannelDataManager.updateBrowsable(channel1.getId(), false, true);
mChannelDataManager.updateBrowsable(channel2.getId(), false, true);
mChannelDataManager.updateBrowsable(channel1.getId(), true, true);
assertThat(mListener.channelBrowsableChangedCalled).isFalse();
List browsableChannelList = mChannelDataManager.getBrowsableChannelList();
assertThat(browsableChannelList).contains(channel1);
assertThat(browsableChannelList).doesNotContain(channel2);
// Test {@link ChannelDataManager#applyUpdatedValuesToDb}
// Disable the update notification to avoid the unwanted call of "onLoadFinished".
mContentResolver.mNotifyDisabled = true;
mChannelDataManager.applyUpdatedValuesToDb();
restart();
browsableChannelList = mChannelDataManager.getBrowsableChannelList();
assertThat(browsableChannelList).hasSize(Constants.UNIT_TEST_CHANNEL_COUNT - 1);
assertThat(browsableChannelList).doesNotContain(channel2);
}
/**
* Test for following methods and channel listener. - {@link ChannelDataManager#updateLocked} -
* {@link ChannelDataManager#applyUpdatedValuesToDb}
*/
@Test
public void testLocked() throws InterruptedException {
startAndWaitForComplete();
// Test if all channels aren't locked at the first time.
List channelList = mChannelDataManager.getChannelList();
for (Channel channel : channelList) {
assertWithMessage(channel + " is locked").that(channel.isLocked()).isFalse();
}
// Prepare for next tests.
Channel channel = mChannelDataManager.getChannelList().get(0);
// Test {@link ChannelDataManager#updateLocked}
mChannelDataManager.updateLocked(channel.getId(), true);
assertThat(mChannelDataManager.getChannel(channel.getId()).isLocked()).isTrue();
// Test {@link ChannelDataManager#applyUpdatedValuesToDb}.
// Disable the update notification to avoid the unwanted call of "onLoadFinished".
mContentResolver.mNotifyDisabled = true;
mChannelDataManager.applyUpdatedValuesToDb();
restart();
assertThat(mChannelDataManager.getChannel(channel.getId()).isLocked()).isTrue();
// Cleanup
mChannelDataManager.updateLocked(channel.getId(), false);
}
/** Test ChannelDataManager when channels in TvContract are updated, removed, or added. */
@Test
public void testChannelListChanged() throws InterruptedException {
startAndWaitForComplete();
// Test channel add.
mListener.reset();
long testChannelId = Constants.UNIT_TEST_CHANNEL_COUNT + 1;
ChannelInfo testChannelInfo = ChannelInfo.create(getTargetContext(), (int) testChannelId);
testChannelId = Constants.UNIT_TEST_CHANNEL_COUNT + 1;
mContentProvider.simulateInsert(testChannelInfo);
assertThat(mListener.channelListUpdatedLatch.await(WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS))
.isTrue();
assertThat(mChannelDataManager.getChannelCount())
.isEqualTo(Constants.UNIT_TEST_CHANNEL_COUNT + 1);
// Test channel update
mListener.reset();
TestChannelDataManagerChannelListener channelListener =
new TestChannelDataManagerChannelListener();
mChannelDataManager.addChannelListener(testChannelId, channelListener);
String newName = testChannelInfo.name + "_test";
mContentProvider.simulateUpdate(testChannelId, newName);
assertThat(mListener.channelListUpdatedLatch.await(WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS))
.isTrue();
assertThat(
channelListener.channelChangedLatch.await(
WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS))
.isTrue();
assertThat(channelListener.removedChannels).isEmpty();
assertThat(channelListener.updatedChannels).hasSize(1);
Channel updatedChannel = channelListener.updatedChannels.get(0);
assertThat(updatedChannel.getId()).isEqualTo(testChannelId);
assertThat(updatedChannel.getDisplayNumber()).isEqualTo(testChannelInfo.number);
assertThat(updatedChannel.getDisplayName()).isEqualTo(newName);
assertThat(mChannelDataManager.getChannelCount())
.isEqualTo(Constants.UNIT_TEST_CHANNEL_COUNT + 1);
// Test channel remove.
mListener.reset();
channelListener.reset();
mContentProvider.simulateDelete(testChannelId);
assertThat(mListener.channelListUpdatedLatch.await(WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS))
.isTrue();
assertThat(
channelListener.channelChangedLatch.await(
WAIT_TIME_OUT_MS, TimeUnit.MILLISECONDS))
.isTrue();
assertThat(channelListener.removedChannels).hasSize(1);
assertThat(channelListener.updatedChannels).isEmpty();
Channel removedChannel = channelListener.removedChannels.get(0);
assertThat(removedChannel.getDisplayName()).isEqualTo(newName);
assertThat(removedChannel.getDisplayNumber()).isEqualTo(testChannelInfo.number);
assertThat(mChannelDataManager.getChannelCount())
.isEqualTo(Constants.UNIT_TEST_CHANNEL_COUNT);
}
private static class ChannelInfoWrapper {
public ChannelInfo channelInfo;
public boolean browsable;
public boolean locked;
public ChannelInfoWrapper(ChannelInfo channelInfo) {
this.channelInfo = channelInfo;
browsable = true;
locked = false;
}
}
private class FakeContentResolver extends MockContentResolver {
boolean mNotifyDisabled;
@Override
public void notifyChange(Uri uri, ContentObserver observer, boolean syncToNetwork) {
super.notifyChange(uri, observer, syncToNetwork);
if (DEBUG) {
Log.d(
TAG,
"onChanged(uri="
+ uri
+ ", observer="
+ observer
+ ") - Notification "
+ (mNotifyDisabled ? "disabled" : "enabled"));
}
if (mNotifyDisabled) {
return;
}
// Do not call {@link ContentObserver#onChange} directly to run it on the correct
// thread.
if (observer != null) {
observer.dispatchChange(false, uri);
} else {
mChannelDataManager.getContentObserver().dispatchChange(false, uri);
}
}
}
// This implements the minimal methods in content resolver
// and detailed assumptions are written in each method.
private class FakeContentProvider extends MockContentProvider {
private final SparseArray mChannelInfoList = new SparseArray<>();
public FakeContentProvider(Context context) {
super(context);
for (int i = 1; i <= Constants.UNIT_TEST_CHANNEL_COUNT; i++) {
mChannelInfoList.put(
i, new ChannelInfoWrapper(ChannelInfo.create(getTargetContext(), i)));
}
}
@Override
public AssetFileDescriptor openTypedAssetFile(Uri url, String mimeType, Bundle opts) {
try {
return getTargetContext().getContentResolver().openAssetFileDescriptor(url, "r");
} catch (FileNotFoundException e) {
return null;
}
}
/**
* Implementation of {@link ContentProvider#query}. This assumes that {@link
* ChannelDataManager} queries channels with empty {@code selection}. (i.e. channels are
* always queries for all)
*/
@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);
}
assertChannelUri(uri);
return new FakeCursor(projection);
}
/**
* Implementation of {@link ContentProvider#update}. This assumes that {@link
* ChannelDataManager} update channels only for changing browsable and locked.
*/
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
if (DEBUG) Log.d(TAG, "update(uri=" + uri + ", selection=" + selection);
assertChannelUri(uri);
List channelIds = new ArrayList<>();
try {
long channelId = ContentUris.parseId(uri);
channelIds.add(channelId);
} catch (NumberFormatException e) {
// Update for multiple channels.
if (TextUtils.isEmpty(selection)) {
for (int i = 0; i < mChannelInfoList.size(); i++) {
channelIds.add((long) mChannelInfoList.keyAt(i));
}
} else {
// See {@link Utils#buildSelectionForIds} for the syntax.
String selectionForId =
selection.substring(
selection.indexOf("(") + 1, selection.lastIndexOf(")"));
String[] ids = selectionForId.split(", ");
if (ids != null) {
for (String id : ids) {
channelIds.add(Long.parseLong(id));
}
}
}
}
int updateCount = 0;
for (long channelId : channelIds) {
boolean updated = false;
ChannelInfoWrapper channel = mChannelInfoList.get((int) channelId);
if (channel == null) {
return 0;
}
if (values.containsKey(COLUMN_BROWSABLE)) {
updated = true;
channel.browsable = (values.getAsInteger(COLUMN_BROWSABLE) == 1);
}
if (values.containsKey(COLUMN_LOCKED)) {
updated = true;
channel.locked = (values.getAsInteger(COLUMN_LOCKED) == 1);
}
updateCount += updated ? 1 : 0;
}
if (updateCount > 0) {
if (channelIds.size() == 1) {
mContentResolver.notifyChange(uri, null);
} else {
mContentResolver.notifyChange(Channels.CONTENT_URI, null);
}
} else {
if (DEBUG) {
Log.d(TAG, "Update to channel(uri=" + uri + ") is ignored for " + values);
}
}
return updateCount;
}
/**
* Simulates channel data insert. This assigns original network ID (the same with channel
* number) to channel ID.
*/
public void simulateInsert(ChannelInfo testChannelInfo) {
long channelId = testChannelInfo.originalNetworkId;
mChannelInfoList.put(
(int) channelId,
new ChannelInfoWrapper(
ChannelInfo.create(getTargetContext(), (int) channelId)));
mContentResolver.notifyChange(TvContract.buildChannelUri(channelId), null);
}
/** Simulates channel data delete. */
public void simulateDelete(long channelId) {
mChannelInfoList.remove((int) channelId);
mContentResolver.notifyChange(TvContract.buildChannelUri(channelId), null);
}
/** Simulates channel data update. */
public void simulateUpdate(long channelId, String newName) {
ChannelInfoWrapper channel = mChannelInfoList.get((int) channelId);
ChannelInfo.Builder builder = new ChannelInfo.Builder(channel.channelInfo);
builder.setName(newName);
channel.channelInfo = builder.build();
mContentResolver.notifyChange(TvContract.buildChannelUri(channelId), null);
}
private void assertChannelUri(Uri uri) {
assertWithMessage("Uri(" + uri + ") isn't channel uri")
.that(uri.toString().startsWith(Channels.CONTENT_URI.toString()))
.isTrue();
}
public void clear() {
mChannelInfoList.clear();
}
public ChannelInfoWrapper get(int position) {
return mChannelInfoList.get(mChannelInfoList.keyAt(position));
}
public int getCount() {
return mChannelInfoList.size();
}
public long keyAt(int position) {
return mChannelInfoList.keyAt(position);
}
}
private class FakeCursor extends MockCursor {
private final String[] allColumns = {
Channels._ID,
Channels.COLUMN_DISPLAY_NAME,
Channels.COLUMN_DISPLAY_NUMBER,
Channels.COLUMN_INPUT_ID,
Channels.COLUMN_VIDEO_FORMAT,
Channels.COLUMN_ORIGINAL_NETWORK_ID,
COLUMN_BROWSABLE,
COLUMN_LOCKED
};
private final String[] mColumns;
private int mPosition;
public FakeCursor(String[] columns) {
mColumns = (columns == null) ? allColumns : columns;
mPosition = -1;
}
@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 long getLong(int columnIndex) {
String columnName = getColumnName(columnIndex);
switch (columnName) {
case Channels._ID:
return mContentProvider.keyAt(mPosition);
default: // fall out
}
if (DEBUG) {
Log.d(TAG, "Column (" + columnName + ") is ignored in getLong()");
}
return 0;
}
@Override
public String getString(int columnIndex) {
String columnName = getColumnName(columnIndex);
ChannelInfoWrapper channel = mContentProvider.get(mPosition);
switch (columnName) {
case Channels.COLUMN_DISPLAY_NAME:
return channel.channelInfo.name;
case Channels.COLUMN_DISPLAY_NUMBER:
return channel.channelInfo.number;
case Channels.COLUMN_INPUT_ID:
return DUMMY_INPUT_ID;
case Channels.COLUMN_VIDEO_FORMAT:
return channel.channelInfo.getVideoFormat();
default: // fall out
}
if (DEBUG) {
Log.d(TAG, "Column (" + columnName + ") is ignored in getString()");
}
return null;
}
@Override
public int getInt(int columnIndex) {
String columnName = getColumnName(columnIndex);
ChannelInfoWrapper channel = mContentProvider.get(mPosition);
switch (columnName) {
case Channels.COLUMN_ORIGINAL_NETWORK_ID:
return channel.channelInfo.originalNetworkId;
case COLUMN_BROWSABLE:
return channel.browsable ? 1 : 0;
case COLUMN_LOCKED:
return channel.locked ? 1 : 0;
default: // fall out
}
if (DEBUG) {
Log.d(TAG, "Column (" + columnName + ") is ignored in getInt()");
}
return 0;
}
@Override
public int getCount() {
return mContentProvider.getCount();
}
@Override
public boolean moveToNext() {
return ++mPosition < mContentProvider.getCount();
}
@Override
public void close() {
// No-op.
}
}
private static class TestChannelDataManagerListener implements ChannelDataManager.Listener {
public CountDownLatch loadFinishedLatch = new CountDownLatch(1);
public CountDownLatch channelListUpdatedLatch = new CountDownLatch(1);
public boolean channelBrowsableChangedCalled;
@Override
public void onLoadFinished() {
loadFinishedLatch.countDown();
}
@Override
public void onChannelListUpdated() {
channelListUpdatedLatch.countDown();
}
@Override
public void onChannelBrowsableChanged() {
channelBrowsableChangedCalled = true;
}
public void reset() {
loadFinishedLatch = new CountDownLatch(1);
channelListUpdatedLatch = new CountDownLatch(1);
channelBrowsableChangedCalled = false;
}
}
private static class TestChannelDataManagerChannelListener
implements ChannelDataManager.ChannelListener {
public CountDownLatch channelChangedLatch = new CountDownLatch(1);
public final List removedChannels = new ArrayList<>();
public final List updatedChannels = new ArrayList<>();
@Override
public void onChannelRemoved(Channel channel) {
removedChannels.add(channel);
channelChangedLatch.countDown();
}
@Override
public void onChannelUpdated(Channel channel) {
updatedChannels.add(channel);
channelChangedLatch.countDown();
}
public void reset() {
channelChangedLatch = new CountDownLatch(1);
removedChannels.clear();
updatedChannels.clear();
}
}
}