[Android] Introduce IncomingCallView

It's a separate view (on the native side) and app (on the JavaScript side) so
applications can use it independently.

Co-authored-by: Shuai Li <sli@atlassian.com>
Co-authored-by: Pawel Domas <pawel.domas@jitsi.org>
This commit is contained in:
Saúl Ibarra Corretgé
2018-07-15 16:50:38 +02:00
committed by Lyubo Marinov
parent 39e236a42c
commit ea22d12581
21 changed files with 869 additions and 37 deletions

View File

@@ -0,0 +1,27 @@
/**
* The type of redux action to answer an incoming call.
*
* {
* type: INCOMING_CALL_ANSWERED,
* }
*/
export const INCOMING_CALL_ANSWERED = Symbol('INCOMING_CALL_ANSWERED');
/**
* The type of redux action to decline an incoming call.
*
* {
* type: INCOMING_CALL_DECLINED,
* }
*/
export const INCOMING_CALL_DECLINED = Symbol('INCOMING_CALL_DECLINED');
/**
* The type of redux action to receive an incoming call.
*
* {
* type: INCOMING_CALL_RECEIVED,
* caller: Object
* }
*/
export const INCOMING_CALL_RECEIVED = Symbol('INCOMING_CALL_RECEIVED');

View File

@@ -0,0 +1,49 @@
// @flow
import {
INCOMING_CALL_ANSWERED,
INCOMING_CALL_DECLINED,
INCOMING_CALL_RECEIVED
} from './actionTypes';
/**
* Answers a received incoming call.
*
* @returns {{
* type: INCOMING_CALL_ANSWERED
* }}
*/
export function incomingCallAnswered() {
return {
type: INCOMING_CALL_ANSWERED
};
}
/**
* Declines a received incoming call.
*
* @returns {{
* type: INCOMING_CALL_DECLINED
* }}
*/
export function incomingCallDeclined() {
return {
type: INCOMING_CALL_DECLINED
};
}
/**
* Shows a received incoming call.
*
* @param {Object} caller - The caller of an incoming call.
* @returns {{
* type: INCOMING_CALL_RECEIVED,
* caller: Object
* }}
*/
export function incomingCallReceived(caller: Object) {
return {
type: INCOMING_CALL_RECEIVED,
caller
};
}

View File

@@ -0,0 +1,39 @@
// @flow
import { connect } from 'react-redux';
import { AbstractButton } from '../../../base/toolbox';
import { translate } from '../../../base/i18n';
import type { AbstractButtonProps } from '../../../base/toolbox';
import { incomingCallAnswered } from '../actions';
type Props = AbstractButtonProps & {
/**
* The redux {@code dispatch} function.
*/
dispatch: Function
};
/**
* An implementation of a button which accepts an incoming call.
*/
class AnswerButton extends AbstractButton<Props, *> {
accessibilityLabel = 'incomingCall.answer';
iconName = 'hangup';
label = 'incomingCall.answer';
/**
* Handles clicking / pressing the button, and answers the incoming call.
*
* @protected
* @returns {void}
*/
_handleClick() {
this.props.dispatch(incomingCallAnswered());
}
}
export default translate(connect()(AnswerButton));

View File

@@ -0,0 +1,39 @@
// @flow
import { connect } from 'react-redux';
import { AbstractButton } from '../../../base/toolbox';
import { translate } from '../../../base/i18n';
import type { AbstractButtonProps } from '../../../base/toolbox';
import { incomingCallDeclined } from '../actions';
type Props = AbstractButtonProps & {
/**
* The redux {@code dispatch} function.
*/
dispatch: Function
};
/**
* An implementation of a button which rejects an incoming call.
*/
class DeclineButton extends AbstractButton<Props, *> {
accessibilityLabel = 'incomingCall.decline';
iconName = 'hangup';
label = 'incomingCall.decline';
/**
* Handles clicking / pressing the button, and declines the incoming call.
*
* @protected
* @returns {void}
*/
_handleClick() {
this.props.dispatch(incomingCallDeclined());
}
}
export default translate(connect()(DeclineButton));

View File

@@ -0,0 +1,63 @@
// @flow
import { BaseApp } from '../../../base/app';
import { incomingCallReceived } from '../actions';
import IncomingCallPage from './IncomingCallPage';
type Props = {
/**
* URL of the avatar for the caller.
*/
callerAvatarUrl: string,
/**
* Name of the caller.
*/
callerName: string,
/**
* Whether this is a video call or not.
*/
hasVideo: boolean
};
/**
* Root application component for incoming call.
*
* @extends BaseApp
*/
export default class IncomingCallApp extends BaseApp<Props> {
_init: Promise<*>;
/**
* Navigates to {@link IncomingCallPage} upon mount.
*
* NOTE: This was implmented here instead of in a middleware for
* the APP_WILL_MOUNT action because that would run also for {@link App}.
*
* @returns {void}
*/
componentWillMount() {
super.componentWillMount();
this._init.then(() => {
const { dispatch } = this.state.store;
const {
callerAvatarUrl: avatarUrl,
callerName: name,
hasVideo
} = this.props;
dispatch(incomingCallReceived({
avatarUrl,
name,
hasVideo
}));
super._navigate({ component: IncomingCallPage });
});
}
}

View File

@@ -0,0 +1,182 @@
// @flow
import React, { Component } from 'react';
import { Image, Text, View } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import { Avatar } from '../../../base/participants';
import AnswerButton from './AnswerButton';
import DeclineButton from './DeclineButton';
import styles, {
AVATAR_BORDER_GRADIENT,
BACKGROUND_OVERLAY_GRADIENT,
CALLER_AVATAR_SIZE
} from './styles';
/**
* The type of React {@code Component} props of {@link IncomingCallPage}.
*/
type Props = {
/**
* Caller's avatar URL.
*/
_callerAvatarUrl: string,
/**
* Caller's name.
*/
_callerName: string,
/**
* Whether the call has video or not.
*/
_hasVideo: boolean,
/**
* Helper for translating strings.
*/
t: Function
};
/**
* The React {@code Component} displays an incoming call screen.
*/
class IncomingCallPage extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { t, _callerName, _hasVideo } = this.props;
const callTitle
= _hasVideo
? t('incomingCall.videoCallTitle')
: t('incomingCall.audioCallTitle');
return (
<View style = { styles.pageContainer }>
<View style = { styles.backgroundAvatar }>
<Image
source = {{ uri: this.props._callerAvatarUrl }}
style = { styles.backgroundAvatarImage } />
</View>
<LinearGradient
colors = { BACKGROUND_OVERLAY_GRADIENT }
style = { styles.backgroundOverlayGradient } />
<Text style = { styles.title }>
{ callTitle }
</Text>
<Text
numberOfLines = { 6 }
style = { styles.callerName } >
{ _callerName }
</Text>
<Text style = { styles.productLabel }>
{ t('incomingCall.productLabel') }
</Text>
{ this._renderCallerAvatar() }
{ this._renderButtons() }
</View>
);
}
/**
* Renders caller avatar.
*
* @private
* @returns {React$Node}
*/
_renderCallerAvatar() {
return (
<View style = { styles.avatarContainer }>
<LinearGradient
colors = { AVATAR_BORDER_GRADIENT }
style = { styles.avatarBorder } />
<View style = { styles.avatar }>
<Avatar
size = { CALLER_AVATAR_SIZE }
uri = { this.props._callerAvatarUrl } />
</View>
</View>
);
}
/**
* Renders buttons.
*
* @private
* @returns {React$Node}
*/
_renderButtons() {
const { t } = this.props;
return (
<View style = { styles.buttonsContainer }>
<View style = { styles.buttonWrapper } >
<DeclineButton
styles = { styles.declineButtonStyles } />
<Text style = { styles.buttonText }>
{ t('incomingCall.decline') }
</Text>
</View>
<View style = { styles.buttonWrapper }>
<AnswerButton
styles = { styles.answerButtonStyles } />
<Text style = { styles.buttonText }>
{ t('incomingCall.answer') }
</Text>
</View>
</View>
);
}
}
/**
* Maps (parts of) the redux state to the component's props.
*
* @param {Object} state - The redux state.
* @param {Object} ownProps - The component's own props.
* @private
* @returns {{
* _callerName: string,
* _callerAvatarUrl: string,
* _hasVideo: boolean
* }}
*/
function _mapStateToProps(state) {
const { caller } = state['features/mobile/incoming-call'] || {};
return {
/**
* The caller's avatar url.
*
* @private
* @type {string}
*/
_callerAvatarUrl: caller.avatarUrl,
/**
* The caller's name.
*
* @private
* @type {string}
*/
_callerName: caller.name,
/**
* Whether the call has video or not.
*
* @private
* @type {boolean}
*/
_hasVideo: caller.hasVideo
};
}
export default translate(connect(_mapStateToProps)(IncomingCallPage));

View File

@@ -0,0 +1,2 @@
export { default as IncomingCallApp } from './IncomingCallApp';
export { default as IncomingCallPage } from './IncomingCallPage';

View File

@@ -0,0 +1,149 @@
import {
ColorPalette,
createStyleSheet
} from '../../../base/styles';
export const AVATAR_BORDER_GRADIENT = [ '#4C9AFF', '#0052CC' ];
export const BACKGROUND_OVERLAY_GRADIENT = [ '#0052CC', '#4C9AFF' ];
const BUTTON_SIZE = 56;
export const CALLER_AVATAR_SIZE = 128;
const CALLER_AVATAR_BORDER_WIDTH = 3;
const CALLER_AVATAR_CIRCLE_SIZE
= CALLER_AVATAR_SIZE + (2 * CALLER_AVATAR_BORDER_WIDTH);
const PAGE_PADDING = 48;
const LINE_SPACING = 8;
const _icon = {
alignSelf: 'center',
color: ColorPalette.white,
fontSize: 32
};
const _responseButton = {
alignSelf: 'center',
borderRadius: BUTTON_SIZE / 2,
borderWidth: 0,
flex: 0,
flexDirection: 'row',
height: BUTTON_SIZE,
justifyContent: 'center',
width: BUTTON_SIZE
};
const _text = {
color: ColorPalette.white,
fontSize: 16
};
export default createStyleSheet({
answerButtonStyles: {
iconStyle: {
..._icon,
transform: [
{ rotateZ: '130deg' }
]
},
style: {
..._responseButton,
backgroundColor: ColorPalette.green
},
underlayColor: ColorPalette.buttonUnderlay
},
avatar: {
position: 'absolute',
marginLeft: CALLER_AVATAR_BORDER_WIDTH,
marginTop: CALLER_AVATAR_BORDER_WIDTH
},
avatarBorder: {
borderRadius: CALLER_AVATAR_CIRCLE_SIZE / 2,
height: CALLER_AVATAR_CIRCLE_SIZE,
position: 'absolute',
width: CALLER_AVATAR_CIRCLE_SIZE
},
avatarContainer: {
height: CALLER_AVATAR_CIRCLE_SIZE,
marginTop: LINE_SPACING * 4,
width: CALLER_AVATAR_CIRCLE_SIZE
},
backgroundAvatar: {
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0
},
backgroundAvatarImage: {
flex: 1
},
backgroundOverlayGradient: {
bottom: 0,
left: 0,
opacity: 0.9,
position: 'absolute',
right: 0,
top: 0
},
buttonsContainer: {
alignItems: 'flex-end',
flex: 1,
flexDirection: 'row'
},
buttonText: {
..._text,
alignSelf: 'center',
marginTop: 1.5 * LINE_SPACING
},
buttonWrapper: {
flex: 1
},
callerName: {
..._text,
fontSize: 36,
marginBottom: LINE_SPACING,
marginLeft: PAGE_PADDING,
marginRight: PAGE_PADDING,
marginTop: LINE_SPACING,
textAlign: 'center'
},
declineButtonStyles: {
iconStyle: _icon,
style: {
..._responseButton,
backgroundColor: ColorPalette.red
},
underlayColor: ColorPalette.buttonUnderlay
},
pageContainer: {
alignItems: 'center',
flex: 1,
paddingBottom: PAGE_PADDING,
paddingTop: PAGE_PADDING
},
productLabel: {
..._text
},
title: {
..._text
}
});

View File

@@ -0,0 +1,4 @@
export * from './components';
import './middleware';
import './reducer';

View File

@@ -0,0 +1,31 @@
// @flow
import { MiddlewareRegistry } from '../../base/redux';
import { getSymbolDescription } from '../../base/util';
import { sendEvent } from '../external-api';
import {
INCOMING_CALL_ANSWERED,
INCOMING_CALL_DECLINED
} from './actionTypes';
/**
* Middleware that captures Redux actions and uses the IncomingCallExternalAPI
* module to turn them into native events so the application knows about them.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const result = next(action);
switch (action.type) {
case INCOMING_CALL_ANSWERED:
case INCOMING_CALL_DECLINED:
sendEvent(store, getSymbolDescription(action.type), /* data */ {});
break;
}
return result;
});

View File

@@ -0,0 +1,26 @@
/* @flow */
import { assign, ReducerRegistry } from '../../base/redux';
import {
INCOMING_CALL_ANSWERED,
INCOMING_CALL_DECLINED,
INCOMING_CALL_RECEIVED
} from './actionTypes';
ReducerRegistry.register(
'features/mobile/incoming-call', (state = {}, action) => {
switch (action.type) {
case INCOMING_CALL_ANSWERED:
case INCOMING_CALL_DECLINED:
return assign(state, {
caller: undefined
});
case INCOMING_CALL_RECEIVED:
return assign(state, {
caller: action.caller
});
}
return state;
});