diff --git a/dependencies.gradle b/dependencies.gradle index 437b99a1..4282a4f8 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -21,12 +21,15 @@ ext.versions = [ semver : "1.0.0", twitterSerial : "0.1.6", koin : "0.9.3", + picasso : "2.71828", junit : "4.12", mockito : "2.16.0", mockitoKotlin : "1.5.0", kluent : "1.35", apacheCodecs : "1.10", + testRunner : "1.0.1", + robolectric : "3.8", ] ext.gradlePlugins = [ @@ -40,6 +43,7 @@ ext.gradlePlugins = [ ext.androidSupport = [ support : "com.android.support:support-v4:$versions.androidSupport", design : "com.android.support:design:$versions.androidSupport", + annotations : "com.android.support:support-annotations:$versions.androidSupport" ] ext.other = [ @@ -53,7 +57,8 @@ ext.other = [ semver : "net.swiftzer.semver:semver:$versions.semver", twitterSerial : "com.twitter.serial:serial:$versions.twitterSerial", koinCore : "org.koin:koin-core:$versions.koin", - koinAndroid : "org.koin:koin-android:$versions.koin" + koinAndroid : "org.koin:koin-android:$versions.koin", + picasso : "com.squareup.picasso:picasso:$versions.picasso", ] ext.testing = [ @@ -63,6 +68,9 @@ ext.testing = [ mockito : "org.mockito:mockito-core:$versions.mockito", mockitoInline : "org.mockito:mockito-inline:$versions.mockito", kluent : "org.amshove.kluent:kluent:$versions.kluent", + kluentAndroid : "org.amshove.kluent:kluent-android:$versions.kluent", mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp", apacheCodecs : "commons-codec:commons-codec:$versions.apacheCodecs", + testRunner : "com.android.support.test:runner:$versions.testRunner", + robolectric : "org.robolectric:robolectric:$versions.robolectric", ] diff --git a/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/ultrasonic/build.gradle b/ultrasonic/build.gradle index bf224674..c2d49505 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -55,6 +55,7 @@ dependencies { implementation project(':library') implementation project(':domain') implementation project(':subsonic-api') + implementation project(':subsonic-api-image-loader') implementation project(':cache') implementation androidSupport.support diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 11730690..bcf7f624 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -1,205 +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/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/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/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/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 index f8bb9d24..8eb735b6 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt @@ -1,10 +1,14 @@ 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() { @@ -15,7 +19,22 @@ class UApp : Application() { startKoin(this, listOf( directoriesModule, baseNetworkModule, - musicServiceModule(sharedPreferences) + 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/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 index 884b4988..7809ff1b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt @@ -1,6 +1,7 @@ @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 @@ -14,6 +15,7 @@ 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 @@ -24,7 +26,10 @@ 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) = applicationContext { +fun musicServiceModule( + sp: SharedPreferences, + context: Context +) = applicationContext { context(MUSIC_SERVICE_CONTEXT) { subsonicApiModule() @@ -109,5 +114,7 @@ fun musicServiceModule(sp: SharedPreferences) = applicationContext { 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/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/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