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 // ringing can be removed as this is now handled by 'CallIntegration'
93 //scheduleWaitingTone();
94 break;
95 case CONNECTED:
96 scheduleConnected();
97 break;
98 case BUSY:
99 scheduleBusy();
100 break;
101 case ENDING_CALL:
102 scheduleEnding();
103 break;
104 case NULL:
105 if (noResetScheduled()) {
106 resetAudioManager();
107 }
108 break;
109 default:
110 throw new IllegalStateException("Unable to handle transition to "+state);
111 }
112 this.state = state;
113 }
114
115 private static RtpEndUserState normalize(final RtpEndUserState endUserState) {
116 if (Arrays.asList(
117 RtpEndUserState.CONNECTED,
118 RtpEndUserState.RECONNECTING,
119 RtpEndUserState.INCOMING_CONTENT_ADD)
120 .contains(endUserState)) {
121 return RtpEndUserState.CONNECTED;
122 } else {
123 return endUserState;
124 }
125 }
126
127 void setAppRtcAudioManagerHasControl(final boolean appRtcAudioManagerHasControl) {
128 this.appRtcAudioManagerHasControl = appRtcAudioManagerHasControl;
129 }
130
131 private void scheduleConnected() {
132 this.currentTone = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(() -> {
133 startTone(ToneGenerator.TONE_PROP_PROMPT, 200);
134 }, 0, TimeUnit.SECONDS);
135 }
136
137 private void scheduleEnding() {
138 this.currentTone = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(() -> {
139 startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
140 }, 0, TimeUnit.SECONDS);
141 this.currentResetFuture = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(this::resetAudioManager, 375, TimeUnit.MILLISECONDS);
142 }
143
144 private void scheduleBusy() {
145 this.currentTone = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(() -> {
146 startTone(ToneGenerator.TONE_CDMA_NETWORK_BUSY, 2500);
147 }, 0, TimeUnit.SECONDS);
148 this.currentResetFuture = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(this::resetAudioManager, 2500, TimeUnit.MILLISECONDS);
149 }
150
151 private void scheduleWaitingTone() {
152 this.currentTone = JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(() -> {
153 startTone(ToneGenerator.TONE_CDMA_DIAL_TONE_LITE, 750);
154 }, 0, 3, TimeUnit.SECONDS);
155 }
156
157 private boolean noResetScheduled() {
158 return this.currentResetFuture == null || this.currentResetFuture.isDone();
159 }
160
161 private void cancelCurrentTone() {
162 if (currentTone != null) {
163 currentTone.cancel(true);
164 }
165 stopTone(toneGenerator);
166 }
167
168 private static void stopTone(final ToneGenerator toneGenerator) {
169 if (toneGenerator == null) {
170 return;
171 }
172 try {
173 toneGenerator.stopTone();
174 } catch (final RuntimeException e) {
175 Log.w(Config.LOGTAG,"tone has already stopped");
176 }
177 }
178
179 private void startTone(final int toneType, final int durationMs) {
180 if (this.toneGenerator != null) {
181 this.toneGenerator.release();;
182
183 }
184 final AudioManager audioManager = ContextCompat.getSystemService(context, AudioManager.class);
185 final boolean ringerModeNormal = audioManager == null || audioManager.getRingerMode() == AudioManager.RINGER_MODE_NORMAL;
186 this.toneGenerator = getToneGenerator(ringerModeNormal);
187 if (toneGenerator != null) {
188 this.toneGenerator.startTone(toneType, durationMs);
189 }
190 }
191
192 private static ToneGenerator getToneGenerator(final boolean ringerModeNormal) {
193 try {
194 // when silent and on Android 12+ use STREAM_MUSIC
195 if (ringerModeNormal || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
196 return new ToneGenerator(AudioManager.STREAM_VOICE_CALL,60);
197 } else {
198 return new ToneGenerator(AudioManager.STREAM_MUSIC,100);
199 }
200 } catch (final Exception e) {
201 Log.d(Config.LOGTAG,"could not create tone generator",e);
202 return null;
203 }
204 }
205
206 private void configureAudioManagerForCall(final Set<Media> media) {
207 if (appRtcAudioManagerHasControl) {
208 Log.d(Config.LOGTAG, ToneManager.class.getName() + ": do not configure audio manager because RTC has control");
209 return;
210 }
211 final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
212 if (audioManager == null) {
213 return;
214 }
215 final boolean isSpeakerPhone = media.contains(Media.VIDEO);
216 Log.d(Config.LOGTAG, ToneManager.class.getName() + ": putting AudioManager into communication mode. speaker=" + isSpeakerPhone);
217 audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
218 audioManager.setSpeakerphoneOn(isSpeakerPhone);
219 }
220
221 private void resetAudioManager() {
222 if (appRtcAudioManagerHasControl) {
223 Log.d(Config.LOGTAG, ToneManager.class.getName() + ": do not reset audio manager because RTC has control");
224 return;
225 }
226 final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
227 if (audioManager == null) {
228 return;
229 }
230 Log.d(Config.LOGTAG, ToneManager.class.getName() + ": putting AudioManager back into normal mode");
231 audioManager.setMode(AudioManager.MODE_NORMAL);
232 audioManager.setSpeakerphoneOn(false);
233 }
234
235 private enum ToneState {
236 NULL, RINGING, CONNECTED, BUSY, ENDING_CALL
237 }
238}