/**
* Shadow - Anonymous web browser for Android devices
* Copyright (C) 2009 Connell Gauld
*
* Thanks to University of Cambridge,
* Alastair Beresford and Andrew Rice
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* version 2 as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
package uk.ac.cam.cl.dtg.android.tor.Shadow;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.http.client.ClientProtocolException;
import uk.ac.cam.cl.dtg.android.tor.TorProxyLib.ITorProxyControl;
import uk.ac.cam.cl.dtg.android.tor.TorProxyLib.TorProxyLib;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.SearchManager;
import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.ServiceConnection;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.graphics.drawable.PaintDrawable;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.preference.PreferenceManager;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.Window;
import android.view.View.OnClickListener;
import android.webkit.PluginData;
import android.webkit.UrlInterceptHandler;
import android.webkit.UrlInterceptRegistry;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.webkit.CacheManager.CacheResult;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
/**
* The main browser activity
* @author Connell Gauld
*
*/
public class Shadow extends Activity implements UrlInterceptHandler,
OnClickListener {
// TorProxy service
private ITorProxyControl mControlService = null;
private final IntentFilter torStatusFilter = new IntentFilter(
TorProxyLib.STATUS_CHANGE_INTENT);
private AnonProxy mAnonProxy = null;
// UI elements
private ShadowWebView mWebView = null;
private LinearLayout mNoTorLayout = null;
private LinearLayout mWebLayout = null;
private Button mStartTor = null;
private LinearLayout mCookieIcon = null;
private TextView mTorStatus = null;
// Misc
private Drawable mGenericFavicon = null;
private boolean mInLoad = false;
private Menu mMenu = null;
private boolean mLastIsTorActive = true;
private CookieManager mCookieManager = null;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Set up title bar of window
this.requestWindowFeature(Window.FEATURE_LEFT_ICON);
this.requestWindowFeature(Window.FEATURE_RIGHT_ICON);
this.requestWindowFeature(Window.FEATURE_PROGRESS);
this.requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
// Allow search to start by just typing
setDefaultKeyMode(DEFAULT_KEYS_SEARCH_LOCAL);
setContentView(R.layout.main);
// Register to capture all URLs in the WebView
UrlInterceptRegistry.registerHandler(this);
mCookieManager = CookieManager.getInstance(this);
mAnonProxy = new AnonProxy();
// Grab UI elements
mWebView = (ShadowWebView) findViewById(R.id.WebView);
mNoTorLayout = (LinearLayout) findViewById(R.id.NoTorLayout);
mWebLayout = (LinearLayout) findViewById(R.id.WebLayout);
mStartTor = (Button) findViewById(R.id.StartTor);
mCookieIcon = (LinearLayout) findViewById(R.id.CookieIcon);
mTorStatus = (TextView) findViewById(R.id.torStatus);
// Set up UI elements
mStartTor.setOnClickListener(this);
mWebView.setWebViewClient(mWebViewClient);
mWebView.setWebChromeClient(mWebViewChrome);
mWebView.getSettings().setBuiltInZoomControls(true);
mWebView.getSettings().setLoadsImagesAutomatically(true);
mWebView.setBlockedCookiesView(mCookieIcon);
mCookieIcon.setOnClickListener(this);
// Misc
mGenericFavicon = getResources().getDrawable(
R.drawable.app_web_browser_sm);
// TODO - properly handle initial Intents
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(this);
String starturl = prefs.getString(getString(R.string.pref_homepage),
getString(R.string.default_homepage));
mCookieManager.setBehaviour(prefs.getString("pref_cookiebehaviour",
"whitelist"));
Intent i = getIntent();
if (i != null) {
if (Intent.ACTION_VIEW.equals(i.getAction())) {
onNewIntent(i);
return;
}
}
loadUrl(starturl);
}
// Service connection to TorProxy service
private ServiceConnection mSvcConn = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mControlService = ITorProxyControl.Stub.asInterface(service);
updateTorStatus();
}
@Override
public void onServiceDisconnected(ComponentName name) {
mControlService = null;
updateTorStatus();
}
};
/*
* Set the title bar icon to the supplied bitmap. Yoinked from the Android
* browser
*/
private void setFavicon(Bitmap icon) {
Drawable[] array = new Drawable[2];
PaintDrawable p = new PaintDrawable(Color.WHITE);
p.setCornerRadius(3f);
array[0] = p;
if (icon == null) {
array[1] = mGenericFavicon;
} else {
array[1] = new BitmapDrawable(icon);
}
LayerDrawable d = new LayerDrawable(array);
d.setLayerInset(1, 2, 2, 2, 2);
getWindow().setFeatureDrawable(Window.FEATURE_LEFT_ICON, d);
}
/**
* Yoinked from android source
*
* @param url
* @return
*/
private static String buildTitleUrl(String url) {
String titleUrl = null;
if (url != null) {
try {
// parse the url string
URL urlObj = new URL(url);
if (urlObj != null) {
titleUrl = "";
String protocol = urlObj.getProtocol();
String host = urlObj.getHost();
if (host != null && 0 < host.length()) {
titleUrl = host;
if (protocol != null) {
// if a secure site, add an "https://" prefix!
if (protocol.equalsIgnoreCase("https")) {
titleUrl = protocol + "://" + host;
}
}
}
}
} catch (MalformedURLException e) {
}
}
return titleUrl;
}
static final Pattern ACCEPTED_URI_SCHEMA = Pattern.compile("(?i)"
+ // switch on case insensitive matching
"("
+ // begin group for schema
"(?:http|https|file):\\/\\/"
+ "|(?:data|about|content|javascript):" + ")" + "(.*)");
private String smartUrlFilter(String url) {
String inUrl = url.trim();
boolean hasSpace = inUrl.indexOf(' ') != -1;
Matcher matcher = ACCEPTED_URI_SCHEMA.matcher(inUrl);
if (matcher.matches()) {
if (hasSpace) {
inUrl = inUrl.replace(" ", "%20");
}
// force scheme to lowercase
String scheme = matcher.group(1);
String lcScheme = scheme.toLowerCase();
if (!lcScheme.equals(scheme)) {
return lcScheme + matcher.group(2);
}
return inUrl;
}
return "http://" + url;
}
/**
* Yoinked from android browser. Builds and returns the page title, which is
* some combination of the page URL and title.
*
* @param url
* The URL of the site being loaded.
* @param title
* The title of the site being loaded.
* @return The page title.
*/
private String buildUrlTitle(String url, String title) {
String urlTitle = "";
if (url != null) {
String titleUrl = buildTitleUrl(url);
if (title != null && 0 < title.length()) {
if (titleUrl != null && 0 < titleUrl.length()) {
urlTitle = titleUrl + ": " + title;
} else {
urlTitle = title;
}
} else {
if (titleUrl != null) {
urlTitle = titleUrl;
}
}
}
return urlTitle;
}
/*
* Intercept the HTTP requests and tunnel over AnonProxy
*
* @see android.webkit.UrlInterceptHandler#getPluginData(java.lang.String,
* java.util.Map)
*/
public PluginData getPluginData(String url, Map<String, String> headers) {
//Log.i("Shadow", "Getting: " + url);
// Intercept internal urls
String internalWebUrl = getString(R.string.internal_web_url);
if (url.startsWith(internalWebUrl)) {
return getFromAsset(url.substring(internalWebUrl.length()));
}
// Intercept HTTPS since it doesn't work
// if (url.toLowerCase().startsWith("https://"))
// return getFromAsset("sslerror.htm");
try {
return mAnonProxy.get(url, headers);
} catch (UnknownHostException e) {
return getFromAsset("unknownhosterror.htm");
} catch (ClientProtocolException e) {
// Not much we can do except output error page
// TODO: implement exception specific error page
e.printStackTrace();
return getErrorPage();
} catch (InterruptedIOException e) {
return stringToPluginData("", 200);
} catch (Exception e) {
// Not much we can do except output error page
// TODO: implement exception specific error page
e.printStackTrace();
return getErrorPage();
}
}
/**
* Fetches an asset as if it were an HTTP request.
*
* @param path
* the path of the asset to get
* @return the PluginData structure containing the asset
*/
private PluginData getFromAsset(String path) {
InputStream in;
try {
// Fetch an InputStream of the asset
in = this.getAssets().open("internal_web/" + path);
} catch (IOException e) {
return stringToPluginData("An error has occurred: " + e.toString(),
200);
}
return new PluginData(in, 0L, new HashMap<String, String[]>(), 200);
}
/**
* Returns a PluginData object filled with HTML from a string
*
* @param s
* the string containing HTML
* @param statuscode
* the HTTP status code for the object
* @return an appropriate PluginData object
*/
private PluginData stringToPluginData(String s, int statuscode) {
// Default error if can't convert provided string
byte[] err = { 68, 111, 104 }; // Doh
try {
err = s.getBytes("utf-8");
} catch (UnsupportedEncodingException e) {
// Oh dear. Not much we can do if UTF-8 isn't supported
// except go with "Doh"
e.printStackTrace();
}
ByteArrayInputStream b = new ByteArrayInputStream(err);
PluginData p = new PluginData(b, err.length,
new HashMap<String, String[]>(), statuscode);
return p;
}
/*
* Return an error page TODO make error page customised to error
*/
public PluginData getErrorPage() {
return getFromAsset("error.htm");
}
@Override
public CacheResult service(String arg0, Map<String, String> arg1) {
// Deprecated; do nothing. Isn't even called any more.
return null;
}
@Override
public boolean onOptionsItemSelected(MenuItem arg0) {
switch (arg0.getItemId()) {
case R.id.menu_go:
onSearchRequested();
return true;
case R.id.menu_forward:
mWebView.goForward();
return true;
case R.id.menu_stop_reload:
if (mInLoad) {
stopLoading();
} else {
mWebView.reload();
}
return true;
case R.id.menu_settings:
startActivity(new Intent(this, EditPreferences.class));
return true;
case R.id.menu_about:
loadUrl(getString(R.string.internal_web_url) + "about.htm");
return true;
}
return super.onOptionsItemSelected(arg0);
}
/**
* Stops the webview from loading a page
*/
private void stopLoading() {
mInLoad = false;
mWebView.stopLoading();
mAnonProxy.stop();
mWebViewClient.onPageFinished(mWebView, mWebView.getUrl());
}
@Override
public boolean onSearchRequested() {
// Open up the search/go dialog
startSearch(mWebView.getOriginalUrl(), true, null, false);
return true;
}
public boolean onCreateOptionsMenu(Menu menu) {
mMenu = menu;
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.main, menu);
return true;
}
/**
* Navigate to a web address
*
* @param url
* the address to navigate to
*/
private void loadUrl(String url) {
mAnonProxy.stop();
mWebView.loadUrl(url);
}
@Override
protected void onPause() {
// Registered in onResume so unregister here
unregisterReceiver(mBroadcastReceiver);
unbindService(mSvcConn);
super.onPause();
}
@Override
protected void onResume() {
super.onResume();
// Register to receive Tor status update broadcasts
registerReceiver(mBroadcastReceiver, torStatusFilter);
// Bind to the TorProxy control service
bindService(new Intent().setComponent(new ComponentName(
TorProxyLib.CONTROL_SERVICE_PACKAGE,
TorProxyLib.CONTROL_SERVICE_CLASS)), mSvcConn, BIND_AUTO_CREATE);
updateTorStatus();
// Update preferences
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(this);
// mWebView.getSettings().setLoadsImagesAutomatically(prefs.getBoolean(
// getString(R.string.pref_images), false));
mWebView.getSettings().setJavaScriptEnabled(
prefs.getBoolean(getString(R.string.pref_javascript), true));
mCookieManager.setBehaviour(prefs.getString("pref_cookiebehaviour",
"whitelist"));
mAnonProxy
.setSendReferrer(prefs.getBoolean("pref_sendreferrer", false));
}
private void updateTorStatus() {
int status = TorProxyLib.STATUS_UNAVAILABLE;
if (mControlService != null) {
try {
status = mControlService.getStatus();
} catch (RemoteException e) {
// Can't do much here except leave active=false
e.printStackTrace();
}
}
// Make the "Anonymous connection not available" page appear
// if Tor is not active
if (status == TorProxyLib.STATUS_ON) {
try {
mAnonProxy.setPort(mControlService.getSOCKSPort());
} catch (RemoteException e) {
// Let's hope that the port was set
e.printStackTrace();
}
mNoTorLayout.setVisibility(View.GONE);
mWebLayout.setVisibility(View.VISIBLE);
if (mLastIsTorActive == false)
mWebView.reload();
mLastIsTorActive = true;
return;
}
mWebLayout.setVisibility(View.GONE);
mNoTorLayout.setVisibility(View.VISIBLE);
mLastIsTorActive = false;
if (status == TorProxyLib.STATUS_CONNECTING) {
mTorStatus.setText(getString(R.string.torConnecting));
return;
}
if (status == TorProxyLib.STATUS_REQUIRES_DEMAND) {
try {
mControlService.registerDemand();
mTorStatus.setText(getString(R.string.torConnecting));
return;
} catch (RemoteException e) {
}
}
mTorStatus.setText(getString(R.string.torInactive));
}
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.StartTor:
// The "Open preferences" button clicked so start TorProxySettings
try {
Intent i = new Intent().setComponent(new ComponentName(
TorProxyLib.SETTINGS_ACTIVITY_PACKAGE,
TorProxyLib.SETTINGS_ACTIVITY_CLASS));
startActivity(i);
} catch (ActivityNotFoundException a) {
AlertDialog.Builder b = new AlertDialog.Builder(this);
b.setMessage(getString(R.string.torProxyNotInstalled));
b.setPositiveButton("OK", null);
b.show();
}
break;
case R.id.CookieIcon:
CookiesBlockedDialog d = new CookiesBlockedDialog(this);
d.show();
break;
}
}
/**
* Sets the title of the Activity
*
* @param url
* the string to set the title to
*/
private void updateTitle(String url, String title) {
getWindow().setTitle(buildUrlTitle(url, title));
}
/**
* Sets the webview settings given a visited URL. Internal pages should show
* images and run javascript even if the load images option is set to off.
*
* @param url
* the URL of the current page
*/
private void updateSettingsPerUrl(String url) {
if (url.startsWith(getString(R.string.internal_web_url))) {
// This built-in home page should always show images
mWebView.getSettings().setLoadsImagesAutomatically(true);
mWebView.getSettings().setJavaScriptEnabled(true);
} else {
// Just a normal page so use the user's preference
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(this);
mWebView.getSettings().setLoadsImagesAutomatically(
prefs.getBoolean(getString(R.string.pref_images), false));
mWebView.getSettings()
.setJavaScriptEnabled(
prefs.getBoolean(
getString(R.string.pref_javascript), true));
}
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
// Enable/disable the "Forward" menu item as appropriate
boolean canGoForward = mWebView.canGoForward();
menu.findItem(R.id.menu_forward).setEnabled(canGoForward);
updateInLoadMenuItems();
return super.onPrepareOptionsMenu(menu);
}
private final WebViewClient mWebViewClient = new WebViewClient() {
@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
//Log.i("Shadow", "Page started");
mCookieManager.clearBlockedCookies();
// Update image loading settings
updateSettingsPerUrl(url);
// Turn on the progress bar and set it to 10%
getWindow().setFeatureInt(Window.FEATURE_PROGRESS, 1000);
setFavicon(favicon);
updateInLoadMenuItems();
updateTitle(url, null);
super.onPageStarted(view, url, favicon);
}
@Override
public void onPageFinished(WebView view, String url) {
// Set the progress bar to 100%
getWindow().setFeatureInt(Window.FEATURE_PROGRESS, 10000);
updateInLoadMenuItems();
super.onPageFinished(view, url);
}
@Override
public void onLoadResource(WebView view, String url) {
//Log.i("Shadow", "OnLoad");
mInLoad = true;
super.onLoadResource(view, url);
}
};
@Override
protected void onNewIntent(Intent intent) {
// The user has probably entered a URL into "Go"
String action = intent.getAction();
if (Intent.ACTION_SEARCH.equals(action)) {
// Navigate to the URL
String url = intent.getStringExtra(SearchManager.QUERY);
url = smartUrlFilter(url);
loadUrl(url);
} else if (Intent.ACTION_VIEW.equals(action)) {
// Navigate to the URL
String url = intent.getDataString();
url = smartUrlFilter(url);
loadUrl(url);
}
}
/**
* Navigate back on the browser
*
* @return whether the browser navigated back
*/
private boolean onBackPressed() {
if (mWebView.canGoBack()) {
mWebView.goBack();
return true;
} else {
return false;
}
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
switch (keyCode) {
case KeyEvent.KEYCODE_BACK:
if (onBackPressed())
return true;
break;
}
return super.onKeyDown(keyCode, event);
}
/**
* Ensures that the menu items are consistent with the page loading state
* (ie stop/refresh).
*/
private void updateInLoadMenuItems() {
if (mMenu == null) {
return;
}
MenuItem src = mInLoad ? mMenu.findItem(R.id.menu_stop) : mMenu
.findItem(R.id.menu_reload);
MenuItem dest = mMenu.findItem(R.id.menu_stop_reload);
dest.setIcon(src.getIcon());
dest.setTitle(src.getTitle());
}
private WebChromeClient mWebViewChrome = new WebChromeClient() {
@Override
public void onProgressChanged(WebView view, int newProgress) {
// Update the progress bar of the activity
getWindow().setFeatureInt(Window.FEATURE_PROGRESS,
newProgress * 100);
if (newProgress == 100) {
if (mInLoad) {
mInLoad = false;
updateInLoadMenuItems();
}
}
if (mCookieManager.hasBlockedCookies()) {
mWebView.setBlockedCookies(true);
} else {
mWebView.setBlockedCookies(false);
}
super.onProgressChanged(view, newProgress);
}
@Override
public void onReceivedTitle(WebView view, String title) {
updateTitle(view.getOriginalUrl(), title);
super.onReceivedTitle(view, title);
}
};
private BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (TorProxyLib.STATUS_CHANGE_INTENT.equals(intent.getAction())) {
// TorProxy has broadcast a Tor status update
updateTorStatus();
}
}
};
}