diff --git a/dependencies.gradle b/dependencies.gradle index 437b99a1..4282a4f8 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -21,12 +21,15 @@ ext.versions = [ semver : "1.0.0", twitterSerial : "0.1.6", koin : "0.9.3", + picasso : "2.71828", junit : "4.12", mockito : "2.16.0", mockitoKotlin : "1.5.0", kluent : "1.35", apacheCodecs : "1.10", + testRunner : "1.0.1", + robolectric : "3.8", ] ext.gradlePlugins = [ @@ -40,6 +43,7 @@ ext.gradlePlugins = [ ext.androidSupport = [ support : "com.android.support:support-v4:$versions.androidSupport", design : "com.android.support:design:$versions.androidSupport", + annotations : "com.android.support:support-annotations:$versions.androidSupport" ] ext.other = [ @@ -53,7 +57,8 @@ ext.other = [ semver : "net.swiftzer.semver:semver:$versions.semver", twitterSerial : "com.twitter.serial:serial:$versions.twitterSerial", koinCore : "org.koin:koin-core:$versions.koin", - koinAndroid : "org.koin:koin-android:$versions.koin" + koinAndroid : "org.koin:koin-android:$versions.koin", + picasso : "com.squareup.picasso:picasso:$versions.picasso", ] ext.testing = [ @@ -63,6 +68,9 @@ ext.testing = [ mockito : "org.mockito:mockito-core:$versions.mockito", mockitoInline : "org.mockito:mockito-inline:$versions.mockito", kluent : "org.amshove.kluent:kluent:$versions.kluent", + kluentAndroid : "org.amshove.kluent:kluent-android:$versions.kluent", mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp", apacheCodecs : "commons-codec:commons-codec:$versions.apacheCodecs", + testRunner : "com.android.support.test:runner:$versions.testRunner", + robolectric : "org.robolectric:robolectric:$versions.robolectric", ] diff --git a/settings.gradle b/settings.gradle index 256032c5..eb222b3d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,7 @@ include ':library' include ':domain' include ':subsonic-api' +include ':subsonic-api-image-loader' include ':cache' include ':menudrawer' include ':pulltorefresh' diff --git a/subsonic-api-image-loader/build.gradle b/subsonic-api-image-loader/build.gradle new file mode 100644 index 00000000..7fc23d69 --- /dev/null +++ b/subsonic-api-image-loader/build.gradle @@ -0,0 +1,66 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +//apply plugin: 'jacoco' +apply from: '../gradle_scripts/code_quality.gradle' + +android { + compileSdkVersion(versions.compileSdk) + + defaultConfig { + minSdkVersion(versions.minSdk) + targetSdkVersion(versions.targetSdk) + + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + test.java.srcDirs += 'src/test/kotlin' + test.java.srcDirs += "${projectDir}/src/integrationTest/kotlin" + test.resources.srcDirs += "${projectDir}/src/integrationTest/resources" + } +} + +dependencies { + api project(':domain') + api project(':subsonic-api') + api other.kotlinStdlib + api other.picasso + + testImplementation testing.junit + testImplementation testing.kotlinJunit + testImplementation testing.mockito + testImplementation testing.mockitoInline + testImplementation testing.mockitoKotlin + testImplementation testing.kluent + testImplementation testing.robolectric +} + +jacoco { + toolVersion(versions.jacoco) +} + +//ext { +// jacocoExclude = [] +//} + +//jacocoTestReport { +// reports { +// html.enabled true +// csv.enabled false +// xml.enabled true +// } +// +// afterEvaluate { +// classDirectories = files(classDirectories.files.collect { +// fileTree(dir: it, excludes: jacocoExclude) +// }) +// } +//} +// +//test.finalizedBy jacocoTestReport +//test { +// jacoco { +// excludes += jacocoExclude +// } +//} diff --git a/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/CommonFunctions.kt b/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/CommonFunctions.kt new file mode 100644 index 00000000..5c4be754 --- /dev/null +++ b/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/CommonFunctions.kt @@ -0,0 +1,9 @@ +package org.moire.ultrasonic.subsonic.loader.image + +import okio.Okio +import java.io.InputStream + +fun Any.loadResourceStream(name: String): InputStream { + val source = Okio.buffer(Okio.source(javaClass.classLoader.getResourceAsStream(name))) + return source.inputStream() +} diff --git a/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/CoverArtRequestHandlerTest.kt b/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/CoverArtRequestHandlerTest.kt new file mode 100644 index 00000000..a4d74733 --- /dev/null +++ b/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/CoverArtRequestHandlerTest.kt @@ -0,0 +1,86 @@ +package org.moire.ultrasonic.subsonic.loader.image + +import android.net.Uri +import com.nhaarman.mockito_kotlin.any +import com.nhaarman.mockito_kotlin.anyOrNull +import com.nhaarman.mockito_kotlin.mock +import com.nhaarman.mockito_kotlin.whenever +import com.squareup.picasso.Picasso +import com.squareup.picasso.Request +import org.amshove.kluent.`should equal` +import org.amshove.kluent.`should not be` +import org.amshove.kluent.`should throw` +import org.amshove.kluent.shouldEqualTo +import org.junit.Test +import org.junit.runner.RunWith +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient +import org.moire.ultrasonic.api.subsonic.response.StreamResponse +import org.robolectric.RobolectricTestRunner +import java.io.IOException + +@RunWith(RobolectricTestRunner::class) +class CoverArtRequestHandlerTest { + private val mockSubsonicApiClientMock = mock() + private val handler = CoverArtRequestHandler(mockSubsonicApiClientMock) + + @Test + fun `Should accept only cover art request`() { + val requestUri = createLoadCoverArtRequest("some-id") + + handler.canHandleRequest(requestUri.buildRequest()) shouldEqualTo true + } + + @Test + fun `Should not accept random request uri`() { + val requestUri = Uri.Builder() + .scheme(SCHEME) + .authority(AUTHORITY) + .appendPath("random") + .build() + + handler.canHandleRequest(requestUri.buildRequest()) shouldEqualTo false + } + + @Test + fun `Should fail loading if uri doesn't contain id`() { + var requestUri = createLoadCoverArtRequest("some-id") + requestUri = requestUri.buildUpon().clearQuery().build() + + val fail = { + handler.load(requestUri.buildRequest(), 0) + } + + fail `should throw` IllegalStateException::class + } + + @Test + fun `Should throw IOException when request to api failed`() { + val streamResponse = StreamResponse(null, null, 500) + whenever(mockSubsonicApiClientMock.getCoverArt(any(), anyOrNull())) + .thenReturn(streamResponse) + + val fail = { + handler.load(createLoadCoverArtRequest("some").buildRequest(), 0) + } + + fail `should throw` IOException::class + } + + @Test + fun `Should load bitmap from network`() { + val streamResponse = StreamResponse( + loadResourceStream("Big_Buck_Bunny.jpeg"), + apiError = null, + responseHttpCode = 200 + ) + whenever(mockSubsonicApiClientMock.getCoverArt(any(), anyOrNull())) + .thenReturn(streamResponse) + + val response = handler.load(createLoadCoverArtRequest("some").buildRequest(), 0) + + response.loadedFrom `should equal` Picasso.LoadedFrom.NETWORK + response.source `should not be` null + } + + private fun Uri.buildRequest() = Request.Builder(this).build() +} diff --git a/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/RequestCreatorTest.kt b/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/RequestCreatorTest.kt new file mode 100644 index 00000000..09927850 --- /dev/null +++ b/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/RequestCreatorTest.kt @@ -0,0 +1,18 @@ +package org.moire.ultrasonic.subsonic.loader.image + +import android.net.Uri +import org.amshove.kluent.shouldEqualTo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RequestCreatorTest { + @Test + fun `Should create valid load cover art request`() { + val entityId = "299" + val expectedUri = Uri.parse("$SCHEME://$AUTHORITY/$COVER_ART_PATH?id=$entityId") + + createLoadCoverArtRequest(entityId).compareTo(expectedUri).shouldEqualTo(0) + } +} diff --git a/subsonic-api-image-loader/src/integrationTest/resources/Big_Buck_Bunny.jpeg b/subsonic-api-image-loader/src/integrationTest/resources/Big_Buck_Bunny.jpeg new file mode 100644 index 00000000..758dfa64 Binary files /dev/null and b/subsonic-api-image-loader/src/integrationTest/resources/Big_Buck_Bunny.jpeg differ diff --git a/subsonic-api-image-loader/src/main/AndroidManifest.xml b/subsonic-api-image-loader/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b36252b8 --- /dev/null +++ b/subsonic-api-image-loader/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + diff --git a/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/CoverArtRequestHandler.kt b/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/CoverArtRequestHandler.kt new file mode 100644 index 00000000..7d898437 --- /dev/null +++ b/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/CoverArtRequestHandler.kt @@ -0,0 +1,32 @@ +package org.moire.ultrasonic.subsonic.loader.image + +import com.squareup.picasso.Picasso.LoadedFrom.NETWORK +import com.squareup.picasso.Request +import com.squareup.picasso.RequestHandler +import okio.Okio +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient +import java.io.IOException + +/** + * Loads cover arts from subsonic api. + */ +class CoverArtRequestHandler(private val apiClient: SubsonicAPIClient) : RequestHandler() { + override fun canHandleRequest(data: Request): Boolean { + return with(data.uri) { + scheme == SCHEME && + authority == AUTHORITY && + path == "/$COVER_ART_PATH" + } + } + + override fun load(request: Request, networkPolicy: Int): Result { + val id = request.uri.getQueryParameter("id") + + val response = apiClient.getCoverArt(id) + if (response.hasError()) { + throw IOException("${response.apiError}") + } else { + return Result(Okio.source(response.stream), NETWORK) + } + } +} diff --git a/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/RequestCreator.kt b/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/RequestCreator.kt new file mode 100644 index 00000000..eb300fe5 --- /dev/null +++ b/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/RequestCreator.kt @@ -0,0 +1,14 @@ +package org.moire.ultrasonic.subsonic.loader.image + +import android.net.Uri + +internal const val SCHEME = "subsonic_api" +internal const val AUTHORITY = BuildConfig.APPLICATION_ID +internal const val COVER_ART_PATH = "cover_art" + +internal fun createLoadCoverArtRequest(entityId: String): Uri = Uri.Builder() + .scheme(SCHEME) + .authority(AUTHORITY) + .appendPath(COVER_ART_PATH) + .appendQueryParameter("id", entityId) + .build() diff --git a/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/SubsonicImageLoader.kt b/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/SubsonicImageLoader.kt new file mode 100644 index 00000000..6a3225b4 --- /dev/null +++ b/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/SubsonicImageLoader.kt @@ -0,0 +1,20 @@ +package org.moire.ultrasonic.subsonic.loader.image + +import android.content.Context +import android.widget.ImageView +import com.squareup.picasso.Picasso +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient + +class SubsonicImageLoader( + private val context: Context, + apiClient: SubsonicAPIClient +) { + private val picasso = Picasso.Builder(context) + .addRequestHandler(CoverArtRequestHandler(apiClient)) + .build().apply { setIndicatorsEnabled(BuildConfig.DEBUG) } + + fun loadCoverArt(entityId: String, view: ImageView) { + picasso.load(createLoadCoverArtRequest(entityId)) + .into(view) + } +} diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index bf224674..c2d49505 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -55,6 +55,7 @@ dependencies { implementation project(':library') implementation project(':domain') implementation project(':subsonic-api') + implementation project(':subsonic-api-image-loader') implementation project(':cache') implementation androidSupport.support