This repository has no description
0

Configure Feed

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

tools.hermes-voice: headset hang-up button ends the call

The app holds each call of the page as a self-managed Telecom call
(transactional API, foreground service), so a headset's AT+CHUP reaches
it. Page and app talk over the Custom Tabs postMessage channel: the page
reports started/ended, the app sends hangup. assetlinks.json grants the
app use_as_origin for that.

+447 -79
+5 -1
tools/hermes-voice/flake-module.nix
··· 12 12 assetlinks = pkgs.writeText "assetlinks.json" ( 13 13 builtins.toJSON [ 14 14 { 15 - relation = [ "delegate_permission/common.handle_all_urls" ]; 15 + # use_as_origin: the app's postMessages to the page (android/src/…/Link.java). 16 + relation = [ 17 + "delegate_permission/common.handle_all_urls" 18 + "delegate_permission/common.use_as_origin" 19 + ]; 16 20 target = { 17 21 namespace = "android_app"; 18 22 package_name = androidPackage;
+13
tools/hermes-voice/android/AndroidManifest.xml
··· 2 2 <manifest xmlns:android="http://schemas.android.com/apk/res/android" 3 3 package="us.lassul.assist"> 4 4 5 + <!-- CallService: each call of the page as a self-managed Telecom call. --> 6 + <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" /> 7 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> 8 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" /> 9 + 5 10 <queries> 6 11 <intent> 7 12 <action android:name="android.support.customtabs.action.CustomTabsService" /> ··· 35 40 <category android:name="android.intent.category.DEFAULT" /> 36 41 </intent-filter> 37 42 </activity> 43 + <service 44 + android:name=".CallService" 45 + android:exported="false" 46 + android:foregroundServiceType="phoneCall" /> 47 + <!-- The browser binds it to deliver the page's postMessages. --> 48 + <service 49 + android:name="androidx.browser.customtabs.PostMessageService" 50 + android:exported="true" /> 38 51 </application> 39 52 </manifest>
+2 -2
tools/hermes-voice/android/default.nix
··· 47 47 ${buildTools}/aapt2 compile --dir res -o res.zip 48 48 ${buildTools}/aapt2 link -o unsigned.apk -I ${androidJar} \ 49 49 --manifest AndroidManifest.xml res.zip \ 50 - --min-sdk-version 29 --target-sdk-version 36 \ 50 + --min-sdk-version 34 --target-sdk-version 36 \ 51 51 --version-code ${toString versionCode} --version-name ${versionName} 52 52 53 53 javac -source 8 -target 8 -Xlint:-options -encoding UTF-8 \ 54 54 -bootclasspath ${androidJar} -cp browser/classes.jar \ 55 55 -d classes $(find src -name '*.java') 56 - ${buildTools}/d8 --release --min-api 29 --lib ${androidJar} --output . \ 56 + ${buildTools}/d8 --release --min-api 34 --lib ${androidJar} --output . \ 57 57 $(find classes -name '*.class') browser/classes.jar 58 58 59 59 touch -d @315532800 classes.dex
+31 -4
tools/hermes-voice/static/app.js
··· 581 581 voice.srcObject = null; 582 582 state.billedSeconds += this.usageSeconds; 583 583 saveState(); 584 - if (call === this) call = null; 584 + if (call === this) { 585 + call = null; 586 + tellApp("ended"); 587 + } 585 588 talk.classList.remove("live", "speaking"); 586 589 talk.textContent = "Talk"; 587 590 talk.disabled = false; ··· 592 595 593 596 async function startCall() { 594 597 call = new Call(); 598 + tellApp("started"); 595 599 talk.disabled = true; 596 600 talk.classList.add("live"); 597 601 talk.textContent = "Hang up"; ··· 609 613 } 610 614 } 611 615 616 + function hangUpCall() { 617 + talk.disabled = true; 618 + setStatus("Hanging up…"); 619 + call.close(); 620 + } 621 + 612 622 talk.addEventListener("click", () => { 613 623 if (call) { 614 - talk.disabled = true; 615 - setStatus("Hanging up…"); 616 - call.close(); 624 + hangUpCall(); 617 625 return; 618 626 } 619 627 startCall(); 620 628 }); 621 629 622 630 window.addEventListener("pagehide", () => call?.finish("page closed")); 631 + 632 + // The Android app holds each call in Android's telephony (Telecom), so a 633 + // headset's hang-up button reaches it; it then sends "hangup". Its first 634 + // message, as this origin (assetlinks.json: use_as_origin), brings the port. 635 + let appPort = null; 636 + 637 + function tellApp(message) { 638 + appPort?.postMessage(message); 639 + } 640 + 641 + window.addEventListener("message", (event) => { 642 + if (event.origin !== location.origin || !event.ports[0]) return; 643 + appPort = event.ports[0]; 644 + appPort.onmessage = ({ data }) => { 645 + if (data === "hangup" && call) hangUpCall(); 646 + }; 647 + // ?listen starts the call before the app's channel is up. 648 + if (call) tellApp("started"); 649 + }); 623 650 624 651 document.getElementById("new").addEventListener("click", () => { 625 652 if (call) call.close();
+5 -72
tools/hermes-voice/android/src/us/lassul/assist/AssistActivity.java
··· 2 2 3 3 import android.app.Activity; 4 4 import android.app.KeyguardManager; 5 - import android.content.ComponentName; 6 5 import android.content.Intent; 7 - import android.content.pm.PackageManager; 8 - import android.content.pm.ResolveInfo; 9 - import android.net.Uri; 10 6 import android.os.Bundle; 11 - import androidx.browser.customtabs.CustomTabsClient; 12 - import androidx.browser.customtabs.CustomTabsIntent; 13 - import androidx.browser.customtabs.CustomTabsService; 14 - import androidx.browser.customtabs.CustomTabsServiceConnection; 15 - import androidx.browser.customtabs.CustomTabsSession; 16 - import androidx.browser.trusted.TrustedWebActivityIntentBuilder; 17 7 18 8 /** 19 - * Opens the voice page as a Trusted Web Activity, else as a Custom Tab or in the browser. The 20 - * page starts listening at once (?listen). 9 + * Entry point (launcher, assist gesture, headset VOICE_COMMAND): unlocks if needed and opens the 10 + * voice page, which starts listening at once (?listen). 21 11 */ 22 12 public class AssistActivity extends Activity { 23 - private static final Uri URL = Uri.parse("https://assist.lassul.us/?listen"); 24 - 25 - private CustomTabsServiceConnection connection; 26 13 private boolean launched; 27 14 28 15 @Override ··· 53 40 } 54 41 55 42 private void open() { 56 - String provider = twaProvider(); 57 - if (provider == null) { 58 - openCustomTab(); 59 - return; 60 - } 61 - connection = new CustomTabsServiceConnection() { 62 - @Override 63 - public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) { 64 - if (launched) { 65 - return; 66 - } 67 - client.warmup(0); 68 - CustomTabsSession session = client.newSession(null); 69 - if (session == null) { 70 - openCustomTab(); 71 - return; 72 - } 73 - launch(new TrustedWebActivityIntentBuilder(URL).build(session).getIntent()); 74 - } 75 - 76 - @Override 77 - public void onServiceDisconnected(ComponentName name) {} 78 - }; 79 - if (!CustomTabsClient.bindCustomTabsService(this, provider, connection)) { 80 - connection = null; 81 - openCustomTab(); 82 - } 83 - } 84 - 85 - /** The first browser offering Trusted Web Activities, null if none does. */ 86 - private String twaProvider() { 87 - Intent intent = new Intent(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION); 88 - for (ResolveInfo info : 89 - getPackageManager().queryIntentServices(intent, PackageManager.GET_RESOLVED_FILTER)) { 90 - if (info.filter != null 91 - && info.filter.hasCategory(CustomTabsService.TRUSTED_WEB_ACTIVITY_CATEGORY)) { 92 - return info.serviceInfo.packageName; 93 - } 94 - } 95 - return null; 96 - } 97 - 98 - /** A Custom Tab where supported; other browsers ignore its extras. */ 99 - private void openCustomTab() { 100 - launch(new CustomTabsIntent.Builder().build().intent.setData(URL)); 101 - } 102 - 103 - private void launch(Intent intent) { 104 43 launched = true; 105 - startActivity(intent); 44 + // Started now, while this activity is in front: from the background Android refuses it. 45 + startForegroundService(new Intent(this, CallService.class)); 46 + Link.open(this); 106 47 } 107 48 108 49 // Back from the page: this activity only sits below it in the task. ··· 112 53 if (launched) { 113 54 finish(); 114 55 } 115 - } 116 - 117 - @Override 118 - protected void onDestroy() { 119 - if (connection != null) { 120 - unbindService(connection); 121 - } 122 - super.onDestroy(); 123 56 } 124 57 }
+234
tools/hermes-voice/android/src/us/lassul/assist/CallService.java
··· 1 + package us.lassul.assist; 2 + 3 + import android.app.Notification; 4 + import android.app.NotificationChannel; 5 + import android.app.NotificationManager; 6 + import android.app.Service; 7 + import android.content.ComponentName; 8 + import android.content.Intent; 9 + import android.content.pm.ServiceInfo; 10 + import android.net.Uri; 11 + import android.os.Bundle; 12 + import android.os.Handler; 13 + import android.os.IBinder; 14 + import android.os.Looper; 15 + import android.os.OutcomeReceiver; 16 + import android.telecom.CallAttributes; 17 + import android.telecom.CallControl; 18 + import android.telecom.CallControlCallback; 19 + import android.telecom.CallEndpoint; 20 + import android.telecom.CallEventCallback; 21 + import android.telecom.CallException; 22 + import android.telecom.DisconnectCause; 23 + import android.telecom.PhoneAccount; 24 + import android.telecom.PhoneAccountHandle; 25 + import android.telecom.TelecomManager; 26 + import android.util.Log; 27 + import java.util.List; 28 + import 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 + */ 36 + public 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 + }
+157
tools/hermes-voice/android/src/us/lassul/assist/Link.java
··· 1 + package us.lassul.assist; 2 + 3 + import android.app.Activity; 4 + import android.content.ComponentName; 5 + import android.content.Context; 6 + import android.content.Intent; 7 + import android.content.pm.PackageManager; 8 + import android.content.pm.ResolveInfo; 9 + import android.net.Uri; 10 + import android.os.Bundle; 11 + import android.util.Log; 12 + import androidx.browser.customtabs.CustomTabsCallback; 13 + import androidx.browser.customtabs.CustomTabsClient; 14 + import androidx.browser.customtabs.CustomTabsIntent; 15 + import androidx.browser.customtabs.CustomTabsService; 16 + import androidx.browser.customtabs.CustomTabsServiceConnection; 17 + import androidx.browser.customtabs.CustomTabsSession; 18 + import androidx.browser.trusted.TrustedWebActivityIntentBuilder; 19 + 20 + /** 21 + * The voice page, opened as a Trusted Web Activity, else as a Custom Tab, and its postMessage 22 + * channel: the page reports "started" and "ended" for each call, the app sends "hangup". One per 23 + * process, used on the main thread only (androidx delivers the callbacks there). 24 + */ 25 + final class Link { 26 + static final String TAG = "Assist"; 27 + // The app speaks as this origin; assetlinks.json grants it (use_as_origin). 28 + private static final Uri ORIGIN = Uri.parse("https://assist.lassul.us"); 29 + private static final Uri URL = Uri.parse("https://assist.lassul.us/?listen"); 30 + 31 + private static Context app; 32 + private static CustomTabsServiceConnection connection; 33 + private static CustomTabsSession session; 34 + private static boolean twa; 35 + // Opened before the browser's service connected: launched once it is. 36 + private static Activity pending; 37 + 38 + private static final CustomTabsCallback CALLBACK = new CustomTabsCallback() { 39 + @Override 40 + public void onRelationshipValidationResult( 41 + int relation, Uri origin, boolean result, Bundle extras) { 42 + Log.i(TAG, "use_as_origin " + origin + ": " + result); 43 + } 44 + 45 + // Every page load needs its own channel; the page takes the port from the first message. 46 + @Override 47 + public void onNavigationEvent(int event, Bundle extras) { 48 + if (event == NAVIGATION_FINISHED && session != null) { 49 + boolean requested = session.requestPostMessageChannel(ORIGIN, ORIGIN, new Bundle()); 50 + Log.i(TAG, "postMessage channel requested: " + requested); 51 + } 52 + } 53 + 54 + @Override 55 + public void onMessageChannelReady(Bundle extras) { 56 + Log.i(TAG, "postMessage channel ready: " + session.postMessage("hello", null)); 57 + } 58 + 59 + @Override 60 + public void onPostMessage(String message, Bundle extras) { 61 + Log.i(TAG, "page: " + message); 62 + if ("started".equals(message)) { 63 + CallService.pageStarted(); 64 + } else if ("ended".equals(message)) { 65 + CallService.pageEnded(); 66 + } 67 + } 68 + }; 69 + 70 + private Link() {} 71 + 72 + static void open(Activity activity) { 73 + if (session != null) { 74 + launch(activity); 75 + return; 76 + } 77 + pending = activity; 78 + if (connection != null) { 79 + return; 80 + } 81 + String provider = twaProvider(activity); 82 + twa = provider != null; 83 + if (provider == null) { 84 + provider = CustomTabsClient.getPackageName(activity, null); 85 + } 86 + app = activity.getApplicationContext(); 87 + connection = new CustomTabsServiceConnection() { 88 + @Override 89 + public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) { 90 + client.warmup(0); 91 + session = client.newSession(CALLBACK); 92 + if (session != null) { 93 + session.validateRelationship( 94 + CustomTabsService.RELATION_USE_AS_ORIGIN, ORIGIN, null); 95 + } 96 + Activity waiting = pending; 97 + pending = null; 98 + if (waiting != null && !waiting.isFinishing()) { 99 + launch(waiting); 100 + } 101 + } 102 + 103 + @Override 104 + public void onServiceDisconnected(ComponentName name) { 105 + session = null; 106 + } 107 + }; 108 + if (provider == null || !CustomTabsClient.bindCustomTabsService(app, provider, connection)) { 109 + connection = null; 110 + pending = null; 111 + launch(activity); 112 + } 113 + } 114 + 115 + /** Sends "hangup" to the page, if it is connected. */ 116 + static void hangUp() { 117 + if (session != null) { 118 + Log.i(TAG, "hangup sent: " + session.postMessage("hangup", null)); 119 + } 120 + } 121 + 122 + static void close() { 123 + if (connection != null) { 124 + app.unbindService(connection); 125 + } 126 + connection = null; 127 + session = null; 128 + pending = null; 129 + } 130 + 131 + /** In the session's browser; without one (no Custom Tabs browser) a plain Custom Tab. */ 132 + private static void launch(Activity activity) { 133 + Intent intent; 134 + if (session == null) { 135 + intent = new CustomTabsIntent.Builder().build().intent.setData(URL); 136 + } else if (twa) { 137 + intent = new TrustedWebActivityIntentBuilder(URL).build(session).getIntent(); 138 + } else { 139 + intent = new CustomTabsIntent.Builder(session).build().intent.setData(URL); 140 + } 141 + activity.startActivity(intent); 142 + } 143 + 144 + /** The first browser offering Trusted Web Activities, null if none does. */ 145 + private static String twaProvider(Context context) { 146 + Intent intent = new Intent(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION); 147 + for (ResolveInfo info : 148 + context.getPackageManager() 149 + .queryIntentServices(intent, PackageManager.GET_RESOLVED_FILTER)) { 150 + if (info.filter != null 151 + && info.filter.hasCategory(CustomTabsService.TRUSTED_WEB_ACTIVITY_CATEGORY)) { 152 + return info.serviceInfo.packageName; 153 + } 154 + } 155 + return null; 156 + } 157 + }