1package eu.siacs.conversations.xmpp.jingle;
2
3import android.content.Context;
4import android.media.AudioManager;
5import android.media.ToneGenerator;
6import android.os.Build;
7import android.util.Log;
8
9import java.util.Arrays;
10import java.util.Set;
11import java.util.concurrent.ScheduledFuture;
12import java.util.concurrent.TimeUnit;
13
14import eu.siacs.conversations.Config;
15
16import static java.util.Arrays.asList;
17
18import androidx.core.content.ContextCompat;
19
20class ToneManager {
21
22 private ToneGenerator toneGenerator;
23 private final Context context;
24
25 private ToneState state = null;
26 private RtpEndUserState endUserState = null;
27 private ScheduledFuture<?> currentTone;
28 private ScheduledFuture<?> currentResetFuture;
29 private boolean appRtcAudioManagerHasControl = false;
30
31 ToneManager(final Context context) {
32 this.context = context;
33 }
34
35 private static ToneState of(final boolean isInitiator, final RtpEndUserState state, final Set<Media> media) {
36 if (isInitiator) {
37 if (asList(RtpEndUserState.FINDING_DEVICE, RtpEndUserState.RINGING, RtpEndUserState.CONNECTING).contains(state)) {
38 return ToneState.RINGING;
39 }
40 if (state == RtpEndUserState.DECLINED_OR_BUSY) {
41 return ToneState.BUSY;
42 }
43 }
44 if (state == RtpEndUserState.ENDING_CALL) {
45 if (media.contains(Media.VIDEO)) {
46 return ToneState.NULL;
47 } else {
48 return ToneState.ENDING_CALL;
49 }
50 }
51 if (Arrays.asList(
52 RtpEndUserState.CONNECTED,
53 RtpEndUserState.RECONNECTING,
54 RtpEndUserState.INCOMING_CONTENT_ADD)
55 .contains(state)) {
56 if (media.contains(Media.VIDEO)) {
57 return ToneState.NULL;
58 } else {
59 return ToneState.CONNECTED;
60 }
61 }
62 return ToneState.NULL;
63 }
64
65 void transition(final RtpEndUserState state, final Set<Media> media) {
66 transition(state, of(true, state, media), media);
67 }
68
69 void transition(final boolean isInitiator, final RtpEndUserState state, final Set<Media> media) {
70 transition(state, of(isInitiator, state, media), media);
71 }
72
73 private synchronized void transition(final RtpEndUserState endUserState, final ToneState state, final Set<Media> media) {
74 final RtpEndUserState normalizeEndUserState = normalize(endUserState);
75 if (this.endUserState == normalizeEndUserState) {
76 return;
77 }
78 this.endUserState = normalizeEndUserState;
79 if (this.state == state) {
80 return;
81 }
82 if (state == ToneState.NULL && this.state == ToneState.ENDING_CALL) {
83 return;
84 }
85 cancelCurrentTone();
86 Log.d(Config.LOGTAG, getClass().getName() + ".transition(" + state + ")");
87 if (state != ToneState.NULL) {
88 configureAudioManagerForCall(media);
89 }
90 switch (state) {
91 case RINGING:
92 scheduleWaitingTone();
93 break;
94 case CONNECTED:
95 scheduleConnected();
96 break;
97 case BUSY:
98 scheduleBusy();
99 break;
100 case ENDING_CALL:
101 scheduleEnding();
102 break;
103 case NULL:
104 if (noResetScheduled()) {
105 resetAudioManager();
106 }
107 break;
108 default:
109 throw new IllegalStateException("Unable to handle transition to "+state);
110 }
111 this.state = state;
112 }
113
114 private static RtpEndUserState normalize(final RtpEndUserState endUserState) {
115 if (Arrays.asList(
116 RtpEndUserState.CONNECTED,
117 RtpEndUserState.RECONNECTING,
118 RtpEndUserState.INCOMING_CONTENT_ADD)
119 .contains(endUserState)) {
120 return RtpEndUserState.CONNECTED;
121 } else {
122 return endUserState;
123 }
124 }
125
126 void setAppRtcAudioManagerHasControl(final boolean appRtcAudioManagerHasControl) {
127 this.appRtcAudioManagerHasControl = appRtcAudioManagerHasControl;
128 }
129
130 private void scheduleConnected() {
131 this.currentTone = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(() -> {
132 startTone(ToneGenerator.TONE_PROP_PROMPT, 200);
133 }, 0, TimeUnit.SECONDS);
134 }
135
136 private void scheduleEnding() {
137 this.currentTone = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(() -> {
138 startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
139 }, 0, TimeUnit.SECONDS);
140 this.currentResetFuture = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(this::resetAudioManager, 375, TimeUnit.MILLISECONDS);
141 }
142
143 private void scheduleBusy() {
144 this.currentTone = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(() -> {
145 startTone(ToneGenerator.TONE_CDMA_NETWORK_BUSY, 2500);
146 }, 0, TimeUnit.SECONDS);
147 this.currentResetFuture = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(this::resetAudioManager, 2500, TimeUnit.MILLISECONDS);
148 }
149
150 private void scheduleWaitingTone() {
151 this.currentTone = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(() -> {
152 startTone(ToneGenerator.TONE_CDMA_NETWORK_USA_RINGBACK, 750);
153 }, 0, 3, TimeUnit.SECONDS);
154 }
155
156 private boolean noResetScheduled() {
157 return this.currentResetFuture == null || this.currentResetFuture.isDone();
158 }
159
160 private void cancelCurrentTone() {
161 if (currentTone != null) {
162 currentTone.cancel(true);
163 }
164 stopTone(toneGenerator);
165 }
166
167 private static void stopTone(final ToneGenerator toneGenerator) {
168 if (toneGenerator == null) {
169 return;
170 }
171 try {
172 toneGenerator.stopTone();
173 } catch (final RuntimeException e) {
174 Log.w(Config.LOGTAG,"tone has already stopped");
175 }
176 }
177
178 public void startTone(final int toneType, final int durationMs) {
179 if (this.toneGenerator != null) {
180 this.toneGenerator.release();
181
182 }
183 final AudioManager audioManager = ContextCompat.getSystemService(context, AudioManager.class);
184 final boolean ringerModeNormal = audioManager == null || audioManager.getRingerMode() == AudioManager.RINGER_MODE_NORMAL;
185 this.toneGenerator = getToneGenerator(ringerModeNormal);
186 if (toneGenerator != null) {
187 this.toneGenerator.startTone(toneType, durationMs);
188 }
189 }
190
191 private static ToneGenerator getToneGenerator(final boolean ringerModeNormal) {
192 try {
193 // when silent and on Android 12+ use STREAM_MUSIC
194 if (ringerModeNormal || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
195 return new ToneGenerator(AudioManager.STREAM_VOICE_CALL,60);
196 } else {
197 return new ToneGenerator(AudioManager.STREAM_MUSIC,100);
198 }
199 } catch (final Exception e) {
200 Log.d(Config.LOGTAG,"could not create tone generator",e);
201 return null;
202 }
203 }
204
205 private void configureAudioManagerForCall(final Set<Media> media) {
206 if (appRtcAudioManagerHasControl) {
207 Log.d(Config.LOGTAG, ToneManager.class.getName() + ": do not configure audio manager because RTC has control");
208 return;
209 }
210 final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
211 if (audioManager == null) {
212 return;
213 }
214 final boolean isSpeakerPhone = media.contains(Media.VIDEO);
215 Log.d(Config.LOGTAG, ToneManager.class.getName() + ": putting AudioManager into communication mode. speaker=" + isSpeakerPhone);
216 audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
217 audioManager.setSpeakerphoneOn(isSpeakerPhone);
218 }
219
220 private void resetAudioManager() {
221 if (appRtcAudioManagerHasControl) {
222 Log.d(Config.LOGTAG, ToneManager.class.getName() + ": do not reset audio manager because RTC has control");
223 return;
224 }
225 final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
226 if (audioManager == null) {
227 return;
228 }
229 Log.d(Config.LOGTAG, ToneManager.class.getName() + ": putting AudioManager back into normal mode");
230 audioManager.setMode(AudioManager.MODE_NORMAL);
231 audioManager.setSpeakerphoneOn(false);
232 }
233
234 private enum ToneState {
235 NULL, RINGING, CONNECTED, BUSY, ENDING_CALL
236 }
237}