Merge pull request #215 from ultrasonic/develop

New 2.6.0 release
This commit is contained in:
Óscar García Amor 2018-07-17 11:38:12 +02:00 committed by GitHub
commit ea9289b5db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 1639 additions and 846 deletions

View File

@ -4,28 +4,32 @@ ext.versions = [
compileSdk : 27, compileSdk : 27,
gradle : '4.5.1', gradle : '4.5.1',
androidTools : "3.1.0", androidTools : "3.1.3",
ktlint : "0.20.0", ktlint : "0.24.0",
ktlintGradle : "3.2.0", ktlintGradle : "4.1.0",
detekt : "1.0.0.RC6-4", detekt : "1.0.0.RC6-4",
jacoco : "0.7.9", jacoco : "0.7.9",
jacocoAndroid : "0.1.2", jacocoAndroid : "0.1.2",
androidSupport : "22.2.1", androidSupport : "22.2.1",
kotlin : "1.2.31", kotlin : "1.2.51",
retrofit : "2.4.0", retrofit : "2.4.0",
jackson : "2.9.5", jackson : "2.9.5",
okhttp : "3.10.0", okhttp : "3.10.0",
semver : "1.0.0", semver : "1.0.0",
twitterSerial : "0.1.6", twitterSerial : "0.1.6",
koin : "0.9.3",
picasso : "2.71828",
junit : "4.12", junit : "4.12",
mockito : "2.16.0", mockito : "2.16.0",
mockitoKotlin : "1.5.0", mockitoKotlin : "1.5.0",
kluent : "1.35", kluent : "1.35",
apacheCodecs : "1.10", apacheCodecs : "1.10",
testRunner : "1.0.1",
robolectric : "3.8",
] ]
ext.gradlePlugins = [ ext.gradlePlugins = [
@ -39,6 +43,7 @@ ext.gradlePlugins = [
ext.androidSupport = [ ext.androidSupport = [
support : "com.android.support:support-v4:$versions.androidSupport", support : "com.android.support:support-v4:$versions.androidSupport",
design : "com.android.support:design:$versions.androidSupport", design : "com.android.support:design:$versions.androidSupport",
annotations : "com.android.support:support-annotations:$versions.androidSupport"
] ]
ext.other = [ ext.other = [
@ -51,6 +56,9 @@ ext.other = [
okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$versions.okhttp", okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$versions.okhttp",
semver : "net.swiftzer.semver:semver:$versions.semver", semver : "net.swiftzer.semver:semver:$versions.semver",
twitterSerial : "com.twitter.serial:serial:$versions.twitterSerial", 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 = [ ext.testing = [
@ -60,6 +68,9 @@ ext.testing = [
mockito : "org.mockito:mockito-core:$versions.mockito", mockito : "org.mockito:mockito-core:$versions.mockito",
mockitoInline : "org.mockito:mockito-inline:$versions.mockito", mockitoInline : "org.mockito:mockito-inline:$versions.mockito",
kluent : "org.amshove.kluent:kluent:$versions.kluent", kluent : "org.amshove.kluent:kluent:$versions.kluent",
kluentAndroid : "org.amshove.kluent:kluent-android:$versions.kluent",
mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp", mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp",
apacheCodecs : "commons-codec:commons-codec:$versions.apacheCodecs", apacheCodecs : "commons-codec:commons-codec:$versions.apacheCodecs",
testRunner : "com.android.support.test:runner:$versions.testRunner",
robolectric : "org.robolectric:robolectric:$versions.robolectric",
] ]

View File

@ -21,6 +21,7 @@ def createJacocoFullReportTask() {
description = "Generate full Jacoco coverage report including all modules." description = "Generate full Jacoco coverage report including all modules."
def subsonicApi = project.findProject("subsonic-api") def subsonicApi = project.findProject("subsonic-api")
def subsonicApiImageLoader = project.findProject("subsonic-api-image-loader")
def ultrasonicApp = project.findProject("ultrasonic") def ultrasonicApp = project.findProject("ultrasonic")
def cache = project.findProject("cache") def cache = project.findProject("cache")
@ -29,6 +30,10 @@ def createJacocoFullReportTask() {
dir: "${subsonicApi.buildDir}/classes/main", dir: "${subsonicApi.buildDir}/classes/main",
excludes: subsonicApi.jacocoExclude excludes: subsonicApi.jacocoExclude
), ),
fileTree(
dir: "${subsonicApiImageLoader.buildDir}/intermediates/classes/debug/org",
excludes: subsonicApiImageLoader.jacocoExclude
),
fileTree( fileTree(
dir: "${ultrasonicApp.buildDir}/intermediates/classes/debug/org", dir: "${ultrasonicApp.buildDir}/intermediates/classes/debug/org",
excludes: ultrasonicApp.jacocoExclude excludes: ultrasonicApp.jacocoExclude
@ -38,8 +43,12 @@ def createJacocoFullReportTask() {
excludes: cache.jacocoExclude excludes: cache.jacocoExclude
) )
) )
sourceDirectories = files(subsonicApi.sourceSets.main.getAllSource(), sourceDirectories = files(
ultrasonicApp.extensions.getByName('android').sourceSets.main.java.sourceFiles) 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") executionData = files("${buildDir}/jacoco/jacoco.exec")
reports { reports {

View File

@ -1,6 +1,7 @@
include ':library' include ':library'
include ':domain' include ':domain'
include ':subsonic-api' include ':subsonic-api'
include ':subsonic-api-image-loader'
include ':cache' include ':cache'
include ':menudrawer' include ':menudrawer'
include ':pulltorefresh' include ':pulltorefresh'

View File

@ -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
}

View File

@ -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<SubsonicAPIClient>()
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()
}

View File

@ -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()
}

View File

@ -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<SubsonicAPIClient>()
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()
}

View File

@ -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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.moire.ultrasonic.subsonic.loader.image">
</manifest>

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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()

View File

@ -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
)
}

View File

@ -13,6 +13,8 @@ dependencies {
api other.kotlinStdlib api other.kotlinStdlib
api other.retrofit api other.retrofit
api other.jacksonConverter api other.jacksonConverter
api other.koinCore
implementation(other.jacksonKotlin) { implementation(other.jacksonKotlin) {
exclude module: 'kotlin-reflect' exclude module: 'kotlin-reflect'
} }
@ -36,7 +38,8 @@ jacoco {
ext { ext {
// Excluding data classes // Excluding data classes
jacocoExclude = [ jacocoExclude = [
'**/models/**' '**/models/**',
'**/di/**'
] ]
} }

View File

@ -21,8 +21,14 @@ class GetStreamUrlTest {
@Before @Before
fun setUp() { fun setUp() {
client = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), val config = SubsonicClientConfiguration(
USERNAME, PASSWORD, V1_6_0, CLIENT_ID) mockWebServerRule.mockWebServer.url("/").toString(),
USERNAME,
PASSWORD,
V1_6_0,
CLIENT_ID
)
client = SubsonicAPIClient(config)
val baseExpectedUrl = mockWebServerRule.mockWebServer.url("").toString() val baseExpectedUrl = mockWebServerRule.mockWebServer.url("").toString()
expectedUrl = "$baseExpectedUrl/rest/stream.view?id=$id&u=$USERNAME" + expectedUrl = "$baseExpectedUrl/rest/stream.view?id=$id&u=$USERNAME" +
"&c=$CLIENT_ID&f=json&v=${V1_6_0.restApiVersion}&p=enc:${PASSWORD.toHexBytes()}" "&c=$CLIENT_ID&f=json&v=${V1_6_0.restApiVersion}&p=enc:${PASSWORD.toHexBytes()}"

View File

@ -10,11 +10,18 @@ import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule
abstract class SubsonicAPIClientTest { abstract class SubsonicAPIClientTest {
@JvmField @Rule val mockWebServerRule = MockWebServerRule() @JvmField @Rule val mockWebServerRule = MockWebServerRule()
protected lateinit var config: SubsonicClientConfiguration
protected lateinit var client: SubsonicAPIClient protected lateinit var client: SubsonicAPIClient
@Before @Before
fun setUp() { fun setUp() {
client = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), config = SubsonicClientConfiguration(
USERNAME, PASSWORD, CLIENT_VERSION, CLIENT_ID) mockWebServerRule.mockWebServer.url("/").toString(),
USERNAME,
PASSWORD,
CLIENT_VERSION,
CLIENT_ID
)
client = SubsonicAPIClient(config)
} }
} }

View File

@ -10,8 +10,9 @@ import org.junit.Test
class SubsonicApiPasswordTest : SubsonicAPIClientTest() { class SubsonicApiPasswordTest : SubsonicAPIClientTest() {
@Test @Test
fun `Should pass PasswordMD5Interceptor in query params for api version 1 13 0`() { fun `Should pass PasswordMD5Interceptor in query params for api version 1 13 0`() {
val clientV12 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), val clientV12 = SubsonicAPIClient(
USERNAME, PASSWORD, SubsonicAPIVersions.V1_14_0, CLIENT_ID) config.copy(minimalProtocolVersion = SubsonicAPIVersions.V1_14_0)
)
mockWebServerRule.enqueueResponse("ping_ok.json") mockWebServerRule.enqueueResponse("ping_ok.json")
clientV12.api.ping().execute() clientV12.api.ping().execute()
@ -25,8 +26,9 @@ class SubsonicApiPasswordTest : SubsonicAPIClientTest() {
@Test @Test
fun `Should pass PasswordHexInterceptor in query params for api version 1 12 0`() { fun `Should pass PasswordHexInterceptor in query params for api version 1 12 0`() {
val clientV11 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), val clientV11 = SubsonicAPIClient(
USERNAME, PASSWORD, SubsonicAPIVersions.V1_12_0, CLIENT_ID) config.copy(minimalProtocolVersion = SubsonicAPIVersions.V1_12_0)
)
mockWebServerRule.enqueueResponse("ping_ok.json") mockWebServerRule.enqueueResponse("ping_ok.json")
clientV11.api.ping().execute() clientV11.api.ping().execute()

View File

@ -90,7 +90,15 @@ class SubsonicApiSSLTest {
assertResponseSuccessful(response) assertResponseSuccessful(response)
} }
private fun createSubsonicClient(allowSelfSignedCertificate: Boolean) = SubsonicAPIClient( private fun createSubsonicClient(allowSelfSignedCertificate: Boolean): SubsonicAPIClient {
"https://$HOST:$PORT/", USERNAME, PASSWORD, CLIENT_VERSION, CLIENT_ID, val config = SubsonicClientConfiguration(
allowSelfSignedCertificate = allowSelfSignedCertificate) "https://$HOST:$PORT/",
USERNAME,
PASSWORD,
CLIENT_VERSION,
CLIENT_ID,
allowSelfSignedCertificate = allowSelfSignedCertificate
)
return SubsonicAPIClient(config)
}
} }

View File

@ -36,44 +36,39 @@ private const val READ_TIMEOUT = 60_000L
* @author Yahor Berdnikau * @author Yahor Berdnikau
*/ */
class SubsonicAPIClient( class SubsonicAPIClient(
baseUrl: String, config: SubsonicClientConfiguration,
username: String, baseOkClient: OkHttpClient = OkHttpClient.Builder().build()
password: String,
minimalProtocolVersion: SubsonicAPIVersions,
clientID: String,
allowSelfSignedCertificate: Boolean = false,
enableLdapUserSupport: Boolean = false,
debug: Boolean = false
) { ) {
private val versionInterceptor = VersionInterceptor(minimalProtocolVersion) { private val versionInterceptor = VersionInterceptor(config.minimalProtocolVersion) {
protocolVersion = it protocolVersion = it
} }
private val proxyPasswordInterceptor = ProxyPasswordInterceptor( private val proxyPasswordInterceptor = ProxyPasswordInterceptor(
minimalProtocolVersion, config.minimalProtocolVersion,
PasswordHexInterceptor(password), PasswordHexInterceptor(config.password),
PasswordMD5Interceptor(password), PasswordMD5Interceptor(config.password),
enableLdapUserSupport) config.enableLdapUserSupport
)
/** /**
* Get currently used protocol version. * Get currently used protocol version.
*/ */
var protocolVersion = minimalProtocolVersion var protocolVersion = config.minimalProtocolVersion
private set(value) { private set(value) {
field = value field = value
proxyPasswordInterceptor.apiVersion = field proxyPasswordInterceptor.apiVersion = field
wrappedApi.currentApiVersion = field wrappedApi.currentApiVersion = field
} }
private val okHttpClient = OkHttpClient.Builder() private val okHttpClient = baseOkClient.newBuilder()
.readTimeout(READ_TIMEOUT, MILLISECONDS) .readTimeout(READ_TIMEOUT, MILLISECONDS)
.apply { if (allowSelfSignedCertificate) allowSelfSignedCertificates() } .apply { if (config.allowSelfSignedCertificate) allowSelfSignedCertificates() }
.addInterceptor { chain -> .addInterceptor { chain ->
// Adds default request params // Adds default request params
val originalRequest = chain.request() val originalRequest = chain.request()
val newUrl = originalRequest.url().newBuilder() val newUrl = originalRequest.url().newBuilder()
.addQueryParameter("u", username) .addQueryParameter("u", config.username)
.addQueryParameter("c", clientID) .addQueryParameter("c", config.clientID)
.addQueryParameter("f", "json") .addQueryParameter("f", "json")
.build() .build()
chain.proceed(originalRequest.newBuilder().url(newUrl).build()) chain.proceed(originalRequest.newBuilder().url(newUrl).build())
@ -81,7 +76,7 @@ class SubsonicAPIClient(
.addInterceptor(versionInterceptor) .addInterceptor(versionInterceptor)
.addInterceptor(proxyPasswordInterceptor) .addInterceptor(proxyPasswordInterceptor)
.addInterceptor(RangeHeaderInterceptor()) .addInterceptor(RangeHeaderInterceptor())
.apply { if (debug) addLogging() } .apply { if (config.debug) addLogging() }
.build() .build()
private val jacksonMapper = ObjectMapper() private val jacksonMapper = ObjectMapper()
@ -90,14 +85,15 @@ class SubsonicAPIClient(
.registerModule(KotlinModule()) .registerModule(KotlinModule())
private val retrofit = Retrofit.Builder() private val retrofit = Retrofit.Builder()
.baseUrl("$baseUrl/rest/") .baseUrl("${config.baseUrl}/rest/")
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(JacksonConverterFactory.create(jacksonMapper)) .addConverterFactory(JacksonConverterFactory.create(jacksonMapper))
.build() .build()
private val wrappedApi = ApiVersionCheckWrapper( private val wrappedApi = ApiVersionCheckWrapper(
retrofit.create(SubsonicAPIDefinition::class.java), retrofit.create(SubsonicAPIDefinition::class.java),
minimalProtocolVersion) config.minimalProtocolVersion
)
val api: SubsonicAPIDefinition get() = wrappedApi val api: SubsonicAPIDefinition get() = wrappedApi

View File

@ -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
)

View File

@ -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()) }
}

View File

@ -8,8 +8,8 @@ android {
defaultConfig { defaultConfig {
applicationId "org.moire.ultrasonic" applicationId "org.moire.ultrasonic"
versionCode 68 versionCode 69
versionName "2.5.0" versionName "2.6.0"
minSdkVersion versions.minSdk minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk targetSdkVersion versions.targetSdk
@ -55,12 +55,14 @@ dependencies {
implementation project(':library') implementation project(':library')
implementation project(':domain') implementation project(':domain')
implementation project(':subsonic-api') implementation project(':subsonic-api')
implementation project(':subsonic-api-image-loader')
implementation project(':cache') implementation project(':cache')
implementation androidSupport.support implementation androidSupport.support
implementation androidSupport.design implementation androidSupport.design
implementation other.kotlinStdlib implementation other.kotlinStdlib
implementation other.koinAndroid
testImplementation other.kotlinReflect testImplementation other.kotlinReflect
testImplementation testing.junit testImplementation testing.junit
@ -69,7 +71,7 @@ dependencies {
testImplementation testing.kluent testImplementation testing.kluent
} }
// Excluding all non-kotlin classes // Excluding all java classes and stuff that should not be covered
ext { ext {
jacocoExclude = [ jacocoExclude = [
'**/activity/**', '**/activity/**',
@ -83,7 +85,8 @@ ext {
'**/view/**', '**/view/**',
'**/R$*.class', '**/R$*.class',
'**/R.class', '**/R.class',
'**/BuildConfig.class' '**/BuildConfig.class',
'**/di/**'
] ]
} }
jacocoAndroidUnitTestReport { jacocoAndroidUnitTestReport {

View File

@ -1,204 +1,205 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:a="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.moire.ultrasonic" package="org.moire.ultrasonic"
a:installLocation="auto"> android:installLocation="auto">
<uses-permission a:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission a:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission a:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission a:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission a:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission a:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission a:name="android.permission.BLUETOOTH"/> <uses-permission android:name="android.permission.BLUETOOTH"/>
<supports-screens <supports-screens
a:anyDensity="true" android:anyDensity="true"
a:largeScreens="true" android:largeScreens="true"
a:normalScreens="true" android:normalScreens="true"
a:smallScreens="true" android:smallScreens="true"
a:xlargeScreens="true"/> android:xlargeScreens="true"/>
<application <application
a:allowBackup="false" android:allowBackup="false"
a:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
a:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
a:theme="@style/Theme.AppCompat" android:theme="@style/Theme.AppCompat"
a:label="@string/common.appname"> android:name=".app.UApp"
android:label="@string/common.appname">
<activity <activity
a:name=".activity.MainActivity" android:name=".activity.MainActivity"
a:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
a:label="UltraSonic" android:label="UltraSonic"
a:launchMode="standard"> android:launchMode="standard">
<intent-filter> <intent-filter>
<action a:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category a:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
a:name=".activity.SelectArtistActivity" android:name=".activity.SelectArtistActivity"
a:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
a:launchMode="standard"/> android:launchMode="standard"/>
<activity <activity
a:name=".activity.SelectAlbumActivity" android:name=".activity.SelectAlbumActivity"
a:configChanges="orientation|keyboardHidden"/> android:configChanges="orientation|keyboardHidden"/>
<activity <activity
a:name=".activity.SearchActivity" android:name=".activity.SearchActivity"
a:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
a:label="@string/search.label" android:label="@string/search.label"
a:launchMode="singleTask"/> android:launchMode="singleTask"/>
<activity <activity
a:name=".activity.SelectPlaylistActivity" android:name=".activity.SelectPlaylistActivity"
a:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
a:label="@string/playlist.label" android:label="@string/playlist.label"
a:launchMode="standard"/> android:launchMode="standard"/>
<activity <activity
a:name=".activity.PodcastsActivity" android:name=".activity.PodcastsActivity"
a:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
a:label="@string/podcasts.label" android:label="@string/podcasts.label"
a:launchMode="standard"/> android:launchMode="standard"/>
<activity <activity
a:name=".activity.BookmarkActivity" android:name=".activity.BookmarkActivity"
a:configChanges="orientation|keyboardHidden"/> android:configChanges="orientation|keyboardHidden"/>
<activity <activity
a:name=".activity.ShareActivity" android:name=".activity.ShareActivity"
a:configChanges="orientation|keyboardHidden"/> android:configChanges="orientation|keyboardHidden"/>
<activity <activity
a:name=".activity.ChatActivity" android:name=".activity.ChatActivity"
a:configChanges="orientation|keyboardHidden"/> android:configChanges="orientation|keyboardHidden"/>
<activity <activity
a:name=".activity.DownloadActivity" android:name=".activity.DownloadActivity"
a:configChanges="keyboardHidden" android:configChanges="keyboardHidden"
a:launchMode="singleTask" android:launchMode="singleTask"
a:exported="true" /> android:exported="true" />
<activity <activity
a:name=".activity.SettingsActivity" android:name=".activity.SettingsActivity"
a:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
a:launchMode="singleTask"/> android:launchMode="singleTask"/>
<activity <activity
a:name=".activity.HelpActivity" android:name=".activity.HelpActivity"
a:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
a:launchMode="singleTask"/> android:launchMode="singleTask"/>
<activity <activity
a:name=".activity.LyricsActivity" android:name=".activity.LyricsActivity"
a:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
a:launchMode="singleTask"/> android:launchMode="singleTask"/>
<activity <activity
a:name=".activity.EqualizerActivity" android:name=".activity.EqualizerActivity"
a:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
a:label="@string/equalizer.label" android:label="@string/equalizer.label"
a:launchMode="singleTask"/> android:launchMode="singleTask"/>
<activity <activity
a:name=".activity.SelectGenreActivity" android:name=".activity.SelectGenreActivity"
a:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
a:launchMode="standard"/> android:launchMode="standard"/>
<activity <activity
a:name=".activity.VoiceQueryReceiverActivity" android:name=".activity.VoiceQueryReceiverActivity"
a:launchMode="singleTask"> android:launchMode="singleTask">
<intent-filter> <intent-filter>
<action a:name="android.media.action.MEDIA_PLAY_FROM_SEARCH"/> <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH"/>
<category a:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
a:name=".activity.QueryReceiverActivity" android:name=".activity.QueryReceiverActivity"
a:launchMode="singleTask"> android:launchMode="singleTask">
<intent-filter> <intent-filter>
<action a:name="android.intent.action.SEARCH"/> <action android:name="android.intent.action.SEARCH"/>
<category a:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
</intent-filter> </intent-filter>
<meta-data <meta-data
a:name="android.app.searchable" android:name="android.app.searchable"
a:resource="@xml/searchable"/> android:resource="@xml/searchable"/>
</activity> </activity>
<activity a:name=".activity.ServerSettingsActivity" /> <activity android:name=".activity.ServerSettingsActivity" />
<service <service
a:name=".service.DownloadServiceImpl" android:name=".service.DownloadServiceImpl"
a:label="UltraSonic Download Service" android:label="UltraSonic Download Service"
a:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action a:name="org.moire.ultrasonic.CMD_TOGGLEPAUSE"/> <action android:name="org.moire.ultrasonic.CMD_TOGGLEPAUSE"/>
<action a:name="org.moire.ultrasonic.CMD_PLAY"/> <action android:name="org.moire.ultrasonic.CMD_PLAY"/>
<action a:name="org.moire.ultrasonic.CMD_PAUSE"/> <action android:name="org.moire.ultrasonic.CMD_PAUSE"/>
<action a:name="org.moire.ultrasonic.CMD_NEXT"/> <action android:name="org.moire.ultrasonic.CMD_NEXT"/>
<action a:name="org.moire.ultrasonic.CMD_PREVIOUS"/> <action android:name="org.moire.ultrasonic.CMD_PREVIOUS"/>
<action a:name="org.moire.ultrasonic.CMD_STOP"/> <action android:name="org.moire.ultrasonic.CMD_STOP"/>
</intent-filter> </intent-filter>
</service> </service>
<receiver a:name=".receiver.MediaButtonIntentReceiver"> <receiver android:name=".receiver.MediaButtonIntentReceiver">
<intent-filter a:priority="2147483647"> <intent-filter android:priority="2147483647">
<action a:name="android.intent.action.MEDIA_BUTTON"/> <action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver a:name=".receiver.BluetoothIntentReceiver"> <receiver android:name=".receiver.BluetoothIntentReceiver">
<intent-filter> <intent-filter>
<action a:name="android.bluetooth.device.action.ACL_CONNECTED"/> <action android:name="android.bluetooth.device.action.ACL_CONNECTED"/>
<action a:name="android.bluetooth.device.action.ACL_DISCONNECTED"/> <action android:name="android.bluetooth.device.action.ACL_DISCONNECTED"/>
<action a:name="android.bluetooth.device.action.ACL_DISCONNECT_REQUESTED"/> <action android:name="android.bluetooth.device.action.ACL_DISCONNECT_REQUESTED"/>
<action a:name="android.bluetooth.a2dp.action.SINK_STATE_CHANGED"/> <action android:name="android.bluetooth.a2dp.action.SINK_STATE_CHANGED"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver <receiver
a:name=".provider.UltraSonicAppWidgetProvider4x1" android:name=".provider.UltraSonicAppWidgetProvider4x1"
a:label="UltraSonic (4x1)"> android:label="UltraSonic (4x1)">
<intent-filter> <intent-filter>
<action a:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter> </intent-filter>
<meta-data <meta-data
a:name="android.appwidget.provider" android:name="android.appwidget.provider"
a:resource="@xml/appwidget_info_4x1"/> android:resource="@xml/appwidget_info_4x1"/>
</receiver> </receiver>
<receiver <receiver
a:name=".provider.UltraSonicAppWidgetProvider4x2" android:name=".provider.UltraSonicAppWidgetProvider4x2"
a:label="UltraSonic (4x2)"> android:label="UltraSonic (4x2)">
<intent-filter> <intent-filter>
<action a:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter> </intent-filter>
<meta-data <meta-data
a:name="android.appwidget.provider" android:name="android.appwidget.provider"
a:resource="@xml/appwidget_info_4x2"/> android:resource="@xml/appwidget_info_4x2"/>
</receiver> </receiver>
<receiver <receiver
a:name=".provider.UltraSonicAppWidgetProvider4x3" android:name=".provider.UltraSonicAppWidgetProvider4x3"
a:label="UltraSonic (4x3)"> android:label="UltraSonic (4x3)">
<intent-filter> <intent-filter>
<action a:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter> </intent-filter>
<meta-data <meta-data
a:name="android.appwidget.provider" android:name="android.appwidget.provider"
a:resource="@xml/appwidget_info_4x3"/> android:resource="@xml/appwidget_info_4x3"/>
</receiver> </receiver>
<receiver <receiver
a:name=".provider.UltraSonicAppWidgetProvider4x4" android:name=".provider.UltraSonicAppWidgetProvider4x4"
a:label="UltraSonic (4x4)"> android:label="UltraSonic (4x4)">
<intent-filter> <intent-filter>
<action a:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter> </intent-filter>
<meta-data <meta-data
a:name="android.appwidget.provider" android:name="android.appwidget.provider"
a:resource="@xml/appwidget_info_4x4"/> android:resource="@xml/appwidget_info_4x4"/>
</receiver> </receiver>
<provider <provider
a:name=".provider.SearchSuggestionProvider" android:name=".provider.SearchSuggestionProvider"
a:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/> android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/>
<meta-data <meta-data
a:name="android.app.default_searchable" android:name="android.app.default_searchable"
a:value="org.moire.ultrasonic.activity.QueryReceiverActivity"/> android:value="org.moire.ultrasonic.activity.QueryReceiverActivity"/>
<receiver <receiver
a:name=".receiver.A2dpIntentReceiver" android:name=".receiver.A2dpIntentReceiver"
a:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action a:name="com.android.music.playstatusrequest"/> <action android:name="com.android.music.playstatusrequest"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
</application> </application>

View File

@ -887,8 +887,11 @@ public class DownloadActivity extends SubsonicTabActivity implements OnGestureLi
} }
Intent intent = new Intent(this, SelectAlbumActivity.class); 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_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); startActivityForResultWithoutTransition(this, intent);
return true; return true;
case R.id.menu_lyrics: case R.id.menu_lyrics:

View File

@ -20,7 +20,6 @@ package org.moire.ultrasonic.activity;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.app.Dialog; import android.app.Dialog;
import android.app.NotificationManager;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
@ -33,54 +32,26 @@ import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import android.support.v7.app.ActionBar; import android.support.v7.app.ActionBar;
import android.util.Log; import android.util.Log;
import android.view.KeyEvent; import android.view.*;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener; import android.view.View.OnClickListener;
import android.view.View.OnTouchListener; import android.view.View.OnTouchListener;
import android.view.ViewGroup; import android.widget.*;
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 net.simonvt.menudrawer.MenuDrawer; import net.simonvt.menudrawer.MenuDrawer;
import net.simonvt.menudrawer.Position; import net.simonvt.menudrawer.Position;
import org.moire.ultrasonic.R; import org.moire.ultrasonic.R;
import org.moire.ultrasonic.app.UApp;
import org.moire.ultrasonic.domain.MusicDirectory; import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.domain.MusicDirectory.Entry; import org.moire.ultrasonic.domain.MusicDirectory.Entry;
import org.moire.ultrasonic.domain.PlayerState; import org.moire.ultrasonic.domain.PlayerState;
import org.moire.ultrasonic.domain.Share; import org.moire.ultrasonic.domain.Share;
import org.moire.ultrasonic.service.DownloadFile; import org.moire.ultrasonic.featureflags.Feature;
import org.moire.ultrasonic.service.DownloadService; import org.moire.ultrasonic.service.*;
import org.moire.ultrasonic.service.DownloadServiceImpl; import org.moire.ultrasonic.subsonic.SubsonicImageLoaderProxy;
import org.moire.ultrasonic.service.MusicService; import org.moire.ultrasonic.util.*;
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 java.io.File; import java.io.File;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.util.ArrayList; import java.util.*;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
@ -820,21 +791,39 @@ public class SubsonicTabActivity extends ResultActivity implements OnClickListen
} }
} }
public synchronized void clearImageLoader() public synchronized void clearImageLoader() {
{ if (IMAGE_LOADER != null &&
if (IMAGE_LOADER != null && IMAGE_LOADER.isRunning()) IMAGE_LOADER.clear(); IMAGE_LOADER.isRunning()) {
} IMAGE_LOADER.clear();
}
public synchronized ImageLoader getImageLoader() IMAGE_LOADER = null;
{ }
if (IMAGE_LOADER == null || !IMAGE_LOADER.isRunning())
{
IMAGE_LOADER = new ImageLoader(this, Util.getImageLoaderConcurrency(this));
IMAGE_LOADER.startImageLoader();
}
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<Entry> songs) void download(final boolean append, final boolean save, final boolean autoPlay, final boolean playNext, final boolean shuffle, final List<Entry> songs)
{ {

View File

@ -14,6 +14,7 @@ import android.view.View;
import org.moire.ultrasonic.BuildConfig; import org.moire.ultrasonic.BuildConfig;
import org.moire.ultrasonic.R; import org.moire.ultrasonic.R;
import org.moire.ultrasonic.cache.Directories;
import org.moire.ultrasonic.cache.PermanentFileStorage; import org.moire.ultrasonic.cache.PermanentFileStorage;
import org.moire.ultrasonic.service.MusicService; import org.moire.ultrasonic.service.MusicService;
import org.moire.ultrasonic.service.MusicServiceFactory; import org.moire.ultrasonic.service.MusicServiceFactory;
@ -282,9 +283,10 @@ public class ServerSettingsFragment extends PreferenceFragment
.getInt(Constants.PREFERENCES_KEY_ACTIVE_SERVERS, 0); .getInt(Constants.PREFERENCES_KEY_ACTIVE_SERVERS, 0);
// Clear permanent storage // 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( final PermanentFileStorage fileStorage = new PermanentFileStorage(
MusicServiceFactory.getDirectories(getActivity()), directories,
storageServerId, storageServerId,
BuildConfig.DEBUG BuildConfig.DEBUG
); );

View File

@ -4,29 +4,21 @@ import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.preference.CheckBoxPreference; import android.preference.*;
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.provider.SearchRecentSuggestions; import android.provider.SearchRecentSuggestions;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
import org.moire.ultrasonic.R; import org.moire.ultrasonic.R;
import org.moire.ultrasonic.activity.ServerSettingsActivity; import org.moire.ultrasonic.activity.ServerSettingsActivity;
import org.moire.ultrasonic.activity.SubsonicTabActivity; 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.provider.SearchSuggestionProvider;
import org.moire.ultrasonic.service.DownloadService; import org.moire.ultrasonic.service.DownloadService;
import org.moire.ultrasonic.service.DownloadServiceImpl; import org.moire.ultrasonic.service.DownloadServiceImpl;
import org.moire.ultrasonic.util.Constants; import org.moire.ultrasonic.util.*;
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 java.io.File; import java.io.File;
@ -115,6 +107,7 @@ public class SettingsFragment extends PreferenceFragment
sharingDefaultGreeting.setText(Util.getShareGreeting(getActivity())); sharingDefaultGreeting.setText(Util.getShareGreeting(getActivity()));
setupClearSearchPreference(); setupClearSearchPreference();
setupGaplessControlSettingsV14(); setupGaplessControlSettingsV14();
setupFeatureFlagsPreferences();
} }
@Override @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() { private void setupGaplessControlSettingsV14() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
PreferenceCategory playbackControlSettings = PreferenceCategory playbackControlSettings =

View File

@ -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 <http://www.gnu.org/licenses/>.
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();
}
};
}
}

View File

@ -21,20 +21,17 @@ package org.moire.ultrasonic.util;
import android.app.Activity; import android.app.Activity;
import android.os.Handler; import android.os.Handler;
import android.util.Log; import android.util.Log;
import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonParseException;
import org.moire.ultrasonic.R; import org.moire.ultrasonic.R;
import org.moire.ultrasonic.service.SubsonicRESTException; import org.moire.ultrasonic.service.SubsonicRESTException;
import org.moire.ultrasonic.subsonic.RestErrorMapper; import org.moire.ultrasonic.subsonic.RestErrorMapper;
import javax.net.ssl.SSLException;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.security.cert.CertPathValidatorException; import java.security.cert.CertPathValidatorException;
import java.security.cert.CertificateException; import java.security.cert.CertificateException;
import javax.net.ssl.SSLException;
/** /**
* @author Sindre Mehus * @author Sindre Mehus
*/ */
@ -70,7 +67,7 @@ public abstract class BackgroundTask<T> implements ProgressListener
protected void error(Throwable error) protected void error(Throwable error)
{ {
Log.w(TAG, String.format("Got exception: %s", error), 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) { protected String getErrorMessage(Throwable error) {

View File

@ -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_SHOW_ALL_SONGS_BY_ARTIST = "showAllSongsByArtist";
public static final String PREFERENCES_KEY_SCAN_MEDIA = "scanMedia"; 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_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. // Number of free trial days for non-licensed servers.
public static final int FREE_TRIAL_DAYS = 30; public static final int FREE_TRIAL_DAYS = 30;

View File

@ -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 <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.util; package org.moire.ultrasonic.util;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap; 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.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.domain.MusicDirectory;
import org.moire.ultrasonic.service.MusicService;
import org.moire.ultrasonic.service.MusicServiceFactory;
import java.util.ArrayList; public interface ImageLoader {
import java.util.Collection; boolean isRunning();
import java.util.Collections;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
/** void setConcurrency(int concurrency);
* Asynchronous loading of images, with caching.
* <p/>
* 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();
private final LRUCache<String, Bitmap> cache = new LRUCache<String, Bitmap>(150); void startImageLoader();
private final BlockingQueue<Task> queue;
private int imageSizeDefault;
private final int imageSizeLarge;
private Bitmap largeUnknownImage;
private Bitmap unknownAvatarImage;
private Context context;
private Collection<Thread> threads;
private AtomicBoolean running = new AtomicBoolean();
private int concurrency;
public ImageLoader(Context context, int concurrency) void stopImageLoader();
{
this.context = context;
this.concurrency = concurrency;
queue = new LinkedBlockingQueue<Task>(1000);
Resources resources = context.getResources(); void loadAvatarImage(
Drawable drawable = resources.getDrawable(R.drawable.unknown_album); View view,
String username,
boolean large,
int size,
boolean crossFade,
boolean highQuality
);
// Determine the density-dependent image sizes. void loadImage(
if (drawable != null) View view,
{ MusicDirectory.Entry entry,
imageSizeDefault = drawable.getIntrinsicHeight(); boolean large,
} int size,
boolean crossFade,
boolean highQuality
);
imageSizeLarge = Util.getMaxDisplayMetric(context); Bitmap getImageBitmap(String username, int size);
createLargeUnknownImage(context);
createUnknownAvatarImage(context);
}
public synchronized boolean isRunning() Bitmap getImageBitmap(MusicDirectory.Entry entry, boolean large, int size);
{
return running.get() && !threads.isEmpty();
}
public void setConcurrency(int concurrency) void addImageToCache(Bitmap bitmap, MusicDirectory.Entry entry, int size);
{
this.concurrency = concurrency;
}
public void startImageLoader() void addImageToCache(Bitmap bitmap, String username, int size);
{
running.set(true);
threads = Collections.synchronizedCollection(new ArrayList<Thread>(this.concurrency)); void clear();
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);
}
}
}
} }

View File

@ -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 <http://www.gnu.org/licenses/>.
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.
* <p/>
* 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<String, Bitmap> cache = new LRUCache<>(150);
private final BlockingQueue<Task> queue;
private int imageSizeDefault;
private final int imageSizeLarge;
private Bitmap largeUnknownImage;
private Bitmap unknownAvatarImage;
private Context context;
private Collection<Thread> 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<Thread>(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);
}
}
}
}

View File

@ -171,14 +171,16 @@ public class Util extends DownloadActivity
return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS, false); 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(); MusicServiceFactory.resetMusicService();
SharedPreferences preferences = getPreferences(context); SharedPreferences preferences = getPreferences(context);
SharedPreferences.Editor editor = preferences.edit(); SharedPreferences.Editor editor = preferences.edit();
editor.putInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, instance); editor.putInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, instance);
editor.commit(); editor.apply();
} }
public static int getActiveServer(Context context) public static int getActiveServer(Context context)
{ {

View File

@ -25,7 +25,6 @@ import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import org.moire.ultrasonic.R; import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.MusicDirectory; import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.service.MusicService; import org.moire.ultrasonic.service.MusicService;

View File

@ -8,7 +8,6 @@ import android.view.ViewGroup;
import android.widget.ArrayAdapter; import android.widget.ArrayAdapter;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import org.moire.ultrasonic.R; import org.moire.ultrasonic.R;
import org.moire.ultrasonic.activity.SubsonicTabActivity; import org.moire.ultrasonic.activity.SubsonicTabActivity;
import org.moire.ultrasonic.domain.ChatMessage; import org.moire.ultrasonic.domain.ChatMessage;

View File

@ -24,7 +24,6 @@ import android.widget.ArrayAdapter;
import android.widget.CheckedTextView; import android.widget.CheckedTextView;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import org.moire.ultrasonic.activity.SubsonicTabActivity; import org.moire.ultrasonic.activity.SubsonicTabActivity;
import org.moire.ultrasonic.domain.MusicDirectory.Entry; import org.moire.ultrasonic.domain.MusicDirectory.Entry;
import org.moire.ultrasonic.util.ImageLoader; import org.moire.ultrasonic.util.ImageLoader;

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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() }
}

View File

@ -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
}

View File

@ -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) }
}

View File

@ -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<Int>(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<String>(name = "ServerID")
return@bean PermanentFileStorage(get(), serverId, BuildConfig.DEBUG)
}
bean {
val instance = get<Int>(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<MusicService>(name = ONLINE_MUSIC_SERVICE) {
return@bean CachedMusicService(RESTMusicService(get(), get()))
}
bean<MusicService>(name = OFFLINE_MUSIC_SERVICE) {
return@bean OfflineMusicService(get(), get())
}
bean { return@bean SubsonicImageLoader(context, get()) }
}
}

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
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<String>(name = "ServerID")
@JvmStatic
fun getDirectories() = get<Directories>()
}

View File

@ -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)
}
}
}

View File

@ -20,8 +20,8 @@
<ImageView <ImageView
a:id="@+id/download_album_art_image" a:id="@+id/download_album_art_image"
a:layout_width="wrap_content" a:layout_width="match_parent"
a:layout_height="fill_parent" a:layout_height="match_parent"
a:scaleType="centerCrop" a:scaleType="centerCrop"
a:layout_alignParentTop="true" a:layout_alignParentTop="true"
a:layout_centerInParent="true" a:layout_centerInParent="true"

View File

@ -8,8 +8,8 @@
<ImageView <ImageView
a:id="@+id/album_coverart" a:id="@+id/album_coverart"
a:layout_width="wrap_content" a:layout_width="64dp"
a:layout_height="wrap_content" a:layout_height="64dp"
a:layout_gravity="left|center_vertical" a:layout_gravity="left|center_vertical"
a:paddingLeft="3dip"/> a:paddingLeft="3dip"/>

View File

@ -436,5 +436,10 @@
<string name="api.subsonic.trial_period_is_over">El período de prueba ha terminado.</string> <string name="api.subsonic.trial_period_is_over">El período de prueba ha terminado.</string>
<string name="api.subsonic.upgrade_client">Versiones incompatibles. Por favor actualiza la aplicación de Android UltraSonic.</string> <string name="api.subsonic.upgrade_client">Versiones incompatibles. Por favor actualiza la aplicación de Android UltraSonic.</string>
<string name="api.subsonic.upgrade_server">Versiones incompatibles. Por favor actualiza el servidor de Subsonic.</string> <string name="api.subsonic.upgrade_server">Versiones incompatibles. Por favor actualiza el servidor de Subsonic.</string>
<string name="feature_flags_category_title">Banderas de características</string>
<string name="feature_flags_image_loader_description">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.
</string>
<string name="feature_flags_image_loader_title">Habilitar nuevo cargador de imágenes</string>
</resources> </resources>

View File

@ -436,5 +436,11 @@
<string name="api.subsonic.trial_period_is_over">La période d\'essai est terminée.</string> <string name="api.subsonic.trial_period_is_over">La période d\'essai est terminée.</string>
<string name="api.subsonic.upgrade_client">Versions incompatible. Veuillez mette à jour l\'application Android UltraSonic.</string> <string name="api.subsonic.upgrade_client">Versions incompatible. Veuillez mette à jour l\'application Android UltraSonic.</string>
<string name="api.subsonic.upgrade_server">Versions incompatible. Veuillez mette à jour le serveur Subsonic.</string> <string name="api.subsonic.upgrade_server">Versions incompatible. Veuillez mette à jour le serveur Subsonic.</string>
<string name="feature_flags_category_title">Drapeaux des fonctionnalités</string>
<string name="feature_flags_image_loader_description">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.
</string>
<string name="feature_flags_image_loader_title">Activer le nouveau chargeur d\'images</string>
</resources> </resources>

View File

@ -436,5 +436,10 @@
<string name="api.subsonic.trial_period_is_over">A próbaidő vége.</string> <string name="api.subsonic.trial_period_is_over">A próbaidő vége.</string>
<string name="api.subsonic.upgrade_client">Nem kompatibilis verzió. Kérjük, frissítse az UltraSonic Android alkalmazást!</string> <string name="api.subsonic.upgrade_client">Nem kompatibilis verzió. Kérjük, frissítse az UltraSonic Android alkalmazást!</string>
<string name="api.subsonic.upgrade_server">Nem kompatibilis verzió. Kérjük, frissítse a Subsonic kiszolgálót!</string> <string name="api.subsonic.upgrade_server">Nem kompatibilis verzió. Kérjük, frissítse a Subsonic kiszolgálót!</string>
<string name="feature_flags_image_loader_description">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.
</string>
<string name="feature_flags_category_title">Jellemzők Zászlók</string>
<string name="feature_flags_image_loader_title">Engedélyezzen új képbetöltőt</string>
</resources> </resources>

View File

@ -436,5 +436,10 @@
<string name="api.subsonic.trial_period_is_over">O período de avaliação acabou.</string> <string name="api.subsonic.trial_period_is_over">O período de avaliação acabou.</string>
<string name="api.subsonic.upgrade_client">Versões incompativeis. Atualize o aplicativo UltraSonic para Android.</string> <string name="api.subsonic.upgrade_client">Versões incompativeis. Atualize o aplicativo UltraSonic para Android.</string>
<string name="api.subsonic.upgrade_server">Versões incompativeis. Atualize o servidor UltraSonic.</string> <string name="api.subsonic.upgrade_server">Versões incompativeis. Atualize o servidor UltraSonic.</string>
<string name="feature_flags_image_loader_description">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.
</string>
<string name="feature_flags_category_title">Bandeiras de recursos</string>
<string name="feature_flags_image_loader_title">Ativar novo carregador de imagens</string>
</resources> </resources>

View File

@ -436,5 +436,10 @@
<string name="api.subsonic.trial_period_is_over">O período de avaliação acabou.</string> <string name="api.subsonic.trial_period_is_over">O período de avaliação acabou.</string>
<string name="api.subsonic.upgrade_client">Versões incompativeis. Atualize o aplicativo UltraSonic para Android.</string> <string name="api.subsonic.upgrade_client">Versões incompativeis. Atualize o aplicativo UltraSonic para Android.</string>
<string name="api.subsonic.upgrade_server">Versões incompativeis. Atualize o servidor UltraSonic.</string> <string name="api.subsonic.upgrade_server">Versões incompativeis. Atualize o servidor UltraSonic.</string>
<string name="feature_flags_image_loader_description">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.
</string>
<string name="feature_flags_category_title">Bandeiras de recursos</string>
<string name="feature_flags_image_loader_title">Ativar novo carregador de imagens</string>
</resources> </resources>

View File

@ -441,4 +441,10 @@
<string name="api.subsonic.upgrade_client">Incompatible versions. Please upgrade UltraSonic Android app.</string> <string name="api.subsonic.upgrade_client">Incompatible versions. Please upgrade UltraSonic Android app.</string>
<string name="api.subsonic.upgrade_server">Incompatible versions. Please upgrade Subsonic server.</string> <string name="api.subsonic.upgrade_server">Incompatible versions. Please upgrade Subsonic server.</string>
<string name="feature_flags_image_loader_title">Enable new image loader</string>
<string name="feature_flags_image_loader_description">Enables new image loader implementation.
Currently it doesn\'t save image in device storage and uses only cache in memory.
</string>
<string name="feature_flags_category_title">Feature Flags</string>
</resources> </resources>

View File

@ -284,5 +284,14 @@
a:summary="@string/settings.screen_lit_summary" a:summary="@string/settings.screen_lit_summary"
a:title="@string/settings.screen_lit_title"/> a:title="@string/settings.screen_lit_title"/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory a:title="@string/feature_flags_category_title">
<CheckBoxPreference
a:key="ff_new_image_loader"
a:persistent="false"
a:title="@string/feature_flags_image_loader_title"
a:summary="@string/feature_flags_image_loader_description"
/>
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>