From fdc96044ad659c31c5eb0422a5af0f8fce789444 Mon Sep 17 00:00:00 2001 From: Lyubomir Marinov Date: Tue, 31 Jan 2017 22:25:09 -0600 Subject: [PATCH] [RN] App-specific URL scheme --- android/app/src/main/AndroidManifest.xml | 6 + ios/app/Info.plist | 13 + react/features/app/actions.js | 4 +- react/features/app/functions.native.js | 237 +++++++++++++----- react/features/app/functions.web.js | 2 +- react/features/conference/route.js | 3 +- .../components/UnsupportedMobileBrowser.js | 71 ++---- .../welcome/components/WelcomePage.web.js | 4 +- 8 files changed, 234 insertions(+), 106 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f46293cfd..39bb46658 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -43,6 +43,12 @@ + + + + + + diff --git a/ios/app/Info.plist b/ios/app/Info.plist index b2f64a624..facf54cf7 100644 --- a/ios/app/Info.plist +++ b/ios/app/Info.plist @@ -20,6 +20,19 @@ 1.2 CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + org.jitsi.meet + CFBundleURLSchemes + + org.jitsi.meet + + + CFBundleVersion 1 ITSAppUsesNonExemptEncryption diff --git a/react/features/app/actions.js b/react/features/app/actions.js index f6c5e6ad1..90cadb597 100644 --- a/react/features/app/actions.js +++ b/react/features/app/actions.js @@ -4,8 +4,8 @@ import { loadConfig, setConfig } from '../base/lib-jitsi-meet'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from './actionTypes'; import { - _getRoomAndDomainFromUrlString, _getRouteToRender, + _parseURIString, init } from './functions'; import './reducer'; @@ -34,7 +34,7 @@ export function appNavigate(urlOrRoom) { const state = getState(); const oldDomain = getDomain(state); - const { domain, room } = _getRoomAndDomainFromUrlString(urlOrRoom); + const { domain, room } = _parseURIString(urlOrRoom); // TODO Kostiantyn Tsaregradskyi: We should probably detect if user is // currently in a conference and ask her if she wants to close the diff --git a/react/features/app/functions.native.js b/react/features/app/functions.native.js index 86cf04d42..b58be8ad2 100644 --- a/react/features/app/functions.native.js +++ b/react/features/app/functions.native.js @@ -3,23 +3,134 @@ import { RouteRegistry } from '../base/react'; import { Conference } from '../conference'; import { WelcomePage } from '../welcome'; +/** + * The RegExp pattern of the authority of a URI. + * + * @private + * @type {string} + */ +const _URI_AUTHORITY_PATTERN = '(//[^/?#]+)'; + +/** + * The RegExp pattern of the path of a URI. + * + * @private + * @type {string} + */ +const _URI_PATH_PATTERN = '([^?#]*)'; + +/** + * The RegExp patther of the protocol of a URI. + * + * @private + * @type {string} + */ +const _URI_PROTOCOL_PATTERN = '([a-z][a-z0-9\\.\\+-]*:)'; + +/** + * Fixes the hier-part of a specific URI (string) so that the URI is well-known. + * For example, certain Jitsi Meet deployments are not conventional but it is + * possible to translate their URLs into conventional. + * + * @param {string} uri - The URI (string) to fix the hier-part of. + * @private + * @returns {string} + */ +function _fixURIStringHierPart(uri) { + // Rewrite the specified URL in order to handle special cases such as + // hipchat.com and enso.me which do not follow the common pattern of most + // Jitsi Meet deployments. + + // hipchat.com + let regex + = new RegExp( + `^${_URI_PROTOCOL_PATTERN}//hipchat\\.com/video/call/`, + 'gi'); + let match = regex.exec(uri); + + if (!match) { + // enso.me + regex + = new RegExp( + `^${_URI_PROTOCOL_PATTERN}//enso\\.me/(?:call|meeting)/`, + 'gi'); + match = regex.exec(uri); + } + if (match) { + /* eslint-disable no-param-reassign, prefer-template */ + + uri + = match[1] /* protocol */ + + '//enso.hipchat.me/' + + uri.substring(regex.lastIndex); /* room (name) */ + + /* eslint-enable no-param-reassign, prefer-template */ + } + + return uri; +} + +/** + * Fixes the scheme part of a specific URI (string) so that it contains a + * well-known scheme such as HTTP(S). For example, the mobile app implements an + * app-specific URI scheme in addition to Universal Links. The app-specific + * scheme may precede or replace the well-known scheme. In such a case, dealing + * with the app-specific scheme only complicates the logic and it is simpler to + * get rid of it (by translating the app-specific scheme into a well-known + * scheme). + * + * @param {string} uri - The URI (string) to fix the scheme of. + * @private + * @returns {string} + */ +function _fixURIStringScheme(uri) { + const regex = new RegExp(`^${_URI_PROTOCOL_PATTERN}+`, 'gi'); + const match = regex.exec(uri); + + if (match) { + // As an implementation convenience, pick up the last scheme and make + // sure that it is a well-known one. + let protocol = match[match.length - 1].toLowerCase(); + + if (protocol !== 'http:' && protocol !== 'https:') { + protocol = 'https:'; + } + + /* eslint-disable no-param-reassign */ + + uri = uri.substring(regex.lastIndex); + if (uri.startsWith('//')) { + // The specified URL was not a room name only, it contained an + // authority. + uri = protocol + uri; + } + + /* eslint-enable no-param-reassign */ + } + + return uri; +} + /** * Gets room name and domain from URL object. * * @param {URL} url - URL object. * @private * @returns {{ - * domain: (string|undefined), - * room: (string|undefined) - * }} + * domain: (string|undefined), + * room: (string|undefined) + * }} */ -function _getRoomAndDomainFromUrlObject(url) { +function _getRoomAndDomainFromURLObject(url) { let domain; let room; if (url) { domain = url.hostname; - room = url.pathname.substr(1); + + // The room (name) is the last component of pathname. + room = url.pathname; + room = room.substring(room.lastIndexOf('/') + 1); // Convert empty string to undefined to simplify checks. if (room === '') { @@ -36,44 +147,6 @@ function _getRoomAndDomainFromUrlObject(url) { }; } -/** - * Gets conference room name and connection domain from URL. - * - * @param {(string|undefined)} url - URL. - * @returns {{ - * domain: (string|undefined), - * room: (string|undefined) - * }} - */ -export function _getRoomAndDomainFromUrlString(url) { - // Rewrite the specified URL in order to handle special cases such as - // hipchat.com and enso.me which do not follow the common pattern of most - // Jitsi Meet deployments. - if (typeof url === 'string') { - // hipchat.com - let regex = /^(https?):\/\/hipchat.com\/video\/call\//gi; - let match = regex.exec(url); - - if (!match) { - // enso.me - regex = /^(https?):\/\/enso\.me\/(?:call|meeting)\//gi; - match = regex.exec(url); - } - if (match && match.length > 1) { - /* eslint-disable no-param-reassign, prefer-template */ - - url - = match[1] /* URL protocol */ - + '://enso.hipchat.me/' - + url.substring(regex.lastIndex); - - /* eslint-enable no-param-reassign, prefer-template */ - } - } - - return _getRoomAndDomainFromUrlObject(_urlStringToObject(url)); -} - /** * Determines which route is to be rendered in order to depict a specific Redux * store. @@ -94,24 +167,74 @@ export function _getRouteToRender(stateOrGetState) { } /** - * Parses a string into a URL (object). + * Parses a specific URI which (supposedly) references a Jitsi Meet resource + * (location). * - * @param {(string|undefined)} url - The URL to parse. - * @private - * @returns {URL} + * @param {(string|undefined)} uri - The URI to parse which (supposedly) + * references a Jitsi Meet resource (location). + * @returns {{ + * domain: (string|undefined), + * room: (string|undefined) + * }} */ -function _urlStringToObject(url) { - let urlObj; +export function _parseURIString(uri) { + let obj; - if (url) { - try { - urlObj = new URL(url); - } catch (ex) { - // The return value will signal the failure & the logged exception - // will provide the details to the developers. - console.log(`${url} seems to be not a valid URL, but it's OK`, ex); + if (typeof uri === 'string') { + let str = uri; + + str = _fixURIStringScheme(str); + str = _fixURIStringHierPart(str); + + obj = {}; + + let regex; + let match; + + // protocol + regex = new RegExp(`^${_URI_PROTOCOL_PATTERN}`, 'gi'); + match = regex.exec(str); + if (match) { + obj.protocol = match[1].toLowerCase(); + str = str.substring(regex.lastIndex); + } + + // authority + regex = new RegExp(`^${_URI_AUTHORITY_PATTERN}`, 'gi'); + match = regex.exec(str); + if (match) { + let authority = match[1]; + + str = str.substring(regex.lastIndex); + + // userinfo + const userinfoEndIndex = authority.indexOf('@'); + + if (userinfoEndIndex !== -1) { + authority = authority.substring(userinfoEndIndex + 1); + } + + obj.host = authority; + + // port + const portBeginIndex = authority.lastIndexOf(':'); + + if (portBeginIndex !== -1) { + obj.port = authority.substring(portBeginIndex + 1); + authority = authority.substring(0, portBeginIndex); + } + + obj.hostname = authority; + } + + // pathname + regex = new RegExp(`^${_URI_PATH_PATTERN}`, 'gi'); + match = regex.exec(str); + if (match) { + obj.pathname = match[1] || '/'; + str = str.substring(regex.lastIndex); } } - return urlObj; + return _getRoomAndDomainFromURLObject(obj); } diff --git a/react/features/app/functions.web.js b/react/features/app/functions.web.js index a312d48f5..a83de8128 100644 --- a/react/features/app/functions.web.js +++ b/react/features/app/functions.web.js @@ -15,7 +15,7 @@ import JitsiMeetLogStorage from '../../../modules/util/JitsiMeetLogStorage'; const Logger = require('jitsi-meet-logger'); -export { _getRoomAndDomainFromUrlString } from './functions.native'; +export { _parseURIString } from './functions.native'; /** * Determines which route is to be rendered in order to depict a specific Redux diff --git a/react/features/conference/route.js b/react/features/conference/route.js index 8c7309acd..7515f6b0b 100644 --- a/react/features/conference/route.js +++ b/react/features/conference/route.js @@ -40,7 +40,8 @@ function _initConference() { * Promise wrapper on obtain config method. When HttpConfigFetch will be moved * to React app it's better to use load config instead. * - * @param {string} location - URL of the domain. + * @param {string} location - URL of the domain from which the config is to be + * obtained. * @param {string} room - Room name. * @private * @returns {Promise} diff --git a/react/features/unsupported-browser/components/UnsupportedMobileBrowser.js b/react/features/unsupported-browser/components/UnsupportedMobileBrowser.js index 979afda03..28f574999 100644 --- a/react/features/unsupported-browser/components/UnsupportedMobileBrowser.js +++ b/react/features/unsupported-browser/components/UnsupportedMobileBrowser.js @@ -6,8 +6,10 @@ import { Platform } from '../../base/react'; /** * The map of platforms to URLs at which the mobile app for the associated * platform is available for download. + * + * @private */ -const URLS = { +const _URLS = { android: 'https://play.google.com/store/apps/details?id=org.jitsi.meet', ios: 'https://itunes.apple.com/us/app/jitsi-meet/id1165103905' }; @@ -19,7 +21,7 @@ const URLS = { */ class UnsupportedMobileBrowser extends Component { /** - * Mobile browser page component's property types. + * UnsupportedMobileBrowser component's property types. * * @static */ @@ -35,35 +37,32 @@ class UnsupportedMobileBrowser extends Component { } /** - * Constructor of UnsupportedMobileBrowser component. + * Initializes the text and URL of the `Start a conference` / `Join the + * conversation` button which takes the user to the mobile app. * - * @param {Object} props - The read-only React Component props with which - * the new instance is to be initialized. - */ - constructor(props) { - super(props); - - // Bind methods - this._onJoinClick = this._onJoinClick.bind(this); - } - - /** - * React lifecycle method triggered before component will mount. - * - * @returns {void} + * @inheritdoc */ componentWillMount() { - const joinButtonText + const joinText = this.props._room ? 'Join the conversation' : 'Start a conference'; + // If the user installed the app while this Component was displayed + // (e.g. the user clicked the Download the App button), then we would + // like to open the current URL in the mobile app. The only way to do it + // appears to be a link with an app-specific scheme, not a Universal + // Link. + const joinURL = `org.jitsi.meet:${window.location.href}`; + this.setState({ - joinButtonText + joinText, + joinURL }); } /** - * Renders component. + * Implements React's {@link Component#render()}. * + * @inheritdoc * @returns {ReactElement} */ render() { @@ -80,7 +79,7 @@ class UnsupportedMobileBrowser extends Component { You need Jitsi Meet to join a conversation on your mobile

- + @@ -90,33 +89,17 @@ class UnsupportedMobileBrowser extends Component {
then

- +
+ + ); } - - /** - * Handles clicks on the button that joins the local participant in a - * conference. - * - * @private - * @returns {void} - */ - _onJoinClick() { - // If the user installed the app while this Component was displayed - // (e.g. the user clicked the Download the App button), then we would - // like to open the current URL in the mobile app. - - // TODO The only way to do it appears to be a link with an app-specific - // scheme, not a Universal Link. - } } /** diff --git a/react/features/welcome/components/WelcomePage.web.js b/react/features/welcome/components/WelcomePage.web.js index 5b9c0eed4..9e03c6849 100644 --- a/react/features/welcome/components/WelcomePage.web.js +++ b/react/features/welcome/components/WelcomePage.web.js @@ -82,7 +82,9 @@ class WelcomePage extends AbstractWelcomePage { * @returns {string} Domain name. */ _getDomain() { - return `${window.location.protocol}//${window.location.host}/`; + const windowLocation = window.location; + + return `${windowLocation.protocol}//${windowLocation.host}/`; } /**