package org.fdroid.fdroid.net.bluetooth; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothServerSocket; import android.bluetooth.BluetoothSocket; import android.util.Log; import android.webkit.MimeTypeMap; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.localrepo.type.BluetoothSwap; import org.fdroid.fdroid.net.bluetooth.httpish.Request; import org.fdroid.fdroid.net.bluetooth.httpish.Response; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import fi.iki.elonen.NanoHTTPD; /** * Act as a layer on top of LocalHTTPD server, by forwarding requests served * over bluetooth to that server. */ public class BluetoothServer extends Thread { private static final String TAG = "BluetoothServer"; private BluetoothServerSocket serverSocket; private final List<ClientConnection> clients = new ArrayList<>(); private final File webRoot; private final BluetoothSwap swap; private boolean isRunning; public BluetoothServer(BluetoothSwap swap, File webRoot) { this.webRoot = webRoot; this.swap = swap; start(); } public boolean isRunning() { return isRunning; } public void close() { for (ClientConnection clientConnection : clients) { clientConnection.interrupt(); } interrupt(); if (serverSocket != null) { Utils.closeQuietly(serverSocket); } } @Override public void run() { isRunning = true; BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); try { serverSocket = adapter.listenUsingInsecureRfcommWithServiceRecord("FDroid App Swap", BluetoothConstants.fdroidUuid()); } catch (IOException e) { Log.e(TAG, "Error starting Bluetooth server socket, will stop the server now", e); swap.stop(); isRunning = false; return; } while (true) { if (isInterrupted()) { Utils.debugLog(TAG, "Server stopped so will terminate loop looking for client connections."); break; } try { BluetoothSocket clientSocket = serverSocket.accept(); if (clientSocket != null) { if (isInterrupted()) { Utils.debugLog(TAG, "Server stopped after socket accepted from client, but before initiating connection."); break; } ClientConnection client = new ClientConnection(clientSocket, webRoot); client.start(); clients.add(client); } } catch (IOException e) { Log.e(TAG, "Error receiving client connection over Bluetooth server socket, will continue listening for other clients", e); } } isRunning = false; } private static class ClientConnection extends Thread { private final BluetoothSocket socket; private final File webRoot; ClientConnection(BluetoothSocket socket, File webRoot) { this.socket = socket; this.webRoot = webRoot; } @Override public void run() { Utils.debugLog(TAG, "Listening for incoming Bluetooth requests from client"); BluetoothConnection connection; try { connection = new BluetoothConnection(socket); connection.open(); } catch (IOException e) { Log.e(TAG, "Error listening for incoming connections over bluetooth", e); return; } while (true) { try { Utils.debugLog(TAG, "Listening for new Bluetooth request from client."); Request incomingRequest = Request.listenForRequest(connection); handleRequest(incomingRequest).send(connection); } catch (IOException e) { Log.e(TAG, "Error receiving incoming connection over bluetooth", e); break; } if (isInterrupted()) { break; } } connection.closeQuietly(); } private Response handleRequest(Request request) { Utils.debugLog(TAG, "Received Bluetooth request from client, will process it now."); Response.Builder builder = null; try { int statusCode = 404; int totalSize = -1; if (request.getMethod().equals(Request.Methods.HEAD)) { builder = new Response.Builder(); } else { HashMap<String, String> headers = new HashMap<>(); Response resp = respond(headers, "/" + request.getPath()); builder = new Response.Builder(resp.toContentStream()); statusCode = resp.getStatusCode(); totalSize = resp.getFileSize(); } // TODO: At this stage, will need to download the file to get this info. // However, should be able to make totalDownloadSize and getCacheTag work without downloading. return builder .setStatusCode(statusCode) .setFileSize(totalSize) .build(); } catch (Exception e) { // throw new IOException("Error getting file " + request.getPath() + " from local repo proxy - " + e.getMessage(), e); Log.e(TAG, "error processing request; sending 500 response", e); if (builder == null) { builder = new Response.Builder(); } return builder .setStatusCode(500) .setFileSize(0) .build(); } } 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(NanoHTTPD.Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: Won't serve ../ for security reasons."); } File f = new File(webRoot, uri); if (!f.exists()) { return createResponse(NanoHTTPD.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(NanoHTTPD.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(NanoHTTPD.Response.Status.NOT_FOUND, NanoHTTPD.MIME_HTML, ""); } return createResponse(NanoHTTPD.Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: No directory listing."); } return respond(headers, uri + indexFile); } Response response = serveFile(uri, headers, f, getMimeTypeForFile(uri)); return response != null ? response : createResponse(NanoHTTPD.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() + String.valueOf(file.length())) .hashCode()); // Support (simple) skipping: long startFrom = 0; long endAt = -1; String range = header.get("range"); if (range != null && 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(NanoHTTPD.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; } }; long skipped = fis.skip(startFrom); if (skipped != startFrom) { throw new IOException("unable to skip the required " + startFrom + " bytes."); } res = createResponse(NanoHTTPD.Response.Status.PARTIAL_CONTENT, mime, fis); res.addHeader("Content-Length", String.valueOf(dataLen)); res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/" + fileLen); res.addHeader("ETag", etag); } } else { if (etag.equals(header.get("if-none-match"))) { res = createResponse(NanoHTTPD.Response.Status.NOT_MODIFIED, mime, ""); } else { res = createResponse(NanoHTTPD.Response.Status.OK, mime, new FileInputStream(file)); res.addHeader("Content-Length", String.valueOf(fileLen)); res.addHeader("ETag", etag); } } } catch (IOException ioe) { res = createResponse(NanoHTTPD.Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: Reading file failed."); } return res; } // Announce that the file server accepts partial content requests private Response createResponse(NanoHTTPD.Response.Status status, String mimeType, String content) { return new Response(status.getRequestStatus(), mimeType, content); } // Announce that the file server accepts partial content requests private Response createResponse(NanoHTTPD.Response.Status status, String mimeType, InputStream content) { return new Response(status.getRequestStatus(), mimeType, content); } 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; } } }