Finishes handshake-test-2

main
Vavassor 8 years ago
parent fd472fbe1f
commit 48c9b71f92
  1. 1
      app/build.gradle
  2. 25
      app/src/main/java/com/keylesspalace/tusky/BaseActivity.java
  3. 15
      app/src/main/java/com/keylesspalace/tusky/Log.java
  4. 245
      app/src/main/java/com/keylesspalace/tusky/LoginActivity.java
  5. 84
      app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java
  6. 23
      app/src/main/res/layout/activity_login.xml

@ -38,7 +38,6 @@ dependencies {
compile 'com.mikhaellopez:circularfillableloaders:1.2.0' compile 'com.mikhaellopez:circularfillableloaders:1.2.0'
compile 'com.squareup.retrofit2:retrofit:2.2.0' compile 'com.squareup.retrofit2:retrofit:2.2.0'
compile 'com.squareup.retrofit2:converter-gson:2.1.0' compile 'com.squareup.retrofit2:converter-gson:2.1.0'
compile 'com.squareup.okhttp3:logging-interceptor:3.6.0'
compile 'com.github.chrisbanes:PhotoView:1.3.1' compile 'com.github.chrisbanes:PhotoView:1.3.1'
compile 'com.mikepenz:google-material-typeface:3.0.1.0.original@aar' compile 'com.mikepenz:google-material-typeface:3.0.1.0.original@aar'
compile 'com.github.arimorty:floatingsearchview:2.0.3' compile 'com.github.arimorty:floatingsearchview:2.0.3'

@ -34,10 +34,7 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import okhttp3.ConnectionSpec;
import okhttp3.Dispatcher; import okhttp3.Dispatcher;
import okhttp3.Interceptor; import okhttp3.Interceptor;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
@ -121,9 +118,29 @@ public class BaseActivity extends AppCompatActivity {
.registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter())
.create(); .create();
OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder()
.addInterceptor(new Interceptor() {
@Override
public Response intercept(Chain chain) throws IOException {
Request originalRequest = chain.request();
Request.Builder builder = originalRequest.newBuilder();
String accessToken = getAccessToken();
if (accessToken != null) {
builder.header("Authorization", String.format("Bearer %s",
accessToken));
}
Request newRequest = builder.build();
return chain.proceed(newRequest);
}
})
.dispatcher(mastodonApiDispatcher)
.build();
Retrofit retrofit = new Retrofit.Builder() Retrofit retrofit = new Retrofit.Builder()
.baseUrl(getBaseUrl()) .baseUrl(getBaseUrl())
.client(OkHttpUtils.getCompatibleClient()) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(GsonConverterFactory.create(gson))
.build(); .build();

@ -18,10 +18,15 @@ package com.keylesspalace.tusky;
/**A wrapper for android.util.Log that allows for disabling logging, such as for release builds.*/ /**A wrapper for android.util.Log that allows for disabling logging, such as for release builds.*/
public class Log { public class Log {
private static final boolean LOGGING_ENABLED = BuildConfig.DEBUG; private static final boolean LOGGING_ENABLED = BuildConfig.DEBUG;
private static String longBoy;
private static String watchedTag;
public static void i(String tag, String string) { public static void i(String tag, String string) {
if (LOGGING_ENABLED) { if (LOGGING_ENABLED) {
android.util.Log.i(tag, string); android.util.Log.i(tag, string);
if (tag.equals(watchedTag)) {
longBoy += string + '\n';
}
} }
} }
@ -48,4 +53,14 @@ public class Log {
android.util.Log.w(tag, string); android.util.Log.w(tag, string);
} }
} }
static void watchTag(String tag) {
longBoy = "";
watchedTag = tag;
}
static String getWatchedMessages() {
watchedTag = null;
return longBoy;
}
} }

@ -24,6 +24,7 @@ import android.content.pm.PackageManager;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import android.support.annotation.NonNull;
import android.support.v7.app.AppCompatActivity; import android.support.v7.app.AppCompatActivity;
import android.text.method.LinkMovementMethod; import android.text.method.LinkMovementMethod;
import android.view.View; import android.view.View;
@ -58,25 +59,90 @@ public class LoginActivity extends AppCompatActivity {
@BindView(R.id.edit_text_domain) EditText editText; @BindView(R.id.edit_text_domain) EditText editText;
@BindView(R.id.button_login) Button button; @BindView(R.id.button_login) Button button;
@BindView(R.id.whats_an_instance) TextView whatsAnInstance; @BindView(R.id.whats_an_instance) TextView whatsAnInstance;
@BindView(R.id.debug_log_display) TextView debugLogDisplay;
/** @Override
* Chain together the key-value pairs into a query string, for either appending to a URL or protected void onCreate(Bundle savedInstanceState) {
* as the content of an HTTP request. super.onCreate(savedInstanceState);
*/
private static String toQueryString(Map<String, String> parameters) { if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean("lightTheme", false)) {
StringBuilder s = new StringBuilder(); setTheme(R.style.AppTheme_Light);
String between = "";
for (Map.Entry<String, String> 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");
} 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();
}
}
@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. */ /** Make sure the user-entered text is just a fully-qualified domain name. */
@NonNull
private static String validateDomain(String s) { private static String validateDomain(String s) {
// Strip any schemes out. // Strip any schemes out.
s = s.replaceFirst("http://", ""); s = s.replaceFirst("http://", "");
@ -95,25 +161,6 @@ public class LoginActivity extends AppCompatActivity {
return scheme + "://" + host + "/"; 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<String, String> 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) { private MastodonAPI getApiFor(String domain) {
Retrofit retrofit = new Retrofit.Builder() Retrofit retrofit = new Retrofit.Builder()
.baseUrl("https://" + domain) .baseUrl("https://" + domain)
@ -143,10 +190,14 @@ public class LoginActivity extends AppCompatActivity {
clientSecret = prefClientSecret; clientSecret = prefClientSecret;
redirectUserToAuthorizeAndLogin(editText); redirectUserToAuthorizeAndLogin(editText);
} else { } else {
Log.watchTag(OkHttpUtils.TAG);
Callback<AppCredentials> callback = new Callback<AppCredentials>() { Callback<AppCredentials> callback = new Callback<AppCredentials>() {
@Override @Override
public void onResponse(Call<AppCredentials> call, Response<AppCredentials> response) { public void onResponse(Call<AppCredentials> call,
Response<AppCredentials> response) {
if (!response.isSuccessful()) { if (!response.isSuccessful()) {
debugLogDisplay.setText(Log.getWatchedMessages());
editText.setError(getString(R.string.error_failed_app_registration)); editText.setError(getString(R.string.error_failed_app_registration));
Log.e(TAG, "App authentication failed. " + response.message()); Log.e(TAG, "App authentication failed. " + response.message());
return; return;
@ -158,11 +209,13 @@ public class LoginActivity extends AppCompatActivity {
editor.putString(domain + "/client_id", clientId); editor.putString(domain + "/client_id", clientId);
editor.putString(domain + "/client_secret", clientSecret); editor.putString(domain + "/client_secret", clientSecret);
editor.apply(); editor.apply();
Log.watchTag(null);
redirectUserToAuthorizeAndLogin(editText); redirectUserToAuthorizeAndLogin(editText);
} }
@Override @Override
public void onFailure(Call<AppCredentials> call, Throwable t) { public void onFailure(Call<AppCredentials> call, Throwable t) {
debugLogDisplay.setText(Log.getWatchedMessages());
editText.setError(getString(R.string.error_failed_app_registration)); editText.setError(getString(R.string.error_failed_app_registration));
t.printStackTrace(); t.printStackTrace();
} }
@ -179,96 +232,44 @@ public class LoginActivity extends AppCompatActivity {
} }
} }
@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<String, String> parameters) {
StringBuilder s = new StringBuilder();
String between = "";
for (Map.Entry<String, String> 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); private void redirectUserToAuthorizeAndLogin(EditText editText) {
ButterKnife.bind(this); /* 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. */
if (savedInstanceState != null) { String endpoint = MastodonAPI.ENDPOINT_AUTHORIZE;
domain = savedInstanceState.getString("domain"); String redirectUri = getOauthRedirectUri();
clientId = savedInstanceState.getString("clientId"); Map<String, String> parameters = new HashMap<>();
clientSecret = savedInstanceState.getString("clientSecret"); 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 { } else {
domain = null; editText.setError(getString(R.string.error_no_web_browser_found));
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();
} }
} }
@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 @Override
protected void onStop() { protected void onStop() {
super.onStop(); super.onStop();
@ -350,4 +351,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();
}
} }

@ -21,7 +21,6 @@ import android.support.annotation.NonNull;
import java.io.IOException; import java.io.IOException;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.Socket; import java.net.Socket;
import java.net.UnknownHostException;
import java.security.KeyManagementException; import java.security.KeyManagementException;
import java.security.KeyStore; import java.security.KeyStore;
import java.security.KeyStoreException; import java.security.KeyStoreException;
@ -30,6 +29,8 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import javax.net.ssl.HandshakeCompletedEvent;
import javax.net.ssl.HandshakeCompletedListener;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.SSLSocketFactory;
@ -39,11 +40,19 @@ import javax.net.ssl.X509TrustManager;
import okhttp3.ConnectionSpec; import okhttp3.ConnectionSpec;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
class OkHttpUtils { class OkHttpUtils {
private static final String TAG = "OkHttpUtils"; // logging tag 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 @NonNull
static OkHttpClient.Builder getCompatibleClientBuilder() { static OkHttpClient.Builder getCompatibleClientBuilder() {
ConnectionSpec fallback = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) ConnectionSpec fallback = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
@ -56,12 +65,8 @@ class OkHttpUtils {
specList.add(fallback); specList.add(fallback);
specList.add(ConnectionSpec.CLEARTEXT); specList.add(ConnectionSpec.CLEARTEXT);
HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
OkHttpClient.Builder builder = new OkHttpClient.Builder() OkHttpClient.Builder builder = new OkHttpClient.Builder()
.connectionSpecs(specList) .connectionSpecs(specList);
.addInterceptor(loggingInterceptor);
return enableHigherTlsOnPreLollipop(builder); return enableHigherTlsOnPreLollipop(builder);
} }
@ -72,7 +77,7 @@ class OkHttpUtils {
} }
private static OkHttpClient.Builder enableHigherTlsOnPreLollipop(OkHttpClient.Builder builder) { private static OkHttpClient.Builder enableHigherTlsOnPreLollipop(OkHttpClient.Builder builder) {
if (Build.VERSION.SDK_INT >= 16 && Build.VERSION.SDK_INT < 22) { // if (Build.VERSION.SDK_INT >= 16 && Build.VERSION.SDK_INT < 22) {
try { try {
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm()); TrustManagerFactory.getDefaultAlgorithm());
@ -89,22 +94,23 @@ class OkHttpUtils {
sslContext.init(null, new TrustManager[] { trustManager }, null); sslContext.init(null, new TrustManager[] { trustManager }, null);
SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
builder.sslSocketFactory(new Tls11And12SocketFactory(sslSocketFactory), builder.sslSocketFactory(new SSLSocketFactoryCompat(sslSocketFactory),
trustManager); trustManager);
} catch (NoSuchAlgorithmException|KeyStoreException|KeyManagementException e) { } catch (NoSuchAlgorithmException|KeyStoreException|KeyManagementException e) {
Log.e(TAG, "Failed enabling TLS 1.1 & 1.2. " + e.getMessage()); Log.e(TAG, "Failed enabling TLS 1.1 & 1.2. " + e.getMessage());
} }
} // }
return builder; return builder;
} }
private static class Tls11And12SocketFactory extends SSLSocketFactory { private static class SSLSocketFactoryCompat extends SSLSocketFactory {
private static final String[] TLS_VERSIONS = { "TLSv1.1", "TLSv1.2" }; private static final String[] DESIRED_TLS_VERSIONS = { "TLSv1", "TLSv1.1", "TLSv1.2",
"TLSv1.3" };
final SSLSocketFactory delegate; final SSLSocketFactory delegate;
Tls11And12SocketFactory(SSLSocketFactory base) { SSLSocketFactoryCompat(SSLSocketFactory base) {
this.delegate = base; this.delegate = base;
} }
@ -125,13 +131,13 @@ class OkHttpUtils {
} }
@Override @Override
public Socket createSocket(String host, int port) throws IOException, UnknownHostException { public Socket createSocket(String host, int port) throws IOException {
return patch(delegate.createSocket(host, port)); return patch(delegate.createSocket(host, port));
} }
@Override @Override
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) public Socket createSocket(String host, int port, InetAddress localHost, int localPort)
throws IOException, UnknownHostException { throws IOException {
return patch(delegate.createSocket(host, port, localHost, localPort)); return patch(delegate.createSocket(host, port, localHost, localPort));
} }
@ -146,10 +152,52 @@ class OkHttpUtils {
return patch(delegate.createSocket(address, port, localAddress, localPort)); return patch(delegate.createSocket(address, port, localAddress, localPort));
} }
@NonNull
private static String[] getMatches(String[] wanted, String[] have) {
List<String> a = new ArrayList<>(Arrays.asList(wanted));
List<String> b = Arrays.asList(have);
a.retainAll(b);
return a.toArray(new String[0]);
}
@NonNull
private static List<String> getDifferences(String[] wanted, String[] have) {
List<String> a = new ArrayList<>(Arrays.asList(wanted));
List<String> b = Arrays.asList(have);
a.removeAll(b);
return a;
}
private Socket patch(Socket socket) { private Socket patch(Socket socket) {
if (socket instanceof SSLSocket) { if (socket instanceof SSLSocket) {
SSLSocket sslSocket = (SSLSocket) socket; SSLSocket sslSocket = (SSLSocket) socket;
sslSocket.setEnabledProtocols(TLS_VERSIONS); String[] protocols = getMatches(DESIRED_TLS_VERSIONS,
sslSocket.getSupportedProtocols());
sslSocket.setEnabledProtocols(protocols);
// Add a debug listener.
String[] enabledProtocols = sslSocket.getEnabledProtocols();
List<String> disabledProtocols = getDifferences(sslSocket.getSupportedProtocols(),
enabledProtocols);
String[] enabledSuites = sslSocket.getEnabledCipherSuites();
List<String> disabledSuites = getDifferences(sslSocket.getSupportedCipherSuites(),
enabledSuites);
Log.i(TAG, "Socket Created-----");
Log.i(TAG, "enabled protocols: " + Arrays.toString(enabledProtocols));
Log.i(TAG, "disabled protocols: " + disabledProtocols.toString());
Log.i(TAG, "enabled cipher suites: " + Arrays.toString(enabledSuites));
Log.i(TAG, "disabled cipher suites: " + disabledSuites.toString());
sslSocket.addHandshakeCompletedListener(new HandshakeCompletedListener() {
@Override
public void handshakeCompleted(HandshakeCompletedEvent event) {
String host = event.getSession().getPeerHost();
String protocol = event.getSession().getProtocol();
String cipherSuite = event.getCipherSuite();
Log.i(TAG, String.format("Handshake: %s %s %s", host, protocol,
cipherSuite));
}
});
} }
return socket; return socket;
} }

@ -13,11 +13,34 @@
android:gravity="center" android:gravity="center"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="300dp">
<ScrollView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="#000000"
android:layout_marginBottom="25dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/debug_log_display"
android:textIsSelectable="true"
android:textColor="#ffffff" />
</ScrollView>
</HorizontalScrollView>
<!--
<ImageView <ImageView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="50dp" android:layout_marginBottom="50dp"
android:src="@drawable/elephant_friend"/> android:src="@drawable/elephant_friend"/>
-->
<android.support.design.widget.TextInputLayout <android.support.design.widget.TextInputLayout
android:layout_height="wrap_content" android:layout_height="wrap_content"

Loading…
Cancel
Save