package com.meshchatx; import android.Manifest; import android.annotation.SuppressLint; import android.content.ActivityNotFoundException; import android.content.ClipData; import android.content.ClipboardManager; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.hardware.usb.UsbManager; import android.net.Uri; import android.os.BatteryManager; import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.os.Handler; import android.os.Looper; import android.os.PowerManager; import android.content.IntentFilter; import android.provider.MediaStore; import android.provider.Settings; import android.view.WindowManager; import android.webkit.CookieManager; import android.webkit.GeolocationPermissions; import android.webkit.PermissionRequest; import android.webkit.ValueCallback; import android.webkit.WebChromeClient; import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; import android.webkit.WebSettings; import android.webkit.JavascriptInterface; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; import androidx.core.view.WindowCompat; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatDelegate; import com.chaquo.python.Python; import com.chaquo.python.android.AndroidPlatform; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.PrintWriter; import java.io.StringWriter; import java.net.URLConnection; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; import okhttp3.Request; import okhttp3.Response; import org.json.JSONObject; public class MainActivity extends AppCompatActivity { private static final String SHELL_PREFS = "meshchatx_shell"; private static final String PREF_UI_THEME = "ui_theme"; private static final String PREF_BLOCK_SCREENSHOTS = "block_screenshots"; private static final String PREF_CLEAR_CLIPBOARD_ON_BACKGROUND = "clear_clipboard_on_background"; private static final String THEME_DARK = "dark"; private static final String THEME_LIGHT = "light"; private WebView webView; private ProgressBar progressBar; private ImageView loadingLogo; private TextView loadingText; private TextView errorText; private static final String SERVER_URL = "https://127.0.0.1:8000"; private static final int SERVER_PORT = 8000; private static final int RUNTIME_PERMISSIONS_REQUEST_CODE = 1001; private static final int WEB_MEDIA_PERMISSION_REQUEST_CODE = 1003; private static final int RNODE_BLUETOOTH_PERMISSION_REQUEST_CODE = 1004; private static final int WEBVIEW_GEOLOCATION_PERMISSION_REQUEST_CODE = 1006; private static final String PREFS_NAME = "meshchatx"; private static final String PREF_BATTERY_OPT_REQUESTED = "battery_opt_requested"; 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; @Nullable private GeolocationPermissions.Callback pendingGeolocationPermissionCallback = null; @Nullable private String pendingGeolocationOrigin = 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…", "Getting the network ready…", "Almost there…", "Opening MeshChatX…", "Finishing up…" }; private static boolean isAllowedWebViewNavigationUri(Uri uri) { if (uri == null) { return false; } String scheme = uri.getScheme(); if (scheme == null) { return false; } String s = scheme.toLowerCase(); if ("about".equals(s)) { String part = uri.getSchemeSpecificPart(); return part != null && "blank".equalsIgnoreCase(part); } if ("blob".equals(s) || "data".equals(s)) { return true; } if (!"http".equals(s) && !"https".equals(s)) { return false; } String host = uri.getHost(); if (host == null) { return false; } String h = host.toLowerCase(); return "127.0.0.1".equals(h) || "localhost".equals(h) || "[::1]".equals(h) || "::1".equals(h); } private void openExternalBrowserUri(Uri uri) { if (uri == null) { return; } try { startActivity(new Intent(Intent.ACTION_VIEW, uri)); } catch (ActivityNotFoundException ignored) { // no browser installed } } private final ActivityResultLauncher filePickerLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { Uri[] selection = null; if (result.getResultCode() == RESULT_OK && result.getData() != null) { Intent data = result.getData(); if (data.getClipData() != null) { int count = data.getClipData().getItemCount(); selection = new Uri[count]; for (int i = 0; i < count; i++) { selection[i] = data.getClipData().getItemAt(i).getUri(); } } else if (data.getData() != null) { selection = new Uri[] { data.getData() }; } } if (filePathCallback != null) { filePathCallback.onReceiveValue(selection); filePathCallback = null; } } ); @SuppressLint("SetJavaScriptEnabled") @Override protected void onCreate(Bundle savedInstanceState) { boolean darkShell = isDarkUiTheme(resolvePreferredUiTheme()); getDelegate().setLocalNightMode( darkShell ? AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO ); getTheme().applyStyle(R.style.OptOutEdgeToEdgeEnforcement, false); super.onCreate(savedInstanceState); WindowCompat.setDecorFitsSystemWindows(getWindow(), true); setContentView(R.layout.activity_main); // Mitigate overlay tapjacking without changing user-visible behavior. getWindow().getDecorView().setFilterTouchesWhenObscured(true); applyBlockScreenshots(isBlockScreenshotsEnabled()); webView = findViewById(R.id.webView); progressBar = findViewById(R.id.progressBar); loadingLogo = findViewById(R.id.loadingLogo); loadingText = findViewById(R.id.loadingText); errorText = findViewById(R.id.errorText); // Match MeshChatX canvas so WebView never flashes default white during Chaquopy boot // or keyboard/resize gaps. Prefer last saved UI theme (default dark). applyShellCanvasTheme(resolvePreferredUiTheme()); webView.setVisibility(android.view.View.INVISIBLE); showLoading("Starting MeshChatX…"); if (!Python.isStarted()) { try { Python.start(new AndroidPlatform(this)); } catch (Exception e) { backendFailed = true; showStartupError("MeshChatX Python runtime failed to start:", toStackTrace(e)); return; } } try { org.able.BLE.setAppContext(this); } catch (Exception ignored) { // BLE class may be unavailable in incomplete builds. } requestRuntimePermissionsIfNeeded(); WebSettings webSettings = webView.getSettings(); webSettings.setJavaScriptEnabled(true); webSettings.setDomStorageEnabled(true); webSettings.setDatabaseEnabled(true); webSettings.setAllowFileAccess(true); webSettings.setAllowContentAccess(true); webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); webSettings.setMediaPlaybackRequiresUserGesture(false); webSettings.setGeolocationEnabled(true); webView.setWebViewClient(new WebViewClient() { @Override @RequiresApi(api = Build.VERSION_CODES.N) public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { Uri uri = request != null ? request.getUrl() : null; if (isAllowedWebViewNavigationUri(uri)) { return false; } openExternalBrowserUri(uri); return true; } @Override @SuppressWarnings("deprecation") public boolean shouldOverrideUrlLoading(WebView view, String url) { Uri uri = url != null ? Uri.parse(url) : null; if (isAllowedWebViewNavigationUri(uri)) { return false; } openExternalBrowserUri(uri); return true; } @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); if (!startupPageLoaded) { if (!isStartupRequest(url)) { return; } if (startupRequestHadLoadError) { return; } startupPageLoaded = true; mainHandler.removeCallbacksAndMessages(null); hideStartupLoadingOverlay(); dispatchPendingIntentUri(); dispatchCallNotificationAction(); return; } hideStartupLoadingOverlay(); } @Override public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { super.onPageStarted(view, url, favicon); if (!startupPageLoaded && isStartupRequest(url)) { startupRequestHadLoadError = false; webView.setVisibility(android.view.View.INVISIBLE); progressBar.setVisibility(android.view.View.VISIBLE); return; } if (!startupPageLoaded) { progressBar.setVisibility(android.view.View.VISIBLE); } } @Override public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { super.onReceivedError(view, request, error); if (request != null && request.isForMainFrame() && isStartupRequest(request.getUrl().toString())) { startupRequestHadLoadError = true; view.stopLoading(); view.loadUrl("about:blank"); if (backendFailed && !startupPageLoaded) { String description = (error != null) ? error.getDescription().toString() : "Unknown error"; showStartupError("WebView failed to load MeshChatX:", description); } } } @Override public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { super.onReceivedError(view, errorCode, description, failingUrl); if (isStartupRequest(failingUrl) && !startupPageLoaded) { startupRequestHadLoadError = true; view.stopLoading(); view.loadUrl("about:blank"); if (backendFailed) { showStartupError("WebView failed to load MeshChatX:", description); } } } @SuppressLint("WebViewClientOnReceivedSslError") @Override public void onReceivedSslError(WebView view, android.webkit.SslErrorHandler handler, android.net.http.SslError error) { // Ignore SSL certificate errors for localhost handler.proceed(); } }); webView.addJavascriptInterface(new MeshChatXAndroidBridge(this), "MeshChatXAndroid"); webView.setDownloadListener(this::onWebViewDownloadStart); webView.setWebChromeClient(new WebChromeClient() { @Override public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { runOnUiThread(() -> { if (callback == null) { return; } if (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) { callback.invoke(origin, true, false); return; } pendingGeolocationOrigin = origin; pendingGeolocationPermissionCallback = callback; ActivityCompat.requestPermissions( MainActivity.this, new String[] { Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION, }, WEBVIEW_GEOLOCATION_PERMISSION_REQUEST_CODE ); }); } @Override public void onGeolocationPermissionsHidePrompt() { runOnUiThread(() -> { pendingGeolocationPermissionCallback = null; pendingGeolocationOrigin = null; }); } @Override public void onPermissionRequest(final PermissionRequest request) { runOnUiThread(() -> { if (request == null) { return; } boolean needsAudioCapture = false; boolean needsVideoCapture = false; for (String resource : request.getResources()) { if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(resource)) { needsAudioCapture = true; } else if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource)) { needsVideoCapture = true; } } if (!needsAudioCapture && !needsVideoCapture) { request.grant(request.getResources()); return; } List missingPermissions = new ArrayList<>(); if ( needsAudioCapture && ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED ) { missingPermissions.add(Manifest.permission.RECORD_AUDIO); } if ( needsVideoCapture && ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ) { missingPermissions.add(Manifest.permission.CAMERA); } if (missingPermissions.isEmpty()) { String[] allowed = grantableWebPermissionResources(request); if (allowed.length > 0) { request.grant(allowed); } else { request.deny(); } return; } pendingWebPermissionRequest = request; ActivityCompat.requestPermissions( MainActivity.this, missingPermissions.toArray(new String[0]), WEB_MEDIA_PERMISSION_REQUEST_CODE ); }); } @Override public void onPermissionRequestCanceled(PermissionRequest request) { runOnUiThread(() -> { if (pendingWebPermissionRequest == request) { pendingWebPermissionRequest = null; } }); } @Override public boolean onShowFileChooser( WebView webView, ValueCallback filePathCallback, WebChromeClient.FileChooserParams fileChooserParams ) { if (MainActivity.this.filePathCallback != null) { MainActivity.this.filePathCallback.onReceiveValue(null); } MainActivity.this.filePathCallback = filePathCallback; Intent chooserIntent; try { chooserIntent = fileChooserParams != null ? fileChooserParams.createIntent() : new Intent(Intent.ACTION_GET_CONTENT); } catch (Exception e) { chooserIntent = new Intent(Intent.ACTION_GET_CONTENT); } chooserIntent.addCategory(Intent.CATEGORY_OPENABLE); if (chooserIntent.getType() == null) { chooserIntent.setType("*/*"); } if (fileChooserParams != null && fileChooserParams.getAcceptTypes() != null) { String[] acceptTypes = fileChooserParams.getAcceptTypes(); ArrayList mimeTypes = new ArrayList<>(); for (String acceptType : acceptTypes) { if (acceptType == null || acceptType.isEmpty()) { continue; } String trimmed = acceptType.trim(); if (trimmed.startsWith(".")) { // Extension filters are not valid MIME types for EXTRA_MIME_TYPES. // Use octet-stream so identity backups without a MIME mapping remain selectable. if (!mimeTypes.contains("application/octet-stream")) { mimeTypes.add("application/octet-stream"); } if (!mimeTypes.contains("*/*")) { mimeTypes.add("*/*"); } } else if (!mimeTypes.contains(trimmed)) { mimeTypes.add(trimmed); } } if (!mimeTypes.isEmpty()) { chooserIntent.putExtra( Intent.EXTRA_MIME_TYPES, mimeTypes.toArray(new String[0]) ); } else { chooserIntent.setType("*/*"); } } boolean allowMultiple = fileChooserParams != null && fileChooserParams.getMode() == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE; chooserIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple); try { filePickerLauncher.launch(chooserIntent); } catch (ActivityNotFoundException e) { if (MainActivity.this.filePathCallback != null) { MainActivity.this.filePathCallback.onReceiveValue(null); MainActivity.this.filePathCallback = null; } Toast.makeText(MainActivity.this, "No file picker available", Toast.LENGTH_SHORT).show(); return false; } return true; } }); handleIncomingIntent(getIntent()); consumeCallIntentForPending(getIntent()); getOnBackPressedDispatcher().addCallback( this, new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { if (webView != null && webView.canGoBack()) { webView.goBack(); } else { setEnabled(false); MainActivity.this.getOnBackPressedDispatcher().onBackPressed(); setEnabled(true); } } } ); try { AndroidStorageManager.runPendingMigration(this); } catch (IOException migrationError) { showStartupError( "MeshChatX could not copy data to file-manager storage:", migrationError.getMessage() ); return; } startMeshChatServer(); scheduleConnectionRetry("Connecting to local server..."); } @Override protected void onStart() { super.onStart(); stopService(new Intent(this, MeshChatForegroundService.class)); } @Override protected void onStop() { if (isClearClipboardOnBackgroundEnabled()) { clearPrimaryClipboard(); } if (!isFinishing() && !backendFailed) { ContextCompat.startForegroundService(this, new Intent(this, MeshChatForegroundService.class)); } super.onStop(); } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); handleIncomingIntent(intent); consumeCallIntentForPending(intent); if (startupPageLoaded) { dispatchPendingIntentUri(); dispatchCallNotificationAction(); } } private void requestRuntimePermissionsIfNeeded() { List missingPermissions = new ArrayList<>(); addIfMissing(missingPermissions, Manifest.permission.RECORD_AUDIO); addIfMissing(missingPermissions, Manifest.permission.CAMERA); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { addIfMissing(missingPermissions, Manifest.permission.BLUETOOTH_CONNECT); addIfMissing(missingPermissions, Manifest.permission.BLUETOOTH_SCAN); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { addIfMissing(missingPermissions, Manifest.permission.POST_NOTIFICATIONS); } if (!missingPermissions.isEmpty()) { ActivityCompat.requestPermissions( this, missingPermissions.toArray(new String[0]), RUNTIME_PERMISSIONS_REQUEST_CODE ); } else { requestBatteryOptimizationExemptionIfNeeded(); } } private void requestBatteryOptimizationExemptionIfNeeded() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return; } PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); if (powerManager != null && powerManager.isIgnoringBatteryOptimizations(getPackageName())) { return; } boolean requestedBefore = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getBoolean(PREF_BATTERY_OPT_REQUESTED, false); if (requestedBefore) { return; } getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putBoolean(PREF_BATTERY_OPT_REQUESTED, true).apply(); Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); intent.setData(Uri.parse("package:" + getPackageName())); try { startActivity(intent); } catch (ActivityNotFoundException e) { try { startActivity(new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)); } catch (ActivityNotFoundException ignored) { Toast.makeText(this, "Open battery settings and allow unrestricted background usage for MeshChatX", Toast.LENGTH_LONG).show(); } } } void addIfMissing(List missingPermissions, String permission) { if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { missingPermissions.add(permission); } } private String[] grantableWebPermissionResources(PermissionRequest request) { if (request == null) { return new String[0]; } List allowed = new ArrayList<>(); for (String resource : request.getResources()) { if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(resource)) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { allowed.add(resource); } } else if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource)) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { allowed.add(resource); } } else { allowed.add(resource); } } return allowed.toArray(new String[0]); } private void completePendingWebPermissionRequestFromRuntimeState() { if (pendingWebPermissionRequest == null) { return; } String[] allowed = grantableWebPermissionResources(pendingWebPermissionRequest); if (allowed.length > 0) { pendingWebPermissionRequest.grant(allowed); } else { pendingWebPermissionRequest.deny(); } pendingWebPermissionRequest = null; } @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == WEBVIEW_GEOLOCATION_PERMISSION_REQUEST_CODE) { boolean granted = false; if (permissions != null && grantResults != null) { for (int i = 0; i < permissions.length && i < grantResults.length; i++) { if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { continue; } if (Manifest.permission.ACCESS_FINE_LOCATION.equals(permissions[i]) || Manifest.permission.ACCESS_COARSE_LOCATION.equals(permissions[i])) { granted = true; break; } } } if (pendingGeolocationPermissionCallback != null) { pendingGeolocationPermissionCallback.invoke(pendingGeolocationOrigin, granted, false); pendingGeolocationPermissionCallback = null; pendingGeolocationOrigin = null; } return; } if (requestCode == WEB_MEDIA_PERMISSION_REQUEST_CODE) { if (pendingWebPermissionRequest == null) { return; } boolean granted = true; for (int result : grantResults) { if (result != PackageManager.PERMISSION_GRANTED) { granted = false; break; } } if (granted) { String[] allowed = grantableWebPermissionResources(pendingWebPermissionRequest); if (allowed.length > 0) { pendingWebPermissionRequest.grant(allowed); } else { pendingWebPermissionRequest.deny(); } } else { pendingWebPermissionRequest.deny(); } pendingWebPermissionRequest = null; return; } if (requestCode != RUNTIME_PERMISSIONS_REQUEST_CODE) { return; } requestBatteryOptimizationExemptionIfNeeded(); completePendingWebPermissionRequestFromRuntimeState(); } private void startMeshChatServer() { new Thread(() -> { try { Python py = Python.getInstance(); String appFilesDir = AndroidStorageManager.resolveActiveBaseDir(MainActivity.this).getAbsolutePath(); // Pass Activity so usb4a / org.able.BLE can open RNode USB and BLE. py.getModule("meshchat_wrapper").callAttr( "start_server", SERVER_PORT, appFilesDir, MainActivity.this); } catch (Exception e) { final String stack = toStackTrace(e); runOnUiThread(() -> { if (startupPageLoaded) { return; } meshchatServerStartAttempts += 1; if (meshchatServerStartAttempts < MESHCHAT_SERVER_START_MAX_ATTEMPTS) { backendFailed = false; showLoading("Having trouble starting. Trying again…"); mainHandler.postDelayed(() -> startMeshChatServer(), MESHCHAT_SERVER_RETRY_DELAY_MS); } else { backendFailed = true; showStartupError("MeshChatX backend failed:", stack); } }); } }).start(); } private boolean isStartupRequest(String url) { return url != null && url.startsWith(SERVER_URL); } private void handleIncomingIntent(Intent intent) { if (intent == null || !Intent.ACTION_VIEW.equals(intent.getAction())) { return; } Uri data = intent.getData(); if (data == null || data.getScheme() == null) { 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; } String uri = pendingIntentUri; pendingIntentUri = null; String js = "window.dispatchEvent(new CustomEvent('meshchatx-intent-uri',{detail:" + JSONObject.quote(uri) + "}));"; webView.evaluateJavascript(js, null); } private void scheduleConnectionRetry(String message) { if (startupPageLoaded || backendFailed) { return; } showLoading(message); long retryDelayMs = Math.min( CONNECTION_RETRY_MAX_DELAY_MS, CONNECTION_RETRY_INITIAL_DELAY_MS + (connectionAttempts * 250L) ); mainHandler.postDelayed(() -> { if (startupPageLoaded || backendFailed) { return; } connectionAttempts += 1; if (connectionAttempts > MAX_CONNECTION_ATTEMPTS) { showStartupError( "Failed to connect to local MeshChatX server after waiting for startup.", null ); return; } webView.loadUrl(SERVER_URL); scheduleConnectionRetry("Still starting…"); }, retryDelayMs); } private String resolvePreferredUiTheme() { SharedPreferences prefs = getSharedPreferences(SHELL_PREFS, MODE_PRIVATE); String stored = prefs.getString(PREF_UI_THEME, null); if (THEME_LIGHT.equals(stored) || THEME_DARK.equals(stored)) { return stored; } return THEME_DARK; } private static boolean isDarkUiTheme(String theme) { return !THEME_LIGHT.equals(theme); } private void persistPreferredUiTheme(String theme) { String normalized = isDarkUiTheme(theme) ? THEME_DARK : THEME_LIGHT; getSharedPreferences(SHELL_PREFS, MODE_PRIVATE) .edit() .putString(PREF_UI_THEME, normalized) .apply(); } private void applyShellCanvasTheme(String theme) { boolean dark = isDarkUiTheme(theme); int canvasColor = ContextCompat.getColor( this, dark ? R.color.meshchat_canvas_dark : R.color.meshchat_canvas_light ); getWindow().getDecorView().setBackgroundColor(canvasColor); android.view.View content = findViewById(android.R.id.content); if (content != null) { content.setBackgroundColor(canvasColor); } if (webView != null) { webView.setBackgroundColor(canvasColor); } if (loadingText != null) { loadingText.setTextColor( ContextCompat.getColor(this, dark ? R.color.white : R.color.black) ); } } private void setUiThemeFromBridge(String theme) { String normalized = isDarkUiTheme(theme) ? THEME_DARK : THEME_LIGHT; persistPreferredUiTheme(normalized); applyShellCanvasTheme(normalized); // Update night mode without forcing an immediate recreate mid-session. // Next cold start applies local night mode before setContentView. int desired = isDarkUiTheme(normalized) ? AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO; if (getDelegate().getLocalNightMode() != desired) { getDelegate().setLocalNightMode(desired); } } private boolean isBlockScreenshotsEnabled() { return getSharedPreferences(SHELL_PREFS, MODE_PRIVATE) .getBoolean(PREF_BLOCK_SCREENSHOTS, false); } private void setBlockScreenshotsEnabled(boolean enabled) { getSharedPreferences(SHELL_PREFS, MODE_PRIVATE) .edit() .putBoolean(PREF_BLOCK_SCREENSHOTS, enabled) .apply(); applyBlockScreenshots(enabled); } private void applyBlockScreenshots(boolean enabled) { if (enabled) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); } else { getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); } } private boolean isClearClipboardOnBackgroundEnabled() { return getSharedPreferences(SHELL_PREFS, MODE_PRIVATE) .getBoolean(PREF_CLEAR_CLIPBOARD_ON_BACKGROUND, false); } private void setClearClipboardOnBackgroundEnabled(boolean enabled) { getSharedPreferences(SHELL_PREFS, MODE_PRIVATE) .edit() .putBoolean(PREF_CLEAR_CLIPBOARD_ON_BACKGROUND, enabled) .apply(); } private void clearPrimaryClipboard() { try { ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); if (clipboard == null) { return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { clipboard.clearPrimaryClip(); } else { clipboard.setPrimaryClip(ClipData.newPlainText("", "")); } } catch (Exception ignored) { // Clipboard access can fail on locked devices or OEM builds. } } private String toStackTrace(Throwable error) { StringWriter sw = new StringWriter(); PrintWriter pw = new PrintWriter(sw); error.printStackTrace(pw); pw.flush(); return sw.toString(); } private void hideStartupLoadingOverlay() { if (webView != null) { webView.setVisibility(android.view.View.VISIBLE); } if (loadingLogo != null) { loadingLogo.setVisibility(android.view.View.GONE); } if (progressBar != null) { progressBar.setVisibility(android.view.View.GONE); } if (loadingText != null) { loadingText.setVisibility(android.view.View.GONE); } if (errorText != null && !backendFailed) { errorText.setVisibility(android.view.View.GONE); } } private void showStartupError(String title, @Nullable String detail) { final String message = StartupErrorReport.format( title, detail, StartupErrorReport.collect(this) ); runOnUiThread(() -> { mainHandler.removeCallbacksAndMessages(null); if (webView != null) { webView.setVisibility(android.view.View.INVISIBLE); } if (loadingLogo != null) { loadingLogo.setVisibility(android.view.View.GONE); } if (progressBar != null) { progressBar.setVisibility(android.view.View.GONE); } if (loadingText != null) { loadingText.setVisibility(android.view.View.GONE); } if (errorText != null) { errorText.setText(message); errorText.setVisibility(android.view.View.VISIBLE); } }); } private void showLoading(String message) { runOnUiThread(() -> { if (startupPageLoaded) { return; } webView.setVisibility(android.view.View.INVISIBLE); if (loadingLogo != null) { loadingLogo.setVisibility(android.view.View.VISIBLE); } progressBar.setVisibility(android.view.View.VISIBLE); errorText.setVisibility(android.view.View.GONE); if (loadingText != null) { loadingText.setText(formatLoadingMessage(message)); loadingText.setVisibility(android.view.View.VISIBLE); } }); } private String formatLoadingMessage(String fallbackMessage) { int phaseIndex = Math.min( STARTUP_PHASES.length - 1, (connectionAttempts * STARTUP_PHASES.length) / Math.max(1, MAX_CONNECTION_ATTEMPTS) ); // Prefer friendly phase copy over technical/counter messages. if (fallbackMessage == null || fallbackMessage.isEmpty()) { return STARTUP_PHASES[phaseIndex]; } String trimmed = fallbackMessage.trim(); if (trimmed.startsWith("Starting MeshChatX") || trimmed.startsWith("Still starting") || trimmed.startsWith("Retrying") || trimmed.startsWith("Having trouble")) { return STARTUP_PHASES[phaseIndex]; } return trimmed; } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); if (webView != null) { webView.post(() -> { if (webView != null) { webView.requestLayout(); } }); } } @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); if (pendingWebPermissionRequest != null) { pendingWebPermissionRequest.deny(); pendingWebPermissionRequest = null; } if (pendingGeolocationPermissionCallback != null) { pendingGeolocationPermissionCallback.invoke(pendingGeolocationOrigin, false, false); pendingGeolocationPermissionCallback = null; pendingGeolocationOrigin = null; } if (filePathCallback != null) { filePathCallback.onReceiveValue(null); filePathCallback = null; } if (webView != null) { webView.destroy(); } } WebView getWebViewForNativeBridge() { return webView; } private void onWebViewDownloadStart( String url, String userAgent, String contentDisposition, String mimeType, long contentLength ) { if (url == null || url.isEmpty()) { return; } Uri uri = Uri.parse(url); if (!isAllowedWebViewNavigationUri(uri)) { return; } String scheme = uri.getScheme(); if (scheme == null) { return; } String s = scheme.toLowerCase(Locale.ROOT); if (!"http".equals(s) && !"https".equals(s)) { return; } String fileName = parseDownloadFileName(contentDisposition, uri); new Thread(() -> fetchAndPersistWebViewDownload(url, fileName), "meshchatx-webview-dl").start(); } private static String parseDownloadFileName(String contentDisposition, Uri uri) { if (contentDisposition != null && !contentDisposition.isEmpty()) { Matcher star = Pattern.compile("filename\\*=UTF-8''([^;\\s]+)", Pattern.CASE_INSENSITIVE) .matcher(contentDisposition); if (star.find()) { try { return java.net.URLDecoder.decode(star.group(1), "UTF-8"); } catch (Exception ignored) { // fall through } } Matcher plain = Pattern.compile("filename=\"?([^\";\\n]+)\"?", Pattern.CASE_INSENSITIVE) .matcher(contentDisposition); if (plain.find()) { return plain.group(1).trim(); } } String segment = uri != null ? uri.getLastPathSegment() : null; if (segment != null && !segment.isEmpty()) { return segment; } return "download.bin"; } private void fetchAndPersistWebViewDownload(String url, String fileName) { try { Request.Builder builder = new Request.Builder().url(url); String cookie = CookieManager.getInstance().getCookie(url); if (cookie != null && !cookie.isEmpty()) { builder.addHeader("Cookie", cookie); } try (Response response = LocalhostTrustOkHttpClient.get().newCall(builder.build()).execute()) { if (!response.isSuccessful() || response.body() == null) { runOnUiThread( () -> Toast.makeText( this, "Download failed: HTTP " + response.code(), Toast.LENGTH_LONG ).show() ); return; } byte[] data = response.body().bytes(); runOnUiThread( () -> { try { persistMeshchatDownload(fileName, data); } catch (IOException e) { Toast.makeText( this, "Save failed: " + e.getMessage(), Toast.LENGTH_LONG ).show(); } } ); } } catch (Exception e) { runOnUiThread( () -> Toast.makeText(this, "Download failed: " + e.getMessage(), Toast.LENGTH_LONG).show() ); } } void persistMeshchatDownload(String fileName, byte[] data) throws IOException { String safe = MeshchatDownloadUtils.sanitizeFileName(fileName); ContentResolver resolver = getContentResolver(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ContentValues values = new ContentValues(); values.put(MediaStore.MediaColumns.DISPLAY_NAME, safe); String mime = URLConnection.guessContentTypeFromName(safe); if (mime == null) { mime = "application/octet-stream"; } values.put(MediaStore.MediaColumns.MIME_TYPE, mime); values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); values.put(MediaStore.MediaColumns.IS_PENDING, 1); Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); if (uri == null) { throw new IOException("MediaStore insert failed"); } try (OutputStream out = resolver.openOutputStream(uri)) { if (out == null) { throw new IOException("openOutputStream failed"); } out.write(data); } values.clear(); values.put(MediaStore.MediaColumns.IS_PENDING, 0); resolver.update(uri, values, null, null); Toast.makeText(this, getString(R.string.download_saved_meshchatx, safe), Toast.LENGTH_LONG).show(); } else { File dir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); if (dir == null) { throw new IOException("no download directory"); } if (!dir.exists() && !dir.mkdirs()) { throw new IOException("mkdirs failed"); } File target = new File(dir, safe); try (FileOutputStream fos = new FileOutputStream(target)) { fos.write(data); } Toast.makeText(this, getString(R.string.download_saved_app_files, target.getAbsolutePath()), Toast.LENGTH_LONG) .show(); } } private static class MeshChatXAndroidBridge { private final MainActivity activity; MeshChatXAndroidBridge(MainActivity activity) { this.activity = activity; } @JavascriptInterface public void saveDownload(String fileName, String base64Data) { if (base64Data == null) { return; } activity.runOnUiThread(() -> { try { byte[] raw = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT); if (raw.length == 0) { Toast.makeText(activity, "Empty file", Toast.LENGTH_SHORT).show(); return; } activity.persistMeshchatDownload(fileName, raw); } catch (IllegalArgumentException e) { Toast.makeText(activity, "Invalid download data", Toast.LENGTH_LONG).show(); } catch (IOException e) { Toast.makeText(activity, "Save failed: " + e.getMessage(), Toast.LENGTH_LONG).show(); } }); } @JavascriptInterface public void exitApp() { activity.runOnUiThread(() -> { activity.finishAffinity(); android.os.Process.killProcess(android.os.Process.myPid()); }); } @JavascriptInterface public String getPlatform() { return "android"; } @JavascriptInterface public String getBatteryStatus() { try { int level = -1; boolean charging = false; BatteryManager batteryManager = (BatteryManager) activity.getSystemService(Context.BATTERY_SERVICE); if (batteryManager != null) { level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY); // BatteryManager may return Integer.MIN_VALUE when unsupported. if (level < 0 || level > 100) { level = -1; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { charging = batteryManager.isCharging(); } } IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); Intent batteryStatus = activity.registerReceiver(null, filter); if (batteryStatus != null) { if (level < 0) { int rawLevel = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, 100); if (rawLevel >= 0 && scale > 0) { level = Math.round((rawLevel * 100f) / scale); } } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { int status = batteryStatus.getIntExtra( BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN); charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL; } } if (level < 0) { return ""; } if (level > 100) { level = 100; } return "{\"level\":" + level + ",\"charging\":" + (charging ? "true" : "false") + ",\"source\":\"android\"}"; } catch (Exception e) { return ""; } } @JavascriptInterface public String getPreferredUiTheme() { return activity.resolvePreferredUiTheme(); } @JavascriptInterface public void setUiTheme(String theme) { activity.runOnUiThread(() -> activity.setUiThemeFromBridge(theme)); } @JavascriptInterface public boolean getBlockScreenshots() { return activity.isBlockScreenshotsEnabled(); } @JavascriptInterface public void setBlockScreenshots(boolean enabled) { activity.runOnUiThread(() -> activity.setBlockScreenshotsEnabled(enabled)); } @JavascriptInterface public boolean getClearClipboardOnBackground() { return activity.isClearClipboardOnBackgroundEnabled(); } @JavascriptInterface public void setClearClipboardOnBackground(boolean enabled) { activity.runOnUiThread(() -> activity.setClearClipboardOnBackgroundEnabled(enabled)); } @JavascriptInterface public String getSidebandPluginsDefaultPath() { try { File dir = new File(activity.getFilesDir(), "meshchatx/sideband-plugins"); if (!dir.isDirectory() && !dir.mkdirs()) { return activity.getFilesDir().getAbsolutePath(); } return dir.getAbsolutePath(); } catch (Exception e) { return ""; } } @JavascriptInterface public void shareApk() { activity.runOnUiThread(() -> { try { String srcPath = activity.getApplicationInfo().sourceDir; File src = new File(srcPath); if (!src.isFile()) { Toast.makeText(activity, R.string.share_apk_unavailable, Toast.LENGTH_LONG).show(); return; } File cacheDir = new File(activity.getCacheDir(), "apk_share"); if (!cacheDir.isDirectory() && !cacheDir.mkdirs()) { Toast.makeText(activity, R.string.share_apk_failed, Toast.LENGTH_LONG).show(); return; } String ver = "unknown"; try { android.content.pm.PackageInfo pi = activity .getPackageManager() .getPackageInfo(activity.getPackageName(), 0); if (pi.versionName != null && !pi.versionName.isEmpty()) { ver = pi.versionName.replaceAll("[^A-Za-z0-9._-]+", "_"); } } catch (PackageManager.NameNotFoundException ignored) { } File dest = new File(cacheDir, "meshchatx-" + ver + ".apk"); try (FileInputStream in = new FileInputStream(src); FileOutputStream out = new FileOutputStream(dest, false)) { byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) { out.write(buf, 0, n); } out.flush(); } Uri uri = FileProvider.getUriForFile( activity, activity.getPackageName() + ".fileprovider", dest); Intent share = new Intent(Intent.ACTION_SEND); share.setType("application/vnd.android.package-archive"); share.putExtra(Intent.EXTRA_STREAM, uri); share.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); share.setClipData( ClipData.newUri( activity.getContentResolver(), activity.getString(R.string.share_apk_chooser_title), uri)); activity.startActivity( Intent.createChooser( share, activity.getString(R.string.share_apk_chooser_title))); } catch (Exception e) { String msg = activity.getString(R.string.share_apk_failed); if (e.getMessage() != null && !e.getMessage().isEmpty()) { msg = msg + ": " + e.getMessage(); } Toast.makeText(activity, msg, Toast.LENGTH_LONG).show(); } }); } @JavascriptInterface public boolean hasBluetoothPermissions() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { return true; } return ContextCompat.checkSelfPermission(activity, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(activity, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED; } @JavascriptInterface public void requestBluetoothPermissions() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { return; } activity.runOnUiThread(() -> { List missing = new ArrayList<>(); activity.addIfMissing(missing, Manifest.permission.BLUETOOTH_CONNECT); activity.addIfMissing(missing, Manifest.permission.BLUETOOTH_SCAN); if (missing.isEmpty()) { return; } ActivityCompat.requestPermissions( activity, missing.toArray(new String[0]), RNODE_BLUETOOTH_PERMISSION_REQUEST_CODE ); }); } @JavascriptInterface public boolean hasUsbPermissions() { UsbManager manager = (UsbManager) activity.getSystemService(Context.USB_SERVICE); if (manager == null) { return false; } java.util.HashMap devices = manager.getDeviceList(); if (devices == null || devices.isEmpty()) { return true; } for (android.hardware.usb.UsbDevice device : devices.values()) { if (!manager.hasPermission(device)) { return false; } } return true; } @JavascriptInterface public void requestUsbPermissions() { activity.runOnUiThread(() -> { try { UsbManager manager = (UsbManager) activity.getSystemService(Context.USB_SERVICE); if (manager == null) { return; } Intent intent = new Intent("com.meshchatx.USB_PERMISSION"); android.app.PendingIntent permissionIntent = android.app.PendingIntent.getBroadcast( activity, 0, intent, android.app.PendingIntent.FLAG_IMMUTABLE); for (android.hardware.usb.UsbDevice device : manager.getDeviceList().values()) { if (!manager.hasPermission(device)) { manager.requestPermission(device, permissionIntent); } } } catch (Exception e) { Toast.makeText( activity, "USB permission request failed", Toast.LENGTH_SHORT).show(); } }); } @JavascriptInterface public void openBluetoothSettings() { activity.runOnUiThread(() -> { try { activity.startActivity(new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)); } catch (ActivityNotFoundException ignored) { Toast.makeText(activity, "Bluetooth settings unavailable", Toast.LENGTH_SHORT).show(); } }); } @JavascriptInterface public void openUsbSettings() { activity.runOnUiThread(() -> { try { activity.startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + activity.getPackageName()))); } catch (ActivityNotFoundException ignored) { Toast.makeText(activity, "USB settings unavailable", Toast.LENGTH_SHORT).show(); } }); } @JavascriptInterface public boolean isNativePcmAudioAvailable() { return TelephoneNativeAudioSession.canRun(activity) && WavPcmAttachmentRecorder.canStart(activity); } /** * Voice-note attachment capture (PCM WAV) only checks the attachment recorder, * independent of telephone native audio, so the WebView can always prefer * native 48 kHz mono PCM for LXST Opus encoding on the server. */ @JavascriptInterface public boolean isNativeWavAttachmentAvailable() { return 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; } } @JavascriptInterface public void showNotification(String title, String body) { AndroidNotificationBridge.showInboundMessage(title, body, null, null); } @JavascriptInterface public void showMessageNotification(String title, String body, String destinationHash) { AndroidNotificationBridge.showInboundMessage(title, body, null, destinationHash); } @JavascriptInterface public void cancelMessageNotifications(String destinationHash) { AndroidNotificationBridge.cancelMessageNotifications(destinationHash); } @JavascriptInterface public void cancelAllMessageNotifications() { AndroidNotificationBridge.cancelAllMessageNotifications(); } @JavascriptInterface public void setOpenConversationHashes(String csv) { AndroidNotificationBridge.setOpenConversationHashes(csv); } @JavascriptInterface public void setDoNotDisturbEnabled(boolean enabled) { AndroidNotificationBridge.setDoNotDisturbEnabled(enabled); } @JavascriptInterface public void showIncomingCallNotification(String callerName) { AndroidNotificationBridge.showIncomingCall(callerName, null); } @JavascriptInterface public void showMissedCallNotification(String title, String body) { AndroidNotificationBridge.showMissedCall(title, body, null); } @JavascriptInterface public void cancelIncomingCallNotification() { AndroidNotificationBridge.cancelIncomingCallNotification(); } @JavascriptInterface public String getAndroidStorageStatus() { try { int versionCode = activity.getPackageManager().getPackageInfo(activity.getPackageName(), 0).versionCode; return AndroidStorageManager.statusJson(activity, versionCode); } catch (PackageManager.NameNotFoundException e) { return "{}"; } catch (Exception e) { return "{}"; } } @JavascriptInterface public void setAndroidStorageMode(String mode) { AndroidStorageManager.setConfiguredMode(activity, mode); } @JavascriptInterface public void markAndroidStorageSetupCompleted() { AndroidStorageManager.markSetupCompleted(activity); } @JavascriptInterface public void scheduleCopyToExternalAndRestart() { AndroidStorageManager.scheduleCopyToExternal(activity); } @JavascriptInterface public void keepInternalStorageAndDismiss() { try { int versionCode = activity.getPackageManager().getPackageInfo(activity.getPackageName(), 0).versionCode; AndroidStorageManager.keepInternalAndDismissUpgrade(activity, versionCode); } catch (PackageManager.NameNotFoundException ignored) { AndroidStorageManager.setConfiguredMode(activity, AndroidStorageManager.MODE_INTERNAL); AndroidStorageManager.markSetupCompleted(activity); } } @JavascriptInterface public void restartApp() { activity.runOnUiThread(() -> { activity.finishAffinity(); android.os.Process.killProcess(android.os.Process.myPid()); }); } } }