diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java index 6db04551b1..56587e577b 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java @@ -71,6 +71,8 @@ public class MediaCaptureService { // Debug flag: Enable detailed end-to-end photo capture timing logs // true = log timing from request to capture, false = suppress timing logs private static final boolean ENABLE_PHOTO_TIMING_LOGS = true; + private static final int DEFAULT_PHOTO_UPLOAD_CONNECT_TIMEOUT_SECONDS = 5; + private static final int LOCAL_PHOTO_UPLOAD_CONNECT_TIMEOUT_SECONDS = 1; private final Context mContext; private final MediaUploadQueueManager mMediaQueueManager; @@ -2337,13 +2339,28 @@ private void performDirectUpload( // - 5 seconds to connect (allows time for DNS+TCP+TLS over WiFi) // - 10 seconds to write the photo data // - 5 seconds to read the response + int connectTimeoutSeconds = + isLocalNetworkWebhook(webhookUrl) + ? LOCAL_PHOTO_UPLOAD_CONNECT_TIMEOUT_SECONDS + : DEFAULT_PHOTO_UPLOAD_CONNECT_TIMEOUT_SECONDS; + if (connectTimeoutSeconds + < DEFAULT_PHOTO_UPLOAD_CONNECT_TIMEOUT_SECONDS) { + Log.d( + TAG, + "⚡ Using fast local receiver connect timeout (" + + connectTimeoutSeconds + + "s) for webhook: " + + webhookUrl); + } + OkHttpClient client = new OkHttpClient.Builder() .connectTimeout( - 5, + connectTimeoutSeconds, java.util.concurrent.TimeUnit - .SECONDS) // Allow time for - // DNS+TCP+TLS on WiFi + .SECONDS) // Local receiver + // failures should fall back quickly; public + // webhooks keep the normal WiFi allowance. .writeTimeout( 10, java.util.concurrent.TimeUnit @@ -2686,6 +2703,41 @@ private void performDirectUpload( .start(); } + private boolean isLocalNetworkWebhook(String webhookUrl) { + try { + URI uri = URI.create(webhookUrl); + String host = uri.getHost(); + if (host == null || host.isEmpty()) { + return false; + } + return isLocalIpv4Literal(host) || "localhost".equalsIgnoreCase(host); + } catch (Exception ignored) { + return false; + } + } + + private boolean isLocalIpv4Literal(String host) { + String[] parts = host.split("\\."); + if (parts.length != 4) { + return false; + } + int[] octets = new int[4]; + for (int i = 0; i < parts.length; i++) { + try { + octets[i] = Integer.parseInt(parts[i]); + } catch (NumberFormatException e) { + return false; + } + if (octets[i] < 0 || octets[i] > 255) { + return false; + } + } + return octets[0] == 10 + || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) + || (octets[0] == 192 && octets[1] == 168) + || octets[0] == 127; + } + /** Upload a video file to AugmentOS Cloud Currently a stub - videos are kept on device */ public void uploadVideo(String videoFilePath, String requestId) { Log.d(TAG, "Video upload not implemented yet. Video saved locally: " + videoFilePath); diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt index adf7afa095..69bf46acfc 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt @@ -1,8 +1,11 @@ package com.mentra.bluetoothsdk.photoreceiver import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.net.Uri import android.util.Log +import com.mentra.bluetoothsdk.DeviceStore import com.mentra.bluetoothsdk.debug.BleTraceLogger import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.modules.Module @@ -129,6 +132,10 @@ class MentraPhotoReceiverModule : Module() { } private fun bestLocalIpv4Address(): String? { + val glassesLocalIp = (DeviceStore.get("glasses", "wifiLocalIp") as? String) + ?.trim() + ?.takeIf { it.isNotEmpty() } + val activeNetworkAddress = activeNetworkIpv4Address() val wifiPrivateCandidates = mutableListOf() val wifiCandidates = mutableListOf() val privateCandidates = mutableListOf() @@ -153,6 +160,30 @@ class MentraPhotoReceiverModule : Module() { } } + if (activeNetworkAddress != null) { + val activeHost = activeNetworkAddress.hostAddress + if (sameIpv4Slash24(activeHost, glassesLocalIp)) { + emitStatus("Using active phone Wi-Fi address $activeHost for glasses $glassesLocalIp") + return activeHost + } + } + + val sameSubnetCandidate = ( + wifiPrivateCandidates + + wifiCandidates + + privateCandidates + + otherCandidates + ).firstOrNull { sameIpv4Slash24(it.hostAddress, glassesLocalIp) } + if (sameSubnetCandidate != null) { + emitStatus("Using same-subnet phone address ${sameSubnetCandidate.hostAddress} for glasses $glassesLocalIp") + return sameSubnetCandidate.hostAddress + } + + if (activeNetworkAddress != null && isPrivateIpv4(activeNetworkAddress.hostAddress.orEmpty())) { + emitStatus("Using active phone network address ${activeNetworkAddress.hostAddress}") + return activeNetworkAddress.hostAddress + } + return ( wifiPrivateCandidates.firstOrNull() ?: wifiCandidates.firstOrNull() ?: @@ -161,6 +192,25 @@ class MentraPhotoReceiverModule : Module() { )?.hostAddress } + private fun activeNetworkIpv4Address(): Inet4Address? { + val connectivityManager = reactContext() + .getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null + val activeNetwork = connectivityManager.activeNetwork ?: return null + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return null + val isLanNetwork = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + if (!isLanNetwork) return null + val linkProperties = connectivityManager.getLinkProperties(activeNetwork) ?: return null + return linkProperties.linkAddresses + .map { it.address } + .filterIsInstance() + .firstOrNull { address -> + !address.isLoopbackAddress && + !address.isLinkLocalAddress && + isPrivateIpv4(address.hostAddress.orEmpty()) + } + } + private fun receiverEndpoint(host: String, port: Int): ReceiverEndpoint { return ReceiverEndpoint( uploadUrl = "http://$host:$port/upload", @@ -190,6 +240,16 @@ class MentraPhotoReceiverModule : Module() { host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\..*")) } + private fun sameIpv4Slash24(first: String?, second: String?): Boolean { + if (first.isNullOrBlank() || second.isNullOrBlank()) return false + val firstParts = first.split(".") + val secondParts = second.split(".") + if (firstParts.size != 4 || secondParts.size != 4) return false + return firstParts[0] == secondParts[0] && + firstParts[1] == secondParts[1] && + firstParts[2] == secondParts[2] + } + private companion object { const val TAG = "MentraPhotoReceiver" val PHOTO_PORTS = listOf(8787, 8788, 8789, 8790)