Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import com.twilio.voice.CallInvite;
import com.twilio.voice.CancelledCallInvite;

class CallRecordDatabase {
public class CallRecordDatabase {
public static class CallRecord {
public enum CallInviteState { NONE, ACTIVE, USED }
public enum Direction { INCOMING, OUTGOING }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.twiliovoicereactnative;

class Constants {
public class Constants {
public static final String VOICE_CHANNEL_GROUP = "notification-group";
public static final String VOICE_CHANNEL_LOW_IMPORTANCE = "notification-channel-low-importance";
public static final String VOICE_CHANNEL_HIGH_IMPORTANCE = "notification-channel-high-importance";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,59 @@

import android.content.Context;
import android.media.AudioAttributes;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.RingtoneManager;
import android.media.SoundPool;
import android.net.Uri;
import android.os.Build;
import android.os.VibrationAttributes;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.os.VibratorManager;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

class MediaPlayerManager {
private static final SDKLog logger = new SDKLog(MediaPlayerManager.class);
public enum SoundTable {
INCOMING,
OUTGOING,
DISCONNECT,
RINGTONE
}
private final Context context;
private final SoundPool soundPool;
private final Map<SoundTable, Integer> soundMap;
private int activeStream;
// Close patch (COMMS-698): track every active stream id rather than only the
// last one. The upstream single `activeStream` field is overwritten whenever
// play() runs again before stop(), which orphans the previous (looping)
// stream so it can never be stopped -- e.g. when a second incoming call
// arrives while the first is still ringing. That leaves the ringtone playing
// forever until the process is killed.
private final Set<Integer> activeStreams;
// Close patch (COMMS-697): the incoming ring is played on a dedicated
// MediaPlayer -- NOT the SoundPool -- so it can use the device's *default
// ringtone* on the ring audio stream (USAGE_NOTIFICATION_RINGTONE ->
// STREAM_RING). That makes the ring honor the user's chosen ringtone and
// ringer volume (silent / vibrate => 0 volume => inaudible), instead of the
// bundled R.raw.incoming sample played at fixed full volume on the voice-call
// stream (USAGE_VOICE_COMMUNICATION).
private MediaPlayer incomingPlayer;
// Close patch (COMMS-697): drive a continuous, repeating vibration for the
// duration of an incoming call ring. Android only vibrates a notification
// once (when it posts), so a proper "ringing" buzz has to be driven manually
// and cancelled the moment ringing ends. It shares the INCOMING play()/stop()
// lifecycle so every place that stops the ring also stops the vibration.
private Vibrator incomingVibrator;
// wait 0ms, buzz 1000ms, pause 1000ms -- repeats until cancelled.
private static final long[] INCOMING_VIBRATION_PATTERN = {0L, 1000L, 1000L};

MediaPlayerManager(Context context) {
this.context = context.getApplicationContext();
soundPool = (new SoundPool.Builder())
.setMaxStreams(2)
.setAudioAttributes(
Expand All @@ -27,31 +63,185 @@ public enum SoundTable {
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build())
.build();
activeStream = 0;
activeStreams = new HashSet<>();
soundMap = new HashMap<>();
soundMap.put(SoundTable.INCOMING, soundPool.load(context, R.raw.incoming, 1));
soundMap.put(SoundTable.OUTGOING, soundPool.load(context, R.raw.outgoing, 1));
soundMap.put(SoundTable.DISCONNECT, soundPool.load(context, R.raw.disconnect, 1));
soundMap.put(SoundTable.RINGTONE, soundPool.load(context, R.raw.ringtone, 1));
}

public void play(final SoundTable sound) {
activeStream = soundPool.play(
public synchronized void play(final SoundTable sound) {
// Close patch (COMMS-698): stop any currently-playing stream before
// starting a new one so a previous looping stream can never be orphaned.
stop();
// Close patch (COMMS-697): route the incoming ring through the device's
// default ringtone on the ring stream instead of the bundled SoundPool
// sample.
if (SoundTable.INCOMING == sound) {
playIncomingRingtone();
return;
}
int streamId = soundPool.play(
soundMap.get(sound),
1.f,
1.f,
1,
(SoundTable.DISCONNECT== sound) ? 0 : -1,
1.f);
if (streamId != 0) {
activeStreams.add(streamId);
}
}

public void stop() {
soundPool.stop(activeStream);
activeStream = 0;
// Close patch (COMMS-697): play the system default ringtone, looping, on the
// ring stream so it follows the user's ringtone choice and ringer volume.
// Falls back to the bundled R.raw.incoming sample if the device exposes no
// default ringtone or the MediaPlayer fails to start.
private void playIncomingRingtone() {
// Vibrate for the whole ring, independent of whether a ringtone sound plays
// (e.g. Vibrate mode has no sound but should still buzz).
startIncomingVibration();

Uri ringtoneUri = RingtoneManager.getActualDefaultRingtoneUri(
context, RingtoneManager.TYPE_RINGTONE);
if (null == ringtoneUri) {
// Some devices/profiles have no ringtone set (e.g. "None"); fall back to
// the bundled sound rather than a system sound that isn't a ringtone.
logger.warning("No default ringtone available, falling back to bundled sound");
playBundledIncoming();
return;
}
// Close patch (COMMS-697): declare the player outside the try so the catch
// can release it. If setDataSource()/prepare() throws (e.g. a stale/deleted
// ringtone URI), the local player would otherwise leak -- guarding the
// incomingPlayer field instead is dead code, since play() calls stop()
// (nulling incomingPlayer) immediately before this runs.
MediaPlayer player = null;
try {
player = new MediaPlayer();
player.setAudioAttributes(
new AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build());
player.setDataSource(context, ringtoneUri);
player.setLooping(true);
player.prepare();
player.start();
incomingPlayer = player;
} catch (Exception e) {
logger.warning(e, "Failed to play default ringtone, falling back to bundled sound");
if (null != player) {
player.release();
}
incomingPlayer = null;
playBundledIncoming();
}
}

private void playBundledIncoming() {
int streamId = soundPool.play(soundMap.get(SoundTable.INCOMING), 1.f, 1.f, 1, -1, 1.f);
if (streamId != 0) {
activeStreams.add(streamId);
}
}

// Close patch (COMMS-697): start a repeating call-style vibration, unless the
// device is in silent/mute mode (the OS suppresses ring vibration there and
// the user explicitly asked for silence). Vibrate + normal modes buzz.
private void startIncomingVibration() {
AudioManager audioManager =
(AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
if (null != audioManager
&& AudioManager.RINGER_MODE_SILENT == audioManager.getRingerMode()) {
return;
}
Vibrator vibrator = resolveVibrator();
if (null == vibrator || !vibrator.hasVibrator()) {
logger.warning("startIncomingVibration: no vibrator available");
return;
}
// Close patch (COMMS-697): tag the vibration with RINGTONE usage. Without a
// usage, Android 13+/Samsung does not treat it as a call vibration and
// cancels the *repeating* waveform after the first cycle, so it buzzes once
// instead of ringing continuously. VibrationAttributes (API 33+) or
// AudioAttributes(USAGE_NOTIFICATION_RINGTONE) mark it as a ringtone buzz.
final AudioAttributes ringtoneAudioAttributes =
new AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build();
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
vibrator.vibrate(
VibrationEffect.createWaveform(INCOMING_VIBRATION_PATTERN, 0),
new VibrationAttributes.Builder()
.setUsage(VibrationAttributes.USAGE_RINGTONE)
.build());
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createWaveform(INCOMING_VIBRATION_PATTERN, 0),
ringtoneAudioAttributes);
} else {
// API 24-25: VibrationEffect doesn't exist; use the legacy pattern
// overload, which still accepts AudioAttributes for the usage hint.
vibrator.vibrate(INCOMING_VIBRATION_PATTERN, 0, ringtoneAudioAttributes);
}
incomingVibrator = vibrator;
} catch (Exception e) {
logger.warning(e, "Failed to start incoming-call vibration");
incomingVibrator = null;
}
}

private void stopIncomingVibration() {
if (null != incomingVibrator) {
try {
incomingVibrator.cancel();
} catch (Exception ignored) {
// best-effort cancel
}
incomingVibrator = null;
}
}

private Vibrator resolveVibrator() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
VibratorManager vibratorManager =
(VibratorManager) context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE);
return (null != vibratorManager) ? vibratorManager.getDefaultVibrator() : null;
}
return (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
}

public synchronized void stop() {
// Close patch (COMMS-697): stop the incoming-call vibration.
stopIncomingVibration();
// Close patch (COMMS-697): tear down the incoming-ring MediaPlayer too.
if (null != incomingPlayer) {
try {
incomingPlayer.stop();
} catch (IllegalStateException ignored) {
// player was never started / already stopped -- nothing to do
}
incomingPlayer.release();
incomingPlayer = null;
}
// Close patch (COMMS-698): stop *all* tracked streams, not just the last.
for (Integer streamId : activeStreams) {
soundPool.stop(streamId);
}
activeStreams.clear();
}

@Override
protected void finalize() throws Throwable {
stopIncomingVibration();
if (null != incomingPlayer) {
incomingPlayer.release();
incomingPlayer = null;
}
soundPool.release();
super.finalize();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;

import android.annotation.SuppressLint;
import android.app.Notification;
Expand Down Expand Up @@ -31,7 +32,7 @@

import com.twiliovoicereactnative.CallRecordDatabase.CallRecord;

class NotificationUtility {
public class NotificationUtility {
private static final SecureRandom secureRandom = new SecureRandom();

private static class NotificationResource {
Expand Down Expand Up @@ -75,7 +76,11 @@ public String getName() {
if (template != null) {
final String processedTemplate =
templateDisplayName(template, this.callRecord.getCustomParameters());
if (!processedTemplate.isEmpty()) {
// Close patch (COMMS-697): only use the processed template if it was
// fully resolved. If an unresolved ${...} placeholder remains (e.g. a
// group call whose push carries no displayName param), fall through to
// the ${from} default instead of showing a literal "${displayName}".
if (!processedTemplate.isEmpty() && !processedTemplate.contains("${")) {
return processedTemplate;
}
}
Expand Down Expand Up @@ -121,7 +126,9 @@ private static String templateDisplayName(final String template, final Map<Strin
String paramValue = e.getValue();
processedTemplate = processedTemplate.replaceAll(
String.format("\\$\\{%s\\}", paramKey),
paramValue);
// Close patch (COMMS-697): quote the replacement so a `$` or `\` in a
// caller/lead name (e.g. "Acme $ Co") doesn't throw in replaceAll.
Matcher.quoteReplacement(paramValue));
}

return processedTemplate;
Expand All @@ -144,13 +151,6 @@ public static Notification createIncomingCallNotification(@NonNull Context conte
.setName(notificationResource.getName())
.build();

Intent foregroundIntent = constructMessage(
context,
Constants.ACTION_FOREGROUND_AND_DEPRIORITIZE_INCOMING_CALL_NOTIFICATION,
Objects.requireNonNull(VoiceApplicationProxy.getMainActivityClass()),
callRecord.getUuid());
PendingIntent piForegroundIntent = constructPendingIntentForActivity(context, foregroundIntent);

Intent rejectIntent = constructMessage(
context,
Constants.ACTION_REJECT_CALL,
Expand All @@ -165,12 +165,32 @@ public static Notification createIncomingCallNotification(@NonNull Context conte
callRecord.getUuid());
PendingIntent piAcceptIntent = constructPendingIntentForActivity(context, acceptIntent);

// Close patch (COMMS-697): route BOTH the full-screen intent (fired when the
// device is locked) and the content intent (tapping the notification body
// when unlocked / app already open) to the dedicated answer screen
// (IncomingCallActivity) instead of the main (inbox) activity. Without this,
// tapping the notification while the app is open just opens the inbox with no
// way to accept/decline the still-ringing call. CallStyle requires a
// full-screen intent, so we keep one -- just point it at the answer screen.
Intent fullScreenIntent = new Intent(
Constants.ACTION_FOREGROUND_AND_DEPRIORITIZE_INCOMING_CALL_NOTIFICATION);
fullScreenIntent.setClassName(
context.getPackageName(), "com.close.mobile.IncomingCallActivity");
fullScreenIntent.putExtra(Constants.MSG_KEY_UUID, callRecord.getUuid());
// Close patch (COMMS-697): unique data per call so concurrent incoming calls
// get distinct PendingIntents. Without this, intents differing only by
// extras are filterEqual and FLAG_UPDATE_CURRENT overwrites the earlier
// call's UUID -- the answer screen would then act on the wrong call.
fullScreenIntent.setData(Uri.parse("close-incoming-call://" + callRecord.getUuid()));
PendingIntent piFullScreenIntent =
constructPendingIntentForActivity(context, fullScreenIntent);

return constructNotificationBuilder(context, channelImportance)
.setSmallIcon(notificationResource.getSmallIconId())
.setCategory(Notification.CATEGORY_CALL)
.setAutoCancel(true)
.setContentIntent(piForegroundIntent)
.setFullScreenIntent(piForegroundIntent, true)
.setContentIntent(piFullScreenIntent)
.setFullScreenIntent(piFullScreenIntent, true)
.addPerson(incomingCaller)
.setStyle(NotificationCompat.CallStyle.forIncomingCall(
incomingCaller, piRejectIntent, piAcceptIntent))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ public void onCreate(Bundle ignoredSavedInstanceState) {
if (!checkPermissions()) {
requestPermissions();
}
// These flags ensure that the activity can be launched when the screen is locked.
Window window = context.getWindow();
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
| WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
// Close patch (COMMS-697): do NOT unconditionally show the main activity
// over the lock screen -- that exposes the full CRM whenever the app is
// foregrounded on a locked device. Show-when-locked is instead applied per
// call intent in MainActivity, and the incoming-call UI is the dedicated
// IncomingCallActivity.
// handle any incoming intents
handleIntent(context.getIntent());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public void onTerminate() {
}
callRecordDatabase.clear();
}
static CallRecordDatabase getCallRecordDatabase() {
public static CallRecordDatabase getCallRecordDatabase() {
return VoiceApplicationProxy.instance.callRecordDatabase;
}
static PreflightTestRecordDatabase getPreflightTestRecordDatabase() {
Expand Down
Loading