feat(android): implement call handling and audio features, add notification channels, and enhance UI with new permissions and shortcuts

This commit is contained in:
Ivan
2026-04-25 16:24:42 -05:00
parent 80d9fd6749
commit e89fc13824
16 changed files with 1297 additions and 7 deletions
@@ -0,0 +1,496 @@
package com.meshchatx;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.media.AudioAttributes;
import android.media.AudioFocusRequest;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import android.media.MediaRecorder;
import android.os.Build;
import android.text.TextUtils;
import android.webkit.CookieManager;
import android.webkit.WebView;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.json.JSONObject;
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
import okio.ByteString;
/**
* Native mic send + speaker receive for {@code /ws/telephone/audio} (same protocol as the web
* audio bridge). Do not use on the UI thread for {@link #start()} other than the initial schedule.
*/
public final class TelephoneNativeAudioSession {
public static final int SAMPLE_RATE = 48000;
private static final int IN_CHANNEL = AudioFormat.CHANNEL_IN_MONO;
private static final int OUT_CHANNEL = AudioFormat.CHANNEL_OUT_MONO;
private static final int FORMAT = AudioFormat.ENCODING_PCM_16BIT;
private final Context appContext;
private final AtomicBoolean running = new AtomicBoolean(false);
private final AtomicBoolean pipelinesReady = new AtomicBoolean(false);
private final MainActivity activity;
@Nullable
private WebSocket webSocket;
@Nullable
private AudioRecord audioRecord;
@Nullable
private AudioTrack audioTrack;
@Nullable
private Thread sendThread;
@Nullable
private Thread connectThread;
@Nullable
private AudioManager audioManager;
@Nullable
private AudioFocusRequest audioFocusRequest;
private final AudioManager.OnAudioFocusChangeListener audioFocusListener = focusChange -> {
};
public TelephoneNativeAudioSession(MainActivity activity) {
this.activity = activity;
this.appContext = activity.getApplicationContext();
}
public static boolean canRun(Context ctx) {
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
return false;
}
return AudioRecord.getMinBufferSize(SAMPLE_RATE, IN_CHANNEL, FORMAT) > 0;
}
public boolean isActive() {
return running.get();
}
public void start() {
if (running.get() || connectThread != null) {
return;
}
if (!canRun(appContext)) {
postDispatch("error", "no_record_audio_permission", null);
return;
}
connectThread = new Thread(
() -> {
try {
openWebSocket();
} finally {
connectThread = null;
}
},
"meshchatx-tel-ws"
);
connectThread.setPriority(Thread.NORM_PRIORITY);
connectThread.start();
}
public void stop() {
running.set(false);
pipelinesReady.set(false);
if (webSocket != null) {
try {
webSocket.close(1000, "client stop");
} catch (Exception ignored) {
}
webSocket = null;
}
if (sendThread != null) {
try {
sendThread.join(2000L);
} catch (InterruptedException ignored) {
}
sendThread = null;
}
releaseAudio();
}
private void postReleaseAfterStop() {
if (sendThread == null) {
releaseTx();
return;
}
new Thread(
() -> {
if (sendThread != null) {
try {
sendThread.join(2000L);
} catch (InterruptedException ignored) {
}
}
releaseTx();
},
"meshchatx-tel-cleanup"
).start();
}
@SuppressWarnings("WeakerAccess")
void onDestroy() {
stop();
}
private void openWebSocket() {
String cookie = "";
try {
String c = CookieManager.getInstance().getCookie("https://127.0.0.1:8000");
if (!TextUtils.isEmpty(c)) {
cookie = c;
}
} catch (Exception ignored) {
}
running.set(true);
pipelinesReady.set(false);
Request.Builder rb = new Request.Builder().url("wss://127.0.0.1:8000/ws/telephone/audio");
if (!TextUtils.isEmpty(cookie)) {
rb.addHeader("Cookie", cookie);
}
try {
webSocket = LocalhostTrustOkHttpClient.get()
.newWebSocket(
rb.build(),
new WebSocketListener() {
@Override
public void onOpen(WebSocket w, Response r) {
w.send("{\"type\":\"attach\"}");
}
@Override
public void onMessage(WebSocket w, String t) {
if (t == null) {
return;
}
try {
JSONObject o = new JSONObject(t);
if ("error".equals(o.optString("type"))) {
String msg = o.optString("message", "error");
if (msg.contains("Web audio is disabled")
|| (msg.equals("error") && o.length() < 2)) {
// keep generic
}
postDispatch("error", "server", msg);
} else if ("web_audio.ready".equals(o.optString("type"))) {
activity.runOnUiThread(TelephoneNativeAudioSession.this::onWebAudioReadyPipelines);
}
} catch (Exception e) {
if (t.contains("web_audio.ready") || t.contains("frame_ms")) {
activity.runOnUiThread(TelephoneNativeAudioSession.this::onWebAudioReadyPipelines);
}
}
}
@Override
public void onMessage(WebSocket w, ByteString bytes) {
playPcm(bytes);
}
@Override
public void onFailure(WebSocket w, Throwable t, @Nullable Response r) {
webSocket = null;
running.set(false);
pipelinesReady.set(false);
String msg = t != null && t.getMessage() != null ? t.getMessage() : "connect_failed";
postDispatch("error", "websocket", msg);
postReleaseAfterStop();
}
@Override
public void onClosed(WebSocket w, int c, @Nullable String r) {
webSocket = null;
running.set(false);
pipelinesReady.set(false);
postDispatch("closed", null, null);
postReleaseAfterStop();
}
}
);
} catch (Exception e) {
running.set(false);
String m = e.getMessage() != null ? e.getMessage() : "okhttp";
postDispatch("error", "connect", m);
}
}
private void onWebAudioReadyPipelines() {
if (pipelinesReady.getAndSet(true)) {
return;
}
if (!running.get()) {
return;
}
try {
if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
postDispatch("error", "no_record_audio_permission", null);
return;
}
acquireCallAudioMode();
int inBuf = Math.max(
AudioRecord.getMinBufferSize(SAMPLE_RATE, IN_CHANNEL, FORMAT),
4096
);
audioRecord = new AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
IN_CHANNEL,
FORMAT,
inBuf * 2
);
if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
releaseTx();
postDispatch("error", "record_init", null);
return;
}
int outMin = AudioTrack.getMinBufferSize(SAMPLE_RATE, OUT_CHANNEL, FORMAT);
if (outMin <= 0) {
outMin = 4096;
}
int trackBuf = Math.max(outMin * 2, outMin);
AudioAttributes playbackAttr = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build();
AudioFormat trackFmt = new AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setEncoding(FORMAT)
.setChannelMask(OUT_CHANNEL)
.build();
audioTrack = new AudioTrack.Builder()
.setAudioAttributes(playbackAttr)
.setAudioFormat(trackFmt)
.setBufferSizeInBytes(trackBuf)
.setTransferMode(AudioTrack.MODE_STREAM)
.build();
if (audioTrack.getState() != AudioTrack.STATE_INITIALIZED) {
releaseTx();
postDispatch("error", "track_init", null);
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
try {
audioTrack.setVolume(AudioTrack.getMaxVolume());
} catch (Exception ignored) {
}
}
try {
audioRecord.startRecording();
} catch (Exception e) {
releaseTx();
postDispatch("error", "record_start", e.getMessage() != null ? e.getMessage() : "");
return;
}
try {
audioTrack.play();
} catch (Exception e) {
releaseTx();
postDispatch("error", "track_start", e.getMessage() != null ? e.getMessage() : "");
return;
}
sendThread = new Thread(this::drainMicrophone, "meshchatx-tel-mic");
sendThread.setPriority(Thread.NORM_PRIORITY);
sendThread.start();
postDispatch("ready", null, null);
} catch (Exception e) {
releaseTx();
postDispatch("error", "setup", e.getMessage() != null ? e.getMessage() : "setup");
}
}
private void drainMicrophone() {
byte[] buf = new byte[4096];
while (running.get() && webSocket != null) {
AudioRecord ar;
WebSocket w;
synchronized (this) {
ar = audioRecord;
w = webSocket;
}
if (ar == null || w == null) {
break;
}
int n;
try {
n = ar.read(buf, 0, buf.length);
} catch (Exception e) {
break;
}
if (n > 0 && w != null) {
try {
w.send(ByteString.of(buf, 0, n));
} catch (Exception e) {
break;
}
} else if (n < 0) {
break;
}
}
}
private void playPcm(ByteString bytes) {
if (bytes == null || bytes.size() == 0) {
return;
}
AudioTrack at;
synchronized (this) {
at = audioTrack;
}
if (at == null) {
return;
}
byte[] raw = bytes.toByteArray();
int off = 0;
int left = raw.length;
while (left > 0) {
if (!running.get()) {
return;
}
synchronized (this) {
at = audioTrack;
}
if (at == null) {
return;
}
int written = 0;
try {
written = at.write(raw, off, left);
} catch (Exception e) {
return;
}
if (written < 0) {
return;
}
if (written == 0) {
Thread.yield();
continue;
}
off += written;
left -= written;
}
}
private void postDispatch(String a, @Nullable String b, @Nullable String c) {
activity.runOnUiThread(() -> dispatchToPage(a, b, c));
}
private void dispatchToPage(String kind, @Nullable String sub, @Nullable String detail) {
try {
WebView wv = activity.getWebViewForNativeBridge();
if (wv == null) {
return;
}
JSONObject o = new JSONObject();
o.put("type", "meshchatx-native-telephone-audio");
o.put("kind", kind);
if (sub != null) {
o.put("sub", sub);
}
if (detail != null) {
o.put("detail", detail);
}
String j = o.toString();
wv.evaluateJavascript(
"try{var d=" + j + ";"
+ "window.dispatchEvent(new CustomEvent('meshchatx-native-telephone-audio',{detail:d}));}catch(e){}",
null
);
} catch (Exception ignored) {
}
}
private void acquireCallAudioMode() {
releaseCallAudioMode();
audioManager = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
if (audioManager == null) {
return;
}
try {
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
} catch (Exception ignored) {
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
AudioAttributes aa = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build();
audioFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(aa)
.setAcceptsDelayedFocusGain(false)
.build();
audioManager.requestAudioFocus(audioFocusRequest);
} else {
audioManager.requestAudioFocus(
audioFocusListener,
AudioManager.STREAM_VOICE_CALL,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
);
}
} catch (Exception ignored) {
}
}
private void releaseCallAudioMode() {
if (audioManager == null) {
return;
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && audioFocusRequest != null) {
audioManager.abandonAudioFocusRequest(audioFocusRequest);
} else {
audioManager.abandonAudioFocus(audioFocusListener);
}
} catch (Exception ignored) {
}
try {
audioManager.setMode(AudioManager.MODE_NORMAL);
} catch (Exception ignored) {
}
audioFocusRequest = null;
audioManager = null;
}
private void releaseTx() {
if (audioRecord != null) {
try {
if (audioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
audioRecord.stop();
}
} catch (Exception ignored) {
}
try {
audioRecord.release();
} catch (Exception ignored) {
}
audioRecord = null;
}
if (audioTrack != null) {
try {
if (audioTrack.getPlayState() == AudioTrack.PLAYSTATE_PLAYING) {
audioTrack.stop();
}
} catch (Exception ignored) {
}
try {
audioTrack.release();
} catch (Exception ignored) {
}
audioTrack = null;
}
releaseCallAudioMode();
}
private void releaseAudio() {
releaseTx();
}
}