From e89fc13824101c8e8d034e632df6f2d40edbfe9c Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 25 Apr 2026 16:24:42 -0500 Subject: [PATCH] feat(android): implement call handling and audio features, add notification channels, and enhance UI with new permissions and shortcuts --- android/app/proguard-rules.pro | 4 + android/app/src/main/AndroidManifest.xml | 18 +- .../meshchatx/AndroidNotificationBridge.java | 188 ++++++- .../meshchatx/LocalhostTrustOkHttpClient.java | 69 +++ .../main/java/com/meshchatx/MainActivity.java | 207 +++++++- .../com/meshchatx/MeshChatApplication.java | 36 ++ .../meshchatx/MeshChatForegroundService.java | 2 +- .../TelephoneNativeAudioSession.java | 496 ++++++++++++++++++ .../meshchatx/WavPcmAttachmentRecorder.java | 204 +++++++ .../main/res/drawable/ic_stat_meshchatx.xml | 11 + .../res/values-v35/edge_to_edge_opt_out.xml | 6 + .../main/res/values/edge_to_edge_opt_out.xml | 4 + android/app/src/main/res/values/strings.xml | 12 + .../app/src/main/res/xml/locales_config.xml | 16 + android/app/src/main/res/xml/shortcuts.xml | 29 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- 16 files changed, 1297 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/java/com/meshchatx/LocalhostTrustOkHttpClient.java create mode 100644 android/app/src/main/java/com/meshchatx/TelephoneNativeAudioSession.java create mode 100644 android/app/src/main/java/com/meshchatx/WavPcmAttachmentRecorder.java create mode 100644 android/app/src/main/res/drawable/ic_stat_meshchatx.xml create mode 100644 android/app/src/main/res/values-v35/edge_to_edge_opt_out.xml create mode 100644 android/app/src/main/res/values/edge_to_edge_opt_out.xml create mode 100644 android/app/src/main/res/xml/locales_config.xml create mode 100644 android/app/src/main/res/xml/shortcuts.xml diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index bc707fd..6f03145 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -4,3 +4,7 @@ -keep class org.conscrypt.** { *; } -dontwarn com.chaquo.python.** -dontwarn org.conscrypt.** +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } +-keep class okio.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 65d52ad..a028dcb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,8 @@ + + @@ -30,16 +32,20 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" + android:localeConfig="@xml/locales_config" android:supportsRtl="true" android:theme="@style/Theme.MeshChatX" android:usesCleartextTraffic="true" - tools:targetApi="31"> - + tools:targetApi="35"> + + @@ -62,6 +68,14 @@ + + + + + + postInboundMessage(ctx, safeTitle, safeBody, dedupeHex)); } + public static void showIncomingCall(String callerName, @Nullable String dedupeHex) { + Context ctx = MeshChatApplication.getAppContext(); + if (ctx == null) { + return; + } + String name = (callerName == null) ? "Mesh" : callerName.trim(); + if (name.isEmpty()) { + name = "Mesh"; + } + final String displayName = name; + new Handler(Looper.getMainLooper()).post(() -> postIncomingCall(ctx, displayName, dedupeHex)); + } + + public static void showMissedCall(String title, String body, @Nullable String dedupeHex) { + Context ctx = MeshChatApplication.getAppContext(); + if (ctx == null) { + return; + } + String safeTitle = TextUtils.isEmpty(title) ? ctx.getString(R.string.notification_missed_call_label) : title; + String safeBody = TextUtils.isEmpty(body) ? ctx.getString(R.string.app_name) : body; + new Handler(Looper.getMainLooper()).post(() -> postMissedCall(ctx, safeTitle, safeBody, dedupeHex)); + } + + public static void cancelIncomingCallNotification() { + Context ctx = MeshChatApplication.getAppContext(); + if (ctx == null) { + return; + } + new Handler(Looper.getMainLooper()).post( + () -> { + NotificationManager nm = ctx.getSystemService(NotificationManager.class); + if (nm == null) { + return; + } + try { + nm.cancel(INCOMING_CALL_NOTIFICATION_ID); + } catch (Exception ignored) { + } + } + ); + } + + private static Intent callIntent(Context ctx, String action) { + Intent i = new Intent(ctx, MainActivity.class); + i.setAction(action); + i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + return i; + } + + private static void postIncomingCall(Context ctx, String callerName, @Nullable String dedupeHex) { + NotificationManager nm = ctx.getSystemService(NotificationManager.class); + if (nm == null) { + return; + } + + PendingIntent open = PendingIntent.getActivity( + ctx, + REQ_CALL_OPEN, + callIntent(ctx, ACTION_CALL_OPEN), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + PendingIntent full = PendingIntent.getActivity( + ctx, + REQ_CALL_FULL, + callIntent(ctx, ACTION_CALL_OPEN), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + PendingIntent answer = PendingIntent.getActivity( + ctx, + REQ_CALL_ANSWER, + callIntent(ctx, ACTION_CALL_ANSWER), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + PendingIntent decline = PendingIntent.getActivity( + ctx, + REQ_CALL_DECLINE, + callIntent(ctx, ACTION_CALL_DECLINE), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + Person person = new Person.Builder().setName(callerName).setImportant(true).build(); + String incomingLabel = ctx.getString(R.string.notification_incoming_call_label, callerName); + + NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, MeshChatApplication.CHANNEL_ID_CALLS) + .setSmallIcon(R.drawable.ic_stat_meshchatx) + .setOngoing(true) + .setAutoCancel(false) + .setContentIntent(open) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(callerName) + .setContentText(ctx.getString(R.string.notification_incoming_call_tap)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + b.setStyle( + NotificationCompat.CallStyle.forIncomingCall( + person, + decline, + answer + ) + ); + } else { + b.setStyle(new NotificationCompat.BigTextStyle().bigText(incomingLabel)); + b.setContentText(ctx.getString(R.string.notification_incoming_call_tap)); + b.addAction(0, ctx.getString(R.string.notification_call_decline), decline); + b.addAction(0, ctx.getString(R.string.notification_call_answer), answer); + } + b.setOnlyAlertOnce(false); + b.setTimeoutAfter(120_000L); + + try { + b.setFullScreenIntent(full, true); + } catch (Exception ignored) { + } + + try { + nm.notify(INCOMING_CALL_NOTIFICATION_ID, b.build()); + } catch (SecurityException ignored) { + } catch (Exception ignored) { + } + } + + private static int missedCallNotificationId(@Nullable String dedupeHex) { + if (dedupeHex != null && dedupeHex.length() >= 8) { + try { + return NOTIFY_BASE_ID + 0x1000 + (int) ( + Long.parseLong( + dedupeHex.substring(0, Math.min(8, dedupeHex.length())), 16) & 0x7fff_ffff); + } catch (NumberFormatException ignored) { + return NOTIFY_BASE_ID + 0x1000 + (dedupeHex.hashCode() & 0x7fff_ffff); + } + } + return NOTIFY_BASE_ID + 0x3fff; + } + + private static void postMissedCall( + Context ctx, + String title, + String body, + @Nullable String dedupeHex + ) { + NotificationManager nm = ctx.getSystemService(NotificationManager.class); + if (nm == null) { + return; + } + + Intent open = new Intent(ctx, MainActivity.class); + open.setAction(ACTION_CALL_OPEN); + open.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pi = PendingIntent.getActivity( + ctx, + REQ_CALL_OPEN, + open, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + int id = missedCallNotificationId(dedupeHex); + NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, MeshChatApplication.CHANNEL_ID_CALLS) + .setSmallIcon(R.drawable.ic_stat_meshchatx) + .setContentTitle(title) + .setContentText(body) + .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) + .setContentIntent(pi) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_MISSED_CALL) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE); + b.setDefaults(NotificationCompat.DEFAULT_ALL); + + try { + nm.notify(id, b.build()); + } catch (SecurityException ignored) { + } + } + private static void postInboundMessage(Context ctx, String title, String body, @Nullable String dedupeHex) { NotificationManager nm = ctx.getSystemService(NotificationManager.class); if (nm == null) { @@ -44,7 +230,7 @@ public final class AndroidNotificationBridge { ); NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, MeshChatApplication.CHANNEL_ID_MESSAGES) - .setSmallIcon(R.drawable.ic_launcher_foreground) + .setSmallIcon(R.drawable.ic_stat_meshchatx) .setContentTitle(title) .setContentText(body) .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) diff --git a/android/app/src/main/java/com/meshchatx/LocalhostTrustOkHttpClient.java b/android/app/src/main/java/com/meshchatx/LocalhostTrustOkHttpClient.java new file mode 100644 index 0000000..cd8bef0 --- /dev/null +++ b/android/app/src/main/java/com/meshchatx/LocalhostTrustOkHttpClient.java @@ -0,0 +1,69 @@ +package com.meshchatx; + +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import okhttp3.OkHttpClient; + +/** + * OkHttp for HTTPS to the embedded MeshChatX server on 127.0.0.1 only (self-signed cert, same + * as WebView SSL ignore). Do not use for public hosts. + */ +final class LocalhostTrustOkHttpClient { + private static OkHttpClient instance; + + private LocalhostTrustOkHttpClient() { + } + + static synchronized OkHttpClient get() { + if (instance == null) { + instance = build(); + } + return instance; + } + + @SuppressWarnings("CustomX509TrustManager") + private static OkHttpClient build() { + try { + final TrustManager[] allTrust = new TrustManager[] { + new X509TrustManager() { + @Override + @SuppressWarnings("MethodDoesntCallSuperMethod") + public void checkClientTrusted(X509Certificate[] c, String t) { + } + + @Override + @SuppressWarnings("MethodDoesntCallSuperMethod") + public void checkServerTrusted(X509Certificate[] c, String t) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } + }; + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(null, allTrust, new SecureRandom()); + return new OkHttpClient.Builder() + .sslSocketFactory(sc.getSocketFactory(), (X509TrustManager) allTrust[0]) + .hostnameVerifier( + (hostname, session) -> + "127.0.0.1".equals(hostname) || "localhost".equalsIgnoreCase(hostname) + ) + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(0, TimeUnit.MILLISECONDS) + .connectTimeout(15, TimeUnit.SECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) + .pingInterval(0, TimeUnit.SECONDS) + .build(); + } catch (Exception e) { + throw new IllegalStateException("localhost OkHttp", e); + } + } +} diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java index 3f7fada..9f9acab 100644 --- a/android/app/src/main/java/com/meshchatx/MainActivity.java +++ b/android/app/src/main/java/com/meshchatx/MainActivity.java @@ -36,6 +36,8 @@ import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; +import androidx.core.view.WindowCompat; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.appcompat.app.AppCompatActivity; import com.chaquo.python.Python; @@ -67,14 +69,23 @@ public class MainActivity extends AppCompatActivity { private static final int MAX_CONNECTION_ATTEMPTS = 120; private static final long CONNECTION_RETRY_INITIAL_DELAY_MS = 500; private static final long CONNECTION_RETRY_MAX_DELAY_MS = 5000; + private static final int MESHCHAT_SERVER_START_MAX_ATTEMPTS = 4; + private static final long MESHCHAT_SERVER_RETRY_DELAY_MS = 2000L; private final Handler mainHandler = new Handler(Looper.getMainLooper()); private PermissionRequest pendingWebPermissionRequest = null; private ValueCallback filePathCallback = null; private boolean startupRequestHadLoadError = false; private boolean startupPageLoaded = false; private boolean backendFailed = false; + private int meshchatServerStartAttempts = 0; private int connectionAttempts = 0; private String pendingIntentUri = null; + @Nullable + private String pendingCallNotificationAction; + @Nullable + TelephoneNativeAudioSession telephoneNativeSession; + @Nullable + WavPcmAttachmentRecorder attachmentPcmRecorder; private static final String[] STARTUP_PHASES = new String[] { "Starting MeshChatX...", "Initializing Reticulum network stack...", @@ -135,7 +146,9 @@ public class MainActivity extends AppCompatActivity { @SuppressLint("SetJavaScriptEnabled") @Override protected void onCreate(Bundle savedInstanceState) { + getTheme().applyStyle(R.style.OptOutEdgeToEdgeEnforcement, false); super.onCreate(savedInstanceState); + WindowCompat.setDecorFitsSystemWindows(getWindow(), true); setContentView(R.layout.activity_main); webView = findViewById(R.id.webView); @@ -197,6 +210,7 @@ public class MainActivity extends AppCompatActivity { loadingText.setVisibility(android.view.View.GONE); errorText.setVisibility(android.view.View.GONE); dispatchPendingIntentUri(); + dispatchCallNotificationAction(); } @Override @@ -355,6 +369,7 @@ public class MainActivity extends AppCompatActivity { } }); handleIncomingIntent(getIntent()); + consumeCallIntentForPending(getIntent()); startMeshChatServer(); scheduleConnectionRetry("Connecting to local server..."); @@ -379,8 +394,10 @@ public class MainActivity extends AppCompatActivity { super.onNewIntent(intent); setIntent(intent); handleIncomingIntent(intent); + consumeCallIntentForPending(intent); if (startupPageLoaded) { dispatchPendingIntentUri(); + dispatchCallNotificationAction(); } } @@ -517,8 +534,27 @@ public class MainActivity extends AppCompatActivity { String appFilesDir = getFilesDir().getAbsolutePath(); py.getModule("meshchat_wrapper").callAttr("start_server", SERVER_PORT, appFilesDir); } catch (Exception e) { - backendFailed = true; - showStartupError("MeshChatX backend failed:\n" + toStackTrace(e)); + final String stack = toStackTrace(e); + runOnUiThread(() -> { + if (startupPageLoaded) { + return; + } + meshchatServerStartAttempts += 1; + if (meshchatServerStartAttempts < MESHCHAT_SERVER_START_MAX_ATTEMPTS) { + backendFailed = false; + showLoading( + "MeshChatX backend error, retrying (" + + meshchatServerStartAttempts + + "/" + + MESHCHAT_SERVER_START_MAX_ATTEMPTS + + ")..." + ); + mainHandler.postDelayed(() -> startMeshChatServer(), MESHCHAT_SERVER_RETRY_DELAY_MS); + } else { + backendFailed = true; + showStartupError("MeshChatX backend failed:\n" + stack); + } + }); } }).start(); } @@ -536,12 +572,64 @@ public class MainActivity extends AppCompatActivity { return; } String scheme = data.getScheme().toLowerCase(); + if ("meshchatx".equals(scheme)) { + if (data.getHost() == null) { + return; + } + if (!"app".equalsIgnoreCase(data.getHost())) { + return; + } + pendingIntentUri = data.toString(); + return; + } if (!"lxma".equals(scheme) && !"lxmf".equals(scheme) && !"lxm".equals(scheme)) { return; } pendingIntentUri = data.toString(); } + private void consumeCallIntentForPending(Intent intent) { + if (intent == null) { + return; + } + String a = intent.getAction(); + if (AndroidNotificationBridge.ACTION_CALL_ANSWER.equals(a)) { + pendingCallNotificationAction = "answer"; + } else if (AndroidNotificationBridge.ACTION_CALL_DECLINE.equals(a)) { + pendingCallNotificationAction = "decline"; + } else if (AndroidNotificationBridge.ACTION_CALL_OPEN.equals(a)) { + pendingCallNotificationAction = "open"; + } + } + + private void dispatchCallNotificationAction() { + if (webView == null) { + return; + } + if (pendingCallNotificationAction == null) { + return; + } + String action = pendingCallNotificationAction; + pendingCallNotificationAction = null; + String js; + if ("decline".equals(action)) { + js = + "(function(){" + + "fetch('/api/v1/telephone/hangup', { credentials: 'include' }).catch(function(){});})();"; + } else if ("answer".equals(action)) { + js = + "(function(){" + + "fetch('/api/v1/telephone/answer', { credentials: 'include' })" + + ".then(function(){ try { window.location.hash = '#/call?tab=phone'; } catch (e) {}})" + + ".catch(function(){});})();"; + } else if ("open".equals(action)) { + js = "(function(){" + "try { window.location.hash = '#/call?tab=phone'; } catch (e) {}" + "})();"; + } else { + return; + } + webView.evaluateJavascript(js, null); + } + private void dispatchPendingIntentUri() { if (webView == null || pendingIntentUri == null || pendingIntentUri.isEmpty()) { return; @@ -653,6 +741,14 @@ public class MainActivity extends AppCompatActivity { @Override protected void onDestroy() { + if (telephoneNativeSession != null) { + telephoneNativeSession.onDestroy(); + telephoneNativeSession = null; + } + if (attachmentPcmRecorder != null) { + attachmentPcmRecorder.cancel(); + attachmentPcmRecorder = null; + } stopService(new Intent(this, MeshChatForegroundService.class)); super.onDestroy(); mainHandler.removeCallbacksAndMessages(null); @@ -688,6 +784,10 @@ public class MainActivity extends AppCompatActivity { return base; } + WebView getWebViewForNativeBridge() { + return webView; + } + void persistMeshchatDownload(String fileName, byte[] data) throws IOException { String safe = sanitizeDownloadFileName(fileName); ContentResolver resolver = getContentResolver(); @@ -842,6 +942,109 @@ public class MainActivity extends AppCompatActivity { } }); } + + @JavascriptInterface + public boolean isNativePcmAudioAvailable() { + return TelephoneNativeAudioSession.canRun(activity) + && WavPcmAttachmentRecorder.canStart(activity); + } + + @JavascriptInterface + public boolean isTelephoneNativeAudioAvailable() { + return TelephoneNativeAudioSession.canRun(activity); + } + + @JavascriptInterface + public String startTelephoneNativeAudio() { + try { + if (activity.telephoneNativeSession == null) { + activity.telephoneNativeSession = new TelephoneNativeAudioSession(activity); + } + activity.telephoneNativeSession.start(); + return "ok"; + } catch (Exception e) { + return e.getMessage() != null ? e.getMessage() : "err"; + } + } + + @JavascriptInterface + public void stopTelephoneNativeAudio() { + try { + if (activity.telephoneNativeSession != null) { + activity.telephoneNativeSession.stop(); + } + } catch (Exception ignored) { + } + } + + @JavascriptInterface + public boolean isTelephoneNativeAudioActive() { + return activity.telephoneNativeSession != null && activity.telephoneNativeSession.isActive(); + } + + @JavascriptInterface + public String startNativeWavAttachment() { + try { + if (activity.attachmentPcmRecorder == null) { + activity.attachmentPcmRecorder = new WavPcmAttachmentRecorder(activity); + } + return activity.attachmentPcmRecorder.start(); + } catch (Exception e) { + return e.getMessage() != null ? e.getMessage() : "err"; + } + } + + @JavascriptInterface + public void stopNativeWavAttachment() { + if (activity.attachmentPcmRecorder == null) { + return; + } + final WavPcmAttachmentRecorder r = activity.attachmentPcmRecorder; + new Thread( + () -> { + String b64 = r.stopBase64Wav(); + activity.runOnUiThread( + () -> { + try { + if (activity.webView == null) { + return; + } + org.json.JSONObject o = new org.json.JSONObject(); + if (b64 == null) { + o.put("ok", false); + o.put("error", "out_of_memory"); + } else if (b64.isEmpty()) { + o.put("ok", false); + o.put("error", "empty"); + } else { + o.put("ok", true); + o.put("data", b64); + } + String p = o.toString(); + activity.webView.evaluateJavascript( + "try{if(window.__meshchatXNative&&typeof window.__meshchatXNative.onWav==='function'){" + + "var p=" + p + + "; window.__meshchatXNative.onWav(p);}}catch(e){}", + null + ); + } catch (Exception ignored) { + } finally { + activity.attachmentPcmRecorder = null; + } + } + ); + }, + "meshchatx-attach-stop" + ).start(); + } + + @JavascriptInterface + public void cancelNativeWavAttachment() { + if (activity.attachmentPcmRecorder != null) { + activity.attachmentPcmRecorder.cancel(); + activity.attachmentPcmRecorder = null; + } + } } } diff --git a/android/app/src/main/java/com/meshchatx/MeshChatApplication.java b/android/app/src/main/java/com/meshchatx/MeshChatApplication.java index ad04105..371df98 100644 --- a/android/app/src/main/java/com/meshchatx/MeshChatApplication.java +++ b/android/app/src/main/java/com/meshchatx/MeshChatApplication.java @@ -1,16 +1,22 @@ package com.meshchatx; import android.app.Application; +import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; +import android.media.AudioAttributes; +import android.media.RingtoneManager; +import android.net.Uri; import android.os.Build; +import android.provider.Settings; import com.chaquo.python.android.PyApplication; public class MeshChatApplication extends PyApplication { public static final String CHANNEL_ID_MESSAGES = "meshchatx_messages"; public static final String CHANNEL_ID_BACKGROUND = "meshchatx_background"; + public static final String CHANNEL_ID_CALLS = "meshchatx_calls"; private static volatile Context appContext; @@ -49,5 +55,35 @@ public class MeshChatApplication extends PyApplication { ); messages.setDescription(getString(R.string.notification_channel_messages_desc)); nm.createNotificationChannel(messages); + + Uri ringUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); + if (ringUri == null) { + ringUri = Settings.System.DEFAULT_RINGTONE_URI; + } + NotificationChannel calls = new NotificationChannel( + CHANNEL_ID_CALLS, + getString(R.string.notification_channel_calls_name), + NotificationManager.IMPORTANCE_HIGH + ); + calls.setDescription(getString(R.string.notification_channel_calls_desc)); + calls.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + calls.setBypassDnd(true); + } + if (ringUri != null) { + calls.setSound( + ringUri, + new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ); + } + long[] pattern = new long[] {0, 400, 200, 400, 200, 600}; + try { + calls.setVibrationPattern(pattern); + } catch (Exception ignored) { + } + nm.createNotificationChannel(calls); } } diff --git a/android/app/src/main/java/com/meshchatx/MeshChatForegroundService.java b/android/app/src/main/java/com/meshchatx/MeshChatForegroundService.java index 11ed06c..cfe0567 100644 --- a/android/app/src/main/java/com/meshchatx/MeshChatForegroundService.java +++ b/android/app/src/main/java/com/meshchatx/MeshChatForegroundService.java @@ -37,7 +37,7 @@ public class MeshChatForegroundService extends Service { PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE ); return new NotificationCompat.Builder(this, MeshChatApplication.CHANNEL_ID_BACKGROUND) - .setSmallIcon(R.drawable.ic_launcher_foreground) + .setSmallIcon(R.drawable.ic_stat_meshchatx) .setContentTitle(getString(R.string.notification_background_title)) .setContentText(getString(R.string.notification_background_text)) .setOngoing(true) diff --git a/android/app/src/main/java/com/meshchatx/TelephoneNativeAudioSession.java b/android/app/src/main/java/com/meshchatx/TelephoneNativeAudioSession.java new file mode 100644 index 0000000..a636bfb --- /dev/null +++ b/android/app/src/main/java/com/meshchatx/TelephoneNativeAudioSession.java @@ -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(); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/meshchatx/WavPcmAttachmentRecorder.java b/android/app/src/main/java/com/meshchatx/WavPcmAttachmentRecorder.java new file mode 100644 index 0000000..2b398cb --- /dev/null +++ b/android/app/src/main/java/com/meshchatx/WavPcmAttachmentRecorder.java @@ -0,0 +1,204 @@ +package com.meshchatx; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.MediaRecorder; +import android.util.Base64; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * 16-bit mono PCM at 48kHz, packaged as RIFF WAV. Capped in duration to keep memory use bounded. + */ +public final class WavPcmAttachmentRecorder { + public static final int SAMPLE_RATE = 48000; + public static final int MAX_DURATION_MS = 120_000; + private static final int CHANNEL = AudioFormat.CHANNEL_IN_MONO; + private static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT; + private static final int MAX_RAW_PCM = SAMPLE_RATE * 2 * (MAX_DURATION_MS / 1000); + private static final int IO_BYTES = 4096; + + private final Context ctx; + private final java.util.concurrent.atomic.AtomicBoolean rec = new java.util.concurrent.atomic.AtomicBoolean(false); + @Nullable + private AudioRecord audioRecord; + @Nullable + private Thread thread; + private final ByteArrayOutputStream pcm = new ByteArrayOutputStream(65536); + + public WavPcmAttachmentRecorder(Context ctx) { + this.ctx = ctx.getApplicationContext(); + } + + public static boolean canStart(Context c) { + if (ContextCompat.checkSelfPermission(c, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + return false; + } + return AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL, ENCODING) > 0; + } + + public String start() { + if (rec.get()) { + return "err: already recording"; + } + if (!canStart(ctx)) { + return "err: record_audio"; + } + int min = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL, ENCODING); + if (min <= 0) { + return "err: buffer"; + } + try { + audioRecord = new AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + CHANNEL, + ENCODING, + min * 2 + ); + if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { + return "err: state"; + } + pcm.reset(); + rec.set(true); + thread = new Thread( + this::pump, + "meshchatx-attach-pcm" + ); + audioRecord.startRecording(); + thread.start(); + } catch (Exception e) { + stopInternal(); + return e.getMessage() != null ? e.getMessage() : "err: start"; + } + return "ok"; + } + + private void pump() { + byte[] buf = new byte[IO_BYTES]; + while (rec.get() && audioRecord != null) { + int n; + try { + n = audioRecord.read(buf, 0, buf.length); + } catch (Exception e) { + break; + } + if (n <= 0) { + break; + } + if (pcm.size() + n > MAX_RAW_PCM) { + rec.set(false); + break; + } + try { + pcm.write(buf, 0, n); + } catch (Exception e) { + break; + } + } + } + + @Nullable + public String stopBase64Wav() { + if (!rec.get() && thread == null && audioRecord == null) { + return null; + } + rec.set(false); + if (thread != null) { + try { + thread.join(4000L); + } catch (InterruptedException ignored) { + } + } + thread = null; + if (audioRecord != null) { + try { + if (audioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) { + audioRecord.stop(); + } + } catch (Exception ignored) { + } + try { + audioRecord.release(); + } catch (Exception ignored) { + } + audioRecord = null; + } + int rawLen = pcm.size(); + if (rawLen == 0) { + return ""; + } + try { + byte[] raw = pcm.toByteArray(); + return Base64.encodeToString(wrapWav(raw, SAMPLE_RATE), Base64.NO_WRAP); + } catch (OutOfMemoryError e) { + return null; + } finally { + try { + pcm.reset(); + } catch (Exception ignored) { + } + } + } + + private void stopInternal() { + rec.set(false); + if (audioRecord != null) { + try { + audioRecord.release(); + } catch (Exception ignored) { + } + audioRecord = null; + } + } + + public void cancel() { + rec.set(false); + if (thread != null) { + try { + thread.join(2000L); + } catch (InterruptedException ignored) { + } + } + thread = null; + stopInternal(); + try { + pcm.reset(); + } catch (Exception ignored) { + } + } + + public boolean isRunning() { + return rec.get(); + } + + static byte[] wrapWav(byte[] pcmS16, int rate) { + int data = pcmS16.length; + int rsize = 36 + data; + int byteRate = rate * 2; + ByteBuffer bb = ByteBuffer.allocate(44 + data).order(ByteOrder.LITTLE_ENDIAN); + bb.put("RIFF".getBytes()); + bb.putInt(rsize); + bb.put("WAVE".getBytes()); + bb.put("fmt ".getBytes()); + bb.putInt(16); + bb.putShort((short) 1); + bb.putShort((short) 1); + bb.putInt(rate); + bb.putInt(byteRate); + bb.putShort((short) 2); + bb.putShort((short) 16); + bb.put("data".getBytes()); + bb.putInt(data); + bb.put(pcmS16); + return bb.array(); + } +} diff --git a/android/app/src/main/res/drawable/ic_stat_meshchatx.xml b/android/app/src/main/res/drawable/ic_stat_meshchatx.xml new file mode 100644 index 0000000..ef06e9a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_meshchatx.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/values-v35/edge_to_edge_opt_out.xml b/android/app/src/main/res/values-v35/edge_to_edge_opt_out.xml new file mode 100644 index 0000000..55c60d3 --- /dev/null +++ b/android/app/src/main/res/values-v35/edge_to_edge_opt_out.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/main/res/values/edge_to_edge_opt_out.xml b/android/app/src/main/res/values/edge_to_edge_opt_out.xml new file mode 100644 index 0000000..abfe85d --- /dev/null +++ b/android/app/src/main/res/values/edge_to_edge_opt_out.xml @@ -0,0 +1,4 @@ + + +