diff --git a/app/build.gradle b/app/build.gradle index 8c0c4cd1a7f095c0dbda101a487cf45821af03d1..d431fddabd25fe35b37eb39b23d7117ef9c61e21 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,6 +26,31 @@ android { } } + packagingOptions { + exclude 'META-INF/DEPENDENCIES' + exclude 'META-INF/LICENSE' + exclude 'META-INF/LICENSE.txt' + exclude 'META-INF/license.txt' + exclude 'META-INF/NOTICE' + exclude 'META-INF/NOTICE.txt' + exclude 'META-INF/notice.txt' + exclude 'META-INF/ASL2.0' + } + + lintOptions { + abortOnError false + } + +} + +ext { + okhttp_version = "3.0.1" + oltu_version = "1.0.0" + retrofit_version = "2.0.0-beta3" + gson_version = "2.4" + swagger_annotations_version = "1.5.0" + junit_version = "4.12" + } dependencies { @@ -46,6 +71,14 @@ dependencies { implementation 'com.google.dagger:dagger-android:2.11' compileOnly 'javax.annotation:jsr250-api:1.0' + // RetroFit + implementation "com.squareup.okhttp3:okhttp:$okhttp_version" + implementation "com.squareup.retrofit2:retrofit:$retrofit_version" + implementation "com.squareup.retrofit2:converter-gson:$retrofit_version" + implementation "com.google.code.gson:gson:$gson_version" + implementation "io.swagger:swagger-annotations:$swagger_annotations_version" + implementation "org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:$oltu_version" + // Room implementation 'android.arch.persistence.room:runtime:1.0.0' annotationProcessor 'android.arch.persistence.room:compiler:1.0.0' diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/XKCDBrowserApplicationComponent.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/XKCDBrowserApplicationComponent.java index a90d0783dd69df9370dcf918e12744b4bc742c1a..6d3221882c38cbd468bb1682185a4cd1e33db5f3 100644 --- a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/XKCDBrowserApplicationComponent.java +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/XKCDBrowserApplicationComponent.java @@ -7,13 +7,17 @@ import dagger.Component; import me.szaki.xkcd.xkcdbrowser.database.DBModule; import me.szaki.xkcd.xkcdbrowser.interactor.InteractorModule; import me.szaki.xkcd.xkcdbrowser.interactor.comics.ComicsInteractor; +import me.szaki.xkcd.xkcdbrowser.network.NetworkModule; import me.szaki.xkcd.xkcdbrowser.ui.UIModule; import me.szaki.xkcd.xkcdbrowser.ui.detail.DetailActivity; +import me.szaki.xkcd.xkcdbrowser.ui.detail.DetailPresenter; import me.szaki.xkcd.xkcdbrowser.ui.favorites.FavoritesActivity; +import me.szaki.xkcd.xkcdbrowser.ui.favorites.FavoritesPresenter; import me.szaki.xkcd.xkcdbrowser.ui.main.MainActivity; +import me.szaki.xkcd.xkcdbrowser.ui.main.MainPresenter; @Singleton -@Component(modules = {UIModule.class, InteractorModule.class, DBModule.class}) +@Component(modules = {UIModule.class, InteractorModule.class, NetworkModule.class, DBModule.class}) public interface XKCDBrowserApplicationComponent { void inject(MainActivity mainActivity); @@ -22,5 +26,11 @@ public interface XKCDBrowserApplicationComponent { void inject(FavoritesActivity favoritesActivity); + void inject(MainPresenter mainPresenter); + + void inject(DetailPresenter detailPresenter); + + void inject(FavoritesPresenter favoritesPresenter); + void inject(ComicsInteractor comicsInteractor); } diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/interactor/comics/ComicsInteractor.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/interactor/comics/ComicsInteractor.java index 30603ccaa0156bc1197e5d322647ae854fa7d9d3..2fd12020b8373bde88711bbf1ffadab2e17f3e71 100644 --- a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/interactor/comics/ComicsInteractor.java +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/interactor/comics/ComicsInteractor.java @@ -1,10 +1,23 @@ package me.szaki.xkcd.xkcdbrowser.interactor.comics; +import java.io.IOException; + +import javax.inject.Inject; + import me.szaki.xkcd.xkcdbrowser.XKCDBrowserApplication; +import me.szaki.xkcd.xkcdbrowser.network.api.ComicApi; +import me.szaki.xkcd.xkcdbrowser.network.model.Comic; public class ComicsInteractor { + @Inject + ComicApi comicApi; + public ComicsInteractor() { XKCDBrowserApplication.injector.inject(this); } + + public Comic getComic(long id) throws IOException { + return this.comicApi.getComic(id).execute().body(); + } } diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/ApiClient.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/ApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..f39724dbfdd172946a6a4a4260769798fa701246 --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/ApiClient.java @@ -0,0 +1,337 @@ +package me.szaki.xkcd.xkcdbrowser.network; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.oltu.oauth2.client.request.OAuthClientRequest.AuthenticationRequestBuilder; +import org.apache.oltu.oauth2.client.request.OAuthClientRequest.TokenRequestBuilder; + +import retrofit2.Converter; +import retrofit2.Retrofit; +import retrofit2.GsonConverterFactory; + + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; +import okhttp3.ResponseBody; + + +import me.szaki.xkcd.xkcdbrowser.network.auth.HttpBasicAuth; +import me.szaki.xkcd.xkcdbrowser.network.auth.ApiKeyAuth; +import me.szaki.xkcd.xkcdbrowser.network.auth.OAuth; +import me.szaki.xkcd.xkcdbrowser.network.auth.OAuth.AccessTokenListener; +import me.szaki.xkcd.xkcdbrowser.network.auth.OAuthFlow; + + +public class ApiClient { + + private Map<String, Interceptor> apiAuthorizations; + private OkHttpClient okClient; + private Retrofit.Builder adapterBuilder; + + public ApiClient() { + apiAuthorizations = new LinkedHashMap<String, Interceptor>(); + createDefaultAdapter(); + } + + public ApiClient(String[] authNames) { + this(); + for(String authName : authNames) { + throw new RuntimeException("auth name \"" + authName + "\" not found in available auth names"); + } + } + + /** + * Basic constructor for single auth name + * @param authName + */ + public ApiClient(String authName) { + this(new String[]{authName}); + } + + /** + * Helper constructor for single api key + * @param authName + * @param apiKey + */ + public ApiClient(String authName, String apiKey) { + this(authName); + this.setApiKey(apiKey); + } + + /** + * Helper constructor for single basic auth or password oauth2 + * @param authName + * @param username + * @param password + */ + public ApiClient(String authName, String username, String password) { + this(authName); + this.setCredentials(username, password); + } + + /** + * Helper constructor for single password oauth2 + * @param authName + * @param clientId + * @param secret + * @param username + * @param password + */ + public ApiClient(String authName, String clientId, String secret, String username, String password) { + this(authName); + this.getTokenEndPoint() + .setClientId(clientId) + .setClientSecret(secret) + .setUsername(username) + .setPassword(password); + } + + public void createDefaultAdapter() { + Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ") + .create(); + + okClient = new OkHttpClient(); + + String baseUrl = "https://xkcd.com/"; + if(!baseUrl.endsWith("/")) + baseUrl = baseUrl + "/"; + + adapterBuilder = new Retrofit + .Builder() + .baseUrl(baseUrl) + .client(okClient) + + .addConverterFactory(GsonCustomConverterFactory.create(gson)); + } + + public <S> S createService(Class<S> serviceClass) { + return adapterBuilder.build().create(serviceClass); + + } + + /** + * Helper method to configure the first api key found + * @param apiKey + */ + private void setApiKey(String apiKey) { + for(Interceptor apiAuthorization : apiAuthorizations.values()) { + if (apiAuthorization instanceof ApiKeyAuth) { + ApiKeyAuth keyAuth = (ApiKeyAuth) apiAuthorization; + keyAuth.setApiKey(apiKey); + return; + } + } + } + + /** + * Helper method to configure the username/password for basic auth or password oauth + * @param username + * @param password + */ + private void setCredentials(String username, String password) { + for(Interceptor apiAuthorization : apiAuthorizations.values()) { + if (apiAuthorization instanceof HttpBasicAuth) { + HttpBasicAuth basicAuth = (HttpBasicAuth) apiAuthorization; + basicAuth.setCredentials(username, password); + return; + } + if (apiAuthorization instanceof OAuth) { + OAuth oauth = (OAuth) apiAuthorization; + oauth.getTokenRequestBuilder().setUsername(username).setPassword(password); + return; + } + } + } + + /** + * Helper method to configure the token endpoint of the first oauth found in the apiAuthorizations (there should be only one) + * @return + */ + public TokenRequestBuilder getTokenEndPoint() { + for(Interceptor apiAuthorization : apiAuthorizations.values()) { + if (apiAuthorization instanceof OAuth) { + OAuth oauth = (OAuth) apiAuthorization; + return oauth.getTokenRequestBuilder(); + } + } + return null; + } + + /** + * Helper method to configure authorization endpoint of the first oauth found in the apiAuthorizations (there should be only one) + * @return + */ + public AuthenticationRequestBuilder getAuthorizationEndPoint() { + for(Interceptor apiAuthorization : apiAuthorizations.values()) { + if (apiAuthorization instanceof OAuth) { + OAuth oauth = (OAuth) apiAuthorization; + return oauth.getAuthenticationRequestBuilder(); + } + } + return null; + } + + /** + * Helper method to pre-set the oauth access token of the first oauth found in the apiAuthorizations (there should be only one) + * @param accessToken + */ + public void setAccessToken(String accessToken) { + for(Interceptor apiAuthorization : apiAuthorizations.values()) { + if (apiAuthorization instanceof OAuth) { + OAuth oauth = (OAuth) apiAuthorization; + oauth.setAccessToken(accessToken); + return; + } + } + } + + /** + * Helper method to configure the oauth accessCode/implicit flow parameters + * @param clientId + * @param clientSecret + * @param redirectURI + */ + public void configureAuthorizationFlow(String clientId, String clientSecret, String redirectURI) { + for(Interceptor apiAuthorization : apiAuthorizations.values()) { + if (apiAuthorization instanceof OAuth) { + OAuth oauth = (OAuth) apiAuthorization; + oauth.getTokenRequestBuilder() + .setClientId(clientId) + .setClientSecret(clientSecret) + .setRedirectURI(redirectURI); + oauth.getAuthenticationRequestBuilder() + .setClientId(clientId) + .setRedirectURI(redirectURI); + return; + } + } + } + + /** + * Configures a listener which is notified when a new access token is received. + * @param accessTokenListener + */ + public void registerAccessTokenListener(AccessTokenListener accessTokenListener) { + for(Interceptor apiAuthorization : apiAuthorizations.values()) { + if (apiAuthorization instanceof OAuth) { + OAuth oauth = (OAuth) apiAuthorization; + oauth.registerAccessTokenListener(accessTokenListener); + return; + } + } + } + + /** + * Adds an authorization to be used by the client + * @param authName + * @param authorization + */ + public void addAuthorization(String authName, Interceptor authorization) { + if (apiAuthorizations.containsKey(authName)) { + throw new RuntimeException("auth name \"" + authName + "\" already in api authorizations"); + } + apiAuthorizations.put(authName, authorization); + okClient.interceptors().add(authorization); + } + + public Map<String, Interceptor> getApiAuthorizations() { + return apiAuthorizations; + } + + public void setApiAuthorizations(Map<String, Interceptor> apiAuthorizations) { + this.apiAuthorizations = apiAuthorizations; + } + + public Retrofit.Builder getAdapterBuilder() { + return adapterBuilder; + } + + public void setAdapterBuilder(Retrofit.Builder adapterBuilder) { + this.adapterBuilder = adapterBuilder; + } + + public OkHttpClient getOkClient() { + return okClient; + } + + public void addAuthsToOkClient(OkHttpClient okClient) { + for(Interceptor apiAuthorization : apiAuthorizations.values()) { + okClient.interceptors().add(apiAuthorization); + } + } + + /** + * Clones the okClient given in parameter, adds the auth interceptors and uses it to configure the Retrofit + * @param okClient + */ + public void configureFromOkclient(OkHttpClient okClient) { + OkHttpClient clone = okClient.newBuilder().build(); + addAuthsToOkClient(clone); + adapterBuilder.client(clone); + } +} + +/** + * This wrapper is to take care of this case: + * when the deserialization fails due to JsonParseException and the + * expected type is String, then just return the body string. + */ +class GsonResponseBodyConverterToString<T> implements Converter<ResponseBody, T> { + private final Gson gson; + private final Type type; + + GsonResponseBodyConverterToString(Gson gson, Type type) { + this.gson = gson; + this.type = type; + } + + @Override public T convert(ResponseBody value) throws IOException { + String returned = value.string(); + try { + return gson.fromJson(returned, type); + } + catch (JsonParseException e) { + return (T) returned; + } + } +} + +class GsonCustomConverterFactory extends Converter.Factory +{ + public static GsonCustomConverterFactory create(Gson gson) { + return new GsonCustomConverterFactory(gson); + } + + private final Gson gson; + private final GsonConverterFactory gsonConverterFactory; + + private GsonCustomConverterFactory(Gson gson) { + if (gson == null) throw new NullPointerException("gson == null"); + this.gson = gson; + this.gsonConverterFactory = GsonConverterFactory.create(gson); + } + + @Override + public Converter<ResponseBody, ?> responseBodyConverter(Type type, Annotation[] annotations, Retrofit retrofit) { + if(type.equals(String.class)) + return new GsonResponseBodyConverterToString<Object>(gson, type); + else + return gsonConverterFactory.responseBodyConverter(type, annotations, retrofit); + } + + @Override + public Converter<?, RequestBody> requestBodyConverter(Type type, Annotation[] annotations, Retrofit retrofit) { + return gsonConverterFactory.requestBodyConverter(type, annotations, retrofit); + } +} + diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/CollectionFormats.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/CollectionFormats.java new file mode 100644 index 0000000000000000000000000000000000000000..a08640283cf098cfaf246b75b9d1dfab5fb0ece2 --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/CollectionFormats.java @@ -0,0 +1,95 @@ +package me.szaki.xkcd.xkcdbrowser.network; + +import java.util.Arrays; +import java.util.List; + +public class CollectionFormats { + + public static class CSVParams { + + protected List<String> params; + + public CSVParams() { + } + + public CSVParams(List<String> params) { + this.params = params; + } + + public CSVParams(String... params) { + this.params = Arrays.asList(params); + } + + public List<String> getParams() { + return params; + } + + public void setParams(List<String> params) { + this.params = params; + } + + @Override + public String toString() { + return StringUtil.join(params.toArray(new String[0]), ","); + } + + } + + public static class SSVParams extends CSVParams { + + public SSVParams() { + } + + public SSVParams(List<String> params) { + super(params); + } + + public SSVParams(String... params) { + super(params); + } + + @Override + public String toString() { + return StringUtil.join(params.toArray(new String[0]), " "); + } + } + + public static class TSVParams extends CSVParams { + + public TSVParams() { + } + + public TSVParams(List<String> params) { + super(params); + } + + public TSVParams(String... params) { + super(params); + } + + @Override + public String toString() { + return StringUtil.join( params.toArray(new String[0]), "\t"); + } + } + + public static class PIPESParams extends CSVParams { + + public PIPESParams() { + } + + public PIPESParams(List<String> params) { + super(params); + } + + public PIPESParams(String... params) { + super(params); + } + + @Override + public String toString() { + return StringUtil.join(params.toArray(new String[0]), "|"); + } + } + +} diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/NetworkModule.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/NetworkModule.java new file mode 100644 index 0000000000000000000000000000000000000000..6773494490ebc430bd4fbc226a2a42115e41087b --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/NetworkModule.java @@ -0,0 +1,16 @@ +package me.szaki.xkcd.xkcdbrowser.network; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; +import me.szaki.xkcd.xkcdbrowser.network.api.ComicApi; + +@Module +public class NetworkModule { + @Singleton + @Provides + public ComicApi provideComicApi() { + return new ApiClient().createService(ComicApi.class); + } +} diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/StringUtil.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/StringUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..86246b0d2770be802f531b256077fc5b326a8119 --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/StringUtil.java @@ -0,0 +1,42 @@ +package me.szaki.xkcd.xkcdbrowser.network; + +@javax.annotation.Generated(value = "class io.swagger.codegen.languages.JavaClientCodegen", date = "2018-04-16T20:13:49.474+02:00") +public class StringUtil { + /** + * Check if the given array contains the given value (with case-insensitive comparison). + * + * @param array The array + * @param value The value to search + * @return true if the array contains the value + */ + public static boolean containsIgnoreCase(String[] array, String value) { + for (String str : array) { + if (value == null && str == null) return true; + if (value != null && value.equalsIgnoreCase(str)) return true; + } + return false; + } + + /** + * Join an array of strings with the given separator. + * <p> + * Note: This might be replaced by utility method from commons-lang or guava someday + * if one of those libraries is added as dependency. + * </p> + * + * @param array The array of strings + * @param separator The separator + * @return the resulting string + */ + public static String join(String[] array, String separator) { + int len = array.length; + if (len == 0) return ""; + + StringBuilder out = new StringBuilder(); + out.append(array[0]); + for (int i = 1; i < len; i++) { + out.append(separator).append(array[i]); + } + return out.toString(); + } +} diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/api/ComicApi.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/api/ComicApi.java new file mode 100644 index 0000000000000000000000000000000000000000..21e4029cc043aa84b3a80c8017fe35cb63f637cc --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/api/ComicApi.java @@ -0,0 +1,33 @@ +package me.szaki.xkcd.xkcdbrowser.network.api; + +import me.szaki.xkcd.xkcdbrowser.network.CollectionFormats.*; + + +import retrofit2.Call; +import retrofit2.http.*; + +import okhttp3.RequestBody; + +import me.szaki.xkcd.xkcdbrowser.network.model.Comic; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public interface ComicApi { + + /** + * Get a comic + * + * @param id Number of the comic + * @return Call<Comic> + */ + + @GET("{id}/info.0.json") + Call<Comic> getComic( + @Path("id") Long id + ); + + +} diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/ApiKeyAuth.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/ApiKeyAuth.java new file mode 100644 index 0000000000000000000000000000000000000000..fb51edb02460e9b7b985b7880e0098c5eea184c8 --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/ApiKeyAuth.java @@ -0,0 +1,68 @@ +package me.szaki.xkcd.xkcdbrowser.network.auth; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; + +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +public class ApiKeyAuth implements Interceptor { + private final String location; + private final String paramName; + + private String apiKey; + + public ApiKeyAuth(String location, String paramName) { + this.location = location; + this.paramName = paramName; + } + + public String getLocation() { + return location; + } + + public String getParamName() { + return paramName; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + @Override + public Response intercept(Chain chain) throws IOException { + String paramValue; + Request request = chain.request(); + + if (location == "query") { + String newQuery = request.url().uri().getQuery(); + paramValue = paramName + "=" + apiKey; + if (newQuery == null) { + newQuery = paramValue; + } else { + newQuery += "&" + paramValue; + } + + URI newUri; + try { + newUri = new URI(request.url().uri().getScheme(), request.url().uri().getAuthority(), + request.url().uri().getPath(), newQuery, request.url().uri().getFragment()); + } catch (URISyntaxException e) { + throw new IOException(e); + } + + request = request.newBuilder().url(newUri.toURL()).build(); + } else if (location == "header") { + request = request.newBuilder() + .addHeader(paramName, apiKey) + .build(); + } + return chain.proceed(request); + } +} \ No newline at end of file diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/HttpBasicAuth.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/HttpBasicAuth.java new file mode 100644 index 0000000000000000000000000000000000000000..66093aa57601943ec133091edcb3bb5f0c610174 --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/HttpBasicAuth.java @@ -0,0 +1,50 @@ +package me.szaki.xkcd.xkcdbrowser.network.auth; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.Credentials; + +public class HttpBasicAuth implements Interceptor { + + private String username; + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public void setCredentials(String username, String password) { + this.username = username; + this.password = password; + } + + @Override + public Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + + // If the request already have an authorization (eg. Basic auth), do nothing + if (request.header("Authorization") == null) { + String credentials = Credentials.basic(username, password); + request = request.newBuilder() + .addHeader("Authorization", credentials) + .build(); + } + return chain.proceed(request); + } +} diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/OAuth.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/OAuth.java new file mode 100644 index 0000000000000000000000000000000000000000..e4d4fe7a435eacf446b72874333b1ce577ae8407 --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/OAuth.java @@ -0,0 +1,161 @@ +package me.szaki.xkcd.xkcdbrowser.network.auth; + +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; + +import java.io.IOException; +import java.util.Map; + +import org.apache.oltu.oauth2.client.OAuthClient; +import org.apache.oltu.oauth2.client.request.OAuthBearerClientRequest; +import org.apache.oltu.oauth2.client.request.OAuthClientRequest; +import org.apache.oltu.oauth2.client.request.OAuthClientRequest.AuthenticationRequestBuilder; +import org.apache.oltu.oauth2.client.request.OAuthClientRequest.TokenRequestBuilder; +import org.apache.oltu.oauth2.client.response.OAuthJSONAccessTokenResponse; +import org.apache.oltu.oauth2.common.exception.OAuthProblemException; +import org.apache.oltu.oauth2.common.exception.OAuthSystemException; +import org.apache.oltu.oauth2.common.message.types.GrantType; +import org.apache.oltu.oauth2.common.token.BasicOAuthToken; + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Request.Builder; +import okhttp3.Response; + +public class OAuth implements Interceptor { + + public interface AccessTokenListener { + public void notify(BasicOAuthToken token); + } + + private volatile String accessToken; + private OAuthClient oauthClient; + + private TokenRequestBuilder tokenRequestBuilder; + private AuthenticationRequestBuilder authenticationRequestBuilder; + + private AccessTokenListener accessTokenListener; + + public OAuth( OkHttpClient client, TokenRequestBuilder requestBuilder ) { + this.oauthClient = new OAuthClient(new OAuthOkHttpClient(client)); + this.tokenRequestBuilder = requestBuilder; + } + + public OAuth(TokenRequestBuilder requestBuilder ) { + this(new OkHttpClient(), requestBuilder); + } + + public OAuth(OAuthFlow flow, String authorizationUrl, String tokenUrl, String scopes) { + this(OAuthClientRequest.tokenLocation(tokenUrl).setScope(scopes)); + setFlow(flow); + authenticationRequestBuilder = OAuthClientRequest.authorizationLocation(authorizationUrl); + } + + public void setFlow(OAuthFlow flow) { + switch(flow) { + case accessCode: + case implicit: + tokenRequestBuilder.setGrantType(GrantType.AUTHORIZATION_CODE); + break; + case password: + tokenRequestBuilder.setGrantType(GrantType.PASSWORD); + break; + case application: + tokenRequestBuilder.setGrantType(GrantType.CLIENT_CREDENTIALS); + break; + default: + break; + } + } + + @Override + public Response intercept(Chain chain) + throws IOException { + + Request request = chain.request(); + + // If the request already have an authorization (eg. Basic auth), do nothing + if (request.header("Authorization") != null) { + return chain.proceed(request); + } + + // If first time, get the token + OAuthClientRequest oAuthRequest; + if (getAccessToken() == null) { + updateAccessToken(null); + } + + // Build the request + Builder rb = request.newBuilder(); + + String requestAccessToken = new String(getAccessToken()); + try { + oAuthRequest = new OAuthBearerClientRequest(request.url().toString()) + .setAccessToken(requestAccessToken) + .buildHeaderMessage(); + } catch (OAuthSystemException e) { + throw new IOException(e); + } + + for ( Map.Entry<String, String> header : oAuthRequest.getHeaders().entrySet() ) { + rb.addHeader(header.getKey(), header.getValue()); + } + rb.url( oAuthRequest.getLocationUri()); + + //Execute the request + Response response = chain.proceed(rb.build()); + + // 401 most likely indicates that access token has expired. + // Time to refresh and resend the request + if ( response.code() == HTTP_UNAUTHORIZED ) { + updateAccessToken(requestAccessToken); + return intercept( chain ); + } + return response; + } + + public synchronized void updateAccessToken(String requestAccessToken) throws IOException { + if (getAccessToken() == null || getAccessToken().equals(requestAccessToken)) { + try { + OAuthJSONAccessTokenResponse accessTokenResponse = oauthClient.accessToken(this.tokenRequestBuilder.buildBodyMessage()); + setAccessToken(accessTokenResponse.getAccessToken()); + if (accessTokenListener != null) { + accessTokenListener.notify((BasicOAuthToken) accessTokenResponse.getOAuthToken()); + } + } catch (OAuthSystemException e) { + throw new IOException(e); + } catch (OAuthProblemException e) { + throw new IOException(e); + } + } + } + + public void registerAccessTokenListener(AccessTokenListener accessTokenListener) { + this.accessTokenListener = accessTokenListener; + } + + public synchronized String getAccessToken() { + return accessToken; + } + + public synchronized void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public TokenRequestBuilder getTokenRequestBuilder() { + return tokenRequestBuilder; + } + + public void setTokenRequestBuilder(TokenRequestBuilder tokenRequestBuilder) { + this.tokenRequestBuilder = tokenRequestBuilder; + } + + public AuthenticationRequestBuilder getAuthenticationRequestBuilder() { + return authenticationRequestBuilder; + } + + public void setAuthenticationRequestBuilder(AuthenticationRequestBuilder authenticationRequestBuilder) { + this.authenticationRequestBuilder = authenticationRequestBuilder; + } + +} diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/OAuthFlow.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/OAuthFlow.java new file mode 100644 index 0000000000000000000000000000000000000000..c0e1a6cd874cc2d0187f5d3690b1edc3331b0962 --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/OAuthFlow.java @@ -0,0 +1,5 @@ +package me.szaki.xkcd.xkcdbrowser.network.auth; + +public enum OAuthFlow { + accessCode, implicit, password, application +} \ No newline at end of file diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/OAuthOkHttpClient.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/OAuthOkHttpClient.java new file mode 100644 index 0000000000000000000000000000000000000000..690aece4f3aa719e0d6503dfe4100591632538eb --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/auth/OAuthOkHttpClient.java @@ -0,0 +1,72 @@ +package me.szaki.xkcd.xkcdbrowser.network.auth; + +import java.io.IOException; +import java.util.Map; +import java.util.Map.Entry; + +import org.apache.oltu.oauth2.client.HttpClient; +import org.apache.oltu.oauth2.client.request.OAuthClientRequest; +import org.apache.oltu.oauth2.client.response.OAuthClientResponse; +import org.apache.oltu.oauth2.client.response.OAuthClientResponseFactory; +import org.apache.oltu.oauth2.common.exception.OAuthProblemException; +import org.apache.oltu.oauth2.common.exception.OAuthSystemException; + + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Request.Builder; +import okhttp3.Response; +import okhttp3.MediaType; +import okhttp3.RequestBody; + + +public class OAuthOkHttpClient implements HttpClient { + + private OkHttpClient client; + + public OAuthOkHttpClient() { + this.client = new OkHttpClient(); + } + + public OAuthOkHttpClient(OkHttpClient client) { + this.client = client; + } + + public <T extends OAuthClientResponse> T execute(OAuthClientRequest request, Map<String, String> headers, + String requestMethod, Class<T> responseClass) + throws OAuthSystemException, OAuthProblemException { + + MediaType mediaType = MediaType.parse("application/json"); + Request.Builder requestBuilder = new Request.Builder().url(request.getLocationUri()); + + if(headers != null) { + for (Entry<String, String> entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase("Content-Type")) { + mediaType = MediaType.parse(entry.getValue()); + } else { + requestBuilder.addHeader(entry.getKey(), entry.getValue()); + } + } + } + + RequestBody body = request.getBody() != null ? RequestBody.create(mediaType, request.getBody()) : null; + requestBuilder.method(requestMethod, body); + + try { + Response response = client.newCall(requestBuilder.build()).execute(); + return OAuthClientResponseFactory.createCustomResponse( + response.body().string(), + response.body().contentType().toString(), + response.code(), + responseClass); + } catch (IOException e) { + throw new OAuthSystemException(e); + } + } + + public void shutdown() { + // Nothing to do here + } + +} diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/model/Comic.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/model/Comic.java new file mode 100644 index 0000000000000000000000000000000000000000..abe341b2261ab5c27fa4fe2569d828b2ce563e86 --- /dev/null +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/network/model/Comic.java @@ -0,0 +1,229 @@ +package me.szaki.xkcd.xkcdbrowser.network.model; + +import java.util.Objects; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +import com.google.gson.annotations.SerializedName; + + + + +@ApiModel(description = "") +public class Comic { + + @SerializedName("month") + private String month = null; + + @SerializedName("num") + private Integer num = null; + + @SerializedName("link") + private String link = null; + + @SerializedName("year") + private String year = null; + + @SerializedName("news") + private String news = null; + + @SerializedName("safe_title") + private String safeTitle = null; + + @SerializedName("transcript") + private String transcript = null; + + @SerializedName("alt") + private String alt = null; + + @SerializedName("img") + private String img = null; + + @SerializedName("title") + private String title = null; + + @SerializedName("day") + private String day = null; + + + + /** + **/ + @ApiModelProperty(value = "") + public String getMonth() { + return month; + } + public void setMonth(String month) { + this.month = month; + } + + + /** + **/ + @ApiModelProperty(value = "") + public Integer getNum() { + return num; + } + public void setNum(Integer num) { + this.num = num; + } + + + /** + **/ + @ApiModelProperty(value = "") + public String getLink() { + return link; + } + public void setLink(String link) { + this.link = link; + } + + + /** + **/ + @ApiModelProperty(value = "") + public String getYear() { + return year; + } + public void setYear(String year) { + this.year = year; + } + + + /** + **/ + @ApiModelProperty(value = "") + public String getNews() { + return news; + } + public void setNews(String news) { + this.news = news; + } + + + /** + **/ + @ApiModelProperty(value = "") + public String getSafeTitle() { + return safeTitle; + } + public void setSafeTitle(String safeTitle) { + this.safeTitle = safeTitle; + } + + + /** + **/ + @ApiModelProperty(value = "") + public String getTranscript() { + return transcript; + } + public void setTranscript(String transcript) { + this.transcript = transcript; + } + + + /** + **/ + @ApiModelProperty(value = "") + public String getAlt() { + return alt; + } + public void setAlt(String alt) { + this.alt = alt; + } + + + /** + **/ + @ApiModelProperty(value = "") + public String getImg() { + return img; + } + public void setImg(String img) { + this.img = img; + } + + + /** + **/ + @ApiModelProperty(value = "") + public String getTitle() { + return title; + } + public void setTitle(String title) { + this.title = title; + } + + + /** + **/ + @ApiModelProperty(value = "") + public String getDay() { + return day; + } + public void setDay(String day) { + this.day = day; + } + + + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Comic comic = (Comic) o; + return Objects.equals(month, comic.month) && + Objects.equals(num, comic.num) && + Objects.equals(link, comic.link) && + Objects.equals(year, comic.year) && + Objects.equals(news, comic.news) && + Objects.equals(safeTitle, comic.safeTitle) && + Objects.equals(transcript, comic.transcript) && + Objects.equals(alt, comic.alt) && + Objects.equals(img, comic.img) && + Objects.equals(title, comic.title) && + Objects.equals(day, comic.day); + } + + @Override + public int hashCode() { + return Objects.hash(month, num, link, year, news, safeTitle, transcript, alt, img, title, day); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Comic {\n"); + + sb.append(" month: ").append(toIndentedString(month)).append("\n"); + sb.append(" num: ").append(toIndentedString(num)).append("\n"); + sb.append(" link: ").append(toIndentedString(link)).append("\n"); + sb.append(" year: ").append(toIndentedString(year)).append("\n"); + sb.append(" news: ").append(toIndentedString(news)).append("\n"); + sb.append(" safeTitle: ").append(toIndentedString(safeTitle)).append("\n"); + sb.append(" transcript: ").append(toIndentedString(transcript)).append("\n"); + sb.append(" alt: ").append(toIndentedString(alt)).append("\n"); + sb.append(" img: ").append(toIndentedString(img)).append("\n"); + sb.append(" title: ").append(toIndentedString(title)).append("\n"); + sb.append(" day: ").append(toIndentedString(day)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/detail/DetailPresenter.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/detail/DetailPresenter.java index 130f5a50d4e8ca7af4561320c5b6f7d70be59a63..19377a01936a6cd73755028d31cb615f6d3c8253 100644 --- a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/detail/DetailPresenter.java +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/detail/DetailPresenter.java @@ -3,6 +3,7 @@ package me.szaki.xkcd.xkcdbrowser.ui.detail; import javax.inject.Inject; +import me.szaki.xkcd.xkcdbrowser.XKCDBrowserApplication; import me.szaki.xkcd.xkcdbrowser.interactor.comics.ComicsInteractor; import me.szaki.xkcd.xkcdbrowser.ui.Presenter; @@ -10,6 +11,10 @@ public class DetailPresenter extends Presenter<DetailScreen> { @Inject ComicsInteractor comicsInteractor; + public DetailPresenter () { + XKCDBrowserApplication.injector.inject(this); + } + @Override public void attachScreen(DetailScreen screen) { super.attachScreen(screen); diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/favorites/FavoritesPresenter.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/favorites/FavoritesPresenter.java index 3aeda6ae2d58aff3d034d945a1646e73840b6351..55ab14b9bb8cd8f10b7f8bf6a99a0c23280d9721 100644 --- a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/favorites/FavoritesPresenter.java +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/favorites/FavoritesPresenter.java @@ -3,6 +3,7 @@ package me.szaki.xkcd.xkcdbrowser.ui.favorites; import javax.inject.Inject; +import me.szaki.xkcd.xkcdbrowser.XKCDBrowserApplication; import me.szaki.xkcd.xkcdbrowser.database.ComicsDatabase; import me.szaki.xkcd.xkcdbrowser.interactor.comics.ComicsInteractor; import me.szaki.xkcd.xkcdbrowser.ui.Presenter; @@ -14,6 +15,10 @@ public class FavoritesPresenter extends Presenter<FavoritesScreen> { @Inject ComicsDatabase db; + public FavoritesPresenter () { + XKCDBrowserApplication.injector.inject(this); + } + @Override public void attachScreen(FavoritesScreen screen) { super.attachScreen(screen); diff --git a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/main/MainPresenter.java b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/main/MainPresenter.java index b18988394bd36d314ec890f7a6acb6945db08cde..60beab22d796e881e38c34f187a5566c3411ebb2 100644 --- a/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/main/MainPresenter.java +++ b/app/src/main/java/me/szaki/xkcd/xkcdbrowser/ui/main/MainPresenter.java @@ -4,6 +4,7 @@ package me.szaki.xkcd.xkcdbrowser.ui.main; import javax.inject.Inject; import me.szaki.xkcd.xkcdbrowser.database.ComicsDatabase; +import me.szaki.xkcd.xkcdbrowser.XKCDBrowserApplication; import me.szaki.xkcd.xkcdbrowser.interactor.comics.ComicsInteractor; import me.szaki.xkcd.xkcdbrowser.ui.Presenter; @@ -14,6 +15,10 @@ public class MainPresenter extends Presenter<MainScreen> { @Inject ComicsDatabase db; + public MainPresenter () { + XKCDBrowserApplication.injector.inject(this); + } + @Override public void attachScreen(MainScreen screen) { super.attachScreen(screen); diff --git a/swagger.yaml b/swagger.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d7f89e101357f65fb58c89c2d0fe2ae36f88d835 --- /dev/null +++ b/swagger.yaml @@ -0,0 +1,73 @@ +swagger: "2.0" +info: + description: "Android viewer for xkcd comics" + version: "1.0.0" + title: "XKCD Browser" + license: + name: "Creative Common" + url: "https://creativecommons.org" +host: "xkcd.com" +basePath: "/" +tags: +- name: "comic" + description: "A comic strip" + externalDocs: + description: "Find out more" + url: "https://xkcd.com/json.html" +schemes: +- "https" +paths: + /{id}/info.0.json: + get: + tags: + - "comic" + summary: "Get a comic" + description: "" + operationId: "getComic" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "path" + name: "id" + description: "Number of the comic" + required: true + type: "integer" + format: "int64" + responses: + 200: + description: "successful operation" + schema: + $ref: "#/definitions/Comic" + +definitions: + Comic: + type: "object" + properties: + month: + type: "string" + num: + type: "integer" + format: "int32" + link: + type: "string" + year: + type: "string" + news: + type: "string" + safe_title: + type: "string" + transcript: + type: "string" + alt: + type: "string" + img: + type: "string" + title: + type: "string" + day: + type: "string" +externalDocs: + description: "Find out more about Swagger" + url: "http://swagger.io" \ No newline at end of file