Skip to content

Commit cd2faa0

Browse files
authored
feat: Voice support (#6918)
1 parent 7771178 commit cd2faa0

308 files changed

Lines changed: 49158 additions & 889 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,14 @@ e2e/e2e_account.ts
8484
**/e2e_account.ts
8585

8686
*.p8
87-
.claude/
8887
.worktrees/
8988
.omc/
89+
.claude/
90+
.agents/
91+
.cursor/
92+
skills-lock.json
93+
CLAUDE.local.md
94+
AGENTS.md
95+
docs/
96+
.superset/
9097
.jest-cache/

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
66

77
Rocket.Chat React Native mobile client. Single-package React Native app (not a monorepo) using Yarn 1.22.22 (npm won't work). Supports iOS 13.4+ and Android 6.0+.
88

9-
- React 19, React Native 0.79, Expo 53
9+
- React 19.1, React Native 0.81, Expo 54
1010
- TypeScript with strict mode, baseUrl set to `app/` (imports resolve from there)
11-
- Min Node: 22.14.0
11+
- Node: engines `>=18`, volta pins 24.13.1
12+
- Read UBIQUITOUS_LANGUAGE.md
1213

1314
## Commands
1415

__mocks__/react-native-callkeep.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default {
2+
setup: jest.fn(),
3+
canMakeMultipleCalls: jest.fn(),
4+
displayIncomingCall: jest.fn(),
5+
endCall: jest.fn(),
6+
setCurrentCallActive: jest.fn(),
7+
setAvailable: jest.fn(),
8+
addEventListener: jest.fn((event, callback) => ({
9+
remove: jest.fn()
10+
}))
11+
};

android/app/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,16 @@ dependencies {
148148

149149
implementation "com.google.code.gson:gson:2.8.9"
150150
implementation "com.tencent:mmkv-static:1.2.10"
151+
implementation "com.github.bumptech.glide:glide:${rootProject.ext.glideVersion}"
151152
implementation 'com.facebook.soloader:soloader:0.10.4'
152153

153154
// For SecureKeystore (EncryptedSharedPreferences)
154155
implementation 'androidx.security:security-crypto:1.1.0'
155156

157+
testImplementation 'junit:junit:4.13.2'
158+
testImplementation 'org.robolectric:robolectric:4.14.1'
159+
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.2'
160+
156161
// For ProcessLifecycleOwner (app foreground detection)
157162
implementation 'androidx.lifecycle:lifecycle-process:2.8.7'
158163
}

android/app/src/main/AndroidManifest.xml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@
1313
<!-- permissions related to jitsi call -->
1414
<uses-permission android:name="android.permission.BLUETOOTH" />
1515

16+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
17+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
18+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
19+
<!-- maxSdkVersion=29: on Android 11+ non-dialer apps cannot obtain this grant; native AudioManager fallback covers the detection path on API 30+. -->
20+
<uses-permission android:name="android.permission.READ_PHONE_STATE" android:maxSdkVersion="29" />
21+
<!-- Strip restricted permissions inherited from react-native-callkeep. The app uses self-managed Telecom (MANAGE_OWN_CALLS) and does not need dialer or phone-number access. -->
22+
<uses-permission android:name="android.permission.CALL_PHONE" tools:node="remove" />
23+
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" tools:node="remove" />
24+
<uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE" />
25+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
26+
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
27+
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
28+
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
29+
<uses-feature android:name="android.hardware.audio.output" android:required="false" />
30+
<uses-feature android:name="android.hardware.microphone" android:required="false" />
31+
<uses-feature android:name="android.hardware.telephony" android:required="false" />
32+
1633
<!-- android 13 notifications -->
1734
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
1835

@@ -105,6 +122,51 @@
105122
<meta-data
106123
android:name="com.bugsnag.android.API_KEY"
107124
android:value="${BugsnagAPIKey}" />
125+
126+
<activity
127+
android:name="chat.rocket.reactnative.voip.IncomingCallActivity"
128+
android:exported="false"
129+
android:launchMode="singleInstance"
130+
android:showOnLockScreen="true"
131+
android:turnScreenOn="true"
132+
android:showWhenLocked="true"
133+
android:theme="@style/Theme.IncomingCall"
134+
android:excludeFromRecents="true"
135+
android:taskAffinity="chat.rocket.reactnative.voip" />
136+
137+
<receiver
138+
android:name="chat.rocket.reactnative.voip.VoipNotification$DeclineReceiver"
139+
android:enabled="true"
140+
android:exported="false" />
141+
142+
<service android:name="io.wazo.callkeep.VoiceConnectionService"
143+
android:label="Wazo"
144+
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
145+
android:exported="true"
146+
android:foregroundServiceType="microphone|phoneCall"
147+
>
148+
<intent-filter>
149+
<action android:name="android.telecom.ConnectionService" />
150+
</intent-filter>
151+
</service>
152+
153+
<service android:name="io.wazo.callkeep.RNCallKeepBackgroundMessagingService" />
154+
155+
<!-- VoIP foreground service for keeping audio calls alive in the background. -->
156+
<service
157+
android:name="chat.rocket.reactnative.voip.VoipCallService"
158+
android:enabled="true"
159+
android:exported="false"
160+
android:foregroundServiceType="phoneCall" />
161+
162+
<!-- react-native-webrtc ships MediaProjectionService (foregroundServiceType=mediaProjection)
163+
for screen sharing. We don't use screen sharing, and Android 15+ forbids starting
164+
restricted foreground service types from BOOT_COMPLETED receivers (expo-notifications
165+
merges one in). Play Console flags this as a crash risk. Strip it from the merged
166+
manifest. To re-enable, remove this block and set enableMediaProjectionService=true. -->
167+
<service
168+
android:name="com.oney.WebRTCModule.MediaProjectionService"
169+
tools:node="remove" />
108170
</application>
109171

110172
<queries>
292 Bytes
Binary file not shown.

android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import chat.rocket.reactnative.storage.MMKVKeyManager;
2121
import chat.rocket.reactnative.storage.SecureStoragePackage;
2222
import chat.rocket.reactnative.notification.VideoConfTurboPackage
2323
import chat.rocket.reactnative.notification.PushNotificationTurboPackage
24+
import chat.rocket.reactnative.VoipTurboPackage
2425
import chat.rocket.reactnative.scroll.InvertedScrollPackage
2526
import chat.rocket.reactnative.input.ExternalInputPackage
2627

@@ -46,6 +47,7 @@ open class MainApplication : Application(), ReactApplication {
4647
add(WatermelonDBJSIPackage())
4748
add(VideoConfTurboPackage())
4849
add(PushNotificationTurboPackage())
50+
add(VoipTurboPackage())
4951
add(SecureStoragePackage())
5052
add(InvertedScrollPackage())
5153
add(ExternalInputPackage())

android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningTurboModule.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ public class SSLPinningTurboModule extends NativeSSLPinningSpec implements KeyCh
4141
private Promise promise;
4242
private static String alias;
4343
private static ReactApplicationContext reactContext;
44+
private static OkHttpClient sharedClient;
45+
46+
public static OkHttpClient getSharedOkHttpClient() {
47+
if (sharedClient != null) {
48+
return sharedClient;
49+
}
50+
if (alias != null) {
51+
OkHttpClient.Builder builder = new OkHttpClient.Builder()
52+
.connectTimeout(0, TimeUnit.MILLISECONDS)
53+
.readTimeout(0, TimeUnit.MILLISECONDS)
54+
.writeTimeout(0, TimeUnit.MILLISECONDS)
55+
.cookieJar(new ReactCookieJarContainer());
56+
57+
SSLSocketFactory sslSocketFactory = getSSLFactory(alias);
58+
X509TrustManager trustManager = getTrustManagerFactory();
59+
if (sslSocketFactory != null) {
60+
builder.sslSocketFactory(sslSocketFactory, trustManager);
61+
}
62+
63+
sharedClient = builder.build();
64+
return sharedClient;
65+
}
66+
return null;
67+
}
4468

4569
public SSLPinningTurboModule(ReactApplicationContext reactContext) {
4670
super(reactContext);
@@ -61,6 +85,10 @@ public void apply(OkHttpClient.Builder builder) {
6185
}
6286

6387
protected OkHttpClient getOkHttpClient() {
88+
OkHttpClient shared = getSharedOkHttpClient();
89+
if (shared != null) {
90+
return shared;
91+
}
6492
OkHttpClient.Builder builder = new OkHttpClient.Builder()
6593
.connectTimeout(0, TimeUnit.MILLISECONDS)
6694
.readTimeout(0, TimeUnit.MILLISECONDS)

android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,8 @@ private void cancelPreviousFallbackNotifications(Ejson ejson) {
404404
}
405405
}
406406

407-
private Bitmap getAvatar(String uri) {
408-
return NotificationHelper.fetchAvatarBitmap(mContext, uri, largeIcon());
407+
private Bitmap getAvatar(String uri, Ejson ejson) {
408+
return NotificationHelper.fetchAvatarBitmap(mContext, uri, ejson, largeIcon());
409409
}
410410

411411
private Bitmap largeIcon() {
@@ -426,7 +426,7 @@ private void notificationIcons(Notification.Builder notification, Bundle bundle)
426426
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
427427
String avatarUri = ejson != null ? ejson.getAvatarUri() : null;
428428
if (avatarUri != null) {
429-
Bitmap avatar = getAvatar(avatarUri);
429+
Bitmap avatar = getAvatar(avatarUri, ejson);
430430
if (avatar != null) {
431431
notification.setLargeIcon(avatar);
432432
}
@@ -506,7 +506,7 @@ private void notificationStyle(Notification.Builder notification, int notId, Bun
506506
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
507507
messageStyle.addMessage(m, timestamp, displaySenderName);
508508
} else {
509-
Bitmap avatar = getAvatar(avatarUri);
509+
Bitmap avatar = getAvatar(avatarUri, ejson);
510510
Person.Builder senderBuilder = new Person.Builder()
511511
.setKey(senderId)
512512
.setName(displaySenderName);

android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public class Ejson {
2525
private static final String TAG = "RocketChat.Ejson";
2626
private static final String TOKEN_KEY = "reactnativemeteor_usertoken-";
2727

28-
String host;
28+
public String host;
2929
String rid;
3030
String type;
3131
Sender sender;
@@ -53,26 +53,13 @@ private MMKV getMMKV() {
5353
return MMKV.mmkvWithID("default", MMKV.SINGLE_PROCESS_MODE);
5454
}
5555

56-
/**
57-
* Helper method to build avatar URI from avatar path.
58-
* Validates server URL and credentials, then constructs the full URI.
59-
*/
60-
private String buildAvatarUri(String avatarPath, String errorContext) {
56+
private String buildAvatarUri(String avatarPath, int sizePx) {
6157
String server = serverURL();
6258
if (server == null || server.isEmpty()) {
63-
Log.w(TAG, "Cannot generate " + errorContext + " avatar URI: serverURL is null");
59+
Log.w(TAG, "Cannot generate avatar URI: serverURL is null");
6460
return null;
6561
}
66-
67-
String userToken = token();
68-
String uid = userId();
69-
70-
String finalUri = server + avatarPath + "?format=png&size=100";
71-
if (!userToken.isEmpty() && !uid.isEmpty()) {
72-
finalUri += "&rc_token=" + userToken + "&rc_uid=" + uid;
73-
}
74-
75-
return finalUri;
62+
return server + avatarPath + "?format=png&size=" + sizePx;
7663
}
7764

7865
public String getAvatarUri() {
@@ -102,23 +89,45 @@ public String getAvatarUri() {
10289
}
10390
}
10491

105-
return buildAvatarUri(avatarPath, "");
92+
return buildAvatarUri(avatarPath, 100);
10693
}
10794

10895
/**
109-
* Generates avatar URI for video conference caller.
96+
* Factory for building caller avatar URIs from host + username (e.g. VoIP payload).
97+
* Caller is package-private, so this is the only way to get avatar URI from outside the package.
98+
*/
99+
public static Ejson forCallerAvatar(String host, String username) {
100+
if (host == null || host.isEmpty() || username == null || username.isEmpty()) {
101+
return null;
102+
}
103+
Ejson ejson = new Ejson();
104+
ejson.host = host;
105+
ejson.caller = new Caller();
106+
ejson.caller.username = username;
107+
return ejson;
108+
}
109+
110+
/**
111+
* Generates avatar URI for video conference caller (default size 100).
110112
* Returns null if caller username is not available (username is required for avatar endpoint).
111113
*/
112114
public String getCallerAvatarUri() {
113-
// Check if caller exists and has username (required - /avatar/{userId} endpoint doesn't exist)
115+
return getCallerAvatarUri(100);
116+
}
117+
118+
/**
119+
* Generates avatar URI for video conference caller with custom size.
120+
* Returns null if caller username is not available.
121+
*/
122+
public String getCallerAvatarUri(int sizePx) {
114123
if (caller == null || caller.username == null || caller.username.isEmpty()) {
115124
Log.w(TAG, "Cannot generate caller avatar URI: caller or username is null");
116125
return null;
117126
}
118127

119128
try {
120129
String avatarPath = "/avatar/" + URLEncoder.encode(caller.username, "UTF-8");
121-
return buildAvatarUri(avatarPath, "caller");
130+
return buildAvatarUri(avatarPath, sizePx);
122131
} catch (UnsupportedEncodingException e) {
123132
Log.e(TAG, "Failed to encode caller username", e);
124133
return null;
@@ -242,4 +251,4 @@ static class Content {
242251
String kid;
243252
String iv;
244253
}
245-
}
254+
}

0 commit comments

Comments
 (0)