diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml new file mode 100644 index 0000000000..d2d7528d1d --- /dev/null +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -0,0 +1,186 @@ +name: iOS IPA (SideStore Build) + +on: + workflow_dispatch: + push: + branches: + - dev + +jobs: + build: + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode 26.2 + run: | + sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer + xcodebuild -version + swift --version + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install deps + working-directory: ./mobile + run: bun install --no-frozen-lockfile + + - name: Setup env + working-directory: ./mobile + run: cp .env.example .env + + - name: Expo prebuild + working-directory: ./mobile + run: bun expo prebuild --platform ios + + - name: Setup Sherpa Onnx (optional) + working-directory: ./mobile + run: | + chmod +x scripts/setup-sherpa-onnx-optional.sh + ./scripts/setup-sherpa-onnx-optional.sh || true + + - name: Patch node_modules source files + working-directory: ./mobile + run: | + # ExpoModulesCore: remove SE-0431 isolated conformance syntax + FILE1="node_modules/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIHostingView.swift" + FILE2="node_modules/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIVirtualView.swift" + FILE3="node_modules/expo-modules-core/ios/Core/Views/ViewDefinition.swift" + sed -i '' 's/, @MainActor AnyExpoSwiftUIHostingView/, AnyExpoSwiftUIHostingView/g' "$FILE1" + sed -i '' 's/: @MainActor ExpoSwiftUI\.ViewWrapper/: ExpoSwiftUI.ViewWrapper/g' "$FILE2" + sed -i '' 's/extension UIView: @MainActor AnyArgument/extension UIView: AnyArgument/g' "$FILE3" + echo "ExpoModulesCore patches applied" + + # ExpoVideo: AVAssetVariant has no 'url' property + TRACKS="node_modules/expo-video/ios/Records/Tracks.swift" + sed -i '' 's/let trackUrl = assetVariant\.url/let trackUrl: URL? = nil/g' "$TRACKS" + sed -i '' 's/let id = extractHlsTrackId(trackUrl: trackUrl,/let id = extractHlsTrackId(trackUrl: trackUrl ?? mainUrl,/g' "$TRACKS" + echo "ExpoVideo Tracks.swift patched" + + - name: Patch Podfile + working-directory: ./mobile/ios + run: | + python3 << 'PYEOF' + with open('Podfile', 'r') as f: + content = f.read() + + injection = """ + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if target.name == 'ExpoModulesCore' + config.build_settings['SWIFT_VERSION'] = '5' + config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' + end + if target.name == 'SwiftProtobuf' + config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' + end + end + end + """ + + marker = 'post_install do |installer|' + if marker in content: + content = content.replace(marker, marker + '\n' + injection, 1) + print("Injected into existing post_install block") + else: + content += f'\n\npost_install do |installer|\n{injection}\nend\n' + print("Added new post_install block") + + with open('Podfile', 'w') as f: + f.write(content) + PYEOF + + - name: Show patched post_install + working-directory: ./mobile/ios + run: grep -A 20 "post_install" Podfile | head -30 + + - name: Install pods + working-directory: ./mobile/ios + run: pod install + + - name: List schemes + working-directory: ./mobile/ios + run: xcodebuild -workspace Mentra.xcworkspace -list + + - name: Archive app + working-directory: ./mobile/ios + env: + SENTRY_DISABLE_AUTO_UPLOAD: "true" + run: | + set -o pipefail + + xcodebuild archive \ + -workspace Mentra.xcworkspace \ + -scheme Mentra \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$GITHUB_WORKSPACE/Mentra.xcarchive" \ + -resultBundlePath /tmp/build.xcresult \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + AD_HOC_CODE_SIGNING_ALLOWED=YES \ + DEVELOPMENT_TEAM="" \ + 2>&1 | tee /tmp/xcodebuild.log + + BUILD_RESULT=${PIPESTATUS[0]} + + if [ "$BUILD_RESULT" -ne 0 ]; then + echo "========== ERRORS ==========" + grep ": error:" /tmp/xcodebuild.log | grep -v "^note:" | head -80 + exit "$BUILD_RESULT" + fi + + echo "✅ Archive succeeded" + + - name: Verify archive and find .app + run: | + APP_PATH=$(find "$GITHUB_WORKSPACE" -name "Mentra.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "❌ Mentra.app not found" + exit 1 + fi + echo "✅ Found: $APP_PATH" + if [ ! -f "$APP_PATH/Info.plist" ]; then + echo "❌ Info.plist missing" + exit 1 + fi + echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV + + - name: Package IPA + run: | + mkdir -p Payload + cp -r "${{ env.APP_PATH }}" Payload/App.app + touch Payload/App.app/embedded.mobileprovision + zip -r Mentra.ipa Payload + ls -lh Mentra.ipa + + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: Mentra-SideStore-IPA + path: Mentra.ipa + retention-days: 14 + + - name: Upload xcresult bundle + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-xcresult + path: /tmp/build.xcresult + retention-days: 5 + + - name: Upload build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: xcodebuild-log + path: /tmp/xcodebuild.log + retention-days: 5 diff --git a/.github/workflows/mentraos-manager-ios-build.yml b/.github/workflows/mentraos-manager-ios-build.yml index 5358f1ac14..16c5ac0b44 100644 --- a/.github/workflows/mentraos-manager-ios-build.yml +++ b/.github/workflows/mentraos-manager-ios-build.yml @@ -1,10 +1,6 @@ name: Mobile App iOS Build on: - pull_request: - paths: - - "mobile/**" - - ".github/workflows/mentraos-manager-ios-build.yml" push: branches: - main diff --git a/all_manifest_permissions.xml b/all_manifest_permissions.xml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/asg_client/AndroidManifestbak.xml b/asg_client/AndroidManifestbak.xml new file mode 100644 index 0000000000..76a10f10e8 --- /dev/null +++ b/asg_client/AndroidManifestbak.xml @@ -0,0 +1,283 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/asg_client/app/build.gradle b/asg_client/app/build.gradle index 152b812536..848b7dab2c 100644 --- a/asg_client/app/build.gradle +++ b/asg_client/app/build.gradle @@ -92,7 +92,7 @@ gradle.taskGraph.whenReady { taskGraph -> // Check Sentry configuration status (from .env file) def sentryDsn = envProperties['SENTRY_DSN'] ?: "" def sentryAuthToken = System.getenv("SENTRY_AUTH_TOKEN") ?: - (project.hasProperty("sentryAuthToken") ? project.property("sentryAuthToken") : null) + (project.hasProperty("sentryAuthToken") ? project.property("sentryAuthToken") : null) def isSentryConfigured = sentryDsn && !sentryDsn.isEmpty() && sentryDsn.contains("sentry.io") @@ -318,17 +318,19 @@ dependencies { implementation "androidx.camera:camera-view:1.3.0" implementation "androidx.camera:camera-extensions:1.3.0" - // StreamPackLite dependencies - implementation project(':core') - implementation project(':extension-rtmp') - implementation project(':extension-srt') + // StreamPack streaming library (replaces local StreamPackLite subproject) + // v2.6.1 matches the package structure used throughout this codebase: + // io.github.thibaultbee.streampack.ext.rtmp / .ext.srt / .data / .listeners / .views + implementation 'io.github.thibaultbee:streampack:2.6.1' + implementation 'io.github.thibaultbee:streampack-extension-rtmp:2.6.1' + implementation 'io.github.thibaultbee:streampack-extension-srt:2.6.1' // OkHttp for network requests implementation 'com.squareup.okhttp3:okhttp:4.9.3' - + // JSON handling implementation 'org.json:json:20210307' - + // AVIF encoding/decoding support implementation 'com.github.awxkee:avif-coder:1.7.3' @@ -348,8 +350,8 @@ dependencies { } // Check if Sentry auth token is available -def sentryAuthToken = System.getenv("SENTRY_AUTH_TOKEN") ?: - project.hasProperty("sentryAuthToken") ? project.property("sentryAuthToken") : null +def sentryAuthToken = System.getenv("SENTRY_AUTH_TOKEN") ?: + project.hasProperty("sentryAuthToken") ? project.property("sentryAuthToken") : null sentry { org = "mentra-labs" @@ -368,7 +370,7 @@ sentry { uploadNativeSymbols = false autoUploadNativeSymbols = false autoUploadSourceContext = false - + // Don't fail the build if Sentry operations fail ignoredBuildTypes = ["release", "debug"] } @@ -381,4 +383,4 @@ afterEvaluate { enabled = false } } -} +} \ No newline at end of file diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/MainActivity.java b/asg_client/app/src/main/java/com/mentra/asg_client/MainActivity.java index 3856640550..a0d1249c81 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/MainActivity.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/MainActivity.java @@ -132,7 +132,7 @@ protected void onCreate(Bundle savedInstanceState) { PackageInfo pInfo = getPackageManager().getPackageInfo(getPackageName(), 0); String versionName = pInfo.versionName; long versionCode = pInfo.getLongVersionCode(); - versionTextView.setText("Version: " + versionName + " (" + versionCode + ")"); + versionTextView.setText(""); versionTextView.setVisibility(View.VISIBLE); } catch (PackageManager.NameNotFoundException e) { versionTextView.setText("Version info not available"); diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/core/BluetoothManagerFactory.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/core/BluetoothManagerFactory.java index 472c967f5e..61d1ca2f51 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/core/BluetoothManagerFactory.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/core/BluetoothManagerFactory.java @@ -4,6 +4,7 @@ import android.util.Log; import com.mentra.asg_client.io.bluetooth.interfaces.IBluetoothManager; +import com.mentra.asg_client.io.bluetooth.managers.InmoGo2BluetoothManager; import com.mentra.asg_client.io.bluetooth.managers.K900BluetoothManager; import com.mentra.asg_client.io.bluetooth.managers.StandardBluetoothManager; import com.mentra.asg_client.service.utils.ServiceUtils; @@ -22,12 +23,15 @@ public class BluetoothManagerFactory { */ public static IBluetoothManager getBluetoothManager(Context context) { Context appContext = context.getApplicationContext(); - - // Switched back to StandardBluetoothManager due to issues with NordicBluetoothManager - //Log.i(TAG, "Using StandardBluetoothManager instead of NordicBluetoothManager"); - //Log.i(TAG, "Implementation class: " + StandardBluetoothManager.class.getName()); - //return new StandardBluetoothManager(appContext); - + + // INMO Go2: standard Android BLE peripheral, custom device name "INMO GO2", + // UUIDs 00004860/4861/4862 confirmed via nRF Connect scan. + if (ServiceUtils.isInmoGo2Device(appContext)) { + Log.i(TAG, "Creating InmoGo2BluetoothManager"); + Log.d(TAG, "Device type: " + ServiceUtils.getDeviceTypeString(appContext)); + return new InmoGo2BluetoothManager(appContext); + } + Log.d(TAG, "[FORCING K900 IMPLEMENTATION OF BLUETOOTH MANAGER]"); if (true || ServiceUtils.isK900Device(appContext)) { Log.i(TAG, "Creating K900BluetoothManager - K900 device detected"); diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/InmoGo2BluetoothManager.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/InmoGo2BluetoothManager.java new file mode 100644 index 0000000000..50c5706114 --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/InmoGo2BluetoothManager.java @@ -0,0 +1,438 @@ +package com.mentra.asg_client.io.bluetooth.managers; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattServer; +import android.bluetooth.BluetoothGattServerCallback; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.le.AdvertiseCallback; +import android.bluetooth.le.AdvertiseData; +import android.bluetooth.le.AdvertiseSettings; +import android.bluetooth.le.BluetoothLeAdvertiser; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelUuid; +import android.util.Log; + +import com.mentra.asg_client.io.bluetooth.core.BaseBluetoothManager; +import com.mentra.asg_client.io.bluetooth.utils.DebugNotificationManager; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.UUID; + +/** + * BLE peripheral manager for the INMO Go2 smart glasses. + * + * The ASG Client runs on the Go2 as a GATT server / BLE peripheral. + * The iOS MentraOS companion app scans for "INMO GO2" and connects as the central. + * + * Confirmed UUIDs (nRF Connect scan + INMO BLE spec): + * Service : 00004860-0000-1000-8000-00805f9b34fb + * TX (Notify, glasses → phone) : 00004861-0000-1000-8000-00805f9b34fb + * RX (Write, phone → glasses) : 00004862-0000-1000-8000-00805f9b34fb + * + * MTU: negotiated up to 247 bytes (Actions Technology module default ceiling). + * + * Data framing: plain UTF-8 JSON — identical to MentraLive. No K900-style + * binary framing (0x23 0x23 … 0x24 0x24) is used. + */ +public class InmoGo2BluetoothManager extends BaseBluetoothManager { + + private static final String TAG = "InmoGo2BtManager"; + + // ---- BLE UUIDs (confirmed from device scan) ---- + private static final UUID SERVICE_UUID = + UUID.fromString("00004860-0000-1000-8000-00805f9b34fb"); + /** TX characteristic: glasses → phone (Notify) */ + private static final UUID TX_CHAR_UUID = + UUID.fromString("00004861-0000-1000-8000-00805f9b34fb"); + /** RX characteristic: phone → glasses (Write / WriteNoResponse) */ + private static final UUID RX_CHAR_UUID = + UUID.fromString("00004862-0000-1000-8000-00805f9b34fb"); + /** Standard CCCD descriptor UUID (required for notifications) */ + private static final UUID CCCD_UUID = + UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + + // ---- Advertising name (must match what iOS scans for) ---- + private static final String DEVICE_NAME = "INMO GO2"; + + // ---- MTU ---- + private static final int PREFERRED_MTU = 247; // Actions chip ceiling + private int currentMtu = 23; // BLE default until negotiated + + // ---- Bluetooth objects ---- + private BluetoothManager bluetoothSystemManager; + private BluetoothAdapter bluetoothAdapter; + private BluetoothLeAdvertiser advertiser; + private BluetoothGattServer gattServer; + private BluetoothGattCharacteristic txCharacteristic; + private BluetoothGattCharacteristic rxCharacteristic; + + private volatile BluetoothDevice connectedDevice; + private boolean isAdvertising = false; + + private final DebugNotificationManager notificationManager; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + // ----------------------------------------------------------------------- + // Constructor + // ----------------------------------------------------------------------- + + public InmoGo2BluetoothManager(Context context) { + super(context); + notificationManager = new DebugNotificationManager(context); + + bluetoothSystemManager = + (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); + if (bluetoothSystemManager != null) { + bluetoothAdapter = bluetoothSystemManager.getAdapter(); + } + + // Set the adapter's advertised name to match what iOS scans for + if (bluetoothAdapter != null) { + try { + bluetoothAdapter.setName(DEVICE_NAME); + Log.d(TAG, "Bluetooth adapter name set to: " + DEVICE_NAME); + } catch (SecurityException e) { + Log.w(TAG, "Could not set Bluetooth adapter name (permission denied)", e); + } + } + + Log.d(TAG, "InmoGo2BluetoothManager created"); + } + + // ----------------------------------------------------------------------- + // IBluetoothManager — advertising + // ----------------------------------------------------------------------- + + @Override + public void startAdvertising() { + if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) { + Log.e(TAG, "Cannot start advertising — Bluetooth unavailable"); + notificationManager.showDebugNotification("BLE Error", "Bluetooth not enabled"); + return; + } +// Ensure the adapter is discoverable so iOS BLE scan can find us + try { + java.lang.reflect.Method method = bluetoothAdapter.getClass().getMethod("setScanMode", int.class); + method.invoke(bluetoothAdapter, BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE); + Log.d(TAG, "Scan mode set via reflection"); + } catch (Exception e) { + Log.w(TAG, "Could not set scan mode via reflection: " + e.getMessage()); + } + // Set up GATT server first + setupGattServer(); + + advertiser = bluetoothAdapter.getBluetoothLeAdvertiser(); + if (advertiser == null) { + Log.e(TAG, "BLE advertising not supported on this device"); + notificationManager.showDebugNotification("BLE Error", "BLE advertising not supported"); + return; + } + + AdvertiseSettings settings = new AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setConnectable(true) + .setTimeout(0) // Advertise indefinitely + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .build(); + + AdvertiseData data = new AdvertiseData.Builder() + .setIncludeDeviceName(true) + .addServiceUuid(new ParcelUuid(SERVICE_UUID)) + .build(); + + AdvertiseData scanResponse = new AdvertiseData.Builder() + .setIncludeDeviceName(false) + .addServiceUuid(new ParcelUuid(SERVICE_UUID)) + .build(); + + advertiser.startAdvertising(settings, data, scanResponse, advertiseCallback); + Log.d(TAG, "Started BLE advertising as: " + DEVICE_NAME); + } + + @Override + public void stopAdvertising() { + if (advertiser == null) return; + try { + advertiser.stopAdvertising(advertiseCallback); + isAdvertising = false; + notificationManager.cancelAdvertisingNotification(); + Log.d(TAG, "Stopped BLE advertising"); + } catch (Exception e) { + Log.e(TAG, "Error stopping advertising", e); + } + } + + // ----------------------------------------------------------------------- + // IBluetoothManager — connection + // ----------------------------------------------------------------------- + + @Override + public boolean isConnected() { + return connectedDevice != null && super.isConnected(); + } + + @Override + public void disconnect() { + if (connectedDevice == null || gattServer == null) return; + try { + gattServer.cancelConnection(connectedDevice); + connectedDevice = null; + notifyConnectionStateChanged(false); + notificationManager.showBluetoothStateNotification(false); + // Restart advertising so iOS can reconnect + mainHandler.postDelayed(() -> { + if (!isConnected() && !isAdvertising) { + startAdvertising(); + } + }, 500); + } catch (Exception e) { + Log.e(TAG, "Error during disconnect", e); + } + } + + // ----------------------------------------------------------------------- + // IBluetoothManager — data transmission (glasses → phone) + // ----------------------------------------------------------------------- + + @Override + protected boolean sendDataInternal(byte[] data) { + if (data == null || data.length == 0) { + Log.w(TAG, "sendDataInternal: null or empty data"); + return false; + } + if (!isConnected() || connectedDevice == null) { + Log.w(TAG, "sendDataInternal: not connected"); + return false; + } + if (gattServer == null || txCharacteristic == null) { + Log.e(TAG, "sendDataInternal: GATT server or TX characteristic not ready"); + return false; + } + + // Fragment large payloads to fit within negotiated MTU + // MTU includes 3-byte ATT header, so usable payload = currentMtu - 3 + final int maxPayload = Math.max(20, currentMtu - 3); + int offset = 0; + while (offset < data.length) { + int chunkLength = Math.min(maxPayload, data.length - offset); + byte[] chunk = Arrays.copyOfRange(data, offset, offset + chunkLength); + txCharacteristic.setValue(chunk); + boolean ok = gattServer.notifyCharacteristicChanged( + connectedDevice, txCharacteristic, false); + if (!ok) { + Log.e(TAG, "notifyCharacteristicChanged failed at offset " + offset); + return false; + } + offset += chunkLength; + } + Log.d(TAG, "Sent " + data.length + " bytes to phone (fragmented into " + + (int) Math.ceil((double) data.length / maxPayload) + " chunks)"); + return true; + } + + // ----------------------------------------------------------------------- + // IBluetoothManager — misc + // ----------------------------------------------------------------------- + + @Override + public boolean isFileTransferInProgress() { + // File-transfer pipeline not required for Go2 in the initial integration + return false; + } + + + // ----------------------------------------------------------------------- + // GATT Server setup + // ----------------------------------------------------------------------- + + private void setupGattServer() { + if (gattServer != null) { + Log.d(TAG, "GATT server already set up"); + return; + } + gattServer = bluetoothSystemManager.openGattServer(context, gattServerCallback); + if (gattServer == null) { + Log.e(TAG, "Failed to open GATT server"); + return; + } + + BluetoothGattService service = new BluetoothGattService( + SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY); + + // TX characteristic: glasses → phone (Notify) + txCharacteristic = new BluetoothGattCharacteristic( + TX_CHAR_UUID, + BluetoothGattCharacteristic.PROPERTY_NOTIFY, + BluetoothGattCharacteristic.PERMISSION_READ); + // CCCD descriptor — required for notifications + BluetoothGattDescriptor txCccd = new BluetoothGattDescriptor( + CCCD_UUID, + BluetoothGattDescriptor.PERMISSION_READ | BluetoothGattDescriptor.PERMISSION_WRITE); + txCccd.setValue(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE); + txCharacteristic.addDescriptor(txCccd); + + // RX characteristic: phone → glasses (Write / WriteNoResponse) + rxCharacteristic = new BluetoothGattCharacteristic( + RX_CHAR_UUID, + BluetoothGattCharacteristic.PROPERTY_WRITE + | BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + BluetoothGattCharacteristic.PERMISSION_WRITE); + + service.addCharacteristic(txCharacteristic); + service.addCharacteristic(rxCharacteristic); + gattServer.addService(service); + + Log.d(TAG, "GATT server set up with service " + SERVICE_UUID); + } + + // ----------------------------------------------------------------------- + // Handshake: phone_ready → glasses_ready + // ----------------------------------------------------------------------- + + /** + * Inspect every incoming message from the phone. + * When we receive {"type":"phone_ready"} we immediately respond with + * {"type":"glasses_ready"} so the iOS readiness-check loop can complete + * and the connection moves to CONNECTED state. + */ + private void handlePhoneMessage(byte[] data) { + if (data == null || data.length == 0) return; + try { + String json = new String(data, StandardCharsets.UTF_8); + if (!json.startsWith("{")) return; // Not JSON — ignore + JSONObject msg = new JSONObject(json); + String type = msg.optString("type", ""); + + if ("phone_ready".equals(type)) { + Log.i(TAG, "📱 phone_ready received — sending glasses_ready"); + JSONObject response = new JSONObject(); + response.put("type", "glasses_ready"); + response.put("timestamp", System.currentTimeMillis()); + byte[] responseBytes = response.toString().getBytes(StandardCharsets.UTF_8); + // Small delay to allow the GATT write response to be sent first + mainHandler.postDelayed(() -> sendData(responseBytes), 50); + } + } catch (JSONException e) { + Log.e(TAG, "handlePhoneMessage: JSON parse error", e); + } catch (Exception e) { + Log.e(TAG, "handlePhoneMessage: unexpected error", e); + } + } + + // ----------------------------------------------------------------------- + // GATT server callbacks + // ----------------------------------------------------------------------- + + private final BluetoothGattServerCallback gattServerCallback = new BluetoothGattServerCallback() { + + @Override + public void onConnectionStateChange(BluetoothDevice device, int status, int newState) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.i(TAG, "Phone connected: " + device.getAddress()); + connectedDevice = device; + stopAdvertising(); + mainHandler.post(() -> { + notifyConnectionStateChanged(true); + notificationManager.showBluetoothStateNotification(true); + }); + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.i(TAG, "Phone disconnected: " + device.getAddress()); + if (device.equals(connectedDevice)) { + connectedDevice = null; + } + mainHandler.post(() -> { + notifyConnectionStateChanged(false); + notificationManager.showBluetoothStateNotification(false); + // Re-advertise so iOS can reconnect + mainHandler.postDelayed(() -> { + if (!isConnected()) startAdvertising(); + }, 1000); + }); + } + } + + @Override + public void onCharacteristicWriteRequest(BluetoothDevice device, + int requestId, BluetoothGattCharacteristic characteristic, + boolean preparedWrite, boolean responseNeeded, + int offset, byte[] value) { + if (RX_CHAR_UUID.equals(characteristic.getUuid())) { + Log.d(TAG, "Received " + (value != null ? value.length : 0) + + " bytes from phone"); + + // Forward to all registered data listeners (rest of the service pipeline) + notifyDataReceived(value); + + // Intercept phone_ready and respond with glasses_ready + handlePhoneMessage(value); + + if (responseNeeded) { + gattServer.sendResponse(device, requestId, + BluetoothGatt.GATT_SUCCESS, 0, null); + } + } else { + if (responseNeeded) { + gattServer.sendResponse(device, requestId, + BluetoothGatt.GATT_FAILURE, 0, null); + } + } + } + + @Override + public void onDescriptorWriteRequest(BluetoothDevice device, + int requestId, BluetoothGattDescriptor descriptor, + boolean preparedWrite, boolean responseNeeded, + int offset, byte[] value) { + if (CCCD_UUID.equals(descriptor.getUuid())) { + boolean notificationsEnabled = + Arrays.equals(value, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); + descriptor.setValue(value); + Log.d(TAG, "Notifications " + (notificationsEnabled ? "ENABLED" : "DISABLED") + + " by: " + device.getAddress()); + } + if (responseNeeded) { + gattServer.sendResponse(device, requestId, + BluetoothGatt.GATT_SUCCESS, 0, null); + } + } + + @Override + public void onMtuChanged(BluetoothDevice device, int mtu) { + currentMtu = mtu; + Log.d(TAG, "MTU changed to " + mtu + " for " + device.getAddress()); + } + }; + + // ----------------------------------------------------------------------- + // Advertising callback + // ----------------------------------------------------------------------- + + private final AdvertiseCallback advertiseCallback = new AdvertiseCallback() { + @Override + public void onStartSuccess(AdvertiseSettings settingsInEffect) { + isAdvertising = true; + Log.i(TAG, "BLE advertising started as: " + DEVICE_NAME); + notificationManager.showAdvertisingNotification(DEVICE_NAME); + } + + @Override + public void onStartFailure(int errorCode) { + isAdvertising = false; + Log.e(TAG, "BLE advertising failed, error: " + errorCode); + notificationManager.showDebugNotification("BLE Error", + "Advertising failed: " + errorCode); + } + }; +} \ No newline at end of file diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/core/HardwareManagerFactory.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/core/HardwareManagerFactory.java index 32c276363c..cdb0035d2a 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/core/HardwareManagerFactory.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/core/HardwareManagerFactory.java @@ -5,6 +5,7 @@ import android.util.Log; import com.mentra.asg_client.io.hardware.interfaces.IHardwareManager; +import com.mentra.asg_client.io.hardware.managers.InmoGo2HardwareManager; import com.mentra.asg_client.io.hardware.managers.K900HardwareManager; import com.mentra.asg_client.io.hardware.managers.StandardHardwareManager; import com.mentra.asg_client.service.utils.ServiceUtils; @@ -43,6 +44,8 @@ private static IHardwareManager createHardwareManager(Context context) { Log.d(TAG, "🔧 Hardware manager selection: " + deviceType); switch (deviceType) { + case INMO_GO2: + return new InmoGo2HardwareManager(context); case K900: return new K900HardwareManager(context); case STANDARD_ANDROID: @@ -69,6 +72,12 @@ private static DeviceType detectDeviceType(Context context) { final String device = Build.DEVICE != null ? Build.DEVICE.toLowerCase() : ""; final String brand = Build.BRAND != null ? Build.BRAND.toLowerCase() : ""; + // Check INMO Go2 first (before generic Google/standard check) + if (ServiceUtils.isInmoGo2Device(context)) { + Log.i(TAG, "INMO Go2 device detected via ServiceUtils"); + return DeviceType.INMO_GO2; + } + // Use centralized K900 detection from ServiceUtils if (ServiceUtils.isK900Device(context)) { Log.i(TAG, "K900 device detected via ServiceUtils"); @@ -103,6 +112,7 @@ public static synchronized boolean hasInstance() { } private enum DeviceType { + INMO_GO2, K900, STANDARD_ANDROID, UNKNOWN diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/managers/InmoGo2HardwareManager.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/managers/InmoGo2HardwareManager.java new file mode 100644 index 0000000000..456037e80e --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/managers/InmoGo2HardwareManager.java @@ -0,0 +1,363 @@ +package com.mentra.asg_client.io.hardware.managers; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.media.MediaPlayer; +import android.os.BatteryManager; +import android.util.Log; + +import com.mentra.asg_client.io.hardware.core.BaseHardwareManager; + +import java.io.IOException; + +/** + * Hardware manager for INMO Go2 smart glasses. + * + * Device fingerprint (from adb getprop): + * ro.product.manufacturer = INMO + * ro.product.model = Go2 + * ro.product.device = ima02_go2 + * ro.product.name = ima02_go2fu + * ro.boot.hardware = ima02_go2 + * ro.build.fingerprint = INMO/ima02_go2fu/ima02_go2:9/... + * + * BLE profile (confirmed via nRF Connect scan): + * Advertised name : "INMO GO2" + * Service UUID : 00004860-0000-1000-8000-00805f9b34fb + * TX (Notify) : 00004861-0000-1000-8000-00805f9b34fb + * RX (Write) : 00004862-0000-1000-8000-00805f9b34fb + * MTU : 247 bytes + * + * The Go2 runs Android 9 on a Unisoc UMS312 SoC (armeabi-v7a). It exposes a + * standard Android Camera2 API and BatteryManager — no proprietary LED or + * audio control SDK is required. + */ +public class InmoGo2HardwareManager extends BaseHardwareManager { + + private static final String TAG = "InmoGo2HardwareManager"; + + // ---- Camera / torch ---- + private final CameraManager cameraManager; + private String cameraWithFlash; + private boolean torchEnabled; + + // ---- Audio ---- + private MediaPlayer mediaPlayer; + + // ----------------------------------------------------------------------- + // Construction & lifecycle + // ----------------------------------------------------------------------- + + public InmoGo2HardwareManager(Context context) { + super(context); + cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); + } + + @Override + public void initialize() { + Log.d(TAG, "⚙️ ========================================="); + Log.d(TAG, "⚙️ INMO GO2 HARDWARE MANAGER INITIALIZE"); + Log.d(TAG, "⚙️ ========================================="); + super.initialize(); + detectFlashCamera(); + Log.d(TAG, "⚙️ ✅ INMO Go2 Hardware Manager initialized"); + } + + // ----------------------------------------------------------------------- + // Identity + // ----------------------------------------------------------------------- + + @Override + public String getDeviceModel() { + return "INMO Go2"; + } + + @Override + public boolean isK900Device() { + // The Go2 is NOT a K900 / XY-family device. + return false; + } + + // ----------------------------------------------------------------------- + // Recording LED (torch-based — same approach as StandardHardwareManager) + // ----------------------------------------------------------------------- + + @Override + public boolean supportsRecordingLed() { + return cameraWithFlash != null; + } + + @Override + public void setRecordingLedOn() { + setTorch(true); + } + + @Override + public void setRecordingLedOff() { + setTorch(false); + } + + @Override + public void setRecordingLedBlinking() { + setTorch(true); // Caller manages timing + } + + @Override + public void setRecordingLedBlinking(long onDurationMs, long offDurationMs) { + setTorch(true); // Caller manages timing + } + + @Override + public void stopRecordingLedBlinking() { + setTorch(false); + } + + @Override + public void flashRecordingLed(long durationMs) { + setTorch(true); // Caller manages off scheduling + } + + @Override + public boolean isRecordingLedOn() { + return torchEnabled; + } + + @Override + public boolean isRecordingLedBlinking() { + // Torch-based LED doesn't have a native blink state; track externally if needed. + return false; + } + + // ----------------------------------------------------------------------- + // LED brightness — not supported (no custom LED HAL on Go2) + // ----------------------------------------------------------------------- + + @Override + public boolean supportsLedBrightness() { + return false; + } + + @Override + public void setRecordingLedBrightness(int percent) { + Log.w(TAG, "LED brightness control not supported on INMO Go2"); + } + + @Override + public void setRecordingLedBrightness(int percent, int durationMs) { + Log.w(TAG, "LED brightness control not supported on INMO Go2"); + } + + @Override + public int getRecordingLedBrightness() { + return 0; + } + + // ----------------------------------------------------------------------- + // RGB LED — not supported + // ----------------------------------------------------------------------- + + @Override + public boolean supportsRgbLed() { + return false; + } + + @Override + public void setRgbLedBrightness(int brightness) { + Log.w(TAG, "RGB LED not supported on INMO Go2"); + } + + @Override + public void setRgbLedOn(int ledIndex, int ontime, int offtime, int count) { + Log.w(TAG, "RGB LED not supported on INMO Go2"); + } + + @Override + public void setRgbLedOn(int ledIndex, int ontime, int offtime, int count, int brightness) { + Log.w(TAG, "RGB LED not supported on INMO Go2"); + } + + @Override + public void setRgbLedOff() { + Log.w(TAG, "RGB LED not supported on INMO Go2"); + } + + @Override + public void flashRgbLedWhite(int durationMs) { + Log.w(TAG, "RGB LED not supported on INMO Go2"); + } + + @Override + public void flashRgbLedWhite(int durationMs, int brightness) { + Log.w(TAG, "RGB LED not supported on INMO Go2"); + } + + @Override + public void setRgbLedSolidWhite(int durationMs) { + Log.w(TAG, "RGB LED not supported on INMO Go2"); + } + + @Override + public void setRgbLedSolidWhite(int durationMs, int brightness) { + Log.w(TAG, "RGB LED not supported on INMO Go2"); + } + + // ----------------------------------------------------------------------- + // Audio playback (standard MediaPlayer — Go2 has working Android audio) + // ----------------------------------------------------------------------- + + @Override + public boolean supportsAudioPlayback() { + return true; + } + + @Override + public void playAudioAsset(String assetName) { + stopAudioPlayback(); + mediaPlayer = new MediaPlayer(); + try { + var afd = context.getAssets().openFd(assetName); + mediaPlayer.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); + afd.close(); + mediaPlayer.setOnCompletionListener(mp -> { + mp.release(); + mediaPlayer = null; + }); + mediaPlayer.setOnErrorListener((mp, what, extra) -> { + Log.e(TAG, "Error playing asset " + assetName + " (" + what + "/" + extra + ")"); + mp.release(); + mediaPlayer = null; + return true; + }); + mediaPlayer.prepare(); + mediaPlayer.start(); + } catch (IOException e) { + Log.e(TAG, "Unable to play asset: " + assetName, e); + mediaPlayer.release(); + mediaPlayer = null; + } + } + + @Override + public void stopAudioPlayback() { + if (mediaPlayer != null) { + try { + if (mediaPlayer.isPlaying()) { + mediaPlayer.stop(); + } + } catch (IllegalStateException ignored) { } + mediaPlayer.release(); + mediaPlayer = null; + } + } + + // ----------------------------------------------------------------------- + // Bluetooth manager hook (no-op — Go2 uses standard Android BLE stack, + // not a proprietary MCU-bridged BT path like K900/BES2700) + // ----------------------------------------------------------------------- + + @Override + public void setBluetoothManager(Object bluetoothManager) { + // INMO Go2 handles BLE via InmoGo2BluetoothManager independently; + // no hardware-level LED or audio is driven through the BT pipe. + Log.d(TAG, "setBluetoothManager: INMO Go2 does not require BT-coupled hardware control"); + } + + // ----------------------------------------------------------------------- + // Battery (standard Android BatteryManager) + // ----------------------------------------------------------------------- + + @Override + public int getBatteryLevel() { + BatteryManager bm = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); + if (bm != null) { + int level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY); + Log.d(TAG, "🔋 Battery level: " + level + "%"); + return level; + } + Log.w(TAG, "🔋 BatteryManager not available"); + return -1; + } + + @Override + public boolean getChargingStatus() { + IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + Intent batteryStatus = context.registerReceiver(null, filter); + if (batteryStatus != null) { + int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING + || status == BatteryManager.BATTERY_STATUS_FULL; + Log.d(TAG, "🔋 Charging status: " + isCharging); + return isCharging; + } + Log.w(TAG, "🔋 Battery status intent unavailable"); + return false; + } + + // ----------------------------------------------------------------------- + // Shutdown + // ----------------------------------------------------------------------- + + @Override + public void shutdown() { + Log.d(TAG, "Shutting down InmoGo2HardwareManager"); + stopTorchIfOn(); + stopAudioPlayback(); + super.shutdown(); + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /** + * Scan available cameras and find the rear camera that has a torch/flash unit. + * The Go2 has a single rear-facing camera (IMX471v1, 4 MP). + */ + private void detectFlashCamera() { + if (cameraManager == null) { + Log.w(TAG, "CameraManager unavailable — torch support disabled"); + return; + } + try { + for (String id : cameraManager.getCameraIdList()) { + CameraCharacteristics ch = cameraManager.getCameraCharacteristics(id); + Boolean flashAvailable = ch.get(CameraCharacteristics.FLASH_INFO_AVAILABLE); + Integer lensFacing = ch.get(CameraCharacteristics.LENS_FACING); + if (Boolean.TRUE.equals(flashAvailable) + && lensFacing != null + && lensFacing == CameraCharacteristics.LENS_FACING_BACK) { + cameraWithFlash = id; + Log.d(TAG, "⚙️ Rear camera with torch detected: id=" + id); + return; + } + } + Log.w(TAG, "⚙️ No rear camera with torch found — recording LED will be unavailable"); + } catch (CameraAccessException e) { + Log.e(TAG, "Camera access exception during flash detection", e); + } + } + + private void setTorch(boolean enabled) { + if (cameraManager == null || cameraWithFlash == null) { + Log.w(TAG, "Torch control unavailable"); + return; + } + try { + cameraManager.setTorchMode(cameraWithFlash, enabled); + torchEnabled = enabled; + Log.d(TAG, enabled ? "🔦 Torch ON" : "🔦 Torch OFF"); + } catch (CameraAccessException | SecurityException e) { + Log.e(TAG, "Failed to set torch state: " + enabled, e); + } + } + + private void stopTorchIfOn() { + if (torchEnabled) { + setTorch(false); + } + } +} diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/streaming/services/RtmpStreamingService.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/streaming/services/RtmpStreamingService.java index 4cbcea1627..4f63f8b2bf 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/streaming/services/RtmpStreamingService.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/streaming/services/RtmpStreamingService.java @@ -410,7 +410,7 @@ public void onError(StreamPackError error) { // Report StreamPack error boolean isRetryable = isRetryableError(error); StreamingReporting.reportPackError(RtmpStreamingService.this, - "stream_error", error.getMessage(), isRetryable); + "stream_error", error.getMessage(), isRetryable); // Classify the error to determine if we should retry or fail immediately if (isRetryable) { @@ -504,7 +504,7 @@ public void onFailed(String message) { // Report connection failure StreamingReporting.reportRtmpConnectionFailure(RtmpStreamingService.this, - mRtmpUrl, message, null); + mRtmpUrl, message, null); // Only notify server immediately for fatal errors that won't be retried if (!isRetryableErrorString(message)) { @@ -565,7 +565,7 @@ public void onLost(String message) { // Report connection lost StreamingReporting.reportRtmpConnectionLost(RtmpStreamingService.this, - mRtmpUrl, streamDuration, message); + mRtmpUrl, streamDuration, message); // Give the StreamPack library time to recover internally before we take over Log.d(TAG, "Waiting 1 second for library internal recovery before external reconnection"); @@ -638,11 +638,8 @@ public void onLost(String message) { int profile = VideoConfig.Companion.getBestProfile(mimeType); int level = VideoConfig.Companion.getBestLevel(mimeType, profile); - // Encode at output size; camera buffer at native capture size when it differs - Size captureSize = - (captureW != videoWidth || captureH != videoHeight) - ? new Size(captureW, captureH) - : null; + // captureSize (StreamPackLite-only field) removed — public StreamPack 2.6.1 + // uses the encode resolution for capture as well. VideoConfig videoConfig = new VideoConfig( MediaFormat.MIMETYPE_VIDEO_AVC, videoBitrate, @@ -650,8 +647,7 @@ public void onLost(String message) { videoFps, profile, level, - 2.0f, // Force keyframe every 2 seconds - captureSize + 2.0f ); // Apply configurations and start preview @@ -678,7 +674,7 @@ public void onLost(String message) { // Report streaming initialization failure StreamingReporting.reportInitializationFailure(RtmpStreamingService.this, - mRtmpUrl, e.getMessage(), e); + mRtmpUrl, e.getMessage(), e); } } @@ -781,7 +777,7 @@ public void startStreaming() { // Report URL validation failure StreamingReporting.reportUrlValidationFailure(RtmpStreamingService.this, - mRtmpUrl != null ? mRtmpUrl : "null", "URL is null or empty"); + mRtmpUrl != null ? mRtmpUrl : "null", "URL is null or empty"); return; } @@ -852,7 +848,7 @@ public void startStreaming() { } else { String error = "Failed to create valid surface for streaming"; StreamingReporting.reportSurfaceCreationFailure(RtmpStreamingService.this, - "create_surface", error, null); + "create_surface", error, null); throw new Exception(error); } @@ -878,7 +874,7 @@ public void resumeWith(Object o) { // Report stream start failure StreamingReporting.reportStreamStartFailure(RtmpStreamingService.this, - mRtmpUrl, ((Throwable) o).getMessage(), (Throwable) o); + mRtmpUrl, ((Throwable) o).getMessage(), (Throwable) o); // Schedule reconnect if we couldn't start the stream scheduleReconnect("start_error"); @@ -924,7 +920,7 @@ public void resumeWith(Object o) { // Check if this is a camera access issue due to power policy if (e.getMessage() != null && e.getMessage().contains("CAMERA_DISABLED") && - e.getMessage().contains("disabled by policy")) { + e.getMessage().contains("disabled by policy")) { Log.w(TAG, "Camera disabled by power policy - likely screen is off during reconnection"); errorMsg = "Camera disabled by power policy (screen off) - " + e.getMessage(); } @@ -940,7 +936,7 @@ public void resumeWith(Object o) { // Report stream start failure StreamingReporting.reportStreamStartFailure(RtmpStreamingService.this, - mRtmpUrl, e.getMessage(), e); + mRtmpUrl, e.getMessage(), e); // Schedule reconnect on exception scheduleReconnect("start_exception"); @@ -1014,7 +1010,7 @@ public void resumeWith(Object o) { // Report stream stop failure StreamingReporting.reportStreamStopFailure(RtmpStreamingService.this, - "stream_stop_error", (Throwable) o); + "stream_stop_error", (Throwable) o); // Notify TPA developer of cleanup failure if (sStatusCallback != null) { @@ -1046,7 +1042,7 @@ public void resumeWith(Object o) { // Report preview stop failure StreamingReporting.reportPreviewStartFailure(RtmpStreamingService.this, - "stop_preview_error", e); + "stop_preview_error", e); // Notify TPA developer of cleanup failure if (sStatusCallback != null) { @@ -1063,7 +1059,7 @@ public void resumeWith(Object o) { // Report resource cleanup failure StreamingReporting.reportResourceCleanupFailure(RtmpStreamingService.this, - "streamer", "release_error", e); + "streamer", "release_error", e); // Notify TPA developer of cleanup failure if (sStatusCallback != null) { @@ -1144,7 +1140,7 @@ private void scheduleReconnect(String reason) { // Report reconnection exhaustion long totalDuration = System.currentTimeMillis() - mLastReconnectionTime; StreamingReporting.reportReconnectionExhaustion(RtmpStreamingService.this, - mRtmpUrl, MAX_RECONNECT_ATTEMPTS, totalDuration); + mRtmpUrl, MAX_RECONNECT_ATTEMPTS, totalDuration); // Stop streaming completely when max attempts reached stopStreaming(); @@ -1261,7 +1257,7 @@ private void handleStreamTimeout(String streamId) { // Report stream timeout error StreamingReporting.reportTimeoutError(RtmpStreamingService.this, - streamId, STREAM_TIMEOUT_MS); + streamId, STREAM_TIMEOUT_MS); // Notify about timeout EventBus.getDefault().post(new StreamingEvent.Error("Stream timed out - no keep-alive from cloud")); @@ -1273,7 +1269,7 @@ private void handleStreamTimeout(String streamId) { forceStopStreamingInternal(false); } else { Log.d(TAG, "Ignoring timeout for old stream: " + streamId + - " (current: " + mCurrentStreamId + ", active: " + mIsStreamingActive + ")"); + " (current: " + mCurrentStreamId + ", active: " + mIsStreamingActive + ")"); } } } @@ -1343,7 +1339,7 @@ public void run() { if (batteryLevel >= 0 && batteryLevel < BatteryConstants.MIN_BATTERY_LEVEL) { Log.w(TAG, "🔋⚠️ Battery dropped to " + batteryLevel + - "% during streaming - stopping"); + "% during streaming - stopping"); shouldStop = true; // Play battery low sound inside lock (quick operation) @@ -1367,7 +1363,7 @@ public void run() { // Reschedule outside lock if (shouldReschedule && mBatteryMonitorHandler != null) { mBatteryMonitorHandler.postDelayed(this, - BatteryConstants.BATTERY_CHECK_INTERVAL_MS); + BatteryConstants.BATTERY_CHECK_INTERVAL_MS); } // Stop streaming OUTSIDE the lock to avoid holding lock during complex operation @@ -1378,7 +1374,7 @@ public void run() { }; mBatteryMonitorHandler.postDelayed(mBatteryCheckRunnable, - BatteryConstants.BATTERY_CHECK_INTERVAL_MS); + BatteryConstants.BATTERY_CHECK_INTERVAL_MS); Log.d(TAG, "🔋 Started battery monitoring for RTMP streaming"); } @@ -1517,7 +1513,7 @@ public static boolean isStreaming() { if (sInstance != null) { synchronized (sInstance.mStateLock) { return sInstance.mStreamState == StreamState.STREAMING || - sInstance.mStreamState == StreamState.STARTING; + sInstance.mStreamState == StreamState.STARTING; } } return false; @@ -1569,8 +1565,8 @@ public static boolean resetStreamTimeout(String streamId) { return true; } else { Log.w(TAG, "Received keep-alive for unknown or inactive stream: " + streamId + - " (current: " + sInstance.mCurrentStreamId + ", active: " + sInstance.mIsStreamingActive + - ", state: " + sInstance.mStreamState + ")"); + " (current: " + sInstance.mCurrentStreamId + ", active: " + sInstance.mIsStreamingActive + + ", state: " + sInstance.mStreamState + ")"); return false; } } @@ -1594,39 +1590,39 @@ private boolean isRetryableError(StreamPackError error) { // Network/connection errors that should trigger reconnection if (message.contains("SocketException") || - message.contains("Connection") || - message.contains("Timeout") || - message.contains("Network") || - message.contains("UnknownHostException") || - message.contains("IOException") || - message.contains("ECONNREFUSED") || - message.contains("ETIMEDOUT")) { + message.contains("Connection") || + message.contains("Timeout") || + message.contains("Network") || + message.contains("UnknownHostException") || + message.contains("IOException") || + message.contains("ECONNREFUSED") || + message.contains("ETIMEDOUT")) { Log.d(TAG, "Error classified as RETRYABLE (network issue)"); return true; } // Fatal errors that shouldn't retry if (message.contains("Permission") || - message.contains("permission") || - message.contains("Invalid URL") || - message.contains("invalid url") || - message.contains("Authentication") || - message.contains("authentication") || - message.contains("Unauthorized") || - message.contains("Codec") || - message.contains("codec") || - message.contains("Not supported") || - message.contains("Illegal") || - message.contains("Invalid parameter")) { + message.contains("permission") || + message.contains("Invalid URL") || + message.contains("invalid url") || + message.contains("Authentication") || + message.contains("authentication") || + message.contains("Unauthorized") || + message.contains("Codec") || + message.contains("codec") || + message.contains("Not supported") || + message.contains("Illegal") || + message.contains("Invalid parameter")) { Log.d(TAG, "Error classified as FATAL (configuration/permission issue)"); return false; } // Camera-specific errors that are usually fatal if (message.contains("Camera") && - (message.contains("busy") || - message.contains("in use") || - message.contains("failed to connect"))) { + (message.contains("busy") || + message.contains("in use") || + message.contains("failed to connect"))) { Log.d(TAG, "Error classified as FATAL (camera unavailable)"); return false; } @@ -1655,43 +1651,43 @@ private boolean isRetryableErrorString(String message) { // Network/connection errors that should trigger reconnection if (lowerMessage.contains("socket") || - lowerMessage.contains("connection") || - lowerMessage.contains("timeout") || - lowerMessage.contains("network") || - lowerMessage.contains("unknownhost") || - lowerMessage.contains("ioexception") || - lowerMessage.contains("unreachable") || - lowerMessage.contains("disconnected") || - lowerMessage.contains("pipe") || // "Broken pipe" - lowerMessage.contains("closed") || // "Socket closed" - lowerMessage.contains("refused") || - lowerMessage.contains("reset") || - lowerMessage.contains("host") || // "Host is unreachable" - lowerMessage.contains("econnrefused") || - lowerMessage.contains("etimedout") || - lowerMessage.contains("peer")) { // "Peer disconnected" + lowerMessage.contains("connection") || + lowerMessage.contains("timeout") || + lowerMessage.contains("network") || + lowerMessage.contains("unknownhost") || + lowerMessage.contains("ioexception") || + lowerMessage.contains("unreachable") || + lowerMessage.contains("disconnected") || + lowerMessage.contains("pipe") || // "Broken pipe" + lowerMessage.contains("closed") || // "Socket closed" + lowerMessage.contains("refused") || + lowerMessage.contains("reset") || + lowerMessage.contains("host") || // "Host is unreachable" + lowerMessage.contains("econnrefused") || + lowerMessage.contains("etimedout") || + lowerMessage.contains("peer")) { // "Peer disconnected" Log.d(TAG, "Error classified as RETRYABLE (network issue)"); return true; } // Fatal errors that shouldn't retry if (lowerMessage.contains("permission") || - lowerMessage.contains("invalid url") || - lowerMessage.contains("authentication") || - lowerMessage.contains("unauthorized") || - lowerMessage.contains("codec") || - lowerMessage.contains("not supported") || - lowerMessage.contains("illegal") || - lowerMessage.contains("invalid parameter")) { + lowerMessage.contains("invalid url") || + lowerMessage.contains("authentication") || + lowerMessage.contains("unauthorized") || + lowerMessage.contains("codec") || + lowerMessage.contains("not supported") || + lowerMessage.contains("illegal") || + lowerMessage.contains("invalid parameter")) { Log.d(TAG, "Error classified as FATAL (configuration/permission issue)"); return false; } // Camera-specific errors that are usually fatal if (lowerMessage.contains("camera") && - (lowerMessage.contains("busy") || - lowerMessage.contains("in use") || - lowerMessage.contains("failed to connect"))) { + (lowerMessage.contains("busy") || + lowerMessage.contains("in use") || + lowerMessage.contains("failed to connect"))) { Log.d(TAG, "Error classified as FATAL (camera unavailable)"); return false; } @@ -1787,4 +1783,4 @@ private static String formatDuration(long durationMs) { return String.format("%ds", seconds); } } -} +} \ No newline at end of file diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/streaming/services/SrtStreamingService.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/streaming/services/SrtStreamingService.java index d2c078621d..be9fea1ea6 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/streaming/services/SrtStreamingService.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/streaming/services/SrtStreamingService.java @@ -60,896 +60,894 @@ @SuppressLint("MissingPermission") public class SrtStreamingService extends Service { - private static final String TAG = "SrtStreamingService"; - private static final String CHANNEL_ID = "SrtStreamingChannel"; - private static final int NOTIFICATION_ID = 8889; - - private static SrtStreamingService sInstance; - private static StreamingStatusCallback sStatusCallback; + private static final String TAG = "SrtStreamingService"; + private static final String CHANNEL_ID = "SrtStreamingChannel"; + private static final int NOTIFICATION_ID = 8889; + + private static SrtStreamingService sInstance; + private static StreamingStatusCallback sStatusCallback; + + private final IBinder mBinder = new LocalBinder(); + private CameraSrtLiveStreamer mSrtStreamer; + private CameraSrtLiveStreamer mLastSrtStreamerForCleanup; + + private String mSrtUrl; + private boolean mIsStreaming = false; + private SurfaceTexture mSurfaceTexture; + private Surface mSurface; + + private RtmpStreamConfig mStreamConfig = new RtmpStreamConfig(); + private static RtmpStreamConfig sPendingStreamConfig = null; + + private int mReconnectAttempts = 0; + private static final int MAX_RECONNECT_ATTEMPTS = 10; + private static final long INITIAL_RECONNECT_DELAY_MS = 1000; + private static final float BACKOFF_MULTIPLIER = 1.5f; + private Handler mReconnectHandler; + private boolean mReconnecting = false; + + private int mConsecutiveFailures = 0; + private static final int MIN_CONSECUTIVE_FAILURES = 3; + private long mLastFailureTime = 0; + private int mTotalFailures = 0; + + private Timer mStreamTimeoutTimer; + private String mCurrentStreamId; + private boolean mIsStreamingActive = false; + private static final long STREAM_TIMEOUT_MS = 60000; + private Handler mTimeoutHandler; + + private boolean mHasShownReconnectingNotification = false; + + private enum StreamState { IDLE, STARTING, STREAMING, STOPPING } + private volatile StreamState mStreamState = StreamState.IDLE; + private final Object mStateLock = new Object(); + + private long mStreamStartTime = 0; + private long mLastReconnectionTime = 0; + private int mReconnectionSequence = 0; + + private IHardwareManager mHardwareManager; + private boolean mLedEnabled = false; + private boolean mSoundEnabled = false; + + private IStateManager mStateManager; + private static IStateManager sPendingStateManager = null; + private Handler mBatteryMonitorHandler = null; + private Runnable mBatteryCheckRunnable = null; + + public class LocalBinder extends Binder { + public SrtStreamingService getService() { + return SrtStreamingService.this; + } + } - private final IBinder mBinder = new LocalBinder(); - private CameraSrtLiveStreamer mSrtStreamer; - private CameraSrtLiveStreamer mLastSrtStreamerForCleanup; + @Override + public void onCreate() { + super.onCreate(); + sInstance = this; - private String mSrtUrl; - private boolean mIsStreaming = false; - private SurfaceTexture mSurfaceTexture; - private Surface mSurface; + if (sPendingStateManager != null) { + mStateManager = sPendingStateManager; + sPendingStateManager = null; + Log.d(TAG, "✅ Applied pending StateManager during onCreate"); + } - private RtmpStreamConfig mStreamConfig = new RtmpStreamConfig(); - private static RtmpStreamConfig sPendingStreamConfig = null; + if (sPendingStreamConfig != null) { + mStreamConfig = sPendingStreamConfig; + sPendingStreamConfig = null; + Log.d(TAG, "✅ Applied pending stream config: " + mStreamConfig.toString()); + } - private int mReconnectAttempts = 0; - private static final int MAX_RECONNECT_ATTEMPTS = 10; - private static final long INITIAL_RECONNECT_DELAY_MS = 1000; - private static final float BACKOFF_MULTIPLIER = 1.5f; - private Handler mReconnectHandler; - private boolean mReconnecting = false; + createNotificationChannel(); - private int mConsecutiveFailures = 0; - private static final int MIN_CONSECUTIVE_FAILURES = 3; - private long mLastFailureTime = 0; - private int mTotalFailures = 0; + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this); + } - private Timer mStreamTimeoutTimer; - private String mCurrentStreamId; - private boolean mIsStreamingActive = false; - private static final long STREAM_TIMEOUT_MS = 60000; - private Handler mTimeoutHandler; + mReconnectHandler = new Handler(Looper.getMainLooper()); + mTimeoutHandler = new Handler(Looper.getMainLooper()); + mHardwareManager = HardwareManagerFactory.getInstance(this); - private boolean mHasShownReconnectingNotification = false; + initStreamer(); + } - private enum StreamState { IDLE, STARTING, STREAMING, STOPPING } - private volatile StreamState mStreamState = StreamState.IDLE; - private final Object mStateLock = new Object(); + @SuppressLint("MissingPermission") + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + startForeground(NOTIFICATION_ID, createNotification()); - private long mStreamStartTime = 0; - private long mLastReconnectionTime = 0; - private int mReconnectionSequence = 0; + if (intent != null) { + String srtUrl = intent.getStringExtra("srt_url"); + String streamId = intent.getStringExtra("stream_id"); + mLedEnabled = intent.getBooleanExtra("enable_led", true); + mSoundEnabled = intent.getBooleanExtra("enable_sound", true); - private IHardwareManager mHardwareManager; - private boolean mLedEnabled = false; - private boolean mSoundEnabled = false; + if (srtUrl != null && !srtUrl.isEmpty()) { + setStreamUrl(srtUrl); - private IStateManager mStateManager; - private static IStateManager sPendingStateManager = null; - private Handler mBatteryMonitorHandler = null; - private Runnable mBatteryCheckRunnable = null; + if (streamId != null && !streamId.isEmpty()) { + mCurrentStreamId = streamId; + Log.d(TAG, "Stream ID set: " + streamId); + } - public class LocalBinder extends Binder { - public SrtStreamingService getService() { - return SrtStreamingService.this; - } - } + mReconnectAttempts = 0; + mReconnecting = false; - @Override - public void onCreate() { - super.onCreate(); - sInstance = this; + new Handler(Looper.getMainLooper()).postDelayed(() -> { + Log.d(TAG, "Auto-starting SRT streaming"); + startStreaming(); + }, 1000); + } + } - if (sPendingStateManager != null) { - mStateManager = sPendingStateManager; - sPendingStateManager = null; - Log.d(TAG, "✅ Applied pending StateManager during onCreate"); + return START_STICKY; } - if (sPendingStreamConfig != null) { - mStreamConfig = sPendingStreamConfig; - sPendingStreamConfig = null; - Log.d(TAG, "✅ Applied pending stream config: " + mStreamConfig.toString()); + @Nullable + @Override + public IBinder onBind(Intent intent) { + return mBinder; } - createNotificationChannel(); + @Override + public void onDestroy() { + if (sInstance == this) sInstance = null; + + if (mReconnectHandler != null) mReconnectHandler.removeCallbacksAndMessages(null); + cancelStreamTimeout(); + if (mTimeoutHandler != null) mTimeoutHandler.removeCallbacksAndMessages(null); + + stopStreaming(); + releaseStreamer(); + releaseSurface(); + releaseWakeLocks(); + + if (EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().unregister(this); + } - if (!EventBus.getDefault().isRegistered(this)) { - EventBus.getDefault().register(this); + super.onDestroy(); } - mReconnectHandler = new Handler(Looper.getMainLooper()); - mTimeoutHandler = new Handler(Looper.getMainLooper()); - mHardwareManager = HardwareManagerFactory.getInstance(this); + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, "SRT Streaming Service", NotificationManager.IMPORTANCE_LOW); + channel.setDescription("Shows when the app is streaming via SRT"); + channel.enableLights(true); + channel.setLightColor(Color.BLUE); + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null) manager.createNotificationChannel(channel); + } + } - initStreamer(); - } + private Notification createNotification() { + String contentText = mIsStreaming ? "Streaming to SRT" : "Ready to stream"; + if (mReconnecting) contentText = "Reconnecting... (Attempt " + mReconnectAttempts + ")"; - @SuppressLint("MissingPermission") - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - startForeground(NOTIFICATION_ID, createNotification()); + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("MentraOS SRT Streaming") + .setContentText(contentText) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build(); + } - if (intent != null) { - String srtUrl = intent.getStringExtra("srt_url"); - String streamId = intent.getStringExtra("stream_id"); - mLedEnabled = intent.getBooleanExtra("enable_led", true); - mSoundEnabled = intent.getBooleanExtra("enable_sound", true); + private void updateNotification() { + NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) manager.notify(NOTIFICATION_ID, createNotification()); + } - if (srtUrl != null && !srtUrl.isEmpty()) { - setStreamUrl(srtUrl); + private void updateNotificationIfImportant() { + boolean shouldUpdate = false; + if (mStreamState == StreamState.STREAMING && !mReconnecting) { + shouldUpdate = true; + mHasShownReconnectingNotification = false; + } else if (mStreamState == StreamState.IDLE && !mReconnecting) { + shouldUpdate = true; + mHasShownReconnectingNotification = false; + } else if (mReconnecting && !mHasShownReconnectingNotification) { + shouldUpdate = true; + mHasShownReconnectingNotification = true; + } + if (shouldUpdate) updateNotification(); + } - if (streamId != null && !streamId.isEmpty()) { - mCurrentStreamId = streamId; - Log.d(TAG, "Stream ID set: " + streamId); + private void createSurface() { + if (mSurfaceTexture != null) releaseSurface(); + try { + int surfaceWidth = mStreamConfig.getCaptureSurfaceWidth(); + int surfaceHeight = mStreamConfig.getCaptureSurfaceHeight(); + mSurfaceTexture = new SurfaceTexture(0); + mSurfaceTexture.setDefaultBufferSize(surfaceWidth, surfaceHeight); + mSurface = new Surface(mSurfaceTexture); + Log.d(TAG, "Surface created: " + surfaceWidth + "x" + surfaceHeight + + " (encode " + mStreamConfig.getVideoWidth() + "x" + mStreamConfig.getVideoHeight() + ")"); + } catch (Exception e) { + Log.e(TAG, "Error creating surface", e); + if (sStatusCallback != null) sStatusCallback.onStreamError("Failed to create surface: " + e.getMessage(), mCurrentStreamId); } + } - mReconnectAttempts = 0; - mReconnecting = false; - - new Handler(Looper.getMainLooper()).postDelayed(() -> { - Log.d(TAG, "Auto-starting SRT streaming"); - startStreaming(); - }, 1000); - } - } - - return START_STICKY; - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return mBinder; - } - - @Override - public void onDestroy() { - if (sInstance == this) sInstance = null; - - if (mReconnectHandler != null) mReconnectHandler.removeCallbacksAndMessages(null); - cancelStreamTimeout(); - if (mTimeoutHandler != null) mTimeoutHandler.removeCallbacksAndMessages(null); - - stopStreaming(); - releaseStreamer(); - releaseSurface(); - releaseWakeLocks(); - - if (EventBus.getDefault().isRegistered(this)) { - EventBus.getDefault().unregister(this); - } - - super.onDestroy(); - } - - private void createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, "SRT Streaming Service", NotificationManager.IMPORTANCE_LOW); - channel.setDescription("Shows when the app is streaming via SRT"); - channel.enableLights(true); - channel.setLightColor(Color.BLUE); - NotificationManager manager = getSystemService(NotificationManager.class); - if (manager != null) manager.createNotificationChannel(channel); - } - } - - private Notification createNotification() { - String contentText = mIsStreaming ? "Streaming to SRT" : "Ready to stream"; - if (mReconnecting) contentText = "Reconnecting... (Attempt " + mReconnectAttempts + ")"; - - return new NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("MentraOS SRT Streaming") - .setContentText(contentText) - .setSmallIcon(android.R.drawable.ic_dialog_info) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build(); - } - - private void updateNotification() { - NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - if (manager != null) manager.notify(NOTIFICATION_ID, createNotification()); - } - - private void updateNotificationIfImportant() { - boolean shouldUpdate = false; - if (mStreamState == StreamState.STREAMING && !mReconnecting) { - shouldUpdate = true; - mHasShownReconnectingNotification = false; - } else if (mStreamState == StreamState.IDLE && !mReconnecting) { - shouldUpdate = true; - mHasShownReconnectingNotification = false; - } else if (mReconnecting && !mHasShownReconnectingNotification) { - shouldUpdate = true; - mHasShownReconnectingNotification = true; - } - if (shouldUpdate) updateNotification(); - } - - private void createSurface() { - if (mSurfaceTexture != null) releaseSurface(); - try { - int surfaceWidth = mStreamConfig.getCaptureSurfaceWidth(); - int surfaceHeight = mStreamConfig.getCaptureSurfaceHeight(); - mSurfaceTexture = new SurfaceTexture(0); - mSurfaceTexture.setDefaultBufferSize(surfaceWidth, surfaceHeight); - mSurface = new Surface(mSurfaceTexture); - Log.d(TAG, "Surface created: " + surfaceWidth + "x" + surfaceHeight - + " (encode " + mStreamConfig.getVideoWidth() + "x" + mStreamConfig.getVideoHeight() + ")"); - } catch (Exception e) { - Log.e(TAG, "Error creating surface", e); - if (sStatusCallback != null) sStatusCallback.onStreamError("Failed to create surface: " + e.getMessage(), mCurrentStreamId); - } - } - - private void releaseSurface() { - if (mSurface != null) { mSurface.release(); mSurface = null; } - if (mSurfaceTexture != null) { mSurfaceTexture.release(); mSurfaceTexture = null; } - } - - @SuppressLint("MissingPermission") - private void initStreamer() { - synchronized (mStateLock) { - if (mSrtStreamer != null) { - Log.d(TAG, "Releasing existing SRT streamer before reinitializing"); - releaseStreamer(true); - try { Thread.sleep(100); } catch (InterruptedException e) { Log.w(TAG, "Interrupted"); } - } - } - - try { - Log.d(TAG, "Initializing SRT streamer"); - wakeUpScreen(); - createSurface(); - - final OnErrorListener errorListener = new OnErrorListener() { - @Override - public void onError(StreamPackError error) { - Log.e(TAG, "SRT streaming error: " + error.getMessage()); - EventBus.getDefault().post(new StreamingEvent.Error("Streaming error: " + error.getMessage())); - boolean isRetryable = isRetryableError(error); - StreamingReporting.reportPackError(SrtStreamingService.this, "stream_error", error.getMessage(), isRetryable); - if (isRetryable) { - scheduleReconnect("stream_error"); - } else { - if (sStatusCallback != null) sStatusCallback.onStreamError("Fatal SRT error: " + error.getMessage(), mCurrentStreamId); - stopStreaming(); - } + private void releaseSurface() { + if (mSurface != null) { mSurface.release(); mSurface = null; } + if (mSurfaceTexture != null) { mSurfaceTexture.release(); mSurfaceTexture = null; } + } + + @SuppressLint("MissingPermission") + private void initStreamer() { + synchronized (mStateLock) { + if (mSrtStreamer != null) { + Log.d(TAG, "Releasing existing SRT streamer before reinitializing"); + releaseStreamer(true); + try { Thread.sleep(100); } catch (InterruptedException e) { Log.w(TAG, "Interrupted"); } + } } - }; - - final OnConnectionListener connectionListener = new OnConnectionListener() { - @Override - public void onSuccess() { - Log.i(TAG, "SRT connection successful"); - synchronized (mStateLock) { - mStreamState = StreamState.STREAMING; - mIsStreaming = true; - mIsStreamingActive = true; - mReconnectAttempts = 0; - boolean wasReconnecting = mReconnecting; - mReconnecting = false; - long currentTime = System.currentTimeMillis(); - if (wasReconnecting) { - long downtime = mLastReconnectionTime > 0 ? currentTime - mLastReconnectionTime : 0; - Log.e(TAG, "🟢 SRT RECONNECTED after " + formatDuration(downtime) + " downtime"); - if (sStatusCallback != null) sStatusCallback.onReconnected(mSrtUrl, mReconnectAttempts, mCurrentStreamId); + try { + Log.d(TAG, "Initializing SRT streamer"); + wakeUpScreen(); + createSurface(); + + final OnErrorListener errorListener = new OnErrorListener() { + @Override + public void onError(StreamPackError error) { + Log.e(TAG, "SRT streaming error: " + error.getMessage()); + EventBus.getDefault().post(new StreamingEvent.Error("Streaming error: " + error.getMessage())); + boolean isRetryable = isRetryableError(error); + StreamingReporting.reportPackError(SrtStreamingService.this, "stream_error", error.getMessage(), isRetryable); + if (isRetryable) { + scheduleReconnect("stream_error"); + } else { + if (sStatusCallback != null) sStatusCallback.onStreamError("Fatal SRT error: " + error.getMessage(), mCurrentStreamId); + stopStreaming(); + } + } + }; + + final OnConnectionListener connectionListener = new OnConnectionListener() { + @Override + public void onSuccess() { + Log.i(TAG, "SRT connection successful"); + synchronized (mStateLock) { + mStreamState = StreamState.STREAMING; + mIsStreaming = true; + mIsStreamingActive = true; + mReconnectAttempts = 0; + boolean wasReconnecting = mReconnecting; + mReconnecting = false; + + long currentTime = System.currentTimeMillis(); + if (wasReconnecting) { + long downtime = mLastReconnectionTime > 0 ? currentTime - mLastReconnectionTime : 0; + Log.e(TAG, "🟢 SRT RECONNECTED after " + formatDuration(downtime) + " downtime"); + if (sStatusCallback != null) sStatusCallback.onReconnected(mSrtUrl, mReconnectAttempts, mCurrentStreamId); + } else { + Log.e(TAG, "🟢 SRT STREAM STARTED"); + if (sStatusCallback != null) sStatusCallback.onStreamStarted(mSrtUrl, mCurrentStreamId); + } + + if (mCurrentStreamId != null && !mCurrentStreamId.isEmpty()) { + scheduleStreamTimeout(mCurrentStreamId); + } + + updateNotificationIfImportant(); + + if (mLedEnabled && mHardwareManager != null && mHardwareManager.supportsRecordingLed()) { + mHardwareManager.setRecordingLedOn(); + } + if (mSoundEnabled && mHardwareManager != null && mHardwareManager.supportsAudioPlayback()) { + mHardwareManager.playAudioAsset(AudioAssets.VIDEO_RECORDING_START); + } + + startBatteryMonitoring(); + EventBus.getDefault().post(new StreamingEvent.Connected()); + EventBus.getDefault().post(new StreamingEvent.Started()); + } + } + + @Override + public void onFailed(String message) { + long currentTime = System.currentTimeMillis(); + if (mStreamStartTime > 0 && mStreamState == StreamState.STREAMING) { + Log.e(TAG, "🔴 SRT STREAM FAILED after " + formatDuration(currentTime - mStreamStartTime)); + } + mLastReconnectionTime = currentTime; + Log.e(TAG, "SRT connection failed: " + message); + EventBus.getDefault().post(new StreamingEvent.ConnectionFailed(message)); + StreamingReporting.reportRtmpConnectionFailure(SrtStreamingService.this, mSrtUrl, message, null); + + if (!isRetryableErrorString(message)) { + Log.w(TAG, "Fatal SRT error - stopping stream"); + if (sStatusCallback != null) sStatusCallback.onStreamError("SRT connection failed: " + message, mCurrentStreamId); + stopStreaming(); + return; + } + + final int currentSequence = mReconnectionSequence; + mReconnectHandler.postDelayed(() -> { + if (currentSequence != mReconnectionSequence) return; + synchronized (mStateLock) { + if (mStreamState == StreamState.STREAMING && mIsStreaming) { + Log.d(TAG, "SRT library recovered internally"); + } else if (mStreamState == StreamState.STARTING) { + scheduleReconnect("connection_failed"); + } + } + }, 1000); + } + + @Override + public void onLost(String message) { + long currentTime = System.currentTimeMillis(); + long streamDuration = mStreamStartTime > 0 ? currentTime - mStreamStartTime : 0; + Log.e(TAG, "🔴 SRT STREAM DISCONNECTED after " + formatDuration(streamDuration)); + mLastReconnectionTime = currentTime; + EventBus.getDefault().post(new StreamingEvent.Disconnected()); + StreamingReporting.reportRtmpConnectionLost(SrtStreamingService.this, mSrtUrl, streamDuration, message); + + final int currentSequence = mReconnectionSequence; + mReconnectHandler.postDelayed(() -> { + if (currentSequence != mReconnectionSequence) return; + synchronized (mStateLock) { + if (mStreamState == StreamState.STREAMING && mIsStreaming) { + Log.d(TAG, "SRT library recovered internally"); + } else if (mStreamState == StreamState.IDLE || mStreamState == StreamState.STOPPING) { + Log.d(TAG, "SRT stream stopped, not reconnecting"); + } else { + scheduleReconnect("connection_lost"); + } + } + }, 1000); + } + }; + + TsServiceInfo tsServiceInfo = new TsServiceInfo( + TsServiceInfo.ServiceType.DIGITAL_TV, + (short) 0x4698, + "AugmentOS", + "Mentra" + ); + mSrtStreamer = new CameraSrtLiveStreamer( + this, true, tsServiceInfo, null, null, errorListener, connectionListener); + + int videoWidth = mStreamConfig.getVideoWidth(); + int videoHeight = mStreamConfig.getVideoHeight(); + int captureW = mStreamConfig.getCaptureSurfaceWidth(); + int captureH = mStreamConfig.getCaptureSurfaceHeight(); + int videoBitrate = mStreamConfig.getVideoBitrate(); + int videoFps = mStreamConfig.getVideoFps(); + int audioBitrate = mStreamConfig.getAudioBitrate(); + int audioSampleRate = mStreamConfig.getAudioSampleRate(); + boolean echoCancellation = mStreamConfig.isEchoCancellation(); + boolean noiseSuppression = mStreamConfig.isNoiseSuppression(); + + Log.i(TAG, "Initializing SRT stream with config: " + mStreamConfig.toString()); + + AudioConfig audioConfig = new AudioConfig( + MediaFormat.MIMETYPE_AUDIO_AAC, audioBitrate, audioSampleRate, + AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, + MediaCodecInfo.CodecProfileLevel.AACObjectLC, echoCancellation, noiseSuppression); + + String mimeType = MediaFormat.MIMETYPE_VIDEO_AVC; + int profile = VideoConfig.Companion.getBestProfile(mimeType); + int level = VideoConfig.Companion.getBestLevel(mimeType, profile); + // captureSize (StreamPackLite-only field) removed — public StreamPack 2.6.1 + // uses the encode resolution for capture as well. + VideoConfig videoConfig = new VideoConfig( + mimeType, videoBitrate, new Size(videoWidth, videoHeight), videoFps, profile, level, + 2.0f); + + mSrtStreamer.configure(videoConfig); + mSrtStreamer.configure(audioConfig); + mLastSrtStreamerForCleanup = mSrtStreamer; + + if (mSurface != null && mSurface.isValid()) { + mSrtStreamer.startPreview(mSurface, "0"); + Log.d(TAG, "Started camera preview (SRT)"); } else { - Log.e(TAG, "🟢 SRT STREAM STARTED"); - if (sStatusCallback != null) sStatusCallback.onStreamStarted(mSrtUrl, mCurrentStreamId); + Log.e(TAG, "Cannot start preview, surface is invalid"); } - if (mCurrentStreamId != null && !mCurrentStreamId.isEmpty()) { - scheduleStreamTimeout(mCurrentStreamId); + EventBus.getDefault().post(new StreamingEvent.Ready()); + Log.i(TAG, "SRT streamer initialized successfully"); + + } catch (Exception e) { + Log.e(TAG, "Failed to initialize SRT streamer", e); + EventBus.getDefault().post(new StreamingEvent.Error("Initialization failed: " + e.getMessage())); + if (sStatusCallback != null) sStatusCallback.onStreamError("Initialization failed: " + e.getMessage(), mCurrentStreamId); + StreamingReporting.reportInitializationFailure(SrtStreamingService.this, mSrtUrl, e.getMessage(), e); + } + } + + private void releaseStreamer() { + releaseStreamer(false); + } + + private void releaseStreamer(boolean preserveSession) { + forceStopStreamingInternal(preserveSession); + releaseWakeLocks(); + } + + public void setStreamUrl(String url) { + this.mSrtUrl = url; + Log.i(TAG, "SRT URL set to: " + url); + } + + @RequiresPermission(Manifest.permission.CAMERA) + public void startStreaming() { + synchronized (mStateLock) { + if (mStreamState != StreamState.IDLE) { + Log.i(TAG, "SRT stream request while in state: " + mStreamState + " - forcing clean restart"); + String preservedStreamId = mReconnecting ? mCurrentStreamId : null; + forceStopStreamingInternal(mReconnecting); + if (preservedStreamId != null) mCurrentStreamId = preservedStreamId; + try { Thread.sleep(500); } catch (InterruptedException e) { Log.w(TAG, "Interrupted during cleanup"); } } - updateNotificationIfImportant(); + if (mReconnectHandler != null) mReconnectHandler.removeCallbacksAndMessages(null); - if (mLedEnabled && mHardwareManager != null && mHardwareManager.supportsRecordingLed()) { - mHardwareManager.setRecordingLedOn(); + if (mReconnecting) { + // Called from scheduleReconnect() — preserve attempt count and reconnecting flag + Log.d(TAG, "Reconnect attempt #" + mReconnectAttempts + " starting (sequence: " + mReconnectionSequence + ")"); + } else { + // Fresh start from external caller — reset everything + if (mReconnectAttempts > 0) { + Log.w(TAG, "Cleaning up stale reconnection state - attempts: " + mReconnectAttempts); + mReconnectAttempts = 0; + } + mReconnectionSequence++; } - if (mSoundEnabled && mHardwareManager != null && mHardwareManager.supportsAudioPlayback()) { - mHardwareManager.playAudioAsset(AudioAssets.VIDEO_RECORDING_START); + + if (CameraNeoService.isCameraInUse()) { + String error = "camera_busy"; + Log.e(TAG, "Cannot start SRT stream - camera is busy"); + if (sStatusCallback != null) sStatusCallback.onStreamError(error, mCurrentStreamId); + StreamingReporting.reportCameraBusyError(SrtStreamingService.this, "start_streaming"); + return; } - startBatteryMonitoring(); - EventBus.getDefault().post(new StreamingEvent.Connected()); - EventBus.getDefault().post(new StreamingEvent.Started()); - } + CameraNeoService.closeKeptAliveCamera(); + + if (mSrtUrl == null || mSrtUrl.isEmpty()) { + String error = "SRT URL not set"; + if (sStatusCallback != null) sStatusCallback.onStreamError(error, mCurrentStreamId); + StreamingReporting.reportUrlValidationFailure(SrtStreamingService.this, "null", "URL is null or empty"); + return; + } + + mStreamState = StreamState.STARTING; } - @Override - public void onFailed(String message) { - long currentTime = System.currentTimeMillis(); - if (mStreamStartTime > 0 && mStreamState == StreamState.STREAMING) { - Log.e(TAG, "🔴 SRT STREAM FAILED after " + formatDuration(currentTime - mStreamStartTime)); - } - mLastReconnectionTime = currentTime; - Log.e(TAG, "SRT connection failed: " + message); - EventBus.getDefault().post(new StreamingEvent.ConnectionFailed(message)); - StreamingReporting.reportRtmpConnectionFailure(SrtStreamingService.this, mSrtUrl, message, null); - - if (!isRetryableErrorString(message)) { - Log.w(TAG, "Fatal SRT error - stopping stream"); - if (sStatusCallback != null) sStatusCallback.onStreamError("SRT connection failed: " + message, mCurrentStreamId); - stopStreaming(); - return; - } + try { + wakeUpScreen(); + try { Thread.sleep(100); } catch (InterruptedException e) { Log.w(TAG, "Interrupted"); } - final int currentSequence = mReconnectionSequence; - mReconnectHandler.postDelayed(() -> { - if (currentSequence != mReconnectionSequence) return; - synchronized (mStateLock) { - if (mStreamState == StreamState.STREAMING && mIsStreaming) { - Log.d(TAG, "SRT library recovered internally"); - } else if (mStreamState == StreamState.STARTING) { - scheduleReconnect("connection_failed"); - } + if (mSrtStreamer == null) { + Log.i(TAG, "SRT streamer is null, reinitializing"); + initStreamer(); + try { Thread.sleep(200); } catch (InterruptedException e) { Log.w(TAG, "Interrupted"); } } - }, 1000); - } - @Override - public void onLost(String message) { - long currentTime = System.currentTimeMillis(); - long streamDuration = mStreamStartTime > 0 ? currentTime - mStreamStartTime : 0; - Log.e(TAG, "🔴 SRT STREAM DISCONNECTED after " + formatDuration(streamDuration)); - mLastReconnectionTime = currentTime; - EventBus.getDefault().post(new StreamingEvent.Disconnected()); - StreamingReporting.reportRtmpConnectionLost(SrtStreamingService.this, mSrtUrl, streamDuration, message); - - final int currentSequence = mReconnectionSequence; - mReconnectHandler.postDelayed(() -> { - if (currentSequence != mReconnectionSequence) return; - synchronized (mStateLock) { - if (mStreamState == StreamState.STREAMING && mIsStreaming) { - Log.d(TAG, "SRT library recovered internally"); - } else if (mStreamState == StreamState.IDLE || mStreamState == StreamState.STOPPING) { - Log.d(TAG, "SRT stream stopped, not reconnecting"); - } else { - scheduleReconnect("connection_lost"); - } + if (mReconnecting) { + Log.i(TAG, "Reconnecting to SRT (attempt " + mReconnectAttempts + ")"); + if (sStatusCallback != null) sStatusCallback.onReconnecting(mReconnectAttempts, MAX_RECONNECT_ATTEMPTS, "connection_retry", mCurrentStreamId); + } else { + Log.i(TAG, "Starting SRT streaming to " + mSrtUrl); + if (sStatusCallback != null) sStatusCallback.onStreamStarting(mSrtUrl, mCurrentStreamId); } - }, 1000); + + releaseSurface(); + createSurface(); + + if (mSurface != null && mSurface.isValid()) { + try { + if (mSrtStreamer != null) mSrtStreamer.stopPreview(); + } catch (Exception e) { + Log.d(TAG, "No preview to stop: " + e.getMessage()); + } + mSrtStreamer.startPreview(mSurface, "0"); + try { Thread.sleep(200); } catch (InterruptedException e) { Log.w(TAG, "Interrupted"); } + } else { + throw new Exception("Failed to create valid surface for streaming"); + } + + final Continuation streamContinuation = new Continuation() { + @Override + public CoroutineContext getContext() { return EmptyCoroutineContext.INSTANCE; } + + @Override + public void resumeWith(Object o) { + synchronized (mStateLock) { + if (o instanceof Throwable) { + String errorMsg = "Failed to start SRT streaming: " + ((Throwable) o).getMessage(); + Log.e(TAG, "Error starting SRT stream", (Throwable) o); + mStreamState = StreamState.IDLE; + mIsStreaming = false; + if (sStatusCallback != null) sStatusCallback.onStreamError(errorMsg, mCurrentStreamId); + StreamingReporting.reportStreamStartFailure(SrtStreamingService.this, mSrtUrl, ((Throwable) o).getMessage(), (Throwable) o); + scheduleReconnect("start_error"); + } else { + if (mStreamState == StreamState.STREAMING) return; + Log.d(TAG, "SRT stream initialization succeeded, waiting for connection..."); + mIsStreaming = false; + if (mStreamStartTime == 0 && !mReconnecting) mStreamStartTime = System.currentTimeMillis(); + if (mStreamState == StreamState.STARTING) EventBus.getDefault().post(new StreamingEvent.Initializing()); + } + } + } + }; + + mSrtStreamer.startStream(mSrtUrl, streamContinuation); + + } catch (Exception e) { + String errorMsg = "Failed to start SRT streaming: " + e.getMessage(); + Log.e(TAG, errorMsg, e); + synchronized (mStateLock) { mStreamState = StreamState.IDLE; mIsStreaming = false; } + if (sStatusCallback != null) sStatusCallback.onStreamError(errorMsg, mCurrentStreamId); + StreamingReporting.reportStreamStartFailure(SrtStreamingService.this, mSrtUrl, e.getMessage(), e); + scheduleReconnect("start_exception"); } - }; - - TsServiceInfo tsServiceInfo = new TsServiceInfo( - TsServiceInfo.ServiceType.DIGITAL_TV, - (short) 0x4698, - "AugmentOS", - "Mentra" - ); - mSrtStreamer = new CameraSrtLiveStreamer( - this, true, tsServiceInfo, null, null, errorListener, connectionListener); - - int videoWidth = mStreamConfig.getVideoWidth(); - int videoHeight = mStreamConfig.getVideoHeight(); - int captureW = mStreamConfig.getCaptureSurfaceWidth(); - int captureH = mStreamConfig.getCaptureSurfaceHeight(); - int videoBitrate = mStreamConfig.getVideoBitrate(); - int videoFps = mStreamConfig.getVideoFps(); - int audioBitrate = mStreamConfig.getAudioBitrate(); - int audioSampleRate = mStreamConfig.getAudioSampleRate(); - boolean echoCancellation = mStreamConfig.isEchoCancellation(); - boolean noiseSuppression = mStreamConfig.isNoiseSuppression(); - - Log.i(TAG, "Initializing SRT stream with config: " + mStreamConfig.toString()); - - AudioConfig audioConfig = new AudioConfig( - MediaFormat.MIMETYPE_AUDIO_AAC, audioBitrate, audioSampleRate, - AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, - MediaCodecInfo.CodecProfileLevel.AACObjectLC, echoCancellation, noiseSuppression); - - String mimeType = MediaFormat.MIMETYPE_VIDEO_AVC; - int profile = VideoConfig.Companion.getBestProfile(mimeType); - int level = VideoConfig.Companion.getBestLevel(mimeType, profile); - Size captureSize = - (captureW != videoWidth || captureH != videoHeight) - ? new Size(captureW, captureH) - : null; - VideoConfig videoConfig = new VideoConfig( - mimeType, videoBitrate, new Size(videoWidth, videoHeight), videoFps, profile, level, - 2.0f, captureSize); - - mSrtStreamer.configure(videoConfig); - mSrtStreamer.configure(audioConfig); - mLastSrtStreamerForCleanup = mSrtStreamer; - - if (mSurface != null && mSurface.isValid()) { - mSrtStreamer.startPreview(mSurface, "0"); - Log.d(TAG, "Started camera preview (SRT)"); - } else { - Log.e(TAG, "Cannot start preview, surface is invalid"); - } - - EventBus.getDefault().post(new StreamingEvent.Ready()); - Log.i(TAG, "SRT streamer initialized successfully"); - - } catch (Exception e) { - Log.e(TAG, "Failed to initialize SRT streamer", e); - EventBus.getDefault().post(new StreamingEvent.Error("Initialization failed: " + e.getMessage())); - if (sStatusCallback != null) sStatusCallback.onStreamError("Initialization failed: " + e.getMessage(), mCurrentStreamId); - StreamingReporting.reportInitializationFailure(SrtStreamingService.this, mSrtUrl, e.getMessage(), e); - } - } - - private void releaseStreamer() { - releaseStreamer(false); - } - - private void releaseStreamer(boolean preserveSession) { - forceStopStreamingInternal(preserveSession); - releaseWakeLocks(); - } - - public void setStreamUrl(String url) { - this.mSrtUrl = url; - Log.i(TAG, "SRT URL set to: " + url); - } - - @RequiresPermission(Manifest.permission.CAMERA) - public void startStreaming() { - synchronized (mStateLock) { - if (mStreamState != StreamState.IDLE) { - Log.i(TAG, "SRT stream request while in state: " + mStreamState + " - forcing clean restart"); - String preservedStreamId = mReconnecting ? mCurrentStreamId : null; - forceStopStreamingInternal(mReconnecting); - if (preservedStreamId != null) mCurrentStreamId = preservedStreamId; - try { Thread.sleep(500); } catch (InterruptedException e) { Log.w(TAG, "Interrupted during cleanup"); } - } - - if (mReconnectHandler != null) mReconnectHandler.removeCallbacksAndMessages(null); - - if (mReconnecting) { - // Called from scheduleReconnect() — preserve attempt count and reconnecting flag - Log.d(TAG, "Reconnect attempt #" + mReconnectAttempts + " starting (sequence: " + mReconnectionSequence + ")"); - } else { - // Fresh start from external caller — reset everything - if (mReconnectAttempts > 0) { - Log.w(TAG, "Cleaning up stale reconnection state - attempts: " + mReconnectAttempts); - mReconnectAttempts = 0; + } + + public void stopStreaming() { + synchronized (mStateLock) { + if (mStreamState == StreamState.STOPPING) { Log.w(TAG, "Already stopping SRT stream"); return; } + mStreamState = StreamState.STOPPING; } + Log.i(TAG, "Stopping SRT streaming"); + forceStopStreamingInternal(false); + } + + private void forceStopStreamingInternal(boolean preserveSession) { + Log.d(TAG, "Force stopping SRT stream (preserveSession=" + preserveSession + ")"); + + if (!preserveSession) stopBatteryMonitoring(); + mReconnectionSequence++; - } + if (mReconnectHandler != null) mReconnectHandler.removeCallbacksAndMessages(null); + + if (!preserveSession) cancelStreamTimeout(); + + mReconnecting = preserveSession; + if (!preserveSession) { mReconnectAttempts = 0; } + + final Continuation stopContinuation = new Continuation() { + @Override + public CoroutineContext getContext() { return EmptyCoroutineContext.INSTANCE; } + @Override + public void resumeWith(Object o) { + if (o instanceof Throwable) { + Log.e(TAG, "Error during SRT stream stop", (Throwable) o); + StreamingReporting.reportStreamStopFailure(SrtStreamingService.this, "stream_stop_error", (Throwable) o); + if (sStatusCallback != null) sStatusCallback.onStreamError("Failed to stop SRT stream: " + ((Throwable) o).getMessage(), mCurrentStreamId); + } + Log.d(TAG, "SRT stream stop completed"); + } + }; + + CameraSrtLiveStreamer srtStreamerToCleanup = mSrtStreamer != null ? mSrtStreamer : mLastSrtStreamerForCleanup; + if (srtStreamerToCleanup != null) { + try { srtStreamerToCleanup.stopStream(stopContinuation); } catch (Exception e) { Log.e(TAG, "Exception stopping SRT stream", e); } + try { srtStreamerToCleanup.stopPreview(); Log.d(TAG, "SRT camera preview stopped"); } catch (Exception e) { + Log.e(TAG, "Error stopping SRT preview", e); + StreamingReporting.reportPreviewStartFailure(SrtStreamingService.this, "stop_preview_error", e); + if (sStatusCallback != null) sStatusCallback.onStreamError("Failed to stop camera preview: " + e.getMessage(), mCurrentStreamId); + } + try { srtStreamerToCleanup.release(); Log.d(TAG, "SRT streamer released"); } catch (Exception e) { + Log.e(TAG, "Error releasing SRT streamer", e); + StreamingReporting.reportResourceCleanupFailure(SrtStreamingService.this, "streamer", "release_error", e); + if (sStatusCallback != null) sStatusCallback.onStreamError("Failed to release SRT resources: " + e.getMessage(), mCurrentStreamId); + } + if (mSrtStreamer == srtStreamerToCleanup) mSrtStreamer = null; + mLastSrtStreamerForCleanup = null; + } - if (CameraNeoService.isCameraInUse()) { - String error = "camera_busy"; - Log.e(TAG, "Cannot start SRT stream - camera is busy"); - if (sStatusCallback != null) sStatusCallback.onStreamError(error, mCurrentStreamId); - StreamingReporting.reportCameraBusyError(SrtStreamingService.this, "start_streaming"); - return; - } + releaseSurface(); - CameraNeoService.closeKeptAliveCamera(); + synchronized (mStateLock) { + mStreamState = StreamState.IDLE; + mIsStreaming = false; + if (!preserveSession) { + mIsStreamingActive = false; + mCurrentStreamId = null; + mStreamStartTime = 0; + mLastReconnectionTime = 0; + } + } - if (mSrtUrl == null || mSrtUrl.isEmpty()) { - String error = "SRT URL not set"; - if (sStatusCallback != null) sStatusCallback.onStreamError(error, mCurrentStreamId); - StreamingReporting.reportUrlValidationFailure(SrtStreamingService.this, "null", "URL is null or empty"); - return; - } + updateNotificationIfImportant(); + + if (mLedEnabled && mHardwareManager != null && mHardwareManager.supportsRecordingLed()) { + if (!preserveSession) mHardwareManager.setRecordingLedOff(); + } + if (!preserveSession && mSoundEnabled && mHardwareManager != null && mHardwareManager.supportsAudioPlayback()) { + mHardwareManager.playAudioAsset(AudioAssets.VIDEO_RECORDING_STOP); + } - mStreamState = StreamState.STARTING; + if (!preserveSession) { + if (sStatusCallback != null) sStatusCallback.onStreamStopped(mCurrentStreamId); + EventBus.getDefault().post(new StreamingEvent.Stopped()); + Log.i(TAG, "SRT streaming stopped"); + } } - try { - wakeUpScreen(); - try { Thread.sleep(100); } catch (InterruptedException e) { Log.w(TAG, "Interrupted"); } + private void scheduleReconnect(String reason) { + if (mReconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + Log.w(TAG, "Max SRT reconnection attempts reached"); + if (sStatusCallback != null) sStatusCallback.onReconnectFailed(MAX_RECONNECT_ATTEMPTS, mCurrentStreamId); + long totalDuration = System.currentTimeMillis() - mLastReconnectionTime; + StreamingReporting.reportReconnectionExhaustion(SrtStreamingService.this, mSrtUrl, MAX_RECONNECT_ATTEMPTS, totalDuration); + stopStreaming(); + return; + } - if (mSrtStreamer == null) { - Log.i(TAG, "SRT streamer is null, reinitializing"); - initStreamer(); - try { Thread.sleep(200); } catch (InterruptedException e) { Log.w(TAG, "Interrupted"); } - } + if (mReconnectHandler != null) mReconnectHandler.removeCallbacksAndMessages(null); + mReconnectAttempts++; + long delay = calculateReconnectDelay(mReconnectAttempts); + Log.d(TAG, "Scheduling SRT reconnection #" + mReconnectAttempts + " in " + delay + "ms (reason: " + reason + ")"); + if (sStatusCallback != null) sStatusCallback.onReconnecting(mReconnectAttempts, MAX_RECONNECT_ATTEMPTS, reason, mCurrentStreamId); + mReconnecting = true; + updateNotificationIfImportant(); - if (mReconnecting) { - Log.i(TAG, "Reconnecting to SRT (attempt " + mReconnectAttempts + ")"); - if (sStatusCallback != null) sStatusCallback.onReconnecting(mReconnectAttempts, MAX_RECONNECT_ATTEMPTS, "connection_retry", mCurrentStreamId); - } else { - Log.i(TAG, "Starting SRT streaming to " + mSrtUrl); - if (sStatusCallback != null) sStatusCallback.onStreamStarting(mSrtUrl, mCurrentStreamId); - } + final int currentSequence = mReconnectionSequence; + mReconnectHandler.postDelayed(() -> { + if (currentSequence != mReconnectionSequence) return; + synchronized (mStateLock) { + // Allow reconnection if we're still actively reconnecting (even from IDLE after a failed attempt) + // Only bail if an explicit stop was requested (mReconnecting would be false) + if (!mReconnecting) { + Log.d(TAG, "Stream was explicitly stopped during reconnection delay, cancelling reconnection"); + return; + } + if (mStreamState == StreamState.STOPPING) { + Log.d(TAG, "Stream is stopping, cancelling reconnection"); + return; + } + mStreamState = StreamState.IDLE; + mIsStreaming = false; + startStreaming(); + } + }, delay); + } - releaseSurface(); - createSurface(); + private long calculateReconnectDelay(int attempt) { + double jitter = Math.random() * 0.3 * INITIAL_RECONNECT_DELAY_MS; + return (long) (INITIAL_RECONNECT_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt - 1) + jitter); + } - if (mSurface != null && mSurface.isValid()) { - try { - if (mSrtStreamer != null) mSrtStreamer.stopPreview(); - } catch (Exception e) { - Log.d(TAG, "No preview to stop: " + e.getMessage()); - } - mSrtStreamer.startPreview(mSurface, "0"); - try { Thread.sleep(200); } catch (InterruptedException e) { Log.w(TAG, "Interrupted"); } - } else { - throw new Exception("Failed to create valid surface for streaming"); - } - - final Continuation streamContinuation = new Continuation() { - @Override - public CoroutineContext getContext() { return EmptyCoroutineContext.INSTANCE; } - - @Override - public void resumeWith(Object o) { - synchronized (mStateLock) { - if (o instanceof Throwable) { - String errorMsg = "Failed to start SRT streaming: " + ((Throwable) o).getMessage(); - Log.e(TAG, "Error starting SRT stream", (Throwable) o); - mStreamState = StreamState.IDLE; - mIsStreaming = false; - if (sStatusCallback != null) sStatusCallback.onStreamError(errorMsg, mCurrentStreamId); - StreamingReporting.reportStreamStartFailure(SrtStreamingService.this, mSrtUrl, ((Throwable) o).getMessage(), (Throwable) o); - scheduleReconnect("start_error"); - } else { - if (mStreamState == StreamState.STREAMING) return; - Log.d(TAG, "SRT stream initialization succeeded, waiting for connection..."); - mIsStreaming = false; - if (mStreamStartTime == 0 && !mReconnecting) mStreamStartTime = System.currentTimeMillis(); - if (mStreamState == StreamState.STARTING) EventBus.getDefault().post(new StreamingEvent.Initializing()); + public static void setStreamingStatusCallback(StreamingStatusCallback callback) { + sStatusCallback = callback; + Log.d(TAG, "SRT streaming status callback " + (callback != null ? "registered" : "unregistered")); + } + + private void scheduleStreamTimeout(String streamId) { + cancelStreamTimeout(); + mCurrentStreamId = streamId; + mIsStreamingActive = true; + mStreamTimeoutTimer = new Timer("SrtStreamTimeout-" + streamId); + mStreamTimeoutTimer.schedule(new TimerTask() { + @Override + public void run() { + Log.w(TAG, "SRT stream timeout for streamId: " + streamId); + mTimeoutHandler.post(() -> handleStreamTimeout(streamId)); + } + }, STREAM_TIMEOUT_MS); + } + + private void handleStreamTimeout(String streamId) { + synchronized (mStateLock) { + if (mCurrentStreamId != null && mCurrentStreamId.equals(streamId) && mIsStreamingActive) { + Log.w(TAG, "SRT stream timed out (no keep-alive): " + streamId); + StreamingReporting.reportTimeoutError(SrtStreamingService.this, streamId, STREAM_TIMEOUT_MS); + if (sStatusCallback != null) sStatusCallback.onStreamError("SRT stream timed out - no keep-alive", mCurrentStreamId); + forceStopStreamingInternal(false); } - } } - }; + } - mSrtStreamer.startStream(mSrtUrl, streamContinuation); + private void cancelStreamTimeout() { + if (mStreamTimeoutTimer != null) { mStreamTimeoutTimer.cancel(); mStreamTimeoutTimer = null; } + mIsStreamingActive = false; + mCurrentStreamId = null; + } - } catch (Exception e) { - String errorMsg = "Failed to start SRT streaming: " + e.getMessage(); - Log.e(TAG, errorMsg, e); - synchronized (mStateLock) { mStreamState = StreamState.IDLE; mIsStreaming = false; } - if (sStatusCallback != null) sStatusCallback.onStreamError(errorMsg, mCurrentStreamId); - StreamingReporting.reportStreamStartFailure(SrtStreamingService.this, mSrtUrl, e.getMessage(), e); - scheduleReconnect("start_exception"); + public static void setStateManager(IStateManager stateManager) { + if (sInstance != null) { + sInstance.mStateManager = stateManager; + Log.d(TAG, "✅ StateManager set for SRT battery monitoring"); + } else { + sPendingStateManager = stateManager; + Log.d(TAG, "✅ StateManager stored as pending for SRT service"); + } } - } - public void stopStreaming() { - synchronized (mStateLock) { - if (mStreamState == StreamState.STOPPING) { Log.w(TAG, "Already stopping SRT stream"); return; } - mStreamState = StreamState.STOPPING; + private void startBatteryMonitoring() { + if (mStateManager == null) { Log.w(TAG, "⚠️ StateManager not set - cannot monitor battery"); return; } + stopBatteryMonitoring(); + if (mBatteryMonitorHandler == null) mBatteryMonitorHandler = new Handler(Looper.getMainLooper()); + + mBatteryCheckRunnable = new Runnable() { + @Override + public void run() { + boolean shouldStop = false, shouldReschedule = false; + synchronized (mStateLock) { + if (mIsStreaming) { + if (mHardwareManager == null) { + shouldReschedule = true; + } else if (mStreamState == StreamState.STREAMING) { + int batteryLevel = mHardwareManager.getBatteryLevel(); + if (batteryLevel >= 0 && batteryLevel < BatteryConstants.MIN_BATTERY_LEVEL) { + Log.w(TAG, "🔋⚠️ Battery too low (" + batteryLevel + "%) - stopping SRT stream"); + shouldStop = true; + if (mHardwareManager.supportsAudioPlayback()) mHardwareManager.playAudioAsset(AudioAssets.BATTERY_LOW); + } else { + shouldReschedule = true; + } + } else { + shouldReschedule = true; + } + } + } + if (shouldReschedule && mBatteryMonitorHandler != null) { + mBatteryMonitorHandler.postDelayed(this, BatteryConstants.BATTERY_CHECK_INTERVAL_MS); + } + if (shouldStop) stopStreaming(); + } + }; + + mBatteryMonitorHandler.postDelayed(mBatteryCheckRunnable, BatteryConstants.BATTERY_CHECK_INTERVAL_MS); + Log.d(TAG, "🔋 Started battery monitoring for SRT streaming"); } - Log.i(TAG, "Stopping SRT streaming"); - forceStopStreamingInternal(false); - } - private void forceStopStreamingInternal(boolean preserveSession) { - Log.d(TAG, "Force stopping SRT stream (preserveSession=" + preserveSession + ")"); + private void stopBatteryMonitoring() { + if (mBatteryMonitorHandler != null) { + if (mBatteryCheckRunnable != null) { + mBatteryMonitorHandler.removeCallbacks(mBatteryCheckRunnable); + mBatteryCheckRunnable = null; + } + mBatteryMonitorHandler.removeCallbacksAndMessages(null); + Log.d(TAG, "🔋 Stopped SRT battery monitoring"); + } + } - if (!preserveSession) stopBatteryMonitoring(); + public static void setStreamConfig(RtmpStreamConfig config) { + if (config == null) config = new RtmpStreamConfig(); + if (sInstance != null) { + sInstance.mStreamConfig = config; + Log.d(TAG, "✅ SRT stream config set: " + config.toString()); + } else { + sPendingStreamConfig = config; + Log.d(TAG, "✅ SRT stream config stored as pending: " + config.toString()); + } + } - mReconnectionSequence++; - if (mReconnectHandler != null) mReconnectHandler.removeCallbacksAndMessages(null); + public static void startStreaming(Context context, String srtUrl, String streamId, + boolean enableLed, boolean enableSound, RtmpStreamConfig config) { + setStreamConfig(config); + + if (sInstance != null) { + if (sInstance.mReconnectHandler != null) sInstance.mReconnectHandler.removeCallbacksAndMessages(null); + sInstance.mReconnectAttempts = 0; + sInstance.mReconnecting = false; + sInstance.setStreamUrl(srtUrl); + sInstance.mCurrentStreamId = streamId; + sInstance.mLedEnabled = enableLed; + sInstance.mSoundEnabled = enableSound; + sInstance.startStreaming(); + } else { + Intent intent = new Intent(context, SrtStreamingService.class); + intent.putExtra("srt_url", srtUrl); + if (streamId != null && !streamId.isEmpty()) intent.putExtra("stream_id", streamId); + intent.putExtra("enable_led", enableLed); + intent.putExtra("enable_sound", enableSound); + context.startService(intent); + } + } - if (!preserveSession) cancelStreamTimeout(); + public static void startStreaming(Context context, String srtUrl, String streamId, + boolean enableLed, boolean enableSound) { + startStreaming(context, srtUrl, streamId, enableLed, enableSound, null); + } - mReconnecting = preserveSession; - if (!preserveSession) { mReconnectAttempts = 0; } + public static void startStreaming(Context context, String srtUrl, String streamId) { + startStreaming(context, srtUrl, streamId, true, true, null); + } - final Continuation stopContinuation = new Continuation() { - @Override - public CoroutineContext getContext() { return EmptyCoroutineContext.INSTANCE; } - @Override - public void resumeWith(Object o) { - if (o instanceof Throwable) { - Log.e(TAG, "Error during SRT stream stop", (Throwable) o); - StreamingReporting.reportStreamStopFailure(SrtStreamingService.this, "stream_stop_error", (Throwable) o); - if (sStatusCallback != null) sStatusCallback.onStreamError("Failed to stop SRT stream: " + ((Throwable) o).getMessage(), mCurrentStreamId); - } - Log.d(TAG, "SRT stream stop completed"); - } - }; - - CameraSrtLiveStreamer srtStreamerToCleanup = mSrtStreamer != null ? mSrtStreamer : mLastSrtStreamerForCleanup; - if (srtStreamerToCleanup != null) { - try { srtStreamerToCleanup.stopStream(stopContinuation); } catch (Exception e) { Log.e(TAG, "Exception stopping SRT stream", e); } - try { srtStreamerToCleanup.stopPreview(); Log.d(TAG, "SRT camera preview stopped"); } catch (Exception e) { - Log.e(TAG, "Error stopping SRT preview", e); - StreamingReporting.reportPreviewStartFailure(SrtStreamingService.this, "stop_preview_error", e); - if (sStatusCallback != null) sStatusCallback.onStreamError("Failed to stop camera preview: " + e.getMessage(), mCurrentStreamId); - } - try { srtStreamerToCleanup.release(); Log.d(TAG, "SRT streamer released"); } catch (Exception e) { - Log.e(TAG, "Error releasing SRT streamer", e); - StreamingReporting.reportResourceCleanupFailure(SrtStreamingService.this, "streamer", "release_error", e); - if (sStatusCallback != null) sStatusCallback.onStreamError("Failed to release SRT resources: " + e.getMessage(), mCurrentStreamId); - } - if (mSrtStreamer == srtStreamerToCleanup) mSrtStreamer = null; - mLastSrtStreamerForCleanup = null; - } - - releaseSurface(); - - synchronized (mStateLock) { - mStreamState = StreamState.IDLE; - mIsStreaming = false; - if (!preserveSession) { - mIsStreamingActive = false; - mCurrentStreamId = null; - mStreamStartTime = 0; - mLastReconnectionTime = 0; - } - } - - updateNotificationIfImportant(); - - if (mLedEnabled && mHardwareManager != null && mHardwareManager.supportsRecordingLed()) { - if (!preserveSession) mHardwareManager.setRecordingLedOff(); - } - if (!preserveSession && mSoundEnabled && mHardwareManager != null && mHardwareManager.supportsAudioPlayback()) { - mHardwareManager.playAudioAsset(AudioAssets.VIDEO_RECORDING_STOP); - } - - if (!preserveSession) { - if (sStatusCallback != null) sStatusCallback.onStreamStopped(mCurrentStreamId); - EventBus.getDefault().post(new StreamingEvent.Stopped()); - Log.i(TAG, "SRT streaming stopped"); - } - } - - private void scheduleReconnect(String reason) { - if (mReconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - Log.w(TAG, "Max SRT reconnection attempts reached"); - if (sStatusCallback != null) sStatusCallback.onReconnectFailed(MAX_RECONNECT_ATTEMPTS, mCurrentStreamId); - long totalDuration = System.currentTimeMillis() - mLastReconnectionTime; - StreamingReporting.reportReconnectionExhaustion(SrtStreamingService.this, mSrtUrl, MAX_RECONNECT_ATTEMPTS, totalDuration); - stopStreaming(); - return; - } - - if (mReconnectHandler != null) mReconnectHandler.removeCallbacksAndMessages(null); - mReconnectAttempts++; - long delay = calculateReconnectDelay(mReconnectAttempts); - Log.d(TAG, "Scheduling SRT reconnection #" + mReconnectAttempts + " in " + delay + "ms (reason: " + reason + ")"); - if (sStatusCallback != null) sStatusCallback.onReconnecting(mReconnectAttempts, MAX_RECONNECT_ATTEMPTS, reason, mCurrentStreamId); - mReconnecting = true; - updateNotificationIfImportant(); - - final int currentSequence = mReconnectionSequence; - mReconnectHandler.postDelayed(() -> { - if (currentSequence != mReconnectionSequence) return; - synchronized (mStateLock) { - // Allow reconnection if we're still actively reconnecting (even from IDLE after a failed attempt) - // Only bail if an explicit stop was requested (mReconnecting would be false) - if (!mReconnecting) { - Log.d(TAG, "Stream was explicitly stopped during reconnection delay, cancelling reconnection"); - return; + public static void stopStreaming(Context context) { + if (sInstance != null) { + sInstance.stopStreaming(); + } else { + EventBus.getDefault().post(new StreamingCommand.Stop()); } - if (mStreamState == StreamState.STOPPING) { - Log.d(TAG, "Stream is stopping, cancelling reconnection"); - return; + } + + public static boolean isStreaming() { + if (sInstance != null) { + synchronized (sInstance.mStateLock) { + return sInstance.mStreamState == StreamState.STREAMING || sInstance.mStreamState == StreamState.STARTING; + } } - mStreamState = StreamState.IDLE; - mIsStreaming = false; - startStreaming(); - } - }, delay); - } - - private long calculateReconnectDelay(int attempt) { - double jitter = Math.random() * 0.3 * INITIAL_RECONNECT_DELAY_MS; - return (long) (INITIAL_RECONNECT_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt - 1) + jitter); - } - - public static void setStreamingStatusCallback(StreamingStatusCallback callback) { - sStatusCallback = callback; - Log.d(TAG, "SRT streaming status callback " + (callback != null ? "registered" : "unregistered")); - } - - private void scheduleStreamTimeout(String streamId) { - cancelStreamTimeout(); - mCurrentStreamId = streamId; - mIsStreamingActive = true; - mStreamTimeoutTimer = new Timer("SrtStreamTimeout-" + streamId); - mStreamTimeoutTimer.schedule(new TimerTask() { - @Override - public void run() { - Log.w(TAG, "SRT stream timeout for streamId: " + streamId); - mTimeoutHandler.post(() -> handleStreamTimeout(streamId)); - } - }, STREAM_TIMEOUT_MS); - } - - private void handleStreamTimeout(String streamId) { - synchronized (mStateLock) { - if (mCurrentStreamId != null && mCurrentStreamId.equals(streamId) && mIsStreamingActive) { - Log.w(TAG, "SRT stream timed out (no keep-alive): " + streamId); - StreamingReporting.reportTimeoutError(SrtStreamingService.this, streamId, STREAM_TIMEOUT_MS); - if (sStatusCallback != null) sStatusCallback.onStreamError("SRT stream timed out - no keep-alive", mCurrentStreamId); - forceStopStreamingInternal(false); - } - } - } - - private void cancelStreamTimeout() { - if (mStreamTimeoutTimer != null) { mStreamTimeoutTimer.cancel(); mStreamTimeoutTimer = null; } - mIsStreamingActive = false; - mCurrentStreamId = null; - } - - public static void setStateManager(IStateManager stateManager) { - if (sInstance != null) { - sInstance.mStateManager = stateManager; - Log.d(TAG, "✅ StateManager set for SRT battery monitoring"); - } else { - sPendingStateManager = stateManager; - Log.d(TAG, "✅ StateManager stored as pending for SRT service"); - } - } - - private void startBatteryMonitoring() { - if (mStateManager == null) { Log.w(TAG, "⚠️ StateManager not set - cannot monitor battery"); return; } - stopBatteryMonitoring(); - if (mBatteryMonitorHandler == null) mBatteryMonitorHandler = new Handler(Looper.getMainLooper()); - - mBatteryCheckRunnable = new Runnable() { - @Override - public void run() { - boolean shouldStop = false, shouldReschedule = false; - synchronized (mStateLock) { - if (mIsStreaming) { - if (mHardwareManager == null) { - shouldReschedule = true; - } else if (mStreamState == StreamState.STREAMING) { - int batteryLevel = mHardwareManager.getBatteryLevel(); - if (batteryLevel >= 0 && batteryLevel < BatteryConstants.MIN_BATTERY_LEVEL) { - Log.w(TAG, "🔋⚠️ Battery too low (" + batteryLevel + "%) - stopping SRT stream"); - shouldStop = true; - if (mHardwareManager.supportsAudioPlayback()) mHardwareManager.playAudioAsset(AudioAssets.BATTERY_LOW); - } else { - shouldReschedule = true; - } - } else { - shouldReschedule = true; + return false; + } + + public static boolean isReconnecting() { + return sInstance != null && sInstance.mReconnecting; + } + + public static int getReconnectAttempt() { + return sInstance != null ? sInstance.mReconnectAttempts : 0; + } + + public static boolean resetStreamTimeout(String streamId) { + if (sInstance != null) { + if (sInstance.mCurrentStreamId != null && sInstance.mCurrentStreamId.equals(streamId) && sInstance.mIsStreamingActive) { + WakeLockManager.acquireFullWakeLockAndBringToForeground(sInstance.getApplicationContext(), 2180000, 5000); + sInstance.scheduleStreamTimeout(streamId); + return true; } - } } - if (shouldReschedule && mBatteryMonitorHandler != null) { - mBatteryMonitorHandler.postDelayed(this, BatteryConstants.BATTERY_CHECK_INTERVAL_MS); + return false; + } + + public static String getCurrentStreamId() { + return sInstance != null ? sInstance.mCurrentStreamId : null; + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onStreamingCommand(StreamingCommand command) { + if (command instanceof StreamingCommand.Start) { + mReconnectAttempts = 0; + mReconnecting = false; + startStreaming(); + } else if (command instanceof StreamingCommand.Stop) { + stopStreaming(); + } else if (command instanceof StreamingCommand.SetRtmpUrl) { + setStreamUrl(((StreamingCommand.SetRtmpUrl) command).getRtmpUrl()); } - if (shouldStop) stopStreaming(); - } - }; - - mBatteryMonitorHandler.postDelayed(mBatteryCheckRunnable, BatteryConstants.BATTERY_CHECK_INTERVAL_MS); - Log.d(TAG, "🔋 Started battery monitoring for SRT streaming"); - } - - private void stopBatteryMonitoring() { - if (mBatteryMonitorHandler != null) { - if (mBatteryCheckRunnable != null) { - mBatteryMonitorHandler.removeCallbacks(mBatteryCheckRunnable); - mBatteryCheckRunnable = null; - } - mBatteryMonitorHandler.removeCallbacksAndMessages(null); - Log.d(TAG, "🔋 Stopped SRT battery monitoring"); - } - } - - public static void setStreamConfig(RtmpStreamConfig config) { - if (config == null) config = new RtmpStreamConfig(); - if (sInstance != null) { - sInstance.mStreamConfig = config; - Log.d(TAG, "✅ SRT stream config set: " + config.toString()); - } else { - sPendingStreamConfig = config; - Log.d(TAG, "✅ SRT stream config stored as pending: " + config.toString()); - } - } - - public static void startStreaming(Context context, String srtUrl, String streamId, - boolean enableLed, boolean enableSound, RtmpStreamConfig config) { - setStreamConfig(config); - - if (sInstance != null) { - if (sInstance.mReconnectHandler != null) sInstance.mReconnectHandler.removeCallbacksAndMessages(null); - sInstance.mReconnectAttempts = 0; - sInstance.mReconnecting = false; - sInstance.setStreamUrl(srtUrl); - sInstance.mCurrentStreamId = streamId; - sInstance.mLedEnabled = enableLed; - sInstance.mSoundEnabled = enableSound; - sInstance.startStreaming(); - } else { - Intent intent = new Intent(context, SrtStreamingService.class); - intent.putExtra("srt_url", srtUrl); - if (streamId != null && !streamId.isEmpty()) intent.putExtra("stream_id", streamId); - intent.putExtra("enable_led", enableLed); - intent.putExtra("enable_sound", enableSound); - context.startService(intent); - } - } - - public static void startStreaming(Context context, String srtUrl, String streamId, - boolean enableLed, boolean enableSound) { - startStreaming(context, srtUrl, streamId, enableLed, enableSound, null); - } - - public static void startStreaming(Context context, String srtUrl, String streamId) { - startStreaming(context, srtUrl, streamId, true, true, null); - } - - public static void stopStreaming(Context context) { - if (sInstance != null) { - sInstance.stopStreaming(); - } else { - EventBus.getDefault().post(new StreamingCommand.Stop()); - } - } - - public static boolean isStreaming() { - if (sInstance != null) { - synchronized (sInstance.mStateLock) { - return sInstance.mStreamState == StreamState.STREAMING || sInstance.mStreamState == StreamState.STARTING; - } - } - return false; - } - - public static boolean isReconnecting() { - return sInstance != null && sInstance.mReconnecting; - } - - public static int getReconnectAttempt() { - return sInstance != null ? sInstance.mReconnectAttempts : 0; - } - - public static boolean resetStreamTimeout(String streamId) { - if (sInstance != null) { - if (sInstance.mCurrentStreamId != null && sInstance.mCurrentStreamId.equals(streamId) && sInstance.mIsStreamingActive) { - WakeLockManager.acquireFullWakeLockAndBringToForeground(sInstance.getApplicationContext(), 2180000, 5000); - sInstance.scheduleStreamTimeout(streamId); + } + + private boolean isRetryableError(StreamPackError error) { + String message = error.getMessage(); + if (message == null) return true; + if (message.contains("SocketException") || message.contains("Connection") || message.contains("Timeout") || + message.contains("Network") || message.contains("UnknownHostException") || message.contains("IOException") || + message.contains("ECONNREFUSED") || message.contains("ETIMEDOUT")) return true; + if (message.contains("Permission") || message.contains("Invalid URL") || message.contains("Authentication") || + message.contains("Codec") || message.contains("Not supported") || message.contains("Illegal")) return false; + if (message.contains("Camera") && (message.contains("busy") || message.contains("in use"))) return false; return true; - } - } - return false; - } - - public static String getCurrentStreamId() { - return sInstance != null ? sInstance.mCurrentStreamId : null; - } - - @Subscribe(threadMode = ThreadMode.MAIN) - public void onStreamingCommand(StreamingCommand command) { - if (command instanceof StreamingCommand.Start) { - mReconnectAttempts = 0; - mReconnecting = false; - startStreaming(); - } else if (command instanceof StreamingCommand.Stop) { - stopStreaming(); - } else if (command instanceof StreamingCommand.SetRtmpUrl) { - setStreamUrl(((StreamingCommand.SetRtmpUrl) command).getRtmpUrl()); - } - } - - private boolean isRetryableError(StreamPackError error) { - String message = error.getMessage(); - if (message == null) return true; - if (message.contains("SocketException") || message.contains("Connection") || message.contains("Timeout") || - message.contains("Network") || message.contains("UnknownHostException") || message.contains("IOException") || - message.contains("ECONNREFUSED") || message.contains("ETIMEDOUT")) return true; - if (message.contains("Permission") || message.contains("Invalid URL") || message.contains("Authentication") || - message.contains("Codec") || message.contains("Not supported") || message.contains("Illegal")) return false; - if (message.contains("Camera") && (message.contains("busy") || message.contains("in use"))) return false; - return true; - } - - private boolean isRetryableErrorString(String message) { - if (message == null) return true; - String lower = message.toLowerCase(); - if (lower.contains("socket") || lower.contains("connection") || lower.contains("timeout") || - lower.contains("network") || lower.contains("ioexception") || lower.contains("refused") || - lower.contains("disconnected") || lower.contains("reset") || lower.contains("host")) return true; - if (lower.contains("permission") || lower.contains("invalid url") || lower.contains("authentication") || - lower.contains("codec") || lower.contains("illegal")) return false; - if (lower.contains("camera") && (lower.contains("busy") || lower.contains("in use"))) return false; - return true; - } - - private void wakeUpScreen() { - WakeLockManager.acquireFullWakeLockAndBringToForeground(this, 2180000, 5000); - } - - private void releaseWakeLocks() { - WakeLockManager.releaseAllWakeLocks(); - } - - private static String formatDuration(long durationMs) { - if (durationMs < 0) return "0s"; - long seconds = durationMs / 1000; - long minutes = seconds / 60; - long hours = minutes / 60; - seconds %= 60; minutes %= 60; - if (hours > 0) return String.format("%dh %dm %ds", hours, minutes, seconds); - if (minutes > 0) return String.format("%dm %ds", minutes, seconds); - return String.format("%ds", seconds); - } -} + } + + private boolean isRetryableErrorString(String message) { + if (message == null) return true; + String lower = message.toLowerCase(); + if (lower.contains("socket") || lower.contains("connection") || lower.contains("timeout") || + lower.contains("network") || lower.contains("ioexception") || lower.contains("refused") || + lower.contains("disconnected") || lower.contains("reset") || lower.contains("host")) return true; + if (lower.contains("permission") || lower.contains("invalid url") || lower.contains("authentication") || + lower.contains("codec") || lower.contains("illegal")) return false; + if (lower.contains("camera") && (lower.contains("busy") || lower.contains("in use"))) return false; + return true; + } + + private void wakeUpScreen() { + WakeLockManager.acquireFullWakeLockAndBringToForeground(this, 2180000, 5000); + } + + private void releaseWakeLocks() { + WakeLockManager.releaseAllWakeLocks(); + } + + private static String formatDuration(long durationMs) { + if (durationMs < 0) return "0s"; + long seconds = durationMs / 1000; + long minutes = seconds / 60; + long hours = minutes / 60; + seconds %= 60; minutes %= 60; + if (hours > 0) return String.format("%dh %dm %ds", hours, minutes, seconds); + if (minutes > 0) return String.format("%dm %ds", minutes, seconds); + return String.format("%ds", seconds); + } +} \ No newline at end of file diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/AsgClientService.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/AsgClientService.java index 1fb2336286..6e1454b87a 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/AsgClientService.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/AsgClientService.java @@ -104,9 +104,6 @@ public class AsgClientService extends Service implements NetworkStateListener, B // --------------------------------------------- // Service State // --------------------------------------------- - // Note: AugmentosService removed - legacy dependency no longer needed - // private AugmentosService augmentosService = null; - // private boolean isAugmentosBound = false; private static AsgClientService instance; private boolean lastI2sPlaying = false; private boolean isConnected = false; // Track connection state based on heartbeat @@ -127,65 +124,13 @@ public class AsgClientService extends Service implements NetworkStateListener, B private BroadcastReceiver restartReceiver; private BroadcastReceiver otaProgressReceiver; private BroadcastReceiver mtkUpdateReceiver; - + // --------------------------------------------- // Heartbeat Timeout Management // --------------------------------------------- private Handler heartbeatTimeoutHandler; private Runnable heartbeatTimeoutRunnable; - // --------------------------------------------- - // ServiceConnection for AugmentosService - DISABLED (legacy code) - // --------------------------------------------- - // Note: AugmentosService binding removed - no longer needed for standalone ASG client - /* - private final ServiceConnection augmentosConnection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - Log.i(TAG, "🔗 AugmentosService connected successfully"); - Log.d(TAG, "📋 Component name: " + name.getClassName()); - - try { - AugmentosService.LocalBinder binder = (AugmentosService.LocalBinder) service; - augmentosService = binder.getService(); - isAugmentosBound = true; - Log.d(TAG, "✅ AugmentosService bound and ready"); - - // Update state manager - if (stateManager instanceof StateManager) { - Log.d(TAG, "📊 Updating state manager with AugmentosService binding"); - ((StateManager) stateManager).setAugmentosServiceBound(true); - } - - // Check WiFi connectivity - if (stateManager.isConnectedToWifi()) { - Log.d(TAG, "🌐 WiFi is connected - triggering onWifiConnected"); - onWifiConnected(); - } else { - Log.d(TAG, "📶 WiFi is not connected - skipping onWifiConnected"); - } - } catch (Exception e) { - Log.e(TAG, "💥 Error in AugmentosService connection", e); - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - Log.w(TAG, "🔌 AugmentosService disconnected"); - Log.d(TAG, "📋 Component name: " + name.getClassName()); - - isAugmentosBound = false; - augmentosService = null; - - // Update state manager - if (stateManager instanceof StateManager) { - Log.d(TAG, "📊 Updating state manager with AugmentosService unbinding"); - ((StateManager) stateManager).setAugmentosServiceBound(false); - } - } - }; - */ - // --------------------------------------------- // Lifecycle Methods // --------------------------------------------- @@ -254,7 +199,7 @@ public void onCreate() { @Override public int onStartCommand(Intent intent, int flags, int startId) { Log.d(TAG, "🎯 onStartCommand() called - StartId: " + startId + ", Flags: " + flags); - + super.onStartCommand(intent, flags, startId); try { @@ -286,18 +231,18 @@ public int onStartCommand(Intent intent, int flags, int startId) { // Delegate action handling to lifecycle manager lifecycleManager.handleAction(action, intent.getExtras()); Log.d(TAG, "✅ Action processed successfully"); - + } catch (Exception e) { Log.e(TAG, "💥 Error in onStartCommand()", e); } - + return START_STICKY; } @Override public void onDestroy() { Log.i(TAG, "🛑 AsgClientServiceV2 onDestroy() started"); - + try { // Unregister from EventBus if (EventBus.getDefault().isRegistered(this)) { @@ -321,19 +266,6 @@ public void onDestroy() { Log.d(TAG, "📻 Unregistering broadcast receivers"); unregisterReceivers(); - // Note: AugmentosService unbinding removed - no longer used - /* - // Unbind from AugmentosService - if (isAugmentosBound) { - Log.d(TAG, "🔌 Unbinding from AugmentosService"); - unbindService(augmentosConnection); - isAugmentosBound = false; - Log.d(TAG, "✅ AugmentosService unbound"); - } else { - Log.d(TAG, "⏭️ Not bound to AugmentosService - skipping unbind"); - } - */ - // Clean up WiFi debouncing if (wifiDebounceHandler != null && wifiDebounceRunnable != null) { Log.d(TAG, "📶 Cleaning up WiFi debouncing"); @@ -349,11 +281,11 @@ public void onDestroy() { // Release RGB LED control authority back to BES Log.d(TAG, "🚨 Releasing RGB LED control authority back to BES"); sendRgbLedControlAuthority(false); - + // Disable touch/swipe event reporting on service destroy Log.d(TAG, "🎯 Disabling touch event reporting on service destroy"); handleTouchEventControl(true); - + Log.d(TAG, "🎯 Disabling swipe volume control on service destroy"); handleSwipeVolumeControl(true); @@ -405,7 +337,7 @@ public void handleI2SAudioState(boolean playing) { // --------------------------------------------- // Touch/Swipe Event Commands // --------------------------------------------- - + /** * Enable or disable touch event reporting * @param enable true to enable touch events, false to disable @@ -480,26 +412,24 @@ private boolean sendK900Command(String payload) { /** * Send RGB LED control authority command to BES chipset. * This tells BES whether MTK (our app) or BES should control the RGB LEDs. - * + * * @param claimControl true = MTK claims control, false = BES resumes control */ private void sendRgbLedControlAuthority(boolean claimControl) { Log.d(TAG, "🚨 sendRgbLedControlAuthority() called - Claim: " + claimControl); - + try { - // Build full K900 format (C, V, B) to avoid double-wrapping JSONObject authorityCommand = new JSONObject(); authorityCommand.put("C", "android_control_led"); - authorityCommand.put("V", 1); // Version field - REQUIRED to prevent double-wrapping - - // Create proper JSON object for B field + authorityCommand.put("V", 1); + JSONObject bField = new JSONObject(); bField.put("on", claimControl); authorityCommand.put("B", bField.toString()); - + String commandStr = authorityCommand.toString(); Log.i(TAG, "🚨 Sending RGB LED authority command: " + commandStr); - + if (serviceContainer == null || serviceContainer.getServiceManager() == null) { Log.w(TAG, "⚠️ ServiceContainer not initialized; deferring RGB LED authority claim"); return; @@ -534,9 +464,9 @@ private void sendRgbLedControlAuthority(boolean claimControl) { // --------------------------------------------- private void initializeServiceContainer() { Log.d(TAG, "🔧 initializeServiceContainer() started"); - + try { - serviceContainer = new ServiceContainer(this, this ); + serviceContainer = new ServiceContainer(this, this); Log.d(TAG, "✅ ServiceContainer created successfully"); // Initialize container @@ -544,7 +474,7 @@ private void initializeServiceContainer() { serviceContainer.initialize(); Log.d(TAG, "✅ Service container initialization completed"); - //Wait for 1 second + // Wait for 1 second Thread.sleep(1000); // Get interface references @@ -555,14 +485,29 @@ private void initializeServiceContainer() { stateManager = serviceContainer.getStateManager(); streamingManager = serviceContainer.getStreamingManager(); commandProcessor = serviceContainer.getCommandProcessor(); - + Log.d(TAG, "✅ All interface references obtained"); Log.d(TAG, "📊 Interface status - LifecycleManager: " + (lifecycleManager != null ? "valid" : "null") + - ", CommunicationManager: " + (communicationManager != null ? "valid" : "null") + - ", ConfigurationManager: " + (configurationManager != null ? "valid" : "null") + - ", StateManager: " + (stateManager != null ? "valid" : "null") + - ", StreamingManager: " + (streamingManager != null ? "valid" : "null") + - ", CommandProcessor: " + (commandProcessor != null ? "valid" : "null")); + ", CommunicationManager: " + (communicationManager != null ? "valid" : "null") + + ", ConfigurationManager: " + (configurationManager != null ? "valid" : "null") + + ", StateManager: " + (stateManager != null ? "valid" : "null") + + ", StreamingManager: " + (streamingManager != null ? "valid" : "null") + + ", CommandProcessor: " + (commandProcessor != null ? "valid" : "null")); + + // --------------------------------------------------------------- + // START BLE ADVERTISING + // The iOS companion app scans for "INMO GO2" and connects as the + // BLE central. Without this call the GATT server never becomes + // visible and the iOS readiness-check loop runs forever. + // --------------------------------------------------------------- + Log.i(TAG, "📡 Starting BLE advertising for iOS companion connection"); + var btManager = serviceContainer.getServiceManager().getBluetoothManager(); + if (btManager != null) { + btManager.startAdvertising(); + Log.i(TAG, "✅ BLE advertising started successfully"); + } else { + Log.e(TAG, "❌ Bluetooth manager is null — cannot start advertising"); + } } catch (Exception e) { Log.e(TAG, "💥 Error initializing service container", e); @@ -579,7 +524,7 @@ private void initializeServiceContainer() { */ private void initializeWifiDebouncing() { Log.d(TAG, "📶 initializeWifiDebouncing() started"); - + try { wifiDebounceHandler = new Handler(Looper.getMainLooper()); wifiDebounceRunnable = () -> { @@ -632,7 +577,7 @@ private void applySavedCameraFovOnStart() { */ private void registerReceivers() { Log.d(TAG, "📻 registerReceivers() started"); - + try { registerHeartbeatReceiver(); registerRestartReceiver(); @@ -649,43 +594,27 @@ private void registerReceivers() { */ private void unregisterReceivers() { Log.d(TAG, "📻 unregisterReceivers() started"); - + try { if (heartbeatReceiver != null) { - Log.d(TAG, "💓 Unregistering heartbeat receiver"); unregisterReceiver(heartbeatReceiver); Log.d(TAG, "✅ Heartbeat receiver unregistered"); - } else { - Log.d(TAG, "⏭️ Heartbeat receiver is null - skipping"); } - if (restartReceiver != null) { - Log.d(TAG, "🔄 Unregistering restart receiver"); unregisterReceiver(restartReceiver); Log.d(TAG, "✅ Restart receiver unregistered"); - } else { - Log.d(TAG, "⏭️ Restart receiver is null - skipping"); } - if (otaProgressReceiver != null) { - Log.d(TAG, "📥 Unregistering OTA progress receiver"); unregisterReceiver(otaProgressReceiver); Log.d(TAG, "✅ OTA progress receiver unregistered"); - } else { - Log.d(TAG, "⏭️ OTA progress receiver is null - skipping"); } - if (mtkUpdateReceiver != null) { - Log.d(TAG, "🔄 Unregistering MTK update receiver"); unregisterReceiver(mtkUpdateReceiver); Log.d(TAG, "✅ MTK update receiver unregistered"); - } else { - Log.d(TAG, "⏭️ MTK update receiver is null - skipping"); } - - // Stop heartbeat monitoring + stopHeartbeatMonitoring(); - + Log.d(TAG, "✅ All receivers unregistered successfully"); } catch (IllegalArgumentException e) { Log.w(TAG, "⚠️ Receiver was not registered: " + e.getMessage()); @@ -700,8 +629,8 @@ private void unregisterReceivers() { @Override public void onWifiStateChanged(boolean isConnected) { Log.i(TAG, "🔄 WiFi state changed: " + (isConnected ? "CONNECTED" : "DISCONNECTED")); - Log.d(TAG, "📊 Previous state: " + (lastWifiState ? "CONNECTED" : "DISCONNECTED") + - ", Pending state: " + (pendingWifiState ? "CONNECTED" : "DISCONNECTED")); + Log.d(TAG, "📊 Previous state: " + (lastWifiState ? "CONNECTED" : "DISCONNECTED") + + ", Pending state: " + (pendingWifiState ? "CONNECTED" : "DISCONNECTED")); pendingWifiState = isConnected; @@ -727,25 +656,23 @@ public void onWifiStateChanged(boolean isConnected) { @Override public void onHotspotStateChanged(boolean isEnabled) { Log.i(TAG, "📡 Hotspot state changed: " + (isEnabled ? "ENABLED" : "DISABLED")); - - // Send hotspot status update to phone + try { if (serviceContainer != null && serviceContainer.getServiceManager() != null) { var networkManager = serviceContainer.getServiceManager().getNetworkManager(); var commManager = serviceContainer.getCommunicationManager(); - + if (networkManager != null && commManager != null) { - // Build hotspot status JSON JSONObject hotspotStatus = new JSONObject(); hotspotStatus.put("type", "hotspot_status_update"); hotspotStatus.put("hotspot_enabled", isEnabled); - + if (isEnabled) { hotspotStatus.put("hotspot_ssid", networkManager.getHotspotSsid()); hotspotStatus.put("hotspot_password", networkManager.getHotspotPassword()); hotspotStatus.put("hotspot_gateway_ip", networkManager.getHotspotGatewayIp()); } - + Log.d(TAG, "📡 🔥 Sending hotspot status update: " + hotspotStatus.toString()); boolean sent = commManager.sendBluetoothResponse(hotspotStatus); Log.d(TAG, "📡 🔥 " + (sent ? "✅ Hotspot status sent successfully" : "❌ Failed to send hotspot status")); @@ -761,22 +688,17 @@ public void onHotspotStateChanged(boolean isEnabled) { @Override public void onWifiCredentialsReceived(String ssid, String password, String authToken) { Log.i(TAG, "🔑 WiFi credentials received for network: " + ssid); - Log.d(TAG, "📋 Credentials - SSID: " + ssid + - ", Password: " + (password != null ? "***" : "null") + - ", AuthToken: " + (authToken != null ? "***" : "null")); } @Override public void onHotspotError(String errorMessage) { Log.e(TAG, "📡 🔥 ❌ Hotspot error occurred: " + errorMessage); - // Send hotspot error to phone try { if (serviceContainer != null && serviceContainer.getServiceManager() != null) { var commManager = serviceContainer.getCommunicationManager(); if (commManager != null) { - // Build hotspot error JSON JSONObject hotspotError = new JSONObject(); hotspotError.put("type", "hotspot_error"); hotspotError.put("error_message", errorMessage); @@ -802,37 +724,30 @@ public void onConnectionStateChanged(boolean connected) { Log.i(TAG, "📶 Bluetooth connection state changed: " + (connected ? "CONNECTED" : "DISCONNECTED")); if (connected) { - // Send the pending APK-done signal immediately on reconnect (before WiFi/version info). - // This is the primary path for the phone to learn the APK updated successfully. OtaHelper otaHelper = OtaHelper.getInstance(); if (otaHelper != null) { otaHelper.onPhoneConnected(); } Log.d(TAG, "⏱️ Scheduling WiFi status send in 3 seconds"); - // Send WiFi status after delay new Handler(Looper.getMainLooper()).postDelayed(() -> { Log.d(TAG, "📤 Sending WiFi status after Bluetooth connection"); if (stateManager.isConnectedToWifi()) { - Log.d(TAG, "🌐 WiFi is connected - sending status"); communicationManager.sendWifiStatusOverBle(true); } else { - Log.d(TAG, "📶 WiFi is not connected - sending status"); communicationManager.sendWifiStatusOverBle(false); } }, 3000); Log.d(TAG, "📋 Sending version information after Bluetooth connection"); sendVersionInfo(); - - // Claim RGB LED control authority when Bluetooth connects + Log.d(TAG, "🚨 Claiming RGB LED control authority on Bluetooth connection"); sendRgbLedControlAuthority(true); - - // Enable touch/swipe event reporting when Bluetooth connects + Log.d(TAG, "🎯 Enabling touch event reporting on Bluetooth connection"); handleTouchEventControl(true); - + Log.d(TAG, "🎯 Enabling swipe volume control on Bluetooth connection"); handleSwipeVolumeControl(false); } else { @@ -843,7 +758,7 @@ public void onConnectionStateChanged(boolean connected) { @Override public void onDataReceived(byte[] data) { Log.d(TAG, "📥 Bluetooth onDataReceived() called"); - + if (data == null || data.length == 0) { Log.w(TAG, "⚠️ Received empty data packet from Bluetooth"); return; @@ -852,10 +767,8 @@ public void onDataReceived(byte[] data) { Log.i(TAG, "📥 Received " + data.length + " bytes from Bluetooth"); String incomingPayload = new String(data, StandardCharsets.UTF_8); Log.d(TAG, "📋 Data preview: " + incomingPayload.substring(0, Math.min(incomingPayload.length(), 100)) + - (incomingPayload.length() > 100 ? "..." : "")); + (incomingPayload.length() > 100 ? "..." : "")); - // BLE/serial can deliver data before getInterfaceReferences() runs (e.g. right after - // MY_PACKAGE_REPLACED when the service is still in onCreate). Guard to avoid NPE. final CommandProcessor processor = commandProcessor; if (processor == null) { Log.w(TAG, "⚠️ CommandProcessor not yet initialized - dropping " + data.length @@ -870,27 +783,17 @@ public void onDataReceived(byte[] data) { } } - // --------------------------------------------- // Helper Methods // --------------------------------------------- private void onWifiConnected() { Log.i(TAG, "🌐 Connected to WiFi network"); - - // Note: AugmentosService check removed - no longer used - /* - if (isAugmentosBound && augmentosService != null) { - Log.i(TAG, "🔗 AugmentOS service is available, connecting to backend..."); - } else { - Log.d(TAG, "⏭️ AugmentOS service not available - waiting for binding"); - } - */ } private void processMediaQueue() { Log.d(TAG, "📁 processMediaQueue() called"); - + if (serviceContainer.getServiceManager().getMediaQueueManager() != null) { if (!serviceContainer.getServiceManager().getMediaQueueManager().isQueueEmpty()) { Log.i(TAG, "📁 WiFi connected - processing media upload queue"); @@ -906,18 +809,13 @@ private void processMediaQueue() { /** * Send version information to phone in two chunks to work around BLE MTU limitations. - * Chunk 1 (version_info_1): app_version, build_number, device_model, android_version - * Chunk 2 (version_info_2): ota_version_url, firmware_version, bt_mac_address - * Phone will accumulate both chunks and process when complete. */ public void sendVersionInfo() { Log.i(TAG, "📊 Sending version information (chunked for MTU)"); try { - // Gather all version data String appVersion = "1.0.0"; String buildNumber = "1"; - Log.d(TAG, "📋 Default app version: " + appVersion + ", Build number: " + buildNumber); try { appVersion = getPackageManager().getPackageInfo(getPackageName(), 0).versionName; @@ -931,30 +829,25 @@ public void sendVersionInfo() { String androidVersion = android.os.Build.VERSION.RELEASE; String otaVersionUrl = OtaConstants.VERSION_JSON_URL; - // Include BES firmware version (cached from hs_syvr command) String besFirmwareVersion = ""; if (serviceContainer.getServiceManager() != null && - serviceContainer.getServiceManager().getAsgSettings() != null) { + serviceContainer.getServiceManager().getAsgSettings() != null) { besFirmwareVersion = serviceContainer.getServiceManager().getAsgSettings().getBesFirmwareVersion(); } - // Include MTK firmware version (from system property) String mtkFirmwareVersion = SysControl.getSystemCurrentVersion(this); - - // Include BES BT MAC address as unique device identifier (stored in system properties) String besBtMac = SysProp.getBesBtMac(this); Log.d(TAG, "📋 Version info prepared - Device: " + deviceModel + - ", Android: " + androidVersion + - ", BES Firmware: " + besFirmwareVersion + - ", MTK Firmware: " + mtkFirmwareVersion + - ", BT MAC: " + besBtMac + - ", OTA URL: " + otaVersionUrl); + ", Android: " + androidVersion + + ", BES Firmware: " + besFirmwareVersion + + ", MTK Firmware: " + mtkFirmwareVersion + + ", BT MAC: " + besBtMac + + ", OTA URL: " + otaVersionUrl); if (serviceContainer.getServiceManager().getBluetoothManager() != null && serviceContainer.getServiceManager().getBluetoothManager().isConnected()) { - // Chunk 1: Basic device info (smaller payload) JSONObject chunk1 = new JSONObject(); chunk1.put("type", "version_info_1"); chunk1.put("app_version", appVersion); @@ -966,14 +859,8 @@ public void sendVersionInfo() { Log.d(TAG, "📤 Sending version_info_1: " + chunk1.toString()); serviceContainer.getServiceManager().getBluetoothManager().sendData(chunk1.toString().getBytes(StandardCharsets.UTF_8)); - // Small delay between chunks to ensure proper ordering - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - // Chunk 2: OTA URL only (isolated due to length) JSONObject chunk2 = new JSONObject(); chunk2.put("type", "version_info_2"); chunk2.put("ota_version_url", otaVersionUrl); @@ -981,14 +868,8 @@ public void sendVersionInfo() { Log.d(TAG, "📤 Sending version_info_2: " + chunk2.toString()); serviceContainer.getServiceManager().getBluetoothManager().sendData(chunk2.toString().getBytes(StandardCharsets.UTF_8)); - // Small delay between chunks to ensure proper ordering - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - // Chunk 3: Firmware info (BES version, MTK version, BT MAC) JSONObject chunk3 = new JSONObject(); chunk3.put("type", "version_info_3"); chunk3.put("bes_fw_version", besFirmwareVersion); @@ -1009,27 +890,13 @@ public void sendVersionInfo() { } } - // REMOVED: saveCoreToken method - now handled directly by ConfigurationManager - // AuthTokenCommandHandler calls configurationManager.saveCoreToken() directly - // --------------------------------------------- - // Public API Methods (Delegating to managers) + // Public API Methods // --------------------------------------------- - // REMOVED: All delegation methods are now handled directly by managers - // Components should access managers through the service container - // --------------------------------------------- - // Getters (Delegating to state manager) - // --------------------------------------------- - // REMOVED: All getter methods are now handled directly by managers - // Components should access managers through the service container - - // --------------------------------------------- - // Media Capture Listeners - // --------------------------------------------- public MediaCaptureService.MediaCaptureListener getMediaCaptureListener() { Log.d(TAG, "📸 Creating media capture listener"); - + return new MediaCaptureService.MediaCaptureListener() { @Override public void onPhotoCapturing(String requestId) { @@ -1079,26 +946,18 @@ public void onMediaError(String requestId, String error, int mediaType) { }; } - /** - * Get the CommandProcessor instance - * @return CommandProcessor or null if not yet initialized - */ public CommandProcessor getCommandProcessor() { return commandProcessor; } public ServiceCallbackInterface getServiceCallback() { Log.d(TAG, "📡 Creating service callback interface"); - + return new ServiceCallbackInterface() { @Override public void sendThroughBluetooth(byte[] data) { - Log.d(TAG, "📤 sendThroughBluetooth() called - Data length: " + (data != null ? data.length : "null")); - if (serviceContainer.getServiceManager().getBluetoothManager() != null) { - Log.d(TAG, "📶 Sending data through Bluetooth"); serviceContainer.getServiceManager().getBluetoothManager().sendData(data); - Log.d(TAG, "✅ Data sent through Bluetooth successfully"); } else { Log.w(TAG, "⚠️ Bluetooth manager is null - cannot send data"); } @@ -1106,10 +965,7 @@ public void sendThroughBluetooth(byte[] data) { @Override public boolean sendFileViaBluetooth(String filePath) { - Log.d(TAG, "📁 sendFileViaBluetooth() called - File: " + filePath); - if (serviceContainer.getServiceManager().getBluetoothManager() != null) { - Log.d(TAG, "📶 Starting BLE file transfer"); boolean started = serviceContainer.getServiceManager().getBluetoothManager().sendImageFile(filePath); if (started) { Log.i(TAG, "✅ BLE file transfer started successfully for: " + filePath); @@ -1122,15 +978,11 @@ public boolean sendFileViaBluetooth(String filePath) { return false; } } - + @Override public boolean isBleTransferInProgress() { - Log.d(TAG, "📊 isBleTransferInProgress() called"); - if (serviceContainer.getServiceManager().getBluetoothManager() != null) { - boolean inProgress = serviceContainer.getServiceManager().getBluetoothManager().isFileTransferInProgress(); - Log.d(TAG, "📊 BLE transfer in progress: " + inProgress); - return inProgress; + return serviceContainer.getServiceManager().getBluetoothManager().isFileTransferInProgress(); } else { Log.w(TAG, "⚠️ Bluetooth manager is null - cannot check transfer status"); return false; @@ -1144,14 +996,14 @@ public boolean isBleTransferInProgress() { // --------------------------------------------- private void registerHeartbeatReceiver() { Log.d(TAG, "💓 registerHeartbeatReceiver() started"); - + try { heartbeatReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); Log.d(TAG, "💓 Heartbeat receiver triggered - Action: " + action); - + if (ACTION_HEARTBEAT.equals(action) || "com.augmentos.otaupdater.ACTION_HEARTBEAT".equals(action)) { @@ -1161,13 +1013,10 @@ public void onReceive(Context context, Intent intent) { Intent ackIntent = new Intent(ACTION_HEARTBEAT_ACK); ackIntent.setPackage("com.augmentos.otaupdater"); sendBroadcast(ackIntent); - Log.i(TAG, "✅ Heartbeat acknowledgment sent successfully"); } catch (Exception e) { Log.e(TAG, "💥 Error sending heartbeat acknowledgment", e); } - } else { - Log.d(TAG, "⏭️ Unknown action received: " + action); } } }; @@ -1175,7 +1024,6 @@ public void onReceive(Context context, Intent intent) { IntentFilter heartbeatFilter = new IntentFilter(); heartbeatFilter.addAction(ACTION_HEARTBEAT); heartbeatFilter.addAction(ACTION_OTA_HEARTBEAT); - registerReceiver(heartbeatReceiver, heartbeatFilter); Log.d(TAG, "✅ Heartbeat receiver registered successfully"); } catch (Exception e) { @@ -1183,37 +1031,22 @@ public void onReceive(Context context, Intent intent) { } } - /** - * Reset heartbeat timeout - called when heartbeat is received - */ private void resetHeartbeatTimeout() { Log.d(TAG, "💓 Resetting heartbeat timeout"); - + try { - // Cancel any existing timeout heartbeatTimeoutHandler.removeCallbacks(heartbeatTimeoutRunnable); - - // Mark as connected isConnected = true; - Log.d(TAG, "🔌 Connection state changed to CONNECTED"); - - // Schedule new timeout heartbeatTimeoutHandler.postDelayed(heartbeatTimeoutRunnable, HEARTBEAT_TIMEOUT_MS); - Log.d(TAG, "⏰ Heartbeat timeout scheduled for " + HEARTBEAT_TIMEOUT_MS + "ms"); - } catch (Exception e) { Log.e(TAG, "💥 Error resetting heartbeat timeout", e); } } - /** - * Start heartbeat monitoring - call this when service becomes active - */ public void startHeartbeatMonitoring() { Log.d(TAG, "💓 Starting heartbeat monitoring"); - + try { - // Initialize heartbeat timeout handler if not already done if (heartbeatTimeoutHandler == null) { Log.d(TAG, "💓 Initializing heartbeat timeout handler"); heartbeatTimeoutHandler = new Handler(Looper.getMainLooper()); @@ -1223,77 +1056,53 @@ public void startHeartbeatMonitoring() { Log.i(TAG, "🔌 Connection state changed to DISCONNECTED due to heartbeat timeout"); }; } - - // Cancel any existing timeout + heartbeatTimeoutHandler.removeCallbacks(heartbeatTimeoutRunnable); - - // Don't set connected state - wait for first heartbeat isConnected = false; Log.d(TAG, "🔌 Connection state initialized as DISCONNECTED - waiting for first heartbeat"); - - // Schedule initial timeout to detect if no heartbeat comes heartbeatTimeoutHandler.postDelayed(heartbeatTimeoutRunnable, HEARTBEAT_TIMEOUT_MS); Log.d(TAG, "⏰ Initial heartbeat timeout scheduled for " + HEARTBEAT_TIMEOUT_MS + "ms"); - + } catch (Exception e) { Log.e(TAG, "💥 Error starting heartbeat monitoring", e); } } - /** - * Stop heartbeat monitoring - call this when service becomes inactive - */ public void stopHeartbeatMonitoring() { Log.d(TAG, "💓 Stopping heartbeat monitoring"); - + try { heartbeatTimeoutHandler.removeCallbacks(heartbeatTimeoutRunnable); isConnected = false; - Log.d(TAG, "🔌 Connection state changed to DISCONNECTED (monitoring stopped)"); } catch (Exception e) { Log.e(TAG, "💥 Error stopping heartbeat monitoring", e); } } - /** - * Get current connection state - */ public boolean isConnected() { return isConnected; } - /** - * Handle the phone_ready/glasses_ready handshake completing over Bluetooth. - */ public void onPhoneReadyHandshakeComplete() { Log.d(TAG, "📱 Phone ready handshake complete - marking phone connection active"); resetHeartbeatTimeout(); } - /** - * Handle service heartbeat received from MentraLiveSGC - */ public void onServiceHeartbeatReceived() { Log.d(TAG, "💓 Service heartbeat received from MentraLiveSGC"); - - // Reset heartbeat timeout and mark as connected resetHeartbeatTimeout(); } private void registerRestartReceiver() { Log.d(TAG, "🔄 registerRestartReceiver() started"); - + try { restartReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); - Log.d(TAG, "🔄 Restart receiver triggered - Action: " + action); - if (ACTION_RESTART_SERVICE.equals(action)) { Log.i(TAG, "🔄 Received restart request from OTA updater"); - } else { - Log.d(TAG, "⏭️ Unknown action received: " + action); } } }; @@ -1308,21 +1117,17 @@ public void onReceive(Context context, Intent intent) { private void registerOtaProgressReceiver() { Log.d(TAG, "📥 registerOtaProgressReceiver() started"); - + try { otaProgressReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); - Log.d(TAG, "📥 OTA progress receiver triggered - Action: " + action); - switch (Objects.requireNonNull(action)) { case ACTION_DOWNLOAD_PROGRESS: - Log.d(TAG, "📥 Handling download progress"); handleDownloadProgress(intent); break; case ACTION_INSTALLATION_PROGRESS: - Log.d(TAG, "🔧 Handling installation progress"); handleInstallationProgress(intent); break; default: @@ -1343,8 +1148,6 @@ public void onReceive(Context context, Intent intent) { } private void handleDownloadProgress(Intent intent) { - Log.d(TAG, "📥 handleDownloadProgress() started"); - try { String status = intent.getStringExtra("status"); int progress = intent.getIntExtra("progress", 0); @@ -1353,19 +1156,10 @@ private void handleDownloadProgress(Intent intent) { String errorMessage = intent.getStringExtra("error_message"); long timestamp = intent.getLongExtra("timestamp", System.currentTimeMillis()); - Log.i(TAG, "📥 Download progress: " + status + " - " + progress + "% (" + - bytesDownloaded + "/" + totalBytes + " bytes)"); - - if (errorMessage != null) { - Log.w(TAG, "⚠️ Download error: " + errorMessage); - } + Log.i(TAG, "📥 Download progress: " + status + " - " + progress + "%"); if (commandProcessor != null) { - Log.d(TAG, "📤 Sending download progress to command processor"); commandProcessor.sendDownloadProgressOverBle(status, progress, bytesDownloaded, totalBytes, errorMessage, timestamp); - Log.d(TAG, "✅ Download progress sent successfully"); - } else { - Log.w(TAG, "⚠️ Command processor is null - cannot send download progress"); } } catch (Exception e) { Log.e(TAG, "💥 Error handling download progress", e); @@ -1373,26 +1167,16 @@ private void handleDownloadProgress(Intent intent) { } private void handleInstallationProgress(Intent intent) { - Log.d(TAG, "🔧 handleInstallationProgress() started"); - try { String status = intent.getStringExtra("status"); String apkPath = intent.getStringExtra("apk_path"); String errorMessage = intent.getStringExtra("error_message"); long timestamp = intent.getLongExtra("timestamp", System.currentTimeMillis()); - Log.i(TAG, "🔧 Installation progress: " + status + " - " + apkPath); - - if (errorMessage != null) { - Log.w(TAG, "⚠️ Installation error: " + errorMessage); - } + Log.i(TAG, "🔧 Installation progress: " + status); if (commandProcessor != null) { - Log.d(TAG, "📤 Sending installation progress to command processor"); commandProcessor.sendInstallationProgressOverBle(status, apkPath, errorMessage, timestamp); - Log.d(TAG, "✅ Installation progress sent successfully"); - } else { - Log.w(TAG, "⚠️ Command processor is null - cannot send installation progress"); } } catch (Exception e) { Log.e(TAG, "💥 Error handling installation progress", e); @@ -1401,7 +1185,7 @@ private void handleInstallationProgress(Intent intent) { private void registerMtkUpdateReceiver() { Log.d(TAG, "🔄 registerMtkUpdateReceiver() started"); - + try { mtkUpdateReceiver = new BroadcastReceiver() { @Override @@ -1412,7 +1196,7 @@ public void onReceive(Context context, Intent intent) { } } }; - + IntentFilter filter = new IntentFilter("com.mentra.asg_client.MTK_UPDATE_COMPLETE"); registerReceiver(mtkUpdateReceiver, filter); Log.d(TAG, "✅ MTK update receiver registered successfully"); @@ -1422,14 +1206,9 @@ public void onReceive(Context context, Intent intent) { } private void sendMtkUpdateCompleteOverBle() { - Log.d(TAG, "📤 sendMtkUpdateCompleteOverBle() started"); try { if (commandProcessor != null) { - Log.d(TAG, "📤 Sending MTK update complete to command processor"); commandProcessor.sendMtkUpdateComplete(); - Log.d(TAG, "✅ MTK update complete sent successfully"); - } else { - Log.w(TAG, "⚠️ Command processor is null - cannot send MTK update complete"); } } catch (Exception e) { Log.e(TAG, "💥 Error sending MTK update complete", e); @@ -1442,16 +1221,13 @@ private void sendMtkUpdateCompleteOverBle() { @Subscribe(threadMode = ThreadMode.MAIN) public void onStreamingEvent(StreamingEvent event) { Log.d(TAG, "📹 Streaming event received: " + event.getClass().getSimpleName()); - + if (event instanceof StreamingEvent.Started) { Log.i(TAG, "✅ RTMP streaming started successfully"); } else if (event instanceof StreamingEvent.Stopped) { Log.i(TAG, "⏹️ RTMP streaming stopped"); } else if (event instanceof StreamingEvent.Error) { - Log.e(TAG, "❌ RTMP streaming error: " + - ((StreamingEvent.Error) event).getMessage()); - } else { - Log.d(TAG, "📹 Unknown streaming event type: " + event.getClass().getSimpleName()); + Log.e(TAG, "❌ RTMP streaming error: " + ((StreamingEvent.Error) event).getMessage()); } } @@ -1469,31 +1245,22 @@ public AsgClientService getService() { // Utility Methods // --------------------------------------------- public static void openWifi(Context context, boolean bEnable) { - Log.d(TAG, "🌐 openWifi() called - Enable: " + bEnable); - try { if (bEnable) { - Log.d(TAG, "📶 Enabling WiFi via ADB command"); SysControl.injectAdbCommand(context, "svc wifi enable"); - Log.d(TAG, "✅ WiFi enable command executed"); } else { - Log.d(TAG, "📶 Disabling WiFi via ADB command"); SysControl.injectAdbCommand(context, "svc wifi disable"); - Log.d(TAG, "✅ WiFi disable command executed"); } } catch (Exception e) { Log.e(TAG, "💥 Error executing WiFi command", e); } } - /** - * Log all available video resolutions from the camera - */ private void logAvailableVideoResolutions() { Log.i(TAG, "📹 ========================================"); Log.i(TAG, "📹 AVAILABLE VIDEO RESOLUTIONS"); Log.i(TAG, "📹 ========================================"); - + try { CameraManager cameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE); if (cameraManager == null) { @@ -1511,7 +1278,7 @@ private void logAvailableVideoResolutions() { try { CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId); StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); - + if (map == null) { Log.w(TAG, "📹 Camera " + cameraId + ": No stream configuration map"); continue; @@ -1531,22 +1298,15 @@ private void logAvailableVideoResolutions() { Log.e(TAG, "📹 Error accessing camera " + cameraId, e); } } - + Log.i(TAG, "📹 ========================================"); } catch (Exception e) { Log.e(TAG, "📹 Error querying video resolutions", e); } } - /** - * Clean up orphaned BLE transfer files from previous sessions. - * These are compressed AVIF files stored in the app's external files directory - * that were never successfully transferred and deleted. - * This runs on boot, so any BLE temp files are by definition orphaned. - */ private void cleanupOrphanedBleTransfers() { try { - // App's external files directory where compressed files are stored java.io.File appFilesDir = getExternalFilesDir(""); if (appFilesDir == null || !appFilesDir.exists()) { Log.d(TAG, "🗑️ App files directory does not exist, skipping cleanup"); @@ -1555,7 +1315,6 @@ private void cleanupOrphanedBleTransfers() { Log.d(TAG, "🗑️ Checking for orphaned BLE transfer files in: " + appFilesDir.getAbsolutePath()); - // Look for package directories java.io.File[] packageDirs = appFilesDir.listFiles(java.io.File::isDirectory); if (packageDirs == null) { Log.d(TAG, "🗑️ No package directories found"); @@ -1566,16 +1325,13 @@ private void cleanupOrphanedBleTransfers() { long totalSpaceFreed = 0; for (java.io.File packageDir : packageDirs) { - // Look for BLE image files (no extension, just bleImgId pattern) java.io.File[] files = packageDir.listFiles((dir, name) -> - // BLE images have pattern like "ble_1234567890" (no extension) - name.startsWith("ble_") && !name.contains(".") + name.startsWith("ble_") && !name.contains(".") ); if (files != null && files.length > 0) { Log.d(TAG, "🗑️ Found " + files.length + " orphaned BLE files in " + packageDir.getName()); - // On boot, ALL BLE temp files are orphaned - no need for time check for (java.io.File file : files) { long fileSize = file.length(); String fileName = file.getName(); @@ -1585,7 +1341,7 @@ private void cleanupOrphanedBleTransfers() { totalCleaned++; totalSpaceFreed += fileSize; Log.d(TAG, "🗑️ Deleted orphaned BLE transfer: " + fileName + - " (age: " + ageMinutes + " minutes, size: " + (fileSize / 1024) + " KB)"); + " (age: " + ageMinutes + " minutes, size: " + (fileSize / 1024) + " KB)"); } else { Log.w(TAG, "🗑️ Failed to delete orphaned file: " + fileName); } @@ -1595,12 +1351,7 @@ private void cleanupOrphanedBleTransfers() { if (totalCleaned > 0) { Log.i(TAG, "🗑️ Cleanup complete: Deleted " + totalCleaned + " orphaned BLE files, freed " + - (totalSpaceFreed / 1024) + " KB"); - // Optional: Show notification about cleanup -// if (serviceContainer != null && serviceContainer.getNotificationManager() != null) { -// serviceContainer.getNotificationManager().showDebugNotification("BLE Cleanup", -// "Cleaned " + totalCleaned + " orphaned transfers (" + (totalSpaceFreed / 1024) + " KB)"); -// } + (totalSpaceFreed / 1024) + " KB"); } else { Log.d(TAG, "🗑️ No orphaned BLE transfer files found"); } @@ -1609,4 +1360,4 @@ private void cleanupOrphanedBleTransfers() { Log.e(TAG, "🗑️ Error cleaning up orphaned BLE transfers", e); } } -} +} \ No newline at end of file diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BatteryCommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BatteryCommandHandler.java index 42a243ac2e..d228e8096e 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BatteryCommandHandler.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BatteryCommandHandler.java @@ -1,7 +1,12 @@ package com.mentra.asg_client.service.core.handlers; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.BatteryManager; import android.util.Log; +import com.mentra.asg_client.service.communication.interfaces.ICommunicationManager; import com.mentra.asg_client.service.legacy.interfaces.ICommandHandler; import com.mentra.asg_client.service.system.interfaces.IStateManager; @@ -15,16 +20,29 @@ */ public class BatteryCommandHandler implements ICommandHandler { private static final String TAG = "BatteryCommandHandler"; - + private final IStateManager stateManager; + private final ICommunicationManager communicationManager; + private final Context context; + public BatteryCommandHandler(IStateManager stateManager, + ICommunicationManager communicationManager, + Context context) { + this.stateManager = stateManager; + this.communicationManager = communicationManager; + this.context = context; + } + + // Keep backward-compatible constructor for existing callers public BatteryCommandHandler(IStateManager stateManager) { this.stateManager = stateManager; + this.communicationManager = null; + this.context = null; } @Override public Set getSupportedCommandTypes() { - return Set.of("battery_status", "request_battery_state"); + return Set.of("battery_status", "request_battery_state", "request_battery_status"); } @Override @@ -34,7 +52,8 @@ public boolean handleCommand(String commandType, JSONObject data) { case "battery_status": return handleBatteryStatus(data); case "request_battery_state": - return handleRequestBatteryState(); + case "request_battery_status": + return handleRequestBatteryStatus(); default: Log.e(TAG, "Unsupported battery command: " + commandType); return false; @@ -46,14 +65,13 @@ public boolean handleCommand(String commandType, JSONObject data) { } /** - * Handle battery status command + * Handle battery status command (phone pushing status to us) */ private boolean handleBatteryStatus(JSONObject data) { try { int level = data.optInt("level", -1); boolean charging = data.optBoolean("charging", false); long timestamp = data.optLong("timestamp", System.currentTimeMillis()); - stateManager.updateBatteryStatus(level, charging, timestamp); return true; } catch (Exception e) { @@ -63,16 +81,51 @@ private boolean handleBatteryStatus(JSONObject data) { } /** - * Handle request battery state command + * Handle request_battery_status — iOS asking glasses for current battery level. + * Read from Android BatteryManager and send a JSON response over BLE. */ - private boolean handleRequestBatteryState() { + private boolean handleRequestBatteryStatus() { try { - // This would typically trigger sending current battery status - // Implementation depends on the state manager - Log.d(TAG, "Requesting battery state"); + int level = -1; + boolean charging = false; + + if (context != null) { + // Read battery level from Android system + BatteryManager bm = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); + if (bm != null) { + level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY); + } + + // Read charging status + IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + Intent batteryStatus = context.registerReceiver(null, filter); + if (batteryStatus != null) { + int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + charging = status == BatteryManager.BATTERY_STATUS_CHARGING + || status == BatteryManager.BATTERY_STATUS_FULL; + } + } + + Log.d(TAG, "🔋 Battery request: level=" + level + "% charging=" + charging); + + // Update state manager + stateManager.updateBatteryStatus(level, charging, System.currentTimeMillis()); + + // Send response back to iOS over BLE + if (communicationManager != null) { + JSONObject response = new JSONObject(); + response.put("type", "battery_status"); + response.put("level", level); + response.put("charging", charging); + response.put("timestamp", System.currentTimeMillis()); + boolean sent = communicationManager.sendBluetoothResponse(response); + Log.d(TAG, "🔋 Battery response sent: " + sent + " → " + response); + return sent; + } + return true; } catch (Exception e) { - Log.e(TAG, "Error handling request battery state command", e); + Log.e(TAG, "Error handling request_battery_status", e); return false; } } @@ -85,20 +138,19 @@ public boolean handleK900BatteryStatus(JSONObject bData) { if (bData != null) { int newBatteryPercentage = bData.optInt("pt", -1); int newBatteryVoltage = bData.optInt("vt", -1); - + if (newBatteryPercentage != -1) { Log.d(TAG, "🔋 Battery percentage: " + newBatteryPercentage + "%"); } if (newBatteryVoltage != -1) { Log.d(TAG, "🔋 Battery voltage: " + newBatteryVoltage + "mV"); } - - // Calculate charging status based on voltage + boolean isCharging = newBatteryVoltage > 3900; - - // Update state manager + if (newBatteryPercentage != -1 || newBatteryVoltage != -1) { - stateManager.updateBatteryStatus(newBatteryPercentage, isCharging, System.currentTimeMillis()); + stateManager.updateBatteryStatus(newBatteryPercentage, isCharging, + System.currentTimeMillis()); return true; } } else { @@ -110,4 +162,4 @@ public boolean handleK900BatteryStatus(JSONObject bData) { return false; } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/DisplayCommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/DisplayCommandHandler.java new file mode 100644 index 0000000000..32ba31536f --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/DisplayCommandHandler.java @@ -0,0 +1,351 @@ +package com.mentra.asg_client.service.core.handlers; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import com.mentra.asg_client.service.legacy.interfaces.ICommandHandler; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.Set; + +/** + * Handles display commands from the iOS companion app: + * text_wall — full-screen text overlay + * double_text_wall — two-line text overlay (top + bottom) + * clear_display — remove the overlay + * teleprompter_update — 3-line scrolling teleprompter overlay + * + * Renders a full-screen semi-transparent overlay on the glasses display + * using a system-level window so it appears over all apps. + * + * text_wall / double_text_wall content is wrapped in a ScrollView and + * auto-scrolled to the bottom after layout. On the Go2's small (640x480) + * panel, longer captions can need more vertical space than the screen has; + * this guarantees the most recent lines (the bottom of the message) stay + * on-screen, with any overflow clipped from the top instead of the bottom. + */ +public class DisplayCommandHandler implements ICommandHandler { + + private static final String TAG = "DisplayCommandHandler"; + + // Text sizes (sp). Reduced from the original 28/22/26 to leave more + // headroom on the 480px-tall panel before scrolling/clipping kicks in. + private static final int TEXT_SIZE_SINGLE = 22; // text_wall + private static final int TEXT_SIZE_DOUBLE_TOP = 18; // double_text_wall top line + private static final int TEXT_SIZE_DOUBLE_BOTTOM = 22; // double_text_wall bottom line + + private final Context context; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + // The overlay view — null when nothing is displayed + private View overlayView; + private WindowManager windowManager; + + // Teleprompter-specific view references (only valid while a teleprompter + // overlay is active; used so updates can mutate text in place instead of + // rebuilding/re-adding the view every frame). + private TextView tpPrevView; + private TextView tpCurrView; + private TextView tpNextView; + private boolean teleprompterActive = false; + + public DisplayCommandHandler(Context context) { + this.context = context.getApplicationContext(); + this.windowManager = (WindowManager) this.context.getSystemService(Context.WINDOW_SERVICE); + } + + @Override + public Set getSupportedCommandTypes() { + return Set.of("text_wall", "double_text_wall", "clear_display", "teleprompter_update"); + } + + @Override + public boolean handleCommand(String commandType, JSONObject data) { + Log.d(TAG, "📺 handleCommand: " + commandType); + try { + switch (commandType) { + case "text_wall": + String text = data != null ? data.optString("text", "") : ""; + Log.i(TAG, "📺 text_wall: " + text); + showTextOverlay(text, null); + return true; + + case "double_text_wall": + String top = data != null ? data.optString("topText", "") : ""; + String bottom = data != null ? data.optString("bottomText", "") : ""; + Log.i(TAG, "📺 double_text_wall top=" + top + " bottom=" + bottom); + showTextOverlay(top, bottom); + return true; + + case "clear_display": + Log.i(TAG, "📺 clear_display"); + clearOverlay(); + return true; + + case "teleprompter_update": + Log.d(TAG, "📺 teleprompter_update"); + handleTeleprompterUpdate(data); + return true; + + default: + Log.w(TAG, "⚠️ Unsupported display command: " + commandType); + return false; + } + } catch (Exception e) { + Log.e(TAG, "💥 Error handling display command: " + commandType, e); + return false; + } + } + + // ----------------------------------------------------------------------- + // text_wall / double_text_wall overlay rendering + // ----------------------------------------------------------------------- + + private void showTextOverlay(final String topText, final String bottomText) { + mainHandler.post(() -> { + try { + // Remove any existing overlay first + removeOverlay(); + + // Content layout — sized to its natural content height (which + // may be taller than the screen); centered within the + // ScrollView's viewport when it fits on its own. + LinearLayout root = new LinearLayout(context); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(Color.argb(220, 0, 0, 0)); + root.setGravity(Gravity.CENTER); + root.setPadding(40, 40, 40, 40); + + if (bottomText == null || bottomText.isEmpty()) { + // Single text_wall — centred, large + TextView tv = makeTextView(topText, TEXT_SIZE_SINGLE, true, Color.WHITE); + root.addView(tv); + } else { + // double_text_wall — top smaller, bottom larger + TextView tvTop = makeTextView(topText, TEXT_SIZE_DOUBLE_TOP, false, Color.WHITE); + tvTop.setPadding(0, 0, 0, 20); + root.addView(tvTop); + + View divider = new View(context); + divider.setBackgroundColor(Color.argb(150, 255, 255, 255)); + LinearLayout.LayoutParams dp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 2); + dp.setMargins(0, 8, 0, 20); + divider.setLayoutParams(dp); + root.addView(divider); + + TextView tvBottom = makeTextView(bottomText, TEXT_SIZE_DOUBLE_BOTTOM, true, Color.WHITE); + root.addView(tvBottom); + } + + ScrollView scrollable = wrapInScrollableContainer(root); + addOverlayView(scrollable); + scrollToBottomAfterLayout(scrollable); + Log.d(TAG, "📺 Overlay displayed"); + + } catch (Exception e) { + Log.e(TAG, "💥 Error showing overlay", e); + } + }); + } + + // ----------------------------------------------------------------------- + // teleprompter_update overlay rendering + // ----------------------------------------------------------------------- + + /** + * Expected payload: + * { + * "type": "teleprompter_update", + * "lines": ["previous line text", "current line text", "next line text"], + * "highlightIndex": 1 // index within "lines" to render as the highlighted/current line + * } + * + * "lines" may contain 1-3 entries. Missing prev/next lines are rendered blank. + * highlightIndex defaults to the middle entry if omitted. + */ + private void handleTeleprompterUpdate(final JSONObject data) { + final String prevText; + final String currText; + final String nextText; + + if (data == null) { + prevText = ""; + currText = ""; + nextText = ""; + } else { + JSONArray lines = data.optJSONArray("lines"); + int highlightIndex = data.optInt("highlightIndex", lines != null ? lines.length() / 2 : 0); + + String p = "", c = "", n = ""; + if (lines != null) { + int len = lines.length(); + // current = highlightIndex + if (highlightIndex >= 0 && highlightIndex < len) { + c = lines.optString(highlightIndex, ""); + } + // previous = highlightIndex - 1 + if (highlightIndex - 1 >= 0 && highlightIndex - 1 < len) { + p = lines.optString(highlightIndex - 1, ""); + } + // next = highlightIndex + 1 + if (highlightIndex + 1 >= 0 && highlightIndex + 1 < len) { + n = lines.optString(highlightIndex + 1, ""); + } + } + prevText = p; + currText = c; + nextText = n; + } + + mainHandler.post(() -> { + try { + if (!teleprompterActive || overlayView == null) { + buildTeleprompterOverlay(); + } + if (tpPrevView != null) tpPrevView.setText(prevText); + if (tpCurrView != null) tpCurrView.setText(currText); + if (tpNextView != null) tpNextView.setText(nextText); + } catch (Exception e) { + Log.e(TAG, "💥 Error updating teleprompter overlay", e); + } + }); + } + + /** + * Builds the 3-line teleprompter overlay (prev / current / next) and adds it + * to the window. Must be called on the main thread. + */ + private void buildTeleprompterOverlay() { + // Remove any existing overlay first (handles switching from text_wall etc.) + removeOverlay(); + + LinearLayout root = new LinearLayout(context); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(Color.argb(220, 0, 0, 0)); + root.setGravity(Gravity.CENTER); + root.setPadding(40, 40, 40, 40); + + // Dimmed previous line + tpPrevView = makeTextView("", 20, false, Color.argb(160, 200, 200, 200)); + tpPrevView.setPadding(0, 0, 0, 12); + root.addView(tpPrevView); + + // Bright current/highlighted line + tpCurrView = makeTextView("", 30, true, Color.WHITE); + tpCurrView.setPadding(0, 0, 0, 12); + root.addView(tpCurrView); + + // Dimmed next line + tpNextView = makeTextView("", 20, false, Color.argb(160, 200, 200, 200)); + root.addView(tpNextView); + + // Note: teleprompter is a fixed 3-line layout, so it's added directly + // (not scroll-wrapped) — there's no growing/overflowing content here. + addOverlayView(root); + teleprompterActive = true; + Log.d(TAG, "📺 Teleprompter overlay built"); + } + + // ----------------------------------------------------------------------- + // Shared overlay helpers + // ----------------------------------------------------------------------- + + /** + * Wraps content in a ScrollView so it can be measured at its full natural + * height (even when taller than the screen) rather than being clamped — + * and therefore silently bottom-clipped — to the window's height. + * fillViewport keeps the original centered look when content is short + * enough to fit on its own. + */ + private ScrollView wrapInScrollableContainer(LinearLayout content) { + content.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + ScrollView scrollView = new ScrollView(context); + scrollView.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + scrollView.setFillViewport(true); + scrollView.setVerticalScrollBarEnabled(false); + scrollView.addView(content); + return scrollView; + } + + /** + * Scrolls to the bottom once the view has gone through layout, so the + * tail of the message — the newest lines — is what stays visible if the + * content is taller than the screen. Older/top lines scroll out of view + * instead of the bottom getting clipped. + */ + private void scrollToBottomAfterLayout(ScrollView scrollView) { + scrollView.post(() -> scrollView.fullScroll(View.FOCUS_DOWN)); + } + + private void addOverlayView(View root) { + int overlayType = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; + + WindowManager.LayoutParams params = new WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + overlayType, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, + android.graphics.PixelFormat.TRANSLUCENT + ); + params.gravity = Gravity.TOP | Gravity.START; + + windowManager.addView(root, params); + overlayView = root; + } + + private void clearOverlay() { + mainHandler.post(this::removeOverlay); + } + + private void removeOverlay() { + if (overlayView != null) { + try { + windowManager.removeView(overlayView); + Log.d(TAG, "📺 Overlay removed"); + } catch (Exception e) { + Log.w(TAG, "⚠️ Error removing overlay: " + e.getMessage()); + } finally { + overlayView = null; + teleprompterActive = false; + tpPrevView = null; + tpCurrView = null; + tpNextView = null; + } + } + } + + private TextView makeTextView(String text, int sizeSp, boolean bold, int color) { + TextView tv = new TextView(context); + tv.setText(text); + tv.setTextColor(color); + tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, sizeSp); + tv.setGravity(Gravity.CENTER); + if (bold) tv.setTypeface(null, Typeface.BOLD); + tv.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + return tv; + } +} \ No newline at end of file diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/NoOpCommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/NoOpCommandHandler.java new file mode 100644 index 0000000000..dbdad74aaf --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/NoOpCommandHandler.java @@ -0,0 +1,24 @@ +package com.mentra.asg_client.service.core.handlers; + +import android.util.Log; +import com.mentra.asg_client.service.legacy.interfaces.ICommandHandler; +import org.json.JSONObject; +import java.util.Set; + +public class NoOpCommandHandler implements ICommandHandler { + private static final String TAG = "NoOpCommandHandler"; + private final Set types; + + public NoOpCommandHandler(Set types) { + this.types = types; + } + + @Override + public Set getSupportedCommandTypes() { return types; } + + @Override + public boolean handleCommand(String commandType, JSONObject data) { + Log.d(TAG, "⏭️ No-op for: " + commandType); + return true; + } +} \ No newline at end of file diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/ServiceHeartbeatCommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/ServiceHeartbeatCommandHandler.java index 4c2f074245..d86c50b3f8 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/ServiceHeartbeatCommandHandler.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/ServiceHeartbeatCommandHandler.java @@ -10,12 +10,14 @@ import java.util.Set; /** - * Handler for service heartbeat commands from MentraLiveSGC. - * Follows Single Responsibility Principle by handling only service heartbeat commands. + * Handler for heartbeat commands. + * Handles both "service_heartbeat" (from MentraLiveSGC) and + * "heartbeat" (from iOS companion app via InmoGo2.swift). + * Resets the 35-second connection timeout on the glasses side. */ public class ServiceHeartbeatCommandHandler implements ICommandHandler { private static final String TAG = "ServiceHeartbeatCommandHandler"; - + private final AsgClientServiceManager serviceManager; public ServiceHeartbeatCommandHandler(AsgClientServiceManager serviceManager) { @@ -24,7 +26,7 @@ public ServiceHeartbeatCommandHandler(AsgClientServiceManager serviceManager) { @Override public Set getSupportedCommandTypes() { - return Set.of("service_heartbeat"); + return Set.of("service_heartbeat", "heartbeat"); } @Override @@ -32,39 +34,39 @@ public boolean handleCommand(String commandType, JSONObject data) { try { switch (commandType) { case "service_heartbeat": - return handleServiceHeartbeat(data); + case "heartbeat": + return handleHeartbeat(commandType, data); default: - Log.e(TAG, "Unsupported service heartbeat command: " + commandType); + Log.e(TAG, "Unsupported heartbeat command: " + commandType); return false; } } catch (Exception e) { - Log.e(TAG, "Error handling service heartbeat command: " + commandType, e); + Log.e(TAG, "Error handling heartbeat command: " + commandType, e); return false; } } - /** - * Handle service heartbeat command from MentraLiveSGC - */ - private boolean handleServiceHeartbeat(JSONObject data) { + private boolean handleHeartbeat(String commandType, JSONObject data) { try { - // Extract heartbeat information long timestamp = data.optLong("timestamp", System.currentTimeMillis()); - int heartbeatCounter = data.optInt("heartbeat_counter", -1); - - Log.d(TAG, "💓 Service heartbeat #" + heartbeatCounter + " received at " + timestamp); - - // Notify AsgClientService about heartbeat to reset timeout + int counter = data.optInt("heartbeat_counter", -1); + + if (counter != -1) { + Log.d(TAG, "💓 " + commandType + " #" + counter + " at " + timestamp); + } else { + Log.d(TAG, "💓 " + commandType + " at " + timestamp); + } + if (serviceManager != null) { serviceManager.onServiceHeartbeatReceived(); return true; } else { - Log.e(TAG, "❌ ServiceManager is null - cannot process heartbeat"); + Log.e(TAG, "❌ ServiceManager is null - cannot reset heartbeat timeout"); return false; } } catch (Exception e) { - Log.e(TAG, "💥 Error handling service heartbeat command", e); + Log.e(TAG, "💥 Error handling heartbeat", e); return false; } } -} +} \ No newline at end of file diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/TeleprompterCommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/TeleprompterCommandHandler.java new file mode 100644 index 0000000000..a45043b0d4 --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/TeleprompterCommandHandler.java @@ -0,0 +1,143 @@ +package com.mentra.asg_client.service.core.handlers; + +import android.content.Context; +import android.util.Log; + +import com.mentra.asg_client.service.legacy.interfaces.ICommandHandler; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileWriter; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Handles teleprompter script transfer from the iOS companion app: + * teleprompter_script_chunk — one chunk of lines for a script, reassembled + * and persisted to local storage so the + * teleprompter can run standalone without the + * phone. + * + * Scripts are stored as JSON files under: + * /teleprompter/.json + * { "scriptId": "...", "lines": ["line1", "line2", ...] } + */ +public class TeleprompterCommandHandler implements ICommandHandler { + + private static final String TAG = "TeleprompterCmdHandler"; + private static final String STORAGE_DIR = "teleprompter"; + + private final Context context; + + // In-progress reassembly buffers, keyed by scriptId + private final Map buffers = new HashMap<>(); + + private static class ScriptBuffer { + final int totalChunks; + final String[] chunks; // serialized JSON line-arrays per chunk index + int receivedCount = 0; + + ScriptBuffer(int totalChunks) { + this.totalChunks = totalChunks; + this.chunks = new String[totalChunks]; + } + } + + public TeleprompterCommandHandler(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public Set getSupportedCommandTypes() { + return Set.of("teleprompter_script_chunk"); + } + + @Override + public boolean handleCommand(String commandType, JSONObject data) { + if (!"teleprompter_script_chunk".equals(commandType)) { + return false; + } + try { + if (data == null) { + Log.w(TAG, "⚠️ teleprompter_script_chunk missing data"); + return false; + } + + String scriptId = data.optString("scriptId", ""); + int chunkIndex = data.optInt("chunkIndex", -1); + int totalChunks = data.optInt("totalChunks", -1); + JSONArray lines = data.optJSONArray("lines"); + + if (scriptId.isEmpty() || chunkIndex < 0 || totalChunks <= 0 || lines == null) { + Log.w(TAG, "⚠️ teleprompter_script_chunk invalid payload: scriptId=" + scriptId + + " chunkIndex=" + chunkIndex + " totalChunks=" + totalChunks); + return false; + } + + Log.d(TAG, "📜 teleprompter_script_chunk scriptId=" + scriptId + + " chunk=" + (chunkIndex + 1) + "/" + totalChunks + + " lines=" + lines.length()); + + ScriptBuffer buffer = buffers.get(scriptId); + if (buffer == null || buffer.totalChunks != totalChunks) { + buffer = new ScriptBuffer(totalChunks); + buffers.put(scriptId, buffer); + } + + if (buffer.chunks[chunkIndex] == null) { + buffer.receivedCount++; + } + buffer.chunks[chunkIndex] = lines.toString(); + + if (buffer.receivedCount == buffer.totalChunks) { + assembleAndSave(scriptId, buffer); + buffers.remove(scriptId); + } + + return true; + } catch (Exception e) { + Log.e(TAG, "💥 Error handling teleprompter_script_chunk", e); + return false; + } + } + + // ----------------------------------------------------------------------- + // Assembly & persistence + // ----------------------------------------------------------------------- + + private void assembleAndSave(String scriptId, ScriptBuffer buffer) { + try { + JSONArray allLines = new JSONArray(); + for (int i = 0; i < buffer.totalChunks; i++) { + JSONArray chunkLines = new JSONArray(buffer.chunks[i]); + for (int j = 0; j < chunkLines.length(); j++) { + allLines.put(chunkLines.getString(j)); + } + } + + JSONObject script = new JSONObject(); + script.put("scriptId", scriptId); + script.put("lines", allLines); + + File dir = new File(context.getFilesDir(), STORAGE_DIR); + if (!dir.exists() && !dir.mkdirs()) { + Log.e(TAG, "💥 Failed to create teleprompter storage dir: " + dir.getAbsolutePath()); + return; + } + + File file = new File(dir, scriptId + ".json"); + try (FileWriter writer = new FileWriter(file)) { + writer.write(script.toString()); + } + + Log.i(TAG, "✅ Saved teleprompter script '" + scriptId + "' (" + + allLines.length() + " lines) to " + file.getAbsolutePath()); + + } catch (Exception e) { + Log.e(TAG, "💥 Error assembling/saving teleprompter script " + scriptId, e); + } + } +} diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java index d7fdd68601..0a59925e1e 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java @@ -32,11 +32,12 @@ import com.mentra.asg_client.service.core.handlers.UserEmailCommandHandler; import com.mentra.asg_client.service.core.handlers.UploadIncidentLogsCommandHandler; import com.mentra.asg_client.reporting.core.ReportManager; - +import com.mentra.asg_client.service.core.handlers.DisplayCommandHandler; import org.json.JSONObject; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import java.util.Set; /** * CommandProcessor - Orchestrates command processing following SOLID principles. @@ -328,7 +329,7 @@ private void initializeCommandHandlers() { commandHandlerRegistry.registerHandler(new WifiCommandHandler(serviceManager, communicationManager, stateManager)); Log.d(TAG, "✅ Registered WifiCommandHandler"); - commandHandlerRegistry.registerHandler(new BatteryCommandHandler(stateManager)); + commandHandlerRegistry.registerHandler(new BatteryCommandHandler(stateManager, communicationManager, context)); Log.d(TAG, "✅ Registered BatteryCommandHandler"); commandHandlerRegistry.registerHandler(new VersionCommandHandler(serviceManager)); @@ -373,6 +374,16 @@ private void initializeCommandHandlers() { Log.i(TAG, "✅ Successfully registered " + commandHandlerRegistry.getHandlerCount() + " command handlers"); + commandHandlerRegistry.registerHandler(new DisplayCommandHandler(context)); + Log.d(TAG, "✅ Registered DisplayCommandHandler"); + + // No-op handler for commands that don't need processing on Android side + commandHandlerRegistry.registerHandler(new com.mentra.asg_client.service.core.handlers.NoOpCommandHandler( + Set.of("set_touch_event_reporting", "button_camera_led", + "camera_fov_setting", "set_mic_enabled") + )); + Log.d(TAG, "✅ Registered NoOpCommandHandler"); + } catch (Exception e) { Log.e(TAG, "💥 Error during command handler initialization", e); } diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/utils/ServiceUtils.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/utils/ServiceUtils.java index dc8e476e67..af56925e17 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/utils/ServiceUtils.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/utils/ServiceUtils.java @@ -33,6 +33,64 @@ public static boolean isValidJsonMessage(byte[] data) { return data.length > 0 && data[0] == '{'; } + // ========================================================================= + // INMO Go2 Device Detection + // ========================================================================= + + /** + * Check whether the device is an INMO Go2. + * + * Relevant build properties (from adb getprop on firmware Go2_B_V1.8.021): + * ro.product.manufacturer = INMO + * ro.product.model = Go2 + * ro.product.device = ima02_go2 + * ro.product.name = ima02_go2fu + * ro.boot.hardware = ima02_go2 + * ro.build.fingerprint = INMO/ima02_go2fu/ima02_go2:9/PPR1.180610.011/... + * + * @param context Application context (may be null) + * @return true if the running device is an INMO Go2 + */ + public static boolean isInmoGo2Device(Context context) { + try { + final String manufacturer = android.os.Build.MANUFACTURER; + final String model = android.os.Build.MODEL; + final String device = android.os.Build.DEVICE; + final String product = android.os.Build.PRODUCT; + final String hardware = android.os.Build.HARDWARE; + final String fingerprint = android.os.Build.FINGERPRINT; + + Log.d(TAG, "INMO Go2 detection — MANUFACTURER:" + manufacturer + + " MODEL:" + model + " DEVICE:" + device + + " PRODUCT:" + product + " HARDWARE:" + hardware); + + // Primary check: manufacturer is INMO and model is Go2 (exact, case-insensitive) + if (manufacturer != null && manufacturer.equalsIgnoreCase("INMO") + && model != null && model.equalsIgnoreCase("Go2")) { + Log.i(TAG, "INMO Go2 detected via MANUFACTURER+MODEL"); + return true; + } + + // Fallback: any build property contains the ima02_go2 device identifier + String[] propsToCheck = {device, product, hardware, fingerprint}; + for (String prop : propsToCheck) { + if (prop != null && prop.toLowerCase().contains("ima02_go2")) { + Log.i(TAG, "INMO Go2 detected via property: " + prop); + return true; + } + } + + Log.d(TAG, "Not an INMO Go2 device"); + return false; + + } catch (Exception e) { + Log.e(TAG, "Error detecting INMO Go2 device", e); + return false; + } + } + + // ========================================================================= + /** * Check if data is a K900 protocol message * @param data Data to check @@ -311,6 +369,13 @@ public static int determineDefaultRotationForDevice(Context context) { if (prop != null) { String propLower = prop.toLowerCase(); + // INMO Go2: camera sensor is mounted with a 90° offset relative to the + // display, matching the same physical layout as MentraLive. + if (propLower.contains("ima02_go2") || propLower.contains("inmo go2")) { + Log.i(TAG, "INMO Go2 detected - using 90° rotation"); + return 90; + } + // MentraLive devices need 90° device rotation (maps to 0° JPEG orientation) if (propLower.contains("mentralive")) { Log.i(TAG, "MentraLive detected - using 90° rotation"); @@ -357,7 +422,11 @@ public static String getDeviceTypeString(Context context) { try { String model = android.os.Build.MODEL; String product = android.os.Build.PRODUCT; - + + if (isInmoGo2Device(context)) { + return "INMO Go2"; + } + if (isK900Device(context)) { // Try to determine specific K900 variant String[] propsToCheck = {model, product, android.os.Build.DISPLAY}; diff --git a/asg_client/app/src/main/res/layout/activity_main.xml b/asg_client/app/src/main/res/layout/activity_main.xml index 199bac291e..4fc72380f6 100644 --- a/asg_client/app/src/main/res/layout/activity_main.xml +++ b/asg_client/app/src/main/res/layout/activity_main.xml @@ -10,40 +10,15 @@ android:background="#000000" tools:context="com.mentra.asg_client.MainActivity"> - - - - -