This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / tools / hermes-voice / android / src / us / lassul / assist / CallService.java
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}