diff --git a/dependencies.gradle b/dependencies.gradle index ce6e4309..a9ee8ed2 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -4,28 +4,32 @@ ext.versions = [ compileSdk : 27, gradle : '4.5.1', - androidTools : "3.1.0", - ktlint : "0.20.0", - ktlintGradle : "3.2.0", + androidTools : "3.1.3", + ktlint : "0.24.0", + ktlintGradle : "4.1.0", detekt : "1.0.0.RC6-4", jacoco : "0.7.9", jacocoAndroid : "0.1.2", androidSupport : "22.2.1", - kotlin : "1.2.31", + kotlin : "1.2.51", retrofit : "2.4.0", jackson : "2.9.5", okhttp : "3.10.0", 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 = [ @@ -39,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 = [ @@ -51,6 +56,9 @@ ext.other = [ okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$versions.okhttp", 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", + picasso : "com.squareup.picasso:picasso:$versions.picasso", ] ext.testing = [ @@ -60,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/gradle_scripts/jacoco.gradle b/gradle_scripts/jacoco.gradle index 5be89860..c8209068 100644 --- a/gradle_scripts/jacoco.gradle +++ b/gradle_scripts/jacoco.gradle @@ -21,6 +21,7 @@ def createJacocoFullReportTask() { description = "Generate full Jacoco coverage report including all modules." def subsonicApi = project.findProject("subsonic-api") + def subsonicApiImageLoader = project.findProject("subsonic-api-image-loader") def ultrasonicApp = project.findProject("ultrasonic") def cache = project.findProject("cache") @@ -29,6 +30,10 @@ def createJacocoFullReportTask() { dir: "${subsonicApi.buildDir}/classes/main", excludes: subsonicApi.jacocoExclude ), + fileTree( + dir: "${subsonicApiImageLoader.buildDir}/intermediates/classes/debug/org", + excludes: subsonicApiImageLoader.jacocoExclude + ), fileTree( dir: "${ultrasonicApp.buildDir}/intermediates/classes/debug/org", excludes: ultrasonicApp.jacocoExclude @@ -38,8 +43,12 @@ def createJacocoFullReportTask() { excludes: cache.jacocoExclude ) ) - sourceDirectories = files(subsonicApi.sourceSets.main.getAllSource(), - ultrasonicApp.extensions.getByName('android').sourceSets.main.java.sourceFiles) + sourceDirectories = files( + subsonicApi.sourceSets.main.getAllSource(), + subsonicApiImageLoader.extensions.getByName('android').sourceSets.main.java.sourceFiles, + ultrasonicApp.extensions.getByName('android').sourceSets.main.java.sourceFiles, + cache.sourceSets.main.getAllSource(), + ) executionData = files("${buildDir}/jacoco/jacoco.exec") reports { 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..7f7d3a71 --- /dev/null +++ b/subsonic-api-image-loader/build.gradle @@ -0,0 +1,55 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'jacoco-android' +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) { + exclude group: "com.android.support" + } + + 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 = [] +} + +jacocoAndroidUnitTestReport { + excludes += jacocoExclude +} + +afterEvaluate { + testDebugUnitTest.finalizedBy jacocoTestDebugUnitTestReport +} \ No newline at end of file diff --git a/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/AvatarRequestHandlerTest.kt b/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/AvatarRequestHandlerTest.kt new file mode 100644 index 00000000..4dbc5f33 --- /dev/null +++ b/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/AvatarRequestHandlerTest.kt @@ -0,0 +1,73 @@ +package org.moire.ultrasonic.subsonic.loader.image + +import android.net.Uri +import com.nhaarman.mockito_kotlin.any +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 org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AvatarRequestHandlerTest { + private val mockSubsonicApiClient = mock() + private val handler = AvatarRequestHandler(mockSubsonicApiClient) + + @Test + fun `Should accept only cover art request`() { + val requestUri = createLoadAvatarRequest("some-username") + + handler.canHandleRequest(requestUri.buildRequest()) shouldEqualTo true + } + + @Test + fun `Should not accept random request uri`() { + val requestUri = Uri.Builder() + .scheme(SCHEME) + .authority(AUTHORITY) + .appendPath("something") + .build() + + handler.canHandleRequest(requestUri.buildRequest()) shouldEqualTo false + } + + @Test + fun `Should fail loading if uri doesn't contain username`() { + var requestUri = createLoadAvatarRequest("some-username") + requestUri = requestUri.buildUpon().clearQuery().build() + + val fail = { + handler.load(requestUri.buildRequest(), 0) + } + + fail `should throw` IllegalStateException::class + } + + @Test + fun `Should load avatar from network`() { + val streamResponse = StreamResponse( + loadResourceStream("Big_Buck_Bunny.jpeg"), + apiError = null, + responseHttpCode = 200 + ) + whenever(mockSubsonicApiClient.getAvatar(any())) + .thenReturn(streamResponse) + + val response = handler.load(createLoadAvatarRequest("some-username").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/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..6572028d --- /dev/null +++ b/subsonic-api-image-loader/src/integrationTest/kotlin/org/moire/ultrasonic/subsonic/loader/image/RequestCreatorTest.kt @@ -0,0 +1,26 @@ +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?$QUERY_ID=$entityId") + + createLoadCoverArtRequest(entityId).compareTo(expectedUri).shouldEqualTo(0) + } + + @Test + fun `Should create valid avatar request`() { + val username = "some-username" + val expectedUri = Uri.parse("$SCHEME://$AUTHORITY/$AVATAR_PATH?$QUERY_USERNAME=$username") + + createLoadAvatarRequest(username).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/AvatarRequestHandler.kt b/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/AvatarRequestHandler.kt new file mode 100644 index 00000000..afce254e --- /dev/null +++ b/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/AvatarRequestHandler.kt @@ -0,0 +1,34 @@ +package org.moire.ultrasonic.subsonic.loader.image + +import com.squareup.picasso.Picasso +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 avatars from subsonic api. + */ +class AvatarRequestHandler( + private val apiClient: SubsonicAPIClient +) : RequestHandler() { + override fun canHandleRequest(data: Request): Boolean { + return with(data.uri) { + scheme == SCHEME && + authority == AUTHORITY && + path == "/$AVATAR_PATH" + } + } + + override fun load(request: Request, networkPolicy: Int): Result { + val username = request.uri.getQueryParameter(QUERY_USERNAME) + + val response = apiClient.getAvatar(username) + if (response.hasError()) { + throw IOException("${response.apiError}") + } else { + return Result(Okio.source(response.stream), Picasso.LoadedFrom.NETWORK) + } + } +} 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..8c6f4cd3 --- /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(QUERY_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..9cecb7e3 --- /dev/null +++ b/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/RequestCreator.kt @@ -0,0 +1,24 @@ +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 const val AVATAR_PATH = "avatar" +internal const val QUERY_ID = "id" +internal const val QUERY_USERNAME = "username" + +internal fun createLoadCoverArtRequest(entityId: String): Uri = Uri.Builder() + .scheme(SCHEME) + .authority(AUTHORITY) + .appendPath(COVER_ART_PATH) + .appendQueryParameter(QUERY_ID, entityId) + .build() + +internal fun createLoadAvatarRequest(username: String): Uri = Uri.Builder() + .scheme(SCHEME) + .authority(AUTHORITY) + .appendPath(AVATAR_PATH) + .appendQueryParameter(QUERY_USERNAME, username) + .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..630bbc4a --- /dev/null +++ b/subsonic-api-image-loader/src/main/kotlin/org/moire/ultrasonic/subsonic/loader/image/SubsonicImageLoader.kt @@ -0,0 +1,80 @@ +package org.moire.ultrasonic.subsonic.loader.image + +import android.content.Context +import android.widget.ImageView +import com.squareup.picasso.Picasso +import com.squareup.picasso.RequestCreator +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient + +class SubsonicImageLoader( + context: Context, + apiClient: SubsonicAPIClient +) { + private val picasso = Picasso.Builder(context) + .addRequestHandler(CoverArtRequestHandler(apiClient)) + .addRequestHandler(AvatarRequestHandler(apiClient)) + .build().apply { setIndicatorsEnabled(BuildConfig.DEBUG) } + + fun load(request: ImageRequest) = when (request) { + is ImageRequest.CoverArt -> loadCoverArt(request) + is ImageRequest.Avatar -> loadAvatar(request) + } + + private fun loadCoverArt(request: ImageRequest.CoverArt) { + picasso.load(createLoadCoverArtRequest(request.entityId)) + .addPlaceholder(request) + .addError(request) + .into(request.imageView) + } + + private fun loadAvatar(request: ImageRequest.Avatar) { + picasso.load(createLoadAvatarRequest(request.username)) + .addPlaceholder(request) + .addError(request) + .into(request.imageView) + } + + private fun RequestCreator.addPlaceholder(request: ImageRequest): RequestCreator { + if (request.placeHolderDrawableRes != null) { + placeholder(request.placeHolderDrawableRes) + } + + return this + } + + private fun RequestCreator.addError(request: ImageRequest): RequestCreator { + if (request.errorDrawableRes != null) { + error(request.errorDrawableRes) + } + + return this + } +} + +sealed class ImageRequest( + val placeHolderDrawableRes: Int? = null, + val errorDrawableRes: Int? = null, + val imageView: ImageView +) { + class CoverArt( + val entityId: String, + imageView: ImageView, + placeHolderDrawableRes: Int? = null, + errorDrawableRes: Int? = null + ) : ImageRequest( + placeHolderDrawableRes, + errorDrawableRes, + imageView + ) + + class Avatar( + val username: String, + imageView: ImageView, + placeHolderDrawableRes: Int? = null, + errorDrawableRes: Int? = null + ) : ImageRequest( + placeHolderDrawableRes, + errorDrawableRes, + imageView + ) +} diff --git a/subsonic-api/build.gradle b/subsonic-api/build.gradle index 4d6a0c05..78f37860 100644 --- a/subsonic-api/build.gradle +++ b/subsonic-api/build.gradle @@ -13,6 +13,8 @@ dependencies { api other.kotlinStdlib api other.retrofit api other.jacksonConverter + api other.koinCore + implementation(other.jacksonKotlin) { exclude module: 'kotlin-reflect' } @@ -36,7 +38,8 @@ jacoco { ext { // Excluding data classes jacocoExclude = [ - '**/models/**' + '**/models/**', + '**/di/**' ] } diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/GetStreamUrlTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/GetStreamUrlTest.kt index 063a5cf7..2b828a1a 100644 --- a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/GetStreamUrlTest.kt +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/GetStreamUrlTest.kt @@ -21,8 +21,14 @@ class GetStreamUrlTest { @Before fun setUp() { - client = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), - USERNAME, PASSWORD, V1_6_0, CLIENT_ID) + val config = SubsonicClientConfiguration( + mockWebServerRule.mockWebServer.url("/").toString(), + USERNAME, + PASSWORD, + V1_6_0, + CLIENT_ID + ) + client = SubsonicAPIClient(config) val baseExpectedUrl = mockWebServerRule.mockWebServer.url("").toString() expectedUrl = "$baseExpectedUrl/rest/stream.view?id=$id&u=$USERNAME" + "&c=$CLIENT_ID&f=json&v=${V1_6_0.restApiVersion}&p=enc:${PASSWORD.toHexBytes()}" diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClientTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClientTest.kt index 6d4dfffd..d82c34c2 100644 --- a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClientTest.kt +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClientTest.kt @@ -10,11 +10,18 @@ import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule abstract class SubsonicAPIClientTest { @JvmField @Rule val mockWebServerRule = MockWebServerRule() + protected lateinit var config: SubsonicClientConfiguration protected lateinit var client: SubsonicAPIClient @Before fun setUp() { - client = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), - USERNAME, PASSWORD, CLIENT_VERSION, CLIENT_ID) + config = SubsonicClientConfiguration( + mockWebServerRule.mockWebServer.url("/").toString(), + USERNAME, + PASSWORD, + CLIENT_VERSION, + CLIENT_ID + ) + client = SubsonicAPIClient(config) } } diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiPasswordTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiPasswordTest.kt index 1a10e6cb..536cb8a7 100644 --- a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiPasswordTest.kt +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiPasswordTest.kt @@ -10,8 +10,9 @@ import org.junit.Test class SubsonicApiPasswordTest : SubsonicAPIClientTest() { @Test fun `Should pass PasswordMD5Interceptor in query params for api version 1 13 0`() { - val clientV12 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), - USERNAME, PASSWORD, SubsonicAPIVersions.V1_14_0, CLIENT_ID) + val clientV12 = SubsonicAPIClient( + config.copy(minimalProtocolVersion = SubsonicAPIVersions.V1_14_0) + ) mockWebServerRule.enqueueResponse("ping_ok.json") clientV12.api.ping().execute() @@ -25,8 +26,9 @@ class SubsonicApiPasswordTest : SubsonicAPIClientTest() { @Test fun `Should pass PasswordHexInterceptor in query params for api version 1 12 0`() { - val clientV11 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), - USERNAME, PASSWORD, SubsonicAPIVersions.V1_12_0, CLIENT_ID) + val clientV11 = SubsonicAPIClient( + config.copy(minimalProtocolVersion = SubsonicAPIVersions.V1_12_0) + ) mockWebServerRule.enqueueResponse("ping_ok.json") clientV11.api.ping().execute() diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiSSLTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiSSLTest.kt index fb43dbc1..89f8f21d 100644 --- a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiSSLTest.kt +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiSSLTest.kt @@ -90,7 +90,15 @@ class SubsonicApiSSLTest { assertResponseSuccessful(response) } - private fun createSubsonicClient(allowSelfSignedCertificate: Boolean) = SubsonicAPIClient( - "https://$HOST:$PORT/", USERNAME, PASSWORD, CLIENT_VERSION, CLIENT_ID, - allowSelfSignedCertificate = allowSelfSignedCertificate) + private fun createSubsonicClient(allowSelfSignedCertificate: Boolean): SubsonicAPIClient { + val config = SubsonicClientConfiguration( + "https://$HOST:$PORT/", + USERNAME, + PASSWORD, + CLIENT_VERSION, + CLIENT_ID, + allowSelfSignedCertificate = allowSelfSignedCertificate + ) + return SubsonicAPIClient(config) + } } diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt index 577e55d2..691a31d3 100644 --- a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt @@ -36,44 +36,39 @@ private const val READ_TIMEOUT = 60_000L * @author Yahor Berdnikau */ class SubsonicAPIClient( - baseUrl: String, - username: String, - password: String, - minimalProtocolVersion: SubsonicAPIVersions, - clientID: String, - allowSelfSignedCertificate: Boolean = false, - enableLdapUserSupport: Boolean = false, - debug: Boolean = false + config: SubsonicClientConfiguration, + baseOkClient: OkHttpClient = OkHttpClient.Builder().build() ) { - private val versionInterceptor = VersionInterceptor(minimalProtocolVersion) { + private val versionInterceptor = VersionInterceptor(config.minimalProtocolVersion) { protocolVersion = it } private val proxyPasswordInterceptor = ProxyPasswordInterceptor( - minimalProtocolVersion, - PasswordHexInterceptor(password), - PasswordMD5Interceptor(password), - enableLdapUserSupport) + config.minimalProtocolVersion, + PasswordHexInterceptor(config.password), + PasswordMD5Interceptor(config.password), + config.enableLdapUserSupport + ) /** * Get currently used protocol version. */ - var protocolVersion = minimalProtocolVersion + var protocolVersion = config.minimalProtocolVersion private set(value) { field = value proxyPasswordInterceptor.apiVersion = field wrappedApi.currentApiVersion = field } - private val okHttpClient = OkHttpClient.Builder() + private val okHttpClient = baseOkClient.newBuilder() .readTimeout(READ_TIMEOUT, MILLISECONDS) - .apply { if (allowSelfSignedCertificate) allowSelfSignedCertificates() } + .apply { if (config.allowSelfSignedCertificate) allowSelfSignedCertificates() } .addInterceptor { chain -> // Adds default request params val originalRequest = chain.request() val newUrl = originalRequest.url().newBuilder() - .addQueryParameter("u", username) - .addQueryParameter("c", clientID) + .addQueryParameter("u", config.username) + .addQueryParameter("c", config.clientID) .addQueryParameter("f", "json") .build() chain.proceed(originalRequest.newBuilder().url(newUrl).build()) @@ -81,7 +76,7 @@ class SubsonicAPIClient( .addInterceptor(versionInterceptor) .addInterceptor(proxyPasswordInterceptor) .addInterceptor(RangeHeaderInterceptor()) - .apply { if (debug) addLogging() } + .apply { if (config.debug) addLogging() } .build() private val jacksonMapper = ObjectMapper() @@ -90,14 +85,15 @@ class SubsonicAPIClient( .registerModule(KotlinModule()) private val retrofit = Retrofit.Builder() - .baseUrl("$baseUrl/rest/") + .baseUrl("${config.baseUrl}/rest/") .client(okHttpClient) .addConverterFactory(JacksonConverterFactory.create(jacksonMapper)) .build() private val wrappedApi = ApiVersionCheckWrapper( retrofit.create(SubsonicAPIDefinition::class.java), - minimalProtocolVersion) + config.minimalProtocolVersion + ) val api: SubsonicAPIDefinition get() = wrappedApi diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicClientConfiguration.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicClientConfiguration.kt new file mode 100644 index 00000000..732efe7a --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicClientConfiguration.kt @@ -0,0 +1,15 @@ +package org.moire.ultrasonic.api.subsonic + +/** + * Provides configuration for [SubsonicAPIClient]. + */ +data class SubsonicClientConfiguration( + val baseUrl: String, + val username: String, + val password: String, + val minimalProtocolVersion: SubsonicAPIVersions, + val clientID: String, + val allowSelfSignedCertificate: Boolean = false, + val enableLdapUserSupport: Boolean = false, + val debug: Boolean = false +) diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/di/SubsonicApiModule.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/di/SubsonicApiModule.kt new file mode 100644 index 00000000..200b6328 --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/di/SubsonicApiModule.kt @@ -0,0 +1,10 @@ +package org.moire.ultrasonic.api.subsonic.di + +import org.koin.dsl.context.Context +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient + +const val SUBSONIC_API_CLIENT_CONTEXT = "SubsonicApiClientContext" + +fun Context.subsonicApiModule() = context(SUBSONIC_API_CLIENT_CONTEXT) { + bean { return@bean SubsonicAPIClient(get(), get()) } +} diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 7cd7c804..6697dd4e 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -8,8 +8,8 @@ android { defaultConfig { applicationId "org.moire.ultrasonic" - versionCode 68 - versionName "2.5.0" + versionCode 69 + versionName "2.6.0" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk @@ -55,12 +55,14 @@ dependencies { implementation project(':library') implementation project(':domain') implementation project(':subsonic-api') + implementation project(':subsonic-api-image-loader') implementation project(':cache') implementation androidSupport.support implementation androidSupport.design implementation other.kotlinStdlib + implementation other.koinAndroid testImplementation other.kotlinReflect testImplementation testing.junit @@ -69,7 +71,7 @@ dependencies { testImplementation testing.kluent } -// Excluding all non-kotlin classes +// Excluding all java classes and stuff that should not be covered ext { jacocoExclude = [ '**/activity/**', @@ -83,7 +85,8 @@ ext { '**/view/**', '**/R$*.class', '**/R.class', - '**/BuildConfig.class' + '**/BuildConfig.class', + '**/di/**' ] } jacocoAndroidUnitTestReport { diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 6c158318..bcf7f624 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -1,204 +1,205 @@ - + android:installLocation="auto"> - - - - - - - + + + + + + + + android:anyDensity="true" + android:largeScreens="true" + android:normalScreens="true" + android:smallScreens="true" + android:xlargeScreens="true"/> + android:allowBackup="false" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher_round" + android:theme="@style/Theme.AppCompat" + android:name=".app.UApp" + android:label="@string/common.appname"> + android:name=".activity.MainActivity" + android:configChanges="orientation|keyboardHidden" + android:label="UltraSonic" + android:launchMode="standard"> - - + + + android:name=".activity.SelectArtistActivity" + android:configChanges="orientation|keyboardHidden" + android:launchMode="standard"/> + android:name=".activity.SelectAlbumActivity" + android:configChanges="orientation|keyboardHidden"/> + android:name=".activity.SearchActivity" + android:configChanges="orientation|keyboardHidden" + android:label="@string/search.label" + android:launchMode="singleTask"/> + android:name=".activity.SelectPlaylistActivity" + android:configChanges="orientation|keyboardHidden" + android:label="@string/playlist.label" + android:launchMode="standard"/> + android:name=".activity.PodcastsActivity" + android:configChanges="orientation|keyboardHidden" + android:label="@string/podcasts.label" + android:launchMode="standard"/> + android:name=".activity.BookmarkActivity" + android:configChanges="orientation|keyboardHidden"/> + android:name=".activity.ShareActivity" + android:configChanges="orientation|keyboardHidden"/> + android:name=".activity.ChatActivity" + android:configChanges="orientation|keyboardHidden"/> + android:name=".activity.DownloadActivity" + android:configChanges="keyboardHidden" + android:launchMode="singleTask" + android:exported="true" /> + android:name=".activity.SettingsActivity" + android:configChanges="orientation|keyboardHidden" + android:launchMode="singleTask"/> + android:name=".activity.HelpActivity" + android:configChanges="orientation|keyboardHidden" + android:launchMode="singleTask"/> + android:name=".activity.LyricsActivity" + android:configChanges="orientation|keyboardHidden" + android:launchMode="singleTask"/> + android:name=".activity.EqualizerActivity" + android:configChanges="orientation|keyboardHidden" + android:label="@string/equalizer.label" + android:launchMode="singleTask"/> + android:name=".activity.SelectGenreActivity" + android:configChanges="orientation|keyboardHidden" + android:launchMode="standard"/> + android:name=".activity.VoiceQueryReceiverActivity" + android:launchMode="singleTask"> - - + + + android:name=".activity.QueryReceiverActivity" + android:launchMode="singleTask"> - - + + + android:name="android.app.searchable" + android:resource="@xml/searchable"/> - + + android:name=".service.DownloadServiceImpl" + android:label="UltraSonic Download Service" + android:exported="false"> - - - - - - + + + + + + - - - + + + - + - - - - + + + + + android:name=".provider.UltraSonicAppWidgetProvider4x1" + android:label="UltraSonic (4x1)"> - + + android:name="android.appwidget.provider" + android:resource="@xml/appwidget_info_4x1"/> + android:name=".provider.UltraSonicAppWidgetProvider4x2" + android:label="UltraSonic (4x2)"> - + + android:name="android.appwidget.provider" + android:resource="@xml/appwidget_info_4x2"/> + android:name=".provider.UltraSonicAppWidgetProvider4x3" + android:label="UltraSonic (4x3)"> - + + android:name="android.appwidget.provider" + android:resource="@xml/appwidget_info_4x3"/> + android:name=".provider.UltraSonicAppWidgetProvider4x4" + android:label="UltraSonic (4x4)"> - + + android:name="android.appwidget.provider" + android:resource="@xml/appwidget_info_4x4"/> + android:name=".provider.SearchSuggestionProvider" + android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/> + android:name="android.app.default_searchable" + android:value="org.moire.ultrasonic.activity.QueryReceiverActivity"/> + android:name=".receiver.A2dpIntentReceiver" + android:exported="false"> - + diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/activity/DownloadActivity.java b/ultrasonic/src/main/java/org/moire/ultrasonic/activity/DownloadActivity.java index 4ed38866..6fd0f884 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/activity/DownloadActivity.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/activity/DownloadActivity.java @@ -887,8 +887,11 @@ public class DownloadActivity extends SubsonicTabActivity implements OnGestureLi } Intent intent = new Intent(this, SelectAlbumActivity.class); - intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, entry.getParent()); + String albumId = Util.getShouldUseId3Tags(this) ? entry.getAlbumId() : entry.getParent(); + intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, albumId); intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, entry.getAlbum()); + intent.putExtra(Constants.INTENT_EXTRA_NAME_IS_ALBUM, true); + intent.putExtra(Constants.INTENT_EXTRA_NAME_PARENT_ID, entry.getParent()); startActivityForResultWithoutTransition(this, intent); return true; case R.id.menu_lyrics: diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/activity/SubsonicTabActivity.java b/ultrasonic/src/main/java/org/moire/ultrasonic/activity/SubsonicTabActivity.java index 46b73a67..be2783af 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/activity/SubsonicTabActivity.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/activity/SubsonicTabActivity.java @@ -20,7 +20,6 @@ package org.moire.ultrasonic.activity; import android.app.AlertDialog; import android.app.Dialog; -import android.app.NotificationManager; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; @@ -33,54 +32,26 @@ import android.os.Bundle; import android.os.Environment; import android.support.v7.app.ActionBar; import android.util.Log; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.MotionEvent; -import android.view.View; +import android.view.*; import android.view.View.OnClickListener; import android.view.View.OnTouchListener; -import android.view.ViewGroup; -import android.widget.CheckBox; -import android.widget.CompoundButton; -import android.widget.EditText; -import android.widget.ImageView; -import android.widget.RemoteViews; -import android.widget.TextView; - +import android.widget.*; import net.simonvt.menudrawer.MenuDrawer; import net.simonvt.menudrawer.Position; - import org.moire.ultrasonic.R; +import org.moire.ultrasonic.app.UApp; import org.moire.ultrasonic.domain.MusicDirectory; import org.moire.ultrasonic.domain.MusicDirectory.Entry; import org.moire.ultrasonic.domain.PlayerState; import org.moire.ultrasonic.domain.Share; -import org.moire.ultrasonic.service.DownloadFile; -import org.moire.ultrasonic.service.DownloadService; -import org.moire.ultrasonic.service.DownloadServiceImpl; -import org.moire.ultrasonic.service.MusicService; -import org.moire.ultrasonic.service.MusicServiceFactory; -import org.moire.ultrasonic.util.BackgroundTask; -import org.moire.ultrasonic.util.Constants; -import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator; -import org.moire.ultrasonic.util.ImageLoader; -import org.moire.ultrasonic.util.ModalBackgroundTask; -import org.moire.ultrasonic.util.ShareDetails; -import org.moire.ultrasonic.util.SilentBackgroundTask; -import org.moire.ultrasonic.util.TabActivityBackgroundTask; -import org.moire.ultrasonic.util.TimeSpan; -import org.moire.ultrasonic.util.TimeSpanPicker; -import org.moire.ultrasonic.util.Util; -import org.moire.ultrasonic.util.VideoPlayerType; +import org.moire.ultrasonic.featureflags.Feature; +import org.moire.ultrasonic.service.*; +import org.moire.ultrasonic.subsonic.SubsonicImageLoaderProxy; +import org.moire.ultrasonic.util.*; import java.io.File; import java.io.PrintWriter; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; +import java.util.*; import java.util.regex.Pattern; /** @@ -820,21 +791,39 @@ public class SubsonicTabActivity extends ResultActivity implements OnClickListen } } - public synchronized void clearImageLoader() - { - if (IMAGE_LOADER != null && IMAGE_LOADER.isRunning()) IMAGE_LOADER.clear(); - } + public synchronized void clearImageLoader() { + if (IMAGE_LOADER != null && + IMAGE_LOADER.isRunning()) { + IMAGE_LOADER.clear(); + } - public synchronized ImageLoader getImageLoader() - { - if (IMAGE_LOADER == null || !IMAGE_LOADER.isRunning()) - { - IMAGE_LOADER = new ImageLoader(this, Util.getImageLoaderConcurrency(this)); - IMAGE_LOADER.startImageLoader(); - } + IMAGE_LOADER = null; + } - return IMAGE_LOADER; - } + public synchronized ImageLoader getImageLoader() { + if (IMAGE_LOADER == null || + !IMAGE_LOADER.isRunning()) { + LegacyImageLoader legacyImageLoader = new LegacyImageLoader( + this, + Util.getImageLoaderConcurrency(this) + ); + + boolean isNewImageLoaderEnabled = ((UApp) getApplication()).getFeaturesStorage() + .isFeatureEnabled(Feature.NEW_IMAGE_DOWNLOADER); + if (isNewImageLoaderEnabled) { + IMAGE_LOADER = new SubsonicImageLoaderProxy( + legacyImageLoader, + ((UApp) getApplication()).getSubsonicImageLoader() + ); + } else { + IMAGE_LOADER = legacyImageLoader; + } + + IMAGE_LOADER.startImageLoader(); + } + + return IMAGE_LOADER; + } void download(final boolean append, final boolean save, final boolean autoPlay, final boolean playNext, final boolean shuffle, final List songs) { diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ServerSettingsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ServerSettingsFragment.java index ccc01852..f2512ba9 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ServerSettingsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ServerSettingsFragment.java @@ -14,6 +14,7 @@ import android.view.View; import org.moire.ultrasonic.BuildConfig; import org.moire.ultrasonic.R; +import org.moire.ultrasonic.cache.Directories; import org.moire.ultrasonic.cache.PermanentFileStorage; import org.moire.ultrasonic.service.MusicService; import org.moire.ultrasonic.service.MusicServiceFactory; @@ -282,9 +283,10 @@ public class ServerSettingsFragment extends PreferenceFragment .getInt(Constants.PREFERENCES_KEY_ACTIVE_SERVERS, 0); // Clear permanent storage - final String storageServerId = MusicServiceFactory.getServerId(sharedPreferences, serverId); + final String storageServerId = MusicServiceFactory.getServerId(); + final Directories directories = MusicServiceFactory.getDirectories(); final PermanentFileStorage fileStorage = new PermanentFileStorage( - MusicServiceFactory.getDirectories(getActivity()), + directories, storageServerId, BuildConfig.DEBUG ); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java index 472c49d1..98feabc8 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java @@ -4,29 +4,21 @@ import android.content.Intent; import android.content.SharedPreferences; import android.os.Build; import android.os.Bundle; -import android.preference.CheckBoxPreference; -import android.preference.EditTextPreference; -import android.preference.ListPreference; -import android.preference.Preference; -import android.preference.PreferenceCategory; -import android.preference.PreferenceFragment; -import android.preference.PreferenceManager; +import android.preference.*; import android.provider.SearchRecentSuggestions; import android.support.annotation.Nullable; import android.util.Log; import android.view.View; - import org.moire.ultrasonic.R; import org.moire.ultrasonic.activity.ServerSettingsActivity; import org.moire.ultrasonic.activity.SubsonicTabActivity; +import org.moire.ultrasonic.app.UApp; +import org.moire.ultrasonic.featureflags.Feature; +import org.moire.ultrasonic.featureflags.FeatureStorage; import org.moire.ultrasonic.provider.SearchSuggestionProvider; import org.moire.ultrasonic.service.DownloadService; import org.moire.ultrasonic.service.DownloadServiceImpl; -import org.moire.ultrasonic.util.Constants; -import org.moire.ultrasonic.util.FileUtil; -import org.moire.ultrasonic.util.ImageLoader; -import org.moire.ultrasonic.util.TimeSpanPreference; -import org.moire.ultrasonic.util.Util; +import org.moire.ultrasonic.util.*; import java.io.File; @@ -115,6 +107,7 @@ public class SettingsFragment extends PreferenceFragment sharingDefaultGreeting.setText(Util.getShareGreeting(getActivity())); setupClearSearchPreference(); setupGaplessControlSettingsV14(); + setupFeatureFlagsPreferences(); } @Override @@ -178,6 +171,24 @@ public class SettingsFragment extends PreferenceFragment } } + private void setupFeatureFlagsPreferences() { + CheckBoxPreference ffImageLoader = (CheckBoxPreference) findPreference( + Constants.PREFERENCES_KEY_FF_IMAGE_LOADER); + + final FeatureStorage featureStorage = ((UApp) getActivity().getApplication()).getFeaturesStorage(); + if (ffImageLoader != null) { + ffImageLoader.setChecked(featureStorage.isFeatureEnabled(Feature.NEW_IMAGE_DOWNLOADER)); + ffImageLoader.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object o) { + featureStorage.changeFeatureFlag(Feature.NEW_IMAGE_DOWNLOADER, (Boolean) o); + ((SubsonicTabActivity) getActivity()).clearImageLoader(); + return true; + } + }); + } + } + private void setupGaplessControlSettingsV14() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { PreferenceCategory playbackControlSettings = diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicServiceFactory.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicServiceFactory.java deleted file mode 100644 index eb859aa5..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicServiceFactory.java +++ /dev/null @@ -1,150 +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 . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.service; - -import android.content.Context; -import android.content.SharedPreferences; -import android.util.Log; - -import org.jetbrains.annotations.NotNull; -import org.moire.ultrasonic.BuildConfig; -import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient; -import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions; -import org.moire.ultrasonic.cache.Directories; -import org.moire.ultrasonic.cache.PermanentFileStorage; -import org.moire.ultrasonic.util.Constants; -import org.moire.ultrasonic.util.Util; - -import java.io.File; - -/** - * @author Sindre Mehus - * @version $Id$ - */ -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) { - 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) { - Log.d(LOG_TAG, "Creating new offline music service"); - OFFLINE_MUSIC_SERVICE = new OfflineMusicService( - createSubsonicApiClient(context), - getPermanentFileStorage(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) { - Log.d(LOG_TAG, "Creating new rest music service"); - REST_MUSIC_SERVICE = new CachedMusicService(new RESTMusicService( - createSubsonicApiClient(context), - getPermanentFileStorage(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); - boolean allowSelfSignedCertificate = preferences - .getBoolean(Constants.PREFERENCES_KEY_ALLOW_SELF_SIGNED_CERTIFICATE + instance, false); - boolean enableLdapUserSupport = preferences - .getBoolean(Constants.PREFERENCES_KEY_LDAP_SUPPORT + instance , false); - - 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, allowSelfSignedCertificate, - enableLdapUserSupport, BuildConfig.DEBUG); - } - - return new SubsonicAPIClient(serverUrl, username, password, - SubsonicAPIVersions.fromApiVersion(Constants.REST_PROTOCOL_VERSION), - Constants.REST_CLIENT_ID, allowSelfSignedCertificate, - enableLdapUserSupport, BuildConfig.DEBUG); - } - - private static PermanentFileStorage getPermanentFileStorage(final Context context) { - final SharedPreferences preferences = Util.getPreferences(context); - int instance = preferences.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); - final String serverId = getServerId(preferences, instance); - - return new PermanentFileStorage(getDirectories(context), serverId, BuildConfig.DEBUG); - } - - public static String getServerId(final SharedPreferences sp, final int instance) { - String serverUrl = sp.getString( - Constants.PREFERENCES_KEY_SERVER_URL + instance, null); - return String.valueOf(Math.abs((serverUrl + instance).hashCode())); - } - - public static Directories getDirectories(final Context context) { - return new Directories() { - @NotNull - @Override - public File getInternalCacheDir() { - return context.getCacheDir(); - } - - @NotNull - @Override - public File getInternalDataDir() { - return context.getFilesDir(); - } - - @Override - public File getExternalCacheDir() { - return context.getExternalCacheDir(); - } - }; - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java index f0e32cb0..444ebb95 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java @@ -21,20 +21,17 @@ package org.moire.ultrasonic.util; import android.app.Activity; import android.os.Handler; import android.util.Log; - import com.fasterxml.jackson.core.JsonParseException; - import org.moire.ultrasonic.R; import org.moire.ultrasonic.service.SubsonicRESTException; import org.moire.ultrasonic.subsonic.RestErrorMapper; +import javax.net.ssl.SSLException; import java.io.FileNotFoundException; import java.io.IOException; import java.security.cert.CertPathValidatorException; import java.security.cert.CertificateException; -import javax.net.ssl.SSLException; - /** * @author Sindre Mehus */ @@ -70,7 +67,7 @@ public abstract class BackgroundTask implements ProgressListener protected void error(Throwable error) { Log.w(TAG, String.format("Got exception: %s", error), error); - new ErrorDialog(activity, getErrorMessage(error), true); + new ErrorDialog(activity, getErrorMessage(error), false); } protected String getErrorMessage(Throwable error) { diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java index e183f878..2fdb0c79 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java @@ -130,6 +130,7 @@ public final class Constants public static final String PREFERENCES_KEY_SHOW_ALL_SONGS_BY_ARTIST = "showAllSongsByArtist"; public static final String PREFERENCES_KEY_SCAN_MEDIA = "scanMedia"; public static final String PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY = "imageLoaderConcurrency"; + public static final String PREFERENCES_KEY_FF_IMAGE_LOADER = "ff_new_image_loader"; // Number of free trial days for non-licensed servers. public static final int FREE_TRIAL_DAYS = 30; diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ImageLoader.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/ImageLoader.java index 60fe1b49..74202148 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ImageLoader.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/ImageLoader.java @@ -1,462 +1,43 @@ -/* - 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 . - - Copyright 2009 (C) Sindre Mehus - */ package org.moire.ultrasonic.util; -import android.content.Context; -import android.content.res.Resources; import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.TransitionDrawable; -import android.os.Handler; -import android.text.TextUtils; -import android.util.Log; import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import org.moire.ultrasonic.R; import org.moire.ultrasonic.domain.MusicDirectory; -import org.moire.ultrasonic.service.MusicService; -import org.moire.ultrasonic.service.MusicServiceFactory; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.atomic.AtomicBoolean; +public interface ImageLoader { + boolean isRunning(); -/** - * Asynchronous loading of images, with caching. - *

- * There should normally be only one instance of this class. - * - * @author Sindre Mehus - */ -public class ImageLoader implements Runnable -{ - private static final String TAG = ImageLoader.class.getSimpleName(); + void setConcurrency(int concurrency); - private final LRUCache cache = new LRUCache(150); - private final BlockingQueue queue; - private int imageSizeDefault; - private final int imageSizeLarge; - private Bitmap largeUnknownImage; - private Bitmap unknownAvatarImage; - private Context context; - private Collection threads; - private AtomicBoolean running = new AtomicBoolean(); - private int concurrency; + void startImageLoader(); - public ImageLoader(Context context, int concurrency) - { - this.context = context; - this.concurrency = concurrency; - queue = new LinkedBlockingQueue(1000); + void stopImageLoader(); - Resources resources = context.getResources(); - Drawable drawable = resources.getDrawable(R.drawable.unknown_album); + void loadAvatarImage( + View view, + String username, + boolean large, + int size, + boolean crossFade, + boolean highQuality + ); - // Determine the density-dependent image sizes. - if (drawable != null) - { - imageSizeDefault = drawable.getIntrinsicHeight(); - } + void loadImage( + View view, + MusicDirectory.Entry entry, + boolean large, + int size, + boolean crossFade, + boolean highQuality + ); - imageSizeLarge = Util.getMaxDisplayMetric(context); - createLargeUnknownImage(context); - createUnknownAvatarImage(context); - } + Bitmap getImageBitmap(String username, int size); - public synchronized boolean isRunning() - { - return running.get() && !threads.isEmpty(); - } + Bitmap getImageBitmap(MusicDirectory.Entry entry, boolean large, int size); - public void setConcurrency(int concurrency) - { - this.concurrency = concurrency; - } + void addImageToCache(Bitmap bitmap, MusicDirectory.Entry entry, int size); - public void startImageLoader() - { - running.set(true); + void addImageToCache(Bitmap bitmap, String username, int size); - threads = Collections.synchronizedCollection(new ArrayList(this.concurrency)); - - for (int i = 0; i < this.concurrency; i++) - { - Thread thread = new Thread(this, String.format("ImageLoader_%d", i)); - threads.add(thread); - thread.start(); - } - } - - public synchronized void stopImageLoader() - { - clear(); - - for (Thread thread : threads) - { - thread.interrupt(); - } - - running.set(false); - threads.clear(); - } - - private void createLargeUnknownImage(Context context) - { - BitmapDrawable drawable = (BitmapDrawable) context.getResources().getDrawable(R.drawable.unknown_album_large); - Log.i(TAG, "createLargeUnknownImage"); - - if (drawable != null) - { - largeUnknownImage = Util.scaleBitmap(drawable.getBitmap(), imageSizeLarge); - } - } - - private void createUnknownAvatarImage(Context context) - { - Resources res = context.getResources(); - Drawable contact = res.getDrawable(R.drawable.ic_contact_picture); - unknownAvatarImage = Util.createBitmapFromDrawable(contact); - } - - public void loadAvatarImage(View view, String username, boolean large, int size, boolean crossFade, boolean highQuality) - { - view.invalidate(); - - if (username == null) - { - setUnknownAvatarImage(view); - return; - } - - if (size <= 0) - { - size = large ? imageSizeLarge : imageSizeDefault; - } - - Bitmap bitmap = cache.get(getKey(username, size)); - - if (bitmap != null) - { - setAvatarImageBitmap(view, username, bitmap, crossFade); - return; - } - - setUnknownAvatarImage(view); - - queue.offer(new Task(view, username, size, large, crossFade, highQuality)); - } - - public void loadImage(View view, MusicDirectory.Entry entry, boolean large, int size, boolean crossFade, boolean highQuality) - { - view.invalidate(); - - if (entry == null) - { - setUnknownImage(view, large); - return; - } - - String coverArt = entry.getCoverArt(); - - if (TextUtils.isEmpty(coverArt)) { - setUnknownImage(view, large); - return; - } - - if (size <= 0) - { - size = large ? imageSizeLarge : imageSizeDefault; - } - - Bitmap bitmap = cache.get(getKey(coverArt, size)); - - if (bitmap != null) - { - setImageBitmap(view, entry, bitmap, crossFade); - return; - } - - setUnknownImage(view, large); - - queue.offer(new Task(view, entry, size, large, crossFade, highQuality)); - } - - private static String getKey(String coverArtId, int size) - { - return String.format("%s:%d", coverArtId, size); - } - - public Bitmap getImageBitmap(String username, int size) - { - Bitmap bitmap = cache.get(getKey(username, size)); - - if (bitmap != null && !bitmap.isRecycled()) - { - Bitmap.Config config = bitmap.getConfig(); - return bitmap.copy(config, false); - } - - return null; - } - - public Bitmap getImageBitmap(MusicDirectory.Entry entry, boolean large, int size) - { - if (entry == null) - { - return null; - } - - String coverArt = entry.getCoverArt(); - - if (TextUtils.isEmpty(coverArt)) { - return null; - } - - if (size <= 0) - { - size = large ? imageSizeLarge : imageSizeDefault; - } - - Bitmap bitmap = cache.get(getKey(coverArt, size)); - - if (bitmap != null && !bitmap.isRecycled()) - { - Bitmap.Config config = bitmap.getConfig(); - return bitmap.copy(config, false); - } - - return null; - } - - private void setImageBitmap(View view, MusicDirectory.Entry entry, Bitmap bitmap, boolean crossFade) - { - if (view instanceof ImageView) - { - ImageView imageView = (ImageView) view; - - MusicDirectory.Entry tagEntry = (MusicDirectory.Entry) view.getTag(); - - // Only apply image to the view if the view is intended for this entry - if (entry != null && tagEntry != null && !entry.equals(tagEntry)) - { - Log.i(TAG, "View is no longer valid, not setting ImageBitmap"); - return; - } - - if (crossFade) - { - Drawable existingDrawable = imageView.getDrawable(); - Drawable newDrawable = Util.createDrawableFromBitmap(this.context, bitmap); - - if (existingDrawable == null) - { - Bitmap emptyImage = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); - existingDrawable = new BitmapDrawable(context.getResources(), emptyImage); - } - - Drawable[] layers = new Drawable[]{existingDrawable, newDrawable}; - - TransitionDrawable transitionDrawable = new TransitionDrawable(layers); - imageView.setImageDrawable(transitionDrawable); - transitionDrawable.startTransition(250); - } - else - { - imageView.setImageBitmap(bitmap); - } - } - } - - private void setAvatarImageBitmap(View view, String username, Bitmap bitmap, boolean crossFade) - { - if (view instanceof ImageView) - { - ImageView imageView = (ImageView) view; - - String tagEntry = (String) view.getTag(); - - // Only apply image to the view if the view is intended for this entry - if (username != null && tagEntry != null && !username.equals(tagEntry)) - { - Log.i(TAG, "View is no longer valid, not setting ImageBitmap"); - return; - } - - if (crossFade) - { - Drawable existingDrawable = imageView.getDrawable(); - Drawable newDrawable = Util.createDrawableFromBitmap(this.context, bitmap); - - if (existingDrawable == null) - { - Bitmap emptyImage = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); - existingDrawable = new BitmapDrawable(context.getResources(), emptyImage); - } - - Drawable[] layers = new Drawable[]{existingDrawable, newDrawable}; - - TransitionDrawable transitionDrawable = new TransitionDrawable(layers); - imageView.setImageDrawable(transitionDrawable); - transitionDrawable.startTransition(250); - } - else - { - imageView.setImageBitmap(bitmap); - } - } - } - - public void setUnknownAvatarImage(View view) - { - setAvatarImageBitmap(view, null, unknownAvatarImage, false); - } - - public void setUnknownImage(View view, boolean large) - { - if (large) - { - setImageBitmap(view, null, largeUnknownImage, false); - } - else - { - if (view instanceof TextView) - { - ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(R.drawable.unknown_album, 0, 0, 0); - } - else if (view instanceof ImageView) - { - ((ImageView) view).setImageResource(R.drawable.unknown_album); - } - } - } - - public void addImageToCache(Bitmap bitmap, MusicDirectory.Entry entry, int size) - { - cache.put(getKey(entry.getCoverArt(), size), bitmap); - } - - public void addImageToCache(Bitmap bitmap, String username, int size) - { - cache.put(getKey(username, size), bitmap); - } - - public void clear() - { - queue.clear(); - } - - @Override - public void run() - { - while (running.get()) - { - try - { - Task task = queue.take(); - task.execute(); - } - catch (InterruptedException ignored) - { - running.set(false); - break; - } - catch (Throwable x) - { - Log.e(TAG, "Unexpected exception in ImageLoader.", x); - } - } - } - - private class Task - { - private final View view; - private final MusicDirectory.Entry entry; - private final String username; - private final Handler handler; - private final int size; - private final boolean saveToFile; - private final boolean crossFade; - private final boolean highQuality; - - public Task(View view, MusicDirectory.Entry entry, int size, boolean saveToFile, boolean crossFade, boolean highQuality) - { - this.view = view; - this.entry = entry; - this.username = null; - this.size = size; - this.saveToFile = saveToFile; - this.crossFade = crossFade; - this.highQuality = highQuality; - handler = new Handler(); - } - - public Task(View view, String username, int size, boolean saveToFile, boolean crossFade, boolean highQuality) - { - this.view = view; - this.entry = null; - this.username = username; - this.size = size; - this.saveToFile = saveToFile; - this.crossFade = crossFade; - this.highQuality = highQuality; - handler = new Handler(); - } - - public void execute() - { - try - { - MusicService musicService = MusicServiceFactory.getMusicService(view.getContext()); - final boolean isAvatar = this.username != null && this.entry == null; - final Bitmap bitmap = this.entry != null ? musicService.getCoverArt(view.getContext(), entry, size, saveToFile, highQuality, null) : musicService.getAvatar(view.getContext(), username, size, saveToFile, highQuality, null); - - if (isAvatar) - addImageToCache(bitmap, username, size); - else - addImageToCache(bitmap, entry, size); - - handler.post(new Runnable() - { - @Override - public void run() - { - if (isAvatar) - { - setAvatarImageBitmap(view, username, bitmap, crossFade); - } - else - { - setImageBitmap(view, entry, bitmap, crossFade); - } - } - }); - } - catch (Throwable x) - { - Log.e(TAG, "Failed to download album art.", x); - } - } - } + void clear(); } diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/LegacyImageLoader.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/LegacyImageLoader.java new file mode 100644 index 00000000..ae1fc517 --- /dev/null +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/LegacyImageLoader.java @@ -0,0 +1,450 @@ +/* + 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 . + + Copyright 2009 (C) Sindre Mehus + */ +package org.moire.ultrasonic.util; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.os.Handler; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import org.moire.ultrasonic.R; +import org.moire.ultrasonic.domain.MusicDirectory; +import org.moire.ultrasonic.service.MusicService; +import org.moire.ultrasonic.service.MusicServiceFactory; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Asynchronous loading of images, with caching. + *

+ * There should normally be only one instance of this class. + * + * @author Sindre Mehus + */ +public class LegacyImageLoader implements Runnable, ImageLoader { + private static final String TAG = LegacyImageLoader.class.getSimpleName(); + + private final LRUCache cache = new LRUCache<>(150); + private final BlockingQueue queue; + private int imageSizeDefault; + private final int imageSizeLarge; + private Bitmap largeUnknownImage; + private Bitmap unknownAvatarImage; + private Context context; + private Collection threads; + private AtomicBoolean running = new AtomicBoolean(); + private int concurrency; + + public LegacyImageLoader( + Context context, + int concurrency + ) { + this.context = context; + this.concurrency = concurrency; + queue = new LinkedBlockingQueue<>(1000); + + Resources resources = context.getResources(); + Drawable drawable = resources.getDrawable(R.drawable.unknown_album); + + // Determine the density-dependent image sizes. + if (drawable != null) { + imageSizeDefault = drawable.getIntrinsicHeight(); + } + + imageSizeLarge = Util.getMaxDisplayMetric(context); + createLargeUnknownImage(context); + createUnknownAvatarImage(context); + } + + @Override + public synchronized boolean isRunning() { + return running.get() && !threads.isEmpty(); + } + + @Override + public void setConcurrency(int concurrency) { + this.concurrency = concurrency; + } + + @Override + public void startImageLoader() { + running.set(true); + + threads = Collections.synchronizedCollection(new ArrayList(this.concurrency)); + + for (int i = 0; i < this.concurrency; i++) { + Thread thread = new Thread(this, String.format("ImageLoader_%d", i)); + threads.add(thread); + thread.start(); + } + } + + @Override + public synchronized void stopImageLoader() { + clear(); + + for (Thread thread : threads) { + thread.interrupt(); + } + + running.set(false); + threads.clear(); + } + + private void createLargeUnknownImage(Context context) { + BitmapDrawable drawable = (BitmapDrawable) context.getResources().getDrawable(R.drawable.unknown_album_large); + Log.i(TAG, "createLargeUnknownImage"); + + if (drawable != null) { + largeUnknownImage = Util.scaleBitmap(drawable.getBitmap(), imageSizeLarge); + } + } + + private void createUnknownAvatarImage(Context context) { + Resources res = context.getResources(); + Drawable contact = res.getDrawable(R.drawable.ic_contact_picture); + unknownAvatarImage = Util.createBitmapFromDrawable(contact); + } + + @Override + public void loadAvatarImage( + View view, + String username, + boolean large, + int size, + boolean crossFade, + boolean highQuality + ) { + view.invalidate(); + + if (username == null) { + setUnknownAvatarImage(view); + return; + } + + if (size <= 0) { + size = large ? imageSizeLarge : imageSizeDefault; + } + + Bitmap bitmap = cache.get(getKey(username, size)); + + if (bitmap != null) { + setAvatarImageBitmap(view, username, bitmap, crossFade); + return; + } + + setUnknownAvatarImage(view); + + queue.offer(new Task(view, username, size, large, crossFade, highQuality)); + } + + @Override + public void loadImage( + View view, + MusicDirectory.Entry entry, + boolean large, + int size, + boolean crossFade, + boolean highQuality + ) { + view.invalidate(); + + if (entry == null) { + setUnknownImage(view, large); + return; + } + + String coverArt = entry.getCoverArt(); + + if (TextUtils.isEmpty(coverArt)) { + setUnknownImage(view, large); + return; + } + + if (size <= 0) { + size = large ? imageSizeLarge : imageSizeDefault; + } + + Bitmap bitmap = cache.get(getKey(coverArt, size)); + + if (bitmap != null) { + setImageBitmap(view, entry, bitmap, crossFade); + return; + } + + setUnknownImage(view, large); + + queue.offer(new Task(view, entry, size, large, crossFade, highQuality)); + } + + private static String getKey(String coverArtId, int size) { + return String.format("%s:%d", coverArtId, size); + } + + @Override + public Bitmap getImageBitmap(String username, int size) { + Bitmap bitmap = cache.get(getKey(username, size)); + + if (bitmap != null && !bitmap.isRecycled()) { + Bitmap.Config config = bitmap.getConfig(); + return bitmap.copy(config, false); + } + + return null; + } + + @Override + public Bitmap getImageBitmap(MusicDirectory.Entry entry, boolean large, int size) { + if (entry == null) { + return null; + } + + String coverArt = entry.getCoverArt(); + + if (TextUtils.isEmpty(coverArt)) { + return null; + } + + if (size <= 0) { + size = large ? imageSizeLarge : imageSizeDefault; + } + + Bitmap bitmap = cache.get(getKey(coverArt, size)); + + if (bitmap != null && !bitmap.isRecycled()) { + Bitmap.Config config = bitmap.getConfig(); + return bitmap.copy(config, false); + } + + return null; + } + + private void setImageBitmap( + View view, + MusicDirectory.Entry entry, + Bitmap bitmap, + boolean crossFade + ) { + if (view instanceof ImageView) { + ImageView imageView = (ImageView) view; + + MusicDirectory.Entry tagEntry = (MusicDirectory.Entry) view.getTag(); + + // Only apply image to the view if the view is intended for this entry + if (entry != null && tagEntry != null && !entry.equals(tagEntry)) { + Log.i(TAG, "View is no longer valid, not setting ImageBitmap"); + return; + } + + if (crossFade) { + Drawable existingDrawable = imageView.getDrawable(); + Drawable newDrawable = Util.createDrawableFromBitmap(this.context, bitmap); + + if (existingDrawable == null) { + Bitmap emptyImage = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + existingDrawable = new BitmapDrawable(context.getResources(), emptyImage); + } + + Drawable[] layers = new Drawable[]{existingDrawable, newDrawable}; + + TransitionDrawable transitionDrawable = new TransitionDrawable(layers); + imageView.setImageDrawable(transitionDrawable); + transitionDrawable.startTransition(250); + } else { + imageView.setImageBitmap(bitmap); + } + } + } + + private void setAvatarImageBitmap( + View view, + String username, + Bitmap bitmap, + boolean crossFade + ) { + if (view instanceof ImageView) { + ImageView imageView = (ImageView) view; + + String tagEntry = (String) view.getTag(); + + // Only apply image to the view if the view is intended for this entry + if (username != null && + tagEntry != null && + !username.equals(tagEntry)) { + Log.i(TAG, "View is no longer valid, not setting ImageBitmap"); + return; + } + + if (crossFade) { + Drawable existingDrawable = imageView.getDrawable(); + Drawable newDrawable = Util.createDrawableFromBitmap(this.context, bitmap); + + if (existingDrawable == null) { + Bitmap emptyImage = Bitmap.createBitmap( + bitmap.getWidth(), + bitmap.getHeight(), + Bitmap.Config.ARGB_8888 + ); + existingDrawable = new BitmapDrawable(context.getResources(), emptyImage); + } + + Drawable[] layers = new Drawable[]{existingDrawable, newDrawable}; + + TransitionDrawable transitionDrawable = new TransitionDrawable(layers); + imageView.setImageDrawable(transitionDrawable); + transitionDrawable.startTransition(250); + } else { + imageView.setImageBitmap(bitmap); + } + } + } + + private void setUnknownAvatarImage(View view) { + setAvatarImageBitmap(view, null, unknownAvatarImage, false); + } + + private void setUnknownImage(View view, boolean large) { + if (large) { + setImageBitmap(view, null, largeUnknownImage, false); + } else { + if (view instanceof TextView) { + ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(R.drawable.unknown_album, 0, 0, 0); + } else if (view instanceof ImageView) { + ((ImageView) view).setImageResource(R.drawable.unknown_album); + } + } + } + + @Override + public void addImageToCache(Bitmap bitmap, MusicDirectory.Entry entry, int size) { + cache.put(getKey(entry.getCoverArt(), size), bitmap); + } + + @Override + public void addImageToCache(Bitmap bitmap, String username, int size) { + cache.put(getKey(username, size), bitmap); + } + + @Override + public void clear() { + queue.clear(); + } + + @Override + public void run() { + while (running.get()) { + try { + Task task = queue.take(); + task.execute(); + } catch (InterruptedException ignored) { + running.set(false); + break; + } catch (Throwable x) { + Log.e(TAG, "Unexpected exception in ImageLoader.", x); + } + } + } + + private class Task { + private final View view; + private final MusicDirectory.Entry entry; + private final String username; + private final Handler handler; + private final int size; + private final boolean saveToFile; + private final boolean crossFade; + private final boolean highQuality; + + Task( + View view, + MusicDirectory.Entry entry, + int size, + boolean saveToFile, + boolean crossFade, + boolean highQuality + ) { + this.view = view; + this.entry = entry; + this.username = null; + this.size = size; + this.saveToFile = saveToFile; + this.crossFade = crossFade; + this.highQuality = highQuality; + handler = new Handler(); + } + + Task( + View view, + String username, + int size, + boolean saveToFile, + boolean crossFade, + boolean highQuality + ) { + this.view = view; + this.entry = null; + this.username = username; + this.size = size; + this.saveToFile = saveToFile; + this.crossFade = crossFade; + this.highQuality = highQuality; + handler = new Handler(); + } + + public void execute() { + try { + MusicService musicService = MusicServiceFactory.getMusicService(view.getContext()); + final boolean isAvatar = this.username != null && this.entry == null; + final Bitmap bitmap = this.entry != null + ? musicService.getCoverArt(view.getContext(), entry, size, saveToFile, highQuality, null) + : musicService.getAvatar(view.getContext(), username, size, saveToFile, highQuality, null); + + if (isAvatar) + addImageToCache(bitmap, username, size); + else + addImageToCache(bitmap, entry, size); + + handler.post(new Runnable() { + @Override + public void run() { + if (isAvatar) { + setAvatarImageBitmap(view, username, bitmap, crossFade); + } else { + setImageBitmap(view, entry, bitmap, crossFade); + } + } + }); + } catch (Throwable x) { + Log.e(TAG, "Failed to download album art.", x); + } + } + } +} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java index 90620e00..e9673e23 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java @@ -171,14 +171,16 @@ public class Util extends DownloadActivity return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS, false); } - public static void setActiveServer(Context context, int instance) - { + 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); - editor.commit(); - } + SharedPreferences preferences = getPreferences(context); + SharedPreferences.Editor editor = preferences.edit(); + editor.putInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, instance); + editor.apply(); + } public static int getActiveServer(Context context) { diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java index 23eb0074..1884e201 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java @@ -25,7 +25,6 @@ import android.view.LayoutInflater; import android.view.View; import android.widget.ImageView; import android.widget.TextView; - import org.moire.ultrasonic.R; import org.moire.ultrasonic.domain.MusicDirectory; import org.moire.ultrasonic.service.MusicService; diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ChatAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/ChatAdapter.java index 9fc000c2..0361e81f 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ChatAdapter.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/ChatAdapter.java @@ -8,7 +8,6 @@ import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.ImageView; import android.widget.TextView; - import org.moire.ultrasonic.R; import org.moire.ultrasonic.activity.SubsonicTabActivity; import org.moire.ultrasonic.domain.ChatMessage; diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/EntryAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/EntryAdapter.java index 4b3b5317..ce229d65 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/EntryAdapter.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/EntryAdapter.java @@ -24,7 +24,6 @@ import android.widget.ArrayAdapter; import android.widget.CheckedTextView; import android.widget.ImageView; import android.widget.TextView; - import org.moire.ultrasonic.activity.SubsonicTabActivity; import org.moire.ultrasonic.domain.MusicDirectory.Entry; import org.moire.ultrasonic.util.ImageLoader; diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt new file mode 100644 index 00000000..8eb735b6 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt @@ -0,0 +1,40 @@ +package org.moire.ultrasonic.app + +import android.app.Application +import org.koin.android.ext.android.get +import org.koin.android.ext.android.startKoin +import org.moire.ultrasonic.di.baseNetworkModule +import org.moire.ultrasonic.di.directoriesModule +import org.moire.ultrasonic.di.featureFlagsModule +import org.moire.ultrasonic.di.musicServiceModule +import org.moire.ultrasonic.featureflags.FeatureStorage +import org.moire.ultrasonic.subsonic.loader.image.SubsonicImageLoader +import org.moire.ultrasonic.util.Util + +class UApp : Application() { + override fun onCreate() { + super.onCreate() + + val sharedPreferences = Util.getPreferences(this) + startKoin(this, listOf( + directoriesModule, + baseNetworkModule, + featureFlagsModule(this), + musicServiceModule(sharedPreferences, this) + )) + } + + /** + * Temporary method to get subsonic image loader from java code. + */ + fun getSubsonicImageLoader(): SubsonicImageLoader { + return get() + } + + /** + * Temporary method to get features storage. + */ + fun getFeaturesStorage(): FeatureStorage { + return get() + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/cache/AndroidDirectories.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/cache/AndroidDirectories.kt new file mode 100644 index 00000000..d8a815a7 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/cache/AndroidDirectories.kt @@ -0,0 +1,17 @@ +package org.moire.ultrasonic.cache + +import android.content.Context +import java.io.File + +/** + * Provides specific to Android implementation of [Directories]. + */ +class AndroidDirectories( + private val context: Context +) : Directories { + override fun getInternalCacheDir(): File = context.cacheDir + + override fun getInternalDataDir(): File = context.filesDir + + override fun getExternalCacheDir(): File? = context.externalCacheDir +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/BaseNetworkModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/BaseNetworkModule.kt new file mode 100644 index 00000000..0455ff53 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/BaseNetworkModule.kt @@ -0,0 +1,11 @@ +package org.moire.ultrasonic.di + +import okhttp3.OkHttpClient +import org.koin.dsl.module.applicationContext + +/** + * Provides base network dependencies. + */ +val baseNetworkModule = applicationContext { + bean { OkHttpClient.Builder().build() } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/DirectoriesModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/DirectoriesModule.kt new file mode 100644 index 00000000..ddbe21f0 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/DirectoriesModule.kt @@ -0,0 +1,9 @@ +package org.moire.ultrasonic.di + +import org.koin.dsl.module.applicationContext +import org.moire.ultrasonic.cache.AndroidDirectories +import org.moire.ultrasonic.cache.Directories + +val directoriesModule = applicationContext { + bean { AndroidDirectories(get()) } bind Directories::class +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/FeatureFlagsModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/FeatureFlagsModule.kt new file mode 100644 index 00000000..81a1b09f --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/FeatureFlagsModule.kt @@ -0,0 +1,11 @@ +package org.moire.ultrasonic.di + +import android.content.Context +import org.koin.dsl.module.applicationContext +import org.moire.ultrasonic.featureflags.FeatureStorage + +fun featureFlagsModule( + context: Context +) = applicationContext { + factory { FeatureStorage(context) } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt new file mode 100644 index 00000000..7809ff1b --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt @@ -0,0 +1,120 @@ +@file:JvmName("MusicServiceModule") +package org.moire.ultrasonic.di + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import org.koin.dsl.module.applicationContext +import org.moire.ultrasonic.BuildConfig +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient +import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions +import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration +import org.moire.ultrasonic.api.subsonic.di.subsonicApiModule +import org.moire.ultrasonic.cache.PermanentFileStorage +import org.moire.ultrasonic.service.CachedMusicService +import org.moire.ultrasonic.service.MusicService +import org.moire.ultrasonic.service.OfflineMusicService +import org.moire.ultrasonic.service.RESTMusicService +import org.moire.ultrasonic.subsonic.loader.image.SubsonicImageLoader +import org.moire.ultrasonic.util.Constants +import kotlin.math.abs + +internal const val MUSIC_SERVICE_CONTEXT = "CurrentMusicService" +internal const val ONLINE_MUSIC_SERVICE = "OnlineMusicService" +internal const val OFFLINE_MUSIC_SERVICE = "OfflineMusicService" +private const val DEFAULT_SERVER_INSTANCE = 1 +private const val UNKNOWN_SERVER_URL = "not-exists" +private const val LOG_TAG = "MusicServiceModule" + +fun musicServiceModule( + sp: SharedPreferences, + context: Context +) = applicationContext { + context(MUSIC_SERVICE_CONTEXT) { + subsonicApiModule() + + bean(name = "ServerInstance") { + return@bean sp.getInt( + Constants.PREFERENCES_KEY_SERVER_INSTANCE, + DEFAULT_SERVER_INSTANCE + ) + } + + bean(name = "ServerID") { + val serverInstance = get(name = "ServerInstance") + val serverUrl = sp.getString( + Constants.PREFERENCES_KEY_SERVER_URL + serverInstance, + null + ) + return@bean if (serverUrl == null) { + UNKNOWN_SERVER_URL + } else { + abs("$serverUrl$serverInstance".hashCode()).toString() + } + } + + bean { + val serverId = get(name = "ServerID") + return@bean PermanentFileStorage(get(), serverId, BuildConfig.DEBUG) + } + + bean { + val instance = get(name = "ServerInstance") + val serverUrl = sp.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null) + val username = sp.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null) + val password = sp.getString(Constants.PREFERENCES_KEY_PASSWORD + instance, null) + val allowSelfSignedCertificate = sp.getBoolean( + Constants.PREFERENCES_KEY_ALLOW_SELF_SIGNED_CERTIFICATE + instance, + false + ) + val enableLdapUserSupport = sp.getBoolean( + Constants.PREFERENCES_KEY_LDAP_SUPPORT + instance, + false + ) + + if (serverUrl == null || + username == null || + password == null + ) { + Log.i(LOG_TAG, "Server credentials is not available") + return@bean SubsonicClientConfiguration( + baseUrl = "http://localhost", + username = "", + password = "", + minimalProtocolVersion = SubsonicAPIVersions.fromApiVersion( + Constants.REST_PROTOCOL_VERSION + ), + clientID = Constants.REST_CLIENT_ID, + allowSelfSignedCertificate = allowSelfSignedCertificate, + enableLdapUserSupport = enableLdapUserSupport, + debug = BuildConfig.DEBUG + ) + } else { + return@bean SubsonicClientConfiguration( + baseUrl = serverUrl, + username = username, + password = password, + minimalProtocolVersion = SubsonicAPIVersions.fromApiVersion( + Constants.REST_PROTOCOL_VERSION + ), + clientID = Constants.REST_CLIENT_ID, + allowSelfSignedCertificate = allowSelfSignedCertificate, + enableLdapUserSupport = enableLdapUserSupport, + debug = BuildConfig.DEBUG + ) + } + } + + bean { return@bean SubsonicAPIClient(get()) } + + bean(name = ONLINE_MUSIC_SERVICE) { + return@bean CachedMusicService(RESTMusicService(get(), get())) + } + + bean(name = OFFLINE_MUSIC_SERVICE) { + return@bean OfflineMusicService(get(), get()) + } + + bean { return@bean SubsonicImageLoader(context, get()) } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/featureflags/Feature.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/featureflags/Feature.kt new file mode 100644 index 00000000..1f0757a3 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/featureflags/Feature.kt @@ -0,0 +1,14 @@ +package org.moire.ultrasonic.featureflags + +/** + * Contains a list of new features/implementations that are not yet finished, + * but possible to try it out. + */ +enum class Feature( + val defaultValue: Boolean +) { + /** + * Enables new image downloader implementation. + */ + NEW_IMAGE_DOWNLOADER(false) +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/featureflags/FeatureStorage.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/featureflags/FeatureStorage.kt new file mode 100644 index 00000000..dca9bb4f --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/featureflags/FeatureStorage.kt @@ -0,0 +1,31 @@ +package org.moire.ultrasonic.featureflags + +import android.content.Context + +private const val SP_NAME = "feature_flags" + +/** + * Provides storage for current feature flag state. + */ +class FeatureStorage( + context: Context +) { + private val sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + + /** + * Get [feature] current enabled state. + */ + fun isFeatureEnabled(feature: Feature): Boolean { + return sp.getBoolean(feature.name, feature.defaultValue) + } + + /** + * Update [feature] enabled state to [isEnabled]. + */ + fun changeFeatureFlag( + feature: Feature, + isEnabled: Boolean + ) { + sp.edit().putBoolean(feature.name, isEnabled).apply() + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt new file mode 100644 index 00000000..5fbb393e --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt @@ -0,0 +1,56 @@ +/* + 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 . + + Copyright 2009 (C) Sindre Mehus + */ +package org.moire.ultrasonic.service + +import android.content.Context +import org.koin.standalone.KoinComponent +import org.koin.standalone.get +import org.koin.standalone.releaseContext +import org.moire.ultrasonic.cache.Directories +import org.moire.ultrasonic.di.MUSIC_SERVICE_CONTEXT +import org.moire.ultrasonic.di.OFFLINE_MUSIC_SERVICE +import org.moire.ultrasonic.di.ONLINE_MUSIC_SERVICE +import org.moire.ultrasonic.util.Util + +@Deprecated("Use DI way to get MusicService") +object MusicServiceFactory : KoinComponent { + @JvmStatic + fun getMusicService(context: Context): MusicService { + return if (Util.isOffline(context)) { + get(OFFLINE_MUSIC_SERVICE) + } else { + get(ONLINE_MUSIC_SERVICE) + } + } + + /** + * Resets [MusicService] to initial state, so on next call to [.getMusicService] + * it will return updated instance of it. + */ + @JvmStatic + fun resetMusicService() { + releaseContext(MUSIC_SERVICE_CONTEXT) + } + + @JvmStatic + fun getServerId() = get(name = "ServerID") + + @JvmStatic + fun getDirectories() = get() +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/SubsonicImageLoaderProxy.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/SubsonicImageLoaderProxy.kt new file mode 100644 index 00000000..4ed49943 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/SubsonicImageLoaderProxy.kt @@ -0,0 +1,65 @@ +package org.moire.ultrasonic.subsonic + +import android.view.View +import android.widget.ImageView +import org.moire.ultrasonic.R +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.subsonic.loader.image.ImageRequest +import org.moire.ultrasonic.subsonic.loader.image.SubsonicImageLoader +import org.moire.ultrasonic.util.ImageLoader +import org.moire.ultrasonic.util.LegacyImageLoader + +/** + * Temporary proxy between new [SubsonicImageLoader] and [ImageLoader] interface and old + * [LegacyImageLoader] implementation. + * + * Should be removed on [LegacyImageLoader] removal. + */ +class SubsonicImageLoaderProxy( + legacyImageLoader: LegacyImageLoader, + private val subsonicImageLoader: SubsonicImageLoader +) : ImageLoader by legacyImageLoader { + override fun loadImage( + view: View?, + entry: MusicDirectory.Entry?, + large: Boolean, + size: Int, + crossFade: Boolean, + highQuality: Boolean + ) { + val id = entry?.coverArt + + if (id != null && + view != null && + view is ImageView) { + val request = ImageRequest.CoverArt( + id, + view, + placeHolderDrawableRes = R.drawable.unknown_album, + errorDrawableRes = R.drawable.unknown_album + ) + subsonicImageLoader.load(request) + } + } + + override fun loadAvatarImage( + view: View?, + username: String?, + large: Boolean, + size: Int, + crossFade: Boolean, + highQuality: Boolean + ) { + if (username != null && + view != null && + view is ImageView) { + val request = ImageRequest.Avatar( + username, + view, + placeHolderDrawableRes = R.drawable.ic_contact_picture, + errorDrawableRes = R.drawable.ic_contact_picture + ) + subsonicImageLoader.load(request) + } + } +} diff --git a/ultrasonic/src/main/res/layout-port/download.xml b/ultrasonic/src/main/res/layout-port/download.xml index 49b442de..ab4c8b9e 100644 --- a/ultrasonic/src/main/res/layout-port/download.xml +++ b/ultrasonic/src/main/res/layout-port/download.xml @@ -20,8 +20,8 @@ diff --git a/ultrasonic/src/main/res/values-es/strings.xml b/ultrasonic/src/main/res/values-es/strings.xml index 9ef97bff..58599912 100644 --- a/ultrasonic/src/main/res/values-es/strings.xml +++ b/ultrasonic/src/main/res/values-es/strings.xml @@ -436,5 +436,10 @@ El período de prueba ha terminado. Versiones incompatibles. Por favor actualiza la aplicación de Android UltraSonic. Versiones incompatibles. Por favor actualiza el servidor de Subsonic. + Banderas de características + Permite la implementación de un nuevo cargador de imágenes. + Actualmente no guarda la imagen en el almacenamiento del dispositivo y sólo utiliza caché en la memoria. + + Habilitar nuevo cargador de imágenes diff --git a/ultrasonic/src/main/res/values-fr/strings.xml b/ultrasonic/src/main/res/values-fr/strings.xml index f2ca90fc..ddd81d03 100644 --- a/ultrasonic/src/main/res/values-fr/strings.xml +++ b/ultrasonic/src/main/res/values-fr/strings.xml @@ -436,5 +436,11 @@ La période d\'essai est terminée. Versions incompatible. Veuillez mette à jour l\'application Android UltraSonic. Versions incompatible. Veuillez mette à jour le serveur Subsonic. + Drapeaux des fonctionnalités + Permet l\'implémentation d\'un nouveau chargeur d\'images. + Actuellement, il n\'enregistre pas l\'image dans le stockage de l\'appareil et n\'utilise que le cache en + mémoire. + + Activer le nouveau chargeur d\'images diff --git a/ultrasonic/src/main/res/values-hu/strings.xml b/ultrasonic/src/main/res/values-hu/strings.xml index 9bfe728e..bca57a9c 100644 --- a/ultrasonic/src/main/res/values-hu/strings.xml +++ b/ultrasonic/src/main/res/values-hu/strings.xml @@ -436,5 +436,10 @@ A próbaidő vége. Nem kompatibilis verzió. Kérjük, frissítse az UltraSonic Android alkalmazást! Nem kompatibilis verzió. Kérjük, frissítse a Subsonic kiszolgálót! + Engedélyezi az új képbetöltő megvalósítását. Jelenleg nem + tárolja a képet az eszköz tárolójában, és csak a memóriában tárolja a gyorsítótárat. + + Jellemzők Zászlók + Engedélyezzen új képbetöltőt diff --git a/ultrasonic/src/main/res/values-pt-rBR/strings.xml b/ultrasonic/src/main/res/values-pt-rBR/strings.xml index a6085eda..4c0c6dfa 100644 --- a/ultrasonic/src/main/res/values-pt-rBR/strings.xml +++ b/ultrasonic/src/main/res/values-pt-rBR/strings.xml @@ -436,5 +436,10 @@ O período de avaliação acabou. Versões incompativeis. Atualize o aplicativo UltraSonic para Android. Versões incompativeis. Atualize o servidor UltraSonic. + Permite nova implementação do carregador de imagens. + Atualmente, ele não salva a imagem no armazenamento do dispositivo e usa apenas o cache na memória. + + Bandeiras de recursos + Ativar novo carregador de imagens diff --git a/ultrasonic/src/main/res/values-pt/strings.xml b/ultrasonic/src/main/res/values-pt/strings.xml index d5f1fcaf..784e116f 100644 --- a/ultrasonic/src/main/res/values-pt/strings.xml +++ b/ultrasonic/src/main/res/values-pt/strings.xml @@ -436,5 +436,10 @@ O período de avaliação acabou. Versões incompativeis. Atualize o aplicativo UltraSonic para Android. Versões incompativeis. Atualize o servidor UltraSonic. + Permite nova implementação do carregador de imagens. + Atualmente, ele não salva a imagem no armazenamento do dispositivo e usa apenas o cache na memória. + + Bandeiras de recursos + Ativar novo carregador de imagens diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index b9edf0d0..fe80ab0c 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -441,4 +441,10 @@ Incompatible versions. Please upgrade UltraSonic Android app. Incompatible versions. Please upgrade Subsonic server. + Enable new image loader + Enables new image loader implementation. + Currently it doesn\'t save image in device storage and uses only cache in memory. + + Feature Flags + diff --git a/ultrasonic/src/main/res/xml/settings.xml b/ultrasonic/src/main/res/xml/settings.xml index e66f2b7e..a4a52386 100644 --- a/ultrasonic/src/main/res/xml/settings.xml +++ b/ultrasonic/src/main/res/xml/settings.xml @@ -284,5 +284,14 @@ a:summary="@string/settings.screen_lit_summary" a:title="@string/settings.screen_lit_title"/> + + + + \ No newline at end of file