/* * Copyright 2012 The Stanford MobiSocial Laboratory * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.mobisocial.corral; import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.UUID; import mobisocial.musubi.App; import mobisocial.musubi.model.DbContactAttributes; import mobisocial.musubi.model.helpers.ObjectManager; import mobisocial.musubi.objects.PictureObj; import mobisocial.musubi.objects.VideoObj; import mobisocial.socialkit.SignedObj; import mobisocial.socialkit.musubi.DbIdentity; import mobisocial.socialkit.musubi.DbObj; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.DefaultHttpClient; import org.json.JSONException; import org.json.JSONObject; import org.mobisocial.corral.CorralDownloadHandler.CorralDownloadFuture; import org.mobisocial.corral.CorralHelper.DownloadProgressCallback; import org.mobisocial.corral.CorralHelper.DownloadProgressCallback.DownloadChannel; import org.mobisocial.corral.CorralHelper.DownloadProgressCallback.DownloadState; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothSocket; import android.content.Context; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.util.Base64; import android.util.Log; public class CorralDownloadClient { private static final String TAG = "corral"; private static final boolean DBG = true; public static final String OBJ_MIME_TYPE = "mimeType"; public static final String OBJ_LOCAL_URI = "localUri"; public static final String OBJ_PRESHARED_KEY = "sharedkey"; private final Context mContext; private ObjectManager mObjectManager; public static CorralDownloadClient getInstance(Context context) { return new CorralDownloadClient(context); } private CorralDownloadClient(Context context) { mContext = context; mObjectManager = new ObjectManager(App.getDatabaseSource(context)); } public boolean fileAvailableLocally(DbObj obj) { try { if (mObjectManager.isObjectFromLocalDevice(obj.getLocalId())) { return true; } // if (obj.getSender() is owned AND obj.getDevice() is this one) ... // return true if we can fetch content locally return localFileForContent(obj, false).exists(); } catch (Exception e) { Log.w(TAG, "Error checking file availability", e); return false; } } /** * Returns a uri for the locally available content or null * if the content is not available locally. */ public Uri getAvailableContentUri(DbObj obj) { if (!fileAvailableLocally(obj)) { return null; } if (mObjectManager.isObjectFromLocalDevice(obj.getLocalId())) { try { String uriString = obj.getJson().getString(OBJ_LOCAL_URI); return Uri.parse(uriString); } catch (Exception e) { return null; } } return Uri.fromFile(localFileForContent(obj, false)); } /** * Synchronized method that retrieves content by any possible transport, and * returns a uri representing it locally. This method blocks until the file * is available locally, or it has been determined that the file cannot * currently be fetched. */ Uri fetchContent(DbObj obj, CorralDownloadFuture future, DownloadProgressCallback callback) throws IOException { if (obj.getJson() == null || !obj.getJson().has(OBJ_LOCAL_URI)) { if (DBG) { Log.d(TAG, "no local uri for obj."); } return null; } if (mObjectManager.isObjectFromLocalDevice(obj.getLocalId())) { try { // TODO: Objects shared out from the content corral should // be accessible through the content corral. We don't have // to copy all files but we should have the option to create // a locate cache. return Uri.parse(obj.getJson().getString(OBJ_LOCAL_URI)); } catch (JSONException e) { Log.e(TAG, "json exception getting local uri", e); return null; } } DbIdentity user = obj.getSender(); if (user == null) { throw new IOException("Null user in corral"); } File localFile = localFileForContent(obj, false); if (localFile.exists()) { return Uri.fromFile(localFile); } try { if (userAvailableOnLan(user)) { return doMediaScan(getFileOverLan(user, obj, future, callback)); } } catch (IOException e) { if (DBG) Log.d(TAG, "Failed to pull LAN file", e); } try { return doMediaScan(CorralHelper.downloadContent(mContext, localFile, obj, future, callback)); } catch (IOException e) { if (DBG) Log.d(TAG, "Failed to pull Corral file", e); } try { return doMediaScan(getFileOverBluetooth(user, obj, future, callback)); } catch (IOException e) { } if (!localFile.exists()) { callback.onProgress(DownloadState.TRANSFER_COMPLETE, DownloadChannel.NONE, DownloadProgressCallback.FAILURE); throw new IOException("Failed to fetch file"); } callback.onProgress(DownloadState.TRANSFER_COMPLETE, DownloadChannel.NONE, DownloadProgressCallback.SUCCESS); return doMediaScan(Uri.fromFile(localFile)); } Uri doMediaScan(Uri content) { String[] paths = new String[] { content.getPath() }; MediaScannerConnection.scanFile(mContext, paths, null, null); return content; } public String getMimeType(DbObj obj) { if (obj.getJson() != null && obj.getJson().has(OBJ_MIME_TYPE)) { try { return obj.getJson().getString(OBJ_MIME_TYPE); } catch (JSONException e) { } } return null; } private Uri getFileOverBluetooth(DbIdentity user, SignedObj obj, CorralDownloadFuture future, DownloadProgressCallback callback) throws IOException { callback.onProgress(DownloadState.PREPARING_CONNECTION, DownloadChannel.BLUETOOTH, 0); String macStr = DbContactAttributes.getAttribute(mContext, user.getLocalId(), DbContactAttributes.ATTR_BT_MAC); if (macStr == null) { throw new IOException("No bluetooth mac address for user"); } String uuidStr = DbContactAttributes.getAttribute(mContext, user.getLocalId(), DbContactAttributes.ATTR_BT_CORRAL_UUID); if (uuidStr == null) { throw new IOException("No corral uuid for user"); } UUID uuid = UUID.fromString(uuidStr); BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); BluetoothDevice device = adapter.getRemoteDevice(macStr); BluetoothSocket socket; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD_MR1) { socket = device.createInsecureRfcommSocketToServiceRecord(uuid); } else { socket = device.createRfcommSocketToServiceRecord(uuid); } // TODO: // Custom wire protocol, look for header bits to map to protocol handler. Log.d(TAG, "BJD BLUETOOTH CORRAL NOT READY: can't pull file over bluetooth."); return null; } private Uri getFileOverLan(DbIdentity user, DbObj obj, CorralDownloadFuture future, DownloadProgressCallback callback) throws IOException { DownloadChannel channel = DownloadChannel.LAN; callback.onProgress(DownloadState.PREPARING_CONNECTION, channel, 0); InputStream in = null; OutputStream out = null; try { // Remote String ip = getUserLanIp(mContext, user); Uri remoteUri = uriForLanContent(ip, obj); if (DBG) { Log.d(TAG, "Attempting to pull lan file " + remoteUri); } HttpClient http = new DefaultHttpClient(); HttpGet get = new HttpGet(remoteUri.toString()); HttpResponse response = http.execute(get); long contentLength = response.getEntity().getContentLength(); File localFile = localFileForContent(obj, false); if (!localFile.exists()) { if (future.isCancelled()) { throw new IOException("User error"); } localFile.getParentFile().mkdirs(); try { in = response.getEntity().getContent(); out = new FileOutputStream(localFile); byte[] buf = new byte[1024]; int len; callback.onProgress(DownloadState.TRANSFER_IN_PROGRESS, channel, 0); int read = 0; int progress = 0; while (!future.isCancelled() && (len = in.read(buf)) > 0) { read += len; if (contentLength > 0) { int newProgress = Math.round(100f * read / contentLength); if (progress != newProgress) { progress = newProgress; callback.onProgress(DownloadState.TRANSFER_IN_PROGRESS, channel, progress); } } out.write(buf, 0, len); } if (future.isCancelled()) { throw new IOException("user cancelled"); } if (DBG) Log.d(TAG, "successfully fetched content over lan"); callback.onProgress(DownloadState.TRANSFER_COMPLETE, channel, DownloadProgressCallback.SUCCESS); } catch (IOException e) { if (DBG) Log.d(TAG, "failed to get content from lan"); callback.onProgress(DownloadState.TRANSFER_COMPLETE, channel, DownloadProgressCallback.FAILURE); if (localFile.exists()) { localFile.delete(); } throw e; } } return Uri.fromFile(localFile); } catch (Exception e) { throw new IOException(e); } finally { try { if(in != null) in.close(); if(out != null) out.close(); } catch (IOException e) { Log.e(TAG, "failed to close handle on get corral content", e); } } } private boolean userAvailableOnLan(DbIdentity user) { // TODO: ipv6 compliance. // TODO: Try multiple ip endpoints; multi-sourced download; // torrent-style sharing // (mobile, distributed CDN) return null != DbContactAttributes.getAttribute(mContext, user.getLocalId(), DbContactAttributes.ATTR_LAN_IP); } private static Uri uriForLanContent(String host, DbObj obj) { try { String localContent = obj.getJson().getString(OBJ_LOCAL_URI); Uri baseUri = Uri.parse("http://" + host + ":" + ContentCorral.SERVER_PORT); return baseUri.buildUpon() .appendQueryParameter("content", localContent) .appendQueryParameter("hash", "" + obj.getUniversalHashString()).build(); } catch (Exception e) { Log.d(TAG, "No uri for content " + obj.getHash() + "; " + obj.getJson()); return null; } } private static String getUserLanIp(Context context, DbIdentity user) { return DbContactAttributes.getAttribute(context, user.getLocalId(), DbContactAttributes.ATTR_LAN_IP); } /** * The filename where this obj's content would be stored. */ public static File localFileForContent(DbObj obj, boolean thumb) { try { File contentDir; String type = obj.getType(); if (PictureObj.TYPE.equals(type) || VideoObj.TYPE.equals(type)) { contentDir = new File(Environment.getExternalStorageDirectory(), ContentCorral.PICTURE_SUBFOLDER); } else { contentDir = new File(Environment.getExternalStorageDirectory(), ContentCorral.FILES_SUBFOLDER); } JSONObject json = obj.getJson(); String suffix = extensionForType(json.optString(OBJ_MIME_TYPE)); if (thumb) { suffix = thumb + "." + suffix; } String fname = obj.getUniversalHashString() + "." + suffix; return new File(contentDir, fname); } catch (Exception e) { Log.e(TAG, "Error looking up file name", e); return null; } } static String extensionForType(String type) { final String DEFAULT = "dat"; if (type == null) { return DEFAULT; } if (type.equals("image/jpeg")) { return "jpg"; } if (type.equals("video/3gpp")) { return "3gp"; } if (type.equals("image/png")) { return "png"; } return DEFAULT; } static boolean containsBytes(byte[] header, byte[] test, int limit) { assert(limit > test.length); for(int i = 0; i < limit - test.length; ++i) { int j = 0; for(; j < test.length; ++j) { if(header[i] != test[j]) break; } if(j == test.length) return true; } return false; } public static String typeForBytes(byte[] header, String obj_type) { String DEFAULT = null; if(obj_type == PictureObj.TYPE) { //TODO: lame our jpeg encoder doesn't put in proper //jfif headers DEFAULT = "image/jpeg"; } if(containsBytes(header, "JFIF".getBytes(), 16)) { return "image/jpeg"; } if(containsBytes(header, "�PNG".getBytes(), 16)) { return "image/png"; } return DEFAULT; } static String typeForExtension(String ext) { if (ext == null) { return null; } if (ext.equals("jpg")) { return "image/jpeg"; } if (ext.equals("3gp")) { return "video/3gp"; } if (ext.equals("png")) { return "image/png"; } return null; } private static class HashUtils { static String convertToHex(byte[] data) { StringBuffer buf = new StringBuffer(); for (int i = 0; i < data.length; i++) { int halfbyte = (data[i] >>> 4) & 0x0F; int two_halfs = 0; do { if ((0 <= halfbyte) && (halfbyte <= 9)) buf.append((char) ('0' + halfbyte)); else buf.append((char) ('a' + (halfbyte - 10))); halfbyte = data[i] & 0x0F; } while (two_halfs++ < 1); } return buf.toString(); } public static String SHA1(String text) throws NoSuchAlgorithmException, UnsupportedEncodingException { MessageDigest md; md = MessageDigest.getInstance("SHA-1"); byte[] sha1hash = new byte[40]; md.update(text.getBytes("iso-8859-1"), 0, text.length()); sha1hash = md.digest(); return convertToHex(sha1hash); } } private static String hashToString(long hash) { try { ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeLong(hash); dos.writeInt(-4); byte[] data = bos.toByteArray(); return Base64.encodeToString(data, Base64.DEFAULT).substring(0, 11); } catch (IOException e) { return null; } } }