diff --git a/res/layout/actionbar_progress_indeterminate.xml b/res/layout/actionbar_progress_indeterminate.xml new file mode 100644 index 0000000..ba1c316 --- /dev/null +++ b/res/layout/actionbar_progress_indeterminate.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/res/layout/list_item_device.xml b/res/layout/list_item_device.xml new file mode 100644 index 0000000..b7d0e2d --- /dev/null +++ b/res/layout/list_item_device.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/menu/main.xml b/res/menu/main.xml index c002028..0aa7c7b 100644 --- a/res/menu/main.xml +++ b/res/menu/main.xml @@ -1,9 +1,35 @@ + + + + android:showAsAction="ifRoom|withText" + android:title="@string/menu_scan"/> + - + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index e96ba21..b412ef1 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -9,5 +9,12 @@ Not supported Supported No data + Unknown Device + + + Connect + Disconnect + Scan + Stop \ No newline at end of file diff --git a/src/uk/co/alt236/btlescan/MainActivity.java b/src/uk/co/alt236/btlescan/MainActivity.java index b04131a..55a85bf 100644 --- a/src/uk/co/alt236/btlescan/MainActivity.java +++ b/src/uk/co/alt236/btlescan/MainActivity.java @@ -2,6 +2,7 @@ package uk.co.alt236.btlescan; import java.util.Collection; +import uk.co.alt236.btlescan.adapters.LeDeviceListAdapter; import uk.co.alt236.btlescan.containers.AdRecord; import uk.co.alt236.btlescan.containers.AdRecordUtils; import uk.co.alt236.btlescan.containers.BluetoothLeDevice; @@ -13,6 +14,7 @@ import android.bluetooth.BluetoothDevice; import android.os.Bundle; import android.util.Log; import android.view.Menu; +import android.view.MenuItem; import android.widget.TextView; import butterknife.ButterKnife; import butterknife.InjectView; @@ -20,12 +22,11 @@ import butterknife.InjectView; public class MainActivity extends ListActivity { @InjectView(R.id.tvBluetoothLe) TextView mTvBluetoothLeStatus; @InjectView(R.id.tvBluetoothStatus) TextView mTvBluetoothStatus; - - private static final long SCAN_PERIOD = 10000; + private BluetoothUtils mBluetoothUtils; private BluetoothLeScanner mScanner; - //private LeDeviceListAdapter mLeDeviceListAdapter; - + private LeDeviceListAdapter mLeDeviceListAdapter; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -35,62 +36,93 @@ public class MainActivity extends ListActivity { mScanner = new BluetoothLeScanner(mLeScanCallback, mBluetoothUtils); } - @Override - public boolean onCreateOptionsMenu(Menu menu) { - // Inflate the menu; this adds items to the action bar if it is present. - getMenuInflater().inflate(R.menu.main, menu); - return true; - } + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.main, menu); + if (!mScanner.isScanning()) { + menu.findItem(R.id.menu_stop).setVisible(false); + menu.findItem(R.id.menu_scan).setVisible(true); + menu.findItem(R.id.menu_refresh).setActionView(null); + } else { + menu.findItem(R.id.menu_stop).setVisible(true); + menu.findItem(R.id.menu_scan).setVisible(false); + menu.findItem(R.id.menu_refresh).setActionView(R.layout.actionbar_progress_indeterminate); + } + return true; + } - - - - + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_scan: + mLeDeviceListAdapter.clear(); + mScanner.scanLeDevice(-1, true); + invalidateOptionsMenu(); + break; + case R.id.menu_stop: + mScanner.scanLeDevice(-1, false); + invalidateOptionsMenu(); + break; + } + return true; + } + private BluetoothAdapter.LeScanCallback mLeScanCallback = new BluetoothAdapter.LeScanCallback() { - @Override - public void onLeScan(final BluetoothDevice device, int rssi, byte[] scanRecord) { - - final BluetoothLeDevice deviceLe = new BluetoothLeDevice(device, rssi, scanRecord); - Log.d("TAG", "~ New BT Device: " + deviceLe); - - final Collection adRecords = deviceLe.getAdRecordStore().getRecordsAsCollection(); - - for(final AdRecord record : adRecords){ - Log.d("TAG", "~ Has Record: " + record.getType() + ": '" + record.getHumanReadableType() +"', data: '"+ AdRecordUtils.getRecordDataAsString(record) + "'"); - } - -// runOnUiThread(new Runnable() { -// @Override -// public void run() { -// mLeDeviceListAdapter.addDevice(device); -// mLeDeviceListAdapter.notifyDataSetChanged(); -// } -// }); - - } + @Override + public void onLeScan(final BluetoothDevice device, int rssi, byte[] scanRecord) { + + final BluetoothLeDevice deviceLe = new BluetoothLeDevice(device, rssi, scanRecord); + Log.d("TAG", "~ New BT Device: " + deviceLe); + + final Collection adRecords = deviceLe.getAdRecordStore().getRecordsAsCollection(); + + for(final AdRecord record : adRecords){ + Log.d("TAG", "~ Has Record: " + record.getType() + ": '" + record.getHumanReadableType() +"', data: '"+ AdRecordUtils.getRecordDataAsString(record) + "'"); + } + + runOnUiThread(new Runnable() { + @Override + public void run() { + mLeDeviceListAdapter.addDevice(deviceLe); + mLeDeviceListAdapter.notifyDataSetChanged(); + } + }); + + } }; - + + @Override + protected void onPause() { + super.onPause(); + mScanner.scanLeDevice(-1, false); + mLeDeviceListAdapter.clear(); + } + @Override public void onResume(){ super.onResume(); final boolean mIsBluetoothOn = mBluetoothUtils.isBluetoothOn(); final boolean mIsBluetoothLePresent = mBluetoothUtils.isBluetoothLeSupported(); - + if(mIsBluetoothOn){ mTvBluetoothStatus.setText(R.string.on); } else { mTvBluetoothStatus.setText(R.string.off); } - + if(mIsBluetoothLePresent){ mTvBluetoothLeStatus.setText(R.string.supported); } else { mTvBluetoothLeStatus.setText(R.string.not_supported); } - + + mLeDeviceListAdapter = new LeDeviceListAdapter(this); + setListAdapter(mLeDeviceListAdapter); + mBluetoothUtils.askUserToEnableBluetoothIfNeeded(); if(mIsBluetoothOn && mIsBluetoothLePresent){ - mScanner.scanLeDevice(true); + mScanner.scanLeDevice(-1, true); + invalidateOptionsMenu(); } } diff --git a/src/uk/co/alt236/btlescan/adapters/LeDeviceListAdapter.java b/src/uk/co/alt236/btlescan/adapters/LeDeviceListAdapter.java new file mode 100644 index 0000000..e05fe59 --- /dev/null +++ b/src/uk/co/alt236/btlescan/adapters/LeDeviceListAdapter.java @@ -0,0 +1,92 @@ +package uk.co.alt236.btlescan.adapters; + +import java.util.ArrayList; +import java.util.List; + +import uk.co.alt236.btlescan.R; +import uk.co.alt236.btlescan.containers.BluetoothLeDevice; +import android.app.Activity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + // Adapter for holding devices found through scanning. + public class LeDeviceListAdapter extends BaseAdapter { + private final List mLeDevices; + private final LayoutInflater mInflator; + + public LeDeviceListAdapter(Activity activity) { + super(); + mLeDevices = new ArrayList(); + mInflator = activity.getLayoutInflater(); + } + + public void addDevice(BluetoothLeDevice device) { + final int position = mLeDevices.indexOf(device); + if(position == -1){ + mLeDevices.add(device); + } else { + mLeDevices.set(position, device); + } + } + + public BluetoothLeDevice getDevice(int position) { + return mLeDevices.get(position); + } + + public void clear() { + mLeDevices.clear(); + } + + @Override + public int getCount() { + return mLeDevices.size(); + } + + @Override + public Object getItem(int i) { + return mLeDevices.get(i); + } + + @Override + public long getItemId(int i) { + return i; + } + + @Override + public View getView(int i, View view, ViewGroup viewGroup) { + ViewHolder viewHolder; + // General ListView optimization code. + if (view == null) { + view = mInflator.inflate(R.layout.list_item_device, null); + viewHolder = new ViewHolder(); + viewHolder.deviceAddress = (TextView) view.findViewById(R.id.device_address); + viewHolder.deviceName = (TextView) view.findViewById(R.id.device_name); + viewHolder.deviceRssi = (TextView) view.findViewById(R.id.device_rssi); + view.setTag(viewHolder); + } else { + viewHolder = (ViewHolder) view.getTag(); + } + + final BluetoothLeDevice device = mLeDevices.get(i); + final String deviceName = device.getName(); + + if (deviceName != null && deviceName.length() > 0){ + viewHolder.deviceName.setText(deviceName); + } else{ + viewHolder.deviceName.setText(R.string.unknown_device); + } + + viewHolder.deviceAddress.setText(device.getAddress()); + viewHolder.deviceRssi.setText(String.valueOf(device.getRssi()) + "db"); + return view; + } + + static class ViewHolder { + TextView deviceName; + TextView deviceAddress; + TextView deviceRssi; + } + + } \ No newline at end of file diff --git a/src/uk/co/alt236/btlescan/containers/AdRecord.java b/src/uk/co/alt236/btlescan/containers/AdRecord.java index c4a4d88..443052f 100644 --- a/src/uk/co/alt236/btlescan/containers/AdRecord.java +++ b/src/uk/co/alt236/btlescan/containers/AdRecord.java @@ -7,6 +7,26 @@ import java.util.Arrays; * AdRecord */ public final class AdRecord { + + // 02 # Number of bytes that follow in first AD structure + // 01 # Flags AD type + // 1A # Flags value 0x1A = 000011010 + // bit 0 (OFF) LE Limited Discoverable Mode + // bit 1 (ON) LE General Discoverable Mode + // bit 2 (OFF) BR/EDR Not Supported + // bit 3 (ON) Simultaneous LE and BR/EDR to Same Device Capable (controller) + // bit 4 (ON) Simultaneous LE and BR/EDR to Same Device Capable (Host) + // 1A # Number of bytes that follow in second (and last) AD structure + // FF # Manufacturer specific data AD type + // 4C 00 # Company identifier code (0x004C == Apple) + // 02 # Byte 0 of iBeacon advertisement indicator + // 15 # Byte 1 of iBeacon advertisement indicator + // e2 c5 6d b5 df fb 48 d2 b0 60 d0 f5 a7 10 96 e0 # iBeacon proximity uuid + // 00 00 # major + // 00 00 # minor + // c5 # The 2's complement of the calibrated Tx Power + + /** * General FLAGS * @@ -143,7 +163,7 @@ public final class AdRecord { case TYPE_MANUFACTURER_SPECIFIC_DATA: return "Manufacturer Specific Data"; case TYPE_LOCAL_NAME_COMPLETE: - return "Name"; + return "Name (Complete)"; case TYPE_LOCAL_NAME_SHORT: return "Name (Short)"; case TYPE_SECURITY_MANAGER_OOB_FLAGS: diff --git a/src/uk/co/alt236/btlescan/containers/AdRecordUtils.java b/src/uk/co/alt236/btlescan/containers/AdRecordUtils.java index fe84c9d..8b57309 100644 --- a/src/uk/co/alt236/btlescan/containers/AdRecordUtils.java +++ b/src/uk/co/alt236/btlescan/containers/AdRecordUtils.java @@ -11,6 +11,25 @@ import android.annotation.SuppressLint; public class AdRecordUtils { /* Helper functions to parse out common data payloads from an AD structure */ + static final String HEXES = "0123456789ABCDEF"; + + public static String byteArrayToHexString(final byte[] array){ + final StringBuffer sb = new StringBuffer(); + boolean firstEntry = true; + sb.append('['); + + for ( final byte b : array ) { + if(!firstEntry){ + sb.append(", "); + } + sb.append(HEXES.charAt((b & 0xF0) >> 4)); + sb.append(HEXES.charAt((b & 0x0F))); + firstEntry = false; + } + + sb.append(']'); + return sb.toString(); + } public static String getRecordDataAsString(AdRecord nameRecord) { if(nameRecord == null){return new String();} @@ -64,7 +83,7 @@ public class AdRecordUtils { return Collections.unmodifiableList(records); } - + @SuppressLint("UseSparseArrays") public static Map parseScanRecordAsMap(byte[] scanRecord) { final Map records = new HashMap(); diff --git a/src/uk/co/alt236/btlescan/containers/BluetoothLeDevice.java b/src/uk/co/alt236/btlescan/containers/BluetoothLeDevice.java index bf82a57..30803c7 100644 --- a/src/uk/co/alt236/btlescan/containers/BluetoothLeDevice.java +++ b/src/uk/co/alt236/btlescan/containers/BluetoothLeDevice.java @@ -7,7 +7,7 @@ import android.bluetooth.BluetoothDevice; public class BluetoothLeDevice { private final BluetoothDevice mDevice; - private final int mRssi; + private transient final int mRssi; private final byte[] mScanRecord; private final AdRecordStore mRecordStore; @@ -18,14 +18,37 @@ public class BluetoothLeDevice { mRecordStore = new AdRecordStore(AdRecordUtils.parseScanRecordAsMap(scanRecord)); } + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + BluetoothLeDevice other = (BluetoothLeDevice) obj; + if (mDevice == null) { + if (other.mDevice != null) + return false; + } else if (!mDevice.equals(other.mDevice)) + return false; + if (!Arrays.equals(mScanRecord, other.mScanRecord)) + return false; + return true; + } + + public String getAddress(){ + return mDevice.getAddress(); + } + public AdRecordStore getAdRecordStore(){ return mRecordStore; } - + public String getBluetoothDeviceBondState(){ return resolveBondingState(mDevice.getBondState()); } - + public String getBluetoothDeviceClassName(){ return resolveBluetoothClass(mDevice.getBluetoothClass().getDeviceClass()); } @@ -33,7 +56,11 @@ public class BluetoothLeDevice { public BluetoothDevice getDevice() { return mDevice; } - + + public String getName(){ + return mDevice.getName(); + } + public int getRssi() { return mRssi; } @@ -41,11 +68,22 @@ public class BluetoothLeDevice { public byte[] getScanRecord() { return mScanRecord; } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((mDevice == null) ? 0 : mDevice.hashCode()); + result = prime * result + Arrays.hashCode(mScanRecord); + return result; + } @Override public String toString() { - return "BluetoothLeDevice [mDevice=" + mDevice + ", mRssi=" + mRssi + ", mScanRecord=" + Arrays.toString(mScanRecord) + ", mRecordStore=" + mRecordStore + ", getBluetoothDeviceBondState()=" + getBluetoothDeviceBondState() + ", getBluetoothDeviceClassName()=" + getBluetoothDeviceClassName() + "]"; + return "BluetoothLeDevice [mDevice=" + mDevice + ", mRssi=" + mRssi + ", mScanRecord=" + AdRecordUtils.byteArrayToHexString(mScanRecord) + ", mRecordStore=" + mRecordStore + ", getBluetoothDeviceBondState()=" + getBluetoothDeviceBondState() + ", getBluetoothDeviceClassName()=" + getBluetoothDeviceClassName() + "]"; } + + private static String resolveBluetoothClass(int btClass){ switch (btClass){ diff --git a/src/uk/co/alt236/btlescan/util/BluetoothLeScanner.java b/src/uk/co/alt236/btlescan/util/BluetoothLeScanner.java index c52156c..38d1f79 100644 --- a/src/uk/co/alt236/btlescan/util/BluetoothLeScanner.java +++ b/src/uk/co/alt236/btlescan/util/BluetoothLeScanner.java @@ -5,8 +5,6 @@ import android.os.Handler; import android.util.Log; public class BluetoothLeScanner { - private static final long SCAN_PERIOD = 10000; - private final Handler mHandler; private final BluetoothAdapter.LeScanCallback mLeScanCallback; private final BluetoothUtils mBluetoothUtils; @@ -18,21 +16,25 @@ public class BluetoothLeScanner { mBluetoothUtils = bluetoothUtils; } - - public void scanLeDevice(final boolean enable) { + public boolean isScanning() { + return mScanning; + } + + public void scanLeDevice(final int duration, final boolean enable) { if (enable) { if(mScanning){return;} Log.d("TAG", "~ Starting Scan"); // Stops scanning after a pre-defined scan period. - mHandler.postDelayed(new Runnable() { - @Override - public void run() { - Log.d("TAG", "~ Stopping Scan (timeout)"); - mScanning = false; - mBluetoothUtils.getBluetoothAdapter().stopLeScan(mLeScanCallback); - } - }, SCAN_PERIOD); - + if(duration > 0){ + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + Log.d("TAG", "~ Stopping Scan (timeout)"); + mScanning = false; + mBluetoothUtils.getBluetoothAdapter().stopLeScan(mLeScanCallback); + } + }, duration); + } mScanning = true; mBluetoothUtils.getBluetoothAdapter().startLeScan(mLeScanCallback); } else {