blob: 93d062e3519e708b0df1cb76620fec03a0d49487 [file] [log] [blame]
// 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;