/** * Radius Networks, Inc. * http://www.radiusnetworks.com * * @author David G. Young * * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you 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 com.radiusnetworks.ibeacon.service; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import android.app.Activity; import android.app.Notification; import android.app.PendingIntent; import android.app.Service; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothManager; import android.content.Context; import android.content.Intent; import android.os.AsyncTask; import android.os.Binder; import android.os.Handler; import android.os.IBinder; import android.os.Message; import android.os.Messenger; import android.util.Log; import com.radiusnetworks.ibeacon.IBeacon; import com.radiusnetworks.ibeacon.Region; /** * Issues: * 1. If two apps register ranges with the same id, they clobber eachother. * 2. If an app goes away after staring monitoring or ranging, we will continue to make callbacks from the service * 3. Is sending so many intents efficient? * @author dyoung * * Differences from Apple's SDK: * 1. You can wildcard all fields in a region to get updates about ANY iBeacon * 2. Ranging updates don't come as reliably every second. * 3. The distance measurement algorithm is not exactly the same * 4. You can do ranging when the app is not in the foreground * 5. It requires Bluetooth Admin privilidges */ public class IBeaconService extends Service { public static final String TAG = "IBeaconService"; private Map<Region, RangeState> rangedRegionState = new HashMap<Region,RangeState>(); private Map<Region, MonitorState> monitoredRegionState = new HashMap<Region,MonitorState>(); private BluetoothAdapter bluetoothAdapter; private boolean scanning; private boolean scanningPaused; private Date lastIBeaconDetectionTime = new Date(); private HashSet<IBeacon> trackedBeacons; private Handler handler = new Handler(); private int bindCount = 0; /* * The scan period is how long we wait between restarting the BLE advertisement scans * Each time we restart we only see the unique advertisements once (e.g. unique iBeacons) * So if we want updates, we have to restart. iOS gets updates once per second, so ideally we * would restart scanning that often to get the same update rate. The trouble is that when you * restart scanning, it is not instantaneous, and you lose any iBeacon packets that were in the * air during the restart. So the more frequently you restart, the more packets you lose. The * frequency is therefore a tradeoff. Testing with 14 iBeacons, transmitting once per second, * here are the counts I got for various values of the SCAN_PERIOD: * * Scan period Avg iBeacons % missed * 1s 6 57 * 2s 10 29 * 3s 12 14 * 5s 14 0 * * Also, because iBeacons transmit once per second, the scan period should not be an even multiple * of seconds, because then it may always miss a beacon that is syncronized with when it is stopping * scanning. * */ private static final long SCAN_PERIOD = 2100; private static final long BACKGROUND_SCAN_PERIOD = 30000; private static final long BACKGROUND_BETWEEN_SCAN_PERIOD = 300000; // 5*60*1000; /** * Class used for the client Binder. Because we know this service always * runs in the same process as its clients, we don't need to deal with IPC. */ public class IBeaconBinder extends Binder { public IBeaconService getService() { Log.i(TAG, "getService of IBeaconBinder called"); // Return this instance of LocalService so clients can call public methods return IBeaconService.this; } } /** Command to the service to display a message */ public static final int MSG_START_RANGING = 2; public static final int MSG_STOP_RANGING = 3; public static final int MSG_START_MONITORING = 4; public static final int MSG_STOP_MONITORING = 5; static class IncomingHandler extends Handler { private final WeakReference<IBeaconService> mService; IncomingHandler(IBeaconService service) { mService = new WeakReference<IBeaconService>(service); } @Override public void handleMessage(Message msg) { IBeaconService service = mService.get(); StartRMData startRMData = (StartRMData) msg.obj; if (service != null) { switch (msg.what) { case MSG_START_RANGING: Log.d(TAG, "start ranging received"); service.startRangingBeaconsInRegion(startRMData.getRegionData(), new com.radiusnetworks.ibeacon.service.Callback(msg.replyTo, startRMData.getIntentActionForCallback())); break; case MSG_STOP_RANGING: Log.d(TAG, "stop ranging received"); service.stopRangingBeaconsInRegion(startRMData.getRegionData()); break; case MSG_START_MONITORING: Log.d(TAG, "start monitoring received"); service.startMonitoringBeaconsInRegion(startRMData.getRegionData(), new com.radiusnetworks.ibeacon.service.Callback(msg.replyTo, startRMData.getIntentActionForCallback())); break; case MSG_STOP_MONITORING: Log.d(TAG, "stop monitoring received"); service.stopMonitoringBeaconsInRegion(startRMData.getRegionData()); break; default: super.handleMessage(msg); } } } } /** * Target we publish for clients to send messages to IncomingHandler. */ final Messenger mMessenger = new Messenger(new IncomingHandler(this)); /** * When binding to the service, we return an interface to our messenger * for sending messages to the service. */ @Override public IBinder onBind(Intent intent) { Log.i(TAG, "binding"); bindCount++; return mMessenger.getBinder(); } @Override public boolean onUnbind (Intent intent) { Log.i(TAG, "unbind called"); bindCount--; return false; } @Override public void onCreate() { Log.i(TAG, "onCreate of IBeaconService called"); // Initializes Bluetooth adapter. final BluetoothManager bluetoothManager = (BluetoothManager) this.getApplicationContext().getSystemService(Context.BLUETOOTH_SERVICE); bluetoothAdapter = bluetoothManager.getAdapter(); } @Override public void onDestroy() { Log.i(TAG, "onDestory called. stopping scanning"); scanLeDevice(false); if (bluetoothAdapter != null) { bluetoothAdapter.stopLeScan(leScanCallback); } } private int ongoing_notification_id = 1; public void runInForeground(Class<? extends Activity> klass) { Intent notificationIntent = new Intent(this, klass); PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0); Notification notification = new Notification.Builder(this.getApplicationContext()) .setContentTitle("Scanning for iBeacons") .setSmallIcon(android.R.drawable.star_on) .addAction(android.R.drawable.star_off, "this is the other title", pendingIntent) .build(); startForeground(ongoing_notification_id++, notification); } /* * Returns true if the service is running, but no bound clients exist */ private boolean isInBackground() { Log.d(TAG, "bound client count:"+bindCount); return bindCount == 0; } /** methods for clients */ // TODO: make it so that regions between apps do not collide public void startRangingBeaconsInRegion(Region region, Callback callback) { if (rangedRegionState.containsKey(region)) { Log.d(TAG, "Already ranging that region -- will replace existing region."); rangedRegionState.remove(region); // need to remove it, otherwise the old object will be retained because they are .equal } rangedRegionState.put(region, new RangeState(callback)); if (!scanning) { scanLeDevice(true); } } public void stopRangingBeaconsInRegion(Region region) { rangedRegionState.remove(region); if (scanning && rangedRegionState.size() == 0 && monitoredRegionState.size() == 0) { scanLeDevice(false); } } public void startMonitoringBeaconsInRegion(Region region, Callback callback) { Log.d(TAG, "startMonitoring called"); if (monitoredRegionState.containsKey(region)) { Log.d(TAG, "Already monitoring that region -- will replace existing region monitor."); monitoredRegionState.remove(region); // need to remove it, otherwise the old object will be retained because they are .equal } monitoredRegionState.put(region, new MonitorState(callback)); Log.d(TAG, "Currently monitoring "+monitoredRegionState.size()+" regions."); if (!scanning) { scanLeDevice(true); } } public void stopMonitoringBeaconsInRegion(Region region) { Log.d(TAG, "stopMonitoring called"); monitoredRegionState.remove(region); Log.d(TAG, "Currently monitoring "+monitoredRegionState.size()+" regions."); if (scanning && rangedRegionState.size() == 0 && monitoredRegionState.size() == 0) { scanLeDevice(false); } } private void scanLeDevice(final Boolean enable) { if (bluetoothAdapter == null) { Log.e(TAG, "no bluetooth adapter. I cannot scan."); return; } if (enable) { // Stops scanning after a pre-defined scan period. long scanPeriod = SCAN_PERIOD; if (isInBackground()) { scanPeriod = BACKGROUND_SCAN_PERIOD; } handler.postDelayed(new Runnable() { @Override public void run() { Log.d(TAG, "Done with scan cycle"); if (scanning == true ) { processRangeData(); Log.d(TAG, "Restarting scan. Unique beacons seen last cycle: "+trackedBeacons.size()); bluetoothAdapter.stopLeScan(leScanCallback); scanningPaused = true; if (isInBackground()) { Log.d(TAG, "We are in the background. Waiting a little bit before scanning again."); handler.postDelayed(new Runnable() { @Override public void run() { scanLeDevice(true); } }, BACKGROUND_BETWEEN_SCAN_PERIOD); } else { scanLeDevice(true); } } } }, scanPeriod); trackedBeacons = new HashSet<IBeacon>(); if (scanning == false || scanningPaused == true) { scanning = true; scanningPaused = false; try { bluetoothAdapter.startLeScan(leScanCallback); } catch (Exception e) { Log.e("TAG", "Exception starting bluetooth scan. Perhaps bluetooth is disabled or unavailable?"); } } else { Log.d(TAG, "We are already scanning"); } Log.d(TAG, "Scan started"); } else { Log.d(TAG, "disabling scan"); scanning = false; bluetoothAdapter.stopLeScan(leScanCallback); } processExpiredMonitors(); } // Device scan callback. private BluetoothAdapter.LeScanCallback leScanCallback = new BluetoothAdapter.LeScanCallback() { @Override public void onLeScan(final BluetoothDevice device, final int rssi, final byte[] scanRecord) { Log.d(TAG, "got record"); new ScanProcessor().execute(new ScanData(device, rssi, scanRecord)); } }; private class ScanData { public ScanData(BluetoothDevice device, int rssi, byte[] scanRecord) { this.device = device; this.rssi = rssi; this.scanRecord = scanRecord; } @SuppressWarnings("unused") public BluetoothDevice device; public int rssi; public byte[] scanRecord; } private void processRangeData() { Iterator<Region> regionIterator = rangedRegionState.keySet().iterator(); while (regionIterator.hasNext()) { Region region = regionIterator.next(); RangeState rangeState = rangedRegionState.get(region); Log.d(TAG, "Calling ranging callback with "+rangeState.getIBeacons().size()+" iBeacons"); rangeState.getCallback().call(IBeaconService.this, "monitoringData", new RangingData(rangeState.getIBeacons(), region)); rangeState.clearIBeacons(); } } private void processExpiredMonitors() { Iterator<Region> monitoredRegionIterator = monitoredRegionState.keySet().iterator(); while (monitoredRegionIterator.hasNext()) { Region region = monitoredRegionIterator.next(); MonitorState state = monitoredRegionState.get(region); if (state.isNewlyOutside()) { Log.d(TAG, "found a monitor that expired: "+region); state.getCallback().call(IBeaconService.this, "monitoringData", new MonitoringData(state.isInside(), region)); } } } private class ScanProcessor extends AsyncTask<ScanData, Void, Void> { @Override protected Void doInBackground(ScanData... params) { ScanData scanData = params[0]; IBeacon iBeacon = IBeacon.fromScanData(scanData.scanRecord, scanData.rssi); if (iBeacon != null) { lastIBeaconDetectionTime = new Date(); trackedBeacons.add(iBeacon); Log.d(TAG, "iBeacon detected :"+iBeacon.getProximityUuid()+" "+iBeacon.getMajor()+" "+iBeacon.getMinor()+" accuracy: "+iBeacon.getAccuracy()+" proximity: "+iBeacon.getProximity()); List<Region> matchedRegions = matchingRegions(iBeacon, monitoredRegionState.keySet()); Iterator<Region> matchedRegionIterator = matchedRegions.iterator(); while (matchedRegionIterator.hasNext()) { Region region = matchedRegionIterator.next(); MonitorState state = monitoredRegionState.get(region); if (state.markInside()) { state.getCallback().call(IBeaconService.this, "monitoringData", new MonitoringData(state.isInside(), region)); } } Log.d(TAG, "looking for ranging region matches for this ibeacon"); matchedRegions = matchingRegions(iBeacon, rangedRegionState.keySet()); matchedRegionIterator = matchedRegions.iterator(); while (matchedRegionIterator.hasNext()) { Region region = matchedRegionIterator.next(); Log.d(TAG, "matches ranging region: "+region); RangeState rangeState = rangedRegionState.get(region); rangeState.addIBeacon(iBeacon); } } //I see a device: 00:02:72:C5:EC:33 with scan data: 02 01 1A 1A FF 4C 00 02 15 84 2A F9 C4 08 F5 11 E3 92 82 F2 3C 91 AE C0 5E D0 00 00 69 C5 0000000000000000000000000000000000000000000000000000000000000000 // // 9: proximityUuid (16 bytes) 84 2A F9 C4 08 F5 11 E3 92 82 F2 3C 91 AE C0 5E // 25: major (2 bytes unsigned int) // 27: minor (2 bytes unsigned int) // 29: tx power (1 byte signed int) return null; } @Override protected void onPostExecute(Void result) { } @Override protected void onPreExecute() { } @Override protected void onProgressUpdate(Void... values) { } } private List<Region> matchingRegions(IBeacon iBeacon, Collection<Region> regions) { List<Region> matched = new ArrayList<Region>(); Iterator<Region> regionIterator = regions.iterator(); while (regionIterator.hasNext()) { Region region = regionIterator.next(); if (region.matchesIBeacon(iBeacon)) { matched.add(region); } else { Log.d(TAG, "This region does not match: "+region); } } return matched; } }