[RN] Add Google Sign In to live streaming

This commit is contained in:
Bettenbuk Zoltan 2018-08-10 12:30:00 +02:00 committed by Saúl Ibarra Corretgé
parent 9fe2b834eb
commit d10d61fb7a
37 changed files with 1091 additions and 129 deletions

View File

@ -41,9 +41,15 @@ android {
dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
compile 'com.android.support:appcompat-v7:22.2.0'
compile 'com.google.android.gms:play-services-auth:15.0.0'
implementation project(':sdk')
debugImplementation 'com.squareup.leakcanary:leakcanary-android:1.6.1'
releaseImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.6.1'
}
if (project.file('google-services.json').exists()) {
apply plugin: 'com.google.gms.google-services'
}

View File

@ -8,6 +8,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:3.0.1'
classpath 'com.google.gms:google-services:3.2.1'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files.
@ -16,6 +17,7 @@ buildscript {
allprojects {
repositories {
maven { url "https://maven.google.com" }
google()
jcenter()
maven { url "$rootDir/../node_modules/jsc-android/dist" }

View File

@ -26,6 +26,9 @@ dependencies {
compile project(':react-native-background-timer')
compile project(':react-native-fast-image')
compile(project(":react-native-google-signin")) {
exclude group: 'com.google.android.gms'
}
compile project(':react-native-immersive')
compile project(':react-native-keep-awake')
compile project(':react-native-linear-gradient')

View File

@ -119,6 +119,7 @@ class ReactInstanceManagerHolder {
.setApplication(application)
.setBundleAssetName("index.android.bundle")
.setJSMainModulePath("index.android")
.addPackage(new co.apptailor.googlesignin.RNGoogleSigninPackage())
.addPackage(new com.BV.LinearGradient.LinearGradientPackage())
.addPackage(new com.calendarevents.CalendarEventsPackage())
.addPackage(new com.corbt.keepawake.KCKeepAwakePackage())

View File

@ -5,6 +5,8 @@ include ':react-native-background-timer'
project(':react-native-background-timer').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-background-timer/android')
include ':react-native-fast-image'
project(':react-native-fast-image').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fast-image/android')
include ':react-native-google-signin'
project(':react-native-google-signin').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-google-signin/android')
include ':react-native-immersive'
project(':react-native-immersive').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-immersive/android')
include ':react-native-keep-awake'

View File

@ -400,6 +400,7 @@ var config = {
externalConnectUrl
firefox_fake_device
googleApiApplicationClientID
googleApiIOSClientID
iAmRecorder
iAmSipGateway
microsoftApiApplicationClientID

22
doc/mobile-google-auth.md Normal file
View File

@ -0,0 +1,22 @@
# Setting up Google Authentication
- Create a Firebase project here: https://firebase.google.com/. You'll need a
signed Android build for that, that can be a debug auto-signed build too, just
retrieve the signing hash.
- Place the generated ```google-services.json``` file in ```android/app```
for Android and the ```GoogleService-Info.plist``` into ```ios/app/src``` for
iOS (you can stop at that step, no need for the driver and the code changes they
suggest in the wizard).
- You may want to exclude these files in YOUR GIT config (do not exclude them in
the ```.gitignore``` of the application itself!).
- Your WEB and iOS client IDs are auto generated during the Firebase project
creation. Find them in the Google Developer console:
https://console.developers.google.com/
- Make sure your config reflects these IDs so then the Redux state of the
feature ```features/base/config``` contains variables
```googleApiApplicationClientID``` and ```googleApiIOSClientID``` with the
respective values.
- Add your iOS client ID as an application URL schema into
```ios/app/src/Info.plist``` (replacing placeholder).
- Enable YouTube API access on the developer console (see above) for live
streaming.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -35,6 +35,8 @@ target 'JitsiMeet' do
pod 'react-native-locale-detector',
:path => '../node_modules/react-native-locale-detector'
pod 'react-native-webrtc', :path => '../node_modules/react-native-webrtc'
pod 'RNGoogleSignin',
:path => '../node_modules/react-native-google-signin'
pod 'RNSound', :path => '../node_modules/react-native-sound'
pod 'RNVectorIcons', :path => '../node_modules/react-native-vector-icons'
pod 'react-native-calendar-events',

View File

@ -7,6 +7,26 @@ PODS:
- DoubleConversion
- glog
- glog (0.3.4)
- GoogleSignIn (4.2.0):
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)"
- "GoogleToolboxForMac/NSString+URLArguments (~> 2.1)"
- GTMOAuth2 (~> 1.0)
- GTMSessionFetcher/Core (~> 1.1)
- GoogleToolboxForMac/DebugUtils (2.1.4):
- GoogleToolboxForMac/Defines (= 2.1.4)
- GoogleToolboxForMac/Defines (2.1.4)
- "GoogleToolboxForMac/NSDictionary+URLArguments (2.1.4)":
- GoogleToolboxForMac/DebugUtils (= 2.1.4)
- GoogleToolboxForMac/Defines (= 2.1.4)
- "GoogleToolboxForMac/NSString+URLArguments (= 2.1.4)"
- "GoogleToolboxForMac/NSString+URLArguments (2.1.4)"
- GTMOAuth2 (1.1.6):
- GTMSessionFetcher (~> 1.1)
- GTMSessionFetcher (1.2.0):
- GTMSessionFetcher/Full (= 1.2.0)
- GTMSessionFetcher/Core (1.2.0)
- GTMSessionFetcher/Full (1.2.0):
- GTMSessionFetcher/Core (= 1.2.0)
- React (0.55.4):
- React/Core (= 0.55.4)
- react-native-background-timer (2.0.0):
@ -63,6 +83,9 @@ PODS:
- React/Core
- React/fishhook
- React/RCTBlob
- RNGoogleSignin (1.0.0-rc3):
- GoogleSignIn
- React
- RNSound (0.10.9):
- React/Core
- RNSound/Core (= 0.10.9)
@ -96,6 +119,7 @@ DEPENDENCIES:
- React/RCTNetwork (from `../node_modules/react-native`)
- React/RCTText (from `../node_modules/react-native`)
- React/RCTWebSocket (from `../node_modules/react-native`)
- RNGoogleSignin (from `../node_modules/react-native-google-signin`)
- RNSound (from `../node_modules/react-native-sound`)
- RNVectorIcons (from `../node_modules/react-native-vector-icons`)
- yoga (from `../node_modules/react-native/ReactCommon/yoga`)
@ -104,6 +128,10 @@ SPEC REPOS:
https://github.com/cocoapods/specs.git:
- boost-for-react-native
- FLAnimatedImage
- GoogleSignIn
- GoogleToolboxForMac
- GTMOAuth2
- GTMSessionFetcher
- SDWebImage
EXTERNAL SOURCES:
@ -127,6 +155,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-locale-detector"
react-native-webrtc:
:path: "../node_modules/react-native-webrtc"
RNGoogleSignin:
:path: "../node_modules/react-native-google-signin"
RNSound:
:path: "../node_modules/react-native-sound"
RNVectorIcons:
@ -140,6 +170,10 @@ SPEC CHECKSUMS:
FLAnimatedImage: 4a0b56255d9b05f18b6dd7ee06871be5d3b89e31
Folly: 211775e49d8da0ca658aebc8eab89d642935755c
glog: 1de0bb937dccdc981596d3b5825ebfb765017ded
GoogleSignIn: 591e46382014e591269f862ba6e7bc0fbd793532
GoogleToolboxForMac: 91c824d21e85b31c2aae9bb011c5027c9b4e738f
GTMOAuth2: c77fe325e4acd453837e72d91e3b5f13116857b2
GTMSessionFetcher: 0c4baf0a73acd0041bf9f71ea018deedab5ea84e
React: aa2040dbb6f317b95314968021bd2888816e03d5
react-native-background-timer: 63dcbf37dbcf294b5c6c071afcdc661fa06a7594
react-native-calendar-events: fe6fbc8ed337a7423c98f2c9012b25f20444de09
@ -147,11 +181,12 @@ SPEC CHECKSUMS:
react-native-keep-awake: 0de4bd66de0c23178107dce0c2fcc3354b2a8e94
react-native-locale-detector: d1b2c6fe5abb56e3a1efb6c2d6f308c05c4251f1
react-native-webrtc: 31b6d3f1e3e2ce373aa43fd682b04367250f807d
RNGoogleSignin: 44debd8c359a662c0e2d585952e88b985bf78008
RNSound: b360b3862d3118ed1c74bb9825696b5957686ac4
RNVectorIcons: c0dbfbf6068fefa240c37b0f71bd03b45dddac44
SDWebImage: 624d6e296c69b244bcede364c72ae0430ac14681
yoga: a23273df0088bf7f2bb7e5d7b00044ea57a2a54a
PODFILE CHECKSUM: 69d3df0b8baa54d636bd653b412ed45db771a3b6
PODFILE CHECKSUM: da74c08f6eb674668c49d8d799f8d9e2476a9fc5
COCOAPODS: 1.5.3

View File

@ -32,6 +32,16 @@
<string>org.jitsi.meet</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.googleusercontent.apps</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.YOUR_ID_HERE</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>

View File

@ -376,6 +376,8 @@
);
inputPaths = (
"${SRCROOT}/../Pods/Target Support Files/Pods-JitsiMeet/Pods-JitsiMeet-resources.sh",
"${PODS_ROOT}/GTMOAuth2/Source/Touch/GTMOAuth2ViewTouch.xib",
"${PODS_ROOT}/GoogleSignIn/Resources/GoogleSignIn.bundle",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Feather.ttf",
@ -390,6 +392,8 @@
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMOAuth2ViewTouch.nib",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Feather.ttf",

View File

@ -502,7 +502,9 @@
"on": "Live Streaming",
"pending": "Starting Live Stream...",
"serviceName": "Live Streaming service",
"signedInAs": "You are currently signed in as:",
"signIn": "Sign in with Google",
"signOut": "Sign out",
"signInCTA": "Sign in or enter your live stream key from YouTube.",
"start": "Start a live stream",
"streamIdHelp": "What's this?",

5
package-lock.json generated
View File

@ -12763,6 +12763,11 @@
"prop-types": "^15.5.10"
}
},
"react-native-google-signin": {
"version": "1.0.0-rc3",
"resolved": "https://registry.npmjs.org/react-native-google-signin/-/react-native-google-signin-1.0.0-rc3.tgz",
"integrity": "sha512-2isJRj262B+48hYRSAwL7feDdPEeiGkhwOE6MPbEkKButra5KJfP4ylcRO/XD99560XDK+/gMTp2ZPIKKCFKaQ=="
},
"react-native-immersive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/react-native-immersive/-/react-native-immersive-1.1.0.tgz",

View File

@ -65,6 +65,7 @@
"react-native-calendar-events": "github:wmcmahan/react-native-calendar-events#cb2731db6684a49b4343e09de7f9c2fcc68bcd9b",
"react-native-callstats": "3.52.0",
"react-native-fast-image": "github:jitsi/react-native-fast-image#1f8c93a5584869848d75cc9b946beb9688efe285",
"react-native-google-signin": "1.0.0-rc3",
"react-native-immersive": "1.1.0",
"react-native-keep-awake": "2.0.6",
"react-native-linear-gradient": "2.4.0",

View File

@ -23,6 +23,8 @@ export const ColorPalette = {
buttonUnderlay: '#495258',
darkGrey: '#555555',
green: '#40b183',
lightGrey: '#AAAAAA',
lighterGrey: '#EEEEEE',
red: '#D00000',
white: 'white',

View File

@ -40,15 +40,11 @@ export function loadGoogleAPI(clientId: string) {
return Promise.resolve();
})
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.LOADED }))
.then(() => dispatch(setGoogleAPIState(GOOGLE_API_STATES.LOADED)))
.then(() => googleApi.isSignedIn())
.then(isSignedIn => {
if (isSignedIn) {
dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN });
dispatch(setGoogleAPIState(GOOGLE_API_STATES.SIGNED_IN));
}
});
}
@ -115,6 +111,25 @@ export function requestLiveStreamsForYouTubeBroadcast(boundStreamID: string) {
});
}
/**
* Sets the current Google API state.
*
* @param {number} googleAPIState - The state to be set.
* @param {Object} googleResponse - The last response from Google.
* @returns {{
* type: SET_GOOGLE_API_STATE,
* googleAPIState: number
* }}
*/
export function setGoogleAPIState(
googleAPIState: number, googleResponse: ?Object) {
return {
type: SET_GOOGLE_API_STATE,
googleAPIState,
googleResponse
};
}
/**
* Forces the Google web client application to prompt for a sign in, such as
* when changing account, and will then fetch available YouTube broadcasts.

View File

@ -0,0 +1,34 @@
// @flow
import { Component } from 'react';
/**
* {@code AbstractGoogleSignInButton} component's property types.
*/
type Props = {
/**
* The callback to invoke when the button is clicked.
*/
onClick: Function,
/**
* True if the user is signed in, so it needs to render a different label
* and maybe different style (for the future).
*/
signedIn?: boolean,
/**
* Function to be used to translate i18n labels.
*/
t: Function
};
/**
* Abstract class of the {@code GoogleSignInButton} to share platform
* independent code.
*
* @inheritdoc
*/
export default class AbstractGoogleSignInButton extends Component<Props> {
}

View File

@ -0,0 +1,62 @@
// @flow
import React from 'react';
import { Image, Text, TouchableOpacity } from 'react-native';
import { translate } from '../../base/i18n';
import AbstractGoogleSignInButton from './AbstractGoogleSignInButton';
import styles from './styles';
/**
* The Google Brand image for Sign In.
*
* NOTE: iOS doesn't handle the react-native-google-signin button component
* well due to our CocoaPods build process (the lib is not intended to be used
* this way), hence the custom button implementation.
*/
const GOOGLE_BRAND_IMAGE
= require('../../../../images/btn_google_signin_dark_normal.png');
/**
* A React Component showing a button to sign in with Google.
*
* @extends Component
*/
class GoogleSignInButton extends AbstractGoogleSignInButton {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { onClick, signedIn, t } = this.props;
if (signedIn) {
return (
<TouchableOpacity
onPress = { onClick }
style = { styles.signOutButton } >
<Text style = { styles.signOutButtonText }>
{ t('liveStreaming.signOut') }
</Text>
</TouchableOpacity>
);
}
return (
<TouchableOpacity
onPress = { onClick }
style = { styles.signInButton } >
<Image
resizeMode = { 'contain' }
source = { GOOGLE_BRAND_IMAGE }
style = { styles.signInImage } />
</TouchableOpacity>
);
}
}
export default translate(GoogleSignInButton);

View File

@ -1,25 +1,18 @@
// @flow
import React, { Component } from 'react';
import React from 'react';
/**
* The type of the React {@code Component} props of {@link GoogleSignInButton}.
*/
type Props = {
import { translate } from '../../base/i18n';
// The callback to invoke when {@code GoogleSignInButton} is clicked.
onClick: Function,
// The text to display within {@code GoogleSignInButton}.
text: string
};
import AbstractGoogleSignInButton from './AbstractGoogleSignInButton';
/**
* A React Component showing a button to sign in with Google.
*
* @extends Component
*/
export default class GoogleSignInButton extends Component<Props> {
class GoogleSignInButton extends AbstractGoogleSignInButton {
/**
* Implements React's {@link Component#render()}.
*
@ -27,6 +20,8 @@ export default class GoogleSignInButton extends Component<Props> {
* @returns {ReactElement}
*/
render() {
const { t } = this.props;
return (
<div
className = 'google-sign-in'
@ -35,9 +30,15 @@ export default class GoogleSignInButton extends Component<Props> {
className = 'google-logo'
src = 'images/googleLogo.svg' />
<div className = 'google-cta'>
{ this.props.text }
{
t(this.props.signedIn
? 'liveStreaming.signOut'
: 'liveStreaming.signIn')
}
</div>
</div>
);
}
}
export default translate(GoogleSignInButton);

View File

@ -1 +1,3 @@
// @flow
export { default as GoogleSignInButton } from './GoogleSignInButton';

View File

@ -0,0 +1,54 @@
// @flow
import { ColorPalette, createStyleSheet } from '../../base/styles';
/**
* For styling explanations, see:
* https://developers.google.com/identity/branding-guidelines
*/
const BUTTON_HEIGHT = 40;
/**
* The styles of the React {@code Components} of google-api.
*/
export default createStyleSheet({
/**
* Image of the sign in button (Google branded).
*/
signInImage: {
flex: 1
},
/**
* An image-based button for sign in.
*/
signInButton: {
alignItems: 'center',
height: BUTTON_HEIGHT,
justifyContent: 'center'
},
/**
* A text-based button for sign out (no sign out button guidance for
* Google).
*/
signOutButton: {
alignItems: 'center',
borderColor: ColorPalette.lightGrey,
borderRadius: 3,
borderWidth: 1,
height: BUTTON_HEIGHT,
justifyContent: 'center'
},
/**
* Text of the sign out button.
*/
signOutButtonText: {
color: ColorPalette.blue,
fontSize: 14,
fontWeight: 'bold'
}
});

View File

@ -1,14 +1,23 @@
// @flow
/**
* The Google API scopes to request access for streaming and calendar.
* Google API URL to retreive streams for a live broadcast of a user.
*
* @type {Array<string>}
* NOTE: The URL must be appended by a broadcast ID returned by a call towards
* {@code API_URL_LIVE_BROADCASTS}.
*
* @type {string}
*/
export const GOOGLE_API_SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/calendar'
];
// eslint-disable-next-line max-len
export const API_URL_BROADCAST_STREAMS = 'https://content.googleapis.com/youtube/v3/liveStreams?part=id%2Csnippet%2Ccdn%2Cstatus&id=';
/**
* Google API URL to retreive live broadcasts of a user.
*
* @type {string}
*/
// eslint-disable-next-line max-len
export const API_URL_LIVE_BROADCASTS = 'https://content.googleapis.com/youtube/v3/liveBroadcasts?broadcastType=all&mine=true&part=id%2Csnippet%2CcontentDetails%2Cstatus';
/**
* Array of API discovery doc URLs for APIs used by the googleApi.
@ -38,5 +47,26 @@ export const GOOGLE_API_STATES = {
/**
* The state in which a user has been logged in through the Google API.
*/
SIGNED_IN: 2
SIGNED_IN: 2,
/**
* The state in which the Google authentication is not available (e.g. Play
* services are not installed on Android).
*/
NOT_AVAILABLE: 3
};
/**
* Google API auth scope to access Google calendar.
*
* @type {string}
*/
export const GOOGLE_SCOPE_CALENDAR = 'https://www.googleapis.com/auth/calendar';
/**
* Google API auth scope to access YouTube streams.
*
* @type {string}
*/
export const GOOGLE_SCOPE_YOUTUBE
= 'https://www.googleapis.com/auth/youtube.readonly';

View File

@ -0,0 +1,173 @@
// @flow
import {
GoogleSignin
} from 'react-native-google-signin';
import {
API_URL_BROADCAST_STREAMS,
API_URL_LIVE_BROADCASTS
} from './constants';
/**
* Class to encapsulate Google API functionalities and provide a similar
* interface to what WEB has. The methods are different, but the point is that
* the export object is similar so no need for different export logic.
*
* For more detailed documentation of the {@code GoogleSignin} API, please visit
* https://github.com/react-native-community/react-native-google-signin.
*/
class GoogleApi {
/**
* Wraps the {@code GoogleSignin.configure} method.
*
* @param {Object} config - The config object to be passed to
* {@code GoogleSignin.configure}.
* @returns {void}
*/
configure(config: Object) {
GoogleSignin.configure(config);
}
/**
* Retrieves the available YouTube streams the user can use for live
* streaming.
*
* @param {string} accessToken - The Google auth token.
* @returns {Promise}
*/
getYouTubeLiveStreams(accessToken: string): Promise<*> {
return new Promise((resolve, reject) => {
// Fetching the list of available broadcasts first.
this._fetchGoogleEndpoint(accessToken,
API_URL_LIVE_BROADCASTS)
.then(broadcasts => {
// Then fetching all the available live streams that the
// user has access to with the broadcasts we retreived
// earlier.
this._getLiveStreamsForBroadcasts(
accessToken, broadcasts).then(resolve, reject);
}, reject);
});
}
/**
* Wraps the {@code GoogleSignin.hasPlayServices} method.
*
* @returns {Promise<*>}
*/
hasPlayServices() {
return GoogleSignin.hasPlayServices();
}
/**
* Wraps the {@code GoogleSignin.signIn} method.
*
* @returns {Promise<*>}
*/
signIn() {
return GoogleSignin.signIn();
}
/**
* Wraps the {@code GoogleSignin.signInSilently} method.
*
* @returns {Promise<*>}
*/
signInSilently() {
return GoogleSignin.signInSilently();
}
/**
* Wraps the {@code GoogleSignin.signOut} method.
*
* @returns {Promise<*>}
*/
signOut() {
return GoogleSignin.signOut();
}
/**
* Helper method to fetch a Google API endpoint in a generic way.
*
* @private
* @param {string} accessToken - The access token used for the API call.
* @param {string} endpoint - The endpoint to fetch, including the URL
* params if needed.
* @returns {Promise}
*/
_fetchGoogleEndpoint(accessToken, endpoint): Promise<*> {
return new Promise((resolve, reject) => {
const headers = {
Authorization: `Bearer ${accessToken}`
};
fetch(endpoint, {
headers
}).then(response => response.json())
.then(responseJSON => {
if (responseJSON.error) {
reject(responseJSON.error.message);
} else {
resolve(responseJSON.items || []);
}
}, reject);
});
}
/**
* Retrieves the available YouTube streams that are available for the
* provided broadcast IDs.
*
* @private
* @param {string} accessToken - The Google access token.
* @param {Array<Object>} broadcasts - The list of broadcasts that we want
* to retreive streams for.
* @returns {Promise}
*/
_getLiveStreamsForBroadcasts(accessToken, broadcasts): Promise<*> {
return new Promise((resolve, reject) => {
const ids = [];
for (const broadcast of broadcasts) {
broadcast.contentDetails
&& broadcast.contentDetails.boundStreamId
&& ids.push(broadcast.contentDetails.boundStreamId);
}
this._fetchGoogleEndpoint(
accessToken,
`${API_URL_BROADCAST_STREAMS}${ids.join(',')}`)
.then(streams => {
const keys = [];
// We construct an array of keys bind with the broadcast
// name for a nice display.
for (const stream of streams) {
const key = stream.cdn.ingestionInfo.streamName;
let title;
// Finding title from the broadcast with the same
// channelId. If not found (unknown scenario), we use
// the key as title again.
for (const broadcast of broadcasts) {
if (broadcast.snippet.channelId
=== stream.snippet.channelId) {
title = broadcast.snippet.title;
}
}
keys.push({
key,
title: title || key
});
}
resolve(keys);
}, reject);
});
}
}
export default new GoogleApi();

View File

@ -1,4 +1,10 @@
import { GOOGLE_API_SCOPES, DISCOVERY_DOCS } from './constants';
import {
API_URL_BROADCAST_STREAMS,
API_URL_LIVE_BROADCASTS,
DISCOVERY_DOCS,
GOOGLE_SCOPE_CALENDAR,
GOOGLE_SCOPE_YOUTUBE
} from './constants';
const GOOGLE_API_CLIENT_LIBRARY_URL = 'https://apis.google.com/js/api.js';
@ -68,7 +74,10 @@ const googleApi = {
api.client.init({
clientId,
discoveryDocs: DISCOVERY_DOCS,
scope: GOOGLE_API_SCOPES.join(' ')
scope: [
GOOGLE_SCOPE_CALENDAR,
GOOGLE_SCOPE_YOUTUBE
].join(' ')
})
.then(resolve)
.catch(reject);
@ -137,10 +146,8 @@ const googleApi = {
* @returns {Promise}
*/
requestAvailableYouTubeBroadcasts() {
const url = this._getURLForLiveBroadcasts();
return this.get()
.then(api => api.client.request(url));
.then(api => api.client.request(API_URL_LIVE_BROADCASTS));
},
/**
@ -152,10 +159,9 @@ const googleApi = {
* @returns {Promise}
*/
requestLiveStreamsForYouTubeBroadcast(boundStreamID) {
const url = this._getURLForLiveStreams(boundStreamID);
return this.get()
.then(api => api.client.request(url));
.then(api => api.client.request(
`${API_URL_BROADCAST_STREAMS}${boundStreamID}`));
},
/**
@ -353,37 +359,6 @@ const googleApi = {
*/
_getGoogleApiClient() {
return window.gapi;
},
/**
* Returns the URL to the Google API endpoint for retrieving the currently
* signed in user's YouTube broadcasts.
*
* @private
* @returns {string}
*/
_getURLForLiveBroadcasts() {
return [
'https://content.googleapis.com/youtube/v3/liveBroadcasts',
'?broadcastType=all',
'&mine=true&part=id%2Csnippet%2CcontentDetails%2Cstatus'
].join('');
},
/**
* Returns the URL to the Google API endpoint for retrieving the live
* streams associated with a YouTube broadcast's bound stream.
*
* @param {string} boundStreamID - The bound stream ID associated with a
* broadcast in YouTube.
* @returns {string}
*/
_getURLForLiveStreams(boundStreamID) {
return [
'https://content.googleapis.com/youtube/v3/liveStreams',
'?part=id%2Csnippet%2Ccdn%2Cstatus',
`&id=${boundStreamID}`
].join('');
}
};

View File

@ -1,6 +1,8 @@
export { GOOGLE_API_STATES } from './constants';
export { default as googleApi } from './googleApi';
// @flow
export * from './actions';
export * from './components';
export * from './constants';
export { default as googleApi } from './googleApi';
import './reducer';

View File

@ -27,7 +27,8 @@ ReducerRegistry.register('features/google-api',
case SET_GOOGLE_API_STATE:
return {
...state,
googleAPIState: action.googleAPIState
googleAPIState: action.googleAPIState,
googleResponse: action.googleResponse
};
case SET_GOOGLE_API_PROFILE:
return {

View File

@ -91,8 +91,8 @@ export type State = {
* but the abstraction of its properties are already present in this abstract
* class.
*/
export default class AbstractStartLiveStreamDialog
extends Component<Props, State> {
export default class AbstractStartLiveStreamDialog<P: Props>
extends Component<P, State> {
_isMounted: boolean;
/**
@ -100,7 +100,7 @@ export default class AbstractStartLiveStreamDialog
*
* @inheritdoc
*/
constructor(props: Props) {
constructor(props: P) {
super(props);
this.state = {
@ -134,10 +134,6 @@ export default class AbstractStartLiveStreamDialog
*/
componentDidMount() {
this._isMounted = true;
if (this.props._googleApiApplicationClientID) {
this._onInitializeGoogleApi();
}
}
/**
@ -197,13 +193,6 @@ export default class AbstractStartLiveStreamDialog
*/
_onGetYouTubeBroadcasts: () => Promise<*>;
/**
* Loads the Google client application used for fetching stream keys.
* If the user is already logged in, then a request for available YouTube
* broadcasts is also made.
*/
_onInitializeGoogleApi: () => Object;
_onStreamKeyChange: string => void;
/**
@ -291,6 +280,8 @@ export default class AbstractStartLiveStreamDialog
* @returns {{
* _conference: Object,
* _googleApiApplicationClientID: string,
* _googleAPIState: number,
* _googleProfileEmail: string,
* _streamKey: string
* }}
*/

View File

@ -0,0 +1,254 @@
// @flow
import React, { Component } from 'react';
import { Text, View } from 'react-native';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import {
GOOGLE_API_STATES,
GOOGLE_SCOPE_YOUTUBE,
googleApi,
GoogleSignInButton,
setGoogleAPIState
} from '../../../google-api';
import styles from './styles';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Prop type of the component {@code GoogleSigninForm}.
*/
type Props = {
/**
* The ID for the Google client application used for making stream key
* related requests.
*/
clientId: string,
/**
* The Redux dispatch Function.
*/
dispatch: Function,
/**
* The current state of the Google api as defined in {@code constants.js}.
*/
googleAPIState: number,
/**
* The recently received Google response.
*/
googleResponse: Object,
/**
* The ID for the Google client application used for making stream key
* related requests on iOS.
*/
iOSClientId: string,
/**
* A callback to be invoked when an authenticated user changes, so
* then we can get (or clear) the YouTube stream key.
*/
onUserChanged: Function,
/**
* Function to be used to translate i18n labels.
*/
t: Function
};
/**
* Class to render a google sign in form, or a google stream picker dialog.
*
* @extends Component
*/
class GoogleSigninForm extends Component<Props> {
/**
* Instantiates a new {@code GoogleSigninForm} component.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._logGoogleError = this._logGoogleError.bind(this);
this._onGoogleButtonPress = this._onGoogleButtonPress.bind(this);
}
/**
* Implements React's Component.componentDidMount.
*
* @inheritdoc
*/
componentDidMount() {
if (!this.props.clientId) {
// NOTE: This is a developer error message, not intended for the
// user to see.
logger.error('Missing clientID');
this._setApiState(GOOGLE_API_STATES.NOT_AVAILABLE);
return;
}
googleApi.hasPlayServices()
.then(() => {
googleApi.configure({
iosClientId: this.props.iOSClientId,
offlineAccess: false,
scopes: [ GOOGLE_SCOPE_YOUTUBE ],
webClientId: this.props.clientId
});
googleApi.signInSilently().then(response => {
this._setApiState(response
? GOOGLE_API_STATES.SIGNED_IN
: GOOGLE_API_STATES.LOADED,
response);
}, () => {
this._setApiState(GOOGLE_API_STATES.LOADED);
});
})
.catch(error => {
this._logGoogleError(error);
this._setApiState(GOOGLE_API_STATES.NOT_AVAILABLE);
});
}
/**
* Renders the component.
*
* @inheritdoc
*/
render() {
const { t } = this.props;
const { googleAPIState, googleResponse } = this.props;
const signedInUser = googleResponse
&& googleResponse.user
&& googleResponse.user.email;
if (googleAPIState === GOOGLE_API_STATES.NOT_AVAILABLE
|| googleAPIState === GOOGLE_API_STATES.NEEDS_LOADING
|| typeof googleAPIState === 'undefined') {
return null;
}
return (
<View style = { styles.formWrapper }>
<View style = { styles.helpText }>
{ signedInUser ? <Text>
{ `${t('liveStreaming.signedInAs')} ${signedInUser}` }
</Text> : <Text>
{ t('liveStreaming.signInCTA') }
</Text> }
</View>
<GoogleSignInButton
onClick = { this._onGoogleButtonPress }
signedIn = {
googleAPIState === GOOGLE_API_STATES.SIGNED_IN } />
</View>
);
}
_logGoogleError: Object => void
/**
* A helper function to log developer related errors.
*
* @private
* @param {Object} error - The error to be logged.
* @returns {void}
*/
_logGoogleError(error) {
// NOTE: This is a developer error message, not intended for the
// user to see.
logger.error('Google API error. Possible cause: bad config.', error);
}
_onGoogleButtonPress: () => void
/**
* Callback to be invoked when the user presses the Google button,
* regardless of being logged in or out.
*
* @private
* @returns {void}
*/
_onGoogleButtonPress() {
const { googleResponse } = this.props;
if (googleResponse && googleResponse.user) {
// the user is signed in
this._onSignOut();
} else {
this._onSignIn();
}
}
_onSignIn: () => void
/**
* Initiates a sign in if the user is not signed in yet.
*
* @private
* @returns {void}
*/
_onSignIn() {
googleApi.signIn().then(response => {
this._setApiState(GOOGLE_API_STATES.SIGNED_IN, response);
}, this._logGoogleError);
}
_onSignOut: () => void
/**
* Initiates a sign out if the user is signed in.
*
* @private
* @returns {void}
*/
_onSignOut() {
googleApi.signOut().then(response => {
this._setApiState(GOOGLE_API_STATES.LOADED, response);
}, this._logGoogleError);
}
/**
* Updates the API (Google Auth) state.
*
* @private
* @param {number} apiState - The state of the API.
* @param {?Object} googleResponse - The response from the API.
* @returns {void}
*/
_setApiState(apiState, googleResponse) {
this.props.onUserChanged(googleResponse);
this.props.dispatch(setGoogleAPIState(apiState, googleResponse));
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code GoogleSigninForm} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* googleAPIState: number,
* googleResponse: Object
* }}
*/
function _mapStateToProps(state: Object) {
const { googleAPIState, googleResponse } = state['features/google-api'];
return {
googleAPIState,
googleResponse
};
}
export default translate(connect(_mapStateToProps)(GoogleSigninForm));

View File

@ -5,20 +5,34 @@ import { View } from 'react-native';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import { googleApi } from '../../../google-api';
import { setLiveStreamKey } from '../../actions';
import AbstractStartLiveStreamDialog, {
_mapStateToProps,
type Props
_mapStateToProps as _abstractMapStateToProps,
type Props as AbstractProps
} from './AbstractStartLiveStreamDialog';
import GoogleSigninForm from './GoogleSigninForm';
import StreamKeyForm from './StreamKeyForm';
import StreamKeyPicker from './StreamKeyPicker';
import styles from './styles';
type Props = AbstractProps & {
/**
* The ID for the Google client application used for making stream key
* related requests on iOS.
*/
_googleApiIOSClientID: string
};
/**
* A React Component for requesting a YouTube stream key to use for live
* streaming of the current conference.
*/
class StartLiveStreamDialog extends AbstractStartLiveStreamDialog {
class StartLiveStreamDialog extends AbstractStartLiveStreamDialog<Props> {
/**
* Constructor of the component.
*
@ -28,27 +42,13 @@ class StartLiveStreamDialog extends AbstractStartLiveStreamDialog {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this);
this._onStreamKeyChangeNative
= this._onStreamKeyChangeNative.bind(this);
this._onStreamKeyPick = this._onStreamKeyPick.bind(this);
this._onUserChanged = this._onUserChanged.bind(this);
this._renderDialogContent = this._renderDialogContent.bind(this);
}
_onInitializeGoogleApi: () => Promise<*>
/**
* Loads the Google client application used for fetching stream keys.
* If the user is already logged in, then a request for available YouTube
* broadcasts is also made.
*
* @private
* @returns {Promise}
*/
_onInitializeGoogleApi() {
// This is a placeholder method for the Google feature.
return Promise.resolve();
}
_onStreamKeyChange: string => void
_onStreamKeyChangeNative: string => void;
@ -70,6 +70,49 @@ class StartLiveStreamDialog extends AbstractStartLiveStreamDialog {
this._onStreamKeyChange(streamKey);
}
_onStreamKeyPick: string => void
/**
* Callback to be invoked when the user selects a stream from the picker.
*
* @private
* @param {string} streamKey - The key of the selected stream.
* @returns {void}
*/
_onStreamKeyPick(streamKey) {
this.setState({
streamKey
});
}
_onUserChanged: Object => void
/**
* A callback to be invoked when an authenticated user changes, so
* then we can get (or clear) the YouTube stream key.
*
* TODO: handle errors by showing some indication to the user.
*
* @private
* @param {Object} response - The retreived signin response.
* @returns {void}
*/
_onUserChanged(response) {
if (response && response.accessToken) {
googleApi.getYouTubeLiveStreams(response.accessToken)
.then(broadcasts => {
this.setState({
broadcasts
});
});
} else {
this.setState({
broadcasts: undefined,
streamKey: undefined
});
}
}
_renderDialogContent: () => React$Component<*>
/**
@ -79,14 +122,37 @@ class StartLiveStreamDialog extends AbstractStartLiveStreamDialog {
*/
_renderDialogContent() {
return (
<View>
<View style = { styles.startDialogWrapper }>
<GoogleSigninForm
clientId = { this.props._googleApiApplicationClientID }
iOSClientId = { this.props._googleApiIOSClientID }
onUserChanged = { this._onUserChanged } />
<StreamKeyPicker
broadcasts = { this.state.broadcasts }
onChange = { this._onStreamKeyPick } />
<StreamKeyForm
onChange = { this._onStreamKeyChangeNative }
value = { this.props._streamKey } />
value = { this.state.streamKey || this.props._streamKey } />
</View>
);
}
}
/**
* Maps part of the Redux state to the component's props.
*
* @param {Object} state - The Redux state.
* @returns {{
* _googleApiApplicationClientID: string
* }}
*/
function _mapStateToProps(state: Object) {
return {
..._abstractMapStateToProps(state),
_googleApiIOSClientID:
state['features/base/config'].googleApiIOSClientID
};
}
export default translate(connect(_mapStateToProps)(StartLiveStreamDialog));

View File

@ -21,7 +21,7 @@ import AbstractStartLiveStreamDialog, {
_mapStateToProps,
type Props
} from './AbstractStartLiveStreamDialog';
import BroadcastsDropdown from './BroadcastsDropdown';
import StreamKeyPicker from './StreamKeyPicker';
import StreamKeyForm from './StreamKeyForm';
/**
@ -31,7 +31,7 @@ import StreamKeyForm from './StreamKeyForm';
* @extends Component
*/
class StartLiveStreamDialog
extends AbstractStartLiveStreamDialog {
extends AbstractStartLiveStreamDialog<Props> {
/**
* Initializes a new {@code StartLiveStreamDialog} instance.
@ -53,6 +53,21 @@ class StartLiveStreamDialog
this._renderDialogContent = this._renderDialogContent.bind(this);
}
/**
* Implements {@link Component#componentDidMount()}. Invoked immediately
* after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
componentDidMount() {
super.componentDidMount();
if (this.props._googleApiApplicationClientID) {
this._onInitializeGoogleApi();
}
}
_onInitializeGoogleApi: () => Promise<*>;
/**
@ -237,18 +252,15 @@ class StartLiveStreamDialog
switch (this.props._googleAPIState) {
case GOOGLE_API_STATES.LOADED:
googleContent = (
<GoogleSignInButton
onClick = { this._onGoogleSignIn }
text = { t('liveStreaming.signIn') } />
);
googleContent
= <GoogleSignInButton onClick = { this._onGoogleSignIn } />;
helpText = t('liveStreaming.signInCTA');
break;
case GOOGLE_API_STATES.SIGNED_IN:
googleContent = (
<BroadcastsDropdown
<StreamKeyPicker
broadcasts = { broadcasts }
onBroadcastSelected = { this._onYouTubeBroadcastIDSelected }
selectedBoundStreamID = { selectedBoundStreamID } />
@ -285,8 +297,7 @@ class StartLiveStreamDialog
if (this.state.errorType !== undefined) {
googleContent = (
<GoogleSignInButton
onClick = { this._onRequestGoogleSignIn }
text = { t('liveStreaming.signIn') } />
onClick = { this._onRequestGoogleSignIn } />
);
helpText = this._getGoogleErrorMessageToDisplay();
}

View File

@ -39,7 +39,7 @@ class StreamKeyForm extends AbstractStreamKeyForm {
const { t } = this.props;
return (
<View style = { styles.streamKeyFormWrapper }>
<View style = { styles.formWrapper }>
<Text style = { styles.streamKeyInputLabel }>
{
t('dialog.streamKey')

View File

@ -0,0 +1,122 @@
// @flow
import React, { Component } from 'react';
import { Text, TouchableHighlight, View } from 'react-native';
import { translate } from '../../../base/i18n';
import styles, { ACTIVE_OPACITY, TOUCHABLE_UNDERLAY } from './styles';
type Props = {
/**
* The list of broadcasts the user can pick from.
*/
broadcasts: ?Array<Object>,
/**
* Callback to be invoked when the user picked a broadcast. To be invoked
* with a single key (string).
*/
onChange: Function,
/**
* Function to be used to translate i18n labels.
*/
t: Function
}
type State = {
/**
* The key of the currently selected stream.
*/
streamKey: ?string
}
/**
* Class to implement a stream key picker (dropdown) component to allow the user
* to choose from the available Google Broadcasts/Streams.
*
* NOTE: This component is currently only used on mobile, but it is advised at
* a later point to unify mobile and web logic for this functionality. But it's
* out of the scope for now of the mobile live streaming functionality.
*/
class StreamKeyPicker extends Component<Props, State> {
/**
* Instantiates a new instance of StreamKeyPicker.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this.state = {
streamKey: null
};
this._onStreamPick = this._onStreamPick.bind(this);
}
/**
* Renders the component.
*
* @inheritdoc
*/
render() {
const { broadcasts } = this.props;
if (!broadcasts || !broadcasts.length) {
return null;
}
return (
<View style = { styles.formWrapper }>
<View style = { styles.streamKeyPickerCta }>
<Text>
{ this.props.t('liveStreaming.choose') }
</Text>
</View>
<View style = { styles.streamKeyPickerWrapper } >
{ broadcasts.map((broadcast, index) =>
(<TouchableHighlight
activeOpacity = { ACTIVE_OPACITY }
key = { index }
onPress = { this._onStreamPick(broadcast.key) }
style = { [
styles.streamKeyPickerItem,
this.state.streamKey === broadcast.key
? styles.streamKeyPickerItemHighlight : null
] }
underlayColor = { TOUCHABLE_UNDERLAY }>
<Text style = { styles.streamKeyPickerItemText }>
{ broadcast.title }
</Text>
</TouchableHighlight>))
}
</View>
</View>
);
}
_onStreamPick: string => Function
/**
* Callback to be invoked when the user picks a stream from the list.
*
* @private
* @param {string} streamKey - The key of the stream selected.
* @returns {Function}
*/
_onStreamPick(streamKey) {
return () => {
this.setState({
streamKey
});
this.props.onChange(streamKey);
};
}
}
export default translate(StreamKeyPicker);

View File

@ -13,7 +13,7 @@ import { translate } from '../../../base/i18n';
*
* @extends Component
*/
class BroadcastsDropdown extends PureComponent {
class StreamKeyPicker extends PureComponent {
/**
* Default values for {@code StreamKeyForm} component's properties.
*
@ -24,7 +24,7 @@ class BroadcastsDropdown extends PureComponent {
};
/**
* {@code BroadcastsDropdown} component's property types.
* {@code StreamKeyPicker} component's property types.
*/
static propTypes = {
/**
@ -64,10 +64,10 @@ class BroadcastsDropdown extends PureComponent {
};
/**
* Initializes a new {@code BroadcastsDropdown} instance.
* Initializes a new {@code StreamKeyPicker} instance.
*
* @param {Props} props - The React {@code Component} props to initialize
* the new {@code BroadcastsDropdown} instance with.
* the new {@code StreamKeyPicker} instance with.
*/
constructor(props) {
super(props);
@ -166,4 +166,4 @@ class BroadcastsDropdown extends PureComponent {
}
}
export default translate(BroadcastsDropdown);
export default translate(StreamKeyPicker);

View File

@ -2,6 +2,16 @@
import { BoxModel, ColorPalette, createStyleSheet } from '../../../base/styles';
/**
* Opacity of the TouchableHighlight.
*/
export const ACTIVE_OPACITY = 0.3;
/**
* Underlay of the TouchableHighlight.
*/
export const TOUCHABLE_UNDERLAY = ColorPalette.lightGrey;
/**
* The styles of the React {@code Components} of LiveStream.
*/
@ -20,22 +30,91 @@ export default createStyleSheet({
fontWeight: 'bold'
},
streamKeyFormWrapper: {
/**
* Generic component to wrap form sections into achieving a unified look.
*/
formWrapper: {
alignItems: 'stretch',
flexDirection: 'column',
padding: BoxModel.padding
},
/**
* Explaining text on the top of the sign in form.
*/
helpText: {
marginBottom: BoxModel.margin
},
/**
* Wrapper for the StartLiveStreamDialog form.
*/
startDialogWrapper: {
flexDirection: 'column'
},
/**
* Helper link text.
*/
streamKeyHelp: {
alignSelf: 'flex-end'
},
/**
* Input field to manually enter stream key.
*/
streamKeyInput: {
alignSelf: 'stretch',
height: 50
},
/**
* Label for the previous field.
*/
streamKeyInputLabel: {
alignSelf: 'flex-start'
},
/**
* Custom component to pick a broadcast from the list fetched from Google.
*/
streamKeyPicker: {
alignSelf: 'stretch',
flex: 1,
height: 40,
marginHorizontal: 4,
width: 300
},
/**
* CTA (label) of the picker.
*/
streamKeyPickerCta: {
marginBottom: 8
},
/**
* Style of a single item in the list.
*/
streamKeyPickerItem: {
padding: 4
},
/**
* Additional style for the selected item.
*/
streamKeyPickerItemHighlight: {
backgroundColor: ColorPalette.lighterGrey
},
/**
* Overall wrapper for the picker.
*/
streamKeyPickerWrapper: {
borderColor: ColorPalette.lightGrey,
borderRadius: 3,
borderWidth: 1,
flexDirection: 'column'
}
});

View File

@ -1,5 +1,4 @@
import { ReducerRegistry } from '../base/redux';
import { PersistenceRegistry } from '../base/storage';
import {
CLEAR_RECORDING_SESSIONS,
RECORDING_SESSION_UPDATED,
@ -17,13 +16,6 @@ const DEFAULT_STATE = {
*/
const STORE_NAME = 'features/recording';
/**
* Sets up the persistence of the feature {@code recording}.
*/
PersistenceRegistry.register(STORE_NAME, {
streamKey: true
}, DEFAULT_STATE);
/**
* Reduces the Redux actions of the feature features/recording.
*/