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
+4
View File
@@ -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.** { *; }
+16 -2
View File
@@ -13,6 +13,8 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
@@ -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">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|uiMode|keyboard|keyboardHidden|navigation">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -62,6 +68,14 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="lxm" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="app"
android:scheme="meshchatx" />
</intent-filter>
</activity>
<service
@@ -4,15 +4,27 @@ import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.app.Person;
public final class AndroidNotificationBridge {
private static final int NOTIFY_BASE_ID = 0x4d434800;
public static final int INCOMING_CALL_NOTIFICATION_ID = 0x4d434900;
public static final String ACTION_CALL_ANSWER = "com.meshchatx.action.CALL_ANSWER";
public static final String ACTION_CALL_DECLINE = "com.meshchatx.action.CALL_DECLINE";
public static final String ACTION_CALL_OPEN = "com.meshchatx.action.CALL_OPEN";
private static final int REQ_CALL_OPEN = 0x4c31;
private static final int REQ_CALL_FULL = 0x4c32;
private static final int REQ_CALL_ANSWER = 0x4c33;
private static final int REQ_CALL_DECLINE = 0x4c34;
private AndroidNotificationBridge() {
}
@@ -28,6 +40,180 @@ public final class AndroidNotificationBridge {
new Handler(Looper.getMainLooper()).post(() -> 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))
@@ -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);
}
}
}
@@ -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<Uri[]> 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;
}
}
}
}
@@ -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);
}
}
@@ -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)
@@ -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();
}
}
@@ -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();
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome: white on transparent. Status bar and notification small icons are alpha-only; color bitmaps (launcher art) look like a white block. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M20,2H4C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2V4C22,2.9 21.1,2 20,2zM18,14H6v-2h12V14zM18,11H6V9h12V11zM18,8H6V6h12V8z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="OptOutEdgeToEdgeEnforcement">
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="OptOutEdgeToEdgeEnforcement" />
</resources>
@@ -7,8 +7,20 @@
<string name="notification_channel_background_desc">Shown while MeshChatX runs when the app is in the background</string>
<string name="notification_channel_messages_name">Messages</string>
<string name="notification_channel_messages_desc">Incoming LXMF message alerts</string>
<string name="notification_channel_calls_name">Calls</string>
<string name="notification_channel_calls_desc">Incoming and missed call alerts (ring + vibrate)</string>
<string name="notification_incoming_call_tap">Incoming call</string>
<string name="notification_incoming_call_label">%1$s is calling</string>
<string name="notification_call_decline">Decline</string>
<string name="notification_call_answer">Answer</string>
<string name="notification_missed_call_label">Missed call</string>
<string name="notification_background_title">MeshChatX is running</string>
<string name="notification_background_text">Reticulum stack active in the background. Tap to return.</string>
<string name="notification_new_message_fallback">New message</string>
<string name="shortcut_messages_short">Messages</string>
<string name="shortcut_messages_long">Open messages</string>
<string name="shortcut_call_short">Call</string>
<string name="shortcut_call_long">Open phone / call</string>
<string name="shortcut_unavailable">Shortcut unavailable</string>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="de" />
<locale android:name="es" />
<locale android:name="fr" />
<locale android:name="it" />
<locale android:name="ja" />
<locale android:name="pl" />
<locale android:name="pt" />
<locale android:name="tr" />
<locale android:name="uk" />
<locale android:name="ru" />
<locale android:name="zh" />
<locale android:name="in" />
</locale-config>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="open_messages"
android:enabled="true"
android:icon="@mipmap/ic_launcher"
android:shortcutShortLabel="@string/shortcut_messages_short"
android:shortcutLongLabel="@string/shortcut_messages_long"
android:shortcutDisabledMessage="@string/shortcut_unavailable">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.meshchatx"
android:targetClass="com.meshchatx.MainActivity"
android:data="meshchatx://app/messages" />
</shortcut>
<shortcut
android:shortcutId="open_call"
android:enabled="true"
android:icon="@mipmap/ic_launcher"
android:shortcutShortLabel="@string/shortcut_call_short"
android:shortcutLongLabel="@string/shortcut_call_long"
android:shortcutDisabledMessage="@string/shortcut_unavailable">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.meshchatx"
android:targetClass="com.meshchatx.MainActivity"
android:data="meshchatx://app/call" />
</shortcut>
</shortcuts>
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME