forked from
lass.pds.lassul.us/superconfig
This repository has no description
7.7 kB
234 lines
1package us.lassul.assist;
2
3import android.app.Notification;
4import android.app.NotificationChannel;
5import android.app.NotificationManager;
6import android.app.Service;
7import android.content.ComponentName;
8import android.content.Intent;
9import android.content.pm.ServiceInfo;
10import android.net.Uri;
11import android.os.Bundle;
12import android.os.Handler;
13import android.os.IBinder;
14import android.os.Looper;
15import android.os.OutcomeReceiver;
16import android.telecom.CallAttributes;
17import android.telecom.CallControl;
18import android.telecom.CallControlCallback;
19import android.telecom.CallEndpoint;
20import android.telecom.CallEventCallback;
21import android.telecom.CallException;
22import android.telecom.DisconnectCause;
23import android.telecom.PhoneAccount;
24import android.telecom.PhoneAccountHandle;
25import android.telecom.TelecomManager;
26import android.util.Log;
27import java.util.List;
28import java.util.function.Consumer;
29
30/**
31 * Holds each call of the page as a self-managed call in Telecom, so a Bluetooth headset sees a
32 * call and its hang-up (AT+CHUP) reaches this app, which tells the page. In the foreground, as
33 * otherwise Android freezes the process once the page is in front. Stops when idle: a call the
34 * page starts after that is not held (the app cannot start this service from the background).
35 */
36public class CallService extends Service {
37 private static final String CHANNEL = "call";
38 private static final long IDLE_MS = 2 * 60 * 1000;
39
40 private static CallService running;
41
42 private final Handler handler = new Handler(Looper.getMainLooper());
43 private final Runnable idleStop = new Runnable() {
44 @Override
45 public void run() {
46 stopSelf();
47 }
48 };
49 private PhoneAccountHandle account;
50 private CallControl call;
51 // The page's call runs; false while adding means: disconnect once added.
52 private boolean wanted;
53
54 static void pageStarted() {
55 if (running != null) {
56 running.startCall();
57 }
58 }
59
60 static void pageEnded() {
61 if (running != null) {
62 running.endCall();
63 }
64 }
65
66 @Override
67 public void onCreate() {
68 super.onCreate();
69 running = this;
70 NotificationManager notifications = getSystemService(NotificationManager.class);
71 notifications.createNotificationChannel(
72 new NotificationChannel(CHANNEL, "Voice call", NotificationManager.IMPORTANCE_LOW));
73 Notification notification = new Notification.Builder(this, CHANNEL)
74 .setSmallIcon(android.R.drawable.sym_action_call)
75 .setContentTitle("Assist")
76 .setContentText("The headset button hangs up")
77 .setOngoing(true)
78 .build();
79 startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL);
80 account = new PhoneAccountHandle(new ComponentName(this, CallService.class), "assist");
81 getSystemService(TelecomManager.class).registerPhoneAccount(
82 PhoneAccount.builder(account, "Assist")
83 .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED
84 | PhoneAccount.CAPABILITY_SUPPORTS_TRANSACTIONAL_OPERATIONS)
85 .setSupportedUriSchemes(List.of(PhoneAccount.SCHEME_SIP))
86 .build());
87 }
88
89 @Override
90 public int onStartCommand(Intent intent, int flags, int startId) {
91 if (call == null && !wanted) {
92 idle();
93 }
94 return START_NOT_STICKY;
95 }
96
97 @Override
98 public void onDestroy() {
99 handler.removeCallbacksAndMessages(null);
100 wanted = false;
101 disconnect();
102 Link.close();
103 running = null;
104 super.onDestroy();
105 }
106
107 @Override
108 public IBinder onBind(Intent intent) {
109 return null;
110 }
111
112 private void idle() {
113 handler.removeCallbacks(idleStop);
114 handler.postDelayed(idleStop, IDLE_MS);
115 }
116
117 private void startCall() {
118 handler.removeCallbacks(idleStop);
119 if (wanted) {
120 return;
121 }
122 wanted = true;
123 if (call != null) {
124 return;
125 }
126 CallAttributes attributes = new CallAttributes.Builder(
127 account,
128 CallAttributes.DIRECTION_OUTGOING,
129 "Hermes",
130 Uri.fromParts(PhoneAccount.SCHEME_SIP, "hermes@assist.lassul.us", null))
131 .setCallType(CallAttributes.AUDIO_CALL)
132 .build();
133 getSystemService(TelecomManager.class).addCall(
134 attributes,
135 getMainExecutor(),
136 new OutcomeReceiver<CallControl, CallException>() {
137 @Override
138 public void onResult(CallControl control) {
139 Log.i(Link.TAG, "telecom call added");
140 call = control;
141 if (wanted) {
142 control.setActive(getMainExecutor(), logged("setActive"));
143 } else {
144 disconnect();
145 }
146 }
147
148 @Override
149 public void onError(CallException error) {
150 Log.w(Link.TAG, "telecom call refused", error);
151 wanted = false;
152 idle();
153 }
154 },
155 new Control(),
156 new Events());
157 }
158
159 private void endCall() {
160 wanted = false;
161 disconnect();
162 idle();
163 }
164
165 private void disconnect() {
166 if (call != null) {
167 call.disconnect(
168 new DisconnectCause(DisconnectCause.LOCAL), getMainExecutor(), logged("disconnect"));
169 call = null;
170 }
171 }
172
173 private static OutcomeReceiver<Void, CallException> logged(String what) {
174 return new OutcomeReceiver<Void, CallException>() {
175 @Override
176 public void onResult(Void result) {}
177
178 @Override
179 public void onError(CallException error) {
180 Log.w(Link.TAG, what + " failed", error);
181 }
182 };
183 }
184
185 private final class Control implements CallControlCallback {
186 @Override
187 public void onSetActive(Consumer<Boolean> done) {
188 done.accept(true);
189 }
190
191 @Override
192 public void onSetInactive(Consumer<Boolean> done) {
193 done.accept(true);
194 }
195
196 @Override
197 public void onAnswer(int videoState, Consumer<Boolean> done) {
198 done.accept(true);
199 }
200
201 // Telecom ended the call: the headset hung up (AT+CHUP) or a call took over.
202 @Override
203 public void onDisconnect(DisconnectCause cause, Consumer<Boolean> done) {
204 Log.i(Link.TAG, "telecom disconnect: " + cause);
205 done.accept(true);
206 call = null;
207 wanted = false;
208 Link.hangUp();
209 idle();
210 }
211
212 @Override
213 public void onCallStreamingStarted(Consumer<Boolean> done) {
214 done.accept(false);
215 }
216 }
217
218 private static final class Events implements CallEventCallback {
219 @Override
220 public void onCallEndpointChanged(CallEndpoint endpoint) {}
221
222 @Override
223 public void onAvailableCallEndpointsChanged(List<CallEndpoint> endpoints) {}
224
225 @Override
226 public void onMuteStateChanged(boolean muted) {}
227
228 @Override
229 public void onCallStreamingFailed(int reason) {}
230
231 @Override
232 public void onEvent(String event, Bundle extras) {}
233 }
234}