From 5050605f723520f39782190c2cf2b202384b63ef Mon Sep 17 00:00:00 2001
From: Hans-Christoph Steiner <hans@eds.org>
Date: Fri, 2 May 2014 20:26:31 -0400
Subject: [PATCH] Activity/Service for running a local repo via http://

This is a skeleton for the upcoming local repo (aka swap aka Kerplapp).
Right now, it just provides an Activity for controlling a Service which
manages a local webserver (nanohttpd).  Next, it will be wired up to the
local repo created via a dedicated Activity for managing the list of apps
included in the local repo.

refs #3204 https://dev.guardianproject.info/issues/3204
---
 AndroidManifest.xml                           |  14 +
 res/layout/local_repo_activity.xml            |  63 ++++
 res/menu/local_repo_activity.xml              |  10 +
 res/values/strings.xml                        |   9 +
 src/org/fdroid/fdroid/FDroid.java             |  16 +-
 src/org/fdroid/fdroid/FDroidApp.java          |  43 +++
 .../fdroid/localrepo/LocalRepoService.java    | 146 ++++++++
 src/org/fdroid/fdroid/net/LocalHTTPD.java     | 341 ++++++++++++++++++
 .../fdroid/views/LocalRepoActivity.java       | 209 +++++++++++
 9 files changed, 845 insertions(+), 6 deletions(-)
 create mode 100644 res/layout/local_repo_activity.xml
 create mode 100644 res/menu/local_repo_activity.xml
 create mode 100644 src/org/fdroid/fdroid/localrepo/LocalRepoService.java
 create mode 100644 src/org/fdroid/fdroid/net/LocalHTTPD.java
 create mode 100644 src/org/fdroid/fdroid/views/LocalRepoActivity.java

diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index e4d28bff9..834132306 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -182,6 +182,19 @@
         <activity
             android:name=".NfcNotEnabledActivity"
             android:noHistory="true" />
+        <activity
+            android:name=".views.LocalRepoActivity"
+            android:configChanges="orientation|keyboardHidden|screenSize"
+            android:label="@string/local_repo"
+            android:launchMode="singleTop"
+            android:parentActivityName=".FDroid"
+            android:screenOrientation="portrait" >
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
         <activity
             android:name=".views.RepoDetailsActivity"
             android:label="@string/menu_manage"
@@ -312,6 +325,7 @@
 
         <service android:name=".UpdateService" />
         <service android:name=".net.WifiStateChangeService" />
+        <service android:name=".localrepo.LocalRepoService" />
     </application>
 
 </manifest>
diff --git a/res/layout/local_repo_activity.xml b/res/layout/local_repo_activity.xml
new file mode 100644
index 000000000..81ba324ca
--- /dev/null
+++ b/res/layout/local_repo_activity.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent"
+    android:orientation="vertical" >
+
+    <ToggleButton
+        android:id="@+id/repoSwitch"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content" />
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content" >
+
+        <TextView
+            android:id="@+id/wifiNetwork"
+            android:layout_width="wrap_content"
+            android:layout_height="match_parent"
+            android:text="@string/wifi_network" />
+
+        <TextView
+            android:id="@+id/wifiNetworkName"
+            android:layout_width="wrap_content"
+            android:layout_height="match_parent"
+            android:layout_marginLeft="15dp"
+            android:textAppearance="?android:attr/textAppearanceMedium"
+            android:textStyle="bold"
+            android:typeface="monospace" />
+    </LinearLayout>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content" >
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="match_parent"
+            android:text="@string/fingerprint" />
+
+        <TextView
+            android:id="@+id/fingerprint"
+            android:layout_width="wrap_content"
+            android:layout_height="match_parent"
+            android:layout_marginLeft="15dp"
+            android:typeface="monospace" />
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/instrucionsTextView"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginLeft="20dp"
+        android:layout_marginRight="20dp"
+        android:text="@string/same_wifi_instructions" />
+
+    <ImageView
+        android:id="@+id/repoQrCode"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:contentDescription="@string/qr_content_description" />
+
+</LinearLayout>
\ No newline at end of file
diff --git a/res/menu/local_repo_activity.xml b/res/menu/local_repo_activity.xml
new file mode 100644
index 000000000..823701dac
--- /dev/null
+++ b/res/menu/local_repo_activity.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android" >
+
+    <item
+        android:id="@+id/menu_settings"
+        android:icon="@android:drawable/ic_menu_preferences"
+        android:showAsAction="never"
+        android:title="@string/menu_preferences"/>
+
+</menu>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index e8e837990..dd2a23dfe 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -150,8 +150,17 @@
 	<string name="category_whatsnew">What\'s New</string>
 	<string name="category_recentlyupdated">Recently Updated</string>
 
+	<string name="local_repo">Local Repo</string>
 	<string name="local_repos_title">Local FDroid Repos</string>
 	<string name="local_repos_scanning">Discovering local FDroid repos&#8230;</string>
+	<string name="local_repo_running">Your local FDroid repo is accessible.</string>
+	<string name="touch_to_configure_local_repo">Touch to setup your local repo.</string>
+	<string name="fingerprint">Fingerprint:</string>
+	<string name="wifi_network">WiFi Network:</string>
+	<string name="enable_wifi">Enable WiFi</string>
+	<string name="enabling_wifi">Enabling WiFi&#8230;</string>
+	<string name="same_wifi_instructions">To connect to other people\'s devices, make sure both devices are on the same WiFi network.  Then either type the URL above into F-Droid, or scan this QR Code:</string>
+	<string name="qr_content_description">QR Code of repo URL</string>
 
 	<!--
 	status_download takes four parameters:
diff --git a/src/org/fdroid/fdroid/FDroid.java b/src/org/fdroid/fdroid/FDroid.java
index 48b87e76d..a0a622c5c 100644
--- a/src/org/fdroid/fdroid/FDroid.java
+++ b/src/org/fdroid/fdroid/FDroid.java
@@ -19,20 +19,17 @@
 
 package org.fdroid.fdroid;
 
-import android.annotation.TargetApi;
 import android.app.AlertDialog;
 import android.app.AlertDialog.Builder;
 import android.app.NotificationManager;
 import android.bluetooth.BluetoothAdapter;
-import android.content.*;
-import android.content.pm.ApplicationInfo;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
 import android.content.pm.PackageInfo;
-import android.content.pm.PackageManager;
-import android.content.pm.PackageManager.NameNotFoundException;
 import android.content.res.Configuration;
 import android.database.ContentObserver;
 import android.net.Uri;
-import android.nfc.NfcAdapter;
 import android.os.Build;
 import android.os.Bundle;
 import android.support.v4.app.FragmentActivity;
@@ -49,6 +46,7 @@ import android.widget.TextView;
 import org.fdroid.fdroid.compat.TabManager;
 import org.fdroid.fdroid.data.AppProvider;
 import org.fdroid.fdroid.views.AppListFragmentPageAdapter;
+import org.fdroid.fdroid.views.LocalRepoActivity;
 
 public class FDroid extends FragmentActivity {
 
@@ -65,6 +63,7 @@ public class FDroid extends FragmentActivity {
     private static final int ABOUT = Menu.FIRST + 3;
     private static final int SEARCH = Menu.FIRST + 4;
     private static final int BLUETOOTH_APK = Menu.FIRST + 5;
+    private static final int LOCAL_REPO = Menu.FIRST + 6;
 
     private FDroidApp fdroidApp = null;
 
@@ -135,6 +134,7 @@ public class FDroid extends FragmentActivity {
                 android.R.drawable.ic_menu_search);
         if (fdroidApp.bluetoothAdapter != null) // ignore on devices without Bluetooth
             menu.add(Menu.NONE, BLUETOOTH_APK, 3, R.string.menu_send_apk_bt);
+        menu.add(Menu.NONE, LOCAL_REPO, 4, R.string.local_repo);
         menu.add(Menu.NONE, PREFERENCES, 4, R.string.menu_preferences).setIcon(
                 android.R.drawable.ic_menu_preferences);
         menu.add(Menu.NONE, ABOUT, 5, R.string.menu_about).setIcon(
@@ -162,6 +162,10 @@ public class FDroid extends FragmentActivity {
             startActivityForResult(prefs, REQUEST_PREFS);
             return true;
 
+        case LOCAL_REPO:
+            startActivity(new Intent(this, LocalRepoActivity.class));
+            return true;
+
         case SEARCH:
             onSearchRequested();
             return true;
diff --git a/src/org/fdroid/fdroid/FDroidApp.java b/src/org/fdroid/fdroid/FDroidApp.java
index 4c01e72cb..10c51528f 100644
--- a/src/org/fdroid/fdroid/FDroidApp.java
+++ b/src/org/fdroid/fdroid/FDroidApp.java
@@ -26,6 +26,7 @@ import android.bluetooth.BluetoothManager;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
+import android.content.ServiceConnection;
 import android.content.SharedPreferences;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
@@ -36,6 +37,7 @@ import android.net.wifi.WifiManager;
 import android.os.Build;
 import android.os.IBinder;
 import android.os.Message;
+import android.os.Messenger;
 import android.os.RemoteException;
 import android.preference.PreferenceManager;
 import android.util.Log;
@@ -53,6 +55,7 @@ import org.fdroid.fdroid.compat.PRNGFixes;
 import org.fdroid.fdroid.data.AppProvider;
 import org.fdroid.fdroid.data.InstalledAppCacheUpdater;
 import org.fdroid.fdroid.data.Repo;
+import org.fdroid.fdroid.localrepo.LocalRepoService;
 import org.fdroid.fdroid.net.WifiStateChangeService;
 import org.thoughtcrime.ssl.pinning.PinningTrustManager;
 import org.thoughtcrime.ssl.pinning.SystemKeyStore;
@@ -82,6 +85,9 @@ public class FDroidApp extends Application {
     public static Repo repo = new Repo();
     static Set<String> selectedApps = new HashSet<String>();
 
+    private static Messenger localRepoServiceMessenger = null;
+    private static boolean localRepoServiceIsBound = false;
+
     BluetoothAdapter bluetoothAdapter = null;
 
     private static enum Theme {
@@ -279,4 +285,41 @@ public class FDroidApp extends Application {
             activity.startActivity(sendBt);
         }
     }
+
+    private static ServiceConnection serviceConnection = new ServiceConnection() {
+        @Override
+        public void onServiceConnected(ComponentName className, IBinder service) {
+            localRepoServiceMessenger = new Messenger(service);
+        }
+
+        @Override
+        public void onServiceDisconnected(ComponentName className) {
+            localRepoServiceMessenger = null;
+        }
+    };
+
+    public static void startLocalRepoService(Context context) {
+        context.bindService(new Intent(context, LocalRepoService.class),
+                serviceConnection, Context.BIND_AUTO_CREATE);
+        localRepoServiceIsBound = true;
+    }
+
+    public static void stopLocalRepoService(Context context) {
+        if (localRepoServiceIsBound) {
+            context.unbindService(serviceConnection);
+            localRepoServiceIsBound = false;
+        }
+    }
+
+    public static void restartLocalRepoService() {
+        if (localRepoServiceMessenger != null) {
+            try {
+                Message msg = Message.obtain(null,
+                        LocalRepoService.RESTART, LocalRepoService.RESTART, 0);
+                localRepoServiceMessenger.send(msg);
+            } catch (RemoteException e) {
+                e.printStackTrace();
+            }
+        }
+    }
 }
diff --git a/src/org/fdroid/fdroid/localrepo/LocalRepoService.java b/src/org/fdroid/fdroid/localrepo/LocalRepoService.java
new file mode 100644
index 000000000..3a92b4cc4
--- /dev/null
+++ b/src/org/fdroid/fdroid/localrepo/LocalRepoService.java
@@ -0,0 +1,146 @@
+
+package org.fdroid.fdroid.localrepo;
+
+import android.annotation.SuppressLint;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Messenger;
+import android.preference.PreferenceManager;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.Log;
+
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.net.LocalHTTPD;
+import org.fdroid.fdroid.net.WifiStateChangeService;
+import org.fdroid.fdroid.views.LocalRepoActivity;
+
+import java.io.IOException;
+
+public class LocalRepoService extends Service {
+    private static final String TAG = "LocalRepoService";
+
+    private NotificationManager notificationManager;
+    // Unique Identification Number for the Notification.
+    // We use it on Notification start, and to cancel it.
+    private int NOTIFICATION = R.string.local_repo_running;
+
+    private Handler webServerThreadHandler = null;
+
+    public static int START = 1111111;
+    public static int STOP = 12345678;
+    public static int RESTART = 87654;
+
+    final Messenger messenger = new Messenger(new Handler() {
+        @Override
+        public void handleMessage(Message msg) {
+            if (msg.arg1 == START) {
+                startWebServer();
+            } else if (msg.arg1 == STOP) {
+                stopWebServer();
+            } else if (msg.arg1 == RESTART) {
+                stopWebServer();
+                startWebServer();
+            } else {
+                Log.e(TAG, "unsupported msg.arg1, ignored");
+            }
+        }
+    });
+
+    private BroadcastReceiver onWifiChange = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent i) {
+            stopWebServer();
+            startWebServer();
+        }
+    };
+
+    @Override
+    public void onCreate() {
+        notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
+        // launch LocalRepoActivity if the user selects this notification
+        Intent intent = new Intent(this, LocalRepoActivity.class);
+        intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+        PendingIntent contentIntent = PendingIntent.getActivity(this, 0, intent, 0);
+        Notification notification = new NotificationCompat.Builder(this)
+                .setContentTitle(getText(R.string.local_repo_running))
+                .setContentText(getText(R.string.touch_to_configure_local_repo))
+                .setSmallIcon(android.R.drawable.ic_dialog_info)
+                .setContentIntent(contentIntent)
+                .build();
+        startForeground(NOTIFICATION, notification);
+        startWebServer();
+        LocalBroadcastManager.getInstance(this).registerReceiver(onWifiChange,
+                new IntentFilter(WifiStateChangeService.BROADCAST));
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        // We want this service to continue running until it is explicitly
+        // stopped, so return sticky.
+        return START_STICKY;
+    }
+
+    @Override
+    public void onDestroy() {
+        stopWebServer();
+        notificationManager.cancel(NOTIFICATION);
+        LocalBroadcastManager.getInstance(this).unregisterReceiver(onWifiChange);
+    }
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return messenger.getBinder();
+    }
+
+    private void startWebServer() {
+        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
+
+        Runnable webServer = new Runnable() {
+            // Tell Eclipse this is not a leak because of Looper use.
+            @SuppressLint("HandlerLeak")
+            @Override
+            public void run() {
+                final LocalHTTPD localHttpd = new LocalHTTPD(getFilesDir(),
+                        prefs.getBoolean("use_https", false));
+
+                Looper.prepare(); // must be run before creating a Handler
+                webServerThreadHandler = new Handler() {
+                    @Override
+                    public void handleMessage(Message msg) {
+                        Log.i(TAG, "we've been asked to stop the webserver: " + msg.obj);
+                        localHttpd.stop();
+                    }
+                };
+                try {
+                    localHttpd.start();
+                } catch (IOException e) {
+                    e.printStackTrace();
+                }
+                Looper.loop(); // start the message receiving loop
+            }
+        };
+        new Thread(webServer).start();
+    }
+
+    private void stopWebServer() {
+        if (webServerThreadHandler == null) {
+            Log.i(TAG, "null handler in stopWebServer");
+            return;
+        }
+        Message msg = webServerThreadHandler.obtainMessage();
+        msg.obj = webServerThreadHandler.getLooper().getThread().getName() + " says stop";
+        webServerThreadHandler.sendMessage(msg);
+    }
+}
diff --git a/src/org/fdroid/fdroid/net/LocalHTTPD.java b/src/org/fdroid/fdroid/net/LocalHTTPD.java
new file mode 100644
index 000000000..499da4967
--- /dev/null
+++ b/src/org/fdroid/fdroid/net/LocalHTTPD.java
@@ -0,0 +1,341 @@
+
+package org.fdroid.fdroid.net;
+
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import fi.iki.elonen.NanoHTTPD;
+
+import org.fdroid.fdroid.FDroidApp;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.StringTokenizer;
+
+import javax.net.ssl.SSLServerSocketFactory;
+
+public class LocalHTTPD extends NanoHTTPD {
+    private static final String TAG = LocalHTTPD.class.getCanonicalName();
+
+    private final File webRoot;
+    private final boolean logRequests;
+
+    public LocalHTTPD(File webRoot, boolean useHttps) {
+        super(FDroidApp.ipAddressString, FDroidApp.port);
+        this.logRequests = false;
+        this.webRoot = webRoot;
+        if (useHttps)
+            enableHTTPS();
+    }
+
+    /**
+     * URL-encodes everything between "/"-characters. Encodes spaces as '%20'
+     * instead of '+'.
+     */
+    private String encodeUriBetweenSlashes(String uri) {
+        String newUri = "";
+        StringTokenizer st = new StringTokenizer(uri, "/ ", true);
+        while (st.hasMoreTokens()) {
+            String tok = st.nextToken();
+            if (tok.equals("/"))
+                newUri += "/";
+            else if (tok.equals(" "))
+                newUri += "%20";
+            else {
+                try {
+                    newUri += URLEncoder.encode(tok, "UTF-8");
+                } catch (UnsupportedEncodingException ignored) {
+                }
+            }
+        }
+        return newUri;
+    }
+
+    @Override
+    public Response serve(IHTTPSession session) {
+        Map<String, String> header = session.getHeaders();
+        Map<String, String> parms = session.getParms();
+        String uri = session.getUri();
+
+        if (logRequests) {
+            Log.i(TAG, session.getMethod() + " '" + uri + "' ");
+
+            Iterator<String> e = header.keySet().iterator();
+            while (e.hasNext()) {
+                String value = e.next();
+                Log.i(TAG, "  HDR: '" + value + "' = '" + header.get(value) + "'");
+            }
+            e = parms.keySet().iterator();
+            while (e.hasNext()) {
+                String value = e.next();
+                Log.i(TAG, "  PRM: '" + value + "' = '" + parms.get(value) + "'");
+            }
+        }
+
+        if (!webRoot.isDirectory()) {
+            return createResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT,
+                    "INTERNAL ERRROR: given path is not a directory (" + webRoot + ").");
+        }
+
+        return respond(Collections.unmodifiableMap(header), uri);
+    }
+
+    private void enableHTTPS() {
+        // TODO copy implementation from Kerplapp
+    }
+
+    private Response respond(Map<String, String> headers, String uri) {
+        // Remove URL arguments
+        uri = uri.trim().replace(File.separatorChar, '/');
+        if (uri.indexOf('?') >= 0) {
+            uri = uri.substring(0, uri.indexOf('?'));
+        }
+
+        // Prohibit getting out of current directory
+        if (uri.contains("../")) {
+            return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT,
+                    "FORBIDDEN: Won't serve ../ for security reasons.");
+        }
+
+        File f = new File(webRoot, uri);
+        if (!f.exists()) {
+            return createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
+                    "Error 404, file not found.");
+        }
+
+        // Browsers get confused without '/' after the directory, send a
+        // redirect.
+        if (f.isDirectory() && !uri.endsWith("/")) {
+            uri += "/";
+            Response res = createResponse(Response.Status.REDIRECT, NanoHTTPD.MIME_HTML,
+                    "<html><body>Redirected: <a href=\"" +
+                            uri + "\">" + uri + "</a></body></html>");
+            res.addHeader("Location", uri);
+            return res;
+        }
+
+        if (f.isDirectory()) {
+            // First look for index files (index.html, index.htm, etc) and if
+            // none found, list the directory if readable.
+            String indexFile = findIndexFileInDirectory(f);
+            if (indexFile == null) {
+                if (f.canRead()) {
+                    // No index file, list the directory if it is readable
+                    return createResponse(Response.Status.OK, NanoHTTPD.MIME_HTML,
+                            listDirectory(uri, f));
+                } else {
+                    return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT,
+                            "FORBIDDEN: No directory listing.");
+                }
+            } else {
+                return respond(headers, uri + indexFile);
+            }
+        }
+
+        Response response = null;
+        response = serveFile(uri, headers, f, getMimeTypeForFile(uri));
+        return response != null ? response :
+                createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
+                        "Error 404, file not found.");
+    }
+
+    /**
+     * Serves file from homeDir and its' subdirectories (only). Uses only URI,
+     * ignores all headers and HTTP parameters.
+     */
+    Response serveFile(String uri, Map<String, String> header, File file, String mime) {
+        Response res;
+        try {
+            // Calculate etag
+            String etag = Integer
+                    .toHexString((file.getAbsolutePath() + file.lastModified() + "" + file.length())
+                            .hashCode());
+
+            // Support (simple) skipping:
+            long startFrom = 0;
+            long endAt = -1;
+            String range = header.get("range");
+            if (range != null) {
+                if (range.startsWith("bytes=")) {
+                    range = range.substring("bytes=".length());
+                    int minus = range.indexOf('-');
+                    try {
+                        if (minus > 0) {
+                            startFrom = Long.parseLong(range.substring(0, minus));
+                            endAt = Long.parseLong(range.substring(minus + 1));
+                        }
+                    } catch (NumberFormatException ignored) {
+                    }
+                }
+            }
+
+            // Change return code and add Content-Range header when skipping is
+            // requested
+            long fileLen = file.length();
+            if (range != null && startFrom >= 0) {
+                if (startFrom >= fileLen) {
+                    res = createResponse(Response.Status.RANGE_NOT_SATISFIABLE,
+                            NanoHTTPD.MIME_PLAINTEXT, "");
+                    res.addHeader("Content-Range", "bytes 0-0/" + fileLen);
+                    res.addHeader("ETag", etag);
+                } else {
+                    if (endAt < 0) {
+                        endAt = fileLen - 1;
+                    }
+                    long newLen = endAt - startFrom + 1;
+                    if (newLen < 0) {
+                        newLen = 0;
+                    }
+
+                    final long dataLen = newLen;
+                    FileInputStream fis = new FileInputStream(file) {
+                        @Override
+                        public int available() throws IOException {
+                            return (int) dataLen;
+                        }
+                    };
+                    fis.skip(startFrom);
+
+                    res = createResponse(Response.Status.PARTIAL_CONTENT, mime, fis);
+                    res.addHeader("Content-Length", "" + dataLen);
+                    res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/"
+                            + fileLen);
+                    res.addHeader("ETag", etag);
+                }
+            } else {
+                if (etag.equals(header.get("if-none-match")))
+                    res = createResponse(Response.Status.NOT_MODIFIED, mime, "");
+                else {
+                    res = createResponse(Response.Status.OK, mime, new FileInputStream(file));
+                    res.addHeader("Content-Length", "" + fileLen);
+                    res.addHeader("ETag", etag);
+                }
+            }
+        } catch (IOException ioe) {
+            res = createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT,
+                    "FORBIDDEN: Reading file failed.");
+        }
+
+        return res;
+    }
+
+    // Announce that the file server accepts partial content requests
+    private Response createResponse(Response.Status status, String mimeType, InputStream message) {
+        Response res = new Response(status, mimeType, message);
+        res.addHeader("Accept-Ranges", "bytes");
+        return res;
+    }
+
+    // Announce that the file server accepts partial content requests
+    private Response createResponse(Response.Status status, String mimeType, String message) {
+        Response res = new Response(status, mimeType, message);
+        res.addHeader("Accept-Ranges", "bytes");
+        return res;
+    }
+
+    public static String getMimeTypeForFile(String uri) {
+        String type = null;
+        String extension = MimeTypeMap.getFileExtensionFromUrl(uri);
+        if (extension != null) {
+            MimeTypeMap mime = MimeTypeMap.getSingleton();
+            type = mime.getMimeTypeFromExtension(extension);
+        }
+        return type;
+    }
+
+    private String findIndexFileInDirectory(File directory) {
+        String indexFileName = "index.html";
+        File indexFile = new File(directory, indexFileName);
+        if (indexFile.exists()) {
+            return indexFileName;
+        }
+        return null;
+    }
+
+    private String listDirectory(String uri, File f) {
+        String heading = "Directory " + uri;
+        StringBuilder msg = new StringBuilder("<html><head><title>" + heading
+                + "</title><style><!--\n" +
+                "span.dirname { font-weight: bold; }\n" +
+                "span.filesize { font-size: 75%; }\n" +
+                "// -->\n" +
+                "</style>" +
+                "</head><body><h1>" + heading + "</h1>");
+
+        String up = null;
+        if (uri.length() > 1) {
+            String u = uri.substring(0, uri.length() - 1);
+            int slash = u.lastIndexOf('/');
+            if (slash >= 0 && slash < u.length()) {
+                up = uri.substring(0, slash + 1);
+            }
+        }
+
+        List<String> files = Arrays.asList(f.list(new FilenameFilter() {
+            @Override
+            public boolean accept(File dir, String name) {
+                return new File(dir, name).isFile();
+            }
+        }));
+        Collections.sort(files);
+        List<String> directories = Arrays.asList(f.list(new FilenameFilter() {
+            @Override
+            public boolean accept(File dir, String name) {
+                return new File(dir, name).isDirectory();
+            }
+        }));
+        Collections.sort(directories);
+        if (up != null || directories.size() + files.size() > 0) {
+            msg.append("<ul>");
+            if (up != null || directories.size() > 0) {
+                msg.append("<section class=\"directories\">");
+                if (up != null) {
+                    msg.append("<li><a rel=\"directory\" href=\"").append(up)
+                            .append("\"><span class=\"dirname\">..</span></a></b></li>");
+                }
+                for (String directory : directories) {
+                    String dir = directory + "/";
+                    msg.append("<li><a rel=\"directory\" href=\"").append(encodeUriBetweenSlashes(uri + dir))
+                            .append("\"><span class=\"dirname\">").append(dir)
+                            .append("</span></a></b></li>");
+                }
+                msg.append("</section>");
+            }
+            if (files.size() > 0) {
+                msg.append("<section class=\"files\">");
+                for (String file : files) {
+                    msg.append("<li><a href=\"").append(encodeUriBetweenSlashes(uri + file))
+                            .append("\"><span class=\"filename\">").append(file)
+                            .append("</span></a>");
+                    File curFile = new File(f, file);
+                    long len = curFile.length();
+                    msg.append("&nbsp;<span class=\"filesize\">(");
+                    if (len < 1024) {
+                        msg.append(len).append(" bytes");
+                    } else if (len < 1024 * 1024) {
+                        msg.append(len / 1024).append(".").append(len % 1024 / 10 % 100)
+                                .append(" KB");
+                    } else {
+                        msg.append(len / (1024 * 1024)).append(".")
+                                .append(len % (1024 * 1024) / 10 % 100).append(" MB");
+                    }
+                    msg.append(")</span></li>");
+                }
+                msg.append("</section>");
+            }
+            msg.append("</ul>");
+        }
+        msg.append("</body></html>");
+        return msg.toString();
+    }
+}
diff --git a/src/org/fdroid/fdroid/views/LocalRepoActivity.java b/src/org/fdroid/fdroid/views/LocalRepoActivity.java
new file mode 100644
index 000000000..2c3ec3713
--- /dev/null
+++ b/src/org/fdroid/fdroid/views/LocalRepoActivity.java
@@ -0,0 +1,209 @@
+
+package org.fdroid.fdroid.views;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.ProgressDialog;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.res.Configuration;
+import android.net.wifi.WifiManager;
+import android.nfc.NdefMessage;
+import android.nfc.NdefRecord;
+import android.nfc.NfcAdapter;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.ToggleButton;
+
+import org.fdroid.fdroid.FDroidApp;
+import org.fdroid.fdroid.PreferencesActivity;
+import org.fdroid.fdroid.QrGenAsyncTask;
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.net.WifiStateChangeService;
+
+import java.util.Locale;
+
+public class LocalRepoActivity extends Activity {
+    private static final String TAG = "LocalRepoActivity";
+    private ProgressDialog repoProgress;
+
+    private WifiManager wifiManager;
+    private ToggleButton repoSwitch;
+
+    private int SET_IP_ADDRESS = 7345;
+
+    /** Called when the activity is first created. */
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.local_repo_activity);
+
+        repoSwitch = (ToggleButton) findViewById(R.id.repoSwitch);
+        wifiManager = (WifiManager) getSystemService(WIFI_SERVICE);
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        resetNetworkInfo();
+        LocalBroadcastManager.getInstance(this).registerReceiver(onWifiChange,
+                new IntentFilter(WifiStateChangeService.BROADCAST));
+    }
+
+    @Override
+    public void onPause() {
+        super.onPause();
+        LocalBroadcastManager.getInstance(this).unregisterReceiver(onWifiChange);
+    }
+
+    private BroadcastReceiver onWifiChange = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent i) {
+            resetNetworkInfo();
+        }
+    };
+
+    private void resetNetworkInfo() {
+        int wifiState = wifiManager.getWifiState();
+        if (wifiState == WifiManager.WIFI_STATE_ENABLED) {
+            setUIFromWifi();
+            wireRepoSwitchToWebServer();
+        } else {
+            repoSwitch.setChecked(false);
+            repoSwitch.setText(R.string.enable_wifi);
+            repoSwitch.setTextOn(getString(R.string.enabling_wifi));
+            repoSwitch.setTextOff(getString(R.string.enable_wifi));
+            repoSwitch.setOnClickListener(new View.OnClickListener() {
+                @Override
+                public void onClick(View v) {
+                    wifiManager.setWifiEnabled(true);
+                    /*
+                     * Once the wifi is connected to a network, then
+                     * WifiStateChangeReceiver will receive notice, and kick off
+                     * the process of getting the info about the wifi
+                     * connection.
+                     */
+                }
+            });
+        }
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        MenuInflater inflater = getMenuInflater();
+        inflater.inflate(R.menu.local_repo_activity, menu);
+        return true;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        switch (item.getItemId()) {
+            case R.id.menu_settings:
+                startActivityForResult(new Intent(this, PreferencesActivity.class), SET_IP_ADDRESS);
+                return true;
+        }
+        return false;
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+        if (resultCode != Activity.RESULT_OK)
+            return;
+        if (requestCode == SET_IP_ADDRESS) {
+            setUIFromWifi();
+        }
+    }
+
+    @Override
+    protected Dialog onCreateDialog(int id) {
+        switch (id) {
+            case 0:
+                repoProgress = new ProgressDialog(this);
+                repoProgress.setMessage("Scanning Apps. Please wait...");
+                repoProgress.setIndeterminate(false);
+                repoProgress.setMax(100);
+                repoProgress.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
+                repoProgress.setCancelable(false);
+                repoProgress.show();
+                return repoProgress;
+            default:
+                return null;
+        }
+    }
+
+    private void wireRepoSwitchToWebServer() {
+        repoSwitch.setOnClickListener(new View.OnClickListener() {
+            @Override
+            public void onClick(View v) {
+                if (repoSwitch.isChecked()) {
+                    FDroidApp.startLocalRepoService(LocalRepoActivity.this);
+                } else {
+                    FDroidApp.stopLocalRepoService(LocalRepoActivity.this);
+                }
+            }
+        });
+    }
+
+    @TargetApi(14)
+    private void setUIFromWifi() {
+        if (TextUtils.isEmpty(FDroidApp.repo.address))
+            return;
+        // the fingerprint is not useful on the button label
+        String buttonLabel = FDroidApp.repo.address.replaceAll("\\?.*$", "");
+        repoSwitch.setText(buttonLabel);
+        repoSwitch.setTextOn(buttonLabel);
+        repoSwitch.setTextOff(buttonLabel);
+        /*
+         * Set URL to UPPER for compact QR Code, FDroid will translate it back.
+         * Remove the SSID from the query string since SSIDs are case-sensitive.
+         * Instead the receiver will have to rely on the BSSID to find the right
+         * wifi AP to join. Lots of QR Scanners are buggy and do not respect
+         * custom URI schemes, so we have to use http:// or https:// :-(
+         */
+        final String qrUriString = Utils.getSharingUri(this, FDroidApp.repo).toString()
+                .replaceFirst("fdroidrepo", "http")
+                .replaceAll("ssid=[^?]*", "")
+                .toUpperCase(Locale.ENGLISH);
+        Log.i("QRURI", qrUriString);
+        new QrGenAsyncTask(this, R.id.repoQrCode).execute(qrUriString);
+
+        TextView wifiNetworkNameTextView = (TextView) findViewById(R.id.wifiNetworkName);
+        wifiNetworkNameTextView.setText(FDroidApp.ssid);
+
+        TextView fingerprintTextView = (TextView) findViewById(R.id.fingerprint);
+        if (FDroidApp.repo.fingerprint != null) {
+            fingerprintTextView.setVisibility(View.VISIBLE);
+            fingerprintTextView.setText(FDroidApp.repo.fingerprint);
+        } else {
+            fingerprintTextView.setVisibility(View.GONE);
+        }
+
+        // the required NFC API was added in 4.0 aka Ice Cream Sandwich
+        if (Build.VERSION.SDK_INT >= 14) {
+            NfcAdapter nfcAdapter = NfcAdapter.getDefaultAdapter(this);
+            if (nfcAdapter == null)
+                return;
+            nfcAdapter.setNdefPushMessage(new NdefMessage(new NdefRecord[] {
+                    NdefRecord.createUri(Utils.getSharingUri(this, FDroidApp.repo)),
+            }), this);
+        }
+    }
+
+    @Override
+    public void onConfigurationChanged(Configuration newConfig) {
+        // ignore orientation/keyboard change
+        super.onConfigurationChanged(newConfig);
+    }
+}