I am working on an Android application that uses the mobile SoundCloud web auth page to log in to SoundCloud. The SoundCloud mobile web auth page provides you with three options for logging in, using SoundCloud, Facebook, or Google+. The interface looks like so:
So far I can log in using my SoundCloud and my Facebook credentials but I fail when using Google+. Here's an abridged version of what I am doing:
public class SoundCloudActivity extends Activity {
...
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.twitter_login_layout);
...
loadingProgressBar = (ProgressBar) findViewById(R.id.loading_progressbar);
WebView webView = (WebView) findViewById(R.id.login_webview);
webView.setVerticalScrollBarEnabled(true);
webView.setHorizontalScrollBarEnabled(true);
webView.setWebViewClient(new SoundcloudWebViewClient());
webView.getSettings().setJavaScriptEnabled(true);
webView.getSettings().setAllowFileAccess(true);
webView.getSettings().setPluginState(PluginState.ON);
webView.getSettings().setJavaScriptCanOpenWindowsAutomatically(true);
authUrl = Api.wrapper.authorizationCodeUrl(Endpoints.CONNECT, Token.SCOPE_NON_EXPIRING).toString();
webView.loadUrl(authUrl);
}
private class SoundcloudWebViewClient extends WebViewClient {
public boolean shouldOverrideUrlLoading(WebView view, String url) {
Log.d(TAG, "shouldOverrideUrlLoading(): url: "+url);
if (url.startsWith(REDIRECT_URI.toString())) {
Uri result = Uri.parse(url);
new Thread(new Runnable() {
@Override
public void run() {
try {
token = Api.wrapper.authorizationCode(code, Token.SCOPE_NON_EXPIRING);
} catch (IOException e) {
e.printStackTrace();
}
...
}
}).start();
return true;
} else if (url.startsWith("authorize")) {
return false;
} else if (url.startsWith("http")) {
view.loadUrl(url);
}
return true;
}
@Override
public void onReceivedError(WebView view, int errorCode,
String description, String failingUrl) {
Log.d(TAG, "Call onError with error: "+description);
super.onReceivedError(view, errorCode, description, failingUrl);
}
@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
Log.d(TAG,"onPageStarted(): url: "+url+" favicon: "+favicon);
loadingProgressBar.setVisibility(ProgressBar.VISIBLE);
super.onPageStarted(view, url, favicon);
}
@Override
public void onPageFinished(WebView view, String url) {
loadingProgressBar.setVisibility(ProgressBar.GONE);
super.onPageFinished(view, url);
}
}
}
When choosing to use Google+, it redirects me to the familiar Google login page. Then when I enter my username and password, it redirects me to a blank page and does nothing including not providing me with an auth token. This is a sample URL of the blank page that is generated after logging in:
https://accounts.google.com/o/oauth2/auth?client_id=984739005367.apps.googleusercontent.com&redirect_uri=postmessage&response_type=code%20token%20id_token%20gsession&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fplus.login%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile&state=539399140%7C0.873620491&access_type=offline&request_visible_actions=http%3A%2F%2Fschemas.google.com%2FAddActivity%20http%3A%2F%2Fschemas.google.com%2FListenActivity%20http%3A%2F%2Fschemas.google.com%2FCreateActivity&after_redirect=keep_open&cookie_policy=single_host_origin&include_granted_scopes=true&proxy=oauth2relay763648117&origin=https%3A%2F%2Fsoundcloud.com&
I'm wondering if there is a setting that I'm missing in WebView . I've already had to enable others to get other features in the SoundCloud mobile web page working. Any suggestions would be very much appreciated.
So Google+ uses cross-site javascript injection to complete the authentication process which requires that the SoundCloud login window still be open during the Google auth process. To handle this you need to force/allow the Google auth into a new webview window. I created a demo project on github that shows the whole process here.
This is the class that does the work, look at comments throughout for more details:
package com.bulwinkel.soundcloudlogin;
import android.content.Context;
import android.content.DialogInterface;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Message;
import android.support.v7.app.AlertDialog;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.CookieManager;
import android.webkit.CookieSyncManager;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
public class SoundCloudLoginActivity extends AppCompatActivity {
private static final String TAG = SoundCloudLoginActivity.class.getSimpleName();
//todo - create a project in the SoundCloud developer portal: https://soundcloud.com/you/apps/
private static final String CALLBACK_SCHEME = "soundcloudlogindemo://authentication.complete"; //todo - replace
private static final String CLIENT_ID = "e64276127b07b38ddfaf1ee458ffc2ac"; //todo - replace
private static final String STATE = SoundCloudLoginActivity.class.getCanonicalName();
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// clear the cookies to make sure the that the user is properly logged out
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
final CookieManager cookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null);
cookieManager.flush();
} else {
CookieSyncManager.createInstance(getApplicationContext()).startSync();
final CookieManager cookieManager = CookieManager.getInstance();
cookieManager.removeAllCookie();
cookieManager.removeSessionCookie();
}
// SoundCloud oauth url
final Uri authUri = new Uri.Builder().scheme("https")
.authority("soundcloud.com")
.appendPath("connect")
.appendQueryParameter("scope", "non-expiring")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("state", STATE)
.appendQueryParameter("display", "popup")
.appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("redirect_uri", CALLBACK_SCHEME)
.build();
Log.d(TAG, "https://soundcloud.com/connect?scope=non-expiring&response_type=code&state=boxset.SoundCloudLoginActivity&display=popup&client_id=6d483c5c02062da985379c36b5e7da95&redirect_uri=http%3A%2F%2Fwonder.fm%2Fincoming%2Fsoundcloud%2Fauth%2F");
Log.d(TAG, authUri.toString());
// we need a handle to this to add the second webview during google plus login
final FrameLayout container = (FrameLayout) findViewById(R.id.container);
// progress hud adds itself to the view hierarchy
final LoadingHud loadingHud = new LoadingHud(container);
final WebView webView = createWebView(this);
webView.loadUrl(authUri.toString());
final WebViewClient webViewClient = new WebViewClient() {
// need to use the depricated method if you are supporting less than api 21
@Override public boolean shouldOverrideUrlLoading(WebView view, String url) {
//GUARD - been stung by this
if (url == null) return false;
//GUARD - check if we have got our callback url yet
// this occurs when navigating to facebook and google plus login screens
if (!url.contains(CALLBACK_SCHEME)) return false;
final Uri uri = Uri.parse(url);
//GUARD
// the state query parameter is echoed back to us so we
// know that the code is coming from a legitimate source
final String state = uri.getQueryParameter("state");
if (!STATE.equals(state)) return false;
//GUARD
final String code = uri.getQueryParameter("code");
if (code == null) {
// something went wrong during the auth process
// you need to handle this
Log.d(TAG, "No code returned from auth process");
return false;
}
// you now have you code to use in the next step of the oauth process
Log.i(TAG, "code = " + code);
new AlertDialog.Builder(view.getContext())
.setTitle("Auth Successful")
.setMessage("Code: " + code)
.setPositiveButton("OK", new DialogInterface.OnClickListener() {
@Override public void onClick(DialogInterface dialogInterface, int i) {
finish();
}
})
.create()
.show();
return true;
}
@Override public void onPageStarted(WebView view, String url, Bitmap favicon) {
super.onPageStarted(view, url, favicon);
loadingHud.show();
}
@Override public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
loadingHud.hide(true);
}
};
webView.setWebViewClient(webViewClient);
// require for google login
// google login requires that the SoundCloud login window be open at the same time
// as it uses cross window/site javascript injection to pass information back to
// SoundCloud on completion
webView.setWebChromeClient(new WebChromeClient() {
@Override public boolean onCreateWindow(WebView view, boolean isDialog, boolean isUserGesture,
Message resultMsg) {
// this WebView has to has the same settings as the original for
// the cross site javascript injection to work
final WebView googleSignInWebView = createWebView(view.getContext());
googleSignInWebView.setWebChromeClient(this);
googleSignInWebView.setWebViewClient(webViewClient);
container.addView(googleSignInWebView);
// this is the glue code that wires the original webview
// and the new webview together so they can communicate
final WebView.WebViewTransport transport = (WebView.WebViewTransport) resultMsg.obj;
transport.setWebView(googleSignInWebView);
resultMsg.sendToTarget();
// this advises that we have actually created and displayed the new window
return true;
}
// since we added the window we also have to handle removing it
@Override public void onCloseWindow(WebView window) {
container.removeView(window);
}
});
container.addView(webView);
}
/**
* @param context the WebView must be given an activity context (instead of application context)
* or it will crash in versions less than 4.4
*
* @return a {@link WebView} suitable for the soundcloud login process
*/
private static WebView createWebView(Context context) {
final WebView webView = new WebView(context);
final WebSettings settings = webView.getSettings();
// this allows the username and password validation to work
settings.setJavaScriptEnabled(true);
// these 2 are for login with google support
// which needs to open a second window
settings.setJavaScriptCanOpenWindowsAutomatically(true);
settings.setSupportMultipleWindows(true);
// prevent caching of user data
settings.setSaveFormData(false);
// prevents the webview asking the user if they want to save their password
// needed for pre 18 devices
settings.setSavePassword(false);
return webView;
}
private static class LoadingHud {
private final RelativeLayout container;
public LoadingHud(ViewGroup parentView) {
container = new RelativeLayout(parentView.getContext());
container.setAlpha(0);
parentView.addView(container);
final ViewGroup.LayoutParams layoutParams = container.getLayoutParams();
layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT;
layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT;
container.setLayoutParams(layoutParams);
addMask(container);
addProgressBar(container);
}
private void addMask(RelativeLayout container) {
final View view = new View(container.getContext());
view.setBackgroundColor(Color.WHITE);
view.setAlpha(.5f);
container.addView(view);
final RelativeLayout.LayoutParams layoutParams =
(RelativeLayout.LayoutParams) view.getLayoutParams();
layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT;
layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT;
view.setLayoutParams(layoutParams);
}
private void addProgressBar(RelativeLayout container) {
final ProgressBar progressBar = new ProgressBar(container.getContext());
container.addView(progressBar);
final RelativeLayout.LayoutParams layoutParams =
(RelativeLayout.LayoutParams) progressBar.getLayoutParams();
layoutParams.addRule(RelativeLayout.CENTER_IN_PARENT);
progressBar.setLayoutParams(layoutParams);
}
void show() {
container.bringToFront();
container.animate().alpha(1f).start();
}
void hide(Boolean animated) {
Float noAlpha = 0f;
if (animated) {
container.animate().alpha(noAlpha).start();
} else {
container.setAlpha(noAlpha);
}
}
}
}