mirror of
https://github.com/ultrasonic/ultrasonic
synced 2025-02-02 10:16:50 +01:00
Base implementation of Subsonic API in Kotlin.
This commit is contained in:
parent
292490be7d
commit
0dd01d18ba
@ -9,6 +9,7 @@ buildscript {
|
||||
}
|
||||
dependencies {
|
||||
classpath gradlePlugins.androidTools
|
||||
classpath gradlePlugins.kotlin
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
]
|
||||
|
@ -1,4 +1,4 @@
|
||||
include ':library'
|
||||
include ':library', ':subsonic-api'
|
||||
include ':menudrawer'
|
||||
include ':pulltorefresh'
|
||||
include ':ultrasonic'
|
||||
|
@ -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
|
||||
}
|
@ -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<SubsonicResponse>) {
|
||||
response.isSuccessful `should be` true
|
||||
response.body() `should not be` null
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
{
|
||||
"subsonic-response" : {
|
||||
"status" : "failed",
|
||||
"version" : "1.13.0",
|
||||
"error" : {
|
||||
"code" : 0,
|
||||
"message" : "Generic error."
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
{
|
||||
"subsonic-response" : {
|
||||
"status" : "ok",
|
||||
"version" : "1.13.0",
|
||||
"musicFolders" : {
|
||||
"musicFolder" : [ {
|
||||
"id" : 0,
|
||||
"name" : "Music"
|
||||
} ]
|
||||
}
|
||||
}
|
||||
}
|
10
subsonic-api/src/integrationTest/resources/license_ok.json
Normal file
10
subsonic-api/src/integrationTest/resources/license_ok.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"subsonic-response" : {
|
||||
"status" : "ok",
|
||||
"version" : "1.13.0",
|
||||
"license" : {
|
||||
"valid" : true,
|
||||
"trialExpires" : "2016-11-23T20:17:15.206Z"
|
||||
}
|
||||
}
|
||||
}
|
6
subsonic-api/src/integrationTest/resources/ping_ok.json
Normal file
6
subsonic-api/src/integrationTest/resources/ping_ok.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"subsonic-response" : {
|
||||
"status" : "ok",
|
||||
"version" : "1.13.0"
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
package org.moire.ultrasonic.api.subsonic;
|
||||
|
||||
public class MyClass {
|
||||
}
|
@ -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()))
|
||||
}
|
||||
}
|
@ -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<SubsonicResponse>
|
||||
|
||||
@GET("getLicense.view")
|
||||
fun getLicense(): Call<SubsonicResponse>
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
@ -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<SubsonicResponse>() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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> = 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))
|
||||
}
|
||||
}
|
@ -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> = 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)
|
||||
}
|
||||
}
|
@ -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> = 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"))
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user