This repository has no description
11 kB
303 lines
1package us.lassul.assist;
2
3import android.Manifest;
4import android.app.Notification;
5import android.app.NotificationChannel;
6import android.app.NotificationManager;
7import android.app.PendingIntent;
8import android.app.Person;
9import android.app.Service;
10import android.content.ComponentName;
11import android.content.Intent;
12import android.content.pm.PackageManager;
13import android.content.pm.ServiceInfo;
14import android.media.AudioManager;
15import android.media.ToneGenerator;
16import android.net.Uri;
17import android.os.Bundle;
18import android.os.Handler;
19import android.os.IBinder;
20import android.os.Looper;
21import android.os.OutcomeReceiver;
22import android.telecom.CallAttributes;
23import android.telecom.CallControl;
24import android.telecom.CallControlCallback;
25import android.telecom.CallEndpoint;
26import android.telecom.CallEventCallback;
27import android.telecom.CallException;
28import android.telecom.DisconnectCause;
29import android.telecom.PhoneAccount;
30import android.telecom.PhoneAccountHandle;
31import android.telecom.TelecomManager;
32import android.util.Log;
33import java.util.List;
34import java.util.function.Consumer;
35
36/**
37 * One call at a time, without a screen: a VoiceCall held as a self-managed Telecom call, so the
38 * audio goes to the Bluetooth headset and its hang-up button (AT+CHUP) ends the call. In the
39 * foreground with the microphone; stops when the call ends. A tone says the voice listens (or
40 * that the call failed).
41 */
42public class CallService extends Service {
43 static final String TAG = "Assist";
44 static final String ACTION_START = "us.lassul.assist.START";
45 static final String ACTION_HANG_UP = "us.lassul.assist.HANG_UP";
46 private static final String CHANNEL = "call";
47 private static final int NOTIFICATION = 1;
48
49 private final Handler main = new Handler(Looper.getMainLooper());
50 private PhoneAccountHandle account;
51 private CallControl telecom;
52 private VoiceCall voice;
53 // What the main screen shows (main thread); null without a call.
54 private static String state;
55
56 static String state() {
57 return state;
58 }
59
60 private static void setState(String next) {
61 state = next;
62 Live.changed();
63 }
64
65 @Override
66 public void onCreate() {
67 super.onCreate();
68 getSystemService(NotificationManager.class).createNotificationChannel(
69 new NotificationChannel(CHANNEL, "Voice call", NotificationManager.IMPORTANCE_LOW));
70 account = new PhoneAccountHandle(new ComponentName(this, CallService.class), "assist");
71 getSystemService(TelecomManager.class).registerPhoneAccount(
72 PhoneAccount.builder(account, "Assist")
73 .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED
74 | PhoneAccount.CAPABILITY_SUPPORTS_TRANSACTIONAL_OPERATIONS)
75 .setSupportedUriSchemes(List.of(PhoneAccount.SCHEME_SIP))
76 .build());
77 }
78
79 @Override
80 public int onStartCommand(Intent intent, int flags, int startId) {
81 String action = intent != null ? intent.getAction() : null;
82 if (ACTION_HANG_UP.equals(action)) {
83 if (voice != null) {
84 voice.close();
85 } else {
86 stopSelf();
87 }
88 return START_NOT_STICKY;
89 }
90 if (voice != null) {
91 // A call runs already: the button press changes nothing.
92 return START_NOT_STICKY;
93 }
94 Api api = Api.load(this);
95 if (api == null
96 || checkSelfPermission(Manifest.permission.RECORD_AUDIO)
97 != PackageManager.PERMISSION_GRANTED) {
98 // startForegroundService() needs startForeground(); the microphone type would be
99 // refused without the permission.
100 startForeground(NOTIFICATION, setupNotification(),
101 ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL);
102 Log.w(TAG, "not set up: open Assist");
103 tone(ToneGenerator.TONE_PROP_NACK);
104 main.postDelayed(new Runnable() {
105 @Override
106 public void run() {
107 stopSelf();
108 }
109 }, 1000);
110 return START_NOT_STICKY;
111 }
112 startForeground(NOTIFICATION, callNotification(),
113 ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
114 | ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
115 setState("Connecting…");
116 voice = new VoiceCall(this, api, Conversation.load(this), new VoiceCall.Listener() {
117 @Override
118 public void onListening() {
119 setState("Listening");
120 tone(ToneGenerator.TONE_PROP_ACK);
121 }
122
123 @Override
124 public void onFinished(String reason, boolean failed) {
125 voice = null;
126 setState(null);
127 if (failed) {
128 tone(ToneGenerator.TONE_PROP_NACK);
129 }
130 disconnectTelecom();
131 // Let a failure tone play before the service, and with it the call audio, goes.
132 main.postDelayed(new Runnable() {
133 @Override
134 public void run() {
135 if (voice == null) {
136 stopSelf();
137 }
138 }
139 }, failed ? 1000 : 0);
140 }
141 });
142 addTelecomCall();
143 return START_NOT_STICKY;
144 }
145
146 @Override
147 public void onDestroy() {
148 main.removeCallbacksAndMessages(null);
149 if (voice != null) {
150 voice.close();
151 }
152 disconnectTelecom();
153 super.onDestroy();
154 }
155
156 @Override
157 public IBinder onBind(Intent intent) {
158 return null;
159 }
160
161 /** The voice starts once Telecom has the call (audio routed), or without it if refused. */
162 private void addTelecomCall() {
163 CallAttributes attributes = new CallAttributes.Builder(
164 account,
165 CallAttributes.DIRECTION_OUTGOING,
166 "Hermes",
167 Uri.fromParts(PhoneAccount.SCHEME_SIP, "hermes@assist.lassul.us", null))
168 .setCallType(CallAttributes.AUDIO_CALL)
169 .build();
170 getSystemService(TelecomManager.class).addCall(
171 attributes,
172 getMainExecutor(),
173 new OutcomeReceiver<CallControl, CallException>() {
174 @Override
175 public void onResult(CallControl control) {
176 if (voice == null) {
177 control.disconnect(new DisconnectCause(DisconnectCause.LOCAL),
178 getMainExecutor(), logged("disconnect"));
179 return;
180 }
181 telecom = control;
182 control.setActive(getMainExecutor(), logged("setActive"));
183 voice.start();
184 }
185
186 @Override
187 public void onError(CallException error) {
188 Log.w(TAG, "telecom refused the call; the headset cannot hang up", error);
189 if (voice != null) {
190 voice.start();
191 }
192 }
193 },
194 new Control(),
195 new Events());
196 }
197
198 private void disconnectTelecom() {
199 if (telecom != null) {
200 telecom.disconnect(new DisconnectCause(DisconnectCause.LOCAL), getMainExecutor(),
201 logged("disconnect"));
202 telecom = null;
203 }
204 }
205
206 private void tone(int tone) {
207 final ToneGenerator generator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80);
208 generator.startTone(tone, 300);
209 main.postDelayed(new Runnable() {
210 @Override
211 public void run() {
212 generator.release();
213 }
214 }, 500);
215 }
216
217 private Notification callNotification() {
218 PendingIntent hangUp = PendingIntent.getService(this, 0,
219 new Intent(this, CallService.class).setAction(ACTION_HANG_UP),
220 PendingIntent.FLAG_IMMUTABLE);
221 return new Notification.Builder(this, CHANNEL)
222 .setSmallIcon(android.R.drawable.sym_action_call)
223 .setStyle(Notification.CallStyle.forOngoingCall(
224 new Person.Builder().setName("Hermes").build(), hangUp))
225 .setOngoing(true)
226 .build();
227 }
228
229 private Notification setupNotification() {
230 PendingIntent setup = PendingIntent.getActivity(this, 0,
231 new Intent(this, SettingsActivity.class), PendingIntent.FLAG_IMMUTABLE);
232 return new Notification.Builder(this, CHANNEL)
233 .setSmallIcon(android.R.drawable.stat_notify_error)
234 .setContentTitle("Assist is not set up")
235 .setContentText("Open it to enter the token and allow the microphone")
236 .setContentIntent(setup)
237 .build();
238 }
239
240 private static OutcomeReceiver<Void, CallException> logged(final String what) {
241 return new OutcomeReceiver<Void, CallException>() {
242 @Override
243 public void onResult(Void result) {}
244
245 @Override
246 public void onError(CallException error) {
247 Log.w(TAG, what + " failed", error);
248 }
249 };
250 }
251
252 private final class Control implements CallControlCallback {
253 @Override
254 public void onSetActive(Consumer<Boolean> done) {
255 done.accept(true);
256 }
257
258 @Override
259 public void onSetInactive(Consumer<Boolean> done) {
260 done.accept(true);
261 }
262
263 @Override
264 public void onAnswer(int videoState, Consumer<Boolean> done) {
265 done.accept(true);
266 }
267
268 // Telecom ended the call: the headset hung up (AT+CHUP) or another call took over.
269 @Override
270 public void onDisconnect(DisconnectCause cause, Consumer<Boolean> done) {
271 Log.i(TAG, "telecom disconnect: " + cause);
272 done.accept(true);
273 telecom = null;
274 if (voice != null) {
275 voice.close();
276 }
277 }
278
279 @Override
280 public void onCallStreamingStarted(Consumer<Boolean> done) {
281 done.accept(false);
282 }
283 }
284
285 private static final class Events implements CallEventCallback {
286 @Override
287 public void onCallEndpointChanged(CallEndpoint endpoint) {
288 Log.i(TAG, "audio to " + endpoint);
289 }
290
291 @Override
292 public void onAvailableCallEndpointsChanged(List<CallEndpoint> endpoints) {}
293
294 @Override
295 public void onMuteStateChanged(boolean muted) {}
296
297 @Override
298 public void onCallStreamingFailed(int reason) {}
299
300 @Override
301 public void onEvent(String event, Bundle extras) {}
302 }
303}