diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index bbe3dc7fa..fd50ec569 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -114,7 +114,11 @@ public class BaseActivity extends AppCompatActivity { protected void createMastodonAPI() { mastodonApiDispatcher = new Dispatcher(); - OkHttpClient okHttpClient = new OkHttpClient.Builder() + Gson gson = new GsonBuilder() + .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) + .create(); + + OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder() .addInterceptor(new Interceptor() { @Override public Response intercept(Chain chain) throws IOException { @@ -123,7 +127,8 @@ public class BaseActivity extends AppCompatActivity { Request.Builder builder = originalRequest.newBuilder(); String accessToken = getAccessToken(); if (accessToken != null) { - builder.header("Authorization", String.format("Bearer %s", accessToken)); + builder.header("Authorization", String.format("Bearer %s", + accessToken)); } Request newRequest = builder.build(); @@ -133,10 +138,6 @@ public class BaseActivity extends AppCompatActivity { .dispatcher(mastodonApiDispatcher) .build(); - Gson gson = new GsonBuilder() - .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) - .create(); - Retrofit retrofit = new Retrofit.Builder() .baseUrl(getBaseUrl()) .client(okHttpClient) @@ -149,6 +150,7 @@ public class BaseActivity extends AppCompatActivity { protected void createTuskyAPI() { Retrofit retrofit = new Retrofit.Builder() .baseUrl(getString(R.string.tusky_api_url)) + .client(OkHttpUtils.getCompatibleClient()) .build(); tuskyAPI = retrofit.create(TuskyAPI.class); diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java index 2b38b66e1..e4695dd7e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java @@ -24,6 +24,7 @@ import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.v7.app.AppCompatActivity; import android.text.method.LinkMovementMethod; import android.view.View; @@ -59,24 +60,88 @@ public class LoginActivity extends AppCompatActivity { @BindView(R.id.button_login) Button button; @BindView(R.id.whats_an_instance) TextView whatsAnInstance; - /** - * Chain together the key-value pairs into a query string, for either appending to a URL or - * as the content of an HTTP request. - */ - private static String toQueryString(Map parameters) { - StringBuilder s = new StringBuilder(); - String between = ""; - for (Map.Entry entry : parameters.entrySet()) { - s.append(between); - s.append(Uri.encode(entry.getKey())); - s.append("="); - s.append(Uri.encode(entry.getValue())); - between = "&"; + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean("lightTheme", false)) { + setTheme(R.style.AppTheme_Light); + } + + setContentView(R.layout.activity_login); + ButterKnife.bind(this); + + if (savedInstanceState != null) { + domain = savedInstanceState.getString("domain"); + clientId = savedInstanceState.getString("clientId"); + clientSecret = savedInstanceState.getString("clientSecret"); + } else { + domain = null; + clientId = null; + clientSecret = null; + } + + preferences = getSharedPreferences( + getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + + button.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onButtonClick(editText); + } + }); + + final Context context = this; + + whatsAnInstance.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + AlertDialog dialog = new AlertDialog.Builder(context) + .setMessage(R.string.dialog_whats_an_instance) + .setPositiveButton(R.string.action_close, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .show(); + TextView textView = (TextView) dialog.findViewById(android.R.id.message); + textView.setMovementMethod(LinkMovementMethod.getInstance()); + } + }); + + // Apply any updates needed. + int versionCode = 1; + try { + versionCode = getPackageManager().getPackageInfo(getPackageName(), 0).versionCode; + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "The app version was not found. " + e.getMessage()); + } + if (preferences.getInt("lastUpdateVersion", 0) != versionCode) { + SharedPreferences.Editor editor = preferences.edit(); + if (versionCode == 14) { + /* This version switches the order of scheme and host in the OAuth redirect URI. + * But to fix it requires forcing the app to re-authenticate with servers. So, clear + * out the stored client id/secret pairs. The only other things that are lost are + * "rememberedVisibility", "loggedInUsername", and "loggedInAccountId". */ + editor.clear(); + } + editor.putInt("lastUpdateVersion", versionCode); + editor.apply(); } - return s.toString(); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putString("domain", domain); + outState.putString("clientId", clientId); + outState.putString("clientSecret", clientSecret); + super.onSaveInstanceState(outState); } /** Make sure the user-entered text is just a fully-qualified domain name. */ + @NonNull private static String validateDomain(String s) { // Strip any schemes out. s = s.replaceFirst("http://", ""); @@ -95,28 +160,10 @@ public class LoginActivity extends AppCompatActivity { return scheme + "://" + host + "/"; } - private void redirectUserToAuthorizeAndLogin(EditText editText) { - /* To authorize this app and log in it's necessary to redirect to the domain given, - * activity_login there, and the server will redirect back to the app with its response. */ - String endpoint = MastodonAPI.ENDPOINT_AUTHORIZE; - String redirectUri = getOauthRedirectUri(); - Map parameters = new HashMap<>(); - parameters.put("client_id", clientId); - parameters.put("redirect_uri", redirectUri); - parameters.put("response_type", "code"); - parameters.put("scope", OAUTH_SCOPES); - String url = "https://" + domain + endpoint + "?" + toQueryString(parameters); - Intent viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - if (viewIntent.resolveActivity(getPackageManager()) != null) { - startActivity(viewIntent); - } else { - editText.setError(getString(R.string.error_no_web_browser_found)); - } - } - private MastodonAPI getApiFor(String domain) { Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://" + domain) + .client(OkHttpUtils.getCompatibleClient()) .addConverterFactory(GsonConverterFactory.create()) .build(); @@ -144,7 +191,8 @@ public class LoginActivity extends AppCompatActivity { } else { Callback callback = new Callback() { @Override - public void onResponse(Call call, Response response) { + public void onResponse(Call call, + Response response) { if (!response.isSuccessful()) { editText.setError(getString(R.string.error_failed_app_registration)); Log.e(TAG, "App authentication failed. " + response.message()); @@ -168,104 +216,54 @@ public class LoginActivity extends AppCompatActivity { }; try { - getApiFor(domain).authenticateApp(getString(R.string.app_name), getOauthRedirectUri(), OAUTH_SCOPES, - getString(R.string.app_website)).enqueue(callback); + getApiFor(domain) + .authenticateApp(getString(R.string.app_name), getOauthRedirectUri(), + OAUTH_SCOPES, getString(R.string.app_website)) + .enqueue(callback); } catch (IllegalArgumentException e) { editText.setError(getString(R.string.error_invalid_domain)); } } } - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean("lightTheme", false)) { - setTheme(R.style.AppTheme_Light); + /** + * Chain together the key-value pairs into a query string, for either appending to a URL or + * as the content of an HTTP request. + */ + @NonNull + private static String toQueryString(Map parameters) { + StringBuilder s = new StringBuilder(); + String between = ""; + for (Map.Entry entry : parameters.entrySet()) { + s.append(between); + s.append(Uri.encode(entry.getKey())); + s.append("="); + s.append(Uri.encode(entry.getValue())); + between = "&"; } + return s.toString(); + } - setContentView(R.layout.activity_login); - ButterKnife.bind(this); - - if (savedInstanceState != null) { - domain = savedInstanceState.getString("domain"); - clientId = savedInstanceState.getString("clientId"); - clientSecret = savedInstanceState.getString("clientSecret"); + private void redirectUserToAuthorizeAndLogin(EditText editText) { + /* To authorize this app and log in it's necessary to redirect to the domain given, + * activity_login there, and the server will redirect back to the app with its response. */ + String endpoint = MastodonAPI.ENDPOINT_AUTHORIZE; + String redirectUri = getOauthRedirectUri(); + Map parameters = new HashMap<>(); + parameters.put("client_id", clientId); + parameters.put("redirect_uri", redirectUri); + parameters.put("response_type", "code"); + parameters.put("scope", OAUTH_SCOPES); + String url = "https://" + domain + endpoint + "?" + toQueryString(parameters); + Intent viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + if (viewIntent.resolveActivity(getPackageManager()) != null) { + startActivity(viewIntent); } else { - domain = null; - clientId = null; - clientSecret = null; - } - - preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE); - - button.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - onButtonClick(editText); - } - }); - - final Context context = this; - - whatsAnInstance.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - AlertDialog dialog = new AlertDialog.Builder(context) - .setMessage(R.string.dialog_whats_an_instance) - .setPositiveButton(R.string.action_close, - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - dialog.dismiss(); - } - }) - .show(); - TextView textView = (TextView) dialog.findViewById(android.R.id.message); - textView.setMovementMethod(LinkMovementMethod.getInstance()); - } - }); - - // Apply any updates needed. - int versionCode = 1; - try { - versionCode = getPackageManager().getPackageInfo(getPackageName(), 0).versionCode; - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "The app version was not found. " + e.getMessage()); - } - if (preferences.getInt("lastUpdate", 0) != versionCode) { - SharedPreferences.Editor editor = preferences.edit(); - if (versionCode == 14) { - /* This version switches the order of scheme and host in the OAuth redirect URI. - * But to fix it requires forcing the app to re-authenticate with servers. So, clear - * out the stored client id/secret pairs. The only other things that are lost are - * "rememberedVisibility", "loggedInUsername", and "loggedInAccountId". */ - editor.clear(); - } - editor.putInt("lastUpdate", versionCode); - editor.apply(); + editText.setError(getString(R.string.error_no_web_browser_found)); } } - @Override - protected void onSaveInstanceState(Bundle outState) { - outState.putString("domain", domain); - outState.putString("clientId", clientId); - outState.putString("clientSecret", clientSecret); - super.onSaveInstanceState(outState); - } - - private void onLoginSuccess(String accessToken) { - SharedPreferences.Editor editor = preferences.edit(); - editor.putString("domain", domain); - editor.putString("accessToken", accessToken); - editor.commit(); - Intent intent = new Intent(this, MainActivity.class); - startActivity(intent); - finish(); - } - @Override protected void onStop() { super.onStop(); @@ -347,4 +345,14 @@ public class LoginActivity extends AppCompatActivity { } } } + + private void onLoginSuccess(String accessToken) { + SharedPreferences.Editor editor = preferences.edit(); + editor.putString("domain", domain); + editor.putString("accessToken", accessToken); + editor.commit(); + Intent intent = new Intent(this, MainActivity.class); + startActivity(intent); + finish(); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java b/app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java index 20d10af2b..9e139187f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java +++ b/app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java @@ -40,6 +40,7 @@ public class MyFirebaseInstanceIdService extends FirebaseInstanceIdService { protected void createTuskyAPI() { Retrofit retrofit = new Retrofit.Builder() .baseUrl(getString(R.string.tusky_api_url)) + .client(OkHttpUtils.getCompatibleClient()) .build(); tuskyAPI = retrofit.create(TuskyAPI.class); diff --git a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java b/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java index d7d816f2a..d278d611d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java +++ b/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java @@ -104,7 +104,7 @@ public class MyFirebaseMessagingService extends FirebaseMessagingService { final String domain = preferences.getString("domain", null); final String accessToken = preferences.getString("accessToken", null); - OkHttpClient okHttpClient = new OkHttpClient.Builder() + OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder() .addInterceptor(new Interceptor() { @Override public okhttp3.Response intercept(Chain chain) throws IOException { diff --git a/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java b/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java new file mode 100644 index 000000000..58dfb447a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java @@ -0,0 +1,171 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is part of Tusky. + * + * Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU + * Lesser General Public License as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * Tusky 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 Lesser + * General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License along with Tusky. If + * not, see . */ + +package com.keylesspalace.tusky; + +import android.os.Build; +import android.support.annotation.NonNull; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import okhttp3.ConnectionSpec; +import okhttp3.OkHttpClient; + +class OkHttpUtils { + static final String TAG = "OkHttpUtils"; // logging tag + + /** + * Makes a Builder with the maximum range of TLS versions and cipher suites enabled. + * + * It first tries the "approved" list of cipher suites given in OkHttp (the default in + * ConnectionSpec.MODERN_TLS) and if that doesn't work falls back to the set of ALL enabled, + * then falls back to plain http. + * + * TLS 1.1 and 1.2 have to be manually enabled on API levels 16-20. + */ + @NonNull + static OkHttpClient.Builder getCompatibleClientBuilder() { + ConnectionSpec fallback = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .allEnabledCipherSuites() + .supportsTlsExtensions(true) + .build(); + + List specList = new ArrayList<>(); + specList.add(ConnectionSpec.MODERN_TLS); + specList.add(fallback); + specList.add(ConnectionSpec.CLEARTEXT); + + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .connectionSpecs(specList); + + return enableHigherTlsOnPreLollipop(builder); + } + + @NonNull + static OkHttpClient getCompatibleClient() { + return getCompatibleClientBuilder().build(); + } + + private static OkHttpClient.Builder enableHigherTlsOnPreLollipop(OkHttpClient.Builder builder) { + if (Build.VERSION.SDK_INT >= 16 && Build.VERSION.SDK_INT < 22) { + try { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); + if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { + throw new IllegalStateException("Unexpected default trust managers:" + + Arrays.toString(trustManagers)); + } + + X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[] { trustManager }, null); + SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + + builder.sslSocketFactory(new SSLSocketFactoryCompat(sslSocketFactory), + trustManager); + } catch (NoSuchAlgorithmException|KeyStoreException|KeyManagementException e) { + Log.e(TAG, "Failed enabling TLS 1.1 & 1.2. " + e.getMessage()); + } + } + + return builder; + } + + private static class SSLSocketFactoryCompat extends SSLSocketFactory { + private static final String[] DESIRED_TLS_VERSIONS = { "TLSv1", "TLSv1.1", "TLSv1.2", + "TLSv1.3" }; + + final SSLSocketFactory delegate; + + SSLSocketFactoryCompat(SSLSocketFactory base) { + this.delegate = base; + } + + @Override + public String[] getDefaultCipherSuites() { + return delegate.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket(Socket s, String host, int port, boolean autoClose) + throws IOException { + return patch(delegate.createSocket(s, host, port, autoClose)); + } + + @Override + public Socket createSocket(String host, int port) throws IOException { + return patch(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) + throws IOException { + return patch(delegate.createSocket(host, port, localHost, localPort)); + } + + @Override + public Socket createSocket(InetAddress host, int port) throws IOException { + return patch(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, + int localPort) throws IOException { + return patch(delegate.createSocket(address, port, localAddress, localPort)); + } + + @NonNull + private static String[] getMatches(String[] wanted, String[] have) { + List a = new ArrayList<>(Arrays.asList(wanted)); + List b = Arrays.asList(have); + a.retainAll(b); + return a.toArray(new String[0]); + } + + private Socket patch(Socket socket) { + if (socket instanceof SSLSocket) { + SSLSocket sslSocket = (SSLSocket) socket; + String[] protocols = getMatches(DESIRED_TLS_VERSIONS, + sslSocket.getSupportedProtocols()); + sslSocket.setEnabledProtocols(protocols); + } + return socket; + } + } +}