From 125f37f0cef6e2fecc6116464c7dcbd3979be602 Mon Sep 17 00:00:00 2001 From: Lindsay-Needs-Sleep Date: Sun, 15 Sep 2019 02:37:23 -0600 Subject: [PATCH] SessionManager.startSession did not work reliably on android 4.4.2, so switch to MediaRouter.selectRoute() Add isNearbyDevice to routes output from startRouteScan (mostly so testing can avoid attempting to join these routes, since they require manual input. But could be useful to the user as well.) Remove unnecessary chrome.cast.js global vars. Added polyfill for Promise to tests since Android 4.4.3- does not have Promise natively. Contributing to Issue #36 Relevant to Issue #22 --- src/android/Chromecast.java | 8 ++- src/android/ChromecastConnection.java | 84 +++++++++++++++++++++++---- tests/tests.js | 44 +++++++++----- www/chrome.cast.js | 21 +++---- 4 files changed, 119 insertions(+), 38 deletions(-) diff --git a/src/android/Chromecast.java b/src/android/Chromecast.java index b76d911..48bc898 100644 --- a/src/android/Chromecast.java +++ b/src/android/Chromecast.java @@ -407,7 +407,7 @@ void onRouteUpdate(List routes) { callbackContext.sendPluginResult(pluginResult); } }; - connection.scanForRoutes(clientScan); + connection.scanForRoutes(null, clientScan, null); return true; } @@ -438,6 +438,12 @@ private JSONArray routesToJSON(List routes) { JSONObject obj = new JSONObject(); obj.put("name", route.getName()); obj.put("id", route.getId()); + + CastDevice device = CastDevice.getFromBundle(route.getExtras()); + if (device != null) { + obj.put("isNearbyDevice", !device.isOnLocalNetwork()); + } + routesArray.put(obj); } catch (JSONException e) { } diff --git a/src/android/ChromecastConnection.java b/src/android/ChromecastConnection.java index 7b3fe0a..2f7417f 100644 --- a/src/android/ChromecastConnection.java +++ b/src/android/ChromecastConnection.java @@ -3,7 +3,6 @@ import android.app.Activity; import android.app.AlertDialog; import android.content.DialogInterface; -import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.os.Handler; @@ -201,15 +200,51 @@ public void run() { callback.onJoin(session); return; } - listenForConnection(callback); - Intent castIntent = new Intent(); - castIntent.putExtra("CAST_INTENT_TO_CAST_ROUTE_ID_KEY", routeId); - // RouteName and toast are just for display - castIntent.putExtra("CAST_INTENT_TO_CAST_DEVICE_NAME_KEY", routeName); - castIntent.putExtra("CAST_INTENT_TO_CAST_NO_TOAST_KEY", false); + // We need this hack so that we can access the foundRoute value + // Without having to store it as a global variable. + // Just always access first element + final boolean[] foundRoute = {false}; + + listenForConnection(callback); - getSessionManager().startSession(castIntent); + // We need to start an active scan because getMediaRouter().getRoutes() may be out + // of date. Also, maintaining a list of known routes doesn't work. It is possible + // to have a route in your "known" routes list, but is not in + // getMediaRouter().getRoutes() which will result in "Ignoring attempt to select + // removed route: ", even if that route *should* be available. This state could + // happen because routes are periodically "removed" and "added", and if the last + // time media router was scanning ended when the route was temporarily removed the + // getRoutes() fn will have no record of the route. We need the active scan to + // avoid this situation as well. PS. Just running the scan non-stop is a poor idea + // since it will drain battery power quickly. + ScanCallback scan = new ScanCallback() { + @Override + void onRouteUpdate(List routes) { + // Look for the matching route + for (RouteInfo route : routes) { + if (!foundRoute[0] && route.getId().equals(routeId)) { + // Found the route! + foundRoute[0] = true; + // So stop the scan + stopScan(this); + // And select it! + getMediaRouter().selectRoute(route); + } + } + } + }; + scanForRoutes(5000L, scan, new Runnable() { + @Override + public void run() { + // If we were not able to find the route + if (!foundRoute[0]) { + stopScan(scan); + callback.onError("TIMEOUT Could not find active route with id: " + + routeId + " after 5s."); + } + } + }); } }); } @@ -308,9 +343,13 @@ public void onSessionStartFailed(CastSession castSession, int errCode) { /** * Starts listening for receiver updates. * Must call stopScan(callback) or the battery will drain with non-stop active scanning. + * @param timeout ms until the scan automatically stops, + * if 0 only calls callback.onRouteUpdate once with the currently known routes + * if null, will scan until stopScan is called * @param callback the callback to receive route updates on + * @param onTimeout called when the timeout hits */ - public void scanForRoutes(ScanCallback callback) { + public void scanForRoutes(Long timeout, ScanCallback callback, Runnable onTimeout) { // Add the callback in active scan mode activity.runOnUiThread(new Runnable() { public void run() { @@ -319,12 +358,31 @@ public void run() { // Send out the initial routes callback.onFilteredRouteUpdate(); + if (timeout != null && timeout == 0) { + return; + } + // Add the callback in active scan mode getMediaRouter().addCallback(new MediaRouteSelector.Builder() .addControlCategory(CastMediaControlIntent.categoryForCast(appId)) .build(), callback, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN); + + if (timeout != null) { + // remove the callback after timeout ms, and notify caller + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + // And stop the scan for routes + getMediaRouter().removeCallback(callback); + // Notify + if (onTimeout != null) { + onTimeout.run(); + } + } + }, timeout); + } } }); } @@ -440,10 +498,9 @@ private void onFilteredRouteUpdate() { if (stopped || mediaRouter == null) { return; } - List routes = mediaRouter.getRoutes(); List outRoutes = new ArrayList<>(); // Filter the routes - for (RouteInfo route : routes) { + for (RouteInfo route : mediaRouter.getRoutes()) { // We don't want default routes, or duplicate active routes // or multizone duplicates https://github.com/jellyfin/cordova-plugin-chromecast/issues/32 Bundle extras = route.getExtras(); @@ -453,7 +510,10 @@ private void onFilteredRouteUpdate() { continue; } } - if (!route.isDefault() && !route.getDescription().equals("Google Cast Multizone Member")) { + if (!route.isDefault() + && !route.getDescription().equals("Google Cast Multizone Member") + && route.getPlaybackType() == RouteInfo.PLAYBACK_TYPE_REMOTE + ) { outRoutes.add(route); } } diff --git a/tests/tests.js b/tests/tests.js index 14addd5..4188c0b 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -2,8 +2,15 @@ * The order of the tests is very important! * Unfortunately using nested describes and beforeAll does not work correctly. * So just be careful with the order of tests! + * Edit: TODO We should really switch to mocha. */ +// We need a promise polyfill for Android < 4.4.3 +// from https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js +/*eslint-disable */ +!(function (e, n) { typeof exports === 'object' && typeof module !== 'undefined' ? n() : typeof define === 'function' && define.amd ? define(n) : n(); }(0, function () { 'use strict'; function e (e) { var n = this.constructor; return this.then(function (t) { return n.resolve(e()).then(function () { return t; }); }, function (t) { return n.resolve(e()).then(function () { return n.reject(t); }); }); } function n (e) { return !(!e || typeof e.length === 'undefined'); } function t () {} function o (e) { if (!(this instanceof o)) throw new TypeError('Promises must be constructed via new'); if (typeof e !== 'function') throw new TypeError('not a function'); this._state = 0, this._handled = !1, this._value = undefined, this._deferreds = [], c(e, this); } function r (e, n) { for (;e._state === 3;)e = e._value; e._state !== 0 ? (e._handled = !0, o._immediateFn(function () { var t = e._state === 1 ? n.onFulfilled : n.onRejected; if (t !== null) { var o; try { o = t(e._value); } catch (r) { return void f(n.promise, r); }i(n.promise, o); } else (e._state === 1 ? i : f)(n.promise, e._value); })) : e._deferreds.push(n); } function i (e, n) { try { if (n === e) throw new TypeError('A promise cannot be resolved with itself.'); if (n && (typeof n === 'object' || typeof n === 'function')) { var t = n.then; if (n instanceof o) return e._state = 3, e._value = n, void u(e); if (typeof t === 'function') return void c((function (e, n) { return function () { e.apply(n, arguments); }; }(t, n)), e); }e._state = 1, e._value = n, u(e); } catch (r) { f(e, r); } } function f (e, n) { e._state = 2, e._value = n, u(e); } function u (e) { e._state === 2 && e._deferreds.length === 0 && o._immediateFn(function () { e._handled || o._unhandledRejectionFn(e._value); }); for (var n = 0, t = e._deferreds.length; t > n; n++)r(e, e._deferreds[n]); e._deferreds = null; } function c (e, n) { var t = !1; try { e(function (e) { t || (t = !0, i(n, e)); }, function (e) { t || (t = !0, f(n, e)); }); } catch (o) { if (t) return; t = !0, f(n, o); } } var a = setTimeout; o.prototype['catch'] = function (e) { return this.then(null, e); }, o.prototype.then = function (e, n) { var o = new this.constructor(t); return r(this, new function (e, n, t) { this.onFulfilled = typeof e === 'function' ? e : null, this.onRejected = typeof n === 'function' ? n : null, this.promise = t; }(e, n, o)), o; }, o.prototype['finally'] = e, o.all = function (e) { return new o(function (t, o) { function r (e, n) { try { if (n && (typeof n === 'object' || typeof n === 'function')) { var u = n.then; if (typeof u === 'function') return void u.call(n, function (n) { r(e, n); }, o); }i[e] = n, --f == 0 && t(i); } catch (c) { o(c); } } if (!n(e)) return o(new TypeError('Promise.all accepts an array')); var i = Array.prototype.slice.call(e); if (i.length === 0) return t([]); for (var f = i.length, u = 0; i.length > u; u++)r(u, i[u]); }); }, o.resolve = function (e) { return e && typeof e === 'object' && e.constructor === o ? e : new o(function (n) { n(e); }); }, o.reject = function (e) { return new o(function (n, t) { t(e); }); }, o.race = function (e) { return new o(function (t, r) { if (!n(e)) return r(new TypeError('Promise.race accepts an array')); for (var i = 0, f = e.length; f > i; i++)o.resolve(e[i]).then(t, r); }); }, o._immediateFn = typeof setImmediate === 'function' && function (e) { setImmediate(e); } || function (e) { a(e, 0); }, o._unhandledRejectionFn = function (e) { void 0 !== console && console && console.warn('Possible Unhandled Promise Rejection:', e); }; var l = (function () { if (typeof self !== 'undefined') return self; if (typeof window !== 'undefined') return window; if (typeof global !== 'undefined') return global; throw Error('unable to locate global object'); }()); 'Promise' in l ? l.Promise.prototype['finally'] || (l.Promise.prototype['finally'] = e) : l.Promise = o; })); +/*eslint-enable */ + /* eslint-env jasmine */ exports.defineAutoTests = function () { /* eslint-disable no-undef */ @@ -11,7 +18,7 @@ exports.defineAutoTests = function () { jasmine.DEFAULT_TIMEOUT_INTERVAL = 9000; var appId = chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID; - var videoUrl = 'https://archive.org/download/CosmosLaundromatFirstCycle/Cosmos%20Laundromat%20-%20First%20Cycle%20%281080p%29.mp4' + var videoUrl = 'https://archive.org/download/CosmosLaundromatFirstCycle/Cosmos%20Laundromat%20-%20First%20Cycle%20%281080p%29.mp4'; describe('chrome.cast', function () { @@ -628,46 +635,57 @@ exports.defineAutoTests = function () { function startRouteScan () { return new Promise(function (resolve) { - var once = true; + var finished = false; chrome.cast.cordova.startRouteScan(function routeUpdate (routes) { - if (once && routes.length > 0) { - once = false; - - var route = routes[0]; + if (finished) { + return; + } + for (var i = 0; i < routes.length; i++) { + var route = routes[i]; test(route).toBeInstanceOf(chrome.cast.cordova.Route); test(route.id).toBeDefined(); test(route.name).toBeDefined(); - + test(route.isNearbyDevice).toBeDefined(); + if (!route.isNearbyDevice) { + finished = true; + } + } + if (finished) { resolve(routes); } }, function (err) { fail(err.code + ': ' + err.description); - resolve(); }); }); } - function stopRouteScan (routes) { + function stopRouteScan (arg) { return new Promise(function (resolve) { // Make sure we can stop the scan chrome.cast.cordova.stopRouteScan(function () { - resolve(routes); + resolve(arg); }, function (err) { test().fail(err.code + ': ' + err.description); - resolve(); }); }); } function selectRoute (routes) { return new Promise(function (resolve) { - chrome.cast.cordova.selectRoute(routes[0], function (session) { + // Find a non-nearby device so that the join is automatic + var route; + for (var i = 0; i < routes.length; i++) { + route = routes[i]; + if (!route.isNearbyDevice) { + break; + } + } + chrome.cast.cordova.selectRoute(route, function (session) { Promise.resolve(session) .then(sessionProperties) .then(resolve); }, function (err) { fail(err.code + ': ' + err.description); - resolve(routes); }); }); } diff --git a/www/chrome.cast.js b/www/chrome.cast.js index 45fd6f3..17e916c 100644 --- a/www/chrome.cast.js +++ b/www/chrome.cast.js @@ -523,9 +523,6 @@ chrome.cast = { } }; -var _sessionRequest = null; -var _autoJoinPolicy = null; -var _defaultActionPolicy = null; var _sessionListener = function () {}; var _receiverListener = function () {}; @@ -545,14 +542,12 @@ chrome.cast.initialize = function (apiConfig, successCallback, errorCallback) { return; } - _autoJoinPolicy = apiConfig.autoJoinPolicy; - _defaultActionPolicy = apiConfig.defaultActionPolicy; - _sessionRequest = apiConfig.sessionRequest; - _sessionListener = apiConfig.sessionListener; - _receiverListener = apiConfig.receiverListener; - - execute('initialize', _sessionRequest.appId, _autoJoinPolicy, _defaultActionPolicy, function (err) { + execute('initialize', apiConfig.sessionRequest.appId, apiConfig.autoJoinPolicy, apiConfig.defaultActionPolicy, function (err) { if (!err) { + // Don't set the listeners config until success + _sessionListener = apiConfig.sessionListener; + _receiverListener = apiConfig.receiverListener; + successCallback(); chrome.cast._.receiverUpdate(false); } else { @@ -1139,7 +1134,8 @@ chrome.cast.cordova = { execute('startRouteScan', function (err, routes) { if (!err) { for (var i = 0; i < routes.length; i++) { - routes[i] = new chrome.cast.cordova.Route(routes[i].id, routes[i].name); + var route = routes[i]; + routes[i] = new chrome.cast.cordova.Route(route.id, route.name, route.isNearbyDevice); } successCallback(routes); } else { @@ -1176,9 +1172,10 @@ chrome.cast.cordova = { } }); }, - Route: function (id, name) { + Route: function (id, name, isNearbyDevice) { this.id = id; this.name = name; + this.isNearbyDevice = isNearbyDevice; } };