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 @@
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 57f4b3f..c0b712e 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -7,8 +7,20 @@
Shown while MeshChatX runs when the app is in the background
Messages
Incoming LXMF message alerts
+ Calls
+ Incoming and missed call alerts (ring + vibrate)
+ Incoming call
+ %1$s is calling
+ Decline
+ Answer
+ Missed call
MeshChatX is running
Reticulum stack active in the background. Tap to return.
New message
+ Messages
+ Open messages
+ Call
+ Open phone / call
+ Shortcut unavailable
diff --git a/android/app/src/main/res/xml/locales_config.xml b/android/app/src/main/res/xml/locales_config.xml
new file mode 100644
index 0000000..0250ade
--- /dev/null
+++ b/android/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/shortcuts.xml b/android/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 0000000..db1416e
--- /dev/null
+++ b/android/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 42d8b50..a1cb08e 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -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