/**
* 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 android.annotation.TargetApi;
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.content.pm.ApplicationInfo;
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.bluetooth.BluetoothCrashResolver;
import com.radiusnetworks.ibeacon.IBeacon;
import com.radiusnetworks.ibeacon.IBeaconManager;
import com.radiusnetworks.ibeacon.Region;
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;
/**
* @author dyoung
*/
@TargetApi(5)
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;
int trackedBeaconsPacketCount;
private Handler handler = new Handler();
private int bindCount = 0;
private BluetoothCrashResolver bluetoothCrashResolver;
private boolean scanCyclerStarted = false;
private boolean scanningEnabled = false;
/*
* 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 synchronized with when it is stopping
* scanning.
*
*/
private long scanPeriod = IBeaconManager.DEFAULT_FOREGROUND_SCAN_PERIOD;
private long betweenScanPeriod = IBeaconManager.DEFAULT_FOREGROUND_BETWEEN_SCAN_PERIOD;
private List<IBeacon> simulatedScanData = null;
/**
* 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;
public static final int MSG_SET_SCAN_PERIODS = 6;
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.i(TAG, "start ranging received");
service.startRangingBeaconsInRegion(startRMData.getRegionData(), new com.radiusnetworks.ibeacon.service.Callback(startRMData.getCallbackPackageName()));
service.setScanPeriods(startRMData.getScanPeriod(), startRMData.getBetweenScanPeriod());
break;
case MSG_STOP_RANGING:
Log.i(TAG, "stop ranging received");
service.stopRangingBeaconsInRegion(startRMData.getRegionData());
service.setScanPeriods(startRMData.getScanPeriod(), startRMData.getBetweenScanPeriod());
break;
case MSG_START_MONITORING:
Log.i(TAG, "start monitoring received");
service.startMonitoringBeaconsInRegion(startRMData.getRegionData(), new com.radiusnetworks.ibeacon.service.Callback(startRMData.getCallbackPackageName()));
service.setScanPeriods(startRMData.getScanPeriod(), startRMData.getBetweenScanPeriod());
break;
case MSG_STOP_MONITORING:
Log.i(TAG, "stop monitoring received");
service.stopMonitoringBeaconsInRegion(startRMData.getRegionData());
service.setScanPeriods(startRMData.getScanPeriod(), startRMData.getBetweenScanPeriod());
break;
case MSG_SET_SCAN_PERIODS:
Log.i(TAG, "set scan intervals received");
service.setScanPeriods(startRMData.getScanPeriod(), startRMData.getBetweenScanPeriod());
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, "unbinding");
bindCount--;
return false;
}
@Override
public void onCreate() {
Log.i(TAG, "iBeaconService is starting up");
getBluetoothAdapter();
bluetoothCrashResolver = new BluetoothCrashResolver(this);
bluetoothCrashResolver.start();
// Look for simulated scan data
try {
Class klass = Class.forName("com.radiusnetworks.ibeacon.SimulatedScanData");
java.lang.reflect.Field f = klass.getField("iBeacons");
this.simulatedScanData = (List<IBeacon>) f.get(null);
} catch (ClassNotFoundException e) {
if (IBeaconManager.debug)
Log.d(TAG, "No com.radiusnetworks.ibeacon.SimulatedScanData class exists.");
} catch (Exception e) {
Log.e(TAG, "Cannot get simulated Scan data. Make sure your com.radiusnetworks.ibeacon.SimulatedScanData class defines a field with the signature 'public static List<IBeacon> iBeacons'", e);
}
}
@Override
@TargetApi(18)
public void onDestroy() {
if (android.os.Build.VERSION.SDK_INT < 18) {
Log.w(TAG, "Not supported prior to API 18.");
return;
}
bluetoothCrashResolver.stop();
Log.i(TAG, "onDestroy called. stopping scanning");
handler.removeCallbacksAndMessages(null);
scanLeDevice(false);
if (bluetoothAdapter != null) {
bluetoothAdapter.stopLeScan((BluetoothAdapter.LeScanCallback)getLeScanCallback());
lastScanEndTime = new Date().getTime();
}
}
private int ongoing_notification_id = 1;
/*
* Returns true if the service is running, but all bound clients have indicated they are in the background
*/
private boolean isInBackground() {
if (IBeaconManager.debug) Log.d(TAG, "bound client count:" + bindCount);
return bindCount == 0;
}
/**
* methods for clients
*/
public void startRangingBeaconsInRegion(Region region, Callback callback) {
synchronized (rangedRegionState) {
if (rangedRegionState.containsKey(region)) {
Log.i(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 (IBeaconManager.debug)
Log.d(TAG, "Currently ranging " + rangedRegionState.size() + " regions.");
if (!scanningEnabled) {
enableScanning();
}
}
public void stopRangingBeaconsInRegion(Region region) {
synchronized (rangedRegionState) {
rangedRegionState.remove(region);
}
if (IBeaconManager.debug)
Log.d(TAG, "Currently ranging " + rangedRegionState.size() + " regions.");
if (scanningEnabled && rangedRegionState.size() == 0 && monitoredRegionState.size() == 0) {
disableScanning();
}
}
public void startMonitoringBeaconsInRegion(Region region, Callback callback) {
if (IBeaconManager.debug) Log.d(TAG, "startMonitoring called");
synchronized (monitoredRegionState) {
if (monitoredRegionState.containsKey(region)) {
Log.i(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));
}
if (IBeaconManager.debug)
Log.d(TAG, "Currently monitoring " + monitoredRegionState.size() + " regions.");
if (!scanningEnabled) {
enableScanning();
}
}
public void stopMonitoringBeaconsInRegion(Region region) {
if (IBeaconManager.debug) Log.d(TAG, "stopMonitoring called");
synchronized (monitoredRegionState) {
monitoredRegionState.remove(region);
}
if (IBeaconManager.debug)
Log.d(TAG, "Currently monitoring " + monitoredRegionState.size() + " regions.");
if (scanningEnabled && rangedRegionState.size() == 0 && monitoredRegionState.size() == 0) {
disableScanning();
}
}
public void setScanPeriods(long scanPeriod, long betweenScanPeriod) {
this.scanPeriod = scanPeriod;
this.betweenScanPeriod = betweenScanPeriod;
long now = new Date().getTime();
if (nextScanStartTime > now) {
// We are waiting to start scanning. We may need to adjust the next start time
// only do an adjustment if we need to make it happen sooner. Otherwise, it will
// take effect on the next cycle.
long proposedNextScanStartTime = (lastScanEndTime + betweenScanPeriod);
if (proposedNextScanStartTime < nextScanStartTime) {
nextScanStartTime = proposedNextScanStartTime;
Log.i(TAG, "Adjusted nextScanStartTime to be " + new Date(nextScanStartTime));
}
}
if (scanStopTime > now) {
// we are waiting to stop scanning. We may need to adjust the stop time
// only do an adjustment if we need to make it happen sooner. Otherwise, it will
// take effect on the next cycle.
long proposedScanStopTime = (lastScanStartTime + scanPeriod);
if (proposedScanStopTime < scanStopTime) {
scanStopTime = proposedScanStopTime;
Log.i(TAG, "Adjusted scanStopTime to be " + new Date(scanStopTime));
}
}
}
private long lastScanStartTime = 0l;
private long lastScanEndTime = 0l;
private long nextScanStartTime = 0l;
private long scanStopTime = 0l;
public void enableScanning() {
scanningEnabled = true;
if (!scanCyclerStarted) {
scanLeDevice(true);
}
}
public void disableScanning() {
scanningEnabled = false;
}
@TargetApi(18)
private void scanLeDevice(final Boolean enable) {
scanCyclerStarted = true;
if (android.os.Build.VERSION.SDK_INT < 18) {
Log.w(TAG, "Not supported prior to API 18.");
return;
}
if (getBluetoothAdapter() == null) {
Log.e(TAG, "No bluetooth adapter. iBeaconService cannot scan.");
if ((simulatedScanData == null) && (IBeaconManager.getBeaconSimulator() == null)) {
Log.w(TAG, "exiting");
return;
} else {
Log.w(TAG, "proceeding with simulated scan data");
}
}
if (enable) {
long millisecondsUntilStart = nextScanStartTime - (new Date().getTime());
if (millisecondsUntilStart > 0) {
if (IBeaconManager.debug)
Log.d(TAG, "Waiting to start next bluetooth scan for another " + millisecondsUntilStart + " milliseconds");
// Don't actually wait until the next scan time -- only wait up to 1 second. this
// allows us to start scanning sooner if a consumer enters the foreground and expects
// results more quickly
handler.postDelayed(new Runnable() {
@Override
public void run() {
scanLeDevice(true);
}
}, millisecondsUntilStart > 1000 ? 1000 : millisecondsUntilStart);
return;
}
trackedBeacons = new HashSet<IBeacon>();
trackedBeaconsPacketCount = 0;
if (scanning == false || scanningPaused == true) {
scanning = true;
scanningPaused = false;
try {
if (getBluetoothAdapter() != null) {
if (getBluetoothAdapter().isEnabled()) {
if (bluetoothCrashResolver.isRecoveryInProgress()) {
Log.w(TAG, "Skipping scan because crash recovery is in progress.");
}
else {
if (scanningEnabled) {
getBluetoothAdapter().startLeScan((BluetoothAdapter.LeScanCallback)getLeScanCallback());
}
else {
if (IBeaconManager.debug) Log.d(TAG, "Scanning unnecessary - no monitoring or ranging active.");
}
}
lastScanStartTime = new Date().getTime();
} else {
Log.w(TAG, "Bluetooth is disabled. Cannot scan for iBeacons.");
}
}
} catch (Exception e) {
Log.e("TAG", "Exception starting bluetooth scan. Perhaps bluetooth is disabled or unavailable?");
}
} else {
if (IBeaconManager.debug) Log.d(TAG, "We are already scanning");
}
scanStopTime = (new Date().getTime() + scanPeriod);
scheduleScanStop();
if (IBeaconManager.debug) Log.d(TAG, "Scan started");
} else {
if (IBeaconManager.debug) Log.d(TAG, "disabling scan");
scanning = false;
if (getBluetoothAdapter() != null) {
getBluetoothAdapter().stopLeScan((BluetoothAdapter.LeScanCallback)getLeScanCallback());
lastScanEndTime = new Date().getTime();
}
}
}
private void scheduleScanStop() {
// Stops scanning after a pre-defined scan period.
long millisecondsUntilStop = scanStopTime - (new Date().getTime());
if (millisecondsUntilStop > 0) {
if (IBeaconManager.debug)
Log.d(TAG, "Waiting to stop scan for another " + millisecondsUntilStop + " milliseconds");
handler.postDelayed(new Runnable() {
@Override
public void run() {
scheduleScanStop();
}
}, millisecondsUntilStop > 1000 ? 1000 : millisecondsUntilStop);
} else {
finishScanCycle();
}
}
@TargetApi(18)
private void finishScanCycle() {
if (android.os.Build.VERSION.SDK_INT < 18) {
Log.w(TAG, "Not supported prior to API 18.");
return;
}
if (IBeaconManager.debug) Log.d(TAG, "Done with scan cycle");
processExpiredMonitors();
if (scanning == true) {
processRangeData();
// If we want to use simulated scanning data, do it here. This is used for testing in an emulator
if (simulatedScanData != null) {
// if simulatedScanData is provided, it will be seen every scan cycle. *in addition* to anything actually seen in the air
// it will not be used if we are not in debug mode
Log.w(TAG, "Simulated scan data is deprecated and will be removed in a future release. Please use the new BeaconSimulator interface instead.");
if (0 != (getApplicationInfo().flags &= ApplicationInfo.FLAG_DEBUGGABLE)) {
for (IBeacon iBeacon : simulatedScanData) {
processIBeaconFromScan(iBeacon);
}
} else {
Log.w(TAG, "Simulated scan data provided, but ignored because we are not running in debug mode. Please remove simulated scan data for production.");
}
}
if (IBeaconManager.getBeaconSimulator() != null) {
// if simulatedScanData is provided, it will be seen every scan cycle. *in addition* to anything actually seen in the air
// it will not be used if we are not in debug mode
if (IBeaconManager.getBeaconSimulator().getBeacons() != null){
if (0 != (getApplicationInfo().flags &= ApplicationInfo.FLAG_DEBUGGABLE)) {
for (IBeacon iBeacon : IBeaconManager.getBeaconSimulator().getBeacons()) {
processIBeaconFromScan(iBeacon);
}
} else {
Log.w(TAG, "Beacon simulations provided, but ignored because we are not running in debug mode. Please remove beacon simulations for production.");
}
} else {
Log.w(TAG, "getBeacons is returning null. No simulated beacons to report.");
}
}
if (getBluetoothAdapter() != null) {
if (getBluetoothAdapter().isEnabled()) {
getBluetoothAdapter().stopLeScan((BluetoothAdapter.LeScanCallback)getLeScanCallback());
lastScanEndTime = new Date().getTime();
} else {
Log.w(TAG, "Bluetooth is disabled. Cannot scan for iBeacons.");
}
}
if (!anyRangingOrMonitoringRegionsActive()) {
if (IBeaconManager.debug)
Log.d(TAG, "Not starting scan because no monitoring or ranging regions are defined.");
scanCyclerStarted = false;
} else {
if (IBeaconManager.debug)
Log.d(TAG, "Restarting scan. Unique beacons seen last cycle: " + trackedBeacons.size()+" Total iBeacon advertisement packets seen: "+trackedBeaconsPacketCount);
scanningPaused = true;
nextScanStartTime = (new Date().getTime() + betweenScanPeriod);
if (scanningEnabled) {
scanLeDevice(true);
}
else {
if (IBeaconManager.debug) Log.d(TAG, "Scanning disabled. No ranging or monitoring regions are active.");
scanCyclerStarted = false;
}
}
}
}
private Object leScanCallback;
@TargetApi(18)
private Object getLeScanCallback() {
if (leScanCallback == null) {
leScanCallback =
new BluetoothAdapter.LeScanCallback() {
@Override
public void onLeScan(final BluetoothDevice device, final int rssi,
final byte[] scanRecord) {
if (IBeaconManager.debug) Log.d(TAG, "got record");
new ScanProcessor().execute(new ScanData(device, rssi, scanRecord));
}
};
}
return leScanCallback;
}
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);
if (IBeaconManager.debug)
Log.d(TAG, "Calling ranging callback with " + rangeState.getIBeacons().size() + " iBeacons");
rangeState.getCallback().call(IBeaconService.this, "rangingData", new RangingData(rangeState.getIBeacons(), region));
synchronized (rangeState) {
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()) {
if (IBeaconManager.debug) Log.d(TAG, "found a monitor that expired: " + region);
state.getCallback().call(IBeaconService.this, "monitoringData", new MonitoringData(state.isInside(), region));
}
}
}
private void processIBeaconFromScan(IBeacon iBeacon) {
lastIBeaconDetectionTime = new Date();
trackedBeaconsPacketCount++;
if (trackedBeacons.contains(iBeacon)) {
if (IBeaconManager.debug) Log.d(TAG,
"iBeacon detected multiple times in scan cycle :" + iBeacon.getProximityUuid() + " "
+ iBeacon.getMajor() + " " + iBeacon.getMinor()
+ " accuracy: " + iBeacon.getAccuracy()
+ " proximity: " + iBeacon.getProximity());
}
trackedBeacons.add(iBeacon);
if (IBeaconManager.debug) Log.d(TAG,
"iBeacon detected :" + iBeacon.getProximityUuid() + " "
+ iBeacon.getMajor() + " " + iBeacon.getMinor()
+ " accuracy: " + iBeacon.getAccuracy()
+ " proximity: " + iBeacon.getProximity());
List<Region> matchedRegions = null;
synchronized(monitoredRegionState) {
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));
}
}
if (IBeaconManager.debug)
Log.d(TAG, "looking for ranging region matches for this ibeacon");
synchronized (rangedRegionState) {
matchedRegions = matchingRegions(iBeacon, rangedRegionState.keySet());
}
matchedRegionIterator = matchedRegions.iterator();
while (matchedRegionIterator.hasNext()) {
Region region = matchedRegionIterator.next();
if (IBeaconManager.debug) Log.d(TAG, "matches ranging region: " + region);
RangeState rangeState = rangedRegionState.get(region);
synchronized (rangeState) {
rangeState.addIBeacon(iBeacon);
}
}
}
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, scanData.device);
if (iBeacon != null) {
processIBeaconFromScan(iBeacon);
}
bluetoothCrashResolver.notifyScannedDevice(scanData.device, (BluetoothAdapter.LeScanCallback)getLeScanCallback());
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 {
if (IBeaconManager.debug) Log.d(TAG, "This region does not match: " + region);
}
}
return matched;
}
/*
Returns false if no ranging or monitoring regions have beeen requested. This is useful in determining if we should scan at all.
*/
private boolean anyRangingOrMonitoringRegionsActive() {
return (rangedRegionState.size() + monitoredRegionState.size()) > 0;
}
@TargetApi(18)
private BluetoothAdapter getBluetoothAdapter() {
if (android.os.Build.VERSION.SDK_INT < 18) {
Log.w(TAG, "Not supported prior to API 18.");
return null;
}
if (bluetoothAdapter == null) {
// Initializes Bluetooth adapter.
final BluetoothManager bluetoothManager =
(BluetoothManager) this.getApplicationContext().getSystemService(Context.BLUETOOTH_SERVICE);
bluetoothAdapter = bluetoothManager.getAdapter();
}
return bluetoothAdapter;
}
}