Merge pull request #11 from ultrasonic/feature-9

Add initial usage of new Subsonic API implementation in RESTMusicService
This commit is contained in:
Yahor Berdnikau 2017-07-27 09:37:47 +02:00 committed by GitHub
commit 4d2a8a16bb
20 changed files with 478 additions and 365 deletions

View File

@ -1,7 +1,7 @@
ext.versions = [
minSdk : 14,
targetSdk : 22,
compileSdk : 26,
compileSdk : 22,
buildTools : "25.0.3",
androidTools : "2.3.3",
@ -12,11 +12,12 @@ ext.versions = [
retrofit : "2.1.0",
jackson : "2.8.7",
okhttp : "3.6.0",
junit : "4.12",
mockitoKotlin : "1.3.0",
kluent : "1.15",
okhttp : "3.6.0",
mockitoKotlin : "1.5.0",
kluent : "1.26",
apacheCodecs : "1.10",
]
ext.gradlePlugins = [
@ -30,17 +31,20 @@ ext.androidSupport = [
]
ext.other = [
kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib-common:$versions.kotlin",
kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin",
retrofit : "com.squareup.retrofit2:retrofit:$versions.retrofit",
gsonConverter : "com.squareup.retrofit2:converter-gson:$versions.retrofit",
jacksonConverter : "com.squareup.retrofit2:converter-jackson:$versions.retrofit",
jacksonKotlin : "com.fasterxml.jackson.module:jackson-module-kotlin:$versions.jackson",
okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$versions.okhttp",
]
ext.testing = [
junit : "junit:junit:$versions.junit",
kotlinJunit : "org.jetbrains.kotlin:kotlin-test-junit:$versions.kotlin",
kotlinReflect : "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin",
mockitoKotlin : "com.nhaarman:mockito-kotlin:$versions.mockitoKotlin",
kluent : "org.amshove.kluent:kluent:$versions.kluent",
mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp"
mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp",
apacheCodecs : "commons-codec:commons-codec:$versions.apacheCodecs",
]

View File

@ -12,10 +12,13 @@ dependencies {
compile other.retrofit
compile other.jacksonConverter
compile other.jacksonKotlin
compile other.okhttpLogging
testCompile testing.junit
testCompile testing.kotlinJunit
testCompile testing.kotlinReflect
testCompile testing.mockitoKotlin
testCompile testing.kluent
testCompile testing.mockWebServer
testCompile testing.apacheCodecs
}

View File

@ -4,23 +4,31 @@ import okhttp3.mockwebserver.MockResponse
import okio.Okio
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should contain`
import org.amshove.kluent.`should equal to`
import org.amshove.kluent.`should equal`
import org.amshove.kluent.`should not be`
import org.amshove.kluent.`should not contain`
import org.apache.commons.codec.binary.Hex
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.moire.ultrasonic.api.subsonic.models.*
import org.moire.ultrasonic.api.subsonic.models.Artist
import org.moire.ultrasonic.api.subsonic.models.Index
import org.moire.ultrasonic.api.subsonic.models.License
import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
import org.moire.ultrasonic.api.subsonic.models.MusicFolder
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule
import retrofit2.Response
import java.nio.charset.Charset
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.*
/**
* Integration test for [SubsonicAPI] class.
* Integration test for [SubsonicAPIClient] class.
*/
class SubsonicAPITest {
class SubsonicAPIClientTest {
companion object {
const val USERNAME = "some-user"
const val PASSWORD = "some-password"
@ -30,19 +38,58 @@ class SubsonicAPITest {
@JvmField @Rule val mockWebServerRule = MockWebServerRule()
private lateinit var api: SubsonicAPI
private lateinit var client: SubsonicAPIClient
@Before
fun setUp() {
api = SubsonicAPI(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD,
client = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD,
CLIENT_VERSION, CLIENT_ID)
}
@Test
fun `Should pass password hash and salt in query params for api version 1_13_0`() {
val clientV12 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME,
PASSWORD, SubsonicAPIVersions.V1_14_0, CLIENT_ID)
enqueueResponse("ping_ok.json")
clientV12.api.ping().execute()
with(mockWebServerRule.mockWebServer.takeRequest()) {
requestLine `should contain` "&s="
requestLine `should contain` "&t="
requestLine `should not contain` "&p=enc:"
val salt = requestLine.split('&').find { it.startsWith("s=") }?.substringAfter('=')
val token = requestLine.split('&').find { it.startsWith("t=") }?.substringAfter('=')
val expectedToken = String(Hex.encodeHex(MessageDigest.getInstance("MD5")
.digest("$PASSWORD$salt".toByteArray()), false))
token!! `should equal` expectedToken
}
}
@Test
fun `Should pass hex encoded password in query params for api version 1_12_0`() {
val clientV11 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME,
PASSWORD, SubsonicAPIVersions.V1_12_0, CLIENT_ID)
enqueueResponse("ping_ok.json")
clientV11.api.ping().execute()
with(mockWebServerRule.mockWebServer.takeRequest()) {
requestLine `should not contain` "&s="
requestLine `should not contain` "&t="
requestLine `should contain` "&p=enc:"
val passParam = requestLine.split('&').find { it.startsWith("p=enc:") }
val encodedPassword = String(Hex.encodeHex(PASSWORD.toByteArray(), false))
passParam `should equal` "p=enc:$encodedPassword"
}
}
@Test
fun `Should parse ping ok response`() {
enqueueResponse("ping_ok.json")
val response = api.getApi().ping().execute()
val response = client.api.ping().execute()
assertResponseSuccessful(response)
with(response.body()) {
@ -52,34 +99,41 @@ class SubsonicAPITest {
@Test
fun `Should parse ping error response`() {
checkErrorCallParsed { api.getApi().ping().execute() }
checkErrorCallParsed { client.api.ping().execute() }
}
@Test
fun `Should parse get license ok response`() {
enqueueResponse("license_ok.json")
val response = api.getApi().getLicense().execute()
val response = client.api.getLicense().execute()
assertResponseSuccessful(response)
with(response.body()) {
assertBaseResponseOk()
license `should equal` License(true, parseDate("2016-11-23T20:17:15.206Z"))
license `should equal` License(valid = true,
trialExpires = parseDate("2016-11-23T20:17:15.206Z"),
email = "someone@example.net",
licenseExpires = parseDate("8994-08-17T07:12:55.807Z"))
}
}
@Test
fun `Should parse get license error response`() {
val response = checkErrorCallParsed { api.getApi().getLicense().execute() }
val response = checkErrorCallParsed { client.api.getLicense().execute() }
response.license `should be` null
response.license `should not be` null
with(response.license) {
email `should equal to` ""
valid `should equal to` false
}
}
@Test
fun `Should parse get music folders ok response`() {
enqueueResponse("get_music_folders_ok.json")
val response = api.getApi().getMusicFolders().execute()
val response = client.api.getMusicFolders().execute()
assertResponseSuccessful(response)
with(response.body()) {
@ -90,9 +144,9 @@ class SubsonicAPITest {
@Test
fun `Should parse get music folders error response`() {
val response = checkErrorCallParsed { api.getApi().getMusicFolders().execute() }
val response = checkErrorCallParsed { client.api.getMusicFolders().execute() }
response.musicFolders `should be` null
response.musicFolders `should equal` emptyList()
}
@Test
@ -100,7 +154,7 @@ class SubsonicAPITest {
// TODO: check for shortcut parsing
enqueueResponse("get_indexes_ok.json")
val response = api.getApi().getIndexes(null, null).execute()
val response = client.api.getIndexes(null, null).execute()
assertResponseSuccessful(response)
response.body().indexes `should not be` null
@ -126,7 +180,7 @@ class SubsonicAPITest {
enqueueResponse("get_indexes_ok.json")
val musicFolderId = 9L
api.getApi().getIndexes(musicFolderId, null).execute()
client.api.getIndexes(musicFolderId, null).execute()
with(mockWebServerRule.mockWebServer.takeRequest()) {
requestLine `should contain` "musicFolderId=$musicFolderId"
@ -138,7 +192,7 @@ class SubsonicAPITest {
enqueueResponse("get_indexes_ok.json")
val ifModifiedSince = System.currentTimeMillis()
api.getApi().getIndexes(null, ifModifiedSince).execute()
client.api.getIndexes(null, ifModifiedSince).execute()
with(mockWebServerRule.mockWebServer.takeRequest()) {
requestLine `should contain` "ifModifiedSince=$ifModifiedSince"
@ -151,7 +205,7 @@ class SubsonicAPITest {
val musicFolderId = 110L
val ifModifiedSince = System.currentTimeMillis()
api.getApi().getIndexes(musicFolderId, ifModifiedSince).execute()
client.api.getIndexes(musicFolderId, ifModifiedSince).execute()
with(mockWebServerRule.mockWebServer.takeRequest()) {
requestLine `should contain` "musicFolderId=$musicFolderId"
@ -161,14 +215,14 @@ class SubsonicAPITest {
@Test
fun `Should parse get indexes error response`() {
val response = checkErrorCallParsed { api.getApi().getIndexes(null, null).execute() }
val response = checkErrorCallParsed { client.api.getIndexes(null, null).execute() }
response.indexes `should be` null
}
@Test
fun `Should parse getMusicDirectory error response`() {
val response = checkErrorCallParsed { api.getApi().getMusicDirectory(1).execute() }
val response = checkErrorCallParsed { client.api.getMusicDirectory(1).execute() }
response.musicDirectory `should be` null
}
@ -178,7 +232,7 @@ class SubsonicAPITest {
enqueueResponse("get_music_directory_ok.json")
val directoryId = 124L
api.getApi().getMusicDirectory(directoryId).execute()
client.api.getMusicDirectory(directoryId).execute()
mockWebServerRule.mockWebServer.takeRequest().requestLine `should contain` "id=$directoryId"
}
@ -187,7 +241,7 @@ class SubsonicAPITest {
fun `Should parse get music directory ok response`() {
enqueueResponse("get_music_directory_ok.json")
val response = api.getApi().getMusicDirectory(1).execute()
val response = client.api.getMusicDirectory(1).execute()
assertResponseSuccessful(response)

View File

@ -4,6 +4,8 @@
"version" : "1.13.0",
"license" : {
"valid" : true,
"email" : "someone@example.net",
"licenseExpires" : "8994-08-17T07:12:55.807Z",
"trialExpires" : "2016-11-23T20:17:15.206Z"
}
}

View File

@ -1,59 +0,0 @@
package org.moire.ultrasonic.api.subsonic
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import java.math.BigInteger
/**
* Main entry for Subsonic API calls.
*
* @see SubsonicAPI http://www.subsonic.org/pages/api.jsp
*/
class SubsonicAPI(baseUrl: String,
username: String,
private val password: String,
clientProtocolVersion: SubsonicAPIVersions,
clientID: String) {
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor { chain ->
// Adds default request params
val originalRequest = chain.request()
val newUrl = originalRequest.url().newBuilder()
.addQueryParameter("u", username)
.addQueryParameter("p", passwordHex())
.addQueryParameter("v", clientProtocolVersion.restApiVersion)
.addQueryParameter("c", clientID)
.addQueryParameter("f", "json")
.build()
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
}.build()
private val jacksonMapper = ObjectMapper()
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
.registerModule(KotlinModule())
private val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(JacksonConverterFactory.create(jacksonMapper))
.build()
private val subsonicAPI = retrofit.create(SubsonicAPIDefinition::class.java)
/**
* Get API instance.
*
* @return initialized API instance
*/
fun getApi() = subsonicAPI
private fun passwordHex() = "enc:${password.toHexBytes()}"
private fun String.toHexBytes(): String {
return String.format("%040x", BigInteger(1, this.toByteArray()))
}
}

View File

@ -0,0 +1,113 @@
package org.moire.ultrasonic.api.subsonic
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import java.lang.IllegalStateException
import java.math.BigInteger
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
/**
* Subsonic API client that provides api access.
*
* For supported API calls see [SubsonicAPIDefinition].
*
* @author Yahor Berdnikau
*/
class SubsonicAPIClient(baseUrl: String,
username: String,
private val password: String,
clientProtocolVersion: SubsonicAPIVersions,
clientID: String,
debug: Boolean = false) {
companion object {
internal val HEX_ARRAY = "0123456789ABCDEF".toCharArray()
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor { chain ->
// Adds default request params
val originalRequest = chain.request()
val newUrl = originalRequest.url().newBuilder()
.addQueryParameter("u", username)
.also {
it.addPasswordQueryParam(clientProtocolVersion)
}
.addQueryParameter("v", clientProtocolVersion.restApiVersion)
.addQueryParameter("c", clientID)
.addQueryParameter("f", "json")
.build()
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
}
.also {
if (debug) {
it.addLogging()
}
}.build()
private val jacksonMapper = ObjectMapper()
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
.registerModule(KotlinModule())
private val retrofit = Retrofit.Builder()
.baseUrl("$baseUrl/rest/")
.client(okHttpClient)
.addConverterFactory(JacksonConverterFactory.create(jacksonMapper))
.build()
val api: SubsonicAPIDefinition = retrofit.create(SubsonicAPIDefinition::class.java)
private val salt: String by lazy {
val secureRandom = SecureRandom()
BigInteger(130, secureRandom).toString(32)
}
private val passwordMD5Hash: String by lazy {
try {
val md5Digest = MessageDigest.getInstance("MD5")
md5Digest.digest("$password$salt".toByteArray()).toHexBytes()
} catch (e: NoSuchAlgorithmException) {
throw IllegalStateException(e)
}
}
private val passwordHex: String by lazy {
"enc:${password.toHexBytes()}"
}
private fun String.toHexBytes(): String {
return this.toByteArray().toHexBytes()
}
private fun ByteArray.toHexBytes(): String {
val hexChars = CharArray(this.size * 2)
for (j in 0..this.lastIndex) {
val v = this[j].toInt().and(0xFF)
hexChars[j * 2] = HEX_ARRAY[v.ushr(4)]
hexChars[j * 2 + 1] = HEX_ARRAY[v.and(0x0F)]
}
return String(hexChars)
}
private fun OkHttpClient.Builder.addLogging() {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BASIC
this.addInterceptor(loggingInterceptor)
}
private fun HttpUrl.Builder.addPasswordQueryParam(clientProtocolVersion: SubsonicAPIVersions) {
if (clientProtocolVersion < SubsonicAPIVersions.V1_13_0) {
this.addQueryParameter("p", passwordHex)
} else {
this.addQueryParameter("t", passwordMD5Hash)
this.addQueryParameter("s", salt)
}
}
}

View File

@ -25,9 +25,11 @@ enum class SubsonicAPIVersions(val subsonicVersions: String, val restApiVersion:
V1_11_0("5.1", "1.11.0"),
V1_12_0("5.2", "1.12.0"),
V1_13_0("5.3", "1.13.0"),
V1_14_0("6.0", "1.14.0");
V1_14_0("6.0", "1.14.0"),
V1_15_0("6.1", "1.15.0");
companion object {
@JvmStatic
fun fromApiVersion(apiVersion: String): SubsonicAPIVersions {
when (apiVersion) {
"1.1.0" -> return V1_1_0
@ -45,6 +47,7 @@ enum class SubsonicAPIVersions(val subsonicVersions: String, val restApiVersion:
"1.12.0" -> return V1_12_0
"1.13.0" -> return V1_13_0
"1.14.0" -> return V1_14_0
"1.15.0" -> return V1_15_0
}
throw IllegalArgumentException("Unknown api version $apiVersion")
}

View File

@ -1,5 +1,9 @@
package org.moire.ultrasonic.api.subsonic.models
import java.util.*
import java.util.Calendar
data class License(val valid: Boolean, val trialExpires: Calendar)
data class License(
val valid: Boolean = false,
val email: String = "",
val trialExpires: Calendar = Calendar.getInstance(),
val licenseExpires: Calendar = Calendar.getInstance())

View File

@ -1,3 +1,3 @@
package org.moire.ultrasonic.api.subsonic.models
data class MusicFolder(val id: Long, val name: String)
data class MusicFolder(val id: Long = -1, val name: String = "")

View File

@ -3,9 +3,8 @@ package org.moire.ultrasonic.api.subsonic.response
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
import org.moire.ultrasonic.api.subsonic.SubsonicError
import org.moire.ultrasonic.api.subsonic.models.License
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
class LicenseResponse(val license: License?,
class LicenseResponse(val license: License = License(),
status: Status,
version: SubsonicAPIVersions,
error: SubsonicError?):

View File

@ -14,7 +14,7 @@ class MusicFoldersResponse(status: Status,
version: SubsonicAPIVersions,
error: SubsonicError?,
@JsonDeserialize(using = MusicFoldersDeserializer::class)
val musicFolders: List<MusicFolder>?):
val musicFolders: List<MusicFolder> = emptyList()):
SubsonicResponse(status, version, error) {
companion object {
class MusicFoldersDeserializer(): JsonDeserializer<List<MusicFolder>>() {

View File

@ -19,8 +19,13 @@ android {
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
test.java.srcDirs += 'src/test/kotlin'
}
packagingOptions {
exclude 'META-INF/LICENSE'
}
}
dependencies {
@ -40,8 +45,10 @@ dependencies {
}
testCompile(testing.mockitoKotlin) {
exclude module: "kotlin-stdlib"
exclude module: "kotlin-reflect"
}
testCompile(testing.kluent) {
exclude module: "kotlin-stdlib"
exclude module: "kotlin-reflect"
}
}

View File

@ -19,20 +19,81 @@
package org.moire.ultrasonic.service;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import org.moire.ultrasonic.BuildConfig;
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient;
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.Util;
/**
* @author Sindre Mehus
* @version $Id$
*/
public class MusicServiceFactory
{
private static final MusicService REST_MUSIC_SERVICE = new CachedMusicService(new RESTMusicService());
private static final MusicService OFFLINE_MUSIC_SERVICE = new OfflineMusicService();
public class MusicServiceFactory {
private static final String LOG_TAG = MusicServiceFactory.class.getSimpleName();
private static MusicService REST_MUSIC_SERVICE = null;
private static MusicService OFFLINE_MUSIC_SERVICE = null;
public static MusicService getMusicService(Context context)
{
return Util.isOffline(context) ? OFFLINE_MUSIC_SERVICE : REST_MUSIC_SERVICE;
}
public static MusicService getMusicService(Context context) {
if (Util.isOffline(context)) {
Log.d(LOG_TAG, "App is offline, returning offline music service.");
if (OFFLINE_MUSIC_SERVICE == null) {
synchronized (MusicServiceFactory.class) {
if (OFFLINE_MUSIC_SERVICE == null) {
OFFLINE_MUSIC_SERVICE = new OfflineMusicService(createSubsonicApiClient(context));
}
}
}
return OFFLINE_MUSIC_SERVICE;
} else {
Log.d(LOG_TAG, "Returning rest music service");
if (REST_MUSIC_SERVICE == null) {
synchronized (MusicServiceFactory.class) {
if (REST_MUSIC_SERVICE == null) {
REST_MUSIC_SERVICE = new CachedMusicService(new RESTMusicService(
createSubsonicApiClient(context)));
}
}
}
return REST_MUSIC_SERVICE;
}
}
/**
* Resets {@link MusicService} to initial state, so on next call to {@link #getMusicService(Context)}
* it will return updated instance of it.
*/
public static void resetMusicService() {
Log.d(LOG_TAG, "Resetting music service");
synchronized (MusicServiceFactory.class) {
REST_MUSIC_SERVICE = null;
OFFLINE_MUSIC_SERVICE = null;
}
}
private static SubsonicAPIClient createSubsonicApiClient(final Context context) {
final SharedPreferences preferences = Util.getPreferences(context);
int instance = preferences.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1);
String serverUrl = preferences.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null);
String username = preferences.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null);
String password = preferences.getString(Constants.PREFERENCES_KEY_PASSWORD + instance, null);
if (serverUrl == null ||
username == null ||
password == null) {
Log.i("MusicServiceFactory", "Server credentials is not available");
return new SubsonicAPIClient("http://localhost", "", "",
SubsonicAPIVersions.fromApiVersion(Constants.REST_PROTOCOL_VERSION),
Constants.REST_CLIENT_ID, BuildConfig.DEBUG);
}
return new SubsonicAPIClient(serverUrl, username, password,
SubsonicAPIVersions.fromApiVersion(Constants.REST_PROTOCOL_VERSION),
Constants.REST_CLIENT_ID, BuildConfig.DEBUG);
}
}

View File

@ -23,6 +23,7 @@ import android.graphics.Bitmap;
import android.media.MediaMetadataRetriever;
import android.util.Log;
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient;
import org.moire.ultrasonic.domain.Artist;
import org.moire.ultrasonic.domain.Genre;
import org.moire.ultrasonic.domain.Indexes;
@ -66,7 +67,11 @@ public class OfflineMusicService extends RESTMusicService
private static final String TAG = OfflineMusicService.class.getSimpleName();
private static final Pattern COMPILE = Pattern.compile(" ");
@Override
public OfflineMusicService(SubsonicAPIClient subsonicAPIClient) {
super(subsonicAPIClient);
}
@Override
public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception
{
return true;

View File

@ -23,57 +23,11 @@ import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.util.Log;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.Test.service.GetPodcastEpisodesTestReaderProvider;
import org.moire.ultrasonic.domain.Bookmark;
import org.moire.ultrasonic.domain.ChatMessage;
import org.moire.ultrasonic.domain.Genre;
import org.moire.ultrasonic.domain.Indexes;
import org.moire.ultrasonic.domain.JukeboxStatus;
import org.moire.ultrasonic.domain.Lyrics;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.domain.MusicFolder;
import org.moire.ultrasonic.domain.Playlist;
import org.moire.ultrasonic.domain.PodcastEpisode;
import org.moire.ultrasonic.domain.PodcastsChannel;
import org.moire.ultrasonic.domain.SearchCriteria;
import org.moire.ultrasonic.domain.SearchResult;
import org.moire.ultrasonic.domain.ServerInfo;
import org.moire.ultrasonic.domain.Share;
import org.moire.ultrasonic.domain.UserInfo;
import org.moire.ultrasonic.domain.Version;
import org.moire.ultrasonic.service.parser.AlbumListParser;
import org.moire.ultrasonic.service.parser.BookmarkParser;
import org.moire.ultrasonic.service.parser.ChatMessageParser;
import org.moire.ultrasonic.service.parser.ErrorParser;
import org.moire.ultrasonic.service.parser.GenreParser;
import org.moire.ultrasonic.service.parser.IndexesParser;
import org.moire.ultrasonic.service.parser.JukeboxStatusParser;
import org.moire.ultrasonic.service.parser.LicenseParser;
import org.moire.ultrasonic.service.parser.LyricsParser;
import org.moire.ultrasonic.service.parser.MusicDirectoryParser;
import org.moire.ultrasonic.service.parser.MusicFoldersParser;
import org.moire.ultrasonic.service.parser.PlaylistParser;
import org.moire.ultrasonic.service.parser.PlaylistsParser;
import org.moire.ultrasonic.service.parser.PodcastEpisodeParser;
import org.moire.ultrasonic.service.parser.PodcastsChannelsParser;
import org.moire.ultrasonic.service.parser.RandomSongsParser;
import org.moire.ultrasonic.service.parser.SearchResult2Parser;
import org.moire.ultrasonic.service.parser.SearchResultParser;
import org.moire.ultrasonic.service.parser.ShareParser;
import org.moire.ultrasonic.service.parser.UserInfoParser;
import org.moire.ultrasonic.service.parser.VersionParser;
import org.moire.ultrasonic.service.ssl.SSLSocketFactory;
import org.moire.ultrasonic.service.ssl.TrustSelfSignedStrategy;
import org.moire.ultrasonic.util.CancellableTask;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.FileUtil;
import org.moire.ultrasonic.util.ProgressListener;
import org.moire.ultrasonic.util.StreamProxy;
import org.moire.ultrasonic.util.Util;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
@ -100,6 +54,53 @@ import org.apache.http.params.HttpParams;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.ExecutionContext;
import org.apache.http.protocol.HttpContext;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient;
import org.moire.ultrasonic.api.subsonic.response.LicenseResponse;
import org.moire.ultrasonic.api.subsonic.response.MusicFoldersResponse;
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse;
import org.moire.ultrasonic.data.APIConverter;
import org.moire.ultrasonic.domain.Bookmark;
import org.moire.ultrasonic.domain.ChatMessage;
import org.moire.ultrasonic.domain.Genre;
import org.moire.ultrasonic.domain.Indexes;
import org.moire.ultrasonic.domain.JukeboxStatus;
import org.moire.ultrasonic.domain.Lyrics;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.domain.MusicFolder;
import org.moire.ultrasonic.domain.Playlist;
import org.moire.ultrasonic.domain.PodcastsChannel;
import org.moire.ultrasonic.domain.SearchCriteria;
import org.moire.ultrasonic.domain.SearchResult;
import org.moire.ultrasonic.domain.Share;
import org.moire.ultrasonic.domain.UserInfo;
import org.moire.ultrasonic.domain.Version;
import org.moire.ultrasonic.service.parser.AlbumListParser;
import org.moire.ultrasonic.service.parser.BookmarkParser;
import org.moire.ultrasonic.service.parser.ChatMessageParser;
import org.moire.ultrasonic.service.parser.ErrorParser;
import org.moire.ultrasonic.service.parser.GenreParser;
import org.moire.ultrasonic.service.parser.IndexesParser;
import org.moire.ultrasonic.service.parser.JukeboxStatusParser;
import org.moire.ultrasonic.service.parser.LyricsParser;
import org.moire.ultrasonic.service.parser.MusicDirectoryParser;
import org.moire.ultrasonic.service.parser.PlaylistParser;
import org.moire.ultrasonic.service.parser.PlaylistsParser;
import org.moire.ultrasonic.service.parser.PodcastEpisodeParser;
import org.moire.ultrasonic.service.parser.PodcastsChannelsParser;
import org.moire.ultrasonic.service.parser.RandomSongsParser;
import org.moire.ultrasonic.service.parser.SearchResult2Parser;
import org.moire.ultrasonic.service.parser.SearchResultParser;
import org.moire.ultrasonic.service.parser.ShareParser;
import org.moire.ultrasonic.service.parser.UserInfoParser;
import org.moire.ultrasonic.service.parser.VersionParser;
import org.moire.ultrasonic.service.ssl.SSLSocketFactory;
import org.moire.ultrasonic.service.ssl.TrustSelfSignedStrategy;
import org.moire.ultrasonic.util.CancellableTask;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.FileUtil;
import org.moire.ultrasonic.util.ProgressListener;
import org.moire.ultrasonic.util.Util;
import java.io.BufferedWriter;
import java.io.File;
@ -110,8 +111,6 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.io.StringReader;
import java.lang.reflect.Array;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
@ -119,9 +118,10 @@ import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import retrofit2.Response;
import static java.util.Arrays.asList;
/**
@ -155,11 +155,12 @@ public class RESTMusicService implements MusicService
private String redirectFrom;
private String redirectTo;
private final ThreadSafeClientConnManager connManager;
private SubsonicAPIClient subsonicAPIClient;
public RESTMusicService()
{
public RESTMusicService(SubsonicAPIClient subsonicAPIClient) {
this.subsonicAPIClient = subsonicAPIClient;
// Create and initialize default HTTP parameters
// Create and initialize default HTTP parameters
HttpParams params = new BasicHttpParams();
ConnManagerParams.setMaxTotalConnections(params, 20);
ConnManagerParams.setMaxConnectionsPerRoute(params, new ConnPerRouteBean(20));
@ -195,56 +196,43 @@ public class RESTMusicService implements MusicService
}
}
@Override
public void ping(Context context, ProgressListener progressListener) throws Exception
{
Reader reader = getReader(context, progressListener, "ping", null);
try
{
new ErrorParser(context).parse(reader);
}
finally
{
Util.close(reader);
}
}
@Override
public void ping(Context context, ProgressListener progressListener) throws Exception {
updateProgressListener(progressListener, R.string.service_connecting);
@Override
public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception
{
Reader reader = getReader(context, progressListener, "getLicense", null);
try
{
ServerInfo serverInfo = new LicenseParser(context).parse(reader);
return serverInfo.isLicenseValid();
}
finally
{
Util.close(reader);
}
}
final Response<SubsonicResponse> response = subsonicAPIClient.getApi().ping().execute();
checkResponseSuccessful(response);
}
@Override
public List<MusicFolder> getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception
{
List<MusicFolder> cachedMusicFolders = readCachedMusicFolders(context);
if (cachedMusicFolders != null && !refresh)
{
return cachedMusicFolders;
}
@Override
public boolean isLicenseValid(Context context, ProgressListener progressListener)
throws Exception {
updateProgressListener(progressListener, R.string.service_connecting);
Reader reader = getReader(context, progressListener, "getMusicFolders", null);
try
{
List<MusicFolder> musicFolders = new MusicFoldersParser(context).parse(reader, progressListener);
writeCachedMusicFolders(context, musicFolders);
return musicFolders;
}
finally
{
Util.close(reader);
}
}
final Response<LicenseResponse> response = subsonicAPIClient.getApi().getLicense().execute();
checkResponseSuccessful(response);
return response.body().getLicense().getValid();
}
@Override
public List<MusicFolder> getMusicFolders(boolean refresh,
Context context,
ProgressListener progressListener) throws Exception {
List<MusicFolder> cachedMusicFolders = readCachedMusicFolders(context);
if (cachedMusicFolders != null && !refresh) {
return cachedMusicFolders;
}
updateProgressListener(progressListener, R.string.parser_reading);
Response<MusicFoldersResponse> response = subsonicAPIClient.getApi().getMusicFolders().execute();
checkResponseSuccessful(response);
List<MusicFolder> musicFolders = APIConverter
.convertMusicFolderList(response.body().getMusicFolders());
writeCachedMusicFolders(context, musicFolders);
return musicFolders;
}
@Override
public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception
@ -348,17 +336,15 @@ public class RESTMusicService implements MusicService
return String.format("indexes-%d.ser", Math.abs(s.hashCode()));
}
private static ArrayList<MusicFolder> readCachedMusicFolders(Context context)
{
String filename = getCachedMusicFoldersFilename(context);
return FileUtil.deserialize(context, filename);
}
private static List<MusicFolder> readCachedMusicFolders(Context context) {
String filename = getCachedMusicFoldersFilename(context);
return FileUtil.deserialize(context, filename);
}
private static void writeCachedMusicFolders(Context context, List<MusicFolder> musicFolders)
{
String filename = getCachedMusicFoldersFilename(context);
FileUtil.serialize(context, new ArrayList<MusicFolder>(musicFolders), filename);
}
private static void writeCachedMusicFolders(Context context, List<MusicFolder> musicFolders) {
String filename = getCachedMusicFoldersFilename(context);
FileUtil.serialize(context, new ArrayList<>(musicFolders), filename);
}
private static String getCachedMusicFoldersFilename(Context context)
{
@ -1788,4 +1774,25 @@ public class RESTMusicService implements MusicService
}
}
private void updateProgressListener(@Nullable final ProgressListener progressListener,
@StringRes final int messageId) {
if (progressListener != null) {
progressListener.updateProgress(messageId);
}
}
private void checkResponseSuccessful(@NonNull final Response<? extends SubsonicResponse> response)
throws IOException {
if (response.isSuccessful() &&
response.body().getStatus() == SubsonicResponse.Status.OK) {
return;
}
if (response.body().getStatus() == SubsonicResponse.Status.ERROR &&
response.body().getError() != null) {
throw new IOException("Server error: " + response.body().getError().getCode());
} else {
throw new IOException("Failed to perform request: " + response.code());
}
}
}

View File

@ -1,73 +0,0 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.service.parser;
import android.content.Context;
import org.xmlpull.v1.XmlPullParser;
import java.io.Reader;
import org.moire.ultrasonic.domain.ServerInfo;
import org.moire.ultrasonic.domain.Version;
/**
* @author Sindre Mehus
*/
public class LicenseParser extends AbstractParser
{
public LicenseParser(Context context)
{
super(context);
}
public ServerInfo parse(Reader reader) throws Exception
{
init(reader);
ServerInfo serverInfo = new ServerInfo();
int eventType;
do
{
eventType = nextParseEvent();
if (eventType == XmlPullParser.START_TAG)
{
String name = getElementName();
if ("subsonic-response".equals(name))
{
serverInfo.setRestVersion(new Version(get("version")));
}
else if ("license".equals(name))
{
serverInfo.setLicenseValid(getBoolean("valid"));
}
else if ("error".equals(name))
{
handleError();
}
}
} while (eventType != XmlPullParser.END_DOCUMENT);
validate();
return serverInfo;
}
}

View File

@ -1,77 +0,0 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.service.parser;
import java.io.Reader;
import java.util.ArrayList;
import java.util.List;
import org.xmlpull.v1.XmlPullParser;
import android.content.Context;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.MusicFolder;
import org.moire.ultrasonic.util.ProgressListener;
/**
* @author Sindre Mehus
*/
public class MusicFoldersParser extends AbstractParser
{
public MusicFoldersParser(Context context)
{
super(context);
}
public List<MusicFolder> parse(Reader reader, ProgressListener progressListener) throws Exception
{
updateProgress(progressListener, R.string.parser_reading);
init(reader);
List<MusicFolder> result = new ArrayList<MusicFolder>();
int eventType;
do
{
eventType = nextParseEvent();
if (eventType == XmlPullParser.START_TAG)
{
String tag = getElementName();
if ("musicFolder".equals(tag))
{
String id = get("id");
String name = get("name");
result.add(new MusicFolder(id, name));
}
else if ("error".equals(tag))
{
handleError();
}
}
} while (eventType != XmlPullParser.END_DOCUMENT);
validate();
updateProgress(progressListener, R.string.parser_reading_done);
return result;
}
}

View File

@ -67,6 +67,7 @@ import org.moire.ultrasonic.service.DownloadService;
import org.moire.ultrasonic.service.DownloadServiceImpl;
import org.apache.http.HttpEntity;
import org.moire.ultrasonic.service.MusicServiceFactory;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
@ -192,6 +193,7 @@ public class Util extends DownloadActivity
public static void setActiveServer(Context context, int instance)
{
MusicServiceFactory.resetMusicService();
SharedPreferences preferences = getPreferences(context);
SharedPreferences.Editor editor = preferences.edit();
editor.putInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, instance);

View File

@ -0,0 +1,15 @@
// Converts entities from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] to app entities.
@file:JvmName("APIConverter")
package org.moire.ultrasonic.data
import org.moire.ultrasonic.domain.MusicFolder
typealias APIMusicFolder = org.moire.ultrasonic.api.subsonic.models.MusicFolder
fun convertMusicFolder(entity: APIMusicFolder): MusicFolder {
return MusicFolder(entity.id.toString(), entity.name)
}
fun convertMusicFolderList(entitiesList: List<APIMusicFolder>): List<MusicFolder> {
return entitiesList.map { convertMusicFolder(it) }
}

View File

@ -0,0 +1,43 @@
@file:Suppress("IllegalIdentifier")
package org.moire.ultrasonic.data
import org.amshove.kluent.`should equal to`
import org.junit.Test
import org.moire.ultrasonic.api.subsonic.models.MusicFolder
/**
* Unit test for functions in SubsonicAPIConverter file.
*
* @author Yahor Berdnikau
*/
class APIConverterTest {
@Test
fun `Should convert MusicFolder entity`() {
val entity = createMusicFolder(10, "some-name")
val convertedEntity = convertMusicFolder(entity)
convertedEntity.name `should equal to` "some-name"
convertedEntity.id `should equal to` 10.toString()
}
@Test
fun `Should convert list of MusicFolder entities`() {
val entityList = listOf(
createMusicFolder(3, "some-name-3"),
createMusicFolder(4, "some-name-4")
)
val convertedList = convertMusicFolderList(entityList)
convertedList.size `should equal to` 2
convertedList[0].id `should equal to` 3.toString()
convertedList[0].name `should equal to` "some-name-3"
convertedList[1].id `should equal to` 4.toString()
convertedList[1].name `should equal to` "some-name-4"
}
private fun createMusicFolder(id: Long = 0, name: String = ""): MusicFolder =
MusicFolder(id, name)
}