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">
-
-
-
-
-
+
\ No newline at end of file
diff --git a/asg_client/settings.gradle b/asg_client/settings.gradle
index 7e2e383d20..e6203e37a7 100644
--- a/asg_client/settings.gradle
+++ b/asg_client/settings.gradle
@@ -16,12 +16,5 @@ dependencyResolutionManagement {
rootProject.name = "AugmentOS ASG Client"
include ':app'
-// StreamPackLite modules
-include ':core'
-project(':core').projectDir = new File(rootProject.projectDir, 'StreamPackLite/core')
-
-include ':extension-rtmp'
-project(':extension-rtmp').projectDir = new File(rootProject.projectDir, 'StreamPackLite/extensions/rtmp')
-
-include ':extension-srt'
-project(':extension-srt').projectDir = new File(rootProject.projectDir, 'StreamPackLite/extensions/srt')
+// StreamPackLite local subproject references removed — using public Maven artifacts instead.
+// See app/build.gradle: io.github.thibaultbee:streampack:2.6.1 and extensions.
\ No newline at end of file
diff --git a/cloud/packages/types/src/capabilities/inmo-go2.ts b/cloud/packages/types/src/capabilities/inmo-go2.ts
new file mode 100644
index 0000000000..ed9c4026b9
--- /dev/null
+++ b/cloud/packages/types/src/capabilities/inmo-go2.ts
@@ -0,0 +1,119 @@
+/**
+ * @fileoverview INMO Go2 Hardware Capabilities
+ *
+ * Capability profile for the INMO Go2 smart glasses.
+ *
+ * Hardware summary (from ro.product getprop):
+ * SoC : Unisoc UMS312 (Cortex-A55, armeabi-v7a)
+ * Camera : IMX471v1 rear-facing, 4 MP (confirmed via vendor.cam.sensor.info)
+ * Display : Waveguide monocular AR display (no bitmap rendering via ASG client)
+ * Microphone : Single built-in mic
+ * Speaker : Single built-in speaker
+ * IMU : Accelerometer + Gyroscope (6-axis)
+ * Button : One physical button + capacitive touchpad
+ * WiFi : 2.4 GHz + 5 GHz (ro.wifi.sup_sprd = true)
+ */
+
+import type { Capabilities } from "../hardware";
+
+/**
+ * INMO Go2 capability profile
+ */
+export const inmoGo2: Capabilities = {
+ modelName: "INMO Go2",
+
+ // Camera — single rear-facing IMX471v1 (4 MP), supports video recording and streaming
+ hasCamera: true,
+ camera: {
+ resolution: { width: 2688, height: 1520 }, // IMX471v1 native (4 MP)
+ hasHDR: false,
+ hasFocus: true,
+ video: {
+ canRecord: true,
+ canStream: true,
+ supportedStreamTypes: ["rtmp"],
+ supportedResolutions: [
+ { width: 1920, height: 1080 },
+ { width: 1280, height: 720 },
+ { width: 640, height: 480 },
+ ],
+ },
+ },
+
+ // Display — monocular waveguide AR overlay; no bitmap rendering from phone side
+ hasDisplay: true,
+display: {
+ count: 1,
+ isColor: false,
+ color: "green",
+ canDisplayBitmap: true, // Android WindowManager can render bitmaps
+ maxTextLines: 10,
+ adjustBrightness: false, // Not yet implemented in ASG client
+ resolution: { width: 640, height: 480 }, // Go2 waveguide effective resolution
+ fieldOfView: { horizontal: 20, vertical: 15 }, // approx INMO Go2 FOV
+},
+
+ // Microphone — single built-in mic; LC3 audio not supported (no BES chip)
+ hasMicrophone: true,
+ microphone: {
+ count: 1,
+ hasVAD: false,
+ },
+
+ // Speaker — single speaker
+ hasSpeaker: true,
+ speaker: {
+ count: 1,
+ isPrivate: false,
+ },
+
+ // IMU — standard Android 6-axis (accelerometer + gyroscope)
+ hasIMU: true,
+ imu: {
+ axisCount: 6,
+ hasAccelerometer: true,
+ hasCompass: false,
+ hasGyroscope: true,
+ },
+
+ // Buttons — one physical button and one capacitive touchpad (side-mounted)
+ hasButton: true,
+ button: {
+ count: 2,
+ buttons: [
+ {
+ type: "press",
+ events: ["press", "double_press", "long_press"],
+ isCapacitive: false,
+ },
+ {
+ type: "swipe1d",
+ events: ["swipe_forward", "swipe_back", "press"],
+ isCapacitive: true,
+ },
+ ],
+ },
+
+ // Light — single torch/privacy LED (camera flash used as recording indicator)
+ hasLight: true,
+ light: {
+ count: 1,
+ lights: [
+ {
+ id: "privacy",
+ purpose: "privacy",
+ isFullColor: false,
+ color: "white",
+ position: "front_facing",
+ },
+ ],
+ },
+
+ // Power — no external battery case
+ power: {
+ hasExternalBattery: false,
+ },
+
+ // WiFi — dual-band supported (ro.wifi.sup_sprd.support5G = true)
+ hasWifi: true,
+};
diff --git a/cloud/packages/types/src/enums.ts b/cloud/packages/types/src/enums.ts
index 62fda432f3..77ca62adfd 100644
--- a/cloud/packages/types/src/enums.ts
+++ b/cloud/packages/types/src/enums.ts
@@ -36,6 +36,7 @@ export enum DeviceTypes {
Z100 = "Vuzix Z100",
NEX = "Mentra Display",
FRAME = "Brilliant Frame",
+ INMO_GO2 = "INMO Go2",
}
export enum ControllerTypes {
diff --git a/cloud/packages/types/src/hardware.ts b/cloud/packages/types/src/hardware.ts
index 40b0fe4385..c164872639 100644
--- a/cloud/packages/types/src/hardware.ts
+++ b/cloud/packages/types/src/hardware.ts
@@ -4,6 +4,7 @@
import { evenRealitiesG1 } from "./capabilities/even-realities-g1";
import { evenRealitiesG2 } from "./capabilities/even-realities-g2";
+import { inmoGo2 } from "./capabilities/inmo-go2";
import { mentraDisplay } from "./capabilities/mentra-display";
import { mentraLive } from "./capabilities/mentra-live";
import { simulatedGlasses } from "./capabilities/simulated-glasses";
@@ -159,6 +160,7 @@ export interface Capabilities {
export const HARDWARE_CAPABILITIES: Record = {
[evenRealitiesG1.modelName]: evenRealitiesG1,
[evenRealitiesG2.modelName]: evenRealitiesG2,
+ [inmoGo2.modelName]: inmoGo2,
[mentraDisplay.modelName]: mentraDisplay,
[mentraLive.modelName]: mentraLive,
[simulatedGlasses.modelName]: simulatedGlasses,
@@ -176,4 +178,4 @@ export const getModelCapabilities = (deviceType: DeviceTypes): Capabilities => {
};
// export * from "./capabilities"
-export { simulatedGlasses, evenRealitiesG1, evenRealitiesG2, mentraLive, vuzixZ100, mentraDisplay };
+export { simulatedGlasses, evenRealitiesG1, evenRealitiesG2, inmoGo2, mentraLive, vuzixZ100, mentraDisplay };
diff --git a/mobile/app.config.ts b/mobile/app.config.ts
index 52c9c726e7..49bfa57986 100644
--- a/mobile/app.config.ts
+++ b/mobile/app.config.ts
@@ -290,5 +290,10 @@ module.exports = ({config}: ConfigContext): Partial => {
tsconfigPaths: true,
typedRoutes: true,
},
+ extra: {
+ eas: {
+ projectId: "33572e40-ab2b-454f-b191-b106a66f0ea8",
+ },
+ },
}
}
diff --git a/mobile/modules/bluetooth-sdk/ios/BluetoothSdkModule.swift b/mobile/modules/bluetooth-sdk/ios/BluetoothSdkModule.swift
index ebaaf65168..123c60313a 100644
--- a/mobile/modules/bluetooth-sdk/ios/BluetoothSdkModule.swift
+++ b/mobile/modules/bluetooth-sdk/ios/BluetoothSdkModule.swift
@@ -492,7 +492,12 @@ public class CoreModule: Module, MentraBluetoothSDKDelegate {
let sdk = await MainActor.run { self.bluetoothSdk() }
try? await sdk.clearDisplay()
}
-
+
+ AsyncFunction("sendTeleprompterUpdate") { (lines: [String], highlightIndex: Int) in
+ let sdk = await MainActor.run { self.bluetoothSdk() }
+ try? await sdk.sendTeleprompterUpdate(lines: lines, highlightIndex: highlightIndex)
+ }
+
// MARK: - STT Model Management
AsyncFunction("setSttModelDetails") { (path: String, languageCode: String) in
diff --git a/mobile/modules/bluetooth-sdk/ios/Source/DeviceManager.swift b/mobile/modules/bluetooth-sdk/ios/Source/DeviceManager.swift
index b7de516e91..1add4086e5 100644
--- a/mobile/modules/bluetooth-sdk/ios/Source/DeviceManager.swift
+++ b/mobile/modules/bluetooth-sdk/ios/Source/DeviceManager.swift
@@ -690,6 +690,8 @@ struct ViewState {
sgc?.type = DeviceTypes.Z100 // Override type to Z100
} else if wearable.contains(DeviceTypes.FRAME) {
// sgc = FrameManager()
+ } else if wearable.contains(DeviceTypes.INMO_GO2) {
+ sgc = InmoGo2()
}
// update device model:
GlassesStore.shared.apply("glasses", "deviceModel", sgc?.type ?? "")
@@ -951,6 +953,10 @@ struct ViewState {
handleMach1Ready()
} else if defaultWearable.contains(DeviceTypes.Z100) {
handleMach1Ready() // Z100 uses same initialization as Mach1
+ } else if defaultWearable.contains(DeviceTypes.INMO_GO2) {
+ // INMO Go2: no device-specific post-ready hardware init needed.
+ // glasses_ready triggers all required setup via InmoGo2.handleGlassesReady().
+ Bridge.log("MAN: INMO Go2 ready — standard ASG client setup complete")
}
// check current audio device:
diff --git a/mobile/modules/bluetooth-sdk/ios/Source/MentraBluetoothSDK.swift b/mobile/modules/bluetooth-sdk/ios/Source/MentraBluetoothSDK.swift
index 83761b763c..c1ec7016f2 100644
--- a/mobile/modules/bluetooth-sdk/ios/Source/MentraBluetoothSDK.swift
+++ b/mobile/modules/bluetooth-sdk/ios/Source/MentraBluetoothSDK.swift
@@ -106,6 +106,7 @@ public enum DeviceModel: String {
case frame
case simulated
case r1
+ case inmoGo2
public var deviceType: String {
switch self {
@@ -127,6 +128,8 @@ public enum DeviceModel: String {
DeviceTypes.SIMULATED
case .r1:
ControllerTypes.R1
+ case .inmoGo2:
+ DeviceTypes.INMO_GO2
}
}
@@ -150,6 +153,8 @@ public enum DeviceModel: String {
.simulated
case ControllerTypes.R1:
.r1
+ case DeviceTypes.INMO_GO2:
+ .inmoGo2
default:
.mentraLive
}
@@ -2060,7 +2065,11 @@ public final class MentraBluetoothSDK {
public func clearDisplay() async throws {
CoreManager.shared.sgc?.clearDisplay()
}
-
+/// Sends a 3-line teleprompter window to the glasses overlay (no-cloud direct path).
+ public func sendTeleprompterUpdate(lines: [String], highlightIndex: Int) async throws {
+ CoreManager.shared.sgc?.sendTeleprompterUpdate(lines: lines, highlightIndex: highlightIndex)
+ }
+
public func showDashboard() {
CoreManager.shared.showDashboard()
}
diff --git a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/InmoGo2.swift b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/InmoGo2.swift
new file mode 100644
index 0000000000..f012c2bbac
--- /dev/null
+++ b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/InmoGo2.swift
@@ -0,0 +1,1292 @@
+//
+// InmoGo2.swift
+// MentraOS
+//
+// SGCManager implementation for INMO Go2 smart glasses.
+//
+// Hardware fingerprint:
+// ro.product.manufacturer = INMO
+// ro.product.model = Go2
+// ro.product.device = ima02_go2
+// ro.build.version.sdk = 28 (Android 9)
+// ro.product.cpu.abilist = armeabi-v7a,armeabi
+//
+// 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 (glasses → phone)
+// RX (Write) : 00004862-0000-1000-8000-00805f9b34fb (phone → glasses)
+// MTU : up to 247 bytes
+//
+// Wire format:
+// The Go2 runs standard Android (no BES2700 chip), so the ASG client
+// sends and receives plain UTF-8 JSON — no K900 ##...$$ binary framing.
+// `sendJson` encodes the JSON dict directly to UTF-8 and writes it on the
+// TX characteristic (withResponse).
+//
+
+import CoreBluetooth
+import Foundation
+import UIKit
+
+// MARK: - Main Manager Class
+
+@MainActor
+class InmoGo2: NSObject, SGCManager {
+
+ // -----------------------------------------------------------------------
+ // MARK: SGCManager identity
+ // -----------------------------------------------------------------------
+
+ var type = DeviceTypes.INMO_GO2 // "INMO Go2"
+ var hasMic = true
+ var connectionState: String = ConnTypes.DISCONNECTED
+
+ // -----------------------------------------------------------------------
+ // MARK: BLE UUIDs (confirmed from nRF Connect scan of production device)
+ // -----------------------------------------------------------------------
+
+ private let SERVICE_UUID = CBUUID(string: "00004860-0000-1000-8000-00805f9b34fb")
+ /// Notify characteristic — glasses → phone
+ private let TX_CHAR_UUID = CBUUID(string: "00004861-0000-1000-8000-00805f9b34fb")
+ /// Write characteristic — phone → glasses
+ private let RX_CHAR_UUID = CBUUID(string: "00004862-0000-1000-8000-00805f9b34fb")
+
+ // -----------------------------------------------------------------------
+ // MARK: Prefs keys
+ // -----------------------------------------------------------------------
+
+ private let PREFS_DEVICE_NAME = "InmoGo2LastConnectedDeviceName"
+ private let PREFS_DEVICE_UUID = "InmoGo2LastConnectedDeviceUUID"
+
+ // -----------------------------------------------------------------------
+ // MARK: BLE objects (iOS acts as central / client)
+ // -----------------------------------------------------------------------
+
+ private var centralManager: CBCentralManager?
+ private var connectedPeripheral: CBPeripheral?
+ private var txCharacteristic: CBCharacteristic? // Notify — data FROM glasses
+ private var rxCharacteristic: CBCharacteristic? // Write — data TO glasses
+
+ // -----------------------------------------------------------------------
+ // MARK: Timing constants
+ // -----------------------------------------------------------------------
+
+ private let BASE_RECONNECT_DELAY_NS: UInt64 = 1_000_000_000 // 1 s
+ private let MAX_RECONNECT_DELAY_NS: UInt64 = 30_000_000_000 // 30 s
+ private let MAX_RECONNECT_ATTEMPTS = 10
+ private let CONNECTION_TIMEOUT_NS: UInt64 = 100_000_000_000 // 100 s
+ private let HEARTBEAT_INTERVAL: TimeInterval = 30.0
+ private let READINESS_CHECK_INTERVAL: TimeInterval = 2.5
+
+ // -----------------------------------------------------------------------
+ // MARK: State
+ // -----------------------------------------------------------------------
+
+ private var isScanning = false
+ private var isConnecting = false
+ private var isKilled = false
+ private var reconnectAttempts = 0
+ private var globalMessageId = 0
+
+ private var fullyBooted: Bool {
+ get { GlassesStore.shared.get("glasses", "fullyBooted") as? Bool ?? false }
+ set { GlassesStore.shared.apply("glasses", "fullyBooted", newValue) }
+ }
+ private var connected: Bool {
+ get { GlassesStore.shared.get("glasses", "connected") as? Bool ?? false }
+ set { GlassesStore.shared.apply("glasses", "connected", newValue) }
+ }
+
+ // Discovered peripherals cache (name → peripheral)
+ private var discoveredPeripherals = [String: CBPeripheral]()
+
+ // -----------------------------------------------------------------------
+ // MARK: Queues & queuing
+ // -----------------------------------------------------------------------
+
+ private let bluetoothQueue = DispatchQueue(label: "InmoGo2Bluetooth", qos: .userInitiated)
+ private let commandQueue = CommandQueue()
+ private var lastSendTimeMs: TimeInterval = 0
+
+ // Pending-message ACK tracking
+ private var pending: PendingMessage?
+ private var pendingMessageTimer: Timer?
+
+ // -----------------------------------------------------------------------
+ // MARK: Timers
+ // -----------------------------------------------------------------------
+
+ private var heartbeatTimer: Timer?
+ private var heartbeatCounter = 0
+ private var readinessCheckTimer: Timer?
+ private var readinessCheckCounter = 0
+ private var connectionTimeoutTimer: Timer?
+ private var reconnectionWorkItem: DispatchWorkItem?
+ private var readinessCheckDispatchTimer: DispatchSourceTimer?
+
+ // -----------------------------------------------------------------------
+ // MARK: Supporting types (mirrors MentraLive internal types)
+ // -----------------------------------------------------------------------
+
+ class PendingMessage {
+ let data: Data
+ let id: String
+ let retries: Int
+ init(data: Data, id: String, retries: Int) {
+ self.data = data
+ self.id = id
+ self.retries = retries
+ }
+ }
+
+ actor CommandQueue {
+ private var commands: [PendingMessage] = []
+ func enqueue(_ cmd: PendingMessage) { commands.append(cmd) }
+ func pushToFront(_ cmd: PendingMessage) { commands.insert(cmd, at: 0) }
+ func dequeue() -> PendingMessage? {
+ guard !commands.isEmpty else { return nil }
+ return commands.removeFirst()
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Init / deinit
+ // -----------------------------------------------------------------------
+
+ override init() {
+ super.init()
+ setupCommandQueue()
+ Bridge.log("GO2: InmoGo2 SGC initialized")
+ }
+
+ deinit {
+ centralManager?.delegate = nil
+ connectedPeripheral?.delegate = nil
+ Bridge.log("GO2: InmoGo2 deinitialized")
+ }
+
+ func cleanup() { destroy() }
+
+ // -----------------------------------------------------------------------
+ // MARK: Command queue pump
+ // -----------------------------------------------------------------------
+
+ private func setupCommandQueue() {
+ Task.detached { [weak self] in
+ guard let self else { return }
+ while true {
+ let pendingIsNil = await MainActor.run { self.pending == nil }
+ if pendingIsNil {
+ if let cmd = await self.commandQueue.dequeue() {
+ await self.processSendQueue(cmd)
+ }
+ }
+ try? await Task.sleep(nanoseconds: 100_000_000) // 100 ms
+ }
+ }
+ }
+
+ private func processSendQueue(_ message: PendingMessage) async {
+ guard let peripheral = connectedPeripheral,
+ let rxChar = rxCharacteristic else { return }
+
+ try? await Task.sleep(nanoseconds: 1_000_000) // 1 ms pacing
+ lastSendTimeMs = Date().timeIntervalSince1970 * 1000
+
+ peripheral.writeValue(message.data, for: rxChar, type: .withResponse)
+
+ if message.id != "-1" {
+ pending = message
+ DispatchQueue.main.async { [weak self] in
+ self?.pendingMessageTimer?.invalidate()
+ self?.pendingMessageTimer = Timer.scheduledTimer(
+ withTimeInterval: 1, repeats: false
+ ) { [weak self] _ in self?.handlePendingMessageTimeout() }
+ }
+ }
+ }
+
+ private func handlePendingMessageTimeout() {
+ guard let pendingMessage = pending else { return }
+ Bridge.log("GO2: ⚠️ Message timeout mId=\(pendingMessage.id), retry \(pendingMessage.retries + 1)/3")
+ pending = nil
+ if pendingMessage.retries < 3 {
+ let retry = PendingMessage(data: pendingMessage.data, id: pendingMessage.id, retries: pendingMessage.retries + 1)
+ Task { await commandQueue.pushToFront(retry) }
+ } else {
+ Bridge.log("GO2: ❌ Message failed after 3 retries mId=\(pendingMessage.id)")
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: SGCManager stubs (display / controller — not applicable to Go2)
+ // -----------------------------------------------------------------------
+
+ func setDashboardPosition(_: Int, _: Int) {}
+ func setSilentMode(_: Bool) {}
+ func exit() {}
+ func showDashboard() {}
+ func displayBitmap(base64ImageData _: String) async -> Bool { return true }
+ func sendDoubleTextWall(_ top: String, _ bottom: String) { sendJson(["type": "double_text_wall", "topText": top, "bottomText": bottom]) }
+ func setHeadUpAngle(_: Int) {}
+ func getBatteryStatus() {}
+ func setBrightness(_: Int, autoMode _: Bool) {}
+ func clearDisplay() { sendJson(["type": "clear_display"]) }
+ func sendTextWall(_ text: String) { sendJson(["type": "text_wall", "text": text]) }
+
+ /// Sends a 3-line teleprompter window to the glasses overlay.
+ /// `lines` should contain the visible window of script lines (1-3 entries),
+ /// and `highlightIndex` is the index within `lines` to render as the
+ /// bright/current line (defaults to the middle line on the Android side
+ /// if omitted).
+ func sendTeleprompterUpdate(lines: [String], highlightIndex: Int) {
+ sendJson([
+ "type": "teleprompter_update",
+ "lines": lines,
+ "highlightIndex": highlightIndex,
+ ], requireAck: false)
+ }
+
+ func connectController() {}
+ func disconnectController() {}
+ func dbg1() {}
+ func dbg2() {}
+ func sortMicRanking(list: [String]) -> [String] { return list }
+
+ func ping() {
+ Bridge.log("GO2: ping()")
+ keepAwake()
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Missing SGCManager protocol stubs
+ // -----------------------------------------------------------------------
+
+ func sendButtonCameraLedSetting() {
+ let enabled = GlassesStore.shared.get("core", "button_camera_led") as? Bool ?? true
+ sendJson(["type": "button_camera_led", "enabled": enabled], wakeUp: true)
+ }
+
+ func sendCameraFovSetting() {
+ let settings = GlassesStore.shared.get("core", "camera_fov") as? [String: Any]
+ ?? ["fov": 118, "roi_position": 0]
+ let fov = settings["fov"] as? Int ?? 118
+ let roiPosition = settings["roi_position"] as? Int ?? 0
+ sendJson([
+ "type": "camera_fov_setting",
+ "params": ["fov": fov, "roi_position": roiPosition],
+ ], wakeUp: true)
+ }
+
+ func sendButtonMaxRecordingTime() {
+ let maxTime = GlassesStore.shared.get("core", "button_max_recording_time") as? Int ?? 10
+ sendButtonMaxRecordingTime(maxTime)
+ }
+
+ func sendRgbLedControl(
+ requestId _: String, packageName _: String?, action _: String,
+ color _: String?, ontime _: Int, offtime _: Int, count _: Int
+ ) {
+ Bridge.log("GO2: sendRgbLedControl — no RGB LED on INMO Go2, ignoring")
+ }
+
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) {
+ var base = (apiBaseUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if base.isEmpty { base = "https://api.mentra.glass" }
+ while base.hasSuffix("/") { base = String(base.dropLast()) }
+ sendJson([
+ "type": "upload_incident_logs",
+ "incidentId": incidentId,
+ "apiBaseUrl": base,
+ ], wakeUp: true)
+ }
+
+ func queryGalleryStatus() {
+ sendJson(["type": "query_gallery_status"], wakeUp: true)
+ }
+
+ func sendGalleryMode() {
+ let active = GlassesStore.shared.get("core", "gallery_mode") as? Bool ?? false
+ sendJson([
+ "type": "save_in_gallery_mode",
+ "active": active,
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
+ ], wakeUp: true)
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Microphone
+ // -----------------------------------------------------------------------
+
+ func setMicEnabled(_ enabled: Bool) {
+ Bridge.log("GO2: 🎤 setMicEnabled(\(enabled))")
+ GlassesStore.shared.apply("glasses", "micEnabled", enabled)
+ let json: [String: Any] = ["type": "set_mic_enabled", "enabled": enabled]
+ sendJson(json, wakeUp: true)
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Scanning / connection lifecycle
+ // -----------------------------------------------------------------------
+
+ func findCompatibleDevices() {
+ Bridge.log("GO2: findCompatibleDevices()")
+ Task {
+ if centralManager == nil {
+ centralManager = CBCentralManager(
+ delegate: self, queue: bluetoothQueue,
+ options: ["CBCentralManagerOptionShowPowerAlertKey": 0]
+ )
+ try? await Task.sleep(nanoseconds: 100 * 1_000_000)
+ }
+ // Clear stored pairing so we do a fresh scan for new device discovery
+ UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME)
+ UserDefaults.standard.set("", forKey: PREFS_DEVICE_UUID)
+ startScan()
+ }
+ }
+
+ func connectById(_ deviceName: String) {
+ Bridge.log("GO2: connectById(\(deviceName))")
+ UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
+
+ if centralManager == nil {
+ centralManager = CBCentralManager(
+ delegate: self, queue: bluetoothQueue,
+ options: ["CBCentralManagerOptionShowPowerAlertKey": 0]
+ )
+ }
+
+ guard let cm = centralManager else { return }
+
+ // 1. Check for already-connected system peripherals (active GATT connections)
+ let connectedPeripherals = cm.retrieveConnectedPeripherals(withServices: [SERVICE_UUID])
+ for peripheral in connectedPeripherals {
+ if let name = peripheral.name, isCompatibleDeviceName(name) {
+ Bridge.log("GO2: Found already-connected peripheral: \(name)")
+ discoveredPeripherals[name] = peripheral
+ emitDiscoveredDevice(name, identifier: peripheral.identifier.uuidString)
+ if name == deviceName {
+ connectToDevice(peripheral)
+ return
+ }
+ }
+ }
+
+ // 2. Try reconnect by stored UUID — handles bonded devices that are
+ // suppressed from scan results by iOS after OS-level pairing
+ if let uuidString = UserDefaults.standard.string(forKey: PREFS_DEVICE_UUID),
+ !uuidString.isEmpty,
+ let uuid = UUID(uuidString: uuidString)
+ {
+ let known = cm.retrievePeripherals(withIdentifiers: [uuid])
+ if let peripheral = known.first {
+ Bridge.log("GO2: Reconnecting via stored UUID: \(uuidString)")
+ discoveredPeripherals[peripheral.name ?? deviceName] = peripheral
+ // Tell scan screen to auto-proceed without waiting for user selection
+ Bridge.sendDiscoveredDevice(
+ DeviceTypes.INMO_GO2, "NOTREQUIREDSKIP",
+ deviceAddress: uuidString, rssi: nil
+ )
+ connectToDevice(peripheral)
+ return
+ }
+ }
+
+ // 3. Fall back to BLE scan (first-time pairing)
+ startScan()
+ }
+
+ func getConnectedBluetoothName() -> String? {
+ return connectedPeripheral?.name
+ }
+
+ func forget() {
+ Bridge.log("GO2: forget()")
+ if isScanning { stopScan(); emitStopScanEvent() }
+ // Clear stored pairing so next launch does a fresh scan
+ UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME)
+ UserDefaults.standard.set("", forKey: PREFS_DEVICE_UUID)
+ destroy()
+ }
+
+ @objc func disconnect() {
+ Bridge.log("GO2: disconnect()")
+ destroy()
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Scan internals
+ // -----------------------------------------------------------------------
+
+ private func startScan() {
+ guard let cm = centralManager, cm.state == .poweredOn else {
+ Bridge.log("GO2: Cannot scan — Bluetooth not powered on")
+ return
+ }
+ Bridge.log("GO2: Starting BLE scan for INMO GO2")
+ isScanning = true
+ startReadinessCheckLoop()
+
+ cm.scanForPeripherals(
+ withServices: nil,
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
+ )
+
+ // Emit already-cached peripherals
+ for (_, peripheral) in discoveredPeripherals {
+ Bridge.log("GO2: (already discovered) \(peripheral.name ?? "Unknown")")
+ emitDiscoveredDevice(peripheral.name!)
+ }
+ }
+
+ func stopScan() {
+ guard isScanning else { return }
+ centralManager?.stopScan()
+ isScanning = false
+ GlassesStore.shared.apply(ObservableStore.coreCategory, "searching", false)
+ Bridge.log("GO2: BLE scan stopped")
+ }
+
+ private func isCompatibleDeviceName(_ name: String) -> Bool {
+ let n = name.uppercased()
+ return n == "INMO GO2" || n == "INMO_GO2"
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Connection internals
+ // -----------------------------------------------------------------------
+
+ private func connectToDevice(_ peripheral: CBPeripheral) {
+ Bridge.log("GO2: Connecting to \(peripheral.identifier.uuidString)")
+ isConnecting = true
+ updateConnectionState(ConnTypes.CONNECTING)
+ connectedPeripheral = peripheral
+ peripheral.delegate = self
+ startConnectionTimeout()
+ centralManager?.connect(peripheral, options: nil)
+ }
+
+ private func handleReconnection() {
+ guard !isKilled else {
+ Bridge.log("GO2: Reconnection aborted — device killed")
+ return
+ }
+ if reconnectAttempts >= MAX_RECONNECT_ATTEMPTS {
+ Bridge.log("GO2: Max reconnection attempts reached")
+ reconnectAttempts = 0
+ updateConnectionState(ConnTypes.DISCONNECTED)
+ connected = false
+ fullyBooted = false
+ return
+ }
+
+ let delayNs = min(
+ BASE_RECONNECT_DELAY_NS * UInt64(1 << reconnectAttempts),
+ MAX_RECONNECT_DELAY_NS
+ )
+ reconnectAttempts += 1
+ updateConnectionState(ConnTypes.CONNECTING)
+
+ Bridge.log("GO2: Reconnect attempt \(reconnectAttempts) in \(Double(delayNs) / 1e9)s")
+
+ let workItem = DispatchWorkItem { [weak self] in
+ guard let self, self.connectedPeripheral == nil, !self.isKilled else { return }
+
+ // Try UUID-based reconnect first before falling back to scan
+ if let uuidString = UserDefaults.standard.string(forKey: self.PREFS_DEVICE_UUID),
+ !uuidString.isEmpty,
+ let uuid = UUID(uuidString: uuidString),
+ let cm = self.centralManager
+ {
+ let known = cm.retrievePeripherals(withIdentifiers: [uuid])
+ if let peripheral = known.first {
+ Bridge.log("GO2: Reconnect via stored UUID: \(uuidString)")
+ self.connectToDevice(peripheral)
+ return
+ }
+ }
+
+ if let lastName = UserDefaults.standard.string(forKey: self.PREFS_DEVICE_NAME),
+ !lastName.isEmpty
+ {
+ self.startScan()
+ } else {
+ self.updateConnectionState(ConnTypes.DISCONNECTED)
+ }
+ }
+ reconnectionWorkItem = workItem
+ DispatchQueue.main.asyncAfter(
+ deadline: .now() + .nanoseconds(Int(delayNs)), execute: workItem
+ )
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: sendJson (plain UTF-8 JSON — no K900 framing)
+ // -----------------------------------------------------------------------
+
+ func sendJson(_ json: [String: Any], wakeUp: Bool = false, requireAck: Bool = true) {
+ do {
+ var payload = json
+ var trackingId = "-1"
+
+ if requireAck {
+ payload["mId"] = globalMessageId
+ trackingId = String(globalMessageId)
+ globalMessageId += 1
+ }
+
+ let data = try JSONSerialization.data(withJSONObject: payload)
+ queueSend(data, id: trackingId)
+
+ if let dbg = String(data: data, encoding: .utf8) {
+ Bridge.log("GO2: → \(dbg.prefix(200))")
+ }
+ } catch {
+ Bridge.log("GO2: sendJson error: \(error)")
+ }
+ }
+
+ func queueSend(_ data: Data, id: String) {
+ Task { await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0)) }
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Incoming data processing
+ // -----------------------------------------------------------------------
+
+ private func processReceivedData(_ data: Data) {
+ guard !data.isEmpty else { return }
+ let bytes = [UInt8](data)
+
+ if bytes[0] == 0x7B,
+ let jsonString = String(data: data, encoding: .utf8),
+ jsonString.hasPrefix("{")
+ {
+ processJsonMessage(jsonString)
+ } else {
+ Bridge.log("GO2: ⚠️ Unexpected non-JSON data (\(data.count) bytes)")
+ }
+ }
+
+ private func processJsonMessage(_ jsonString: String) {
+ do {
+ guard let data = jsonString.data(using: .utf8),
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else { return }
+ processJsonObject(json)
+ } catch {
+ Bridge.log("GO2: JSON parse error: \(error)")
+ }
+ }
+
+ private func processJsonObject(_ json: [String: Any]) {
+ if let type = json["type"] as? String, type == "msg_ack" {
+ if let mId = json["mId"] as? Int, String(mId) == pending?.id {
+ Bridge.log("GO2: ✅ ACK for mId=\(mId)")
+ pending = nil
+ pendingMessageTimer?.invalidate()
+ pendingMessageTimer = nil
+ }
+ return
+ }
+
+ if let mId = json["mId"] as? Int {
+ sendAckToGlasses(messageId: mId)
+ }
+
+ guard let type = json["type"] as? String else {
+ Bridge.log("GO2: JSON missing 'type' field — ignoring")
+ return
+ }
+
+ switch type {
+
+ case "glasses_ready":
+ handleGlassesReady()
+
+ case "battery_status":
+ let level = json["level"] as? Int ?? 0
+ let isCharging = json["charging"] as? Bool ?? false
+ updateBatteryStatus(level: level, isCharging: isCharging)
+
+ case "wifi_status":
+ let isConn = json["connected"] as? Bool ?? false
+ let ssid = json["ssid"] as? String ?? ""
+ let ip = json["local_ip"] as? String ?? ""
+ updateWifiStatus(connected: isConn, ssid: ssid, ip: ip)
+
+ case "wifi_scan_result":
+ if let networks = json["networks_neo"] as? [[String: Any]] {
+ Bridge.updateWifiScanResults(networks)
+ }
+
+ case "button_press":
+ let buttonId = json["buttonId"] as? String ?? "unknown"
+ let pressType = json["pressType"] as? String ?? "short"
+ Bridge.log("GO2: 🔘 Button press — id=\(buttonId) type=\(pressType)")
+ Bridge.sendButtonPress(buttonId: buttonId, pressType: pressType)
+
+ case "touch_event":
+ let gesture = json["gesture_name"] as? String ?? "unknown"
+ let timestamp = json["timestamp"] as? Int64 ?? Int64(Date().timeIntervalSince1970 * 1000)
+ Bridge.sendTouchEvent(
+ deviceModel: DeviceTypes.INMO_GO2,
+ gestureName: gesture,
+ timestamp: timestamp
+ )
+
+ case "version_info", "version_info_1", "version_info_2":
+ handleVersionInfo(json)
+
+ case "ota_status":
+ Bridge.sendTypedMessage("ota_status", body: json)
+
+ case "stream_status":
+ Bridge.sendTypedMessage("stream_status", body: json)
+
+ case "gallery_status":
+ Bridge.sendTypedMessage("gallery_status", body: json)
+
+ case "hotspot_status_update":
+ let enabled = json["hotspot_enabled"] as? Bool ?? false
+ let ssid = json["hotspot_ssid"] as? String ?? ""
+ let password = json["hotspot_password"] as? String ?? ""
+ let ip = json["hotspot_gateway_ip"] as? String ?? ""
+ updateHotspotStatus(enabled: enabled, ssid: ssid, password: password, ip: ip)
+
+ case "hotspot_error":
+ let msg = json["error_message"] as? String ?? "Unknown hotspot error"
+ let ts = json["timestamp"] as? Int64 ?? Int64(Date().timeIntervalSince1970 * 1000)
+ Bridge.sendTypedMessage("hotspot_error", body: ["error_message": msg, "timestamp": ts])
+
+ default:
+ Bridge.log("GO2: Unhandled message type: \(type)")
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Glasses-ready handler
+ // -----------------------------------------------------------------------
+
+ private func handleGlassesReady() {
+ Bridge.log("GO2: 🎉 glasses_ready received — SOC booted")
+
+ stopReadinessCheckLoop()
+
+ GlassesStore.shared.apply("glasses", "buildNumber", "")
+ GlassesStore.shared.apply("glasses", "appVersion", "")
+ GlassesStore.shared.apply("glasses", "besFwVersion", "")
+ GlassesStore.shared.apply("glasses", "mtkFwVersion", "")
+
+ requestBatteryStatus()
+ requestWifiStatus()
+ requestVersionInfo()
+ sendCoreTokenToAsgClient()
+ sendStoredUserEmailToAsgClient()
+ sendUserSettings()
+ setTouchEventReporting(true)
+
+ startHeartbeat()
+
+ fullyBooted = true
+ connected = true
+ updateConnectionState(ConnTypes.CONNECTED)
+ CoreManager.shared.handleDeviceReady()
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: ACK helpers
+ // -----------------------------------------------------------------------
+
+ private func sendAckToGlasses(messageId: Int) {
+ let ack: [String: Any] = ["type": "msg_ack", "mId": messageId]
+ sendJson(ack, requireAck: false)
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Status requests & outgoing commands
+ // -----------------------------------------------------------------------
+
+ private func requestBatteryStatus() {
+ sendJson(["type": "request_battery_status"], wakeUp: true)
+ }
+
+ private func requestWifiStatus() {
+ sendJson(["type": "request_wifi_status"], wakeUp: true)
+ }
+
+ func requestVersionInfo() {
+ sendJson(["type": "request_version"])
+ }
+
+ func keepAwake() {
+ sendJson(["type": "keep_awake", "timestamp": Int64(Date().timeIntervalSince1970 * 1000)], wakeUp: true)
+ }
+
+ func sendOtaStart() {
+ sendJson(["type": "ota_start", "timestamp": Int(Date().timeIntervalSince1970 * 1000)], wakeUp: true)
+ }
+
+ func sendOtaQueryStatus() {
+ sendJson(["type": "ota_query_status", "timestamp": Int(Date().timeIntervalSince1970 * 1000)], wakeUp: true)
+ }
+
+ func requestWifiScan() {
+ sendJson(["type": "request_wifi_scan"], wakeUp: true)
+ }
+
+ func sendWifiCredentials(_ ssid: String, _ password: String) {
+ guard !ssid.isEmpty else { return }
+ sendJson(["type": "set_wifi_credentials", "ssid": ssid, "password": password], wakeUp: true)
+ }
+
+ func sendHotspotState(_ enabled: Bool) {
+ sendJson(["type": "set_hotspot_state", "enabled": enabled], wakeUp: true)
+ }
+
+ func forgetWifiNetwork(_ ssid: String) {
+ guard !ssid.isEmpty else { return }
+ sendJson(["type": "forget_wifi", "ssid": ssid], wakeUp: true)
+ }
+
+ func sendUserEmailToGlasses(_ email: String) {
+ guard !email.isEmpty else { return }
+ sendJson(["type": "user_email", "email": email], wakeUp: true)
+ }
+
+ @objc func sendShutdown() {
+ sendJson(["type": "shutdown"])
+ }
+
+ @objc func sendReboot() {
+ sendJson(["type": "reboot"])
+ }
+
+ func requestPhoto(
+ _ requestId: String, appId: String, size: String?, webhookUrl: String?,
+ authToken: String?, compress: String?, flash: Bool, sound: Bool,
+ exposureTimeNs: Double?
+ ) {
+ var json: [String: Any] = [
+ "type": "take_photo",
+ "requestId": requestId,
+ "appId": appId,
+ "flash": flash,
+ "sound": sound,
+ ]
+ if let size, ["small", "medium", "large", "full"].contains(size) {
+ json["size"] = size
+ } else {
+ json["size"] = "medium"
+ }
+ json["compress"] = compress ?? "none"
+ if let wh = webhookUrl, !wh.isEmpty { json["webhookUrl"] = wh }
+ if let at = authToken, !at.isEmpty { json["authToken"] = at }
+ if let e = exposureTimeNs, e.isFinite, e > 0, e <= Double(Int64.max) {
+ json["exposureTimeNs"] = Int64(e)
+ }
+ sendJson(json, wakeUp: true)
+ }
+
+ func startStream(_ message: [String: Any]) {
+ var json = message; json.removeValue(forKey: "timestamp")
+ sendJson(json, wakeUp: true)
+ }
+
+ func stopStream() {
+ sendJson(["type": "stop_stream"], wakeUp: true)
+ }
+
+ func sendStreamKeepAlive(_ message: [String: Any]) {
+ sendJson(message)
+ }
+
+ func startVideoRecording(requestId: String, save: Bool, flash: Bool, sound: Bool) {
+ startVideoRecording(requestId: requestId, save: save, flash: flash, sound: sound, width: 0, height: 0, fps: 0)
+ }
+
+ func startVideoRecording(requestId: String, save: Bool, flash: Bool, sound: Bool, width: Int, height: Int, fps: Int) {
+ var json: [String: Any] = [
+ "type": "start_video_recording",
+ "request_id": requestId,
+ "save": save, "flash": flash, "sound": sound,
+ ]
+ if width > 0, height > 0 {
+ json["settings"] = ["width": width, "height": height, "fps": fps > 0 ? fps : 30]
+ }
+ sendJson(json)
+ }
+
+ func stopVideoRecording(requestId: String) {
+ sendJson(["type": "stop_video_recording", "request_id": requestId])
+ }
+
+ private func setTouchEventReporting(_ enabled: Bool) {
+ sendJson(["type": "set_touch_event_reporting", "enabled": enabled])
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: User settings
+ // -----------------------------------------------------------------------
+
+ private func sendUserSettings() {
+ sendButtonVideoRecordingSettings()
+ let maxTime = GlassesStore.shared.get("core", "button_max_recording_time") as? Int ?? 10
+ sendButtonMaxRecordingTime(maxTime)
+ sendButtonPhotoSettings()
+ }
+
+ func sendButtonVideoRecordingSettings() {
+ let settings = GlassesStore.shared.get("core", "button_video_settings") as? [String: Any]
+ ?? ["width": 1280, "height": 720, "fps": 30]
+ let width = (settings["width"] as? Int ?? 1280).clampedPositive(default: 1280)
+ let height = (settings["height"] as? Int ?? 720 ).clampedPositive(default: 720)
+ let fps = (settings["fps"] as? Int ?? 30 ).clampedPositive(default: 30)
+ sendJson([
+ "type": "button_video_recording_setting",
+ "params": ["width": width, "height": height, "fps": fps],
+ ], wakeUp: true)
+ }
+
+ func sendButtonMaxRecordingTime(_ minutes: Int) {
+ sendJson(["type": "button_max_recording_time", "minutes": minutes], wakeUp: true)
+ }
+
+ func sendButtonPhotoSettings() {
+ let size = GlassesStore.shared.get("core", "button_photo_size") as? String ?? "medium"
+ sendJson(["type": "button_photo_setting", "size": size], wakeUp: true)
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Auth / token propagation
+ // -----------------------------------------------------------------------
+
+ private func sendCoreTokenToAsgClient() {
+ let token = GlassesStore.shared.get("core", "core_token") as? String ?? ""
+ guard !token.isEmpty else { return }
+ sendJson([
+ "type": "auth_token",
+ "coreToken": token,
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
+ ])
+ }
+
+ private func sendStoredUserEmailToAsgClient() {
+ let email = GlassesStore.shared.store.get("core", "auth_email") as? String ?? ""
+ guard !email.isEmpty else { return }
+ sendUserEmailToGlasses(email)
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Heartbeat
+ // -----------------------------------------------------------------------
+
+ private func startHeartbeat() {
+ heartbeatCounter = 0
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ self.heartbeatTimer?.invalidate()
+ self.heartbeatTimer = Timer.scheduledTimer(
+ withTimeInterval: self.HEARTBEAT_INTERVAL, repeats: true
+ ) { [weak self] _ in self?.sendHeartbeat() }
+ }
+ }
+
+ private func stopHeartbeat() {
+ DispatchQueue.main.async { [weak self] in
+ self?.heartbeatTimer?.invalidate()
+ self?.heartbeatTimer = nil
+ }
+ }
+
+ private func sendHeartbeat() {
+ heartbeatCounter += 1
+ sendJson([
+ "type": "heartbeat",
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
+ ], requireAck: false)
+ if heartbeatCounter % 10 == 0 { requestBatteryStatus() }
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Readiness check loop
+ // -----------------------------------------------------------------------
+
+ private func startReadinessCheckLoop() {
+ readinessCheckCounter = 0
+ stopReadinessCheckLoop()
+ let timer = DispatchSource.makeTimerSource(queue: .main)
+ timer.schedule(deadline: .now() + READINESS_CHECK_INTERVAL, repeating: READINESS_CHECK_INTERVAL)
+ timer.setEventHandler { [weak self] in
+ guard let self else { return }
+ self.readinessCheckCounter += 1
+ if !self.fullyBooted, self.connectionState == ConnTypes.CONNECTING {
+ Bridge.log("GO2: 🔄 Readiness check \(self.readinessCheckCounter) — waiting for glasses_ready")
+ let json: [String: Any] = ["type": "phone_ready", "timestamp": Int64(Date().timeIntervalSince1970 * 1000)]
+ self.sendJson(json, wakeUp: true)
+ } else {
+ self.stopReadinessCheckLoop()
+ }
+ }
+ timer.resume()
+ readinessCheckDispatchTimer = timer
+ }
+
+ private func stopReadinessCheckLoop() {
+ readinessCheckDispatchTimer?.cancel()
+ readinessCheckDispatchTimer = nil
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Connection timeout
+ // -----------------------------------------------------------------------
+
+ private func startConnectionTimeout() {
+ connectionTimeoutTimer?.invalidate()
+ connectionTimeoutTimer = Timer.scheduledTimer(
+ withTimeInterval: Double(CONNECTION_TIMEOUT_NS) / 1e9, repeats: false
+ ) { [weak self] _ in
+ guard let self else { return }
+ if self.isConnecting, self.connectionState != ConnTypes.CONNECTED {
+ Bridge.log("GO2: Connection timeout")
+ self.isConnecting = false
+ if let p = self.connectedPeripheral { self.centralManager?.cancelPeripheralConnection(p) }
+ self.handleReconnection()
+ }
+ }
+ }
+
+ private func stopConnectionTimeout() {
+ connectionTimeoutTimer?.invalidate()
+ connectionTimeoutTimer = nil
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Timers: stop-all
+ // -----------------------------------------------------------------------
+
+ private func stopAllTimers() {
+ stopHeartbeat()
+ stopReadinessCheckLoop()
+ stopConnectionTimeout()
+ pendingMessageTimer?.invalidate()
+ pendingMessageTimer = nil
+ reconnectionWorkItem?.cancel()
+ reconnectionWorkItem = nil
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: State helpers
+ // -----------------------------------------------------------------------
+
+ private func updateConnectionState(_ state: String) {
+ connectionState = state
+ GlassesStore.shared.apply("glasses", "connectionState", state)
+ if state == ConnTypes.DISCONNECTED {
+ GlassesStore.shared.apply("glasses", "signalStrength", -1)
+ GlassesStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
+ }
+ }
+
+ private func updateBatteryStatus(level: Int, isCharging: Bool) {
+ GlassesStore.shared.apply("glasses", "batteryLevel", level)
+ GlassesStore.shared.apply("glasses", "charging", isCharging)
+ if level >= 0 { Bridge.sendBatteryStatus(level: level, charging: isCharging) }
+ }
+
+ private func updateWifiStatus(connected: Bool, ssid: String, ip: String) {
+ GlassesStore.shared.apply("glasses", "wifiConnected", connected)
+ GlassesStore.shared.apply("glasses", "wifiSsid", ssid)
+ GlassesStore.shared.apply("glasses", "wifiLocalIp", ip)
+ Bridge.sendWifiStatusChange(connected: connected, ssid: ssid, localIp: ip)
+ }
+
+ private func updateHotspotStatus(enabled: Bool, ssid: String, password: String, ip: String) {
+ GlassesStore.shared.apply("glasses", "hotspotEnabled", enabled)
+ GlassesStore.shared.apply("glasses", "hotspotSsid", ssid)
+ GlassesStore.shared.apply("glasses", "hotspotPassword", password)
+ GlassesStore.shared.apply("glasses", "hotspotGatewayIp", ip)
+ if let status = HotspotStatus.fromStoreFields(
+ enabled: enabled, ssid: ssid, password: password, localIp: ip
+ ) {
+ Bridge.sendTypedMessage("hotspot_status_change", body: status.values)
+ }
+ }
+
+ private func handleVersionInfo(_ json: [String: Any]) {
+ let appVersion = json["app_version"] as? String ?? ""
+ let buildNumber = json["build_number"] as? String ?? ""
+ let deviceModel = json["device_model"] as? String ?? ""
+ let androidVersion = json["android_version"] as? String ?? ""
+ let otaVersionUrl = json["ota_version_url"] as? String ?? ""
+ let firmwareVersion = json["firmware_version"] as? String ?? ""
+ let btMacAddress = json["bt_mac_address"] as? String ?? ""
+
+ GlassesStore.shared.apply("glasses", "appVersion", appVersion)
+ GlassesStore.shared.apply("glasses", "buildNumber", buildNumber)
+ GlassesStore.shared.apply("glasses", "deviceModel", deviceModel)
+ GlassesStore.shared.apply("glasses", "androidVersion", androidVersion)
+ GlassesStore.shared.apply("glasses", "otaVersionUrl", otaVersionUrl)
+ GlassesStore.shared.apply("glasses", "fwVersion", firmwareVersion)
+
+ Bridge.log("GO2: Version — app=\(appVersion) build=\(buildNumber) device=\(deviceModel) android=\(androidVersion)")
+
+ Bridge.sendTypedMessage("version_info", body: [
+ "app_version": appVersion,
+ "build_number": buildNumber,
+ "device_model": deviceModel,
+ "android_version": androidVersion,
+ "ota_version_url": otaVersionUrl,
+ "firmware_version": firmwareVersion,
+ "bt_mac_address": btMacAddress,
+ ])
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Event emission
+ // -----------------------------------------------------------------------
+
+ private func emitDiscoveredDevice(_ name: String, identifier: String = "", rssi: Int? = nil) {
+ Bridge.sendDiscoveredDevice(DeviceTypes.INMO_GO2, name, deviceAddress: identifier, rssi: rssi)
+ }
+
+ private func emitStopScanEvent() {
+ Bridge.sendTypedMessage("compatible_glasses_search_stop", body: [
+ "compatible_glasses_search_stop": ["device_model": DeviceTypes.INMO_GO2],
+ ])
+ }
+
+ // -----------------------------------------------------------------------
+ // MARK: Cleanup
+ // -----------------------------------------------------------------------
+
+ private func destroy() {
+ Bridge.log("GO2: Destroying InmoGo2")
+ isKilled = true
+ if isScanning { stopScan(); emitStopScanEvent() }
+ stopAllTimers()
+ if let p = connectedPeripheral { centralManager?.cancelPeripheralConnection(p) }
+ GlassesStore.shared.apply("glasses", "connected", false)
+ GlassesStore.shared.apply("glasses", "fullyBooted", false)
+ GlassesStore.shared.apply("glasses", "wifiConnected", false)
+ GlassesStore.shared.apply("glasses", "wifiSsid", "")
+ GlassesStore.shared.apply("glasses", "wifiLocalIp", "")
+ GlassesStore.shared.apply("glasses", "hotspotEnabled", false)
+ GlassesStore.shared.apply("glasses", "hotspotSsid", "")
+ GlassesStore.shared.apply("glasses", "hotspotPassword", "")
+ GlassesStore.shared.apply("glasses", "hotspotGatewayIp","")
+ connectedPeripheral = nil
+ centralManager?.delegate = nil
+ centralManager = nil
+ updateConnectionState(ConnTypes.DISCONNECTED)
+ }
+}
+
+// MARK: - CBCentralManagerDelegate
+
+extension InmoGo2: CBCentralManagerDelegate {
+
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
+ switch central.state {
+ case .poweredOn:
+ Bridge.log("GO2: Bluetooth powered on")
+ // Try UUID-based reconnect first, then fall back to scan
+ if let uuidString = UserDefaults.standard.string(forKey: PREFS_DEVICE_UUID),
+ !uuidString.isEmpty,
+ let uuid = UUID(uuidString: uuidString)
+ {
+ let known = central.retrievePeripherals(withIdentifiers: [uuid])
+ if let peripheral = known.first {
+ Bridge.log("GO2: Auto-reconnect via stored UUID on BT power on")
+ connectToDevice(peripheral)
+ return
+ }
+ }
+ if let saved = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
+ !saved.isEmpty
+ {
+ startScan()
+ }
+ case .poweredOff:
+ Bridge.log("GO2: Bluetooth powered off")
+ updateConnectionState(ConnTypes.DISCONNECTED)
+ case .unauthorized:
+ Bridge.log("GO2: Bluetooth unauthorized")
+ updateConnectionState(ConnTypes.DISCONNECTED)
+ case .unsupported:
+ Bridge.log("GO2: Bluetooth unsupported")
+ updateConnectionState(ConnTypes.DISCONNECTED)
+ default:
+ Bridge.log("GO2: Bluetooth state: \(central.state.rawValue)")
+ }
+ }
+
+ func centralManager(
+ _: CBCentralManager, didDiscover peripheral: CBPeripheral,
+ advertisementData _: [String: Any], rssi: NSNumber
+ ) {
+ guard let name = peripheral.name, isCompatibleDeviceName(name) else { return }
+
+ Bridge.log("GO2: 🔍 Found INMO GO2: \(name) (\(peripheral.identifier.uuidString)) RSSI=\(rssi)")
+ discoveredPeripherals[name] = peripheral
+ emitDiscoveredDevice(name, identifier: peripheral.identifier.uuidString, rssi: rssi.intValue)
+
+ if let saved = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME), saved == name {
+ Bridge.log("GO2: Found remembered device, connecting: \(name)")
+ centralManager?.stopScan()
+ isScanning = false
+ connectToDevice(peripheral)
+ }
+ }
+
+ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
+ Bridge.log("GO2: ✅ GATT connected — discovering services…")
+ stopConnectionTimeout()
+ isConnecting = false
+ connectedPeripheral = peripheral
+
+ // Persist UUID so we can reconnect directly next time
+ // (bypasses iOS advertisement suppression for bonded devices)
+ UserDefaults.standard.set(peripheral.identifier.uuidString, forKey: PREFS_DEVICE_UUID)
+
+ if let name = peripheral.name {
+ UserDefaults.standard.set(name, forKey: PREFS_DEVICE_NAME)
+ GlassesStore.shared.apply("glasses", "bluetoothName", name)
+ }
+ GlassesStore.shared.apply("core", "device_address", peripheral.identifier.uuidString)
+ peripheral.discoverServices([SERVICE_UUID])
+ reconnectAttempts = 0
+ }
+
+ func centralManager(_: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?) {
+ Bridge.log("GO2: Disconnected from GATT server")
+ isConnecting = false
+ connectedPeripheral = nil
+ fullyBooted = false
+ connected = false
+ updateConnectionState(ConnTypes.DISCONNECTED)
+ stopAllTimers()
+ txCharacteristic = nil
+ rxCharacteristic = nil
+ if !isKilled { handleReconnection() }
+ }
+
+ func centralManager(_: CBCentralManager, didFailToConnect _: CBPeripheral, error: Error?) {
+ Bridge.log("GO2: Failed to connect: \(error?.localizedDescription ?? "unknown")")
+ stopConnectionTimeout()
+ isConnecting = false
+ connectedPeripheral = nil
+ updateConnectionState(ConnTypes.DISCONNECTED)
+ if !isKilled { handleReconnection() }
+ }
+}
+
+// MARK: - CBPeripheralDelegate
+
+extension InmoGo2: CBPeripheralDelegate {
+
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
+ if let error {
+ Bridge.log("GO2: Service discovery error: \(error.localizedDescription)")
+ centralManager?.cancelPeripheralConnection(peripheral)
+ return
+ }
+ guard let services = peripheral.services else { return }
+ for service in services where service.uuid == SERVICE_UUID {
+ Bridge.log("GO2: Found MentraOS service — discovering characteristics…")
+ peripheral.discoverCharacteristics([TX_CHAR_UUID, RX_CHAR_UUID], for: service)
+ }
+ }
+
+ func peripheral(
+ _ peripheral: CBPeripheral,
+ didDiscoverCharacteristicsFor service: CBService,
+ error: Error?
+ ) {
+ if let error {
+ Bridge.log("GO2: Characteristic discovery error: \(error.localizedDescription)")
+ centralManager?.cancelPeripheralConnection(peripheral)
+ return
+ }
+ guard let characteristics = service.characteristics else { return }
+
+ for ch in characteristics {
+ let props = ch.properties
+ let desc = [
+ props.contains(.notify) ? "NOTIFY" : nil,
+ props.contains(.indicate) ? "INDICATE" : nil,
+ props.contains(.write) ? "WRITE" : nil,
+ props.contains(.writeWithoutResponse) ? "WRITE_NR" : nil,
+ ].compactMap { $0 }.joined(separator: " ")
+ Bridge.log("GO2: 📋 Characteristic \(ch.uuid): [\(desc)]")
+
+ if ch.uuid == TX_CHAR_UUID {
+ txCharacteristic = ch
+ Bridge.log("GO2: ✅ TX (Notify) characteristic found")
+ } else if ch.uuid == RX_CHAR_UUID {
+ rxCharacteristic = ch
+ Bridge.log("GO2: ✅ RX (Write) characteristic found")
+ }
+ }
+
+ if let tx = txCharacteristic, rxCharacteristic != nil {
+ Bridge.log("GO2: ✅ Both characteristics found — enabling notifications")
+ updateConnectionState(ConnTypes.CONNECTING)
+ peripheral.setNotifyValue(true, for: tx)
+ startReadinessCheckLoop()
+ } else {
+ Bridge.log("GO2: ❌ Required characteristics not found — disconnecting")
+ if txCharacteristic == nil { Bridge.log("GO2: Missing TX (4861)") }
+ if rxCharacteristic == nil { Bridge.log("GO2: Missing RX (4862)") }
+ centralManager?.cancelPeripheralConnection(peripheral)
+ }
+ }
+
+ func peripheral(
+ _: CBPeripheral,
+ didUpdateValueFor characteristic: CBCharacteristic,
+ error: Error?
+ ) {
+ if let error {
+ Bridge.log("GO2: Characteristic update error: \(error.localizedDescription)")
+ return
+ }
+ guard let data = characteristic.value else { return }
+ processReceivedData(data)
+ }
+
+ func peripheral(_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?) {
+ if let error {
+ Bridge.log("GO2: Write error: \(error.localizedDescription)")
+ }
+ }
+
+ func peripheral(
+ _: CBPeripheral,
+ didUpdateNotificationStateFor characteristic: CBCharacteristic,
+ error: Error?
+ ) {
+ if let error {
+ Bridge.log("GO2: Notification state error: \(error.localizedDescription)")
+ } else {
+ Bridge.log("GO2: Notifications \(characteristic.isNotifying ? "ON" : "OFF") for \(characteristic.uuid)")
+ }
+ }
+
+ func peripheral(_: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
+ guard error == nil else { return }
+ let rssi = Int(truncating: RSSI)
+ GlassesStore.shared.apply("glasses", "signalStrength", rssi)
+ GlassesStore.shared.apply("glasses", "signalStrengthUpdatedAt", Int64(Date().timeIntervalSince1970 * 1000))
+ }
+}
+
+// MARK: - Private Int helper
+
+private extension Int {
+ func clampedPositive(default fallback: Int) -> Int {
+ return self > 0 ? self : fallback
+ }
+}
diff --git a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/SGCManager.swift b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/SGCManager.swift
index 9b2d63a4ed..c924ada648 100644
--- a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/SGCManager.swift
+++ b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/SGCManager.swift
@@ -49,6 +49,14 @@ protocol SGCManager {
func setDashboardHeightOnly(_ height: Int)
func setDashboardDepthOnly(_ depth: Int)
+ // MARK: - Teleprompter (default no-op; only INMO Go2 implements)
+
+ /// Sends a 3-line teleprompter window to the glasses overlay.
+ /// `lines` should contain the visible window of script lines (1-3 entries),
+ /// and `highlightIndex` is the index within `lines` to render as the
+ /// bright/current line.
+ func sendTeleprompterUpdate(lines: [String], highlightIndex: Int)
+
// MARK: - Dashboard Menu
func setDashboardMenu(_ items: [[String: Any]])
@@ -127,6 +135,10 @@ extension SGCManager {
func setDashboardMenu(_: [[String: Any]]) {}
+ // MARK: - Teleprompter (default no-op — only INMO Go2 implements)
+
+ func sendTeleprompterUpdate(lines: [String], highlightIndex: Int) {}
+
// MARK: - Default GlassesStore-backed property implementations
var fullyBooted: Bool {
diff --git a/mobile/modules/bluetooth-sdk/ios/Source/utils/Constants.swift b/mobile/modules/bluetooth-sdk/ios/Source/utils/Constants.swift
index dca56158e5..dcf4e0dfd2 100644
--- a/mobile/modules/bluetooth-sdk/ios/Source/utils/Constants.swift
+++ b/mobile/modules/bluetooth-sdk/ios/Source/utils/Constants.swift
@@ -7,6 +7,7 @@ struct DeviceTypes {
static let Z100 = "Vuzix Z100"
static let NEX = "Mentra Display"
static let FRAME = "Brilliant Frame"
+ static let INMO_GO2 = "INMO Go2"
static let ALL = [
SIMULATED,
@@ -17,6 +18,7 @@ struct DeviceTypes {
Z100,
NEX,
FRAME,
+ INMO_GO2,
]
/// Private init to prevent instantiation
diff --git a/mobile/modules/bluetooth-sdk/scripts/with-node.sh b/mobile/modules/bluetooth-sdk/scripts/with-node.sh
new file mode 100644
index 0000000000..41b6b3b715
--- /dev/null
+++ b/mobile/modules/bluetooth-sdk/scripts/with-node.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Copyright (c) Meta Platforms, Inc. and affiliates.
+# Copyright 2018-present 650 Industries. All rights reserved.
+#
+# @generated by expo-module-scripts
+#
+# USAGE:
+# ./with-node.sh command
+
+CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+
+# Start with a default
+NODE_BINARY=$(command -v node)
+export NODE_BINARY
+
+# Override the default with the global environment
+ENV_PATH="$PODS_ROOT/../.xcode.env"
+if [[ -f "$ENV_PATH" ]]; then
+ source "$ENV_PATH"
+fi
+
+# Override the global with the local environment
+LOCAL_ENV_PATH="${ENV_PATH}.local"
+if [[ -f "$LOCAL_ENV_PATH" ]]; then
+ source "$LOCAL_ENV_PATH"
+fi
+
+if [[ -n "$NODE_BINARY" && -x "$NODE_BINARY" ]]; then
+ echo "Node found at: ${NODE_BINARY}"
+else
+ cat >&2 << EOF
+[ERROR] Could not find "node" while running an Xcode build script. You need to specify the path to your Node.js executable by defining an environment variable named NODE_BINARY in your project's .xcode.env or .xcode.env.local file. You can set this up quickly by running:
+
+ echo "export NODE_BINARY=\$(command -v node)" >> .xcode.env
+
+in the ios folder of your project.
+EOF
+ exit 1
+fi
+
+# Execute argument, if present
+if [[ "$#" -gt 0 ]]; then
+ "$NODE_BINARY" "$@"
+fi
diff --git a/mobile/modules/bluetooth-sdk/src/BluetoothSdkModule.ts b/mobile/modules/bluetooth-sdk/src/BluetoothSdkModule.ts
index bc61977a0b..98db7c71ce 100644
--- a/mobile/modules/bluetooth-sdk/src/BluetoothSdkModule.ts
+++ b/mobile/modules/bluetooth-sdk/src/BluetoothSdkModule.ts
@@ -32,7 +32,9 @@ declare class CoreModule extends NativeModule {
displayEvent(params: Record): Promise
displayText(params: Record): Promise
clearDisplay(): Promise
-
+ /** Sends a 3-line teleprompter window to the glasses overlay (no-cloud direct path). */
+ sendTeleprompterUpdate(lines: string[], highlightIndex: number): Promise
+
// Connection Commands
requestStatus(): Promise
connectDefault(options?: ConnectOptions): Promise
diff --git a/mobile/modules/crust/scripts/with-node.sh b/mobile/modules/crust/scripts/with-node.sh
new file mode 100644
index 0000000000..41b6b3b715
--- /dev/null
+++ b/mobile/modules/crust/scripts/with-node.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Copyright (c) Meta Platforms, Inc. and affiliates.
+# Copyright 2018-present 650 Industries. All rights reserved.
+#
+# @generated by expo-module-scripts
+#
+# USAGE:
+# ./with-node.sh command
+
+CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+
+# Start with a default
+NODE_BINARY=$(command -v node)
+export NODE_BINARY
+
+# Override the default with the global environment
+ENV_PATH="$PODS_ROOT/../.xcode.env"
+if [[ -f "$ENV_PATH" ]]; then
+ source "$ENV_PATH"
+fi
+
+# Override the global with the local environment
+LOCAL_ENV_PATH="${ENV_PATH}.local"
+if [[ -f "$LOCAL_ENV_PATH" ]]; then
+ source "$LOCAL_ENV_PATH"
+fi
+
+if [[ -n "$NODE_BINARY" && -x "$NODE_BINARY" ]]; then
+ echo "Node found at: ${NODE_BINARY}"
+else
+ cat >&2 << EOF
+[ERROR] Could not find "node" while running an Xcode build script. You need to specify the path to your Node.js executable by defining an environment variable named NODE_BINARY in your project's .xcode.env or .xcode.env.local file. You can set this up quickly by running:
+
+ echo "export NODE_BINARY=\$(command -v node)" >> .xcode.env
+
+in the ios folder of your project.
+EOF
+ exit 1
+fi
+
+# Execute argument, if present
+if [[ "$#" -gt 0 ]]; then
+ "$NODE_BINARY" "$@"
+fi
diff --git a/mobile/modules/island/scripts/with-node.sh b/mobile/modules/island/scripts/with-node.sh
new file mode 100644
index 0000000000..41b6b3b715
--- /dev/null
+++ b/mobile/modules/island/scripts/with-node.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Copyright (c) Meta Platforms, Inc. and affiliates.
+# Copyright 2018-present 650 Industries. All rights reserved.
+#
+# @generated by expo-module-scripts
+#
+# USAGE:
+# ./with-node.sh command
+
+CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+
+# Start with a default
+NODE_BINARY=$(command -v node)
+export NODE_BINARY
+
+# Override the default with the global environment
+ENV_PATH="$PODS_ROOT/../.xcode.env"
+if [[ -f "$ENV_PATH" ]]; then
+ source "$ENV_PATH"
+fi
+
+# Override the global with the local environment
+LOCAL_ENV_PATH="${ENV_PATH}.local"
+if [[ -f "$LOCAL_ENV_PATH" ]]; then
+ source "$LOCAL_ENV_PATH"
+fi
+
+if [[ -n "$NODE_BINARY" && -x "$NODE_BINARY" ]]; then
+ echo "Node found at: ${NODE_BINARY}"
+else
+ cat >&2 << EOF
+[ERROR] Could not find "node" while running an Xcode build script. You need to specify the path to your Node.js executable by defining an environment variable named NODE_BINARY in your project's .xcode.env or .xcode.env.local file. You can set this up quickly by running:
+
+ echo "export NODE_BINARY=\$(command -v node)" >> .xcode.env
+
+in the ios folder of your project.
+EOF
+ exit 1
+fi
+
+# Execute argument, if present
+if [[ "$#" -gt 0 ]]; then
+ "$NODE_BINARY" "$@"
+fi
diff --git a/mobile/src/app/asg/teleprompter.tsx b/mobile/src/app/asg/teleprompter.tsx
new file mode 100644
index 0000000000..7b6533a730
--- /dev/null
+++ b/mobile/src/app/asg/teleprompter.tsx
@@ -0,0 +1,214 @@
+import {useEffect, useState} from "react"
+import {ScrollView, View, TextInput, TouchableOpacity, Alert} from "react-native"
+import AsyncStorage from "@react-native-async-storage/async-storage"
+import {Header, Screen, Text} from "@/components/ignite"
+import {useAppTheme} from "@/contexts/ThemeContext"
+import {useNavigationStore} from "@/stores/navigation"
+import CoreModule from "@mentra/bluetooth-sdk"
+
+const STORAGE_KEY = "teleprompter_scripts"
+
+interface TeleprompterScript {
+ id: string
+ title: string
+ body: string
+ updatedAt: number
+}
+
+function makeId() {
+ return `script_${Date.now()}_${Math.floor(Math.random() * 1e6)}`
+}
+
+export default function TeleprompterScreen() {
+ const {theme} = useAppTheme()
+ const {goBack} = useNavigationStore.getState()
+
+ const [scripts, setScripts] = useState([])
+ const [activeId, setActiveId] = useState(null)
+ const [title, setTitle] = useState("")
+ const [body, setBody] = useState("")
+ const [sending, setSending] = useState(false)
+
+ useEffect(() => {
+ loadScripts()
+ }, [])
+
+ async function loadScripts() {
+ try {
+ const raw = await AsyncStorage.getItem(STORAGE_KEY)
+ const parsed: TeleprompterScript[] = raw ? JSON.parse(raw) : []
+ setScripts(parsed.sort((a, b) => b.updatedAt - a.updatedAt))
+ } catch (e) {
+ console.error("Failed to load teleprompter scripts", e)
+ }
+ }
+
+ async function saveScripts(next: TeleprompterScript[]) {
+ setScripts(next.sort((a, b) => b.updatedAt - a.updatedAt))
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next))
+ }
+
+ function startNew() {
+ setActiveId(null)
+ setTitle("")
+ setBody("")
+ }
+
+ function openScript(script: TeleprompterScript) {
+ setActiveId(script.id)
+ setTitle(script.title)
+ setBody(script.body)
+ }
+
+ async function saveCurrent() {
+ const trimmedTitle = title.trim() || "Untitled script"
+ const id = activeId ?? makeId()
+ const existingIndex = scripts.findIndex(s => s.id === id)
+ const updated: TeleprompterScript = {
+ id,
+ title: trimmedTitle,
+ body,
+ updatedAt: Date.now(),
+ }
+ const next = [...scripts]
+ if (existingIndex >= 0) {
+ next[existingIndex] = updated
+ } else {
+ next.push(updated)
+ }
+ await saveScripts(next)
+ setActiveId(id)
+ }
+
+ async function deleteScript(id: string) {
+ const next = scripts.filter(s => s.id !== id)
+ await saveScripts(next)
+ if (activeId === id) {
+ startNew()
+ }
+ }
+
+ async function sendToGlasses() {
+ if (!body.trim()) {
+ Alert.alert("Empty script", "Write or load a script before sending.")
+ return
+ }
+ await saveCurrent()
+ const id = activeId ?? makeId()
+ const lines = body.split("\n")
+ setSending(true)
+ try {
+ await CoreModule.sendTeleprompterScript(id, lines)
+ Alert.alert("Sent", "Script transferred to glasses.")
+ } catch (e) {
+ console.error("Failed to send teleprompter script", e)
+ Alert.alert("Error", "Could not send script to glasses.")
+ } finally {
+ setSending(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {scripts.length === 0 && (
+
+ )}
+
+ {scripts.map(script => (
+
+ openScript(script)}>
+
+
+
+ deleteScript(script.id)} style={{paddingLeft: 12}}>
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/mobile/src/app/pairing/prep.tsx b/mobile/src/app/pairing/prep.tsx
index b165211d9d..a5c66e71a9 100644
--- a/mobile/src/app/pairing/prep.tsx
+++ b/mobile/src/app/pairing/prep.tsx
@@ -30,34 +30,25 @@ export default function PairingPrepScreen() {
return
}
- // Always request Bluetooth permissions - required for Android 14+ foreground service
let needsBluetoothPermissions = true
- // we don't need bluetooth permissions for simulated glasses
if (deviceModel.startsWith(DeviceTypes.SIMULATED) && Platform.OS === "ios") {
needsBluetoothPermissions = false
}
try {
- // Check for Android-specific permissions
if (Platform.OS === "android") {
- // Android-specific Phone State permission - request for ALL glasses including simulated
console.log("Requesting PHONE_STATE permission...")
const phoneStateGranted = await requestFeaturePermissions(PermissionFeatures.PHONE_STATE)
console.log("PHONE_STATE permission result:", phoneStateGranted)
if (!phoneStateGranted) {
- // The specific alert for previously denied permission is already handled in requestFeaturePermissions
- // We just need to stop the flow here
return
}
- // Bluetooth permissions only for physical glasses
if (needsBluetoothPermissions) {
const bluetoothPermissions: BluetoothPermission[] = []
- // Bluetooth permissions based on Android version
if (typeof Platform.Version === "number" && Platform.Version < 31) {
- // For Android 9, 10, and 11 (API 28-30), use legacy Bluetooth permissions
bluetoothPermissions.push("android.permission.BLUETOOTH")
bluetoothPermissions.push("android.permission.BLUETOOTH_ADMIN")
}
@@ -67,44 +58,25 @@ export default function PairingPrepScreen() {
bluetoothPermissions.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_ADVERTISE)
}
- // Request Bluetooth permissions directly
if (bluetoothPermissions.length > 0) {
- console.log("RIGHT BEFORE ASKING FOR PERMS")
- console.log("Bluetooth permissions array:", bluetoothPermissions)
- console.log(
- "Bluetooth permission values:",
- bluetoothPermissions.map((p) => `${p} (${typeof p})`),
- )
-
const results = await PermissionsAndroid.requestMultiple(bluetoothPermissions as Permission[])
const allGranted = Object.values(results).every((value) => value === PermissionsAndroid.RESULTS.GRANTED)
- // Since we now handle NEVER_ASK_AGAIN in requestFeaturePermissions,
- // we just need to check if all are granted
if (!allGranted) {
- // Check if any are NEVER_ASK_AGAIN to show proper dialog
const anyNeverAskAgain = Object.values(results).some(
(value) => value === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN,
)
if (anyNeverAskAgain) {
- // Show "previously denied" dialog for Bluetooth
showAlert(
translate("pairing:permissionRequired"),
translate("pairing:bluetoothPermissionPreviouslyDenied"),
[
- {
- text: translate("pairing:openSettings"),
- onPress: () => Linking.openSettings(),
- },
- {
- text: translate("common:cancel"),
- style: "cancel",
- },
+ {text: translate("pairing:openSettings"), onPress: () => Linking.openSettings()},
+ {text: translate("common:cancel"), style: "cancel"},
],
)
} else {
- // Show standard permission required dialog
showAlert(
translate("pairing:bluetoothPermissionRequiredTitle"),
translate("pairing:bluetoothPermissionRequiredMessage"),
@@ -114,12 +86,9 @@ export default function PairingPrepScreen() {
return
}
}
+ }
+ }
- // Phone state permission already requested above for all Android devices
- } // End of Bluetooth permissions block
- } // End of Android-specific permissions block
-
- // Check connectivity early for iOS (permissions work differently)
console.log("DEBUG: needsBluetoothPermissions:", needsBluetoothPermissions, "Platform.OS:", Platform.OS)
if (needsBluetoothPermissions && Platform.OS === "ios") {
console.log("DEBUG: Running iOS connectivity check early")
@@ -129,7 +98,6 @@ export default function PairingPrepScreen() {
}
}
- // Cross-platform permissions needed for both iOS and Android (only if connectivity check passed)
if (needsBluetoothPermissions) {
const hasBluetoothPermission = await requestFeaturePermissions(PermissionFeatures.BLUETOOTH)
if (!hasBluetoothPermission) {
@@ -138,41 +106,27 @@ export default function PairingPrepScreen() {
translate("pairing:bluetoothPermissionRequiredMessageAlt"),
[{text: translate("common:ok")}],
)
- return // Stop the connection process
+ return
}
}
- // Request microphone permission (needed for both platforms)
console.log("Requesting microphone permission...")
-
- // This now handles showing alerts for previously denied permissions internally
const micGranted = await requestFeaturePermissions(PermissionFeatures.MICROPHONE)
-
console.log("Microphone permission result:", micGranted)
if (!micGranted) {
- // The specific alert for previously denied permission is already handled in requestFeaturePermissions
- // We just need to stop the flow here
return
}
- // Request location permission (needed for Android BLE scanning)
if (Platform.OS === "android") {
console.log("Requesting location permission for Android BLE scanning...")
-
- // This now handles showing alerts for previously denied permissions internally
const locGranted = await requestFeaturePermissions(PermissionFeatures.LOCATION)
-
console.log("Location permission result:", locGranted)
if (!locGranted) {
- // The specific alert for previously denied permission is already handled in requestFeaturePermissions
- // We just need to stop the flow here
return
}
- // Check connectivity for Android AFTER all permissions are granted
- // This must be done after location permission is granted to avoid premature "Connection issue" popup
if (needsBluetoothPermissions) {
const requirementsCheck = await checkConnectivityRequirementsUI()
if (!requirementsCheck) {
@@ -192,11 +146,8 @@ export default function PairingPrepScreen() {
console.log("needsBluetoothPermissions", needsBluetoothPermissions)
- // Stop any running apps from previous sessions to prevent mic race conditions
- // This is symmetric with the logic in DeviceSettings that stops apps when unpairing
await useAppStatusStore.getState().stopAll()
- // skip pairing for simulated glasses:
if (deviceModel.startsWith(DeviceTypes.SIMULATED)) {
await CoreModule.connectSimulated()
clearHistoryAndGoHome()
@@ -228,10 +179,10 @@ export default function PairingPrepScreen() {
source: `${CDN_BASE}/ONB1_power_button_loop.mp4`,
poster: require("@assets/onboarding/live/thumbnails/ONB0_power.png"),
transition: false,
- title: translate("pairing:powerOn"), // for spacing so it's consistent with the other steps
+ title: translate("pairing:powerOn"),
subtitle: translate("onboarding:livePowerOnTutorial"),
info: translate("onboarding:livePowerOnInfo"),
- playCount: -1, // repeat forever
+ playCount: -1,
showButtonImmediately: true,
},
]
@@ -243,13 +194,9 @@ export default function PairingPrepScreen() {
showCloseButton={false}
showSkipButton={false}
showHeader={false}
- skipFn={() => {
- advanceToPairing()
- }}
+ skipFn={() => { advanceToPairing() }}
endButtonText={translate("pairing:poweredOn")}
- endButtonFn={() => {
- advanceToPairing()
- }}
+ endButtonFn={() => { advanceToPairing() }}
/>
)
}
@@ -257,18 +204,9 @@ export default function PairingPrepScreen() {
const MentraMach1PairingGuide = () => {
return (
-
-
-
+
+
+
)
}
@@ -276,18 +214,9 @@ export default function PairingPrepScreen() {
const VuzixZ100PairingGuide = () => {
return (
-
-
-
+
+
+
)
}
@@ -296,39 +225,24 @@ export default function PairingPrepScreen() {
return (
-
+
)
}
const G1PairingGuide = () => {
const {theme} = useAppTheme()
-
return (
-
+
-
-
-
+
+
)
@@ -342,44 +256,24 @@ export default function PairingPrepScreen() {