text: // Copyright 2017 The Chromium Authors. All rights reserved.
  // Use of this source code is governed by a BSD-style license that can be
  // found in the LICENSE file.
  
  goog.module('mr.dial.SinkDiscoveryService');
  goog.module.declareLegacyNamespace();
  
  const DeviceCounts = goog.require('mr.DeviceCounts');
  const DeviceCountsProvider = goog.require('mr.DeviceCountsProvider');
  const DialAnalytics = goog.require('mr.DialAnalytics');
  const DialSink = goog.require('mr.dial.Sink');
  const Logger = goog.require('mr.Logger');
  const PersistentData = goog.require('mr.PersistentData');
  const PersistentDataManager = goog.require('mr.PersistentDataManager');
  const SinkAppStatus = goog.require('mr.dial.SinkAppStatus');
  const SinkDiscoveryCallbacks = goog.require('mr.dial.SinkDiscoveryCallbacks');
  const SinkList = goog.require('mr.SinkList');
  
  
  /**
   * Implements local discovery using DIAL.
   * DIAL specification:
   * http://www.dial-multiscreen.org/dial-protocol-specification
   * @implements {PersistentData}
   * @implements {DeviceCountsProvider}
   */
  class SinkDiscoveryService {
    /**
     * @param {!SinkDiscoveryCallbacks} sinkCallBacks
     * @final
     */
    constructor(sinkCallBacks) {
      /**
       * @private @const {!SinkDiscoveryCallbacks}
       */
      this.sinkCallBacks_ = sinkCallBacks;
  
      /**
       * @private @const {?Logger}
       */
      this.logger_ = Logger.getInstance('mr.dial.SinkDiscoveryService');
  
      /**
       * The current set of *accessible* receivers, indexed by id.
       * @private @const {!Map<string, !DialSink>}
       */
      this.sinkMap_ = new Map();
  
      /**
       * The most recent snapshot of device counts.
       * Updated when a DIAL onDeviceList or onError event is received.
       * Part of PersistentData.
       * @private {!DeviceCounts}
       */
      this.deviceCounts_ = {availableDeviceCount: 0, knownDeviceCount: 0};
  
      /**
       * The last time device counts were recorded in DialAnalytics.
       * Persistent data.
       * @private {number}
       */
      this.deviceCountMetricsRecordTime_ = 0;
    }
  
    /**
     * Initializes the service. Must be called before any other methods.
     */
    init() {
      PersistentDataManager.register(this);
    }
  
    /**
     * Add |sinks| to sink map. Remove outdated sinks that are in sink map but not
     * in |sinks|.
     * @param {!Array<!mojo.Sink>} sinks list of sinks discovered by Media Router.
     */
    addSinks(sinks) {
      this.logger_.info('addSinks returned ' + sinks.length + ' sinks');
      this.logger_.fine(() => '....the list is: ' + JSON.stringify(sinks));
  
      const oldSinkIds = new Set(this.sinkMap_.keys());
      sinks.forEach(mojoSink => {
        const dialSink = SinkDiscoveryService.convertSink_(mojoSink);
        this.mayAddSink_(dialSink);
        oldSinkIds.delete(dialSink.getId());
      });
  
      let removedSinks = [];
      oldSinkIds.forEach(sinkId => {
        const sink = this.sinkMap_.get(sinkId);
        removedSinks.push(sink);
        this.sinkMap_.delete(sinkId);
      });
  
      if (removedSinks.length > 0) {
        this.sinkCallBacks_.onSinksRemoved(removedSinks);
      }
  
      // Record device count for feedback.
      const sinkCount = this.getSinkCount();
      this.deviceCounts_ = {
        availableDeviceCount: sinkCount,
        knownDeviceCount: sinkCount
      };
    }
  
    /**
     * Updates deviceCounts_ with the given counts, and reports to analytics if
     * applicable.
     * @param {number} availableDeviceCount
     * @param {number} knownDeviceCount
     * @private
     */
    recordDeviceCounts_(availableDeviceCount, knownDeviceCount) {
      this.deviceCounts_ = {
        availableDeviceCount: availableDeviceCount,
        knownDeviceCount: knownDeviceCount
      };
      if (Date.now() - this.deviceCountMetricsRecordTime_ <
          SinkDiscoveryService.DEVICE_COUNT_METRIC_THRESHOLD_MS_) {
        return;
      }
      DialAnalytics.recordDeviceCounts(this.deviceCounts_);
      this.deviceCountMetricsRecordTime_ = Date.now();
    }
  
    /**
     * Adds or updates an existing sink with the given sink.
     * @param {!DialSink} sink The new or updated sink.
     * @private
     */
    mayAddSink_(sink) {
      this.logger_.fine('mayAddSink, id = ' + sink.getId());
      const sinkToUpdate = this.sinkMap_.get(sink.getId());
      if (sinkToUpdate) {
        if (sinkToUpdate.update(sink)) {
          this.logger_.fine('Updated sink ' + sinkToUpdate.getId());
          this.sinkCallBacks_.onSinkUpdated(sinkToUpdate);
        }
      } else {
        this.logger_.fine(
            () => `Adding new sink ${sink.getId()}: ${sink.toDebugString()}`);
        this.sinkMap_.set(sink.getId(), sink);
        this.sinkCallBacks_.onSinkAdded(sink);
      }
    }
  
    /**
     * Converts a mojo.Sink to a DialSink.
     * @param {!mojo.Sink} mojoSink returned by Media Router at browser side.
     * @return {!DialSink} DIAL sink.
     * @private
     */
    static convertSink_(mojoSink) {
  
      const uniqueId = mojoSink.sink_id;
      const extraData = mojoSink.extra_data.dial_media_sink;
      const isDiscoveryOnly =
          SinkDiscoveryService.isDiscoveryOnly_(extraData.model_name);
  
      const ip_address = extraData.ip_address.address_bytes ?
          extraData.ip_address.address_bytes.join('.') :
          extraData.ip_address.address.join('.');
      return new DialSink(mojoSink.name, uniqueId)
          .setIpAddress(ip_address)
          .setDialAppUrl(extraData.app_url.url)
          .setModelName(extraData.model_name)
          .setSupportsAppAvailability(!isDiscoveryOnly);
    }
  
    /**
     * Returns true if DIAL (SSDP) was only used to discover this sink, and it is
     * not expected to support other DIAL features (app discovery, activity
     * discovery, etc.)
     * @param {string} modelName
     * @return {boolean}
     * @private
     */
    static isDiscoveryOnly_(modelName) {
      return SinkDiscoveryService.DISCOVERY_ONLY_RE_.test(modelName);
    }
  
    /**
     * Returns the sink with the given ID, or null if not found.
     * @param {string} sinkId
     * @return {?DialSink}
     */
    getSinkById(sinkId) {
      return this.sinkMap_.get(sinkId) || null;
    }
  
    /**
     * Returns sinks that report availability of the given app name.
     * @param {string} appName
     * @return {!SinkList}
     */
    getSinksByAppName(appName) {
      const sinks = [];
      this.sinkMap_.forEach(dialSink => {
        if (dialSink.getAppStatus(appName) == SinkAppStatus.AVAILABLE)
          sinks.push(dialSink.getMrSink());
      });
      return new SinkList(
          sinks, SinkDiscoveryService.APP_ORIGIN_WHITELIST_[appName]);
    }
  
    /**
     * Returns current sinks.
     * @return {!Array<!DialSink>}
     */
    getSinks() {
      return Array.from(this.sinkMap_.values());
    }
  
    /**
     * @override
     */
    getDeviceCounts() {
      return this.deviceCounts_;
    }
  
    /**
     * @return {number}
     */
    getSinkCount() {
      return this.sinkMap_.size;
    }
  
    /**
     * Invoked when the app status of a sink changes.
     * @param {string} appName
     * @param {!DialSink} sink The sink whose status changed.
     */
    onAppStatusChanged(appName, sink) {
      this.sinkCallBacks_.onSinkUpdated(sink);
    }
  
    /**
     * @override
     */
    getStorageKey() {
      return 'dial.DialSinkDiscoveryService';
    }
  
    /**
     * @override
     */
    getData() {
      return [
        new SinkDiscoveryService.PersistentData_(
            Array.from(this.sinkMap_), this.deviceCounts_),
        {'deviceCountMetricsRecordTime': this.deviceCountMetricsRecordTime_}
      ];
    }
  
    /**
     * @override
     */
    loadSavedData() {
      const tempData =
          /** @type {?SinkDiscoveryService.PersistentData_} */ (
              PersistentDataManager.getTemporaryData(this));
      if (tempData) {
        for (const entry of tempData.sinks) {
          this.sinkMap_.set(entry[0], DialSink.createFrom(entry[1]));
        }
        this.deviceCounts_ = tempData.deviceCounts;
      }
  
      const permanentData = PersistentDataManager.getPersistentData(this);
      if (permanentData) {
        this.deviceCountMetricsRecordTime_ =
            permanentData['deviceCountMetricsRecordTime'];
      }
    }
  }
  
  
  /**
   * @private @const {!Object<string, !Array<string>>}
   */
  SinkDiscoveryService.APP_ORIGIN_WHITELIST_ = {
    'YouTube': [
      'https://tv.youtube.com', 'https://tv-green-qa.youtube.com',
      'https://tv-release-qa.youtube.com', 'https://web-green-qa.youtube.com',
      'https://web-release-qa.youtube.com', 'https://www.youtube.com'
    ],
    'Netflix': ['https://www.netflix.com'],
    'Pandora': ['https://www.pandora.com'],
    'Radio': ['https://www.pandora.com'],
    'Hulu': ['https://www.hulu.com'],
    'Vimeo': ['https://www.vimeo.com'],
    'Dailymotion': ['https://www.dailymotion.com'],
    'com.dailymotion': ['https://www.dailymotion.com'],
  };
  
  
  /**
   * Matches DIAL model names that only support discovery.
  
   * @private @const {!RegExp}
   */
  SinkDiscoveryService.DISCOVERY_ONLY_RE_ =
      new RegExp('Eureka Dongle|Chromecast Audio|Chromecast Ultra', 'i');
  
  /**
   * How long to wait between device counts metrics are recorded. Set to 1 hour.
   * @private @const {number}
   */
  SinkDiscoveryService.DEVICE_COUNT_METRIC_THRESHOLD_MS_ = 60 * 60 * 1000;
  
  
  /**
   * @private
   */
  SinkDiscoveryService.PersistentData_ = class {
    /**
     * @param {!Array} sinks
     * @param {!DeviceCounts} deviceCounts
     */
    constructor(sinks, deviceCounts) {
      /**
       * @const {!Array}
       */
      this.sinks = sinks;
  
      /**
       * @const {!DeviceCounts}
       */
      this.deviceCounts = deviceCounts;
    }
  };
  
  exports = SinkDiscoveryService;
  