This repository has no description
0

Configure Feed

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

tools.hermes-voice: Android wrapper as digital assistant

packages.hermes-voice-android builds an unsigned APK (us.lassul.assist,
"Assist") that opens https://assist.lassul.us/ as a Trusted Web
Activity, so Chrome handles the mic permission and the stored access
token exactly as in the browser. Its single activity is the launcher
entry and handles android.intent.action.ASSIST. That is enough to be
offered under Default apps > Digital assistant app: the ASSISTANT
role's AssistantRoleBehavior (packages/modules/Permission) qualifies a
package either by an assist-capable VoiceInteractionService or by an
exported activity resolving ACTION_ASSIST, with no system-app check.

The activity binds the first browser offering the TWA category, opens
a session and launches the TWA intent; without such a browser it opens
a Custom Tab, which other browsers treat as a plain VIEW intent. It
stays below the page in the task and finishes when returned to.

The build is plain aapt2 + javac + d8 from androidenv (build-tools and
platform 36; the SDK is unfree, so androidenv comes from a nixpkgs
instance with allowUnfree and the license accepted). The only library
is androidx.browser 1.10.0's classes.jar (AAR from Google Maven, 289
KB): the client, session and TWA intent builder used here need none of
its dependencies (androidx.core is only referenced by launchUrl-style
helpers, which are not called). The APK is 72 KB and byte-identical
across rebuilds (fixed zip timestamps).

packages.hermes-voice-android-sign zipaligns and signs it with
apksigner (v1-v3, no .idsig) from a keystore and password file, by
default the packaged unsigned APK, and prints the certificate so it
can be compared with assetlinks.json.

+221
+33
tools/hermes-voice/flake-module.nix
··· 38 38 cp ${assetlinks} $out/.well-known/assetlinks.json 39 39 ''} 40 40 ''; 41 + # The SDK is unfree; it only builds the APK and is not part of any system. 42 + inherit 43 + (import pkgs.path { 44 + inherit (pkgs.stdenv.hostPlatform) system; 45 + config.allowUnfree = true; 46 + config.android_sdk.accept_license = true; 47 + }) 48 + androidenv 49 + ; 50 + android = pkgs.callPackage ./android { inherit icon androidenv; }; 41 51 in 42 52 { 43 53 # Full-duplex voice conversations with Hermes: gpt-live-1 in the ··· 52 62 ''; 53 63 }).overrideAttrs 54 64 { passthru.usage = builtins.readFile ./usage.kdl; }; 65 + 66 + # The voice page as Android app and digital assistant (Trusted Web 67 + # Activity), unsigned. 68 + packages.hermes-voice-android = android; 69 + 70 + # Aligns and signs the APK with the keystore from 71 + # `clan vars get coaxmetal hermes-voice-android-signing/keystore`. 72 + packages.hermes-voice-android-sign = pkgs.writeShellApplication { 73 + name = "hermes-voice-android-sign"; 74 + runtimeInputs = [ pkgs.jdk17_headless ]; 75 + text = '' 76 + PATH=${android.buildTools}:$PATH 77 + if [ $# -lt 3 ]; then 78 + echo "usage: hermes-voice-android-sign KEYSTORE PASSWORD_FILE OUT_APK [UNSIGNED_APK]" >&2 79 + exit 1 80 + fi 81 + aligned=$(mktemp) 82 + trap 'rm -f "$aligned"' EXIT 83 + zipalign -f -p 4 "''${4:-${android}/hermes-voice-android-unsigned.apk}" "$aligned" 84 + apksigner sign --ks "$1" --ks-pass "file:$2" --v4-signing-enabled false --out "$3" "$aligned" 85 + apksigner verify --print-certs "$3" 86 + ''; 87 + }; 55 88 }; 56 89 }
+29
tools/hermes-voice/android/AndroidManifest.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <manifest xmlns:android="http://schemas.android.com/apk/res/android" 3 + package="us.lassul.assist"> 4 + 5 + <queries> 6 + <intent> 7 + <action android:name="android.support.customtabs.action.CustomTabsService" /> 8 + </intent> 9 + </queries> 10 + 11 + <application 12 + android:label="Assist" 13 + android:icon="@mipmap/ic_launcher" 14 + android:allowBackup="false"> 15 + <activity 16 + android:name=".AssistActivity" 17 + android:exported="true" 18 + android:theme="@android:style/Theme.Translucent.NoTitleBar"> 19 + <intent-filter> 20 + <action android:name="android.intent.action.MAIN" /> 21 + <category android:name="android.intent.category.LAUNCHER" /> 22 + </intent-filter> 23 + <intent-filter> 24 + <action android:name="android.intent.action.ASSIST" /> 25 + <category android:name="android.intent.category.DEFAULT" /> 26 + </intent-filter> 27 + </activity> 28 + </application> 29 + </manifest>
+64
tools/hermes-voice/android/default.nix
··· 1 + { 2 + stdenvNoCC, 3 + androidenv, 4 + fetchurl, 5 + jdk17_headless, 6 + unzip, 7 + zip, 8 + icon, 9 + }: 10 + let 11 + sdk = androidenv.composeAndroidPackages { 12 + platformVersions = [ "36" ]; 13 + buildToolsVersions = [ "36.0.0" ]; 14 + includeEmulator = false; 15 + includeSystemImages = false; 16 + includeNDK = false; 17 + }; 18 + sdkRoot = "${sdk.androidsdk}/libexec/android-sdk"; 19 + buildTools = "${sdkRoot}/build-tools/36.0.0"; 20 + androidJar = "${sdkRoot}/platforms/android-36/android.jar"; 21 + browser = fetchurl { 22 + url = "https://dl.google.com/android/maven2/androidx/browser/browser/1.10.0/browser-1.10.0.aar"; 23 + hash = "sha256-+0DR7l/ZEMnREiAIaKlTQVanqZmrwo+7PGgVouOsUVA="; 24 + }; 25 + in 26 + # Unsigned release APK; tools/hermes-voice/flake-module.nix has the signer. 27 + # Only androidx.browser's classes go in: the parts used here need none of its 28 + # dependencies. 29 + stdenvNoCC.mkDerivation { 30 + pname = "hermes-voice-android"; 31 + version = "1"; 32 + src = ./.; 33 + nativeBuildInputs = [ 34 + jdk17_headless 35 + unzip 36 + zip 37 + ]; 38 + buildPhase = '' 39 + runHook preBuild 40 + unzip -q ${browser} classes.jar -d browser 41 + 42 + mkdir -p res/mipmap-xxxhdpi 43 + cp ${icon}/icon-192.png res/mipmap-xxxhdpi/ic_launcher.png 44 + ${buildTools}/aapt2 compile --dir res -o res.zip 45 + ${buildTools}/aapt2 link -o unsigned.apk -I ${androidJar} \ 46 + --manifest AndroidManifest.xml res.zip \ 47 + --min-sdk-version 29 --target-sdk-version 36 \ 48 + --version-code 1 --version-name 1 49 + 50 + javac -source 8 -target 8 -Xlint:-options -encoding UTF-8 \ 51 + -bootclasspath ${androidJar} -cp browser/classes.jar \ 52 + -d classes $(find src -name '*.java') 53 + ${buildTools}/d8 --release --min-api 29 --lib ${androidJar} --output . \ 54 + $(find classes -name '*.class') browser/classes.jar 55 + 56 + touch -d @315532800 classes.dex 57 + TZ=UTC zip -X -D unsigned.apk classes.dex 58 + runHook postBuild 59 + ''; 60 + installPhase = '' 61 + install -Dm444 unsigned.apk $out/hermes-voice-android-unsigned.apk 62 + ''; 63 + passthru = { inherit buildTools; }; 64 + }
+95
tools/hermes-voice/android/src/us/lassul/assist/AssistActivity.java
··· 1 + package us.lassul.assist; 2 + 3 + import android.app.Activity; 4 + import android.content.ComponentName; 5 + import android.content.Intent; 6 + import android.content.pm.PackageManager; 7 + import android.content.pm.ResolveInfo; 8 + import android.net.Uri; 9 + import android.os.Bundle; 10 + import androidx.browser.customtabs.CustomTabsClient; 11 + import androidx.browser.customtabs.CustomTabsIntent; 12 + import androidx.browser.customtabs.CustomTabsService; 13 + import androidx.browser.customtabs.CustomTabsServiceConnection; 14 + import androidx.browser.customtabs.CustomTabsSession; 15 + import androidx.browser.trusted.TrustedWebActivityIntentBuilder; 16 + 17 + /** Opens the voice page as a Trusted Web Activity, else as a Custom Tab or in the browser. */ 18 + public class AssistActivity extends Activity { 19 + private static final Uri URL = Uri.parse("https://assist.lassul.us/"); 20 + 21 + private CustomTabsServiceConnection connection; 22 + private boolean launched; 23 + 24 + @Override 25 + protected void onCreate(Bundle state) { 26 + super.onCreate(state); 27 + String provider = twaProvider(); 28 + if (provider == null) { 29 + openCustomTab(); 30 + return; 31 + } 32 + connection = new CustomTabsServiceConnection() { 33 + @Override 34 + public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) { 35 + if (launched) { 36 + return; 37 + } 38 + client.warmup(0); 39 + CustomTabsSession session = client.newSession(null); 40 + if (session == null) { 41 + openCustomTab(); 42 + return; 43 + } 44 + launch(new TrustedWebActivityIntentBuilder(URL).build(session).getIntent()); 45 + } 46 + 47 + @Override 48 + public void onServiceDisconnected(ComponentName name) {} 49 + }; 50 + if (!CustomTabsClient.bindCustomTabsService(this, provider, connection)) { 51 + connection = null; 52 + openCustomTab(); 53 + } 54 + } 55 + 56 + /** The first browser offering Trusted Web Activities, null if none does. */ 57 + private String twaProvider() { 58 + Intent intent = new Intent(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION); 59 + for (ResolveInfo info : 60 + getPackageManager().queryIntentServices(intent, PackageManager.GET_RESOLVED_FILTER)) { 61 + if (info.filter != null 62 + && info.filter.hasCategory(CustomTabsService.TRUSTED_WEB_ACTIVITY_CATEGORY)) { 63 + return info.serviceInfo.packageName; 64 + } 65 + } 66 + return null; 67 + } 68 + 69 + /** A Custom Tab where supported; other browsers ignore its extras. */ 70 + private void openCustomTab() { 71 + launch(new CustomTabsIntent.Builder().build().intent.setData(URL)); 72 + } 73 + 74 + private void launch(Intent intent) { 75 + launched = true; 76 + startActivity(intent); 77 + } 78 + 79 + // Back from the page: this activity only sits below it in the task. 80 + @Override 81 + protected void onRestart() { 82 + super.onRestart(); 83 + if (launched) { 84 + finish(); 85 + } 86 + } 87 + 88 + @Override 89 + protected void onDestroy() { 90 + if (connection != null) { 91 + unbindService(connection); 92 + } 93 + super.onDestroy(); 94 + } 95 + }