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…</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…</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(" <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); + } +}