mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-26 23:55:46 +00:00
feat(android): implement call handling and audio features, add notification channels, and enhance UI with new permissions and shortcuts
This commit is contained in:
Vendored
+4
@@ -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.** { *; }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user