content: package org.altbeacon.beacon.distance;
  
  import android.annotation.TargetApi;
  import android.content.Context;
  import android.content.SharedPreferences;
  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 = loadModelMapFromStorage();
              // 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 SharedPreferences getSharedPreferences() {
          return mContext.getSharedPreferences("org.altbeacon.beacon", Context.MODE_PRIVATE);
      }
  
      private boolean loadModelMapFromStorage() {
          String jsonString = getSharedPreferences().getString(CONFIG_FILE, null);
          if (jsonString == null) {
              return false;
          }
          try {
              buildModelMapWithLock(jsonString);
              return true;
          } catch (JSONException e) {
              LogManager.e(
                      e,
                      TAG,
                      "Cannot update distance models from online database at %s with JSON: %s",
                      mRemoteUpdateUrlString, jsonString
              );
              return false;
          }
      }
  
      private boolean saveJson(String jsonString) {
          getSharedPreferences().edit().putString(CONFIG_FILE, jsonString).commit();
          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 at %s", mRemoteUpdateUrlString);
                      try {
                          buildModelMapWithLock(body);
                          if (saveJson(body)) {
                              loadModelMapFromStorage();
                              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 {
          HashMap<AndroidModel, DistanceCalculator> map = 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);
              map.put(androidModel, distanceCalculator);
              if (defaultFlag) {
                  mDefaultModel = androidModel;
              }
          }
          mModelMap = map;
      }
  
      private void loadDefaultModelMap() {
          try {
              buildModelMap(stringFromFilePath(CONFIG_FILE));
          }
          catch (Exception e) {
              mModelMap = new HashMap<AndroidModel, DistanceCalculator>();
              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();
      }
  
  }
  