iBeacon detection with Android (2): Create UI and improve logic
In last post, I demonstrated how to detect iBeacon on Android by capturing BLE packet and checking packet structure.
This time, I will add some improvements to our logic and display data on UI:
User should be able to start/stop scan from UI
Scanned devices are displayed in a list
If the device is iBeacon, display iBeacon exclusive properties
If the device is not iBeacon, display BLE general properties
If app receives packet from a new device, add to list
If app receives packet from the same device, only update list
UI
Main view
I will use Recycleview to display BLE device list. The code below is used in activity_main.xml:
/**
* Start BLE scan
* Check Bluetooth before scanning.
* If Bluetooth is disabled, request user to turn on Bluetooth
*/privatefunstartScan(){// check Bluetooth
if(!scanService.isBluetoothEnabled()){Log.d(TAG,"@startScan Bluetooth is disabled")valintent=Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)requestBluetooth.launch(intent)}else{scanService.initScanner()// start scanning BLE device
if(scanService.isScanning()){binding.scanBtn.text=resources.getString(R.string.label_scan)scanService.stopBLEScan()}else{scanService.startBLEScan()binding.scanBtn.text=resources.getString(R.string.label_scanning)}}}/**
* exit application
*/privatefunexitApp(){// if scanning service is running, stop scan then exit
if(scanService.isScanning()){binding.scanBtn.text=resources.getString(R.string.label_scan)scanService.stopBLEScan()}this@MainActivity.finish()exitProcess(0)}privatevarrequestBluetooth=registerForActivityResult(ActivityResultContracts.StartActivityForResult()){result->if(result.resultCode==RESULT_OK){Log.d(TAG,"@requestBluetooth Bluetooth is enabled")}else{Log.d(TAG,"@requestBluetooth Bluetooth usage is denied")}}
Item list UI
Since I need to display BLE device and iBeacon in the same list, and their data are different, I need to create 2 different layouts for them, then populate the view based on their type.
Next is creating a new adapter called DeviceListAdapter to bind the data:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
classDeviceListAdapter(privatevaldeviceList:ArrayList<Any>):Adapter<RecyclerView.ViewHolder>(){overridefungetItemViewType(position:Int):Int{}// Create new views (invoked by the layout manager)
overridefunonCreateViewHolder(viewGroup:ViewGroup,viewType:Int):RecyclerView.ViewHolder{}// Replace the contents of a view (invoked by the layout manager)
overridefunonBindViewHolder(holder:RecyclerView.ViewHolder,position:Int){}overridefungetItemCount():Int{returndeviceList.size}}
In this adapter, I need to do the below:
Override getItemViewType to check what kind of device, is it iBeacon or just BLE
in onCreateViewHolder, return different ViewHolders based on the ViewType
Populate corresponding data in onBindViewHolder
First I need to define constant for Viewtype outside of DeviceListAdapter class (to follow Kotlin style guide)
/**
* Get viewtype based on BLE device type
* If BLE device is iBeacon, use iBeacon layout
*/overridefungetItemViewType(position:Int):Int{valdev:Any=deviceList.get(position)if(devisIBeacon)returnVIEW_TYPE_IBEACONreturnVIEW_TYPE_BLE}// Create new views (invoked by the layout manager)
overridefunonCreateViewHolder(viewGroup:ViewGroup,viewType:Int):RecyclerView.ViewHolder{// Create a new view, which defines the UI of the list item
if(viewType==VIEW_TYPE_IBEACON){returnIBeaconViewHolder(LayoutInflater.from(viewGroup.context).inflate(R.layout.ibeacon_item,viewGroup,false))}returnBLEViewHolder(LayoutInflater.from(viewGroup.context).inflate(R.layout.ble_item,viewGroup,false))}// Replace the contents of a view (invoked by the layout manager)
overridefunonBindViewHolder(holder:RecyclerView.ViewHolder,position:Int){when(holder.itemViewType){VIEW_TYPE_IBEACON->{valiBeaconView=holderasIBeaconViewHoldervalibeacon=deviceList[position]asIBeaconiBeaconView.uuid.text=ibeacon.getUUID()iBeaconView.major.text=ibeacon.getMajor().toString()iBeaconView.minor.text=ibeacon.getMinor().toString()iBeaconView.address.text=ibeacon.getAddress()iBeaconView.rssi.text=ibeacon.getRssi().toString()}VIEW_TYPE_BLE->{valbleView=holderasBLEViewHoldervalble=deviceList[position]asBLEDevicebleView.address.text=ble.getAddress()bleView.rssi.text=ble.getRssi().toString()}}}
Logic improvement
BLEDevice and IBeacon model
In order to store data and display on UI, I need to update these 2 classes:
openclassBLEDevice(scanResult:ScanResult){/**
* The measured signal strength of the Bluetooth packet
*/privatevarrssi:Int=0/**
* Device mac address
*/privatevaraddress:String=""/**
* Device friendly name
*/privatevarname:String=""init{if(scanResult.device.name!=null){name=scanResult.device.name}address=scanResult.device.addressrssi=scanResult.rssi}fungetAddress():String{returnaddress}fungetRssi():Int{returnrssi}}
classIBeacon(scanResult:ScanResult,packetData:ByteArray):BLEDevice(scanResult){/**
* beacon UUID
*/privatevaruuid:String=""/**
* packet raw data
*/privatevarrawByteData:ByteArray=ByteArray(30)/**
* major minor, and their position (based on iBeacon specs)
*/privatevarmajor:Int?=nullprivatevalmajorPosStart=25privatevalmajorPosEnd=26privatevarminor:Int?=nullprivatevalminorPosStart=27privatevalminorPosEnd=28init{rawByteData=packetData}/**
* Parse iBeacon UUID from packet
*/privatefunparseUUID(){varstartByte=2while(startByte<=5){if(rawByteData[startByte+2].toInt()and0xff==0x02&&rawByteData[startByte+3].toInt()and0xff==0x15){valuuidBytes=ByteArray(16)System.arraycopy(rawByteData,startByte+4,uuidBytes,0,16)valhexString=ConversionUtils.bytesToHex(uuidBytes)if(!hexString.isNullOrEmpty()){uuid=hexString.substring(0,8)+"-"+hexString.substring(8,12)+"-"+hexString.substring(12,16)+"-"+hexString.substring(16,20)+"-"+hexString.substring(20,32)return}}startByte++}}/**
* UUID getter method
* if UUID is not calculated, calculate from packet raw data, then store to property
*/fungetUUID():String{if(uuid.isNullOrEmpty()){parseUUID()}returnuuid}/**
* Get iBeacon major
* if major is not calculated, calculate from packet raw data, then store to property
*/fungetMajor():Int{if(major==null)major=(rawByteData[majorPosStart].toInt()and0xff)*0x100+(rawByteData[majorPosEnd].toInt()and0xff)returnmajorasInt}/**
* Get iBeacon minor
* if minor is not calculated, calculate from packet raw data, then store to property
*/fungetMinor():Int{if(minor==null)minor=(rawByteData[minorPosStart].toInt()and0xff)*0x100+(rawByteData[minorPosEnd].toInt()and0xff)returnminorasInt}overridefuntoString():String{return"Major= "+major.toString()+" Minor= "+minor.toString()+" rssi="+getRssi()}}
Then when activity is created, initialize those properties. Remember to check for permissions before initializing scan service:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
overridefunonCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState)binding=ActivityMainBinding.inflate(layoutInflater)setContentView(binding.root)binding.scanBtn.setOnClickListener{startScan()}binding.exitBtn.setOnClickListener{exitApp()}valrecycleView:RecyclerView=findViewById(R.id.deviceList)deviceList=ArrayList()this.adapter=DeviceListAdapter(this.deviceList)recycleView.adapter=this.adapter// check for permission to scan BLE
if(isPermissionGranted(this)){Log.d(TAG,"@onCreate init scan service")scanService=ScanService(this,this.deviceList,this.adapter)}}
ScanService and beacon related logic
For scanning process, first I will change scan mode to Low latency. This mode will use more power and is preferable when app is in Foreground.
In ScanService constructor:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
constructor(context:Context,deviceList:ArrayList<Any>,adapter:DeviceListAdapter){this.deviceList=deviceListbuilder=ScanSettings.Builder()builder.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)Log.d(TAG,"set scan mode to low latency")this.adapter=adapterbluetoothManager=context.getSystemService(BluetoothManager::class.java)bluetoothAdapter=bluetoothManager.adapterif(bluetoothAdapter==null){// Device doesn't support Bluetooth
throwException("Device doesn't support Bluetooth")}if(isBluetoothEnabled()){bluetoothLeScanner=bluetoothAdapter.bluetoothLeScanner}}
Create a new initScanner, this method is called from MainAcitivity to initialize BLE scanner:
/**
* Start BLE scan using bluetoothLeScanner
* if app is not scanning, start scan by calling startScan and passing a callback method
* else do nothing (return)
* @return {none}
*/funstartBLEScan(){if(isScanning)returnLog.d(TAG,"@startBLEScan start beacon scan")isScanning=truetry{bluetoothLeScanner.startScan(null,builder.build(),leScanCallback)}catch(e:SecurityException){Log.e(TAG,"@startScan SecurityException: "+e.message)}}funstopBLEScan(){if(!isScanning)returnLog.d(TAG,"@startBLEScan start beacon scan")isScanning=falsebluetoothLeScanner.stopScan(leScanCallback)}
Whenever the app receives a BLE packet, I want to display it on UI. But if the packet came from device that is already in the list, I want to update the data only.
In this case, I will use add logic in checkDeviceExists by checking device address:
/**
* callback method when app detects BLE device
* parse packet to determine if device is iBeacon
* if device is iBeacon and device is not added in device list, then add to list
* if device exists in list, update latest value
*/privatevalleScanCallback=object: ScanCallback(){overridefunonScanResult(callbackType:Int,result:ScanResult?){if(result!=null){valscanRecord=result.scanRecordLog.e(TAG,"@result: "+result.device.address)super.onScanResult(callbackType,result)try{if(scanRecord!=null){if(isIBeacon(scanRecord.bytes)){valiBeacon=IBeacon(result,scanRecord.bytes)validx=checkDeviceExists(result)if(idx==-1){deviceList.add(iBeacon)}else{// update
deviceList[idx]=iBeacon}}else{valble=BLEDevice(result)validx=checkDeviceExists(result)if(idx==-1){deviceList.add(ble)}else{// update
deviceList[idx]=ble}}adapter.notifyDataSetChanged()}}catch(e:SecurityException){Log.e(TAG,"@startScan SecurityException: "+e.message)}}return}}/**
* check if our device list already has a scan result whose MAC address is identical to the new incoming ScanResult
* @param result BLE scan result
* @return -1 if doesn't exist
*/funcheckDeviceExists(result:ScanResult):Int{valindexQuery=deviceList.indexOfFirst{(itasBLEDevice).getAddress()==result.device.address}returnindexQuery}
Final result
Now build the project, load your app, and put an iBeacon nearby, you should see something like this:
If you want to see the sample code, here is the repository: Simple BLE Detection