Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4702ee7
merge feat.voip-lib
diegolmello Jan 12, 2026
e532171
feat(voip): enhance call handling with UUID mapping and event listeners
diegolmello Jan 12, 2026
1299b0e
Base call UI
diegolmello Jan 12, 2026
d2cef3d
feat(voip): integrate Zustand for call state management and enhance C…
diegolmello Jan 13, 2026
b0b78cd
feat(voip): add simulateCall function for mock call handling in UI de…
diegolmello Jan 13, 2026
0d18314
refactor(CallView): update button handlers and improve UI responsiveness
diegolmello Jan 13, 2026
4b33a79
Add pause-shape-unfilled icon
diegolmello Jan 13, 2026
ece7e27
Base CallHeader
diegolmello Jan 14, 2026
c3dd2ae
toggleFocus
diegolmello Jan 14, 2026
1df1b29
collapse buttons
diegolmello Jan 14, 2026
8f9129e
Header components
diegolmello Jan 14, 2026
8a5de04
Hide header when no call
diegolmello Jan 14, 2026
a9ec70d
Timer
diegolmello Jan 14, 2026
d6229d9
Add use memo
diegolmello Jan 14, 2026
e718561
Add voice call item on sidebar
diegolmello Jan 14, 2026
26502cb
cleanup
diegolmello Jan 14, 2026
db29a47
Temp use @rocket.chat/media-signaling from .tgz
diegolmello Jan 14, 2026
2b16f4b
cleanup
diegolmello Jan 15, 2026
eae9137
Check module and permissions to enable voip
diegolmello Jan 15, 2026
bb2a8bb
Refactor stop method to use optional chaining for media signal listeners
diegolmello Jan 15, 2026
10593d6
voip push first test
diegolmello Jan 16, 2026
b6766f3
Add VoIP call handling with pending call management
diegolmello Jan 16, 2026
ac85af8
Remove pending store and create getInitialEvents on app/index
diegolmello Jan 20, 2026
9b28770
Attempt to make iOS calls work from cold state
diegolmello Jan 20, 2026
5c5e2be
lint and format
diegolmello Jan 20, 2026
01e42e2
Patch callkeep ios
diegolmello Jan 20, 2026
aa3ca88
Temp send iOS voip push token on gcm
diegolmello Jan 20, 2026
548e855
Temp fix require cycle
diegolmello Jan 20, 2026
abbb072
chore: format code and fix lint issues [skip ci]
diegolmello Jan 20, 2026
77cb36e
CallIDUUID module on android and voip push
diegolmello Jan 21, 2026
59f25eb
Add setCallUUID on useCallStore to persist calls accepted on native A…
diegolmello Jan 22, 2026
cd74d43
remove callkeep from notification
diegolmello Jan 23, 2026
9b71cf9
Android Incoming Call UI POC
diegolmello Jan 27, 2026
7348135
chore: format code and fix lint issues [skip ci]
diegolmello Jan 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions __mocks__/react-native-callkeep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default {
setup: jest.fn(),
canMakeMultipleCalls: jest.fn(),
displayIncomingCall: jest.fn(),
endCall: jest.fn(),
setCurrentCallActive: jest.fn(),
addEventListener: jest.fn((event, callback) => ({
remove: jest.fn()
}))
};
24 changes: 24 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@
<!-- permissions related to jitsi call -->
<uses-permission android:name="android.permission.BLUETOOTH" />

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" /> -->
<!-- <uses-permission android:name="android.permission.CALL_PHONE" /> -->
<uses-feature android:name="android.hardware.audio.output" />
<uses-feature android:name="android.hardware.microphone" />
Comment on lines +25 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add android:required="false" to avoid filtering devices.

Without required="false", Play Store will filter out devices that lack these hardware features. Most VoIP apps should work on devices without dedicated audio output or microphone hardware (e.g., tablets using Bluetooth).

Proposed fix
-    <uses-feature android:name="android.hardware.audio.output" />
-    <uses-feature android:name="android.hardware.microphone" />
+    <uses-feature android:name="android.hardware.audio.output" android:required="false" />
+    <uses-feature android:name="android.hardware.microphone" android:required="false" />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<uses-feature android:name="android.hardware.audio.output" />
<uses-feature android:name="android.hardware.microphone" />
<uses-feature android:name="android.hardware.audio.output" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
🤖 Prompt for AI Agents
In `@android/app/src/main/AndroidManifest.xml` around lines 25 - 26, Update the
two <uses-feature> entries for android.hardware.audio.output and
android.hardware.microphone so they don't cause Play Store device filtering:
modify the <uses-feature android:name="android.hardware.audio.output" /> and
<uses-feature android:name="android.hardware.microphone" /> elements to include
android:required="false" (i.e., set the required attribute to false for both
features) so devices without those hardware features are not excluded.


<!-- android 13 notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Expand Down Expand Up @@ -104,6 +115,19 @@
<meta-data
android:name="com.bugsnag.android.API_KEY"
android:value="${BugsnagAPIKey}" />

<service android:name="io.wazo.callkeep.VoiceConnectionService"
android:label="Wazo"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true"
android:foregroundServiceType="microphone"
>
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>

<service android:name="io.wazo.callkeep.RNCallKeepBackgroundMessagingService" />
</application>

<queries>
Expand Down
Binary file modified android/app/src/main/assets/fonts/custom.ttf
Binary file not shown.
64 changes: 35 additions & 29 deletions app/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ThemeContext } from './theme';
import { setCurrentScreen } from './lib/methods/helpers/log';
import { themes } from './lib/constants/colors';
import { emitter } from './lib/methods/helpers';
import CallHeader from './containers/CallHeader/CallHeader';

const createStackNavigator = createNativeStackNavigator;

Expand All @@ -34,6 +35,7 @@ const SetUsernameStack = () => (
const Stack = createStackNavigator<StackParamList>();
const App = memo(({ root, isMasterDetail }: { root: string; isMasterDetail: boolean }) => {
const { theme } = useContext(ThemeContext);

useEffect(() => {
if (root) {
const state = Navigation.navigationRef.current?.getRootState();
Expand All @@ -50,35 +52,39 @@ const App = memo(({ root, isMasterDetail }: { root: string; isMasterDetail: bool
const navTheme = navigationTheme(theme);

return (
<NavigationContainer
theme={navTheme}
ref={Navigation.navigationRef}
onReady={() => {
emitter.emit('navigationReady');
}}
onStateChange={state => {
const previousRouteName = Navigation.routeNameRef.current;
const currentRouteName = getActiveRouteName(state);
if (previousRouteName !== currentRouteName) {
setCurrentScreen(currentRouteName);
}
Navigation.routeNameRef.current = currentRouteName;
}}>
<Stack.Navigator screenOptions={{ headerShown: false, animation: 'none', navigationBarColor: themes[theme].surfaceLight }}>
{root === RootEnum.ROOT_LOADING || root === RootEnum.ROOT_LOADING_SHARE_EXTENSION ? (
<Stack.Screen name='AuthLoading' component={AuthLoadingView} />
) : null}
{root === RootEnum.ROOT_OUTSIDE ? <Stack.Screen name='OutsideStack' component={OutsideStack} /> : null}
{root === RootEnum.ROOT_INSIDE && isMasterDetail ? (
<Stack.Screen name='MasterDetailStack' component={MasterDetailStack} />
) : null}
{root === RootEnum.ROOT_INSIDE && !isMasterDetail ? <Stack.Screen name='InsideStack' component={InsideStack} /> : null}
{root === RootEnum.ROOT_SET_USERNAME ? <Stack.Screen name='SetUsernameStack' component={SetUsernameStack} /> : null}
{root === RootEnum.ROOT_SHARE_EXTENSION ? (
<Stack.Screen name='ShareExtensionStack' component={ShareExtensionStack} />
) : null}
</Stack.Navigator>
</NavigationContainer>
<>
<CallHeader />
<NavigationContainer
theme={navTheme}
ref={Navigation.navigationRef}
onReady={() => {
emitter.emit('navigationReady');
}}
onStateChange={state => {
const previousRouteName = Navigation.routeNameRef.current;
const currentRouteName = getActiveRouteName(state);
if (previousRouteName !== currentRouteName) {
setCurrentScreen(currentRouteName);
}
Navigation.routeNameRef.current = currentRouteName;
}}>
<Stack.Navigator
screenOptions={{ headerShown: false, animation: 'none', navigationBarColor: themes[theme].surfaceLight }}>
{root === RootEnum.ROOT_LOADING || root === RootEnum.ROOT_LOADING_SHARE_EXTENSION ? (
<Stack.Screen name='AuthLoading' component={AuthLoadingView} />
) : null}
{root === RootEnum.ROOT_OUTSIDE ? <Stack.Screen name='OutsideStack' component={OutsideStack} /> : null}
{root === RootEnum.ROOT_INSIDE && isMasterDetail ? (
<Stack.Screen name='MasterDetailStack' component={MasterDetailStack} />
) : null}
{root === RootEnum.ROOT_INSIDE && !isMasterDetail ? <Stack.Screen name='InsideStack' component={InsideStack} /> : null}
{root === RootEnum.ROOT_SET_USERNAME ? <Stack.Screen name='SetUsernameStack' component={SetUsernameStack} /> : null}
{root === RootEnum.ROOT_SHARE_EXTENSION ? (
<Stack.Screen name='ShareExtensionStack' component={ShareExtensionStack} />
) : null}
</Stack.Navigator>
</NavigationContainer>
</>
);
});
const mapStateToProps = (state: any) => ({
Expand Down
2 changes: 1 addition & 1 deletion app/actions/actionsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const METEOR = createRequestTypes('METEOR_CONNECT', [...defaultTypes, 'DI
export const LOGOUT = 'LOGOUT'; // logout is always success
export const DELETE_ACCOUNT = 'DELETE_ACCOUNT';
export const SNIPPETED_MESSAGES = createRequestTypes('SNIPPETED_MESSAGES', ['OPEN', 'READY', 'CLOSE', 'MESSAGES_RECEIVED']);
export const DEEP_LINKING = createRequestTypes('DEEP_LINKING', ['OPEN', 'OPEN_VIDEO_CONF']);
export const DEEP_LINKING = createRequestTypes('DEEP_LINKING', ['OPEN', 'OPEN_VIDEO_CONF', 'VOIP_CALL']);
export const SORT_PREFERENCES = createRequestTypes('SORT_PREFERENCES', ['SET_ALL', 'SET']);
export const SET_CUSTOM_EMOJIS = 'SET_CUSTOM_EMOJIS';
export const ACTIVE_USERS = createRequestTypes('ACTIVE_USERS', ['SET', 'CLEAR']);
Expand Down
21 changes: 21 additions & 0 deletions app/actions/deepLinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ interface IDeepLinkingOpen extends Action {
params: Partial<IParams>;
}

interface IVoipCallParams {
callId: string;
callUUID: string;
host: string;
}

interface IVoipCallOpen extends Action {
params: IVoipCallParams;
}

export function deepLinkingOpen(params: Partial<IParams>): IDeepLinkingOpen {
return {
type: DEEP_LINKING.OPEN,
Expand All @@ -29,3 +39,14 @@ export function deepLinkingClickCallPush(params: any): IDeepLinkingOpen {
params
};
}

/**
* Action to handle VoIP call from push notification.
* Triggers server switching if needed and processes the incoming call.
*/
export function voipCallOpen(params: IVoipCallParams): IVoipCallOpen {
return {
type: DEEP_LINKING.VOIP_CALL,
params
};
}
46 changes: 46 additions & 0 deletions app/containers/CallHeader/CallHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { useTheme } from '../../theme';
import Collapse from './components/Collapse';
import Title from './components/Title';
import EndCall from './components/EndCall';
import { useCallStore } from '../../lib/services/voip/useCallStore';

const styles = StyleSheet.create({
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 12,
paddingBottom: 4,
borderBottomWidth: StyleSheet.hairlineWidth
}
});

const CallHeader = () => {
'use memo';

const { colors } = useTheme();
const insets = useSafeAreaInsets();

const defaultHeaderStyle = {
backgroundColor: colors.surfaceNeutral,
paddingTop: insets.top
};

const call = useCallStore(state => state.call);
if (!call) {
return <View style={defaultHeaderStyle} />;
}

return (
<View style={[styles.header, { ...defaultHeaderStyle, borderBottomColor: colors.strokeLight }]}>
<Collapse />
<Title />
<EndCall />
</View>
);
};

export default CallHeader;
24 changes: 24 additions & 0 deletions app/containers/CallHeader/components/Collapse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import I18n from '../../../i18n';
import { useTheme } from '../../../theme';
import { useCallStore } from '../../../lib/services/voip/useCallStore';
import * as HeaderButton from '../../Header/components/HeaderButton';

const Collapse = () => {
'use memo';

const { colors } = useTheme();
const focused = useCallStore(state => state.focused);
const toggleFocus = useCallStore(state => state.toggleFocus);
return (
<HeaderButton.Container left>
<HeaderButton.Item
accessibilityLabel={I18n.t('Minimize')}
onPress={toggleFocus}
iconName={focused ? 'arrow-collapse' : 'arrow-expand'}
color={colors.fontDefault}
/>
</HeaderButton.Container>
);
};

export default Collapse;
18 changes: 18 additions & 0 deletions app/containers/CallHeader/components/EndCall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import I18n from '../../../i18n';
import { useTheme } from '../../../theme';
import { useCallStore } from '../../../lib/services/voip/useCallStore';
import * as HeaderButton from '../../Header/components/HeaderButton';

const EndCall = () => {
'use memo';

const { colors } = useTheme();
const endCall = useCallStore(state => state.endCall);
return (
<HeaderButton.Container>
<HeaderButton.Item accessibilityLabel={I18n.t('End')} onPress={endCall} iconName='phone-end' color={colors.fontDanger} />
</HeaderButton.Container>
);
};

export default EndCall;
35 changes: 35 additions & 0 deletions app/containers/CallHeader/components/Timer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import { Text } from 'react-native';

import { useCallStore } from '../../../lib/services/voip/useCallStore';

const formatDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const hoursStr = hours > 0 ? `${hours.toString().padStart(2, '0')}:` : '';
return `${hoursStr}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};

const Timer = () => {
'use memo';

const callStartTime = useCallStore(state => state.callStartTime);
const [duration, setDuration] = useState(callStartTime ? Math.floor((new Date().getTime() - callStartTime) / 1000) : 0);

useEffect(() => {
if (!callStartTime) {
return;
}
const updateDuration = () => {
setDuration(Math.floor((Date.now() - callStartTime) / 1000));
};
updateDuration();
const interval = setInterval(updateDuration, 1000);
return () => clearInterval(interval);
}, [callStartTime]);

return <Text>{formatDuration(duration)}</Text>;
};

export default Timer;
47 changes: 47 additions & 0 deletions app/containers/CallHeader/components/Title.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { StyleSheet, Text } from 'react-native';

import { useTheme } from '../../../theme';
import { useCallStore } from '../../../lib/services/voip/useCallStore';
import I18n from '../../../i18n';
import sharedStyles from '../../../views/Styles';
import Timer from './Timer';

const styles = StyleSheet.create({
headerTitle: {
...sharedStyles.textSemibold,
fontSize: 16,
lineHeight: 24
}
});

const Title = () => {
'use memo';

const { colors } = useTheme();
const callState = useCallStore(state => state.callState);
const callStartTime = useCallStore(state => state.callStartTime);
const contact = useCallStore(state => state.contact);

const callerName = contact.displayName || contact.username || I18n.t('Unknown');
const isConnecting = callState === 'none' || callState === 'ringing' || callState === 'accepted';
const isConnected = callState === 'active';

const getHeaderTitle = () => {
if (isConnecting) {
return I18n.t('Connecting');
}
if (isConnected && callStartTime) {
return `${callerName} – `;
}
return callerName;
};

return (
<Text style={[styles.headerTitle, { color: colors.fontDefault }]} testID='call-view-header-title'>
{getHeaderTitle()}
<Timer />
</Text>
);
Comment on lines +39 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Timer renders unconditionally, even during "Connecting" state.

The Timer component is always rendered, but the title logic at line 31 returns "Connecting" without the timer. This results in displaying "Connecting00:00" when isConnecting is true.

 	return (
 		<Text style={[styles.headerTitle, { color: colors.fontDefault }]} testID='call-view-header-title'>
 			{getHeaderTitle()}
-			<Timer />
+			{isConnected && callStartTime ? <Timer /> : null}
 		</Text>
 	);
🤖 Prompt for AI Agents
In `@app/containers/CallHeader/components/Title.tsx` around lines 39 - 44, The
Timer is always rendered causing "Connecting00:00"; update the JSX in Title.tsx
to render <Timer /> only when the header is not in the connecting state—use the
existing isConnecting flag (or the result of getHeaderTitle() === "Connecting")
to conditionally include Timer so when isConnecting is true the Timer is
omitted; locate the getHeaderTitle() call and the Timer component in the return
block and wrap Timer with a conditional check tied to isConnecting (or title
check).

};

export default Title;
1 change: 1 addition & 0 deletions app/containers/CustomIcon/mappedIcons.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export const mappedIcons = {
'pause': 59803,
'pause-filled': 59802,
'pause-shape-filled': 59843,
'pause-shape-unfilled': 59879,
'percentage': 59777,
'phone': 59806,
'phone-disabled': 59804,
Expand Down
2 changes: 1 addition & 1 deletion app/containers/CustomIcon/selection.json

Large diffs are not rendered by default.

Loading
Loading