Merge pull request #76 from ultrasonic/add-get-avatar

Add get avatar
This commit is contained in:
Yahor Berdnikau 2017-11-19 21:50:57 +01:00 committed by GitHub
commit 7f5e04ebb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 127 additions and 533 deletions

View File

@ -0,0 +1,65 @@
package org.moire.ultrasonic.api.subsonic
import okhttp3.mockwebserver.MockResponse
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should equal to`
import org.amshove.kluent.`should equal`
import org.amshove.kluent.`should not be`
import org.junit.Test
/**
* Integration test for [SubsonicAPIClient.getAvatar] call.
*/
class SubsonicApiGetAvatarTest : SubsonicAPIClientTest() {
@Test
fun `Should handle api error response`() {
mockWebServerRule.enqueueResponse("generic_error_response.json")
val response = client.getAvatar("some")
with(response) {
stream `should be` null
responseHttpCode `should equal to` 200
apiError `should equal` SubsonicError.GENERIC
}
}
@Test
fun `Should handle server error`() {
val httpErrorCode = 500
mockWebServerRule.mockWebServer.enqueue(MockResponse().setResponseCode(httpErrorCode))
val response = client.getAvatar("some")
with(response) {
stream `should equal` null
responseHttpCode `should equal to` httpErrorCode
apiError `should be` null
}
}
@Test
fun `Should return successful call stream`() {
mockWebServerRule.mockWebServer.enqueue(MockResponse()
.setBody(mockWebServerRule.loadJsonResponse("ping_ok.json")))
val response = client.stream("some")
with(response) {
responseHttpCode `should equal to` 200
apiError `should be` null
stream `should not be` null
val expectedContent = mockWebServerRule.loadJsonResponse("ping_ok.json")
stream!!.bufferedReader().readText() `should equal to` expectedContent
}
}
@Test
fun `Should pass username as param`() {
val username = "Guardian"
mockWebServerRule.assertRequestParam(expectedParam = "username=$username") {
client.api.getAvatar(username).execute()
}
}
}

View File

@ -101,6 +101,17 @@ class SubsonicAPIClient(baseUrl: String,
api.stream(id, maxBitrate, offset = offset).execute()
}
/**
* Convenient method to get user avatar using [username].
*
* It detects the response `Content-Type` and tries to parse subsonic error if there is one.
*
* Prefer this method over [SubsonicAPIDefinition.getAvatar] as this handles error cases.
*/
fun getAvatar(username: String): StreamResponse = handleStreamResponse {
api.getAvatar(username).execute()
}
private inline fun handleStreamResponse(apiCall: () -> Response<ResponseBody>): StreamResponse {
val response = apiCall()
return if (response.isSuccessful) {

View File

@ -245,4 +245,7 @@ interface SubsonicAPIDefinition {
@GET("getVideos.view")
fun getVideos(): Call<VideosResponse>
@GET("getAvatar.view")
fun getAvatar(@Query("username") username: String): Call<ResponseBody>
}

View File

@ -19,7 +19,6 @@
package org.moire.ultrasonic.service;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
@ -28,16 +27,6 @@ import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.util.Log;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.conn.params.ConnManagerParams;
import org.apache.http.conn.params.ConnPerRouteBean;
import org.apache.http.conn.scheme.PlainSocketFactory;
@ -46,13 +35,9 @@ import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.scheme.SocketFactory;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.ExecutionContext;
import org.apache.http.protocol.HttpContext;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient;
import org.moire.ultrasonic.api.subsonic.models.AlbumListType;
@ -117,12 +102,10 @@ import org.moire.ultrasonic.domain.SearchResult;
import org.moire.ultrasonic.domain.Share;
import org.moire.ultrasonic.domain.UserInfo;
import org.moire.ultrasonic.domain.Version;
import org.moire.ultrasonic.service.parser.ErrorParser;
import org.moire.ultrasonic.service.parser.SubsonicRESTException;
import org.moire.ultrasonic.service.ssl.SSLSocketFactory;
import org.moire.ultrasonic.service.ssl.TrustSelfSignedStrategy;
import org.moire.ultrasonic.util.CancellableTask;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.FileUtil;
import org.moire.ultrasonic.util.ProgressListener;
import org.moire.ultrasonic.util.Util;
@ -133,16 +116,10 @@ import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicReference;
import kotlin.Pair;
import retrofit2.Response;
@ -150,20 +127,12 @@ import retrofit2.Response;
/**
* @author Sindre Mehus
*/
public class RESTMusicService implements MusicService
{
public class RESTMusicService implements MusicService {
private static final String TAG = RESTMusicService.class.getSimpleName();
private static final int SOCKET_CONNECT_TIMEOUT = 10 * 1000;
private static final int SOCKET_READ_TIMEOUT_DEFAULT = 10 * 1000;
/**
* URL from which to fetch latest versions.
*/
private static final String VERSION_URL = "http://subsonic.org/backend/version.view";
private static final int HTTP_REQUEST_MAX_ATTEMPTS = 5;
private static final long REDIRECTION_CHECK_INTERVAL_MILLIS = 60L * 60L * 1000L;
private final DefaultHttpClient httpClient;
@ -748,13 +717,6 @@ public class RESTMusicService implements MusicService
}
}
private static boolean checkServerVersion(Context context, String version)
{
Version serverVersion = Util.getServerRestVersion(context);
Version requiredVersion = new Version(version);
return serverVersion == null || serverVersion.compareTo(requiredVersion) >= 0;
}
@Override
public Bitmap getCoverArt(Context context,
final MusicDirectory.Entry entry,
@ -961,209 +923,6 @@ public class RESTMusicService implements MusicService
return APIShareConverter.toDomainEntitiesList(response.body().getShares());
}
private Reader getReader(Context context, ProgressListener progressListener, String method, HttpParams requestParams, List<String> parameterNames, List<Object> parameterValues) throws Exception
{
if (progressListener != null)
{
progressListener.updateProgress(R.string.service_connecting);
}
String url = Util.getRestUrl(context, method);
return getReaderForURL(context, url, requestParams, parameterNames, parameterValues, progressListener);
}
private Reader getReaderForURL(Context context, String url, HttpParams requestParams, List<String> parameterNames, List<Object> parameterValues, ProgressListener progressListener) throws Exception
{
HttpEntity entity = getEntityForURL(context, url, requestParams, parameterNames, parameterValues, progressListener);
if (entity == null)
{
throw new RuntimeException(String.format("No entity received for URL %s", url));
}
InputStream in = entity.getContent();
return new InputStreamReader(in, Constants.UTF_8);
}
private HttpEntity getEntityForURL(Context context, String url, HttpParams requestParams, List<String> parameterNames, List<Object> parameterValues, ProgressListener progressListener) throws Exception
{
return getResponseForURL(context, url, requestParams, parameterNames, parameterValues, null, progressListener, null).getEntity();
}
private HttpResponse getResponseForURL(Context context, String url, HttpParams requestParams, List<String> parameterNames, List<Object> parameterValues, Iterable<Header> headers, ProgressListener progressListener, CancellableTask task) throws Exception
{
Log.d(TAG, String.format("Connections in pool: %d", connManager.getConnectionsInPool()));
// If not too many parameters, extract them to the URL rather than
// relying on the HTTP POST request being
// received intact. Remember, HTTP POST requests are converted to GET
// requests during HTTP redirects, thus
// loosing its entity.
if (parameterNames != null)
{
int parameters = parameterNames.size();
if (parameters < 10)
{
StringBuilder builder = new StringBuilder(url);
for (int i = 0; i < parameters; i++)
{
builder.append('&').append(parameterNames.get(i)).append('=');
builder.append(URLEncoder.encode(String.valueOf(parameterValues.get(i)), "UTF-8"));
}
url = builder.toString();
parameterNames = null;
parameterValues = null;
}
}
String rewrittenUrl = rewriteUrlWithRedirect(context, url);
return executeWithRetry(context, rewrittenUrl, url, requestParams, parameterNames, parameterValues, headers, progressListener, task);
}
private HttpResponse executeWithRetry(Context context, String url, String originalUrl, HttpParams requestParams, List<String> parameterNames, List<Object> parameterValues, Iterable<Header> headers, ProgressListener progressListener, CancellableTask task) throws IOException
{
Log.i(TAG, String.format("Using URL %s", url));
int networkTimeout = Util.getNetworkTimeout(context);
HttpParams newParams = httpClient.getParams();
HttpConnectionParams.setSoTimeout(newParams, networkTimeout);
httpClient.setParams(newParams);
final AtomicReference<Boolean> cancelled = new AtomicReference<Boolean>(false);
int attempts = 0;
while (true)
{
attempts++;
HttpContext httpContext = new BasicHttpContext();
final HttpPost request = new HttpPost(url);
if (task != null)
{
// Attempt to abort the HTTP request if the task is cancelled.
task.setOnCancelListener(new CancellableTask.OnCancelListener()
{
@Override
public void onCancel()
{
new Thread(new Runnable()
{
@Override
public void run()
{
try
{
cancelled.set(true);
request.abort();
}
catch (Exception e)
{
Log.e(TAG, "Failed to stop http task");
}
}
}).start();
}
});
}
if (parameterNames != null)
{
List<NameValuePair> params = new ArrayList<NameValuePair>();
for (int i = 0; i < parameterNames.size(); i++)
{
params.add(new BasicNameValuePair(parameterNames.get(i), String.valueOf(parameterValues.get(i))));
}
request.setEntity(new UrlEncodedFormEntity(params, Constants.UTF_8));
}
if (requestParams != null)
{
request.setParams(requestParams);
Log.d(TAG, String.format("Socket read timeout: %d ms.", HttpConnectionParams.getSoTimeout(requestParams)));
}
if (headers != null)
{
for (Header header : headers)
{
request.addHeader(header);
}
}
// Set credentials to get through apache proxies that require authentication.
SharedPreferences preferences = Util.getPreferences(context);
int instance = preferences.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1);
String username = preferences.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null);
String password = preferences.getString(Constants.PREFERENCES_KEY_PASSWORD + instance, null);
httpClient.getCredentialsProvider().setCredentials(new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT), new UsernamePasswordCredentials(username, password));
try
{
HttpResponse response = httpClient.execute(request, httpContext);
detectRedirect(originalUrl, context, httpContext);
return response;
}
catch (IOException x)
{
request.abort();
if (attempts >= HTTP_REQUEST_MAX_ATTEMPTS || cancelled.get())
{
throw x;
}
if (progressListener != null)
{
String msg = context.getResources().getString(R.string.music_service_retry, attempts, HTTP_REQUEST_MAX_ATTEMPTS - 1);
progressListener.updateProgress(msg);
}
Log.w(TAG, String.format("Got IOException (%d), will retry", attempts), x);
increaseTimeouts(requestParams);
Util.sleepQuietly(2000L);
}
}
}
private static void increaseTimeouts(HttpParams requestParams)
{
if (requestParams != null)
{
int connectTimeout = HttpConnectionParams.getConnectionTimeout(requestParams);
if (connectTimeout != 0)
{
HttpConnectionParams.setConnectionTimeout(requestParams, (int) (connectTimeout * 1.3F));
}
int readTimeout = HttpConnectionParams.getSoTimeout(requestParams);
if (readTimeout != 0)
{
HttpConnectionParams.setSoTimeout(requestParams, (int) (readTimeout * 1.5F));
}
}
}
private void detectRedirect(String originalUrl, Context context, HttpContext httpContext)
{
HttpUriRequest request = (HttpUriRequest) httpContext.getAttribute(ExecutionContext.HTTP_REQUEST);
HttpHost host = (HttpHost) httpContext.getAttribute(ExecutionContext.HTTP_TARGET_HOST);
// Sometimes the request doesn't contain the "http://host" part
String redirectedUrl;
redirectedUrl = request.getURI().getScheme() == null ? host.toURI() + request.getURI() : request.getURI().toString();
redirectFrom = originalUrl.substring(0, originalUrl.indexOf("/rest/"));
redirectTo = redirectedUrl.substring(0, redirectedUrl.indexOf("/rest/"));
Log.i(TAG, String.format("%s redirects to %s", redirectFrom, redirectTo));
redirectionLastChecked = System.currentTimeMillis();
redirectionNetworkType = getCurrentNetworkType(context);
}
private String rewriteUrlWithRedirect(Context context, String url)
{
// Only cache for a certain time.
@ -1373,80 +1132,56 @@ public class RESTMusicService implements MusicService
checkResponseSuccessful(response);
}
@Override
public Bitmap getAvatar(Context context, String username, int size, boolean saveToFile, boolean highQuality, ProgressListener progressListener) throws Exception
{
// Return silently if server is too old
if (!checkServerVersion(context, "1.8"))
return null;
@Override
public Bitmap getAvatar(final Context context,
final String username,
final int size,
final boolean saveToFile,
final boolean highQuality,
final ProgressListener progressListener) throws Exception {
// Synchronize on the username so that we don't download concurrently for
// the same user.
if (username == null) {
return null;
}
// Synchronize on the username so that we don't download concurrently for
// the same user.
if (username == null)
{
return null;
}
synchronized (username) {
// Use cached file, if existing.
Bitmap bitmap = FileUtil.getAvatarBitmap(username, size, highQuality);
synchronized (username)
{
// Use cached file, if existing.
Bitmap bitmap = FileUtil.getAvatarBitmap(username, size, highQuality);
if (bitmap == null) {
InputStream in = null;
try {
updateProgressListener(progressListener, R.string.parser_reading);
StreamResponse response = subsonicAPIClient.getAvatar(username);
if (response.hasError()) {
return null;
}
in = response.getStream();
byte[] bytes = Util.toByteArray(in);
if (bitmap == null)
{
String url = Util.getRestUrl(context, "getAvatar");
// If we aren't allowing server-side scaling, always save the file to disk because it will be unmodified
if (saveToFile) {
OutputStream out = null;
InputStream in = null;
try {
out = new FileOutputStream(FileUtil.getAvatarFile(username));
out.write(bytes);
} finally {
Util.close(out);
}
}
try
{
List<String> parameterNames;
List<Object> parameterValues;
bitmap = FileUtil.getSampledBitmap(bytes, size, highQuality);
} finally {
Util.close(in);
}
}
parameterNames = Collections.singletonList("username");
parameterValues = Arrays.<Object>asList(username);
HttpEntity entity = getEntityForURL(context, url, null, parameterNames, parameterValues, progressListener);
in = entity.getContent();
// If content type is XML, an error occurred. Get it.
String contentType = Util.getContentType(entity);
if (contentType != null && contentType.startsWith("text/xml"))
{
new ErrorParser(context).parse(new InputStreamReader(in, Constants.UTF_8));
return null; // Never reached.
}
byte[] bytes = Util.toByteArray(in);
// If we aren't allowing server-side scaling, always save the file to disk because it will be unmodified
if (saveToFile)
{
OutputStream out = null;
try
{
out = new FileOutputStream(FileUtil.getAvatarFile(username));
out.write(bytes);
}
finally
{
Util.close(out);
}
}
bitmap = FileUtil.getSampledBitmap(bytes, size, highQuality);
}
finally
{
Util.close(in);
}
}
// Return scaled bitmap
return Util.scaleBitmap(bitmap, size);
}
}
// Return scaled bitmap
return Util.scaleBitmap(bitmap, size);
}
}
private void updateProgressListener(@Nullable final ProgressListener progressListener,
@StringRes final int messageId) {
@ -1462,7 +1197,9 @@ public class RESTMusicService implements MusicService
return;
}
if (response.body().getStatus() == SubsonicResponse.Status.ERROR &&
if (!response.isSuccessful()) {
throw new IOException("Server error, code: " + response.code());
} else if (response.body().getStatus() == SubsonicResponse.Status.ERROR &&
response.body().getError() != null) {
throw new IOException("Server error: " + response.body().getError().getCode());
} else {

View File

@ -1,167 +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.parser;
import android.content.Context;
import android.util.Xml;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Version;
import org.moire.ultrasonic.util.ProgressListener;
import org.moire.ultrasonic.util.Util;
import org.xmlpull.v1.XmlPullParser;
import java.io.Reader;
/**
* @author Sindre Mehus
*/
public abstract class AbstractParser
{
private final Context context;
private XmlPullParser parser;
private boolean rootElementFound;
public AbstractParser(Context context)
{
this.context = context;
}
protected Context getContext()
{
return context;
}
protected void handleError() throws Exception
{
int code = getInteger("code");
String message;
switch (code)
{
case 20:
message = context.getResources().getString(R.string.parser_upgrade_client);
break;
case 30:
message = context.getResources().getString(R.string.parser_upgrade_server);
break;
case 40:
message = context.getResources().getString(R.string.parser_not_authenticated);
break;
case 50:
message = context.getResources().getString(R.string.parser_not_authorized);
break;
default:
message = get("message");
break;
}
throw new SubsonicRESTException(code, message);
}
protected void updateProgress(ProgressListener progressListener, int messageId)
{
if (progressListener != null)
{
progressListener.updateProgress(messageId);
}
}
protected void updateProgress(ProgressListener progressListener, String message)
{
if (progressListener != null)
{
progressListener.updateProgress(message);
}
}
protected String getText()
{
return parser.getText();
}
protected String get(String name)
{
return parser.getAttributeValue(null, name);
}
protected boolean getBoolean(String name)
{
return "true".equals(get(name));
}
protected boolean getValueExists(String name)
{
String value = get(name);
return value != null && !value.isEmpty();
}
protected Integer getInteger(String name)
{
String s = get(name);
return s == null ? null : Integer.valueOf(s);
}
protected Long getLong(String name)
{
String s = get(name);
return s == null ? null : Long.valueOf(s);
}
protected Float getFloat(String name)
{
String s = get(name);
return s == null ? null : Float.valueOf(s);
}
protected void init(Reader reader) throws Exception
{
parser = Xml.newPullParser();
parser.setInput(reader);
rootElementFound = false;
}
protected int nextParseEvent() throws Exception
{
return parser.next();
}
protected String getElementName()
{
String name = parser.getName();
if ("subsonic-response".equals(name))
{
rootElementFound = true;
String version = get("version");
if (version != null)
{
Util.setServerRestVersion(context, new Version(version));
}
}
return name;
}
protected void validate() throws Exception
{
if (!rootElementFound)
{
throw new Exception(context.getResources().getString(R.string.background_task_parse_error));
}
}
}

View File

@ -1,55 +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.parser;
import android.content.Context;
import org.xmlpull.v1.XmlPullParser;
import java.io.Reader;
/**
* @author Sindre Mehus
*/
public class ErrorParser extends AbstractParser
{
public ErrorParser(Context context)
{
super(context);
}
public void parse(Reader reader) throws Exception
{
init(reader);
int eventType;
do
{
eventType = nextParseEvent();
if (eventType == XmlPullParser.START_TAG && "error".equals(getElementName()))
{
handleError();
}
} while (eventType != XmlPullParser.END_DOCUMENT);
validate();
}
}