package org.altbeacon.beacon.distance;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.AsyncTask;
import android.os.Build;
import org.altbeacon.beacon.logging.LogManager;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
/**
* Obtains a <code>DistanceCalculator</code> appropriate for a specific Android model. Each model
* may have a different Bluetooth chipset, radio and antenna and sees a different signal level
* at the same distance, therefore requiring a different equation coefficients for each model.
*
* This class uses a configuration table to look for a matching Android device model for which
* coefficients are known. If an exact match cannot be found, this class will attempt to find the
* closest match possible based on the assumption that an unknown model made by Samsung, for example
* might have a different signal response as a known device model also made by Samsung.
*
* If no match can be found at all, the device model marked as the default will be used for the
* calculation.
*
* The configuration table is stored in model-distance-calculations.json
*
* For information on how to get new Android device models added to this table, please
* see <a href='http://altbeacon.github.io/android-beacon-library/distance-calculations.html'
* Optimizing Distance Calculations</a>
*
* Created by dyoung on 8/28/14.
*/
public class ModelSpecificDistanceCalculator implements DistanceCalculator {
Map<AndroidModel,DistanceCalculator> mModelMap;
private static final String CONFIG_FILE = "model-distance-calculations.json";
private static final String TAG = "ModelSpecificDistanceCalculator";
private AndroidModel mDefaultModel;
private DistanceCalculator mDistanceCalculator;
private AndroidModel mModel;
private AndroidModel mRequestedModel;
private String mRemoteUpdateUrlString = null;
private Context mContext;
private final ReentrantLock mLock = new ReentrantLock();
/**
* Obtains the best possible <code>DistanceCalculator</code> for the Android device calling
* the constructor
*/
public ModelSpecificDistanceCalculator(Context context, String remoteUpdateUrlString) {
this(context, remoteUpdateUrlString, AndroidModel.forThisDevice());
}
/**
* Obtains the best possible <code>DistanceCalculator</code> for the Android device passed
* as an argument
*/
public ModelSpecificDistanceCalculator(Context context, String remoteUpdateUrlString, AndroidModel model) {
mRequestedModel = model;
mRemoteUpdateUrlString = remoteUpdateUrlString;
mContext = context;
loadModelMap();
mDistanceCalculator = findCalculatorForModelWithLock(model);
}
/**
* @return the Android device model used for distance calculations
*/
public AndroidModel getModel() {
return mModel;
}
/**
* @return the Android device model requested to be used for distance calculations
*/
public AndroidModel getRequestedModel() {
return mRequestedModel;
}
@Override
public double calculateDistance(int txPower, double rssi) {
if (mDistanceCalculator == null) {
LogManager.w(TAG, "distance calculator has not been set");
return -1.0;
}
return mDistanceCalculator.calculateDistance(txPower, rssi);
}
DistanceCalculator findCalculatorForModelWithLock(AndroidModel model) {
mLock.lock();
try {
return findCalculatorForModel(model);
} finally {
mLock.unlock();
}
}
private DistanceCalculator findCalculatorForModel(AndroidModel model) {
LogManager.d(TAG, "Finding best distance calculator for %s, %s, %s, %s",
model.getVersion(), model.getBuildNumber(), model.getModel(),
model.getManufacturer());
if (mModelMap == null) {
LogManager.d(TAG, "Cannot get distance calculator because modelMap was never initialized");
return null;
}
int highestScore = 0;
AndroidModel bestMatchingModel = null;
for (AndroidModel candidateModel : mModelMap.keySet()) {
if (candidateModel.matchScore(model) > highestScore) {
highestScore = candidateModel.matchScore(model);
bestMatchingModel = candidateModel;
}
}
if (bestMatchingModel != null) {
LogManager.d(TAG, "found a match with score %s", highestScore);
LogManager.d(TAG, "Finding best distance calculator for %s, %s, %s, %s",
bestMatchingModel.getVersion(), bestMatchingModel.getBuildNumber(),
bestMatchingModel.getModel(), bestMatchingModel.getManufacturer());
mModel = bestMatchingModel;
} else {
mModel = mDefaultModel;
LogManager.w(TAG, "Cannot find match for this device. Using default");
}
return mModelMap.get(mModel);
}
private void loadModelMap() {
boolean mapLoaded = false;
if (mRemoteUpdateUrlString != null) {
mapLoaded = loadModelMapFromFile();
// We only want to try to download an update from the server the first time the app is
// run. If we successfully download an update it gets saved to a file, so if the file
// is present that means should not download again.
if (!mapLoaded) {
requestModelMapFromWeb();
}
}
if (!mapLoaded) {
loadDefaultModelMap();
}
mDistanceCalculator = findCalculatorForModelWithLock(mRequestedModel);
}
private boolean loadModelMapFromFile() {
File file = new File(mContext.getFilesDir(), CONFIG_FILE);
FileInputStream inputStream = null;
BufferedReader reader = null;
StringBuilder sb = new StringBuilder();
try {
inputStream = new FileInputStream(file);
reader = new BufferedReader(new InputStreamReader(inputStream));
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
}
catch (FileNotFoundException fnfe){
//This occurs on the first time the app is run, no error message necessary.
return false;
}
catch (IOException e) {
LogManager.e(e, TAG, "Cannot open distance model file %s", file);
return false;
}
finally {
if (reader != null) {
try { reader.close(); } catch (Exception e2) {}
}
if (inputStream != null) {
try { inputStream.close(); } catch (Exception e2) {}
}
}
try {
buildModelMapWithLock(sb.toString());
return true;
} catch (JSONException e) {
LogManager.e(TAG, "Cannot update distance models from online database at %s with JSON",
e, mRemoteUpdateUrlString, sb.toString());
return false;
}
}
private boolean saveJson(String jsonString) {
FileOutputStream outputStream = null;
try {
outputStream = mContext.openFileOutput(CONFIG_FILE, Context.MODE_PRIVATE);
outputStream.write(jsonString.getBytes());
outputStream.close();
} catch (Exception e) {
LogManager.w(e, TAG, "Cannot write updated distance model to local storage");
return false;
}
finally {
try {
if (outputStream != null) outputStream.close();
}
catch (Exception e) {}
}
LogManager.i(TAG, "Successfully saved new distance model file");
return true;
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
private void requestModelMapFromWeb() {
if (mContext.checkCallingOrSelfPermission("android.permission.INTERNET") != PackageManager.PERMISSION_GRANTED) {
LogManager.w(TAG, "App has no android.permission.INTERNET permission. Cannot check for distance model updates");
return;
}
new ModelSpecificDistanceUpdater(mContext, mRemoteUpdateUrlString,
new ModelSpecificDistanceUpdater.CompletionHandler() {
@Override
public void onComplete(String body, Exception ex, int code) {
if (ex != null) {
LogManager.w(TAG, "Cannot updated distance models from online database at %s",
ex, mRemoteUpdateUrlString);
}
else if (code != 200) {
LogManager.w(TAG, "Cannot updated distance models from online database at %s "
+ "due to HTTP status code %s", mRemoteUpdateUrlString, code);
}
else {
LogManager.d(TAG,
"Successfully downloaded distance models from online database");
try {
buildModelMapWithLock(body);
if (saveJson(body)) {
loadModelMapFromFile();
mDistanceCalculator = findCalculatorForModelWithLock(mRequestedModel);
LogManager.i(TAG, "Successfully updated distance model with latest from online database");
}
} catch (JSONException e) {
LogManager.w(e, TAG, "Cannot parse json from downloaded distance model");
}
}
}
}).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
void buildModelMapWithLock(String jsonString) throws JSONException {
mLock.lock();
try {
buildModelMap(jsonString);
} finally {
mLock.unlock();
}
}
private void buildModelMap(String jsonString) throws JSONException {
mModelMap = new HashMap<AndroidModel, DistanceCalculator>();
JSONObject jsonObject = new JSONObject(jsonString);
JSONArray array = jsonObject.getJSONArray("models");
for (int i = 0; i < array.length(); i++) {
JSONObject modelObject = array.getJSONObject(i);
boolean defaultFlag = false;
if (modelObject.has("default")) {
defaultFlag = modelObject.getBoolean("default");
}
Double coefficient1 = modelObject.getDouble("coefficient1");
Double coefficient2 = modelObject.getDouble("coefficient2");
Double coefficient3 = modelObject.getDouble("coefficient3");
String version = modelObject.getString("version");
String buildNumber = modelObject.getString("build_number");
String model = modelObject.getString("model");
String manufacturer = modelObject.getString("manufacturer");
CurveFittedDistanceCalculator distanceCalculator =
new CurveFittedDistanceCalculator(coefficient1,coefficient2,coefficient3);
AndroidModel androidModel = new AndroidModel(version, buildNumber, model, manufacturer);
mModelMap.put(androidModel, distanceCalculator);
if (defaultFlag) {
mDefaultModel = androidModel;
}
}
}
private void loadDefaultModelMap() {
mModelMap = new HashMap<AndroidModel, DistanceCalculator>();
try {
buildModelMap(stringFromFilePath(CONFIG_FILE));
}
catch (Exception e) {
LogManager.e(e, TAG, "Cannot build model distance calculations");
}
}
private String stringFromFilePath(String path) throws IOException {
InputStream stream = null;
BufferedReader bufferedReader = null;
StringBuilder inputStringBuilder = new StringBuilder();
try {
stream = ModelSpecificDistanceCalculator.class.getResourceAsStream("/" + path);
if (stream == null) {
stream = this.getClass().getClassLoader().getResourceAsStream("/" + path);
}
if (stream == null) {
throw new RuntimeException("Cannot load resource at " + path);
}
bufferedReader = new BufferedReader(new InputStreamReader(stream, "UTF-8"));
String line = bufferedReader.readLine();
while(line != null){
inputStringBuilder.append(line);inputStringBuilder.append('\n');
line = bufferedReader.readLine();
}
}
finally {
if (bufferedReader != null) {
bufferedReader.close();
}
if (stream != null) {
stream.close();
}
}
return inputStringBuilder.toString();
}
}