diff --git a/app/build.gradle b/app/build.gradle
index bc0d2bd..4298d25 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -38,6 +38,13 @@ android {
}
}
+ productFlavors {
+ instrumentationTest {
+ applicationId "org.xbmc.kore.instrumentationtest"
+ testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ }
+ }
+
buildTypes {
// debug {
// minifyEnabled true
@@ -94,5 +101,32 @@ dependencies {
compile 'com.astuetz:pagerslidingtabstrip:1.0.1'
compile 'com.melnykov:floatingactionbutton:1.3.0'
+ androidTestCompile 'com.android.support.test:runner:0.5'
+ androidTestCompile 'com.android.support.test:rules:0.5'
+ androidTestCompile 'org.hamcrest:hamcrest-library:1.3'
+ androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.2'
+ androidTestCompile 'com.android.support.test.espresso:espresso-idling-resource:2.2.2'
+ androidTestCompile 'com.android.support:support-v13:23.4.0'
+
compile fileTree(dir: 'libs', include: ['*.jar'])
}
+
+// Get the path to ADB. Required when running tests directly from Android Studio.
+// Source: http://stackoverflow.com/a/26771087/112705
+def adb = android.getAdbExe().toString()
+
+// Source: http://stackoverflow.com/q/29908110/112705
+afterEvaluate {
+ task grantAnimationPermissionDev(type: Exec, dependsOn: 'installInstrumentationTestDebug') {
+ println("Executing: $adb shell pm grant $android.productFlavors.instrumentationTest.applicationId android.permission.SET_ANIMATION_SCALE")
+ commandLine "$adb shell pm grant $android.productFlavors.instrumentationTest.applicationId android.permission.SET_ANIMATION_SCALE".split(' ')
+ }
+
+ // When launching individual tests from Android Studio, it seems that only the assemble tasks
+ // get called directly, not the install* versions
+ tasks.each { task ->
+ if (task.name.startsWith('assembleInstrumentationTestDebugAndroidTest')) {
+ task.dependsOn grantAnimationPermissionDev
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/xbmc/kore/ApplicationTest.java b/app/src/androidTest/java/org/xbmc/kore/ApplicationTest.java
deleted file mode 100644
index 3f3697a..0000000
--- a/app/src/androidTest/java/org/xbmc/kore/ApplicationTest.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.xbmc.kore;
-
-import android.app.Application;
-import android.test.ApplicationTestCase;
-
-/**
- * Testing Fundamentals
- */
-public class ApplicationTest extends ApplicationTestCase {
- public ApplicationTest() {
- super(Application.class);
- }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/xbmc/kore/testhelpers/Database.java b/app/src/androidTest/java/org/xbmc/kore/testhelpers/Database.java
new file mode 100644
index 0000000..7c10a0b
--- /dev/null
+++ b/app/src/androidTest/java/org/xbmc/kore/testhelpers/Database.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2016 Martijn Brekhof. All rights reserved.
+ *
+ * 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 org.xbmc.kore.testhelpers;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+
+import org.xbmc.kore.host.HostInfo;
+import org.xbmc.kore.host.HostManager;
+import org.xbmc.kore.jsonrpc.ApiException;
+import org.xbmc.kore.jsonrpc.method.AudioLibrary;
+import org.xbmc.kore.jsonrpc.method.VideoLibrary;
+import org.xbmc.kore.jsonrpc.type.AudioType;
+import org.xbmc.kore.jsonrpc.type.LibraryType;
+import org.xbmc.kore.jsonrpc.type.VideoType;
+import org.xbmc.kore.provider.MediaContract;
+import org.xbmc.kore.provider.MediaProvider;
+import org.xbmc.kore.service.library.SyncUtils;
+import org.xbmc.kore.utils.LogUtils;
+
+import java.io.IOException;
+import java.util.ArrayList;
+
+public class Database {
+ public static final String TAG = LogUtils.makeLogTag(Database.class);
+
+ public static HostInfo fill(Context context) throws ApiException, IOException {
+ MediaProvider mediaProvider = new MediaProvider();
+ mediaProvider.setContext(context);
+ mediaProvider.onCreate();
+
+ HostInfo hostInfo = addHost(context);
+
+ insertMovies(context, hostInfo.getId());
+ insertArtists(context, hostInfo.getId());
+ insertGenres(context, hostInfo.getId());
+ insertAlbums(context, hostInfo.getId());
+ insertSongs(context, hostInfo.getId());
+
+ return hostInfo;
+ }
+
+ public static void flush(Context context, HostInfo hostInfo) {
+ context.getContentResolver()
+ .delete(MediaContract.Hosts.buildHostUri(hostInfo.getId()), null, null);
+ }
+
+ private static HostInfo addHost(Context context) {
+ return HostManager.getInstance(context).addHost("TestHost", "127.0.0.1", 1, 80, 9090, null, null, "52:54:00:12:35:02", 9, false, 9777);
+ }
+
+ private static void insertMovies(Context context, int hostId) throws ApiException, IOException {
+ VideoLibrary.GetMovies getMovies = new VideoLibrary.GetMovies();
+ String result = Utils.readFile(context, "Video.Details.Movie.json");
+ ArrayList movieList = (ArrayList) getMovies.resultFromJson(result);
+
+
+ ContentValues movieValuesBatch[] = new ContentValues[movieList.size()];
+ int castCount = 0;
+
+ // Iterate on each movie
+ for (int i = 0; i < movieList.size(); i++) {
+ VideoType.DetailsMovie movie = movieList.get(i);
+ movieValuesBatch[i] = SyncUtils.contentValuesFromMovie(hostId, movie);
+ castCount += movie.cast.size();
+ }
+
+ context.getContentResolver().bulkInsert(MediaContract.Movies.CONTENT_URI, movieValuesBatch);
+
+ ContentValues movieCastValuesBatch[] = new ContentValues[castCount];
+ int count = 0;
+ // Iterate on each movie/cast
+ for (VideoType.DetailsMovie movie : movieList) {
+ for (VideoType.Cast cast : movie.cast) {
+ movieCastValuesBatch[count] = SyncUtils.contentValuesFromCast(hostId, cast);
+ movieCastValuesBatch[count].put(MediaContract.MovieCastColumns.MOVIEID, movie.movieid);
+ count++;
+ }
+ }
+
+ context.getContentResolver().bulkInsert(MediaContract.MovieCast.CONTENT_URI, movieCastValuesBatch);
+ }
+
+ private static void insertArtists(Context context, int hostId) throws ApiException, IOException {
+ AudioLibrary.GetArtists getArtists = new AudioLibrary.GetArtists(false);
+ String result = Utils.readFile(context, "AudioLibrary.GetArtists.json");
+ ArrayList artistList = (ArrayList) getArtists.resultFromJson(result).items;
+
+ ContentValues artistValuesBatch[] = new ContentValues[artistList.size()];
+ for (int i = 0; i < artistList.size(); i++) {
+ AudioType.DetailsArtist artist = artistList.get(i);
+ artistValuesBatch[i] = SyncUtils.contentValuesFromArtist(hostId, artist);
+ }
+
+ context.getContentResolver().bulkInsert(MediaContract.Artists.CONTENT_URI, artistValuesBatch);
+ }
+
+ private static void insertGenres(Context context, int hostId) throws ApiException, IOException {
+ AudioLibrary.GetGenres getGenres = new AudioLibrary.GetGenres();
+ ArrayList genreList = (ArrayList) getGenres.resultFromJson(Utils.readFile(context, "AudioLibrary.GetGenres.json"));
+
+ ContentValues genresValuesBatch[] = new ContentValues[genreList.size()];
+ for (int i = 0; i < genreList.size(); i++) {
+ LibraryType.DetailsGenre genre = genreList.get(i);
+ genresValuesBatch[i] = SyncUtils.contentValuesFromAudioGenre(hostId, genre);
+ }
+
+ context.getContentResolver().bulkInsert(MediaContract.AudioGenres.CONTENT_URI, genresValuesBatch);
+ }
+
+ private static void insertAlbums(Context context, int hostId) throws ApiException, IOException {
+ AudioLibrary.GetAlbums getAlbums = new AudioLibrary.GetAlbums();
+ String result = Utils.readFile(context, "AudioLibrary.GetAlbums.json");
+ ArrayList albumList = (ArrayList) getAlbums.resultFromJson(result).items;
+
+ ContentResolver contentResolver = context.getContentResolver();
+
+ ContentValues albumValuesBatch[] = new ContentValues[albumList.size()];
+ int artistsCount = 0, genresCount = 0;
+ for (int i = 0; i < albumList.size(); i++) {
+ AudioType.DetailsAlbum album = albumList.get(i);
+ albumValuesBatch[i] = SyncUtils.contentValuesFromAlbum(hostId, album);
+
+ artistsCount += album.artistid.size();
+ genresCount += album.genreid.size();
+ }
+ contentResolver.bulkInsert(MediaContract.Albums.CONTENT_URI, albumValuesBatch);
+
+ // Iterate on each album, collect the artists and the genres and insert them
+ ContentValues albumArtistsValuesBatch[] = new ContentValues[artistsCount];
+ ContentValues albumGenresValuesBatch[] = new ContentValues[genresCount];
+ int artistCount = 0, genreCount = 0;
+ for (AudioType.DetailsAlbum album : albumList) {
+ for (int artistId : album.artistid) {
+ albumArtistsValuesBatch[artistCount] = new ContentValues();
+ albumArtistsValuesBatch[artistCount].put(MediaContract.AlbumArtists.HOST_ID, hostId);
+ albumArtistsValuesBatch[artistCount].put(MediaContract.AlbumArtists.ALBUMID, album.albumid);
+ albumArtistsValuesBatch[artistCount].put(MediaContract.AlbumArtists.ARTISTID, artistId);
+ artistCount++;
+ }
+
+ for (int genreId : album.genreid) {
+ albumGenresValuesBatch[genreCount] = new ContentValues();
+ albumGenresValuesBatch[genreCount].put(MediaContract.AlbumGenres.HOST_ID, hostId);
+ albumGenresValuesBatch[genreCount].put(MediaContract.AlbumGenres.ALBUMID, album.albumid);
+ albumGenresValuesBatch[genreCount].put(MediaContract.AlbumGenres.GENREID, genreId);
+ genreCount++;
+ }
+ }
+
+ contentResolver.bulkInsert(MediaContract.AlbumArtists.CONTENT_URI, albumArtistsValuesBatch);
+ contentResolver.bulkInsert(MediaContract.AlbumGenres.CONTENT_URI, albumGenresValuesBatch);
+ }
+
+ private static void insertSongs(Context context, int hostId) throws ApiException, IOException {
+ AudioLibrary.GetSongs getSongs = new AudioLibrary.GetSongs();
+ ArrayList songList = (ArrayList) getSongs.resultFromJson(Utils.readFile(context, "AudioLibrary.GetSongs.json")).items;
+
+ ContentValues songValuesBatch[] = new ContentValues[songList.size()];
+ for (int i = 0; i < songList.size(); i++) {
+ AudioType.DetailsSong song = songList.get(i);
+ songValuesBatch[i] = SyncUtils.contentValuesFromSong(hostId, song);
+ }
+ context.getContentResolver().bulkInsert(MediaContract.Songs.CONTENT_URI, songValuesBatch);
+ }
+}
diff --git a/app/src/androidTest/java/org/xbmc/kore/testhelpers/EspressoTestUtils.java b/app/src/androidTest/java/org/xbmc/kore/testhelpers/EspressoTestUtils.java
new file mode 100644
index 0000000..9d14fb7
--- /dev/null
+++ b/app/src/androidTest/java/org/xbmc/kore/testhelpers/EspressoTestUtils.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2016 Martijn Brekhof. All rights reserved.
+ *
+ * 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 org.xbmc.kore.testhelpers;
+
+import android.app.Activity;
+import android.content.pm.ActivityInfo;
+import android.content.res.Configuration;
+import android.support.test.espresso.Espresso;
+import android.support.test.espresso.NoMatchingViewException;
+import android.widget.AutoCompleteTextView;
+
+import org.xbmc.kore.R;
+
+import static android.support.test.espresso.Espresso.onData;
+import static android.support.test.espresso.Espresso.onView;
+import static android.support.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu;
+import static android.support.test.espresso.action.ViewActions.clearText;
+import static android.support.test.espresso.action.ViewActions.click;
+import static android.support.test.espresso.action.ViewActions.typeText;
+import static android.support.test.espresso.assertion.ViewAssertions.doesNotExist;
+import static android.support.test.espresso.assertion.ViewAssertions.matches;
+import static android.support.test.espresso.matcher.ViewMatchers.isAssignableFrom;
+import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static android.support.test.espresso.matcher.ViewMatchers.withId;
+import static android.support.test.espresso.matcher.ViewMatchers.withText;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.anything;
+import static org.hamcrest.Matchers.containsString;
+
+public class EspressoTestUtils {
+
+ public static void rotateDevice(Activity activity) {
+ int orientation
+ = activity.getResources().getConfiguration().orientation;
+ activity.setRequestedOrientation(
+ (orientation == Configuration.ORIENTATION_PORTRAIT) ?
+ ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE :
+ ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+ }
+
+ /**
+ * Clicks a menu item regardless if it is in the overflow menu or
+ * visible as icon in the action bar
+ * @param activity
+ * @param name Name of the menu item in the overflow menu
+ * @param resourceId Resource identifier of the menu item
+ */
+ public static void clickMenuItem(Activity activity, String name, int resourceId) {
+ try {
+ onView(withId(resourceId)).perform(click());
+ } catch (NoMatchingViewException e) {
+ openActionBarOverflowOrOptionsMenu(activity);
+ //Use onData as item might not be visible in the View without scrolling
+ onData(allOf(
+ Matchers.withMenuTitle(name)))
+ .perform(click());
+ }
+ }
+
+ public static void clickHomeButton() {
+ onView(withId(android.R.id.home)).perform(click());
+ }
+
+ /**
+ * Clicks on the search menu item and enters the given search query
+ * @param activity
+ * @param query
+ */
+ public static void enterSearchQuery(Activity activity, String query) {
+ EspressoTestUtils.clickMenuItem(activity, activity.getString(R.string.action_search), R.id.action_search);
+
+ onView(isAssignableFrom(AutoCompleteTextView.class))
+ .perform(click(), typeText(query));
+
+ Espresso.closeSoftKeyboard();
+ }
+
+ /**
+ * Clicks on the search menu item and clears the search query by entering the empty string
+ * @param activity
+ */
+ public static void clearSearchQuery(Activity activity) {
+ EspressoTestUtils.clickMenuItem(activity, activity.getString(R.string.action_search), R.id.action_search);
+
+ onView(isAssignableFrom(AutoCompleteTextView.class))
+ .perform(click(), clearText());
+
+ Espresso.closeSoftKeyboard();
+ }
+
+ /**
+ * Clears the search query by pressing the X button
+ * @param activity
+ */
+ public static void clearSearchQueryXButton(Activity activity) {
+ try {
+ onView(withId(R.id.search_close_btn)).perform(click());
+ } catch (NoMatchingViewException e) {
+ EspressoTestUtils.clickMenuItem(activity, activity.getString(R.string.action_search), R.id.action_search);
+ onView(withId(R.id.search_close_btn)).perform(click());
+ }
+ Espresso.closeSoftKeyboard();
+ }
+
+ /**
+ * Performs a click on an item in an adapter view, such as GridView or ListView
+ * @param position
+ * @param resourceId
+ */
+ public static void clickAdapterViewItem(int position, int resourceId) {
+ onData(anything()).inAdapterView(allOf(withId(resourceId), isDisplayed()))
+ .atPosition(position).perform(click());
+ }
+
+ /**
+ * Checks that SearchView contains the given text
+ * @param query text that SearchView should contain
+ */
+ public static void checkTextInSearchQuery(String query) {
+ onView(isAssignableFrom(AutoCompleteTextView.class)).check(matches(withText(query)));
+ }
+
+ /**
+ * Checks that the list contains item(s) matching search query
+ * @param query text each element must contain
+ * @param listSize amount of elements expected in list
+ */
+ public static void checkListMatchesSearchQuery(String query, int listSize, int resourceId) {
+ onView(allOf(withId(resourceId), isDisplayed()))
+ .check(matches(Matchers.withOnlyMatchingDataItems(Matchers.withItemContent(containsString(query)))));
+ onView(allOf(withId(resourceId), isDisplayed()))
+ .check(matches(Matchers.withAdapterSize(listSize)));
+ }
+
+ /**
+ * Checks if search action view does not exist in the current view hierarchy
+ */
+ public static void checkSearchMenuCollapsed() {
+ onView(isAssignableFrom(AutoCompleteTextView.class)).check(doesNotExist());
+ }
+}
diff --git a/app/src/androidTest/java/org/xbmc/kore/testhelpers/LoaderIdlingResource.java b/app/src/androidTest/java/org/xbmc/kore/testhelpers/LoaderIdlingResource.java
new file mode 100644
index 0000000..b979981
--- /dev/null
+++ b/app/src/androidTest/java/org/xbmc/kore/testhelpers/LoaderIdlingResource.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2016 Martijn Brekhof. All rights reserved.
+ *
+ * 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 org.xbmc.kore.testhelpers;
+
+import android.support.test.espresso.IdlingResource;
+import android.support.v4.app.LoaderManager;
+
+public class LoaderIdlingResource implements IdlingResource {
+
+ private ResourceCallback mResourceCallback;
+ private LoaderManager loaderManager;
+
+ public LoaderIdlingResource(LoaderManager loaderManager) {
+ this.loaderManager = loaderManager;
+ }
+
+ @Override
+ public String getName() {
+ return LoaderIdlingResource.class.getName();
+ }
+
+ @Override
+ public boolean isIdleNow() {
+ boolean idle = !loaderManager.hasRunningLoaders();
+ if (idle && mResourceCallback != null) {
+ mResourceCallback.onTransitionToIdle();
+ }
+ return idle;
+ }
+
+ @Override
+ public void registerIdleTransitionCallback(ResourceCallback resourceCallback) {
+ mResourceCallback = resourceCallback;
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/xbmc/kore/testhelpers/Matchers.java b/app/src/androidTest/java/org/xbmc/kore/testhelpers/Matchers.java
new file mode 100644
index 0000000..559b2ba
--- /dev/null
+++ b/app/src/androidTest/java/org/xbmc/kore/testhelpers/Matchers.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2016 Martijn Brekhof. All rights reserved.
+ *
+ * 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 org.xbmc.kore.testhelpers;
+
+import android.database.Cursor;
+import android.support.test.espresso.matcher.BoundedMatcher;
+import android.support.test.espresso.matcher.CursorMatchers;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Adapter;
+import android.widget.AdapterView;
+
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+
+public class Matchers {
+ public static MenuItemTitleMatcher withMenuTitle(String title) {
+ return new MenuItemTitleMatcher(title);
+ }
+
+ public static class MenuItemTitleMatcher extends BaseMatcher