code: /*
   * Copyright 2014 Google Inc. All rights reserved.
   *
   * Licensed 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 org.physical_web.physicalweb;
  
  import org.physical_web.collection.PhysicalWebCollection;
  import org.physical_web.collection.PhysicalWebCollectionException;
  import org.physical_web.collection.PwPair;
  import org.physical_web.collection.PwsResult;
  import org.physical_web.collection.PwsResultCallback;
  import org.physical_web.collection.PwsResultIconCallback;
  import org.physical_web.collection.UrlDevice;
  
  import android.app.Notification;
  import android.app.PendingIntent;
  import android.app.Service;
  import android.content.Intent;
  import android.content.SharedPreferences;
  import android.content.res.Resources;
  import android.graphics.Color;
  import android.os.Binder;
  import android.os.Build;
  import android.os.Handler;
  import android.os.IBinder;
  import android.preference.PreferenceManager;
  import android.support.v4.app.NotificationCompat;
  import android.support.v4.app.NotificationManagerCompat;
  import android.view.View;
  import android.widget.RemoteViews;
  
  import org.json.JSONException;
  import org.json.JSONObject;
  
  import java.util.ArrayList;
  import java.util.Collection;
  import java.util.Date;
  import java.util.List;
  import java.util.concurrent.TimeUnit;
  
  /**
   * This is a service that scans for nearby Physical Web Objects.
   * It is created by MainActivity.
   * It finds nearby ble beacons,
   * and stores a count of them.
   * It also listens for screen on/off events
   * and start/stops the scanning accordingly.
   * It also silently issues a notification
   * informing the user of nearby beacons.
   * As beacons are found and lost,
   * the notification is updated to reflect
   * the current number of nearby beacons.
   */
  
  public class UrlDeviceDiscoveryService extends Service
                                         implements UrlDeviceDiscoverer.UrlDeviceDiscoveryCallback {
  
    private static final String TAG = UrlDeviceDiscoveryService.class.getSimpleName();
    private static final String NOTIFICATION_GROUP_KEY = "URI_BEACON_NOTIFICATIONS";
    private static final String PREFS_VERSION_KEY = "prefs_version";
    private static final String SCAN_START_TIME_KEY = "scan_start_time";
    private static final String PW_COLLECTION_KEY = "pw_collection";
    private static final int PREFS_VERSION = 2;
    private static final int NEAREST_BEACON_NOTIFICATION_ID = 23;
    private static final int SECOND_NEAREST_BEACON_NOTIFICATION_ID = 24;
    private static final int SUMMARY_NOTIFICATION_ID = 25;
    private static final int NON_LOLLIPOP_NOTIFICATION_TITLE_COLOR = Color.parseColor("#ffffff");
    private static final int NON_LOLLIPOP_NOTIFICATION_URL_COLOR = Color.parseColor("#999999");
    private static final int NON_LOLLIPOP_NOTIFICATION_SNIPPET_COLOR = Color.parseColor("#999999");
    private static final int NOTIFICATION_VISIBILITY = NotificationCompat.VISIBILITY_PUBLIC;
    private static final long FIRST_SCAN_TIME_MILLIS = TimeUnit.SECONDS.toMillis(2);
    private static final long SECOND_SCAN_TIME_MILLIS = TimeUnit.SECONDS.toMillis(10);
    private static final long SCAN_STALE_TIME_MILLIS = TimeUnit.MINUTES.toMillis(2);
    private static final long LOCAL_SCAN_STALE_TIME_MILLIS = TimeUnit.SECONDS.toMillis(30);
    private boolean mCanUpdateNotifications = false;
    private boolean mSecondScanComplete = false;
    private boolean mIsBound = false;
    private long mScanStartTime;
    private Handler mHandler;
    private NotificationManagerCompat mNotificationManager;
    private List<UrlDeviceDiscoverer> mUrlDeviceDiscoverers;
    private List<UrlDeviceDiscoveryListener> mUrlDeviceDiscoveryListeners;
    private PhysicalWebCollection mPwCollection;
  
    // Notification of urls happens as follows:
    // 0. Begin scan
    // 1. Delete notification, show top two urls (mFirstScanTimeout)
    // 2. Show each new url as it comes in, if it's in the top two
    // 3. Stop scanning if no clients are subscribed (mSecondScanTimeout)
  
    private Runnable mFirstScanTimeout = new Runnable() {
      @Override
      public void run() {
        mCanUpdateNotifications = true;
        updateNotifications();
      }
    };
  
    private Runnable mSecondScanTimeout = new Runnable() {
      @Override
      public void run() {
        mSecondScanComplete = true;
        if (!mIsBound) {
          stopSelf();
        }
      }
    };
  
    /**
     * Binder class for getting connections to the service.
     */
    public class LocalBinder extends Binder {
      public UrlDeviceDiscoveryService getServiceInstance() {
        return UrlDeviceDiscoveryService.this;
      }
    }
    private IBinder mBinder = new LocalBinder();
  
    /**
     * Callback for subscribers to this service.
     */
    public interface UrlDeviceDiscoveryListener {
      public void onUrlDeviceDiscoveryUpdate();
    }
  
    public UrlDeviceDiscoveryService() {
    }
  
    private void initialize() {
      mNotificationManager = NotificationManagerCompat.from(this);
      mUrlDeviceDiscoverers = new ArrayList<>();
  
      if (Utils.isMdnsEnabled(this)) {
        Log.d(TAG, "mdns started");
        mUrlDeviceDiscoverers.add(new MdnsUrlDeviceDiscoverer(this));
      }
      if (Utils.isWifiDirectEnabled(this)) {
        Log.d(TAG, "wifi direct started");
        mUrlDeviceDiscoverers.add(new WifiUrlDeviceDiscoverer(this));
      }
      mUrlDeviceDiscoverers.add(new SsdpUrlDeviceDiscoverer(this));
      mUrlDeviceDiscoverers.add(new BleUrlDeviceDiscoverer(this));
      for (UrlDeviceDiscoverer urlDeviceDiscoverer : mUrlDeviceDiscoverers) {
        urlDeviceDiscoverer.setCallback(this);
      }
      mUrlDeviceDiscoveryListeners = new ArrayList<>();
      mHandler = new Handler();
      mPwCollection = new PhysicalWebCollection();
      if (!Utils.setPwsEndpoint(this, mPwCollection)) {
        Utils.warnUserOnMissingApiKey(this);
      }
      mCanUpdateNotifications = false;
    }
  
    private void restoreCache() {
      // Make sure we are trying to load the right version of the cache
      SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
      int prefsVersion = prefs.getInt(PREFS_VERSION_KEY, 0);
      long now = new Date().getTime();
      if (prefsVersion != PREFS_VERSION) {
        mScanStartTime = now;
        return;
      }
  
      // Don't load the cache if it's stale
      mScanStartTime = prefs.getLong(SCAN_START_TIME_KEY, 0);
      long scanDelta = now - mScanStartTime;
      if (scanDelta >= SCAN_STALE_TIME_MILLIS) {
        mScanStartTime = now;
        return;
      }
  
      // Restore the cached metadata
      try {
        JSONObject serializedCollection = new JSONObject(prefs.getString(PW_COLLECTION_KEY, null));
        mPwCollection = PhysicalWebCollection.jsonDeserialize(serializedCollection);
        Utils.setPwsEndpoint(this, mPwCollection);
      } catch (JSONException e) {
        Log.e(TAG, "Could not restore Physical Web collection cache", e);
      } catch (PhysicalWebCollectionException e) {
        Log.e(TAG, "Could not restore Physical Web collection cache", e);
      }
      // replace TxPower and RSSI data after restoring cache
      for (UrlDevice urlDevice : mPwCollection.getUrlDevices()) {
        if (Utils.isBleUrlDevice(urlDevice)) {
          Utils.updateRegion(urlDevice);
        }
      }
      // Unresolvable devices are typically not
      // relevant outside of scan range. Hence,
      // we specially clean them from the cache.
      if (scanDelta >= LOCAL_SCAN_STALE_TIME_MILLIS) {
        for (UrlDevice urlDevice : mPwCollection.getUrlDevices()) {
          if (!Utils.isResolvableDevice(urlDevice)) {
            mPwCollection.removeUrlDevice(urlDevice);
          }
        }
      }
    }
  
    @Override
    public void onCreate() {
      super.onCreate();
      initialize();
      restoreCache();
      cancelNotifications();
      mHandler.postDelayed(mFirstScanTimeout, FIRST_SCAN_TIME_MILLIS);
      mHandler.postDelayed(mSecondScanTimeout, SECOND_SCAN_TIME_MILLIS);
    }
  
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
      startScan();
      return START_STICKY;
    }
  
    @Override
    public IBinder onBind(Intent intent) {
      mIsBound = true;
      return mBinder;
    }
  
    @Override
    public boolean onUnbind(Intent intent) {
      mIsBound = false;
      if (mSecondScanComplete) {
        stopSelf();
      }
      // true ensures onRebind is called on succcessive binds
      return true;
    }
  
    @Override
    public void onRebind(Intent intent) {
      mIsBound = true;
    }
    private void cancelNotifications() {
      mNotificationManager.cancel(NEAREST_BEACON_NOTIFICATION_ID);
      mNotificationManager.cancel(SECOND_NEAREST_BEACON_NOTIFICATION_ID);
      mNotificationManager.cancel(SUMMARY_NOTIFICATION_ID);
    }
  
    private void saveCache() {
      // Write the PW Collection
      PreferenceManager.getDefaultSharedPreferences(this).edit()
          .putInt(PREFS_VERSION_KEY, PREFS_VERSION)
          .putLong(SCAN_START_TIME_KEY, mScanStartTime)
          .putString(PW_COLLECTION_KEY, mPwCollection.jsonSerialize().toString())
          .apply();
    }
  
    @Override
    public void onDestroy() {
      Log.d(TAG, "onDestroy:  service exiting");
  
      // Stop the scanners
      mHandler.removeCallbacks(mFirstScanTimeout);
      mHandler.removeCallbacks(mSecondScanTimeout);
      stopScan();
      saveCache();
      super.onDestroy();
    }
  
    @Override
    public void onUrlDeviceDiscovered(UrlDevice urlDevice) {
      // Add Device and fetch results
      // Don't short circuit because icons
      // and metadata may not be fetched
      mPwCollection.addUrlDevice(urlDevice);
      Log.d(TAG, urlDevice.getUrl());
      if (!Utils.isResolvableDevice(urlDevice)) {
        mPwCollection.addMetadata(new PwsResult.Builder(urlDevice.getUrl(), urlDevice.getUrl())
          .setTitle(Utils.getTitle(urlDevice))
          .setDescription(Utils.getDescription(urlDevice))
          .build());
      }
      mPwCollection.fetchPwsResults(new PwsResultCallback() {
        long mPwsTripTimeMillis = 0;
  
        @Override
        public void onPwsResult(PwsResult pwsResult) {
          PwsResult replacement = new Utils.PwsResultBuilder(pwsResult)
              .setPwsTripTimeMillis(pwsResult, mPwsTripTimeMillis)
              .build();
          mPwCollection.addMetadata(replacement);
          triggerCallback();
          updateNotifications();
        }
  
        @Override
        public void onPwsResultAbsent(String url) {
          triggerCallback();
        }
  
        @Override
        public void onPwsResultError(Collection<String> urls, int httpResponseCode, Exception e) {
          Log.d(TAG, "PwsResultError: " + httpResponseCode + " ", e);
          triggerCallback();
        }
  
        @Override
        public void onResponseReceived(long durationMillis) {
          mPwsTripTimeMillis = durationMillis;
        }
      }, new PwsResultIconCallback() {
        @Override
        public void onIcon(byte[] icon) {
          triggerCallback();
        }
  
        @Override
        public void onError(int httpResponseCode, Exception e) {
          Log.d(TAG, "PwsResultError: " + httpResponseCode + " ", e);
          triggerCallback();
        }
      });
      triggerCallback();
    }
  
    private void triggerCallback() {
      for (UrlDeviceDiscoveryListener urlDeviceDiscoveryListener : mUrlDeviceDiscoveryListeners) {
        urlDeviceDiscoveryListener.onUrlDeviceDiscoveryUpdate();
      }
    }
  
    /**
     * Create a new set of notifications or update those existing.
     */
    private void updateNotifications() {
      if (!mCanUpdateNotifications) {
        return;
      }
  
      List<PwPair> pwPairs = mPwCollection.getGroupedPwPairsSortedByRank(
          new Utils.PwPairRelevanceComparator());
      List<PwPair> notBlockedPwPairs = new ArrayList<>();
      for (PwPair i : pwPairs) {
        if (!Utils.isBlocked(i)) {
          notBlockedPwPairs.add(i);
        }
      }
  
  
      // If no beacons have been found
      if (notBlockedPwPairs.size() == 0) {
        // Remove all existing notifications
        cancelNotifications();
      } else if (notBlockedPwPairs.size() == 1) {
        updateNearbyBeaconNotification(true, notBlockedPwPairs.get(0),
            NEAREST_BEACON_NOTIFICATION_ID);
      } else {
        // Create a summary notification for both beacon notifications.
        // Do this first so that we don't first show the individual notifications
        updateSummaryNotification(notBlockedPwPairs);
        // Create or update a notification for second beacon
        updateNearbyBeaconNotification(false, notBlockedPwPairs.get(1),
                                       SECOND_NEAREST_BEACON_NOTIFICATION_ID);
        // Create or update a notification for first beacon. Needs to be added last to show up top
        updateNearbyBeaconNotification(false, notBlockedPwPairs.get(0),
                                       NEAREST_BEACON_NOTIFICATION_ID);
  
      }
    }
  
    /**
     * Create or update a notification with the given id for the beacon with the given address.
     */
    private void updateNearbyBeaconNotification(boolean single, PwPair pwPair, int notificationId) {
      PwsResult pwsResult = pwPair.getPwsResult();
      UrlDevice urlDevice = pwPair.getUrlDevice();
      NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
      builder.setSmallIcon(R.drawable.ic_notification)
          .setLargeIcon(Utils.getBitmapIcon(mPwCollection, pwsResult))
          .setContentTitle(pwsResult.getTitle())
          .setContentText(pwsResult.getDescription())
          .setPriority((Utils.isFavorite(pwPair.getPwsResult().getSiteUrl()))
              ? NotificationCompat.PRIORITY_DEFAULT : NotificationCompat.PRIORITY_MIN);
      if (Utils.isFatBeaconDevice(urlDevice)) {
        Intent intent = new Intent(this, OfflineTransportConnectionActivity.class);
        intent.putExtra(OfflineTransportConnectionActivity.EXTRA_CONNECTION_TYPE,
            OfflineTransportConnectionActivity.EXTRA_FAT_BEACON_CONNECTION);
        intent.putExtra(OfflineTransportConnectionActivity.EXTRA_DEVICE_ADDRESS,
            pwsResult.getSiteUrl());
        intent.putExtra(OfflineTransportConnectionActivity.EXTRA_PAGE_TITLE, pwsResult.getTitle());
        int requestID = (int) System.currentTimeMillis();
        builder.setContentIntent(PendingIntent.getActivity(this, requestID, intent, 0));
      } else if (Utils.isWifiDirectDevice(urlDevice)) {
        Intent intent = new Intent(this, OfflineTransportConnectionActivity.class);
        intent.putExtra(OfflineTransportConnectionActivity.EXTRA_CONNECTION_TYPE,
            OfflineTransportConnectionActivity.EXTRA_WIFI_DIRECT_CONNECTION);
        intent.putExtra(OfflineTransportConnectionActivity.EXTRA_DEVICE_ADDRESS,
            Utils.getWifiAddress(urlDevice));
        intent.putExtra(OfflineTransportConnectionActivity.EXTRA_PAGE_TITLE, pwsResult.getTitle());
        intent.putExtra(OfflineTransportConnectionActivity.EXTRA_DEVICE_PORT,
            Utils.getWifiPort(urlDevice));
        int requestID = (int) System.currentTimeMillis();
        builder.setContentIntent(PendingIntent.getActivity(this, requestID, intent, 0));
      } else {
        builder.setContentIntent(Utils.createNavigateToUrlPendingIntent(pwsResult, this));
      }
      if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
          && Utils.isPublic(pwPair.getUrlDevice())) {
        builder.setVisibility(NOTIFICATION_VISIBILITY);
      }
      // For some reason if there is only one notification and you call setGroup
      // the notification doesn't show up on the N7 running kit kat
      if (!single) {
        builder = builder.setGroup(NOTIFICATION_GROUP_KEY);
      }
      Notification notification = builder.build();
  
      mNotificationManager.notify(notificationId, notification);
    }
  
    /**
     * Create or update the a single notification that is a collapsed version
     * of the top two beacon notifications.
     */
    private void updateSummaryNotification(List<PwPair> pwPairs) {
      int numNearbyBeacons = pwPairs.size();
      String contentTitle = String.valueOf(numNearbyBeacons);
      Resources resources = getResources();
      contentTitle += " " + resources.getQuantityString(R.plurals.numFoundBeacons, numNearbyBeacons,
                                                        numNearbyBeacons);
      String contentText = getString(R.string.summary_notification_pull_down);
      NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
      builder.setSmallIcon(R.drawable.ic_notification)
          .setContentTitle(contentTitle)
          .setContentText(contentText)
          .setSmallIcon(R.drawable.ic_notification)
          .setGroup(NOTIFICATION_GROUP_KEY)
          .setGroupSummary(true)
          .setPriority(Utils.containsFavorite(pwPairs)
              ? NotificationCompat.PRIORITY_DEFAULT : NotificationCompat.PRIORITY_MIN)
          .setContentIntent(createReturnToAppPendingIntent());
      if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        builder.setVisibility(NOTIFICATION_VISIBILITY);
      }
      Notification notification = builder.build();
  
      // Create the big view for the notification (viewed by pulling down)
      RemoteViews remoteViews = updateSummaryNotificationRemoteViews(pwPairs);
      notification.bigContentView = remoteViews;
  
      mNotificationManager.notify(SUMMARY_NOTIFICATION_ID, notification);
    }
  
    /**
     * Create the big view for the summary notification.
     */
    private RemoteViews updateSummaryNotificationRemoteViews(List<PwPair> pwPairs) {
      RemoteViews remoteViews = new RemoteViews(getPackageName(), R.layout.notification_big_view);
  
      // Fill in the data for the top two beacon views
      updateSummaryNotificationRemoteViewsFirstBeacon(pwPairs.get(0), remoteViews);
      updateSummaryNotificationRemoteViewsSecondBeacon(pwPairs.get(1), remoteViews);
  
      // Create a pending intent that will open the physical web app
      // TODO(cco3): Use a clickListener on the VIEW MORE button to do this
      PendingIntent pendingIntent = createReturnToAppPendingIntent();
      remoteViews.setOnClickPendingIntent(R.id.otherBeaconsLayout, pendingIntent);
  
      return remoteViews;
    }
  
    private void updateSummaryNotificationRemoteViewsFirstBeacon(PwPair pwPair,
                                                                 RemoteViews remoteViews) {
      PwsResult pwsResult = pwPair.getPwsResult();
      remoteViews.setImageViewBitmap(
          R.id.icon_firstBeacon, Utils.getBitmapIcon(mPwCollection, pwsResult));
      remoteViews.setTextViewText(R.id.title_firstBeacon, pwsResult.getTitle());
      remoteViews.setTextViewText(R.id.url_firstBeacon, pwsResult.getSiteUrl());
      remoteViews.setTextViewText(R.id.description_firstBeacon, pwsResult.getDescription());
      // Recolor notifications to have light text for non-Lollipop devices
      if (!(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)) {
        remoteViews.setTextColor(R.id.title_firstBeacon, NON_LOLLIPOP_NOTIFICATION_TITLE_COLOR);
        remoteViews.setTextColor(R.id.url_firstBeacon, NON_LOLLIPOP_NOTIFICATION_URL_COLOR);
        remoteViews.setTextColor(R.id.description_firstBeacon,
                                 NON_LOLLIPOP_NOTIFICATION_SNIPPET_COLOR);
      }
  
      // Create an intent that will open the browser to the beacon's url
      // if the user taps the notification
      remoteViews.setOnClickPendingIntent(R.id.first_beacon_main_layout,
          Utils.createNavigateToUrlPendingIntent(pwsResult, this));
      remoteViews.setViewVisibility(R.id.firstBeaconLayout, View.VISIBLE);
    }
  
    private void updateSummaryNotificationRemoteViewsSecondBeacon(PwPair pwPair,
                                                                  RemoteViews remoteViews) {
      PwsResult pwsResult = pwPair.getPwsResult();
      remoteViews.setImageViewBitmap(
          R.id.icon_secondBeacon, Utils.getBitmapIcon(mPwCollection, pwsResult));
      remoteViews.setTextViewText(R.id.title_secondBeacon, pwsResult.getTitle());
      remoteViews.setTextViewText(R.id.url_secondBeacon, pwsResult.getSiteUrl());
      remoteViews.setTextViewText(R.id.description_secondBeacon, pwsResult.getDescription());
      // Recolor notifications to have light text for non-Lollipop devices
      if (!(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)) {
        remoteViews.setTextColor(R.id.title_secondBeacon, NON_LOLLIPOP_NOTIFICATION_TITLE_COLOR);
        remoteViews.setTextColor(R.id.url_secondBeacon, NON_LOLLIPOP_NOTIFICATION_URL_COLOR);
        remoteViews.setTextColor(R.id.description_secondBeacon,
                                 NON_LOLLIPOP_NOTIFICATION_SNIPPET_COLOR);
      }
  
      // Create an intent that will open the browser to the beacon's url
      // if the user taps the notification
      remoteViews.setOnClickPendingIntent(R.id.second_beacon_main_layout,
          Utils.createNavigateToUrlPendingIntent(pwsResult, this));
      remoteViews.setViewVisibility(R.id.secondBeaconLayout, View.VISIBLE);
    }
  
    private PendingIntent createReturnToAppPendingIntent() {
      Intent intent = new Intent(this, MainActivity.class);
      int requestID = (int) System.currentTimeMillis();
      PendingIntent pendingIntent = PendingIntent.getActivity(this, requestID, intent, 0);
      return pendingIntent;
    }
  
    public void addCallback(UrlDeviceDiscoveryListener urlDeviceDiscoveryListener) {
      mUrlDeviceDiscoveryListeners.add(urlDeviceDiscoveryListener);
    }
  
    public void removeCallback(UrlDeviceDiscoveryListener urlDeviceDiscoveryListener) {
      mUrlDeviceDiscoveryListeners.remove(urlDeviceDiscoveryListener);
    }
  
    public long getScanStartTime() {
      return mScanStartTime;
    }
  
    private void startScan() {
      for (UrlDeviceDiscoverer urlDeviceDiscoverer : mUrlDeviceDiscoverers) {
        urlDeviceDiscoverer.startScan();
      }
    }
  
    private void stopScan() {
      for (UrlDeviceDiscoverer urlDeviceDiscoverer : mUrlDeviceDiscoverers) {
        urlDeviceDiscoverer.stopScan();
      }
    }
  
    public void restartScan() {
      stopScan();
      mScanStartTime = new Date().getTime();
      startScan();
    }
  
    public boolean hasResults() {
      return !mPwCollection.getPwPairs().isEmpty();
    }
  
    public PhysicalWebCollection getPwCollection() {
      return mPwCollection;
    }
  
    public void clearCache() {
      stopScan();
      mScanStartTime = new Date().getTime();
      Utils.setPwsEndpoint(this, mPwCollection);
      mPwCollection.clear();
      saveCache();
    }
  
    public void newPwsStartScan() {
      Utils.setPwsEndpoint(this, mPwCollection);
      restartScan();
    }
  }
  