diff --git a/build.gradle b/build.gradle index a7c6c440..0c47a8d1 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,7 @@ buildscript { } dependencies { classpath gradlePlugins.androidTools + classpath gradlePlugins.kotlin } } diff --git a/dependencies.gradle b/dependencies.gradle index 913e873a..403b4fe7 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -7,13 +7,41 @@ ext.versions = [ androidTools : "2.2.3", androidSupport : "22.2.1", + + kotlin : "1.0.6", + + retrofit : "2.1.0", + + junit : "4.12", + mockitoKotlin : "1.3.0", + kluent : "1.14", + okhttp : "3.6.0", ] ext.gradlePlugins = [ - androidTools : "com.android.tools.build:gradle:$versions.androidTools" + androidTools : "com.android.tools.build:gradle:$versions.androidTools", + kotlin : "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin", ] ext.androidSupport = [ support : "com.android.support:support-v4:$versions.androidSupport", design : "com.android.support:design:$versions.androidSupport", ] + +ext.kotlin = [ + stdlib : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin" +] + +ext.other = [ + retrofit : "com.squareup.retrofit2:retrofit:$versions.retrofit", + gsonConverter : "com.squareup.retrofit2:converter-gson:$versions.retrofit", + simpleXmlConverter : "com.squareup.retrofit2:converter-simplexml:$versions.retrofit", +] + +ext.testing = [ + junit : "junit:junit:$versions.junit", + kotlinJunit : "org.jetbrains.kotlin:kotlin-test-junit:$versions.kotlin", + mockitoKotlin : "com.nhaarman:mockito-kotlin:$versions.mockitoKotlin", + kluent : "org.amshove.kluent:kluent:$versions.kluent", + mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp" +] diff --git a/settings.gradle b/settings.gradle index 65e3df07..7601205f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,4 @@ -include ':library' +include ':library', ':subsonic-api' include ':menudrawer' include ':pulltorefresh' include ':ultrasonic' diff --git a/subsonic-api/build.gradle b/subsonic-api/build.gradle index 88258c77..b9e606ee 100644 --- a/subsonic-api/build.gradle +++ b/subsonic-api/build.gradle @@ -1,14 +1,21 @@ -apply plugin: 'java' apply plugin: 'kotlin' +sourceSets { + main.java.srcDirs += 'src/main/kotlin' + test.java.srcDirs += 'src/integrationTest/kotlin' + test.resources.srcDirs += 'src/integrationTest/resources' + test.output.resourcesDir = test.output.classesDir +} + dependencies { - compile other.okhttp + compile kotlin.stdlib + + compile other.retrofit + compile other.gsonConverter testCompile testing.junit testCompile testing.kotlinJunit testCompile testing.mockitoKotlin testCompile testing.kluent -} - -sourceCompatibility = "1.7" -targetCompatibility = "1.7" + testCompile testing.mockWebServer +} \ No newline at end of file diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPITest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPITest.kt new file mode 100644 index 00000000..80c9d8bd --- /dev/null +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPITest.kt @@ -0,0 +1,92 @@ +package org.moire.ultrasonic.api.subsonic + +import okhttp3.mockwebserver.MockResponse +import okio.Okio +import org.amshove.kluent.`should be` +import org.amshove.kluent.`should not be` +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.moire.ultrasonic.api.subsonic.models.SubsonicResponse +import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule +import retrofit2.Response +import java.nio.charset.Charset + +/** + * Integration test for [SubsonicAPI] class. + */ +class SubsonicAPITest { + companion object { + val USERNAME = "some-user" + val PASSWORD = "some-password" + val CLIENT_VERSION = SubsonicAPIVersions.V1_13_0 + val CLIENT_ID = "test-client" + } + + @JvmField + @Rule + val mockWebServerRule = MockWebServerRule() + + private lateinit var api: SubsonicAPI + + @Before + fun setUp() { + api = SubsonicAPI(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD, + CLIENT_VERSION, CLIENT_ID) + } + + @Test + fun `Should parse ping ok response`() { + enqueueResponse("ping_ok.json") + + val response = api.getApi().ping().execute() + + assertResponseSuccessful(response) + with(response.body()) { + status `should be` SubsonicResponse.Status.OK + version `should be` SubsonicAPIVersions.V1_13_0 + } + } + + @Test + fun `Should parse error response`() { + enqueueResponse("generic_error_response.json") + + val response = api.getApi().ping().execute() + + assertResponseSuccessful(response) + with(response.body()) { + status `should be` SubsonicResponse.Status.ERROR + version `should be` SubsonicAPIVersions.V1_13_0 + error `should be` SubsonicError.GENERIC + } + } + + @Test + fun `Should parse get license response`() { + enqueueResponse("license_ok.json") + + val response = api.getApi().getLicense().execute() + + assertResponseSuccessful(response) + with(response.body()) { + status `should be` SubsonicResponse.Status.OK + version `should be` SubsonicAPIVersions.V1_13_0 + } + } + + private fun enqueueResponse(resourceName: String) { + mockWebServerRule.mockWebServer.enqueue(MockResponse() + .setBody(loadJsonResponse(resourceName))) + } + + private fun loadJsonResponse(name: String): String { + val source = Okio.buffer(Okio.source(javaClass.classLoader.getResourceAsStream(name))) + return source.readString(Charset.forName("UTF-8")) + } + + private fun assertResponseSuccessful(response: Response) { + response.isSuccessful `should be` true + response.body() `should not be` null + } +} \ No newline at end of file diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/rules/MockWebServerRule.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/rules/MockWebServerRule.kt new file mode 100644 index 00000000..4c88ed26 --- /dev/null +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/rules/MockWebServerRule.kt @@ -0,0 +1,27 @@ +package org.moire.ultrasonic.api.subsonic.rules + +import okhttp3.mockwebserver.MockWebServer +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * Starts mock web server for test and shut it down after. + */ +class MockWebServerRule: TestRule { + val mockWebServer = MockWebServer() + + override fun apply(base: Statement?, description: Description?): Statement { + val ruleStatement = object: Statement() { + override fun evaluate() { + try { + mockWebServer.start() + base?.evaluate() + } finally { + mockWebServer.shutdown() + } + } + } + return ruleStatement + } +} \ No newline at end of file diff --git a/subsonic-api/src/integrationTest/resources/generic_error_response.json b/subsonic-api/src/integrationTest/resources/generic_error_response.json new file mode 100644 index 00000000..1b5db1cc --- /dev/null +++ b/subsonic-api/src/integrationTest/resources/generic_error_response.json @@ -0,0 +1,10 @@ +{ + "subsonic-response" : { + "status" : "failed", + "version" : "1.13.0", + "error" : { + "code" : 0, + "message" : "Generic error." + } + } +} \ No newline at end of file diff --git a/subsonic-api/src/integrationTest/resources/get_music_directories_ok.json b/subsonic-api/src/integrationTest/resources/get_music_directories_ok.json new file mode 100644 index 00000000..b4f1d547 --- /dev/null +++ b/subsonic-api/src/integrationTest/resources/get_music_directories_ok.json @@ -0,0 +1,12 @@ +{ + "subsonic-response" : { + "status" : "ok", + "version" : "1.13.0", + "musicFolders" : { + "musicFolder" : [ { + "id" : 0, + "name" : "Music" + } ] + } + } +} \ No newline at end of file diff --git a/subsonic-api/src/integrationTest/resources/license_ok.json b/subsonic-api/src/integrationTest/resources/license_ok.json new file mode 100644 index 00000000..5a800572 --- /dev/null +++ b/subsonic-api/src/integrationTest/resources/license_ok.json @@ -0,0 +1,10 @@ +{ + "subsonic-response" : { + "status" : "ok", + "version" : "1.13.0", + "license" : { + "valid" : true, + "trialExpires" : "2016-11-23T20:17:15.206Z" + } + } +} \ No newline at end of file diff --git a/subsonic-api/src/integrationTest/resources/ping_ok.json b/subsonic-api/src/integrationTest/resources/ping_ok.json new file mode 100644 index 00000000..2b685c8a --- /dev/null +++ b/subsonic-api/src/integrationTest/resources/ping_ok.json @@ -0,0 +1,6 @@ +{ + "subsonic-response" : { + "status" : "ok", + "version" : "1.13.0" + } +} diff --git a/subsonic-api/src/main/java/org/moire/ultrasonic/api/subsonic/MyClass.java b/subsonic-api/src/main/java/org/moire/ultrasonic/api/subsonic/MyClass.java deleted file mode 100644 index 14d720b0..00000000 --- a/subsonic-api/src/main/java/org/moire/ultrasonic/api/subsonic/MyClass.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.moire.ultrasonic.api.subsonic; - -public class MyClass { -} diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPI.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPI.kt new file mode 100644 index 00000000..b22d6c19 --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPI.kt @@ -0,0 +1,59 @@ +package org.moire.ultrasonic.api.subsonic + +import com.google.gson.GsonBuilder +import okhttp3.OkHttpClient +import org.moire.ultrasonic.api.subsonic.models.SubsonicResponse +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +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 gson = GsonBuilder() + .registerTypeAdapter(SubsonicResponse::class.javaObjectType, + SubsonicResponse.Companion.ClassTypeAdapter()) + .create() + + private val retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .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())) + } +} \ No newline at end of file diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIDefinition.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIDefinition.kt new file mode 100644 index 00000000..fa97e854 --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIDefinition.kt @@ -0,0 +1,16 @@ +package org.moire.ultrasonic.api.subsonic + +import org.moire.ultrasonic.api.subsonic.models.SubsonicResponse +import retrofit2.Call +import retrofit2.http.GET + +/** + * // TODO + */ +interface SubsonicAPIDefinition { + @GET("ping.view") + fun ping(): Call + + @GET("getLicense.view") + fun getLicense(): Call +} \ No newline at end of file diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIVersions.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIVersions.kt new file mode 100644 index 00000000..940829fb --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIVersions.kt @@ -0,0 +1,45 @@ +package org.moire.ultrasonic.api.subsonic + +/** + * Subsonic REST API versions. + */ +enum class SubsonicAPIVersions(val subsonicVersions: String, val restApiVersion: String) { + V1_1_0("3.8", "1.1.0"), + V1_1_1("3.9", "1.1.1"), + V1_2_0("4.0", "1.2.0"), + V1_3_0("4.1", "1.3.0"), + V1_4_0("4.2", "1.4.0"), + V1_5_0("4.4", "1.5.0"), + V1_6_0("4.5", "1.6.0"), + V1_7_0("4.6", "1.7.0"), + V1_8_0("4.7", "1.8.0"), + V1_9_0("4.8", "1.9.0"), + V1_10_2("4.9", "1.10.2"), + 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"); + + companion object { + fun fromApiVersion(apiVersion: String): SubsonicAPIVersions { + when (apiVersion) { + "1.1.0" -> return V1_1_0 + "1.1.1" -> return V1_1_1 + "1.2.0" -> return V1_2_0 + "1.3.0" -> return V1_3_0 + "1.4.0" -> return V1_4_0 + "1.5.0" -> return V1_5_0 + "1.6.0" -> return V1_6_0 + "1.7.0" -> return V1_7_0 + "1.8.0" -> return V1_8_0 + "1.9.0" -> return V1_9_0 + "1.10.2" -> return V1_10_2 + "1.11.0" -> return V1_11_0 + "1.12.0" -> return V1_12_0 + "1.13.0" -> return V1_13_0 + "1.14.0" -> return V1_14_0 + } + throw IllegalArgumentException("Unknown api version $apiVersion") + } + } +} \ No newline at end of file diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicError.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicError.kt new file mode 100644 index 00000000..9e09682c --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicError.kt @@ -0,0 +1,22 @@ +package org.moire.ultrasonic.api.subsonic + +/** + * Common API errors. + */ +enum class SubsonicError(val code: Int) { + GENERIC(0), + REQUIRED_PARAM_MISSING(10), + INCOMPATIBLE_CLIENT_PROTOCOL_VERSION(20), + INCOMPATIBLE_SERVER_PROTOCOL_VERSION(30), + WRONG_USERNAME_OR_PASSWORD(40), + TOKEN_AUTH_NOT_SUPPORTED_FOR_LDAP(41), + USER_NOT_AUTHORIZED_FOR_OPERATION(50), + TRIAL_PERIOD_IS_OVER(60), + REQUESTED_DATA_WAS_NOT_FOUND(70); + + companion object { + fun parseErrorFromJson(jsonErrorCode: Int) = SubsonicError.values() + .filter { it.code == jsonErrorCode }.firstOrNull() + ?: throw IllegalArgumentException("Unknown code $jsonErrorCode") + } +} \ No newline at end of file diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/SubsonicResponse.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/SubsonicResponse.kt new file mode 100644 index 00000000..32d8f3b1 --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/models/SubsonicResponse.kt @@ -0,0 +1,73 @@ +package org.moire.ultrasonic.api.subsonic.models + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions +import org.moire.ultrasonic.api.subsonic.SubsonicError + +/** + * Base Subsonic API response. + */ +data class SubsonicResponse(val status: Status, + val version: SubsonicAPIVersions, + val error: SubsonicError?) { + enum class Status(val jsonValue: String) { + OK("ok"), ERROR("failed"); + + companion object { + fun getStatusFromJson(jsonValue: String) = Status.values() + .filter { it.jsonValue == jsonValue }.firstOrNull() + ?: throw IllegalArgumentException("Unknown status value: $jsonValue") + } + } + + companion object { + class ClassTypeAdapter: TypeAdapter() { + override fun read(`in`: JsonReader?): SubsonicResponse { + if (`in` == null) { + throw NullPointerException("No json for parsing") + } + + var status: Status = Status.ERROR + var version: SubsonicAPIVersions = SubsonicAPIVersions.V1_1_0 + var error: SubsonicError? = null + `in`.beginObject() + if ("subsonic-response" == `in`.nextName()) { + `in`.beginObject() + while (`in`.hasNext()) { + when (`in`.nextName()) { + "status" -> status = Status.getStatusFromJson(`in`.nextString()) + "version" -> version = SubsonicAPIVersions.fromApiVersion(`in`.nextString()) + "error" -> error = parseError(`in`) + else -> `in`.skipValue() + } + } + `in`.endObject() + } else{ + throw IllegalArgumentException("Not a subsonic-response json!") + } + `in`.endObject() + return SubsonicResponse(status, version, error) + } + + override fun write(out: JsonWriter?, value: SubsonicResponse?) { + throw UnsupportedOperationException("not implemented") + } + + private fun parseError(reader: JsonReader): SubsonicError? { + var error: SubsonicError? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "code" -> error = SubsonicError.parseErrorFromJson(reader.nextInt()) + else -> reader.skipValue() + } + } + reader.endObject() + return error + } + } + } +} \ No newline at end of file diff --git a/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIVersionsTest.kt b/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIVersionsTest.kt new file mode 100644 index 00000000..50a5c9b9 --- /dev/null +++ b/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIVersionsTest.kt @@ -0,0 +1,28 @@ +package org.moire.ultrasonic.api.subsonic + +import org.amshove.kluent.`should equal` +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** + * Unit test for [SubsonicAPIVersions] class. + */ +@RunWith(Parameterized::class) +class SubsonicAPIVersionsTest(private val apiVersion: SubsonicAPIVersions) { + companion object { + @JvmStatic + @Parameterized.Parameters + fun data(): List = SubsonicAPIVersions.values().asList() + } + + @Test + fun `Should proper convert api version to enum`() { + SubsonicAPIVersions.fromApiVersion(apiVersion.restApiVersion) `should equal` apiVersion + } + + @Test(expected = IllegalArgumentException::class) + fun `Should throw IllegalArgumentException for unknown api version`() { + SubsonicAPIVersions.fromApiVersion(apiVersion.restApiVersion.substring(0, 2)) + } +} \ No newline at end of file diff --git a/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicErrorTest.kt b/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicErrorTest.kt new file mode 100644 index 00000000..ccce3b2e --- /dev/null +++ b/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicErrorTest.kt @@ -0,0 +1,28 @@ +package org.moire.ultrasonic.api.subsonic + +import org.amshove.kluent.`should equal` +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** + * Unit test for [SubsonicError]. + */ +@RunWith(Parameterized::class) +class SubsonicErrorTest(private val error: SubsonicError) { + companion object { + @JvmStatic + @Parameterized.Parameters + fun data(): List = SubsonicError.values().toList() + } + + @Test + fun `Should proper convert error code to error`() { + SubsonicError.parseErrorFromJson(error.code) `should equal` error + } + + @Test(expected = IllegalArgumentException::class) + fun `Should throw IllegalArgumentException from unknown error code`() { + SubsonicError.parseErrorFromJson(error.code + 10000) + } +} \ No newline at end of file diff --git a/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/models/StatusTest.kt b/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/models/StatusTest.kt new file mode 100644 index 00000000..baa31842 --- /dev/null +++ b/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/models/StatusTest.kt @@ -0,0 +1,28 @@ +package org.moire.ultrasonic.api.subsonic.models + +import org.amshove.kluent.`should equal` +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** + * Unit test for [SubsonicResponse.Status] class + */ +@RunWith(Parameterized::class) +class StatusTest(private val status: SubsonicResponse.Status) { + companion object { + @JvmStatic + @Parameterized.Parameters + fun data(): List = SubsonicResponse.Status.values().toList() + } + + @Test + fun `Should proper parse response status`() { + SubsonicResponse.Status.getStatusFromJson(status.jsonValue) `should equal` status + } + + @Test(expected = IllegalArgumentException::class) + fun `Should throw IllegalArgumentException on unknown status`() { + SubsonicResponse.Status.getStatusFromJson(status.jsonValue.plus("-some")) + } +} \ No newline at end of file diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 17f7b5f1..70938fab 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' android { compileSdkVersion versions.compileSdk @@ -16,13 +17,25 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' } } + + sourceSets { + test.java.srcDirs += 'src/test/kotlin' + } } dependencies { compile project(':menudrawer') compile project(':pulltorefresh') compile project(':library') + compile project(':subsonic-api') compile androidSupport.support compile androidSupport.design + + compile kotlin.stdlib + + testCompile testing.junit + testCompile testing.kotlinJunit + testCompile testing.mockitoKotlin + testCompile testing.kluent }